유성

[백프레셔 전략] 엔진 내부 TCP 수신 성능 개선 본문

Architecture

[백프레셔 전략] 엔진 내부 TCP 수신 성능 개선

백엔드 유성 2026. 1. 7. 00:15

실무에서 만난 간단한 성능 문제와 해결했던 방법에 대해 알아보자.

 

1. TCP 수신 속도에 따른 병목 발생

문제가 발생했다. 발신부에서 TCP로 요청을 보내는데 병목이 발생한다고 한다.

분당 5,000건을 보냈는데 성공건수를 보니 분당 3,000건 정도만 처리되고 나머지는 Timeout이 발생했다고 한다.

 

아래는 문제가 되는 코드를 간략하게 표현했다.

try (ServerSocket serverSocket = new ServerSocket(1770)) {
    while (!Thread.currentThread().isInterrupted()) {
        Socket client = serverSocket.accept();
        new Thread(new MyReceiver(client)).start();
    }
}

 

로직을 보면 1770번 포트로 TCP 요청을 받아 MyReceiver로 넘어간다.

스레드가 과도하게 생성되어 OOM이 발생할 여지가 있으나, 일단 문제가 되는 코드를 찾아야 하므로 넘어가자.

 

2. 문제에 대한 확인

로그를 확인했을 때 에러는 발생하지 않았으나, 로그와 코드를 비교하여 확인해보니 문제를 바로 알수있었다.

MyReceiver 시작 부분에 INFO 로그가 있는데 로그 주기가 일정하게 20ms 로 잡혀있었다.

 

이를 계산해보면 정확히 1분동안 약 3000개의 처리가 가능한 구조로 개발이 되어있던 것이다.

 

accept 연산은 이미 올라온 연결을 가져오기만 하는것이기에 ns 단위로 처리가 된다.

그러면 다음 코드인 스레드의 생성 후 실행까지 약 20ms 지연이 발생한 것으로 추측해볼 수 있다.

스레드 생성은 스레드 생성과 스택 메모리 할당으로 오버헤드가 큰 작업이다.

 

3. 문제에 대한 개선

이를 토대로 서비스에 대한 구조를 재설계했다.

우선적으로 성능을 올려야 하기때문에 다음과 같은 순서로 뼈대를 잡기 시작했다.

 

'스레드를 생성하는 스레드'가 accept를 물고있으면 안된다.

accept를 수행하는 스레드는 스레드 생성 시간을 Blocking하고있으면 안된다.

우선 이를 분리하기 위해 ExecutorService를 사용하여 미리 만들어놓은 스레드로 작업을 시작하도록 구상했다.

 

피크 타임을 대비하여 TPS를 자동으로 조절해야 한다.

피크 타임이 발생하는 작업이고, 피크 타임에 자동적으로 TPS를 증가시켜야 한다.

그래서 스레드를 초과 생성하고자 가변 스레드 풀을 구상했다.

 

정합성을 지켜야 한다.

일부 보완 처리가 있어, 정합성을 완벽하게 만족하지 않아도 되는 작업이였으나 개인적으로 적어도 99% 이상 만족하도록 설계하고자 했다.

그래서 백프레셔를 사용하여 처리 속도가 수신 속도를 못따라잡거나 하는 일을 제거하는 것으로 구상했다.

만약 처리 속도가 못받혀주면 수신 속도를 제어하는 역방향 제어 형식이다.

 

이를 모두 만족하면서 간단하게 만들 수 있는 코드는 다음과 같이 작성할 수 있다.

(간략화된 임시 코드)

try (ThreadPoolExecutor executor = new ThreadPoolExecutor(
        20, // core
        60, // max
        40, // time
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(60), // queue
        new ThreadPoolExecutor.CallerRunsPolicy());
     ServerSocket serverSocket = new ServerSocket(1770)) {
    while (!Thread.currentThread().isInterrupted()) {
        Socket client = serverSocket.accept();
        executor.execute(new MyReceiver(client));
    }
}

 

수정된 코드를 간략히 보면 다음과 같다.

  • 20개의 Core 스레드를 사용한다. 
  • 최대 60개의 스레드까지 생성된다.
  • 초과된 스레드는 1분 후 제거된다.
  • CallerRunsPolicy를 사용하여, 60개의 스레드가 모두 활성화되고 queue가 가득 차게되면 현재 스레드에서 작업을 처리한다.

위와 같이 작성했으며, 만약 MyReceiver 로직 실행시간이 30ms라고 가정했을 때 TPS는 다음과 같아진다.

  • 20개의 Core 스레드: 약 660TPS(분당 4만건)
  • 60개의 Max 스레드: 약 2,000TPS(분당 12만건)

MyReceiver 처리에 대한 메모리 사용 비용이 크지 않고

단순한 작업과 단순한 DB 작업으로 Max 스레드를 크게 잡을 수 있었으며,

분당 5,000개의 처리는 20개의 Core스레드 내에서 모두 처리가 가능한 구조가 된것이다.

 

한번 수정을 할 때 좀 튼튼하게 만들고자 하여 백프레셔를 추가했다.

백프레셔가 작동하는 구간은 요청이 분당 12만건을 넘어서는 구간이다.

 

12만건을 넘어서면, 모든 스레드가 활성화된 후 큐가 가득차면서 Reject 예외가 발생하는데,

이 때 이전에 설정한 CallerRunsPolicy 인 백프레셔가 동작하는 구간이 된다.

 

CallerRunsPolicy를 보면 단순하다. while문을 실행하던 스레드가 executor.execute를 호출하면 스레드 풀에게 작업을 맏기지 않고 자신이 직접 수행하는 것이다.

자신이 직접 수행하면 수행이 끝날때까지 while문을 돌지 않는다. 이 경우 accept까지 지연이 전파된다.
accept의 지연 발생은 되려 스레드 풀이 작업들을 처리할 시간을 벌어다 주게 되어, 오히려 수신량을 적절히 제어하는 역할을 하는 것이다.

 

ServerSocket은 백로그로 50개(default)의 connection을 가지고 있고 이에 대하여 분당 12만건을 넘어서면 발신 쪽에서는 Timeout을 발생시키고, 수신쪽에서는 백프레셔를 활용해 처리량과 CPU/MEM을 지키는 전략을 새운 것이다.