Netty 구조적 특징 (3) : 이벤트 루프

주싱·2023년 3월 26일
5

Network Programming

목록 보기
15/21

Loop icons created by Freepik

지난 몇 년 동안 네티 프레임워크를 사용하여 우주 지상국 소프트웨어를 개발했습니다. 개발을 진행하면서 저와 동료들이 네티의 특징을 제대로 이해하지 못해 실수했던 몇몇 경험들이 있는데 그 경험들을 바탕으로 네티의 중요한 구조적 특징들에 대해 나누어서 정리해 보려고 합니다.

이번 글에서는 네티의 I/O 쓰레드인 이벤트 루프에 대해 정리합니다. 전체 예제 코드는 GitHub에서 확인할 수 있습니다.

멀티플렉싱

이벤트 루프(EventLoop)는 이름처럼 계속해서 루프를 돌며 이벤트를 처리하는 네티의 핵심 구성 요소 중 하나입니다. 이벤트 루프는 멀티플렉싱(multiplexing) 방식을 사용해 하나의 쓰레드로 여러 채널에서 발생하는 이벤트를 병행해서 처리할 수 있습니다. 이벤트 루프가 처리하는 이벤트의 종류는 크게 두 종류로 나눌 수 있습니다. 하나는 저수준 채널에서 발생하는 I/O 이벤트이고 다른 하나는 사용자의 I/O 처리 요청입니다. 예를 들면 이벤트 루프는 네트워크 채널에서 읽기 가능한 데이터가 수신되면 데이터를 읽어서 처리하기도 하고 사용자가 데이터 쓰기 요청을 하는 경우 이를 받아 채널로 전송하기도 합니다.

// 이벤트 루프 공유
@Test
void oneEventLoopHandleMultiChannels() throws Exception {
    // When: 2개의 클라이언트 연결 (1개 이벤트 루프 공유)
    int nThread = 1;
    EventLoopGroup eventLoopGroup = new NioEventLoopGroup(nThread);
    clientOne = newConnection("localhost", 12345, eventLoopGroup);
    clientTwo = newConnection("localhost", 12345, eventLoopGroup);

    // Then: 하나의 이벤트 루프 공유하여 정상 통신
    assertSame(clientOne.test().eventLoop(), clientTwo.test().eventLoop());
    assertNormalCommunication();
}

// 서버와 클라이언트 간 일반적인 데이터 교환 테스트
void assertNormalCommunication() throws Exception {
    // 서버의 통신 가능 상태 대기
    await().until(() -> server.isActive(clientOne.localAddress()) &&
                        server.isActive(clientTwo.localAddress()));

    // 서버 -> 클라이언트
    server.sendAll("Hello All");
    assertEquals("Hello All", clientOne.read(1, TimeUnit.SECONDS));
    assertEquals("Hello All", clientTwo.read(1, TimeUnit.SECONDS));

    // 클라이언트 -> 서버
    clientOne.send("Hello I am One");
    assertEquals("Hello I am One", server.read(clientOne.localAddress(), 1, TimeUnit.SECONDS));
    clientTwo.send("Hello I am Two");
    assertEquals("Hello I am Two", server.read(clientTwo.localAddress(), 1, TimeUnit.SECONDS));
}

그룹핑

이벤트 루프는 이벤트 루프 그룹(EventLoopGroup)이라는 상위 모듈에 의해 그룹핑됩니다. 이벤트 루프 그룹이 생성될 때 기본적으로 CPU 코어 x 2개의 이벤트 루프를 생성하고 관리합니다. 물론 생성되는 이벤트 루프 개수를 변경할 수도 있습니다. 이러한 그룹핑을 통해서 추후 연관된 이벤트 루프들에서 관리하는 채널들을 한 번에 간단히 종료할 수도 있습니다. 서버 애플리케이션이라면 아래와 같은 코드 한 줄을 통해 서버에 접속된 모든 클라이언트들과의 연결을 닫고 이벤트 루프를 종료시켜 줄 수 있습니다.

// 서버에 접속된 모든 클라이언트 연결 종료
bootstrap.config().childGroup().shutdownGracefully();

채널에 할당되는 이벤트 루프

이벤트 루프 그룹은 부트스트랩(Bootstrap)을 통해 연결이 수립되고 통신 가능한 채널(Channel)이 생성되기 위한 필수 요소입니다. 따라서 초기화 과정에 이벤트 루프 그룹이 부트스트랩에 할당되어야 합니다. 이제 부트스트랩으로 연결을 시도하면 이벤트 루프 그룹에서 관리하는 이벤트 루프 하나가 선택되어 생성하는 채널(Channel)에 할당합니다. 이벤트 루프 선택에는 단순하게 라운드 로빈(Round-Robin, 하나씩 돌아가면서 선택) 방식이 사용됩니다.

이벤트 루프 전용 쓰레드

이벤트 루프는 자신의 독립적인 쓰레드를 가집니다. 그러나 이벤트 루프가 생성될 때 쓰레드를 생성하지는 않습니다. 이벤트 루프의 전용 쓰레드는 사용자가 부트스트랩으로 연결을 요청할 때 생성되고 시작됩니다. 한 번 시작된 이벤트 루프 쓰레드는 종료 요청이 있을 때까지는 계속해서 살아있게 됩니다. 심지어 처음에 자신을 생성되게 한 채널 연결이 종료되더라고 이벤트 루프 쓰레드는 이후 다른 채널을 서비스하기 위해 살아서 대기합니다. 따라서 처음에 N번의 연결 요청이 있을 때 까지만 쓰레드가 생성되고 이후 부터는 쓰레드풀 처럼 미리 생성된 쓰레드가 재사용됩니다. 이벤트 루프는 쓰레드 풀처럼 운영체제의 고비용 자원 중 하나인 쓰레드를 최대한 효율적으로 사용하려는 목적을 가집니다.

@Test
void keepAliveEvenIfChannelGetClosed() throws Exception {
    // Given: 연결된 채널의 이벤트 루프 쓰레드 획득
    EventLoopGroup clientEventLoopGroup = new NioEventLoopGroup(1);
    clientOne = newConnection("localhost", 12345, clientEventLoopGroup);
    Thread threadOne = clientOne.test().eventLoopThread();

    // When: 연결 끊기
    clientOne.disconnect();
    await().atMost(1000, TimeUnit.MILLISECONDS)
            .until(() -> !server.isActive(clientOne.localAddress()));

    // Then: 이벤트 루프의 쓰레드는 살아있음
    await().during(3000, TimeUnit.MILLISECONDS)
            .until(threadOne::isAlive);
}

불필요하게 생성되는 쓰레드 관리

이벤트 루프의 쓰레드는 연결이 요청될 때 생성된다고 앞서 설명했는데 정확하게 이해해야 하는 사실 한 가지가 있습니다. 그것은 이벤트 루프 쓰레드는 연결이 성공한 후에 생성되는 것이 아니라 연결을 시도하기 전에 생성된다는 사실입니다. 따라서 다른 관점에서 연결이 실패한 경우에도 이벤트 루프 쓰레드가 생성되어 남아있게 됩니다. 만약 우리 시스템이 대규모의 사용자 요청을 처리하는 시스템이 아니라 하나의 서버에 연결하는 하나의 클라이언트(또는 이벤트 루프 그룹의 이벤트 루프 보다 적은 개수)를 다루는 시스템이라면 연결이 실패할 때 마다 사용되지 않는 쓰레드 하나가 생성되는 상황이 생깁니다. 물론 해당 이벤트 루프 쓰레드에는 연결된 채널이 할당되지 않겠지만 여러 자원들이 생성되어 메모리에 로드되고 운영체제의 고비용 자원 중 하나인 쓰레드가 생기고 사용되지 않는 비효율이 발생할 수도 있습니다. 대부분의 어플리케이션에서 크게 신경쓰지 않아도 될지도 모르지만 최적화가 필요한 환경이라면 이런 사실을 염두에 두어야 합니다. 따라서 다음과 같이 연결이 실패한 경우에는 생성된 이벤트 루프 쓰레드를 종료하도록 설정할 수도 있습니다.

bootstrap.connect().addListener((ChannelFutureListener) future -> {
    if (!future.isSuccess()) {
        future.channel().eventLoop().shutdownGracefully();
    }
});

이벤트 루프의 종료

네티의 도움이 없이 네트워크 프로그래밍을 할 때 이 이벤트 루프(보통 반복해서 채널에서 실시간으로 수신된 데이터를 읽기 위해)를 연결 당 생성하고 관리하는 기능은 꽤 구현이 번거로운 기능입니다. 쓰레드를 직접 생성해서 관리해야 할 뿐 아니라, 이 쓰레드를 안전하게 종료해 주는 기능 역시 세심하게 구현해야 합니다. (참조 → 안전한 쓰레드 종료 코드 직접 만들기) 이벤트 루프 외부에서 실행 종료를 요청할 수 있는 인터페이스를 만들어야 하고, 이벤트 루프 자신은 어딘가에 무한히 블락킹 되지 않고 틈틈히 이 종료 요청을 확인하도록 구현해야 합니다. 그리고 종료를 요청한 외부에서는 이벤트 루프 종료 완료를 기다릴 수도 있어야 하며 또한 쓰레드가 종료될 때는 사용한 자원들 역시 안전하게 모두 해제해 주어야 할 책임도 가집니다. 네티를 사용하면 아래 코드 한 줄로 모든 것이 해결됩니다.

channel.eventLoop().shutdownGracefully(); 

연관된 채널의 모든 연결을 닫아준다

위 코드 라인이 실행되면 이벤트 루프 쓰레드의 실행이 종료되고 관련된 자원들이 안전하게 해제됩니다. 뿐만아니라 이벤트 루프에 매핑되어 있던 채널도 모두 닫아주기 때문에 별도로 채널 닫기 요청을 하지 않아도 됩니다.

@Test
void shutdownAll() throws Exception {
    // Given: 2개의 클라이언트 연결
    EventLoopGroup clientEventLoopGroup = new NioEventLoopGroup(2);
    clientOne = newConnection("localhost", 12345, clientEventLoopGroup);
    clientTwo = newConnection("localhost", 12345, clientEventLoopGroup);

    // When: 이벤트 루프 그룹 닫기
    clientEventLoopGroup.shutdownGracefully().sync();

    // Then: 하위 모든 채널 연결 닫힘
    await().atMost(1000, TimeUnit.MILLISECONDS)
            .until(() -> !server.isActive(clientOne.localAddress()) &&
                         !server.isActive(clientTwo.localAddress()));
}

감사합니다.

profile
소프트웨어 엔지니어, 일상

0개의 댓글