EmbeddedChannel — 핸들러 단위 테스트
핸들러 로직을 작성했는데, 테스트하려면 서버를 띄우고 클라이언트로 접속해야 하는 걸까? 네트워크 연결 없이 파이프라인만 돌려볼 수 있는 방법은 없을까?
왜 핸들러 테스트가 어려운가
네티 핸들러는 파이프라인 안에서만 동작 합니다. ChannelInboundHandler의 channelRead()가 호출되려면 채널이 있어야 하고, 채널이 있으려면 EventLoop에 등록되어야 하고, 보통은 실제 네트워크 소켓까지 필요합니다.
이걸 순서대로 정리하면 이렇습니다.
- 핸들러 단독으로는 실행 불가 — 반드시
ChannelPipeline에 등록해야 함 - 파이프라인은
Channel에 종속 —Channel없이 파이프라인만 만들 수 없음 - 일반적인
Channel은 실제 소켓을 필요로 함 — 서버를 띄우고 클라이언트를 연결해야 함
결국 핸들러 하나를 단위 테스트하려면 서버/클라이언트를 모두 구성해야 하는 상황이 생깁니다. 이건 단위 테스트가 아니라 통합 테스트에 가깝습니다.
단위 테스트의 핵심은 "테스트 대상만 격리해서 빠르게 검증하는 것"인데, 네트워크 연결까지 필요하면 그 원칙이 무너집니다.
EmbeddedChannel이란
EmbeddedChannel은 네티가 제공하는 ** 테스트 전용 Channel 구현체 **입니다. 실제 네트워크 I/O 없이 파이프라인을 구동할 수 있어서, 핸들러 로직을 격리된 환경에서 검증할 수 있습니다.
// EmbeddedChannel은 생성자에서 핸들러를 바로 받는다
EmbeddedChannel channel = new EmbeddedChannel(
new MyDecoder(),
new MyBusinessHandler(),
new MyEncoder()
);
핵심 특징을 정리하면 다음과 같습니다.
- ** 실제 소켓 없음** — 네트워크 연결 없이 파이프라인이 동작
- ** 동기 실행** —
EventLoop스레드를 별도로 띄우지 않고, 호출 스레드에서 바로 실행 - ** 인바운드/아웃바운드 큐** — 파이프라인을 통과한 메시지를 내부 큐에 저장해서 꺼내 볼 수 있음
- ** 예외 캡처** — 파이프라인에서 발생한 예외를 저장해 두고 나중에 확인 가능
동기 실행이라는 점이 특히 중요합니다. 일반 네티 채널은 EventLoop 스레드에서 비동기로 처리되기 때문에 테스트에서 타이밍 문제가 생기기 쉬운데, EmbeddedChannel은 ** 호출 즉시 결과를 확인 **할 수 있습니다.
기본 사용법 — 인바운드와 아웃바운드
EmbeddedChannel의 핵심 메서드는 네 가지입니다.
| 메서드 | 방향 | 역할 |
|---|---|---|
writeInbound(Object...) | 인바운드 | 데이터를 파이프라인 앞쪽에 주입 |
readInbound() | 인바운드 | 파이프라인을 통과한 인바운드 결과를 꺼냄 |
writeOutbound(Object...) | 아웃바운드 | 데이터를 파이프라인 뒤쪽에 주입 |
readOutbound() | 아웃바운드 | 파이프라인을 통과한 아웃바운드 결과를 꺼냄 |
인바운드 흐름
// 간단한 인바운드 핸들러 테스트
EmbeddedChannel channel = new EmbeddedChannel(new StringDecoder());
// 인바운드 데이터 주입
channel.writeInbound(Unpooled.copiedBuffer("Hello", CharsetUtil.UTF_8));
// 파이프라인을 통과한 결과 꺼내기
String result = channel.readInbound();
assertEquals("Hello", result);
writeInbound()는 마치 ** 네트워크에서 데이터가 들어온 것처럼** 파이프라인의 첫 번째 인바운드 핸들러부터 순차적으로 실행합니다. 파이프라인 끝까지 도달한 메시지는 내부 인바운드 큐에 쌓이고, readInbound()로 꺼낼 수 있습니다.
아웃바운드 흐름
// 아웃바운드 핸들러 테스트
EmbeddedChannel channel = new EmbeddedChannel(new StringEncoder());
// 아웃바운드 데이터 주입
channel.writeOutbound("Hello");
// 인코딩된 결과 꺼내기
ByteBuf result = channel.readOutbound();
assertEquals("Hello", result.toString(CharsetUtil.UTF_8));
result.release(); // ByteBuf는 반드시 해제
아웃바운드는 반대 방향입니다. writeOutbound()는 ** 애플리케이션에서 데이터를 보내는 것처럼** 파이프라인의 마지막 아웃바운드 핸들러부터 역순으로 실행합니다.
디코더 테스트 — ByteToMessageDecoder 검증
디코더 테스트는 EmbeddedChannel의 가장 대표적인 사용 사례입니다. ByteBuf를 주입하고, 디코딩된 Java 객체가 올바른지 확인합니다.
테스트 대상 디코더
// 고정 길이 정수 디코더 — 4바이트마다 int 하나를 디코딩
public class FixedLengthIntDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
while (in.readableBytes() >= 4) {
out.add(in.readInt());
}
}
}
기본 디코더 테스트
@Test
void 정수_디코딩_테스트() {
EmbeddedChannel channel = new EmbeddedChannel(new FixedLengthIntDecoder());
// 4바이트 × 3개 = 12바이트 주입
ByteBuf input = Unpooled.buffer();
input.writeInt(1);
input.writeInt(2);
input.writeInt(3);
// writeInbound() 반환값: 읽을 수 있는 메시지가 생겼으면 true
assertTrue(channel.writeInbound(input));
// 디코딩된 정수 하나씩 꺼내서 검증
assertEquals(1, (int) channel.readInbound());
assertEquals(2, (int) channel.readInbound());
assertEquals(3, (int) channel.readInbound());
// 더 이상 메시지가 없으면 null
assertNull(channel.readInbound());
}
TCP 분할 패킷 시뮬레이션
실제 네트워크에서는 12바이트가 한 번에 도착하리라는 보장이 없습니다. 이 상황을 테스트하는 게 중요합니다.
@Test
void 분할_패킷_디코딩_테스트() {
EmbeddedChannel channel = new EmbeddedChannel(new FixedLengthIntDecoder());
ByteBuf input = Unpooled.buffer();
input.writeInt(1);
input.writeInt(2);
input.writeInt(3);
// 12바이트를 2바이트씩 나눠서 주입 — TCP 분할 패킷 시뮬레이션
ByteBuf firstTwo = input.readSlice(2); // 0~1번째 바이트
// 아직 4바이트가 안 됐으니 디코딩 결과 없음
assertFalse(channel.writeInbound(firstTwo.retain()));
assertNull(channel.readInbound());
ByteBuf nextFour = input.readSlice(4); // 2~5번째 바이트
// 이제 누적 6바이트 → 첫 번째 int(4바이트) 디코딩 가능
assertTrue(channel.writeInbound(nextFour.retain()));
assertEquals(1, (int) channel.readInbound());
// 나머지 6바이트를 한번에 주입
assertTrue(channel.writeInbound(input.retain()));
assertEquals(2, (int) channel.readInbound());
assertEquals(3, (int) channel.readInbound());
assertFalse(channel.finish()); // 잔여 메시지 없음
}
분할 패킷 테스트를 빠뜨리면, 로컬에서는 잘 되는데 운영 환경에서 간헐적으로 디코딩이 깨지는 문제가 생길 수 있습니다. EmbeddedChannel 덕분에 이런 엣지 케이스를 네트워크 없이 재현할 수 있습니다.
인코더 테스트 — Java 객체에서 바이트로
인코더 테스트는 ** 아웃바운드 방향 **이라 writeOutbound()와 readOutbound()를 사용합니다.
테스트 대상 인코더
// 정수를 4바이트 ByteBuf로 인코딩
public class IntegerEncoder extends MessageToByteEncoder<Integer> {
@Override
protected void encode(ChannelHandlerContext ctx, Integer msg, ByteBuf out) {
out.writeInt(msg);
}
}
인코더 테스트
@Test
void 정수_인코딩_테스트() {
EmbeddedChannel channel = new EmbeddedChannel(new IntegerEncoder());
// Java 객체를 아웃바운드로 주입
assertTrue(channel.writeOutbound(42));
// 인코딩된 ByteBuf 꺼내기
ByteBuf result = channel.readOutbound();
// ByteBuf 내용 검증
assertNotNull(result);
assertEquals(42, result.readInt());
assertFalse(result.isReadable()); // 4바이트 다 읽었으니 더 이상 읽을 게 없음
result.release(); // ByteBuf 해제 잊지 말기
assertFalse(channel.finish());
}
디코더 테스트와 반대 방향이라는 점만 기억하면 패턴은 동일합니다.
- ** 디코더 **:
writeInbound(ByteBuf)→readInbound()→ Java 객체 검증 - ** 인코더 **:
writeOutbound(Object)→readOutbound()→ByteBuf검증
예외 테스트 — checkException()
파이프라인 내부에서 예외가 발생하면, EmbeddedChannel은 그 예외를 ** 내부에 저장 **해 둡니다. 그냥 두면 테스트가 통과해 버리기 때문에, 반드시 checkException()을 호출해서 예외를 확인해야 합니다.
예외를 발생시키는 핸들러
// 음수가 들어오면 예외를 던지는 핸들러
public class NegativeNumberValidator extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
int value = (int) msg;
if (value < 0) {
throw new IllegalArgumentException("음수는 허용되지 않습니다: " + value);
}
ctx.fireChannelRead(msg); // 양수면 다음 핸들러로 전달
}
}
checkException() 사용법
@Test
void 음수_입력_시_예외_발생_테스트() {
EmbeddedChannel channel = new EmbeddedChannel(
new FixedLengthIntDecoder(),
new NegativeNumberValidator()
);
ByteBuf input = Unpooled.buffer();
input.writeInt(-1);
channel.writeInbound(input);
// checkException()을 호출해야 저장된 예외가 다시 던져진다
assertThrows(IllegalArgumentException.class, () -> {
channel.checkException();
});
}
checkException()을 호출하지 않으면 어떻게 될까요?
@Test
void 예외가_묻히는_위험한_테스트() {
EmbeddedChannel channel = new EmbeddedChannel(
new FixedLengthIntDecoder(),
new NegativeNumberValidator()
);
ByteBuf input = Unpooled.buffer();
input.writeInt(-1);
channel.writeInbound(input);
// checkException()을 빠뜨리면 테스트가 통과해 버린다!
// 핸들러에서 예외가 발생했는데도 모르고 넘어감
}
checkException()을 빠뜨리는 실수는 생각보다 흔합니다. 테스트가 초록불인데 실제로는 예외가 발생하고 있을 수 있으니, EmbeddedChannel 테스트 끝에 항상 호출하는 습관을 들이는 게 좋습니다.
참고로 finish() 메서드도 내부적으로 checkException()을 호출합니다. 그래서 테스트 마지막에 finish()를 호출하면 예외 확인과 채널 정리를 동시에 할 수 있습니다.
실전 패턴 — JUnit/AssertJ와 조합
실제 프로젝트에서는 JUnit 5와 AssertJ를 함께 사용하는 경우가 많습니다. EmbeddedChannel 테스트를 좀 더 깔끔하게 작성하는 패턴을 살펴보겠습니다.
AssertJ 스타일 검증
@Test
void assertj_스타일_디코더_테스트() {
EmbeddedChannel channel = new EmbeddedChannel(new FixedLengthIntDecoder());
ByteBuf input = Unpooled.buffer();
input.writeInt(100);
input.writeInt(200);
assertThat(channel.writeInbound(input)).isTrue();
Integer first = channel.readInbound();
Integer second = channel.readInbound();
assertThat(first).isEqualTo(100);
assertThat(second).isEqualTo(200);
assertThat((Object) channel.readInbound()).isNull();
assertThat(channel.finish()).isFalse();
}
복합 파이프라인 테스트
여러 핸들러를 조합한 파이프라인도 한 번에 테스트할 수 있습니다.
@Test
void 복합_파이프라인_테스트() {
// 디코더 → 비즈니스 로직 → 인코더를 한 번에 테스트
EmbeddedChannel channel = new EmbeddedChannel(
new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4), // 프레이밍
new FixedLengthIntDecoder(), // 디코딩
new DoubleValueHandler(), // 비즈니스: 값을 2배로
new IntegerEncoder() // 인코딩
);
// 인바운드: 길이 헤더 + 정수 데이터
ByteBuf input = Unpooled.buffer();
input.writeInt(4); // 길이 헤더: 4바이트
input.writeInt(21); // 데이터: 21
channel.writeInbound(input);
// 아웃바운드 결과 확인: 21 × 2 = 42가 인코딩되었는지
ByteBuf output = channel.readOutbound();
assertThat(output).isNotNull();
assertThat(output.readInt()).isEqualTo(42);
output.release();
channel.finish();
}
@BeforeEach / @AfterEach 활용
class MyDecoderTest {
private EmbeddedChannel channel;
@BeforeEach
void setUp() {
channel = new EmbeddedChannel(new FixedLengthIntDecoder());
}
@AfterEach
void tearDown() {
// finish()로 채널 정리 + 미처리 메시지 확인 + 예외 확인
channel.finish();
}
@Test
void 단일_정수_디코딩() {
ByteBuf input = Unpooled.buffer();
input.writeInt(42);
channel.writeInbound(input);
assertThat((Integer) channel.readInbound()).isEqualTo(42);
}
@Test
void 빈_입력_처리() {
ByteBuf input = Unpooled.buffer(); // 빈 버퍼
channel.writeInbound(input);
assertThat((Object) channel.readInbound()).isNull();
}
}
finish() 메서드로 정리
finish()는 테스트 마지막에 반드시 호출하는 게 좋습니다.
// finish()가 하는 일
boolean hasRemaining = channel.finish();
// 1. 채널을 닫는다
// 2. 내부에 저장된 예외가 있으면 다시 던진다 (checkException)
// 3. 인바운드 또는 아웃바운드 큐에 아직 안 읽은 메시지가 있으면 true 반환
반환값을 활용하면 ** 의도치 않게 남은 메시지 **를 잡아낼 수 있습니다.
@Test
void finish_반환값_활용() {
EmbeddedChannel channel = new EmbeddedChannel(new FixedLengthIntDecoder());
ByteBuf input = Unpooled.buffer();
input.writeInt(1);
input.writeInt(2);
channel.writeInbound(input);
// 하나만 읽고 finish
channel.readInbound();
// 아직 안 읽은 메시지(2)가 남아있으므로 true 반환
assertThat(channel.finish()).isTrue();
// 남은 메시지 정리
Integer remaining = channel.readInbound();
assertThat(remaining).isEqualTo(2);
}
정리
EmbeddedChannel을 쓰면서 기억할 포인트를 정리합니다.
| 항목 | 핵심 내용 |
|---|---|
| ** 목적** | 네트워크 없이 핸들러/파이프라인을 단위 테스트 |
| ** 인바운드 테스트** | writeInbound() → readInbound() |
| ** 아웃바운드 테스트** | writeOutbound() → readOutbound() |
| ** 예외 확인** | checkException() 또는 finish() |
| ** 분할 패킷** | 데이터를 나눠서 writeInbound() 여러 번 호출 |
| ** 정리** | 테스트 끝에 finish() 호출 습관화 |
EmbeddedChannel은 "핸들러를 파이프라인에 넣고 데이터를 흘려보는 것"을 코드 한 줄로 할 수 있게 해줍니다. 서버를 띄울 필요 없이 디코더가 바이트를 제대로 자르는지, 인코더가 올바른 포맷으로 변환하는지, 예외가 적절히 처리되는지를 빠르게 검증할 수 있습니다.