기능 개발을 마치면 우리 팀은 PR을 올리고 꼼꼼하게 코드 리뷰를 주고받는다.
리뷰는 단순히 버그를 찾는 수준을 넘어, 더 좋은 구조와 유지보수하기 쉬운 설계 방향까지 함께 고민해 주는 문화가 자리 잡혀 있다. 잘한 부분은 왜 좋았는지 이야기해 주고, 개선할 부분은 어떤 점을 보완하면 더 좋은 구조가 될지 이유와 함께 제안해 준다.
이번 글도 그런 리뷰를 통해 배우고 개선한 내용을 정리한 기록이다.
처음에는 Claude API의 readTimeout 설정 하나만 수정하면 끝날 일이라고 생각했다.
하지만 리뷰를 따라 코드를 하나씩 살펴보면서,
- HTTP 타임아웃과 애플리케이션 타임아웃이 서로 어떤 영향을 주는지 이해하게 되었고,
- 재시도가 여러 계층에서 중복되고 있다는 구조적인 문제를 발견했으며,
- 결국
ClaudeClient의 책임과 인터페이스까지 다시 정리하게 되었다.
돌아보면 시작은 readTimeout 하나였지만, 끝은 클라이언트의 설계를 다시 바라보는 일이었다.
1. 시작은 readTimeout 리뷰였다.
콘텐츠 적합성 검증 파이프라인
이번 작업에서는 사용자가 등록한 콘텐츠가 개발 관련 글인지 자동으로 판별하는 콘텐츠 적합성 검증 파이프라인을 구현했다.
모든 콘텐츠를 곧바로 AI에게 보내는 것은 비용도 크고 응답 시간도 길기 때문에, 검증은 다음과 같은 3단계로 나누어 처리하도록 설계했다.
1차. 정적 가드레일
- 글자 수
- 비속어
- 프롬프트 인젝션 키워드
↓
2차. 화이트리스트
- 신뢰 가능한 도메인인지 확인
↓
3차. Claude AI 검증
- 개발 관련 콘텐츠인지 최종 판정
앞 단계에서 결과가 확정되면 다음 단계는 수행하지 않는다. 예를 들어 글자 수가 너무 적거나 비속어가 포함되어 있으면 AI를 호출하지 않고 바로 검증을 종료하고, 신뢰할 수 있는 화이트리스트 도메인이라면 Claude API를 호출하지 않고 즉시 통과시킨다.
이번 글에서 다루는 내용은 이 세 번째 단계인 Claude AI 검증을 구현하면서 발생한 이야기다.
Claude API는 외부 네트워크를 거치는 호출이라 응답이 수 초 이상 걸릴 수 있다. 그래서 사용자의 콘텐츠 등록 요청은 먼저 응답을 반환하고, AI 검증은 별도의 비동기 작업에서 수행하도록 구현했다.
비동기 작업은 Java의 CompletableFuture를 사용했다. CompletableFuture는 작업을 별도 스레드에서 실행하고, 필요할 때 결과를 기다릴 수 있는 비동기 API다.
return future.get(properties.callTimeoutSeconds(), TimeUnit.SECONDS);
여기서 future.get(5, TimeUnit.SECONDS)는 최대 5초 동안만 결과를 기다리고, 그 안에 작업이 끝나지 않으면 TimeoutException을 발생시킨다는 의미다.
Claude API 호출은 프로젝트에서 공통으로 사용하는 ClaudeClient를 통해 이루어진다. ClaudeClient는 Claude API 호출, 응답 파싱, 재시도 등의 공통 로직을 담당하는 컴포넌트다.
검증 관련 설정은 다음과 같았다.
content:
validation:
max-attempts: 2
retry-delay-ms: 1000
call-timeout-seconds: 5
첫 번째 리뷰
기능 구현을 마치고 PR을 올리자 팀원이 코드 리뷰를 해주었다.
그중 하나가 바로 HTTP 타임아웃에 관한 리뷰였다.
애플리케이션은 5초만 기다리지만, HTTP readTimeout은 30초로 설정되어 있습니다.
이 경우 애플리케이션은 이미 타임아웃으로 처리했더라도 실제 HTTP 요청은 최대 30초까지 계속 대기할 수 있습니다.
이후 재시도가 시작되면 하나의 검증 요청이 여러 스레드를 동시에 점유할 수 있습니다.
특히 claudeCallExecutor의 최대 스레드 수가 10개이고 AbortPolicy를 사용하고 있어, 요청이 몰리는 상황에서는 큐 포화와 작업 거절로 이어질 수 있을 것 같습니다.
검증 전용 RestClient를 분리해서 readTimeout을 애플리케이션 타임아웃보다 조금 짧게 맞추는 방향도 고려해 보면 좋을 것 같습니다.
여기서 처음 등장한 readTimeout은 HTTP 클라이언트가 서버의 응답을 최대 얼마 동안 기다릴지 정하는 시간이다.
처음에는 "애플리케이션 타임아웃은 5초인데 HTTP는 30초네. 값을 맞추면 되겠구나." 정도로만 생각했다.
하지만 이 리뷰를 이해하려고 코드를 하나씩 따라가 보니, 단순히 타임아웃 숫자를 맞추는 문제가 아니라 재시도 구조 자체에 더 큰 문제가 숨어 있다는 사실을 알게 되었다.
2. 첫 번째 리뷰를 이해하기
왜 HTTP 요청은 계속 살아 있었을까?
당시 AiValidationService에서는 Claude API 호출을 별도 스레드에서 실행한 뒤, 최대 5초 동안만 결과를 기다리도록 구현해 두었다.
try {
return future.get(properties.callTimeoutSeconds(), TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new CustomException(...);
}
여기서 future.get(5, TimeUnit.SECONDS)는 최대 5초 동안만 결과를 기다린다는 뜻이다.
나는 처음에 future.cancel(true)를 호출하면 실행 중이던 Claude API 요청도 바로 종료될 거라고 생각했다.
하지만 실제로는 그렇지 않았다.
future.cancel(true)는 작업을 강제로 종료하는 메서드가 아니다.
정확히는 실행 중인 스레드에
"이제 그만 멈춰 주세요."
라는 인터럽트(interrupt) 신호를 보내는 메서드다.
여기서 중요한 점은 인터럽트는 강제 종료가 아니라 종료를 요청하는 신호라는 것이다.
즉 스레드가 이 신호를 확인하고 스스로 작업을 중단해야 실제로 종료된다.
예를 들어 반복문을 실행하는 작업이라면 중간중간 인터럽트 여부를 확인한 뒤 바로 빠져나올 수 있다.
하지만 모든 작업이 이렇게 동작하는 것은 아니다.
HTTP 요청처럼 서버의 응답을 기다리며 소켓 읽기(read) 상태로 블로킹되어 있는 경우에는 인터럽트에 즉시 반응하지 않을 수도 있다.
즉 애플리케이션은
"5초가 지났으니 이 요청은 실패다."
라고 판단했더라도, 실제 HTTP 요청은 계속 살아서 서버의 응답을 기다리고 있을 수 있다.
그 요청이 언제 끝나는지는 애플리케이션 타임아웃이 아니라 HTTP 클라이언트의 readTimeout이 결정한다.
우리 프로젝트에서는 그 값이 30초였다.
결국 애플리케이션은 이미 포기했는데도 HTTP 요청은 최대 30초 동안 계속 살아 있는 상황이 만들어질 수 있었다.
그럼 왜 스레드가 여러 개가 되는 걸까?
문제는 여기서부터였다.
AiValidationService는 Claude API 호출이 실패하거나 AI 응답 스키마 검증에 실패하면 최대 2번까지 재시도하도록 구현되어 있었다.
그런데 첫 번째 HTTP 요청이 아직 살아 있는 동안 애플리케이션이 두 번째 시도를 시작하면 어떻게 될까?
1차 시도
└─ HTTP 요청 (30초 동안 응답 대기)
↓
애플리케이션은 5초 뒤 타임아웃
↓
2차 시도 시작
└─ 새로운 HTTP 요청
애플리케이션 입장에서는 첫 번째 요청을 포기했지만, 실제로는 첫 번째 HTTP 요청이 아직 살아 있기 때문에
- 첫 번째 요청을 처리하는 스레드
- 두 번째 요청을 처리하는 스레드
가 동시에 실행된다.
즉 하나의 검증 요청이 여러 개의 스레드를 동시에 점유하는 상황이 발생할 수 있는 것이다.
처음 리뷰에서 말한
"하나의 검증 요청이 여러 스레드를 동시에 점유할 수 있습니다."
라는 문장이 이제야 이해됐다.
왜 스레드가 부족해질 수 있다는 걸까?
검증 작업은 claudeCallExecutor라는 별도의 스레드 풀에서 실행되고 있었다.
스레드 풀(Thread Pool)은 동시에 실행할 스레드를 미리 만들어 두고 재사용하는 공간이다. 당시 최대 스레드 수는 10개였다.
또한 큐까지 모두 차면 AbortPolicy를 사용하도록 설정되어 있었다.
AbortPolicy도 처음에는 이름만 보고 무슨 의미인지 감이 오지 않았다.
간단히 말하면 "더 이상 처리할 자리가 없으면 새로운 작업은 받지 않고 바로 거절한다."는 정책이다.
즉 스레드 풀과 대기 큐가 모두 가득 찬 상태에서 새로운 요청이 들어오면 기다리게 하지 않고 즉시 예외를 발생시킨다.
평소에는 문제가 되지 않는다.
하지만 하나의 검증 요청이 재시도 때문에 스레드를 두 개씩 점유하기 시작하면 상황이 달라진다.
예를 들어 동시에 10개의 검증 요청이 들어왔는데, 각 요청이 스레드를 두 개씩 사용하게 되면 필요한 스레드는 최대 20개가 된다.
하지만 실제 사용할 수 있는 스레드는 10개뿐이다.
결국 스레드 풀이 금방 가득 차고, 이후 들어오는 요청은 실행조차 하지 못한 채 거절될 수 있다.
그제야 팀원이 왜
"readTimeout을 애플리케이션 타임아웃보다 조금 짧게 맞추는 것이 좋겠습니다."
라고 리뷰를 남겼는지 제대로 이해할 수 있었다.
3. 첫 번째 해결
검증 전용 RestClient 분리
팀원의 리뷰를 반영해 검증 전용 RestClient를 새로 만들었다.
기존 ClaudeClient는 채점과 콘텐츠 검증 모두 같은 HTTP 클라이언트를 사용하고 있었는데, 기본 readTimeout이 30초로 설정되어 있었다.
반면 콘텐츠 검증은 애플리케이션이 최대 5초까지만 기다리도록 설계되어 있었기 때문에, 검증 전용 RestClient를 따로 분리하고 readTimeout을 4초로 설정했다.
@Bean
public RestClient validationClaudeRestClient(...) {
HttpClient httpClient =
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
JdkClientHttpRequestFactory requestFactory =
new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofSeconds(4));
...
}
이제는 HTTP 클라이언트가 애플리케이션보다 먼저 타임아웃되도록 맞춰졌다.
즉 애플리케이션이 5초 뒤 타임아웃을 처리하기 전에 HTTP 요청이 먼저 종료되므로, 앞에서 살펴본 것처럼 오래 살아 있는 HTTP 요청이 스레드를 계속 점유하는 문제는 해결할 수 있게 되었다.
ClaudeClient도 검증 전용 메서드를 추가했다
ClaudeClient에도 검증 전용 메서드를 추가해 새로 만든 validationClaudeRestClient를 사용하도록 변경했다.
public ClaudeResponse generateValidationMessage(
String systemPrompt,
String userPrompt) {
return generateMessageInternal(
validationClaudeRestClient,
properties.getModel(),
systemPrompt,
userPrompt);
}
이렇게 하면 기존 채점 기능은 그대로 유지하면서, 콘텐츠 검증만 별도의 HTTP 타임아웃 정책을 적용할 수 있다.
이제 해결됐다고 생각했다
수정을 마친 뒤에는 "이제 문제는 해결됐다."고 생각했다.
HTTP 요청도 애플리케이션보다 먼저 종료되고,
future.get()이 타임아웃된 뒤에도 HTTP 요청이 계속 살아 있는 상황도 없어졌기 때문이다.
그런데 코드를 다시 푸시하자, 이번에는 CodeRabbit이 전혀 다른 관점의 리뷰를 남겼다.
검증 요청의 전체 deadline을 한 계층으로 모으세요.
현재는 서비스 레벨의 재시도와 ClaudeClient 내부 재시도가 함께 존재합니다.
이 구조에서는 5초 예산을 넘겨도 작업이 계속 누적될 수 있습니다.
처음에는 이해가 되지 않았다.
이미 readTimeout을 4초로 줄였는데, 도대체 무엇이 또 문제라는 걸까?
4. 재시도가 두 계층에 있었다
CodeRabbit은 무엇을 본 걸까?
CodeRabbit의 리뷰를 다시 읽어봤다.
이번에는 readTimeout 이야기가 아니었다.
리뷰에서 계속 강조하고 있는 건 "서비스 레벨 재시도"와 "ClaudeClient 내부 재시도"였다.
처음에는 무슨 말인지 잘 이해되지 않았다.
나는 검증 서비스에서만 재시도를 구현했다고 생각하고 있었기 때문이다.
그래서 검증 로직을 처음부터 다시 따라가기 시작했다.
서비스도 재시도하고 있었다
검증 서비스에서는 AI 호출이 실패하거나 응답 스키마 검증에 실패하면 최대 두 번까지 재시도하도록 구현되어 있었다.
content:
validation:
max-attempts: 2
retry-delay-ms: 1000
이 부분은 내가 직접 구현한 로직이라 잘 알고 있었다.
그래서 당연히 재시도는 여기서만 이루어진다고 생각했다.
그런데 generateValidationMessage()를 따라가 보니 예상하지 못한 메서드를 호출하고 있었다.
public ClaudeResponse generateValidationMessage(...) {
return generateMessageInternal(...);
}
ClaudeClient도 재시도하고 있었다
generateMessageInternal()을 열어보니 이미 재시도 로직이 들어 있었다.
private ClaudeResponse generateMessageInternal(...) {
try {
return executeGenerateMessage(...);
} catch (...) {
return retryCall(...);
}
}
429나 5xx 오류가 발생하면 500ms를 기다린 뒤 한번 더 호출하고,
네트워크 오류가 발생해도 다시 한 번 호출하도록 구현되어 있었다.
그제야 상황이 이해됐다.
나는 서비스에서 재시도를 구현하면서, 이미 재시도를 수행하는 ClaudeClient를 그대로 사용하고 있었던 것이다.
새로 만든 코드라고 생각했는데, 사실은 기존 컴포넌트의 책임을 제대로 이해하지 못한 채 그대로 가져다 쓰고 있었다.
그래서 "5초"가 아니었다
재시도가 두 계층에서 일어나고 있다는 사실을 알고 나니, 실제 최악의 실행 시간을 다시 계산해 봤다.
한 번의 서비스 재시도 안에서 ClaudeClient가 다시 한 번 재시도하므로, 한 번의 서비스 시도는 다음과 같이 동작한다.
1차 서비스 시도
└─ HTTP 호출 (최대 4초)
└─ 500ms 대기
└─ ClaudeClient 내부 재시도 (최대 4초)
= 최대 8.5초
그리고 서비스 자체도 최대 두 번까지 재시도하도록 구현되어 있었다.
1차 서비스 시도 : 최대 8.5초
1초 대기
2차 서비스 시도 : 최대 8.5초
최악의 경우를 모두 더하면
8.5초
+ 1초
+ 8.5초
= 최대 18초
이 된다.
그동안 나는 future.get(5, TimeUnit.SECONDS)를 사용하고 있었기 때문에,
"우리 서비스는 최대 5초만 기다린다."
고 생각하고 있었다.
하지만 실제 실행 시간은 전혀 달랐다.
future.get()의 타임아웃은 현재 시도가 5초를 넘겨 기다리지 않도록 하는 장치일 뿐이었다.
그 아래에서 재시도가 여러 계층에 걸쳐 일어나고 있었기 때문에, 전체 요청은 내가 생각했던 것보다 훨씬 오래 실행될 수 있었다.
그제야 CodeRabbit 리뷰의 의미가 완전히 이해됐다.
첫 번째 리뷰는
"readTimeout이 너무 길어서 HTTP 요청이 오래 살아 있을 수 있습니다."
라는 증상을 이야기하고 있었다.
반면 두 번째 리뷰는
"재시도가 여러 계층에 흩어져 있습니다."
라는 구조적인 원인을 이야기하고 있었다.
첫 번째 리뷰를 반영하면서 증상은 줄일 수 있었지만, 구조 자체는 그대로 남아 있었던 것이다.
그래서 이번에는 타임아웃 값을 조정하는 것이 아니라, 재시도 구조 자체를 바꾸기로 했다.
5. 그래서 구조를 바꾸기로 했다
재시도는 한 계층에서만 한다
이번에는 타임아웃 값을 조정하는 대신 구조 자체를 바꾸기로 했다.
원칙은 하나였다.
재시도는 한 계층에서만 수행한다.
콘텐츠 적합성 검증에서는 AI 응답이 단순히 HTTP 요청에 실패하는 경우만 재시도하는 것이 아니었다.
- Claude API 호출 실패
- AI 응답 스키마 검증 실패
- 점수 범위 등 응답 형식이 잘못된 경우
처럼 HTTP 레벨에서는 알 수 없는 도메인 판단도 함께 재시도 조건에 포함되어 있었다.
이런 판단은 ClaudeClient가 아니라 AiValidationService가 가장 잘 알고 있다.
그래서 재시도 책임을 서비스 레이어로 모으고, ClaudeClient는 HTTP 요청을 한 번만 수행하는 역할만 담당하도록 변경했다.
기존에는 검증 전용 메서드도 내부 재시도를 포함한 generateMessageInternal()을 호출하고 있었다.
public ClaudeResponse generateValidationMessage(...) {
return generateMessageInternal(...);
}
이를 실제 HTTP 요청만 수행하는 executeGenerateMessage()를 호출하도록 수정했다.
public ClaudeResponse generateValidationMessage(...) {
return executeGenerateMessage(
validationClaudeRestClient,
properties.getModel(),
systemPrompt,
userPrompt
);
}
이제 검증 파이프라인에서는 AiValidationService만 재시도를 담당하고, ClaudeClient는 요청을 한 번 수행한 결과만 반환한다.
전체 실행 시간도 단순해졌다
구조를 변경한 뒤에는 실행 흐름도 훨씬 단순해졌다.
1차 서비스 시도
└─ HTTP 호출 (최대 4초)
1초 대기
2차 서비스 시도
└─ HTTP 호출 (최대 4초)
최악의 경우도
4초
+ 1초
+ 4초
= 최대 9초
로 계산할 수 있게 되었다.
18초에서 9초로 줄어든 것도 의미 있는 개선이었지만,
내가 더 중요하게 느낀 것은 전체 실행 시간을 이제 한눈에 계산할 수 있게 되었다는 점이었다.
예전에는
- 서비스 재시도
- 클라이언트 재시도
- HTTP 타임아웃
이 서로 얽혀 있어서 실제 최대 실행 시간을 머릿속으로 계산하기가 쉽지 않았다.
하지만 지금은
서비스 재시도 횟수 × HTTP 호출 시간
만 보면 된다.
나중에 maxAttempts나 readTimeout 값을 변경하더라도 전체 동작을 예측하기가 훨씬 쉬워졌다.
꼭 서비스가 재시도를 담당해야 하는 것은 아니다
이번 수정을 하면서 한 가지 원칙을 세웠다.
재시도는 한 계층에서만 수행한다.
중요한 것은 반드시 서비스 레이어에서 재시도해야 한다는 것이 아니라, 하나의 요청에 대해 재시도 책임이 여러 계층에 흩어지지 않는 것이다.
콘텐츠 적합성 검증에서는 AI 응답 스키마 검증 실패처럼 HTTP 레벨에서는 알 수 없는 도메인 판단도 재시도 조건에 포함되어 있었다.
이런 판단은 ClaudeClient보다 AiValidationService가 더 잘 알고 있다.
그래서 검증 기능에서는 재시도 책임을 AiValidationService로 모으고, ClaudeClient는 HTTP 요청을 한 번 수행하는 역할만 담당하도록 변경했다.
이번 경험을 통해 느낀 것은, 재시도를 어디에서 할지는 구현보다 책임의 문제라는 점이다.
재시도 여부를 판단하는 데 필요한 정보를 가장 많이 가지고 있는 계층이 그 책임을 가져야 하고, 다른 계층은 그 결정을 따르는 것이 구조를 훨씬 단순하게 만든다.
이번 수정은 단순히 실행 시간을 줄인 것이 아니라, 각 컴포넌트의 책임을 다시 정리하는 과정이었다.
6. 같은 문제가 채점 기능에도 있었다
검증만의 문제가 아니었다
검증 기능의 구조를 정리하고 나서 문득 이런 생각이 들었다.
"혹시 다른 기능도 같은 구조를 사용하고 있는 건 아닐까?"
프로젝트에는 Claude API를 사용하는 기능이 하나 더 있었다.
바로 퀴즈 채점 기능이었다.
채점 로직도 살펴보니 검증 기능과 같은 패턴을 사용하고 있었다.
서비스에서는 자체적으로 타임아웃과 재시도를 수행하고 있었고,
그 안에서 호출하는 ClaudeClient도 내부 재시도를 수행하고 있었다.
즉, 검증 기능에서 발견했던 것과 동일하게 재시도가 두 계층에서 중복되고 있는 구조였다.
만약 Claude API가 일시적으로 500 에러를 반환한다면 다음과 같이 동작할 수 있었다.
ClaudeClient 호출
└─ 실패
└─ 내부 재시도
↓
서비스에서 실패 감지
↓
서비스 재시도
↓
ClaudeClient 호출
└─ 실패
└─ 내부 재시도
결과적으로 하나의 채점 요청이 최대 4번의 API 호출로 이어질 수 있는 구조였다.
담당 팀원과 함께 수정했다
이 내용을 채점 기능 담당 팀원에게 공유했다.
확인해 보니 같은 문제가 맞았고, 이후 채점 기능도 generateMessageInternal() 대신 executeGenerateMessage()를 호출하도록 수정되었다.
덕분에 검증 기능뿐 아니라 채점 기능도 재시도가 한 계층에서만 이루어지는 구조로 정리할 수 있었다.
ClaudeClient도 함께 정리되었다
재시도 구조를 다시 살펴보는 과정에서 ClaudeClient 자체도 개선할 부분이 보이기 시작했다.
가장 먼저 눈에 띈 것은 캡슐화였다.
외부에서 호출하지 않는 내부 구현 메서드들까지 public으로 열려 있었기 때문에, 실제 사용해야 하는 인터페이스가 무엇인지 한눈에 알기 어려웠다.
그래서 내부 구현 메서드들은 private으로 변경하고, 서비스에서 필요한 메서드만 외부에 공개하도록 정리했다.
또 하나는 응답 파싱 책임이었다.
기능마다 Claude 응답에서 텍스트를 추출하는 방식이 조금씩 달랐다.
어떤 곳은 첫 번째 텍스트 블록만 사용했고, 어떤 곳은 여러 블록을 이어 붙이고 있었다.
Claude 응답 형식이 바뀌거나 thinking 블록처럼 새로운 타입이 추가되면 기능마다 서로 다른 버그가 발생할 수도 있는 구조였다.
그래서 텍스트 추출 책임을 ClaudeClient 한 곳으로 모으고, 모든 기능이 동일한 방식으로 응답을 처리하도록 정리했다.
마지막으로, 실제로는 항상 같은 모델을 사용하는데도 메서드마다 model 파라미터를 계속 전달하고 있었다.
외부에서 모델을 바꿔 사용할 계획이 없다면 굳이 서비스마다 같은 값을 넘길 이유가 없었다.
그래서 모델 선택도 ClaudeClient 내부에서 담당하도록 변경하면서 메서드 시그니처도 훨씬 단순해졌다.
처음에는 readTimeout 설정 하나를 수정하는 작업이라고 생각했다.
하지만 리뷰를 따라 하나씩 구조를 살펴보다 보니, 결국 프로젝트 전체에서 ClaudeClient의 역할과 책임을 다시 정리하는 리팩터링으로 이어졌다.
7. 마무리
처음 이 작업을 시작했을 때는 정말 단순한 문제라고 생각했다.
팀원의 리뷰대로 readTimeout만 조정하면 끝나는 수정이라고 믿었다.
하지만 리뷰를 하나씩 따라가며 코드를 다시 읽어보니,
future.cancel(true)는 작업을 강제로 종료하지 않는다는 점을 이해하게 되었고,- HTTP 타임아웃과 애플리케이션 타임아웃이 서로 어떤 영향을 주는지도 알게 되었으며,
- 재시도가 여러 계층에서 중복되고 있다는 구조적인 문제도 발견했다.
결국 수정한 것은 readTimeout 값 하나가 아니라 ClaudeClient의 책임과 서비스의 역할이었다.
이번 경험을 통해 가장 크게 느낀 점은 기존 컴포넌트를 재사용할 때는 "무엇을 하는 컴포넌트인가"보다 "이미 어떤 책임을 가지고 있는 컴포넌트인가"를 먼저 살펴봐야 한다는 것이었다.
나는 이미 재시도 책임을 가진 ClaudeClient를 그대로 사용하면서, 그 위에 서비스 재시도를 또 얹고 있었다.
컴포넌트의 책임을 제대로 이해하지 못한 채 재사용한 것이 결국 구조적인 문제를 만든 셈이었다.
또 하나 인상 깊었던 것은 좋은 코드 리뷰는 버그를 찾는 데서 끝나지 않는다는 것이었다.
첫 번째 리뷰는 readTimeout이라는 증상을 발견하게 해 주었고,
두 번째 리뷰는 그 증상 뒤에 숨어 있던 구조적인 원인을 발견하게 해 주었다.
만약 첫 번째 리뷰만 반영하고 "이제 끝났다."고 생각했다면, 재시도가 중복되는 구조는 그대로 남아 있었을 것이다.
그리고 그 구조를 이해하는 과정에서 검증 기능뿐 아니라 퀴즈 채점 기능에도 동일한 문제가 있다는 것을 발견할 수 있었다.
담당 개발자에게 내용을 공유했고, 이후 채점 기능도 같은 방향으로 수정되었다.
결과적으로 프로젝트 전체에서 재시도는 한 계층에서만 수행한다는 원칙을 적용할 수 있었고, ClaudeClient 역시 캡슐화와 응답 파싱 책임까지 함께 정리할 수 있었다.
돌아보면 시작은 readTimeout 하나였다.
하지만 끝은 "책임은 어디에 있어야 하는가?"를 다시 고민하는 일이었다.
이번 경험을 통해 앞으로는 새로운 기능을 만들기 전에,
"이 컴포넌트는 이미 어떤 책임을 가지고 있는가?"
를 먼저 확인하는 습관을 가져야겠다고 느꼈다.
아마 이번 프로젝트에서 가장 오래 기억에 남을 리뷰는 readTimeout 리뷰가 아니라, 구조를 다시 보게 만들어 준 리뷰였던 것 같다.
'Backend > Architecture & Design' 카테고리의 다른 글
| [TIL-260326] DTO, VO, Entity, Domain 뭐가 다른걸까..??? (0) | 2026.03.26 |
|---|---|
| [TIL-260128] 프로젝트를 하며 알게 된 용어 정리 (0) | 2026.02.16 |