기술 블로그에 글을 쓰기 위해 샘플 코드를 만들던 중에 콘솔에 로그를 남기는 모듈을 검증하는 코드를 언뜻 작성할 수 없었습니다. 외부 API 서버라면 통신 시뮬레이터라도 만들텐데 콘솔은 그럴 수도 없고, 어떻게 콘솔에 로그를 남기는 코드를 테스트 할 수 있을지 고민해 보았습니다.
Netty 프레임워크를 활용해서 네트워크로부터 수신된 메시지를 콘솔에 로깅하는 핸들러가 있습니다. 현재는 콘솔에 로그를 남기지만 추후 파일 또는 데이버베이스에 로그를 남기게 할 예정입니다. 이 핸들러의 동작은 간단합니다. LogSource 인터페이스를 구현한 메시지를 입력 받아서, 로그 대상 개체(현재는 콘솔)에 로그를 남기고 파이프라인의 다음 핸들러에게 메시지를 전달합니다.
public class Logger extends SimpleChannelInboundHandler<LogSource> {
// 수신한 메시지에 대한 로그를 남깁니다.
@Override
protected void channelRead0(ChannelHandlerContext ctx, LogSource msg) throws Exception {
System.out.println(msg.toLog());
ctx.fireChannelRead(msg);
}
}
테스트를 위해 로깅 핸들러에 넘겨줄 메시지를 하나 정의합니다. 단순하게 사용자의 이름과 나이 정보를 가진 메시지를 정의하고 LogSource 인터페이스를 구현합니다.
public interface LogSource {
String toLog();
}
@RequiredArgsConstructor
@EqualsAndHashCode
@Getter
public class User implements LogSource {
private final String name;
private final int age;
@Override
public String toLog() {
return "name = %s, age = %d".formatted(name, age);
}
}
이제 로깅 핸들러를 테스트 하는 코드를 작성해 봅니다. 먼저 준비 단계에서 테스트 대상 객체인 로깅 핸들러(Logger)를 생성하고 단위 테스트를 위해 Netty에서 제공하는 채널(EmbeddedChannel) 파이프라인에 로깅 핸들러를 추가해 줍니다. 그리고 테스트의 입력으로 앞서 정의한 사용자(User) 메시지를 입력해 줍니다. 이제 결과를 확인할 차례인데 콘솔에 로그가 잘 찍히는지 눈으로 확인하는 방법 외에는 다른 방법이 생각나지 않습니다.
public class LoggerTest {
@Test
void log_received_message() {
// Given : 채널 파이프라인 구성
Logger logger = new Logger(); // 테스트 대상 핸들러
EmbeddedChannel channel = new EmbeddedChannel(); // 핸들러 테스트 위한 채널
channel.pipeline().addLast(logger); // 채널 파이프라인 구성
// When : 로깅할 메시지 입력
User user = new User("Joo", 40);
channel.writeInbound(user);
// Then : 로깅 확인
// 어떻게 로그가 남겨진 걸 테스트할 수 있지???
}
}
이 문제를 해결하기 위해 아래 로깅 코드를 두 가지로 나누어 생각해 볼 수 있습니다. 먼저 우리는 System.out.println() 메서드로 어떤 값 “abc”를 입력하면 반드시 “abc”가 콘솔에 출력된다는 것을 참이라고 가정할 수 있습니다. 진짜 그런지 한 번 정도 눈으로 확인하고 신뢰할 수 있는 동작으로 보는데 큰 무리가 없습니다. 그럼 이제 msg.toLog() 메서드에 의해 생성된 실제 로깅 메시지가 System.out.println() 메서드 입력으로 잘 전달되고 있는지만 검증하면 됩니다.
System.out.println(msg.toLog());
그런데 System.out.println() 메서드는 우리가 구현한 클래스가 아니기 때문에 테스트 과정에서 해당 메서드로 어떤 값이 전달되었는지 확인할 수 있는 방법이 없습니다. 그래서 System.out.println() 과 동일한 기능을 수행하면서 추가적으로 우리가 검증해야 하는 메시지 입력을 저장해 두는, 콘솔 로깅을 추상화한 클래스(ConsoleLog)를 만들 수 있습니다.
public class ConsoleLog implements LogTarget {
private final List<String> inputs = new ArrayList<>();
@Override
public void log(String msg) {
System.out.println(msg);
inputs.add(msg);
}
@Override
public List<String> logged() {
return inputs;
}
}
public interface LogTarget {
void log(String msg);
List<String> logged();
}
위 코드를 보면 ConsoleLog 클래스가 LogTarget 이라는 인터페이스를 구현하는 것을 볼 수 있습니다. 몇 가지 이유를 생각해 볼 수 있는데 하나는 ConsoleLog 클래스에 테스트를 위한 코드가 추가 되어 있기 때문에 운영 시에는 로깅 핸들러에 다른 운영용 클래스를 주입해 줄 수 있기 위해서 입니다. 또 다른 이유는 앞서 잠시 설명했지만 로깅 시스템은 추후 콘솔 대신 데이터베이스나 파일 시스템으로 변경될 수 있습니다. 따라서 로깅 핸들러 입장에서 구체적인 로깅 시스템이 변경되어도 변경의 영향 없이 일관되게 로그를 처리할 수 있도록 하기 위함입니다. 그래서 Logger 클래스는 메시지 로깅을 위해 구체적인 ConsoleLog 클래스가 아닌 LogTarget 인터페이스로 객체를 참조합니다.
@RequiredArgsConstructor
public class Logger extends SimpleChannelInboundHandler<LogSource> {
private final LogTarget target;
// 수신한 메시지에 대한 로그를 남깁니다.
@Override
protected void channelRead0(ChannelHandlerContext ctx, LogSource msg) throws Exception {
target.log(msg.toLog());
ctx.fireChannelRead(msg);
}
}
여기까지 System.out.println() 메서드 기능을 추상화한 ConsoleLog 클래스를 사용해 Logger 클래스가 콘솔에 로깅을 잘 하고 있는지 테스트가 가능하고 테스트가 성공하는 것을 확인할 수 있습니다.
public class LoggerTest {
@Test
void log_received_message() {
// Given : 채널 파이프라인 구성
LogTarget logTarget = new ConsoleLog();
Logger logger = new Logger(logTarget); // 테스트 대상 핸들러
EmbeddedChannel channel = new EmbeddedChannel(); // 핸들러 테스트 위한 채널
channel.pipeline().addLast(logger); // 채널 파이프라인 구성
// When : 로깅할 메시지 입력
User user = new User("Joo", 40);
channel.writeInbound(user);
// Then : 로깅 확인
List<String> logged = logTarget.logged();
Assertions.assertArrayEquals(new String[] { user.toLog() }, logged.toArray());
}
}
앞서 설명에서 ConsoleLog 클래스에 테스트를 위한 코드가 포함되어 있기 때문에 운영 시에는 해당 기능이 제거된 다른 클래스를 추가해 줄 수 있다고 했습니다. 현재로서 큰 문제는 아니지만 서비스에 사용되지 않는 테스트 코드를 운영 코드에 포함시켜 두면 잠재적인 위험 요소가 될 수 있습니다. 테스트를 위해 수정한 코드가 언제 운영 코드에 영향을 미칠지 모르는 일이니까요. 지금 당장 아무리 별 문제가 없더라도 가장 깔끔한 것은 운영에 사용되는 코드만 프로덕트에 포함하는 것입니다. 이를 위해 ConsoleLog 에 테스트를 위한 코드가 포함된 버전(테스트용)과 순수 콘솔 로깅만 담당하는 버전(운영용)을 나누어 관리할 수도 있겠지만 번거로운 일입니다. 다행히도 우리는 테스트를 위한 코드 구현 없이 목 프레임워크의 도움을 받을 수 있습니다. 아래와 같이 ConsoleLog 클래스와 LogTarget 인터페이스에 테스트를 위해 추가한 코드는 모두 제거하고 Mock 프레임워크의 도움을 받아 테스트 코드를 다시 작성할 수 있습니다. ( → Mockito Reference )
public interface LogTarget {
void log(String msg);
}
public class ConsoleLog implements LogTarget {
@Override
public void log(String msg) {
System.out.println(msg);
}
}
public class LoggerTest {
@Test
void log_received_message() {
// Given : 채널 파이프라인 구성
LogTarget logTarget = Mockito.mock(ConsoleLog.class); // ConsoleLog 목킹
Logger logger = new Logger(logTarget); // 테스트 대상 핸들러
EmbeddedChannel channel = new EmbeddedChannel(); // 핸들러 테스트 위한 채널
channel.pipeline().addLast(logger); // 채널 파이프라인 구성
// When : 로깅할 메시지 입력
User user = new User("Joo", 40);
channel.writeInbound(user);
// Then : 로깅 확인
String expected = user.toLog();
Mockito.verify(logTarget, Mockito.times(1)).log(expected);
}
}
이제 Mock 프레임워크를 활용해 운영 코드에 테스트를 위한 어떠한 코드도 추가하지 않고 로깅 핸들러의 동작을 테스트 할 수 있게 되었습니다. 그리고 테스트를 위해 서비스 코드(Logger)를 개선하다 보니 변경에도 유연하게 대응할 수 있는 더 나은 구조로 다시 태어났습니다. 추후 콘솔이 아니라 데이터베이스나 파일 시스템에 로깅을 하고 싶다면 LogTarget 인터페이스를 구현한 새로운 클래스를 만들고 Logger에 주입해 주기만 하면 됩니다. 서비스 추상화 개념과 Mock 프레임워크를 잘 활용하면 좋은 서비스 코드와 테스트 코드를 동시에 만들 수 있습니다.