-
Notifications
You must be signed in to change notification settings - Fork 0
Troubleshooting
TAEHEON KIM edited this page Jun 27, 2026
·
8 revisions
증상: 20명이 동시에 같은 게시글에 지원 시 DataIntegrityViolationException 발생,
일부 요청 500 에러
원인: SELECT(중복 체크) → INSERT 사이에 다른 트랜잭션이 끼어들어 유니크 제약 위반
해결:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(QueryHint(name = JAKARTA_LOCK_TIMEOUT, value = "3000"))
fun findByPostIdWithLock(postId: UUID): StudyPost?
- 비관적 락으로 게시글 행 선점
- 락 타임아웃 3초로 무한 대기 방지
- DataIntegrityViolationException catch → 409 응답
- 락 획득 순서를 항상 게시글 → 지원 단방향 고정으로 데드락 제거
---
2. 알림 처리로 인한 API 응답 지연
증상: 댓글 작성 API p(95) 급등, 에러율 87%
원인: 댓글 저장 + 알림 DB 저장 + Redis Pub/Sub 발행이 동기 처리 → 커넥션 즉시 고갈
해결: @Async("notificationExecutor")로 알림 처리 분리
@Bean(name = ["notificationExecutor"])
fun notificationExecutor(): Executor {
val executor = SimpleAsyncTaskExecutor("notification-")
executor.setVirtualThreads(true)
executor.setTaskDecorator(mdcTaskDecorator())
return executor
}
ThreadPoolExecutor.DiscardPolicy 적용 — 풀 포화 시 알림만 조용히 버리고 API 정상 응답 유지
결과: 에러율 87% → 2.3%, RPS 137 → 940 (585% 향상)
---
3. 비동기 스레드에서 MDC 컨텍스트 소실
증상: @Async 처리 시 로그에 requestId, userId가 빠져 Kibana에서 요청 추적 불가
원인: MDC는 ThreadLocal 기반 → 부모 스레드의 컨텍스트가 자식 스레드로 전파되지 않음
해결: TaskDecorator로 비동기 실행 전 MDC 컨텍스트 캡처 후 주입
private fun mdcTaskDecorator(): TaskDecorator = TaskDecorator { task ->
val mdcContext = MDC.getCopyOfContextMap()
Runnable {
try {
if (mdcContext != null) MDC.setContextMap(mdcContext)
task.run()
} finally {
MDC.clear()
}
}
}
---
4. 멀티 인스턴스 알림, 채팅 미수신
증상: App1에 연결된 클라이언트가 보낸 채팅이 App2에 연결된 클라이언트에게 전달 안 됨
원인: WebSocket STOMP 세션은 인스턴스별로 독립적 → 다른 인스턴스의 연결에 접근 불가
해결: Redis Pub/Sub으로 인스턴스 간 브로드캐스트
- 채팅: RedisChatPublisherAdapter.publish() → 전 인스턴스 구독 → 각자 WebSocket 전달
- 알림: Kafka Consumer → Redis Pub/Sub → 전 인스턴스 → SSE 전달
---
5. Kafka 발행 실패 시 알림 유실
증상: Kafka 브로커 일시 장애 시 알림 이벤트 소멸
원인: 애플리케이션 이벤트 발행 후 Kafka 전송 실패 시 재시도 수단 없음
해결: Outbox 패턴 도입
알림 이벤트 발생
→ Outbox DB 저장 (같은 트랜잭션)
→ Kafka 발행 시도
→ 실패 시 OutboxRetryScheduler가 PENDING 상태 재시도
→ 영구 실패 시 FAILED 처리 후 DLT 격리
---
6. CI 빌드 시 jOOQ 코드 생성 실패
증상: GitHub Actions에서 generateJooq 태스크 실행 시 DB 연결 없어 빌드 실패
원인: jOOQ 코드 생성이 실제 DB 스키마를 읽어야 하는데 CI 환경에 DB 없음
해결: CI Gradle 명령에 -x generateJooq 추가로 태스크 스킵.
생성된 jOOQ 클래스는 소스로 커밋해 CI에서 별도 생성 없이 빌드
---
7. IdempotencyFilter 응답 이중 쓰기
증상: Idempotency-Key 헤더가 있는 요청에서
Trailing token found after value Jackson 역직렬화 오류 발생
원인: IdempotencyResponseWrapper.flushBuffer()가 실제 response에 body를 쓰고,
IdempotencyFilter에서도 wrapper.flushBuffer()를 명시적으로 호출해 body가 두 번 write됨
해결:
- flushBuffer()에서 실제 response 쓰기 제거 (내부 버퍼 flush만 수행)
- IdempotencyFilter에서 캡처한 body를 딱 한 번만 response에 쓰도록 수정
- status code도 함께 캐시("201|{body}" 형식)해 재응답 시 원래 상태 코드 복원
// 수정 전: flushBuffer()에서 response에 write → filter에서도 write → 이중 쓰기
// 수정 후: filter에서만 write
val capturedBody = wrapper.capturedBody
val capturedStatus = wrapper.statusCode
idempotencyStoragePort.set(redisKey, "$capturedStatus|$capturedBody", TTL)
response.status = capturedStatus
response.writer.write(capturedBody)
---
8. Redis 캐시 역직렬화 오류 (Jackson 3.x)
증상: 게시글 단건 조회 시 Redis 캐시 역직렬화 실패,
Cannot construct instance of StudyPostResponse (no Creators) 오류
원인: Spring Boot 4.x는 Jackson 3.x(tools.jackson)를 사용하는데,
Kotlin data class에 @JsonCreator가 없으면 역직렬화 시 생성자를 찾지 못함
해결: StudyPostResponse에 @JsonCreator + 각 파라미터에 @JsonProperty 추가
data class StudyPostResponse @JsonCreator constructor(
@JsonProperty("id") val id: UUID,
@JsonProperty("title") val title: String,
...
)
동일 문제 발생 위치: KakaoIdTokenPayload, KakaoTokenResponse도 같은 방식으로 수정
---
9. 중복 지원 비즈니스 룰 누락
증상: 동일 사용자가 다른 Idempotency-Key로 같은 게시글에 재지원 시 중복 저장
원인: Idempotency-Key는 네트워크 재시도만 방지하며,
사용자가 의도적으로 다시 지원하는 경우는 비즈니스 룰로 막아야 함
해결: 도메인 엔티티에 중복 검증 추가
// StudyPost.kt
fun validateNotDuplicateApply(alreadyApplied: Boolean) {
if (alreadyApplied) throw CustomException(ErrorCode.ALREADY_APPLIED)
}
// ApplyStudyPostService.kt
post.validateNotDuplicateApply(
applyRepository.existsByPostIdAndApplicantId(postId, userId)
)
---
10. 스파이크 테스트 dial: i/o timeout
증상: 15,000 VU 스파이크 테스트에서 dial: i/o timeout 발생, 에러율 100%
원인: Tomcat accept 큐(기본 200개) 포화 → TCP 연결 시도조차 타임아웃
서버가 응답을 못 주는 게 아니라 연결 자체가 OS 레벨에서 드롭됨
분석: accept-count를 늘리면 빠른 실패(Fail Fast) 원칙에 어긋남.
근본 원인은 단일 서버 인스턴스 한계 — k6와 앱이 같은 머신에서 자원 경쟁
결론: 설정값으로 해결할 문제가 아닌 단일 서버 한계.
실무라면 Auto Scaling으로 수평 확장, prod 환경 2개 인스턴스 + Nginx 로드밸런싱 구성이 이미 되어 있음
---
11. 스파이크 테스트 한글 URL 인코딩 오류
증상: spike-search.js에서 Invalid character found in the request target 오류,
검색 요청 62% 실패
원인: k6에서 한글 키워드를 URL 인코딩 없이 그대로 전송
→ Tomcat이 RFC 7230/3986 위반으로 요청 거부 (400)
해결: encodeURIComponent() 적용
// 수정 전
const res = http.get(`${BASE_URL}/api/posts/search?keyword=${keyword}`);
// 수정 후
const keyword = encodeURIComponent(KEYWORDS[__VU % KEYWORDS.length]);
const res = http.get(`${BASE_URL}/api/posts/search?keyword=${keyword}`);
---
12. 스파이크 테스트 Idempotency 캐시로 인한 테스트 실패
증상: 새 토큰으로 spike-idempotency.js 재실행 시에도 100% 실패
원인: 이전 실패 테스트(만료 토큰으로 실행)에서 401 응답이 Redis에 캐시됨
(idem-test-{VU} 키로 "401|..." 저장, TTL 24시간)
새 토큰으로 재실행해도 동일 키로 캐시된 401 응답 반환
해결: 테스트 실행마다 고유한 키 prefix 사용
const RUN_ID = Date.now();
export default function () {
const idempotencyKey = `idem-test-${RUN_ID}-${__VU}`;
// ...
}
실행 단위로 키가 달라져 이전 캐시와 충돌하지 않음