배포 직후 첫 로그인 1500ms → 320ms, 첫 요청 지연 잡기

희운·2026년 4월 23일

기존 미팀 프로젝트에서는 미팀 자체 로그인과 소셜 로그인을 사용했지만, 대학생 중심으로 페르소나를 좁히기 위해 세종대학교 포털 로그인을 통해 학생 인증을 하기로 했다. 사용자가 미팀을 통해 로그인하면 학교 API에 요청을 보내 재학생인지를 검증한다. 이렇게 학교 API로 요청을 보낼 때 응답 시간을 측정해 보고, 그중 TCP 커넥션을 맺는 데 드는 시간이 전체 응답 시간에서 얼마나 차지하는지 알고 싶었다.

학교 API 응답시간 분석

운영 서버에서 학교 포털 서버로 curl 명령어를 통해 응답 시간을 분석해 보았다.

{
  "DNS 조회 시간": 0.001779s,
  "TCP 연결 시간": 0.012955s,
  "SSL/TLS 핸드쉐이크": 0.030608s,
  "첫 데이터 수신(TTFB)": 0.039398s,
  "-----------------": "-----------------",
  "전체 소요 시간": 0.039451s
}

위 결과는 누적 시간이므로, 이전 단계까지 걸린 시간을 빼서 각 구간의 순수 소요 시간을 구하고, 전체 소요 시간에서 차지하는 비율을 계산해 보았다.

분석 구간누적 시간 (Cumulative)순수 소요 시간 (Duration)비중 (%)비고
DNS 조회0.0017s1.7ms4.3%도메인 IP 변환
순수 TCP 연결0.0129s11.2ms28.4%3-way Handshake
순수 SSL 핸드쉐이크0.0306s17.7ms44.9%보안 터널 구축 (가장 무거운 작업)
순수 서버 처리 (TTFB)0.0393s8.7ms22.1%API 로직 및 DB 조회
전체 합계0.0394s39.4ms100%-

전체 시간 39.4ms 중 데이터를 주고받기 위한 준비 단계(DNS + TCP + SSL)에만 30.6ms가 소요되었다. 즉, 전체 요청 시간의 약 77%가 네트워크 연결을 맺는 데 쓰이고 있다. 실제 서버가 비즈니스 로직을 처리하는 시간(8.7ms)보다 연결을 만드는 시간이 약 3.5배 더 길다는 것을 알 수 있다.

물론 순수 서버 처리 시간(TTFB)에는 실제 데이터를 주고받는 네트워크 시간이 일부 포함되어 있다. 하지만 그 부분을 감안하더라도, 대부분의 시간이 커넥션을 맺는 데 쓰이고 있다는 사실은 분명했다.

학교 서버는 커넥션을 유지할까?

HTTP/1.1부터는 지속적인 연결(persistent connection)을 지원한다. Connection: keep-alive 헤더를 통해 커넥션을 재사용할 수 있다는 것을 알 수 있다. 그런데 HTTP/1.1부터는 keep-alive가 기본값이라 헤더를 생략해도 된다고 하는데, 왜 학교 서버는 굳이 헤더에 명시적으로 넣어줄까? 찾아보니 서버 앞단의 보안 장비나 로드밸런서에서 이 헤더를 명시적으로 붙여주는 경우가 있다고 한다.

curl -i -s -A "Mozilla/5.0" \
-e "https://portal.sej****.ac.kr" \
--data-urlencode "mainLogin=N" \
--data-urlencode "id=*******" \
--data-urlencode "password=*********" \
"https://***********************" | head -n 25
HTTP/1.1 200 OK
Server: Apache
Date: Mon, 27 Apr 2026 10:27:11 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 2060
Connection: keep-alive
X-ORACLE-DMS-ECID: aa7c22e1-262d-48bd-abe4-24bf5a67be31-0097724e
X-ORACLE-DMS-RID: 0

응답 헤더를 보면 학교 서버가 커넥션을 유지한다는 것은 알 수 있다. 하지만 몇 초 동안 유지하는지는 알 수 없다. 만약 커넥션을 1초만 유지한다고 가정하면, 그 짧은 시간 안에 다음 요청이 들어와 커넥션을 재사용할 일은 거의 없을 것이다. 트래픽이 아주 많다면 1초 동안에도 많은 요청이 커넥션을 재사용하겠지만, 현재 미팀 서비스의 트래픽 규모에서는 사실상 무의미한 유지 시간이 된다.

Keep-Alive: timeout=5, max=100처럼 헤더에 timeout과 max 요청 수를 명시적으로 알려주는 경우도 있지만, 학교 서버는 이를 생략했거나 의도적으로 공개하지 않은 것으로 보인다. 결국 미팀 서버에서 의미 있는 성능 개선을 하려면, 학교 서버가 커넥션을 얼마나 유지하는지를 먼저 알아야 커넥션을 안정적으로 재사용할 수 있다고 판단했다.

학교 서버의 timeout 측정하기

학교 서버와의 커넥션 유지 시간을 직접 측정해 보기로 했다. 터미널 1에서는 openssl 명령어로 443 포트에 연결을 맺어 유지하고, 터미널 2에서는 while 스크립트로 1초마다 그 연결이 살아있는지 확인했다.

while true; do
  # openssl이 443 포트로 연결을 맺고 있는지 확인
  STATUS=$(lsof -i :443 | grep openssl)

  if [ -z "$STATUS" ]; then
    echo "---------------------------------------"
    echo "X [$(date +%T)] 커넥션이 끊겼습니다. (실험 종료)"
    echo "---------------------------------------"
    break
  fi

  echo "O [$(date +%T)] 연결 유지 중..."
  sleep 1
done
O [19:50:33] 연결 유지 중...
O [19:50:34] 연결 유지 중...
O [19:50:35] 연결 유지 중...
O [19:50:36] 연결 유지 중...
O [19:50:38] 연결 유지 중...
--- 생략
O [19:55:49] 연결 유지 중...
O [19:55:50] 연결 유지 중...
---------------------------------------
X [19:55:51] 커넥션이 끊겼습니다. (실험 종료)
---------------------------------------

대략 5분 20초(320초) 동안 커넥션이 유지되는 것을 확인할 수 있었다. 예상보다 학교 서버는 꽤 오랜 시간 동안 커넥션을 유지하도록 설정되어 있었다. 재사용 가능한 시간이 충분히 길기 때문에, 학교 서버로 요청이 많이 몰리더라도 커넥션을 재사용해 연결 비용을 줄일 수 있겠다고 판단했다.

짧은 시간 안에 요청이 많을 경우

그렇다면 짧은 시간 안에 로그인 요청이 한꺼번에 몰리면 어떻게 될까? 요청 속도 > 커넥션 반환 속도인 상황이라면, 앞선 요청이 커넥션을 반납하기 전에 다음 요청이 들어오므로 커넥션을 재사용할 수 없다.

이런 상황에서는 커넥션을 재사용하지 못하고 매번 새로운 커넥션을 만들어 요청을 보내게 된다. 물론 지금은 한 요청이 커넥션을 점유하는 시간이 짧기 때문에 이런 상황은 비교적 낮은 확률로 발생한다. 위 그림의 요청1, 요청2, 요청3처럼 동시 요청이 들어오면, 그 동시 요청 수만큼 외부 서버와 커넥션을 새로 맺게 된다.

이는 문제가 될 수 있다. 짧은 시간에 다량의 커넥션 생성과 요청이 발생하면 외부 API 서버 입장에서는 DDoS 공격처럼 보일 수 있고, 끊긴 커넥션이 TIME_WAIT 상태로 쌓이면서 포트 자원이 고갈되는 문제도 생길 수 있다. 따라서 외부 서버와 소통하는 커넥션을 잘 관리하는 것이 중요하다.

실제로 동시 요청이 발생했을 때 서버가 새로운 커넥션을 생성해 병렬로 처리하는지 확인하기 위해 실험을 진행했다. Apache Bench로 10명의 사용자가 동시에 로그인을 시도하는 상황을 시뮬레이션했고, netstat 명령어로 실시간 ESTABLISHED 상태의 TCP 커넥션 개수를 확인했다.

ab -n 200 -c 10 -k -p post_data.txt -T "application/json" http://localhost:8080/api/v1/auth/login/sejong & sleep 0.2; netstat -an | grep 8080 | grep ESTABLISHED
Benchmarking localhost (be patient)
tcp6       0      0  ::1.8080               ::1.53425              ESTABLISHED
tcp6       0      0  ::1.53425              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53424              ESTABLISHED
tcp6       0      0  ::1.53424              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53423              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53422              ESTABLISHED
tcp6       0      0  ::1.53423              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53422              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53421              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53420              ESTABLISHED
tcp6       0      0  ::1.53421              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53420              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53419              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53418              ESTABLISHED
tcp6       0      0  ::1.53419              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53418              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53417              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53416              ESTABLISHED
tcp6       0      0  ::1.53417              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53416              ::1.8080               ESTABLISHED

예상대로 동시 요청을 보낼 경우 새로운 커넥션을 생성해 학교 로그인 API로 요청을 보낸다. 이렇게 동시 요청이 많으면 학교 서버 쪽에서 DDoS 공격으로 인지할 수 있기 때문에, 학교 서버와 맺는 커넥션 수를 제한하고 재사용하는 편이 좋겠다고 판단했다.

커넥션 풀을 사용하자

학교 서버는 대략 320초 동안 커넥션을 유지한다는 것을 확인했다. 그렇다면 클라이언트(미팀 서버)에서 320초 이상 커넥션을 붙들고 있으면 어떻게 될까? 이미 서버 쪽에서 끊어버린 커넥션을 들고 요청을 보내봤자 오류가 발생할 것이다. 즉, 클라이언트만 연결이 살아있다고 착각한 채로 요청을 보내고, 결국 사용자에게 오류 응답을 내려주게 된다. 게다가 앞선 그림처럼 커넥션 풀 없이 동시 요청이 들어오면 keep-alive의 이점을 전혀 누릴 수 없다.

커넥션을 효율적으로 유지하기 위해 두 가지 방법을 생각해 보았다.

  1. keep-alive가 끝나기 전(약 320초)에 주기적으로 API를 재요청해서 커넥션을 살려두기
  2. 커넥션 풀을 미리 만들어 두고, 320초가 되기 전에 유휴 커넥션을 정리하며 관리하기

1번은 주기적으로 요청을 보내 커넥션을 계속 살려두는 방식인데, 처음에는 이 방법을 시도하려 했다. 하지만 학교 서버의 keep-alive max 값(커넥션당 허용 요청 수)을 알 수 없기 때문에, 계속 요청을 보내다 보면 허용 요청 수를 초과해 오히려 연결이 끊어질 수 있다. 게다가 320초가 되기 전마다 빠짐없이 요청을 보내야 한다는 부담도 있었다.

학교 서버는 keep-alive 헤더에 timeout과 max 값을 함께 내려주지 않기 때문에 max 값은 알 수 없고, timeout은 직접 실험했을 때 약 320초로 측정되었다. 이 값을 기준으로 커넥션 풀 파라미터를 설정하기로 했다.

HikariCP에서 idleTimeout은 유휴 커넥션이 사용되지 않을 때 풀에서 제거하기까지의 시간을 뜻한다. 같은 맥락으로, 외부 HTTP 통신에 사용하는 Apache HttpClient에서는 evictIdleConnections()로 유휴 커넥션의 타임아웃을 지정할 수 있다. 현재 학교 서버의 keep-alive timeout이 약 320초로 측정되었으므로, 그보다 짧은 시간 안에 미팀 서버(클라이언트)가 먼저 커넥션을 끊어줘야 죽은 커넥션으로 요청을 보내는 상황을 막을 수 있다.

또한 DDoS로 오인되는 상황을 방지하기 위해 최대 커넥션 개수를 5개로 제한했다. 학교 서버로의 로그인 요청 평균 응답 시간이 약 200ms로 측정되었으므로, 커넥션 1개당 초당 약 5건(1000 / 200)을 처리할 수 있다. 따라서 5개의 커넥션이면 초당 약 5 × 5 = 25건의 로그인 요청을 감당할 수 있고, 현재 미팀 서비스의 규모에서는 이 정도면 충분하다고 판단했다.

배포 직후 학교 서버 API 요청 시

커넥션 풀을 사용하더라도 기본적으로 keep-alive로 커넥션이 유지되고 있는 상태라면, 평상시 성능에는 큰 변화가 없을 것이다. 그래서 변화가 두드러질 만한 배포 직후 상황을 들여다보기로 했다. 배포 직후 학교 API로 2번씩, 총 10번 요청을 보내 보았다. 그런데 첫 번째 요청이 두 번째 요청보다 약 6배 이상 느린 상황을 확인할 수 있었다.

위 그래프에서 볼 수 있듯이, 첫 요청은 약 1500ms, 두 번째 요청은 약 250ms가 소요되었다.

첫 요청 시에는 학교 서버와 아직 커넥션을 맺지 않은 상태이므로, 두 번째 요청과 달리 TCP 연결 + SSL 핸드셰이크 비용이 전체 응답 시간에 포함된다. 여기까지는 자연스럽다. 하지만 그 비용이 두 요청의 차이인 1500 - 250 = 1250ms나 된다는 점이 이상했다. 커넥션 수립에만 1초가 넘게 걸린다는 건 상식적으로 너무 컸다.

그래서 미팀 서버(클라이언트)에서 학교 서버로 curl 명령어를 보내, TCP 연결 + SSL 핸드셰이크에 실제로 걸리는 지연 시간을 직접 측정해 보았다.

  • 첫 요청
DNS: 0.083265s
TCP: 0.091098s
SSL: 0.119399s
Total: 0.127828s
  • 두 번째 요청
DNS: 0.002056s
TCP: 0.018932s
SSL: 0.046125s
Total: 0.054546s

물론 그래프는 내 로컬 → 미팀 서버 → 학교 서버로 요청한 상황이고, curl은 미팀 서버 내부에서 학교 서버로 직접 보낸 것이라 경로가 다르다. 즉 내 로컬과 미팀 서버 사이의 네트워크 비용은 빠져 있다. 하지만 그 차이를 감안하더라도, 커넥션 수립과 SSL 핸드셰이크에 1250ms가 걸렸을 가능성은 0에 가깝다. 첫 요청의 커넥션 비용은 길어야 100~200ms 수준이었다.

그렇다면 나머지 시간은 어디로 갔을까? 적어도 커넥션 수립 비용만큼은, 배포 직후에 미리 커넥션을 만들어 두면 첫 요청에서 조금이나마 줄일 수 있을 것이다.

커넥션 풀 워밍업 해보기

직접 눈으로 확인하고 싶어, 배포 시점에 ApplicationRunner를 통해 학교 API로 요청을 보내 커넥션을 미리 만들어 두었다.

@Slf4j
@Component
@RequiredArgsConstructor
public class WarmUpRunner implements ApplicationRunner {

    private final SejongPortalClient sejongPortalClient;

    @Value("${app.warmup.enabled:true}")
    private boolean warmupEnabled;

    @Override
    public void run(ApplicationArguments args) {
        if (!warmupEnabled) {
            log.info("=== Warm-up 비활성화됨 (app.warmup.enabled=false) ===");
            return;
        }

        log.info("=== 애플리케이션 Warm-up 시작 ===");
        long startTime = System.currentTimeMillis();

        try {
            // 세종대 포털 커넥션 풀 워밍업
            sejongPortalClient.warmUp();
            log.info("세종대 포털 커넥션 풀 Warm-up 완료");
        } catch (Exception e) {
            log.warn("세종대 포털 Warm-up 실패 (서비스는 정상 동작): {}", e.getMessage());
        }

        long elapsed = System.currentTimeMillis() - startTime;
        log.info("=== 애플리케이션 Warm-up 완료 ({}ms) ===", elapsed);
    }
}

커넥션을 미리 만들어 둔 뒤, 앞과 같은 조건으로(2번씩 총 10번) 학교 서버에 요청을 보내 보았다.

HTTP 커넥션을 미리 생성해 두자 첫 요청이 눈에 띄게 줄었다. 기존에는 첫 요청이 두 번째 요청보다 약 6배 이상 느렸지만, 약 4.5배로 단축되었다.

분명 개선이긴 하지만, 여전히 첫 요청은 두 번째 요청보다 4.5배나 느리다. 커넥션 비용을 미리 지불했는데도 격차가 이만큼 남아 있다는 건, 첫 요청을 무겁게 만드는 진짜 원인이 네트워크가 아니라 다른 곳에 있다는 뜻이었다.

배포 이후 사용되는 클래스 Warm-up

남은 비용은 네트워크가 아니라 애플리케이션 내부, 정확히는 스프링 초기화와 JVM에 있었다.

JVM은 애플리케이션이 실행되는 도중에 필요한 클래스를 그때그때 로딩한다(lazy class loading). 또한 자주 실행되는 코드를 JIT 컴파일러가 기계어로 최적화하기 전까지는 바이트코드를 인터프리터 방식으로 한 줄씩 느리게 실행한다. 즉, 처음 실행되는 코드 경로는 항상 가장 느리다.

로그인 요청 하나가 처리되는 과정을 떠올려 보면, 컨트롤러 → 서비스 → HTTP 클라이언트로 이어지는 동안 수많은 클래스가 처음으로 로딩되고 초기화된다. 요청 바디를 직렬화·역직렬화하는 Jackson, 검증 로직, 트랜잭션/AOP 프록시 객체 생성, HttpClient 내부 클래스들까지. 첫 요청은 커넥션 비용뿐 아니라 이 모든 "처음" 비용을 혼자서 떠안는다. 커넥션 풀만 미리 데웠을 때 첫 요청이 여전히 4.5배나 느렸던 이유가 바로 여기에 있었다. 네트워크 지연을 걷어내고 보니, 남은 실질적인 원인은 첫 요청 시점에 발생하는 스프링 내부 초기화 + JVM 클래스 로딩 오버헤드였다.

그래서 워밍업의 범위를 넓혔다. 단순히 커넥션만 미리 맺는 것이 아니라, 배포 시점에 실제 로그인 API 로직을 모의로 한 번 호출하도록 했다. 이렇게 하면 커넥션 풀이 채워지는 동시에, 로그인 경로에서 사용되는 클래스들이 미리 로딩되고 JIT가 핫스팟을 데워두게 된다. 사용자가 처음 로그인할 때 떠안던 "처음" 비용을, 사용자가 도착하기 전에 서버 스스로 미리 치르게 한 것이다.

결과는 명확했다. 실제 로그인 로직까지 워밍업하자 첫 요청 응답 시간이 약 1500ms → 320ms로 단축되었다. 두 번째 요청(약 250ms)과의 격차도 기존 약 6배에서 약 1.2배 수준까지 좁혀졌다. 배포 직후 첫 사용자가 마주하던 1초 이상의 지연이, 후속 요청과 거의 차이 없는 수준으로 사라진 것이다.

결국 첫 요청을 무겁게 만들던 비용은 네트워크 커넥션 비용 + 스프링 초기화·JVM 클래스 로딩 비용 두 갈래였고, 실제 로그인 로직을 모의 호출하는 한 번의 워밍업으로 두 가지를 모두 미리 데워둠으로써 사용자가 처음 마주하는 응답을 후속 요청 수준으로 끌어올릴 수 있었다.

마치며

이번 작업은 "학교 API 응답이 느린데, 그 시간이 정확히 어디서 새는 걸까?"라는 단순한 궁금증에서 시작했다. 그 궁금증을 따라가다 보니 다음과 같은 흐름으로 정리되었다.

  • 측정: 학교 API 응답 시간의 약 77%가 비즈니스 로직이 아니라 커넥션을 맺는 데(DNS + TCP + SSL) 쓰이고 있었다.
  • 확인: 학교 서버는 커넥션을 약 320초간 유지하므로, 재사용 가치가 충분했다.
  • 관리: 다만 동시 요청이 몰리면 커넥션이 우후죽순 생겨 DDoS로 오인되거나 TIME_WAIT이 쌓일 수 있어, 커넥션 풀로 최대 5개까지만 재사용하도록 제한하고 320초보다 짧게 유휴 커넥션을 정리하도록 설정했다.
  • 워밍업: 배포 직후 첫 요청이 후속 요청보다 약 6배 느린 문제를 파고든 결과, 원인이 네트워크 커넥션과 스프링 초기화·JVM 클래스 로딩 두 갈래임을 확인했다. 커넥션 풀만 데웠을 땐 여전히 4.5배가 남았지만, 배포 시점에 실제 로그인 API를 모의 호출해 커넥션과 클래스를 함께 워밍업하자 첫 요청이 1500ms → 320ms로 줄며 후속 요청과 약 1.2배 차이까지 좁혀졌다.

돌아보면 가장 의미 있었던 건 성능 수치 자체보다, "느리다"를 "어느 구간이 몇 ms 느리다"로 쪼개어 본 과정이었다. 막연히 "커넥션이 비싸겠지"라고 짐작하고 끝냈다면, 첫 요청의 1250ms 격차가 사실은 커넥션이 아니라 스프링·JVM 워밍업 문제였다는 사실은 끝내 몰랐을 것이다. 직접 curl로 커넥션 비용을 재보고 "이 숫자로는 1250ms가 설명이 안 된다"고 의심한 덕분에 진짜 원인에 닿을 수 있었다.

물론 현재 미팀의 트래픽 규모에서 이 차이가 체감될 일은 많지 않다. 하지만 외부 의존성과 통신하는 구간을 측정하고, 의심하고, 근거를 가지고 파라미터를 정하는 이 습관 자체가, 트래픽이 커졌을 때 흔들리지 않는 서비스를 만드는 밑바탕이 된다고 생각한다.

profile
기록하는 공간

0개의 댓글