(Working Effectively with Legacy Code) 015. My Application Is All API Calls

My Application Is All API Calls

애플리케이션 개발에 드는 리소스를 줄이기 위해 상용 라이브러리를 사용하는 경우가 많다.

이때 사용하려는 라이브러리가 얼마나 안정적인지, 충분한 기능을 제공하는지, 사용 편의성은 어떤지 파악해야할 필요가 있다.

라이브러리를 적용하고 나면 실제로 의미있는 코드를 작성하지 않기때문에 테스트가 필요없다고 오해하기 쉽다.

하지만 레거시 시스템을 개선하기 위해 모든 코드를 개발자가 작성하지않았음에도 시스템 유지를 위해 변경에 대한 테스트는 수행하는 것이 좋다.

아래는 매우 엉성하게 작성되어 동작이 보장되지않는 메일링 서비스의 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import java.io.IOException; import java.util.Properties;
import javax.mail.*;
import javax.mail.internet.*;

public class MailingListServer {
public static final String SUBJECT_MARKER = "[list]";
public static final String LOOP_HEADER = "X-Loop";

public static void main(String[] args) {
if (args.length != 8) {
System.err.println("Usage: java MailingList <popHost> " +
"<smtpHost> <pop3user> <pop3password> " +
"<smtpuser> <smtppassword> <listname> " +
"<relayinterval>");
return;
}

HostInformation host = new HostInformation(args[0], args[1], args[2], args[3], args[4], args[5]);
String listAddress = args[6];
int interval = new Integer(args[7]).intValue();
Roster roster = null;
try {
roster = new FileRoster("roster.txt");
} catch (Exception e) {
System.err.println("unable to open roster.txt");
return;
}

try {
do {
try {
Properties properties = System.getProperties();
Session session = Session.getDefaultInstance(properties, null);
Store store = session.getStore ("pop3");
store.connect(host.pop3Host, -1, host.pop3User, host.pop3Password);
Folder defaultFolder = store.getDefaultFolder();
if (defaultFolder == null) {
System.err.println("Unable to open default folder");
return;
}
Folder folder = defaultFolder.getFolder("INBOX");
if (folder == null) {
System.err.println("Unable to get: " + defaultFolder);
return;
}
folder.open (Folder.READ_WRITE);
process(host, listAddress, roster, session,store, folder);
} catch (Exception e) {
System.err.println(e);
System.err.println("(retrying mail check)");
}
System.err.print(".");
try {
Thread.sleep(interval * 1000);
} catch (InterruptedException e) {
// Do Nothings
}
} while (true);
} catch (Exception e) {
e.printStackTrace ();
}
}

private static void process(HostInformation host, String listAddress, Roster roster,
Session session,Store store, Folder folder) throws MessagingException {
try {
if (folder.getMessageCount() != 0) {
Message[] messages = folder.getMessages();
doMessage(host, listAddress, roster, session, folder, messages);
}
} catch (Exception e) {
System.err.println("message handling error");
e.printStackTrace (System.err);
} finally {
folder.close(true);
store.close();
}
}

private static void doMessage(HostInformation host,
String listAddress,
Roster roster,
Session session,
Folder folder,
Message[] messages) throws MessagingException, AddressException, IOException, NoSuchProviderException {
FetchProfile fp = new FetchProfile();
fp.add(FetchProfile.Item.ENVELOPE);
fp.add(FetchProfile.Item.FLAGS);
fp.add("X-Mailer");
folder.fetch(messages, fp);

for (int i = 0; i < messages.length; i++) {
Message message = messages [i];
if (message.getFlags().contains(Flags.Flag.DELETED)) {
continue;
}
System.out.println("message received: " + message.getSubject ());
if (!roster.containsOneOf(message.getFrom())) {
continue;
}
MimeMessage forward = new MimeMessage(session);
InternetAddress result = null;
Address[] fromAddress = message.getFrom();
if (fromAddress != null && fromAddress.length > 0) {
result = new InternetAddress(fromAddress[0].toString());
}
InternetAddress from = result;
forward.setFrom(from);
forward.setReplyTo(new Address[] { new InternetAddress(listAddress) });
forward.addRecipients(Message.RecipientType.TO, listAddress);
forward.addRecipients(Message.RecipientType.BCC, roster.getAddresses ());
String subject = message.getSubject();
if (-1 == message.getSubject().indexOf(SUBJECT_MARKER)) {
subject = SUBJECT_MARKER + " " + message.getSubject();
forward.setSubject (subject);
}
forward.setSentDate(message.getSentDate());
forward.addHeader(LOOP_HEADER, listAddress);
Object content = message.getContent();
if (content instanceof Multipart) {
forward.setContent((Multipart)content);
} else {
forward.setText ((String)content);
}
Properties props = new Properties();
props.put("mail.smtp.host", host.smtpHost);
Session smtpSession = Session.getDefaultInstance(props, null);
Transport transport = smtpSession.getTransport("smtp");
transport.connect (host.smtpHost, host.smtpUser, host.smtpPassword);
transport.sendMessage(forward, roster.getAddresses());
message.setFlag (Flags.Flag.DELETED, true);
}
}
}

코드의 양이 이전의 예제들과 달리 긴 편인데, 이는 이 코드들이 전부 메릴링 서비스의 API를 호출하는 영역이기 때문이다.

이 코드의 구조는 어떻게 개선할 수 있을까?

첫 번째로 코드의 핵심 동작을 식별해야 한다.

코드의 전반적인 동작을 술어로 표현하면 아래와 같다.

이 코드는 프로그램을 실행한 커맨드로부터 설정 정보를 읽고, 파일로부터 이메일의 주소 목록을 읽어온다.

주기적으로 이메일을 확인하다가, 수신된 메일을 발견하면 파일 내의 이메일 주소 각각에 해당 메일을 전달해준다.

특정 파일의 입출력 외에도 프로그램 내에서 별도의 스레드를 실행하여 주기적으로 메일 수신 여부를 확인하며, 수신 메일에 기반해 새로운 메시지를 만들어낸다.

동작의 책임을 구분하여 정리하면 아래와 같다.

  1. 수신된 메시지를 받아서 시스템에 전달한다.
  2. 메일 메시지를 발송한다.
  3. 수신 메시지와 수신자 목록을 바탕으로 새로운 메시지를 작성한다.
  4. 대부분의 시간은 휴면 상태로 있다가 수신 메일을 확인하기 위해 주기적으로 깨어난다.

각 책임들을 살펴보면 Java Mail Api에 여러 의존을 하고 있음을 알 수 있다.

1번과 2번은 메일 Api에, 3번은 메일 Api의 일부이긴 하지만 가짜 수신 메시지를 생성하여 테스트할 수 있고, 4번은 메일 Api와는 관련이 없다.

아래 UML은 책임을 분리한 경우를 보여준다.

Figure 15.1 A better mailing list server.

ListDriver 클래스는 시스템을 구동하며, 대부분의 시간을 휴면 상태로 보내다가 주기적으로 깨어나 메일을 확인하는 스레드를 가지고 있다.

수신된 메일의 확인은 MailReceiver 클래스에게 지시하며, MailReceiver 클래스는 메일을 읽고 메시지를 하나씩 MessageForwarder 클래스에 보낸다.

MessageForwarder 클래스는 메일링 리스트 내의 수신자마다 메시지를 작성한 후 MailSender를 통해 메일을 보낸다.

이 설계는 MessageProcessorMailService 인터페이스 덕분에 클래스를 독립적으로 테스트할 수 있어 유용하다고 볼 수 있다.

특히 메일을 실제로 발송하지않도고 테스트하네스 내에서 MailService를 테스트할 수 있도록 MailService 인터페이스를 구현한 위장 객체 FakeMailSender 클래스를 만들 수 있다.

좋은 설계를 했고, 이를 통해 개선할 수 있는 것은 좋은데 실제로는 어떻게 접근해야할까?

첫 번째는 API를 포장하고, 두 번째는 책임을 기반으로 추출하는 방법으로 접근하면 된다.

API 포장 기법9Skin and Wrap the API) 은 가급적 API에 가깝게 모방한 인터페이스를 만들고, 이 API의 Wrapper를 작성한다.

이때 오류를 최소화하기 위해 시그니처 유지 기법(Preserve Signatures) 을 사용할 수 있으면, API 코드에 의존하지 않는다는 장점이 있다.

또한 Wrapper를 사용하기 때문에 테스트 코드에서 위장 객체를 이용할 수도 있다.

예제 코드에서 메일 메시지를 보내는 코드만 따로 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void doMessage(HostInformation host,
String listAddress,
Roster roster,
Session session,
Folder folder,
Message[] messages) throws MessagingException, AddressException, IOException, NoSuchProviderException {
// ...
Session smtpSession = Session.getDefaultInstance(props, null);
Transport transport = smtpSession.getTransport("smtp");
transport.connect (host.smtpHost, host.smtpUser, host.smtpPassword);
transport.sendMessage(forward, roster.getAddresses());
// ...
}

여기서 Transport 클래스에 대한 의존 관계를 제거하고 싶은 경우, Wrapper를 만들면 될까?

자세히 보면 Transport 클래스는 Session 클래스로부터 생성되었으므르 이는 불가능하다.

그럼 Session 클래스의 Wrapper는 가능할까?

아쉽게도 Session 클래스는 final 키워드가 적용되어 상속이 불가능한 클래스이다.

따라서 이 예제는 첫 번째 접근 방법인 API 포장 기법을 쓰기엔 적절하지않다.

두 번째 접근 방법인 책임을 기반으로 추출하는 방식으로 접근해보자.

책임 기반 추출에서는 코드의 책임을 식별하고 책임 단위로 메서드를 추출한다.

코드의 전체적인 목적을 알아내고, 이 목적을 달성하기 위한 조건들을 통해 책임을 분리할 수 있다.

예제의 가장 큰 목적은 메시지를 전송하는 것 이며, 이를 위해 SMTP 세션과 Transport 계층 연결이 필요하다.

설계대로 MailSender 클래스를 생성하여 메시지 전송 책임을 메서드로 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import javax.mail.*;
import javax.mail.internet.InternetAddress;
import java.util.Properties;

public class MailSender {
private HostInformation host;
private Roster roster;

public MailSender(HostInformation host, Roster roster) {
this.host = host;
this.roster = roster;
}

public void sendMessage(Message message) throws Exception {
Transport transport = getSMTPSession().getTransport("smtp");
transport.connect(host.smtpHost, host.smtpUser, host.smtpPassword);
transport.sendMessage(message, roster.getAddresses());
}

private Session getSMTPSession() {
Properties props = new Properties();
props.put("mail.smtp.host", host.smtpHost);
return Session.getDefaultInstance(props, null);
}
}

SMTP 세션을 통해 Transport 계층 연결의 책임이 분리된 것을 확인할 수 있다.

마무리

라이브러리에 대한 의존도가 높은 시스템에서 테스트 코드를 추가하기위한 접근 방법 두 가지를 알아보았다.

두 접근 방법 중 어떤 것을 선택해야할까?

API 포장 기법은 아래와 같은 상황에서 적절하다.

  • API의 크기가 상대적으로 작은 경우
  • 서드파티 라이브러리에 대한 의존 관계를 완전히 분리하고 싶은 경우
  • 현재 테스트 루틴이 없고 API를 통해 테스트할 수 없는 탓에 테스트 루틴을 자성할 수 없는 경우

API 포장 기법을 적용하면 Wrapper로부터 실제 API 클래스로 위임을 수행하는 부분을 제외한 모든 코드를 테스트 루틴안에 넣을 수 있다.

책임 기반 추출은 아래와 같은 상황에서 적절하다.

  • API의 크기가 상대적으로 크고 복잡한 경우
  • 안전하게 메서드를 추출할 수 있는 리팩토링 도구를 가지고 있는 경우
  • 리팩토링 도구와 상관없이 안전하게 수동으로 추출할 수 있다는 확신이 드는 경우