사이드프로젝트 이야기입니다. 받은 제보 하나에서 시작합니다.
"인증하려고 하면 가끔 오류가 떠요. 다시 누르면 되긴 하는데.."
처음 들었을 때는 솔직히 '트래픽 튀는 순간에 흔히 있는 일이겠거니' 하고 넘어갈 뻔했습니다. 그런데 패턴을 보니 아무 때나 나는 게 아니었습니다. 저녁 인증 마감 시간대, 그러니까 여러 명이 한꺼번에 사진을 올리는 순간에만 났습니다. 혼자 테스트할 때는 단 한 번도 재현이 안 됐고요.
처음 의심한 건 스레드 부족이었습니다
'동시에 몰리면 에러난다'는 문장만 보면 제일 먼저 떠오르는 게 스레드 풀 고갈이죠. 그런데 Spring Boot 스레드 기본풀이 200입니다. (스프링부트의 위력을 다시한번 체감합니다) 저희 이 서비스는 한 기수당 동시 참가자 25명, 실시간 동시 접속도 많아야 십수 명 수준입니다. 에러 메시지도 스레드 관련이 아니라 죄다 DB 커넥션을 못 받았다는 내용이었습니다.
HikariPool-1 - Connection is not available, request timed out after 30000ms
이상했습니다. Max동시 참가자 25명에 DB 기본 커넥션 풀이 10개인데, 이게 부족할 규모가 아니거든요. 그런데 왜 커넥션이 바닥났을까요.
진짜 범인 — 트랜잭션이 API 응답을 기다리는 동안 커넥션을 놓지 않고 있었다
레오집사가 하루 피드백을 만들어주는 메서드를 보니 이랬습니다.
@Transactional
public AiFeedbackResult generateFeedback(Long userId, LocalDate date) {
// 1. DB에서 미션 진행률 조회
// 2. OpenAI 호출 (aiFeedbackClient.generate) ← 여기서 몇 초씩 걸림
// 3. 결과를 DB에 저장
}
메서드 전체에 @Transactional이 걸려 있었습니다. 문제는 이 메서드 중간에 OpenAI API 호출이 껴 있다는 것이었습니다. @Transactional은 메서드가 끝날 때까지 DB 커넥션 하나를 계속 붙잡고 있습니다. 즉 OpenAI가 응답을 주는 그 몇 초 동안, 아무 일도 안 하면서 커넥션 하나가 그냥 묶여 있었던 겁니다.
혼자 쓸 때는 문제가 안 됩니다. 그런데 마감 시간대에 여러 명이 동시에 이 엔드포인트를 부르면, 각자 몇 초씩 커넥션을 붙잡는 요청이 겹치면서 풀 10개가 순식간에 바닥납니다. 그리고 더 안 좋은 건, 이렇게 되면 피드백 요청이랑 전혀 무관한 요청—로그인, 기록 저장 같은—까지 커넥션을 못 받아서 같이 실패한다는 점이었습니다. 사용자 입장에서는 "그냥 아무거나 누르면 가끔 오류난다"로 보일 수밖에 없었죠.
고치는 방향은 명확했습니다. @Transactional을 걷어내는 겁니다.
// 의도적으로 @Transactional을 걸지 않는다.
// DB에 실제로 닿는 호출들(조회, 저장)은 Spring Data JPA 리포지토리 메서드가
// 이미 자기 자신의 짧은 트랜잭션을 가지고 있다. 조회들은 서로 독립적이고,
// 마지막 save 한 번이면 충분해서 메서드 전체를 하나로 묶을 이유가 없다.
public AiFeedbackResult generateFeedback(Long userId, LocalDate date) {
...
}
"트랜잭션은 일단 걸어두면 안전하다"고 막연히 생각하고 있었는데, 이 경우엔 정반대였습니다. 굳이 필요 없는 원자성을 지키려다가 외부 API 응답 시간만큼 커넥션을 낭비하고 있었던 셈입니다.
DB작업은 이미 끝났는데도, OPEN AI 답 기다리는동안 하나를 계속 예약해두고 있었어요
before
트랜잭션 시작
↓
DB 커넥션 확보
↓
DB 조회
↓
OpenAI API 호출 ← 5초 걸림
↑
이 동안에도 커넥션을
계속 잡고 있을 수 있음
↓
DB 저장
↓
트랜잭션 종료
↓
커넥션 반환
after 개선입니다
DB 조회
↓
커넥션 반환
OpenAI 호출
↓
몇 초 대기
(DB 커넥션 안 잡음)
DB 저장
↓
잠깐 커넥션 확보
↓
저장 후 바로 반환
근데 이걸로 끝이 아니었습니다
트랜잭션을 걷어내고 나니 커넥션 고갈은 없어졌는데, 여전히 남는 문제가 있었습니다. OpenAI 쪽 레이트리밋입니다.
동시에 여러 명이 인증 사진을 올리면, 우리 서버에서 OpenAI로 나가는 요청도 순간적으로 튑니다. 이러면 OpenAI가 429(Too Many Requests)나 일시적인 5xx를 돌려줄 수 있는데, 그때까지 코드는 이걸 그냥 그대로 사용자에게 에러로 흘려보내고 있었습니다. 재시도 로직이 아예 없었거든요.
덤으로 하나 더 있었습니다. 기본 JDK HttpClient는 타임아웃이 없습니다. OpenAI가 응답을 늦게 주거나 안 주면, 그 요청 스레드는 이론상 무한정 붙잡혀 있을 수 있는 상태였습니다.
그래서 뭘 고쳤나
1. 앱 안에 작은 대기열을 만들었습니다.
Kafka나 Redis 같은 별도 메시지 큐를 놓을 규모는 아니라고 판단했습니다. 애플리케이션 안에 bounded semaphore 하나만 둬도 순간적으로 몰리는 요청을 흡수하기엔 충분했습니다.
@Component
public class OpenAiCallLimiter {
private static final int MAX_CONCURRENT_CALLS = 6;
private static final long MAX_WAIT_SECONDS = 25;
private static final int MAX_ATTEMPTS = 3;
// fair=true: 먼저 기다린 요청부터 순서대로 permit을 받는다
private final Semaphore semaphore = new Semaphore(MAX_CONCURRENT_CALLS, true);
public <T> T call(Supplier<T> openAiCall) {
boolean acquired = semaphore.tryAcquire(MAX_WAIT_SECONDS, TimeUnit.SECONDS);
if (!acquired) {
throw new OpenAiOverloadedException(); // 25초 넘게 기다려야 하면 진짜 과부하
}
try {
return callWithRetry(openAiCall);
} finally {
semaphore.release();
}
}
private <T> T callWithRetry(Supplier<T> openAiCall) {
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
return openAiCall.get();
} catch (HttpClientErrorException.TooManyRequests | HttpServerErrorException e) {
if (attempt < MAX_ATTEMPTS) sleepBackoff(attempt); // 0.5s, 1s, 2s + 지터
}
}
throw lastError;
}
}
동시에 6개까지만 OpenAI로 내보내고, 나머지는 순서대로 기다립니다. 429나 5xx를 만나면 조용히 재시도하는데, 이때 재시도 간격에 무작위 지터를 섞었습니다. 여러 요청이 동시에 429를 맞았을 때 재시도가 또 같은 타이밍에 몰리는 걸 막기 위해서입니다. 그래도 25초 안에 순서가 안 오면, 무한정 기다리게 두지 않고 명확한 에러로 실패시킵니다.
*참고: Open AI에서 보통 일정 시간 동안의 요청 수, 토큰 사용량 등 여러 제한에 의해 발생할 수 있음
예를 들어 동시에 20명이 인증하면:
처음:
1~6번 → OpenAI 호출 중
7~20번 → 대기
조금 뒤:
2번 완료 → 7번 시작
5번 완료 → 8번 시작
1번 완료 → 9번 시작
...
이쯤에서 세마포어를 복습해봅니다..
저도 대학교 3학년 OS 수업 이후로 까맣게 잊고 있던 개념이라 짧게 정리하고 넘어갑니다.
간단하게 말하면 세마포어는 "동시에 몇 명까지만 들어갈 수 있는 방의 열쇠 뭉치"입니다. 방에 열쇠가 6개 있다고 치면, 들어가려는 사람은 열쇠를 하나 집어야 하고, 열쇠가 다 나가고 없으면 밖에서 기다립니다. 안에 있던 사람이 나오면서 열쇠를 반납하면, 기다리던 사람 중 하나가 그 열쇠로 들어갑니다. 열쇠 개수가 곧 "동시에 몇 개까지 허용할지"를 정하는 숫자예요.
뮤텍스랑 묶어서 배우는데, 차이는 열쇠 개수 하나입니다.
- 뮤텍스 — 열쇠 1개. 한 번에 딱 하나만 허용 (상호배제).
- 세마포어 — 열쇠 N개. 한 번에 최대 N개까지 허용 (카운팅).
위 OpenAiCallLimiter가 쓴 게 정확히 이 카운팅 세마포어입니다.
private final Semaphore semaphore = new Semaphore(6, true);
열쇠가 6개 있는 겁니다. OpenAI 호출이 들어올 때마다 열쇠를 하나 집으려고 시도하고, 이미 6개가 다 나가있으면 반납될 때까지 기다립니다. 그래서 아무리 여러 명이 동시에 인증 버튼을 눌러도, 실제로 OpenAI에 나가는 요청은 항상 6개 이하로 유지됩니다. 두 번째 인자 true는 "fair 모드"로, 먼저 줄 선 요청이 먼저 열쇠를 받게 합니다. 꺼두면 운 나쁜 요청이 계속 순서를 뺏겨서 영영 못 들어갈 수도 있어서 켜뒀습니다.
학교에서는 스레드끼리 공유 버퍼나 카운터 같은 자원에 접근하는 걸 조절하는 용도로 배웠는데, 여기서는 그 개념을 그대로 가져와서 "우리 서버가 OpenAI를 동시에 너무 세게 두드리지 않도록 스스로 조절하는 문지기"로 쓴 셈입니다.
2. 그 에러를 사용자에게 제대로 보여주게 했습니다.
@ExceptionHandler(OpenAiCallLimiter.OpenAiOverloadedException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public ApiErrorResponse onOpenAiOverloaded(OpenAiCallLimiter.OpenAiOverloadedException ex) {
return new ApiErrorResponse("OPENAI_OVERLOADED", ex.getMessage());
}
진짜 과부하 상황이라면, 500으로 뭉뚱그리는 대신 503 + "지금 인증 요청이 많이 몰려 있어요. 잠시 후 다시 시도해주세요" 같은 명확한 안내가 뜨도록 했습니다. 대기열이 있어도 못 막는 경우는 어차피 있을 수 있으니, 그럴 땐 사용자가 원인을 알 수 있게 하는 게 다음으로 중요하다고 봤습니다.
3. 타임아웃을 명시했습니다.
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build());
requestFactory.setReadTimeout(Duration.ofSeconds(45)); // Vision: 이미지 업로드 + 추론이라 넉넉하게
Vision 호출(이미지 판독)은 read timeout 45초, 텍스트만 다루는 피드백 호출은 20초로 다르게 잡았습니다. 두 호출의 성격이 다른데 하나로 묶어서 볼 이유가 없었거든요.
4. DB 커넥션 풀도 약간 손봤습니다.
spring.datasource.hikari.maximum-pool-size=${DB_POOL_SIZE:20}
spring.datasource.hikari.connection-timeout=${DB_POOL_CONNECTION_TIMEOUT_MS:10000}
근본 원인(트랜잭션이 커넥션을 오래 붙잡는 것)은 이미 고쳤지만, 이 서비스 규모에 맞게 풀 크기에 여유를 좀 더 두고(10 → 20), 커넥션을 못 받았을 때도 기본값인 30초 대신 10초 만에 빨리 실패하도록 명시했습니다. 안전판을 하나 더 깔아둔 셈입니다.
여기서 남은 것
동시성 버그가 무서운 이유는, 코드를 한 줄 한 줄 읽으면 전부 맞는 코드처럼 보인다는 것이었습니다. 조회하고, API 부르고, 저장하고 — 순서도 맞고 로직도 맞습니다. 문제는 그 전체가 트랜잭션 하나로 묶여 있었다는, 코드만 봐서는 잘 안 보이는 사실 하나였습니다. 그리고 이건 사람이 한 명씩 테스트할 때는 절대 재현되지 않습니다. 여러 명이 같은 순간에 부딪혀야만 드러나거든요.
이번에 정리하고 나니 남은 원칙은 세 가지였습니다.
- 외부 API 호출은 트랜잭션 밖으로. 트랜잭션은 "이 구간이 원자적이어야 한다"는 뜻이지 "안전하게 감싸두면 좋은 것"이 아닙니다. 몇 초짜리 블로킹 호출을 트랜잭션 안에 넣는 순간, 그 몇 초는 DB 커넥션이라는 공유 자원을 잠그는 시간이 됩니다.
- 타임아웃 없는 HTTP 클라이언트는 없는 것과 같습니다. 기본값을 안 정하면 "무한대"가 기본값이 됩니다.
- 재시도에는 지터를 섞어야 합니다. 안 그러면 재시도끼리 또 부딪혀서 같은 문제를 반복합니다.
어떻게 마무리를할지 모르겠네요.. 그럼 건강하세요
'백엔드 엔지니어링 > Spring Boot' 카테고리의 다른 글
| [Java] 객체 생성은 어디서 해요? (6) | 2025.08.16 |
|---|---|
| [Spring Boot 기초]JVM이란? (6) | 2025.08.15 |
| [Java]Collection? (5) | 2025.08.15 |