핸들러 로직을 작성했는데, 테스트하려면 서버를 띄우고 클라이언트로 접속해야 하는 걸까? 네트워크 연결 없이 파이프라인만 돌려볼 수 있는 방법은 없을까?

왜 핸들러 테스트가 어려운가

네티 핸들러는 파이프라인 안에서만 동작 합니다. ChannelInboundHandlerchannelRead()가 호출되려면 채널이 있어야 하고, 채널이 있으려면 EventLoop에 등록되어야 하고, 보통은 실제 네트워크 소켓까지 필요합니다.

이걸 순서대로 정리하면 이렇습니다.

  • 핸들러 단독으로는 실행 불가 — 반드시 ChannelPipeline에 등록해야 함
  • 파이프라인은 Channel에 종속 — Channel 없이 파이프라인만 만들 수 없음
  • 일반적인 Channel은 실제 소켓을 필요로 함 — 서버를 띄우고 클라이언트를 연결해야 함

결국 핸들러 하나를 단위 테스트하려면 서버/클라이언트를 모두 구성해야 하는 상황이 생깁니다. 이건 단위 테스트가 아니라 통합 테스트에 가깝습니다.

단위 테스트의 핵심은 "테스트 대상만 격리해서 빠르게 검증하는 것"인데, 네트워크 연결까지 필요하면 그 원칙이 무너집니다.


EmbeddedChannel이란

EmbeddedChannel은 네티가 제공하는 ** 테스트 전용 Channel 구현체 **입니다. 실제 네트워크 I/O 없이 파이프라인을 구동할 수 있어서, 핸들러 로직을 격리된 환경에서 검증할 수 있습니다.

JAVA
// EmbeddedChannel은 생성자에서 핸들러를 바로 받는다
EmbeddedChannel channel = new EmbeddedChannel(
    new MyDecoder(),
    new MyBusinessHandler(),
    new MyEncoder()
);

핵심 특징을 정리하면 다음과 같습니다.

  • ** 실제 소켓 없음** — 네트워크 연결 없이 파이프라인이 동작
  • ** 동기 실행** — EventLoop 스레드를 별도로 띄우지 않고, 호출 스레드에서 바로 실행
  • ** 인바운드/아웃바운드 큐** — 파이프라인을 통과한 메시지를 내부 큐에 저장해서 꺼내 볼 수 있음
  • ** 예외 캡처** — 파이프라인에서 발생한 예외를 저장해 두고 나중에 확인 가능

동기 실행이라는 점이 특히 중요합니다. 일반 네티 채널은 EventLoop 스레드에서 비동기로 처리되기 때문에 테스트에서 타이밍 문제가 생기기 쉬운데, EmbeddedChannel은 ** 호출 즉시 결과를 확인 **할 수 있습니다.


기본 사용법 — 인바운드와 아웃바운드

EmbeddedChannel의 핵심 메서드는 네 가지입니다.

메서드방향역할
writeInbound(Object...)인바운드데이터를 파이프라인 앞쪽에 주입
readInbound()인바운드파이프라인을 통과한 인바운드 결과를 꺼냄
writeOutbound(Object...)아웃바운드데이터를 파이프라인 뒤쪽에 주입
readOutbound()아웃바운드파이프라인을 통과한 아웃바운드 결과를 꺼냄

인바운드 흐름

JAVA
// 간단한 인바운드 핸들러 테스트
EmbeddedChannel channel = new EmbeddedChannel(new StringDecoder());

// 인바운드 데이터 주입
channel.writeInbound(Unpooled.copiedBuffer("Hello", CharsetUtil.UTF_8));

// 파이프라인을 통과한 결과 꺼내기
String result = channel.readInbound();
assertEquals("Hello", result);

writeInbound()는 마치 ** 네트워크에서 데이터가 들어온 것처럼** 파이프라인의 첫 번째 인바운드 핸들러부터 순차적으로 실행합니다. 파이프라인 끝까지 도달한 메시지는 내부 인바운드 큐에 쌓이고, readInbound()로 꺼낼 수 있습니다.

아웃바운드 흐름

JAVA
// 아웃바운드 핸들러 테스트
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 객체가 올바른지 확인합니다.

테스트 대상 디코더

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());
        }
    }
}

기본 디코더 테스트

JAVA
@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바이트가 한 번에 도착하리라는 보장이 없습니다. 이 상황을 테스트하는 게 중요합니다.

JAVA
@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()를 사용합니다.

테스트 대상 인코더

JAVA
// 정수를 4바이트 ByteBuf로 인코딩
public class IntegerEncoder extends MessageToByteEncoder<Integer> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Integer msg, ByteBuf out) {
        out.writeInt(msg);
    }
}

인코더 테스트

JAVA
@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()을 호출해서 예외를 확인해야 합니다.

예외를 발생시키는 핸들러

JAVA
// 음수가 들어오면 예외를 던지는 핸들러
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() 사용법

JAVA
@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()을 호출하지 않으면 어떻게 될까요?

JAVA
@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 스타일 검증

JAVA
@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();
}

복합 파이프라인 테스트

여러 핸들러를 조합한 파이프라인도 한 번에 테스트할 수 있습니다.

JAVA
@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 활용

JAVA
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()는 테스트 마지막에 반드시 호출하는 게 좋습니다.

JAVA
// finish()가 하는 일
boolean hasRemaining = channel.finish();
// 1. 채널을 닫는다
// 2. 내부에 저장된 예외가 있으면 다시 던진다 (checkException)
// 3. 인바운드 또는 아웃바운드 큐에 아직 안 읽은 메시지가 있으면 true 반환

반환값을 활용하면 ** 의도치 않게 남은 메시지 **를 잡아낼 수 있습니다.

JAVA
@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은 "핸들러를 파이프라인에 넣고 데이터를 흘려보는 것"을 코드 한 줄로 할 수 있게 해줍니다. 서버를 띄울 필요 없이 디코더가 바이트를 제대로 자르는지, 인코더가 올바른 포맷으로 변환하는지, 예외가 적절히 처리되는지를 빠르게 검증할 수 있습니다.

댓글 로딩 중...