Tomcat 구현하기 삽질기 (feat.acceptCount)

2025. 9. 15. 08:04·개발/Java & Spring

Two Cats (feat. 우리집)

 

우아한테크코스의 레벨4 첫 번째 미션은 Tomcat 구현하기이다.

 

Web application server의 일종이며, 스프링의 내장 서버로 잘 알려져있는 Tomcat을 간단한 버전으로 구현해보는 미션이다. Tomcat과 똑같이 만들기 보다는, 주어진 요구사항을 통해 HTTP와 서블릿, 스레드 풀과 같은 다양한 개념을 익히는 것이 중요한 미션이다.

 

총 4단계로 이루어진 미션 중 4단계 동시성 확장하기는 스레드 풀 관련 기능을 추가하는 단계다.

 

미션에서 acceptCount 설정 값 관련 실험을 하다가 많은 삽질을 했다. 이 글에서는 실험 과정에서 발견한 예상 밖의 결과와, 그를 통해 배운 TCP 3-way Handshake, SYN Queue, Accept Queue의 동작 원리를 공유하려 한다.

 

잘못된 정보가 포함되어 있을 수 있습니다! 조언과 지적은 언제든 환영합니다.

 

🐈 실제 Tomcat 

톰캣 공식문서에서는 acceptCount에 대해 다음과 같이 설명하고 있다.

maxConnections에 도달했을 때 운영 체제가 들어오는 연결 요청을 위해 제공하는 대기열의 최대 길이입니다. 운영 체제는 이 설정을 무시하고 대기열에 다른 크기를 사용할 수 있습니다. 이 대기열이 가득 차면 운영 체제가 추가 연결을 적극적으로 거부하거나 해당 연결이 시간 초과될 수 있습니다. 기본값은 100입니다.

 

... 잘 이해가 되지 않는다.

 

🐈‍⬛ 테스트용 Tomcat 

미션의 뼈대 코드에서 Tomcat의 Connector를 모방한 코드를 제공한다. 아래 코드는 실험에 필요한 부분만 골라서 정리한 코드다.

public class TestConnector implements Runnable {

    private static final Logger log = LoggerFactory.getLogger(TestConnector.class);
    
    private final ServerSocket serverSocket;
    private boolean stopped;

    public TestConnector(final int port, final int acceptCount, final int maxThreads) {
        this.serverSocket = createServerSocket(port, acceptCount);
        this.stopped = false;
    }

    private ServerSocket createServerSocket(final int port, final int acceptCount) {
        try {
            // acceptCount를 ServerSocket의 backlog 값으로 전달
            return new ServerSocket(port, acceptCount); 
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    @Override
    public void run() {
        while (!stopped) {
            connect();
        }
    }

    private void connect() {
        try {
            Socket connection = serverSocket.accept();
            process(connection);
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }

    private void process(final Socket connection) {
        if (connection == null) {
            return;
        }
        var processor = new TestHttp11Processor(connection);
        new Thread(processor).start();
    }
}

 

테스트용 Connector에서 acceptCount는 java.net.ServerSocket 생성자의 backlog 인자로 쓰이고 있다. 공식 설명에 의하면 backlog는 다음과 같이 설명되어 있다. “requested maximum number of pending connections on the socket”, “requested maximum length of the queue of incoming connections”. 해석하기가 매우 어려운데, 대충 connection에 대한 대기열 최대 길이정도로 해석이 된다.

java.net.ServerSocket


🧪 실험 

위의 과정을 통해 다음과 같은 사실을 알았다.

  • acceptCount가 ServerSocket을 생성할 때 backlog 값을 결정하게 된다.
  • backlog는 소켓 연결 대기 큐(accept queue)의 길이를 결정한다.

실제 acceptCount가 서버의 요청 처리에 영향을 주는 것을 눈으로 확인하기 위해 테스트코드를 작성했다. acceptCount 값을 1로 설정하고, 10개의 요청을 동시에 보냈다.

@DisplayName("acceptCount가 작을 때 클라이언트가 연결에 실패할 수 있다.")
@Test
void acceptCountTest() throws InterruptedException {
    int acceptCount = 1;
    int requestCount = 10;

    TestConnector testConnector = new TestConnector(TEST_PORT, acceptCount, 1);
    testConnector.start();

    Thread[] requestThreads = new Thread[requestCount];

    for (int i = 0; i < requestCount; i++) {
        final int num = i + 1;
        requestThreads[i] = new Thread(() -> {
            log.info("{} 요청 시작", num);
            try (Socket socket = new Socket(LOCALHOST, TEST_PORT)) {
                log.info("[{}] ✅ 연결 성공", num);
            } catch (ConnectException e) {
                log.info("[{}] ❌ 연결 거부: {}", num, e.getMessage());
            } catch (IOException e) {
                log.info("[{}] ❌ 기타 오류: {}", num, e.getMessage());
            }
        });
    }

    // 모든 스레드 시작
    for (Thread thread : requestThreads) {
        thread.start();
    }

    // 모든 스레드 완료 대기
    for (Thread thread : requestThreads) {
        thread.join();
    }
}

1️⃣ 첫 번째 실험 

가설

acceptCount = 1 로 설정하고 10개의 요청을 보냈을 때, 다음과 같은 결과를 예측했다.

  • 1번째 요청: 즉시 처리
  • 2번째 요청: Accept Queue에서 대기 후, 첫 번째 요청이 끝나면 처리
  • 3번째 ~ 10번째 요청: Accept Queue가 꽉 차있기 때문에 즉시 요청 실패

결과

하지만 결과는 완전히 예상을 빗나갔다. 10개의 요청이 전부 성공했다.

02:58:22.058 [Test worker] INFO support.TestConnector -- Web Application Server started 8081 port.
02:58:22.066 [Thread-6] INFO org.apache.catalina.connector.ConnectorTest -- 3 요청 시작
02:58:22.067 [Thread-9] INFO org.apache.catalina.connector.ConnectorTest -- 6 요청 시작
02:58:22.065 [Thread-4] INFO org.apache.catalina.connector.ConnectorTest -- 1 요청 시작
02:58:22.067 [Thread-11] INFO org.apache.catalina.connector.ConnectorTest -- 8 요청 시작
02:58:22.067 [Thread-8] INFO org.apache.catalina.connector.ConnectorTest -- 5 요청 시작
02:58:22.069 [Thread-12] INFO org.apache.catalina.connector.ConnectorTest -- 9 요청 시작
02:58:22.065 [Thread-5] INFO org.apache.catalina.connector.ConnectorTest -- 2 요청 시작
02:58:22.069 [Thread-13] INFO org.apache.catalina.connector.ConnectorTest -- 10 요청 시작
02:58:22.067 [Thread-10] INFO org.apache.catalina.connector.ConnectorTest -- 7 요청 시작
02:58:22.067 [Thread-7] INFO org.apache.catalina.connector.ConnectorTest -- 4 요청 시작
02:58:22.080 [Thread-4] INFO org.apache.catalina.connector.ConnectorTest -- [1] ✅ 연결 성공
02:58:22.080 [Thread-5] INFO org.apache.catalina.connector.ConnectorTest -- [2] ✅ 연결 성공
02:58:22.209 [Thread-10] INFO org.apache.catalina.connector.ConnectorTest -- [7] ✅ 연결 성공
02:58:22.301 [Thread-7] INFO org.apache.catalina.connector.ConnectorTest -- [4] ✅ 연결 성공
02:58:22.483 [Thread-13] INFO org.apache.catalina.connector.ConnectorTest -- [10] ✅ 연결 성공
02:58:22.725 [Thread-6] INFO org.apache.catalina.connector.ConnectorTest -- [3] ✅ 연결 성공
02:58:23.205 [Thread-9] INFO org.apache.catalina.connector.ConnectorTest -- [6] ✅ 연결 성공
02:58:24.167 [Thread-11] INFO org.apache.catalina.connector.ConnectorTest -- [8] ✅ 연결 성공
02:58:26.086 [Thread-12] INFO org.apache.catalina.connector.ConnectorTest -- [9] ✅ 연결 성공
02:58:28.007 [Thread-8] INFO org.apache.catalina.connector.ConnectorTest -- [5] ✅ 연결 성공

Connector 코드 수정 - 병목 지점 추가

문제를 파악하기 위해 서버에 인위적인 지연을 추가했다.

@Override
public void run() {
    while (!stopped) {
        connect();
    }
}

private void connect() {
    try {
        Socket connection = serverSocket.accept();
        process(connection);
    } catch (IOException e) {
        log.error(e.getMessage(), e);
    }
}

private void process(final Socket connection) {
    if (connection == null) {
        return;
    }
    TestHttp11Processor processor = new TestHttp11Processor(connection);
//     new Thread(processor).start();
    processor.run(); // 스레드를 생성하는 대신 Connector에서 직접 실행
    // 다음 serverSocket.accept() 메서드를 실행하기 전까지 Blocking 가능
}
public class TestHttp11Processor implements Runnable, Processor {

    private static final Logger log = LoggerFactory.getLogger(TestHttp11Processor.class);
    private final Socket connection;

    public TestHttp11Processor(Socket connection) {
        this.connection = connection;
    }

    @Override
    public void run() {
        process(connection);
    }

    // processor.run() 하면 실행되는 메서드
    @Override
    public void process(final Socket connection) {
        try (
                final InputStream inputStream = connection.getInputStream();
                final OutputStream outputStream = connection.getOutputStream()
        ) {
			// 테스트용 Thread.sleep() 추가
            Thread.sleep(7000);
        } catch (final Exception e) {
            log.error(e.getMessage());
        }
    }
}

2️⃣ 두 번째 실험 - Thread.sleep() 시간에 따른 결과 변화 관찰 

TestHttp11Processor 의 process() 메서드에서 Thread.sleep() 에 넘겨주는 파라미터 값을 키워가면서 콘솔에 출력되는 결과를 관찰했다. 테스트 코드는 그대로 사용했다. acceptCount 는 1로, 요청 수는 10으로 고정시키고 Thread.sleep()에 넘겨주는 파라미터 값만 변경했다.

@DisplayName("acceptCount가 작을 때 클라이언트가 연결에 실패할 수 있다.")
@Test
void acceptCountTest() throws InterruptedException {
    int acceptCount = 1;
    int requestCount = 10;

    TestConnector testConnector = new TestConnector(TEST_PORT, acceptCount, 1);
    testConnector.start();

    Thread[] requestThreads = new Thread[requestCount];

    for (int i = 0; i < requestCount; i++) {
        final int num = i + 1;
        requestThreads[i] = new Thread(() -> {
            log.info("{} 요청 시작", num);
            try (Socket socket = new Socket(LOCALHOST, TEST_PORT)) {
                log.info("[{}] ✅ 연결 성공", num);
            } catch (ConnectException e) {
                log.info("[{}] ❌ 연결 거부: {}", num, e.getMessage());
            } catch (IOException e) {
                log.info("[{}] ❌ 기타 오류: {}", num, e.getMessage());
            }
        });
    }

    // 모든 스레드 시작
    for (Thread thread : requestThreads) {
        thread.start();
    }

    // 모든 스레드 완료 대기
    for (Thread thread : requestThreads) {
        thread.join();
    }
}

결과

 

결과 해석

지연 시간 7초 이상 부터는, 첫 번째 가설에서 예상한 대로 2개의 요청만 성공했고 나머지 8개의 요청은 실패했다.

 

지연 시간을 늘려가면서 관찰했을 때, 발견하게 된 특이점이 있다.

 

연결 거부와 로깅 출력 시간의 연관성

지연 시간을 변화시킬 때, 10개 중 몇개가 연결이 실패되는지는 달라지만, 전부 다 요청 시작 후 7초 후 정도 부터 연결 실패 로깅이 찍혔다.

 

지연시간 0.1s 일 때

03:03:21.006 Web Application Server started 8081 port.
03:03:21.013 요청 시작
03:03:21.026 [5] ✅ 연결 성공
03:03:21.026 [10] ✅ 연결 성공
03:03:22.085 [4] ✅ 연결 성공
03:03:23.044 [3] ✅ 연결 성공
03:03:24.965 [1] ✅ 연결 성공
03:03:26.885 [9] ✅ 연결 성공
03:03:28.808 [7] ❌ 연결 거부: Operation timed out
03:03:28.808 [6] ❌ 연결 거부: Operation timed out
03:03:28.808 [2] ❌ 연결 거부: Operation timed out
03:03:28.808 [8] ❌ 연결 거부: Operation timed out

 

지연시간 0.5s 일 때

03:06:20.474  Web Application Server started 8081 port.
03:06:20.480  요청 시작
03:06:20.494 [7] ✅ 연결 성공
03:06:20.495 [5] ✅ 연결 성공
03:06:26.355 [10] ✅ 연결 성공
03:06:28.276 [2] ❌ 연결 거부: Operation timed out
03:06:28.276 [8] ❌ 연결 거부: Operation timed out
03:06:28.276 [1] ❌ 연결 거부: Operation timed out
03:06:28.276 [9] ❌ 연결 거부: Operation timed out
03:06:28.276 [6] ❌ 연결 거부: Operation timed out
03:06:28.276 [3] ❌ 연결 거부: Operation timed out
03:06:28.276 [4] ❌ 연결 거부: Operation timed out

 

Operation timed out

연결이 거부된 경우 Exception의 메시지를 출력하고 있다. 그런데 이 출력 메시지가 Operation timed out 이다. 즉 서버측에서 요청을 거절한 것이 아니라, 클라이언트가 요청 후 대기하다가 7초가 지나면 일제히 time out이 난 것이다.

3️⃣ 세 번째 실험 - acceptCount에 따른 연결 성공 횟수 실험 

앞의 실험에서, 지연 시간을 7초로 설정하면 acceptCount + 1 개 만큼은 성공하고, 그 이후의 요청은 실패하는 것을 발견했다.

세 번째 실험에서는 지연 시간을 7초로 고정하고, acceptCount를 변화시키면서 동일한 테스트를 수행했다.

결과

이번에도 모두 예상대로 acceptCount + 1 개 만큼 성공했다.


최초 예측과 달랐던 결과

세 번의 실험을 통해서, 기존에 예측했던 가설이 실제와 많이 다르다는 것을 알았다.

틀린 가설:

Accept Queue 가 가득 찬 상태에서 요청이 서버가 요청을 즉시 거절한다.

각 요청이 처리될 때마다 서버는 Thread::sleep 을 하고 있다. 만약 즉시 거절했다면, 모든 요청들이 acceptCount + 1 개만 처리됐을 것이다. 하지만 예상과 달리 7초 이내에는 모든 요청이 처리됐다.

ServerSocket이 accept() 해야만 클라이언트 측 new Socket()이 반환된다.

테스트코드에 다음과 같은 부분이 있었다. 서버측에서 ServerSocket.accpet() 했을 때 클라이언트의 new Socket()이 반환될 것이라고 예측하고 작성한 코드였다.

requestThreads[i] = new Thread(() -> {
  log.info("{} 요청 시작", num);
  try (Socket socket = new Socket(LOCALHOST, TEST_PORT)) {
      log.info("[{}] ✅ 연결 성공", num);
  } ...

 

실제 코드에서 ServerSocekt.accept() 는 Thread.sleep() 에 설정된 숫자 만큼의 간격만큼 간격을 가지고 실행된다. 만약 가설이 맞다면, ✅ 연결 성공 로깅은 항상 sleep 숫자 이상의 간격을 가져야 한다.

 

하지만 실제 로그를 확인해보면 그렇지 않았다. 아래는 accountCount는 3으로 설정하고, 3초씩 sleep을 한 테스트의 결과다.

06:50:48.358 Web Application Server started 8081 port.
06:50:48.380 [4] ✅ 연결 성공
06:50:48.381 [3] ✅ 연결 성공
06:50:48.381 [9] ✅ 연결 성공
06:50:48.380 [5] ✅ 연결 성공
06:50:52.332 [8] ✅ 연결 성공
06:50:56.176 [1] ❌ 연결 거부: Operation timed out
06:50:56.175 [7] ❌ 연결 거부: Operation timed out
06:50:56.176 [10] ❌ 연결 거부: Operation timed out
06:50:56.175 [2] ❌ 연결 거부: Operation timed out
06:50:56.176 [6] ❌ 연결 거부: Operation timed out

 


TCP 3-way Handshake와 두 개의 Queue 

 

두 가지 가설의 진실을 알기 위해서는 소켓 통신의 연결 수립 과정과, SYN Queue 개념을 알고 있어야 한다.

연결 수립 과정

클라이언트가 new Socket(host, port) 를 호출하면 다음과 같은 일이 일어난다.

  1. Client → Server: SYN (연결 요청)
  2. Server → Client: SYN+ACK (요청 승인 + 확인 요청)
  3. Client → Server: ACK (최종 확인)

이를 TCP 3-way Handshake라 한다. 이 과정은 우리가 작성한 코드와 관계 없이 OS 레벨에서 관리된다.

3단계를 거쳐 OS가 연결할 준비를 끝냈다면, Socket 객체가 반환된다. ServerSocket의 accept() 메서드 호출과는 별개이다.

3-way Handshake가 끝난 후 ServerSocket이 accpet()를 호출하는 것이다.

 

즉, 테스트에서 ✅ 연결 성공 로깅이 출력된 것은, 소켓이 Three-way Handshake를 끝냈다는 뜻이다. 아직 accept() 되지 않았을 수 있다. 또한 ❌ 연결 거부 가 출력된 요청은 Three-way Handshake 가 완료되지 않은 상태로 끝났음을 알 수 있다.

SYN Queue vs Accept Queue

 

서버 커널은 이 과정을 위해 두 종류의 큐를 사용한다.

1. SYN Queue (Incomplete Connection Queue)

  • 목적: Handshake 과정이 아직 완료되지 않은 연결 요청을 임시로 저장하는 공간이다.
  • 동작:
    1. 서버가 클라이언트로부터 최초의 SYN 패킷을 받는다.
    2. 서버는 연결 정보를 이 SYN Queue에 넣고, 상태를 SYN_RCVD로 변경한다.
    3. 클라이언트에게 SYN+ACK 패킷을 보낸 후, 클라이언트의 마지막 ACK를 기다린다.

2. Accept Queue (Completed Connection Queue)

  • 목적: Handshake를 완료한 연결들을 저장하는 공간이다. 애플리케이션이 accept() 시스템 콜을 호출하여 연결을 가져갈 때까지 대기하는 장소다.
  • 동작:
    1. 서버가 클라이언트로부터 마지막 ACK 패킷을 받으면, 3-way Handshake가 완료된다.
    2. 커널은 해당 연결을 SYN Queue에서 Accept Queue로 이동시키고, 상태를 ESTABLISHED로 변경한다.
    3. 연결은 애플리케이션이 accept()를 호출하여 가져갈 때까지 이 큐에서 대기한다.

전체 워크플로우

  1. [Client] SYN 전송
  2. [Server] SYN 수신 후, 연결 정보를 SYN Queue에 저장 (SYN_RCVD 상태)
  3. [Server] SYN+ACK 전송
  4. [Client] SYN+ACK 수신 후, ACK 전송
  5. [Server] ACK 수신 (Handshake 완료). 커널은 연결 정보를 SYN Queue에서 Accept Queue로 이동 (ESTABLISHED 상태)
  6. [Application] accept() 시스템 콜 호출
  7. [Kernel] Accept Queue의 맨 앞 연결을 애플리케이션에 전달

틀린 가설 다시보기

Accept Queue 앞단에서 일어나는 일을 이해한 다음, 실험 결과를 다시 보자

 

Accept Queue 가 가득 찬 상태에서 요청이 서버가 요청을 즉시 거절한다.

→ Accept Queue 가 가득 차면 요청은 SYN Queue에서 대기한다.

acceptCount = 1, 10개 요청, 7초 지연인 경우

  1. 1번째 요청: 즉시 처리 시작 (7초 소요)
  2. 2번째 요청: Handshake 완료 → Accept Queue에서 7초 대기 → 처리
  3. 3~10번째 요청: SYN Queue에서 대기
    • 첫 번째 요청이 끝나면 → Accept Queue 자리 생김
    • 하지만 7초는 너무 김 → 클라이언트가 타임아웃으로 포기

 

ServerSocket이 accept() 해야만 클라이언트 측 new Socket()이 반환된다.

-> 클라이언트의 Socket 객체는 서버의 accept() 호출 전에, 3-way Handshake 완료 시점에 생성된다.

 

accepCount + 1개 만큼은, 3-way Handshake 과정을 완료한 후 Accept Queue로 이동되기 때문에 7초를 기다리지 않고 바로 ✅ 연결 성공 로깅이 출력된다.

마무리

Java는 추상화를 통해 사용을 용이하게 하지만, 그만큼 OS 레벨의 동작 원리를 알기가 어려운 것 같다.

단순해 보이는 acceptCount 설정을 파고 들어가다보니 복잡한 TCP 연결 관리 매커니즘이 있었다.

 

사실 빨리 끝내려면 빨리 끝낼 수 있는 미션이었는데, 이것저것 실험하다가 하루를 꼬박 새웠다… 😇 그리고 사실 아직 완벽히 의문을 해결하지는 못했다. 실패한 요청은 Handshake의 어떤 단계에서 실패했는지와 같은 것들은 Wireshark를 이용해서 확인해야지 확실할 것 같다.

 

비록 많은 시간이 걸리고, 완벽한 결과를 얻지도 못했지만 acceptCount가 무엇인지에 대한 직관적인 이해력을 기를 수 있었다. 또한 Three-way Handshake를 개념적으로만 알고 있었는데 코드와 함께 연결지어 이해할 수 있게 됐다.

 

이번에는 삽질을 많이 했지만, 이런 경험들이 쌓이면 더 능숙한 삽질을 할 수 있게 되지 않을까~?!

'개발 > Java & Spring' 카테고리의 다른 글

DispatcherServlet의 service() 메서드는 어디에?  (0) 2025.09.29
[@MVC 구현하기] Spring MVC의 HandlerMapping 등록 원리 파헤치기  (4) 2025.09.22
Fixture와 Builder 패턴으로 테스트 코드 가독성 높이기  (4) 2025.07.25
'개발/Java & Spring' 카테고리의 다른 글
  • DispatcherServlet의 service() 메서드는 어디에?
  • [@MVC 구현하기] Spring MVC의 HandlerMapping 등록 원리 파헤치기
  • Fixture와 Builder 패턴으로 테스트 코드 가독성 높이기
yesjuhee
yesjuhee
Dopamine Driven Developer
  • yesjuhee
    나랑 노랑
    yesjuhee
  • 전체
    오늘
    어제
    • 분류 전체보기 (29)
      • 개발 (11)
        • DevOps (2)
        • Java & Spring (4)
        • AI (1)
        • DB (1)
        • 기타 (3)
      • 후기 or 회고 (15)
        • 우아한테크코스 (11)
        • 기타 (4)
      • 독서 (2)
      • 기타 (1)
      • 초록 스터디 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    claude code
    소프티어 부트캠프
    초록 밋업
    바킹독
    우테코
    SCG
    독서
    레벨2
    spring
    QueryDSL
    후기
    레벨4
    모아온
    Ai
    coderabbit
    우아콘
    DispatcherServlet
    mysql
    초록 스터디
    레벨3
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
yesjuhee
Tomcat 구현하기 삽질기 (feat.acceptCount)
상단으로

티스토리툴바