1차 개선 때 대략 max 스레드가 2000까지 올라가는 현상을 700개 까지 줄였다. 그래도 스래드 개수가 일정하지 않는건 어딘가에서 또 스레드를 계속 생성하고 있나? 라는 의심이 들었다.

IO bound 서버에서의 스레드 불필요하게 많으면 안 좋은 이유

  • 컨텍스트 스위칭으로 인한 오버해드의 증가
  • 메모리 소비 증가
    • 각 스레드는 스택과 힙 공간을 할당받기 때문에, 스레드 수가 많아질수록 메모리 사용량이 증가
  • 락 및 동기화에 따른 비용 증가
  • ETC….

원인 파악

1. 엘레베이터 호출 서버의 API 콜 개수 확인

스레드가 많이 생성되고 있다는 건 많은 HTTP 요청을 받는 부분이 문제가 아닐까? 라고 생각하여 엘레베이터 호출 서버의 요청 추이를 확인해봤다.

CloudWatch 엘리베이터 콜 요청 추이

그냥 엘리베이터 콜 수가 압도적으로 많다.

OPTIONS httpMethod를 필터링 하면 그냥 대부분 요청이 엘리베이터 콜이다. 이정도면 엘리베이터가 원인이다.

2. 엘레베이터 콜 이력 개수와 Total Thread 그래프 대조

그래도 좀 더 정확하게 파악하기 위해 엘리베이터 콜 이력 개수와 스레드 개수 그래프를 대조해보면 서로 유사한 그래프가 나온다.

  • 엘레베이터 콜 호출 API 엘리베이터 콜 이력 그래프
  • Total Thread 수 Total Thread 그래프

3. 문제 코드 분석

엘리베이터 콜은 TCP기반 소켓 통신을 하고, 이를 수행하기 위해 Netty를 사용한다.

Netty EventLoop, EventLoopGroup 간단 설명

Netty는 이벤트 기반 비동기 네트워크 애플리케이션 프레임워크로, 효율적이고 고성능의 소켓 프로그래밍을 지원합니다. 이를 위해 Netty는 EventLoop를 사용합니다.

이벤트 큐에 쌓인 이벤트를 처리하기 위해 무한 루프를 도는 이벤트 루프 스레드가 있습니다.

Netty는 하나의 채널이 동일한 이벤트 루프에서 작업이 처리되도록 보장합니다. (여기서는 상세한 내용은 다루지 않습니다) 채널은 Netty에서 네트워크 연결을 나타냅니다.

netty는 EventLoopGroup은 한 개 또는 여러 개의 EventLoop를 포함한다. (일종의 스레드 풀 개념) 만약 EventLoopGroup 크기가 5 → EventLoop 개수가 5 → 이벤트 루프 스레드 5

Netty EventLoop 개념 Netty EventLoopGroup 개념

문제의 로직은 다음과 같다.

@Service
class NettyService(
    ...
) {

    fun lobbyCall(
        ... 생략
    ) {
        val eventLoopGroup = NioEventLoopGroup() // 요놈이 문제임

        try {
            val timeOut = getTimeOut(fireWallInfo.evType)
            val elevatorCallInfo = ElevatorCallInfo(
                ...생략
            )
            val customReadTimeoutHandler = CustomReadTimeoutHandler(
                timeout = timeOut,
                unit = TimeUnit.MILLISECONDS
            )
            val bootstrap = Bootstrap()
            bootstrap.group(eventLoopGroup)
                .channel(NioSocketChannel::class.java)
                .option(ChannelOption.SO_LINGER, 0)
                .option(ChannelOption.SO_REUSEADDR, true)
                .handler(object : ChannelInitializer<SocketChannel>() {
                    override fun initChannel(ch: SocketChannel) {
                        val pipeline = ch.pipeline()
                        pipeline.addLast("readTimeOutHandler", customReadTimeoutHandler)
                        pipeline.addLast(ElevatorLobbyCallHandler(elevatorCallInfo, elevatorCallHistoryService))
                    }
                })
                .connect(fireWallInfo.ipAddress, fireWallInfo.port).sync()
                .channel().closeFuture().sync()
        } catch (e: Exception) {
            ... 생략
        } finally {
            eventLoopGroup.shutdownGracefully().sync()
        }
    }

}

소켓통신을 하기 위해 Bootstrap객체를 사용해야 함 Bootstrap은 group(), channel(), handler() 를 구성해야 한다.

NioEventLoopGroup 객체를 생성하면 내부적으로 이벤트 루프를 만들어 관리한다. NioEventLoopGroup 의 기본 생성자를 사용하면 JVM에서 사용가능한 코어 개수의 2배에 해당하는 스레드를 생성한다. 그렇다는 건… 해당 JVM에서 사용할 수 있는 프로세스의 2배만큼 스레드가 생성되고 있었다는 이야기가 된다.

4. 문제해결 및 부하테스트

기존 로직

0.1초 간격으로 총 600개의 요청(분당 600개)을 수행한 결과 최대 304 스래드가 생성된 것을 확인할 수 있다.

기존 로직 부하 테스트 결과

이벤트 루프 그룹을 재사용 하는 경우

위와 같은 조건으로 테스트를 한 결과 thread가 튀는 현상은 발생하지 않았다. 사진과 같이 스레드 개수는 일정하게 유지되었다.

상용 배포 결과

상용 배포 후 지표 1 상용 배포 후 지표 2

기존에는 heap memory가 대략 최대 900MB였는데 현재 배포 이후 최대 heap memory 530MB 정도 올라간다. 기존 대비 41.1% 정도 감소했다. 아무래도 엘리베이터 콜당 EventLoopGroup 객체를 생성했으니 Heap Size에 영향이 간 게 아닐까 추측한다.

Total Thread 지표에서 최대 720까지 튀던 스레드 개수가 현재는 Total Thread max 개수가 200개 정도로 약 72.22% 정도로 감소했다.

1, 2차 개선 모두 합하면 90% 정도로 스레드 개수가 감소되었다.

Reference