Skip to content

Troubleshooting

TAEHEON KIM edited this page Jul 7, 2026 · 8 revisions

Troubleshooting

1. 동시 지원 레이스컨디션

증상: 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 응답
  • 락 획득 순서를 항상 게시글 → 지원 단방향 고정으로 데드락 제거 (단, 취소 경로에서 별도의 데드락이 재발 -> 1-1 참고)

1-1. Apply 취소 경로에서 재발한 데드락

증상: 위 1번 수정 이후에도, 게시글 작성자가 지원을 승인하는 시점과 지원자가 같은 지원을 취소하는 시점이 겹치면 간헐적으로 데드락 발생

원인: ApproveApplyService는 게시글 -> 지원 순서로 명시적 PESSIMISTIC_WRITE 락을 잡는 반면, CancelApplyService는 지원 행을 락 없이 조회한 뒤 바로 DELETE를 실행함. 지원(Apply)은 게시글(Post)을 참조하는 FK가 있어 DELETE 시점에 InnoDB가 참조 무결성 검사를 위해 게시글 행에 대한 락을 암묵적으로 요구함 -> 결과적으로 "지원 -> 게시글" 역순 대기가 발생해 "게시글 -> 지원" 순서로 락을 잡는 승인 트랜잭션과 순환 대기(circular wait)가 만들어짐

해결:

// CancelApplyService
val applyId = applyRepository.findByPostIdAndApplicantId(postId, userId)?.id
    ?: throw CustomException(ErrorCode.APPLICATION_NOT_FOUND)

// Post 락이 필요 없으므로 Apply 행만 잠가 Approve(Post -> Apply)와의
// 역순 락으로 인한 데드락을 피한다.
val apply = applyRepository.findByIdForUpdate(applyId)
    ?: throw CustomException(ErrorCode.APPLICATION_NOT_FOUND)
apply.validatePending()
applyRepository.delete(apply)
  • 취소는 게시글 상태를 변경하지 않으므로 애초에 게시글 락이 불필요
  • 지원 행만 PESSIMISTIC_WRITE로 명시적으로 선점한 뒤 삭제해, 락 획득 시점을 트랜잭션 앞쪽으로 당기고 게시글 행은 아예 건드리지 않도록 변경
  • 취소 트랜잭션이 게시글 락을 요청하지 않으므로 승인 트랜잭션과의 역순 경합 자체가 사라짐

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}; // ... } 실행 단위로 키가 달라져 이전 캐시와 충돌하지 않음


13. 비관적 락 쿼리의 불필요한 JOIN FETCH로 인한 락 범위 확대

증상: 락 쿼리 점검 중, 지원 동시성 제어에 쓰이는 PESSIMISTIC_WRITE 쿼리가 락 대상 엔티티 외에 연관 엔티티까지 JOIN FETCH하고 있는 것을 발견. SELECT ... FOR UPDATE가 조인된 테이블 행까지 함께 잠가, 게시글 하나에 지원이 몰릴 때 그 게시글 작성자의 User 행이 매 지원 요청마다 반복적으로 잠기는 구조였음 — 작성자와 무관한 지원 트랜잭션들이 작성자의 User 행을 두고 불필요하게 경합.

원인: 지원 락 쿼리(findByIdWithPostAndApplicantForUpdate)와 게시글 락 쿼리 (findByIdWithAuthorForUpdate)가 실제로는 필드를 쓰지 않는 연관관계(a.applicant, p.author)까지 JOIN FETCH하고 있었음. 락 대상 행 하나만 잠그려는 의도와 달리, JPA가 생성한 FOR UPDATE 쿼리는 조인에 포함된 모든 테이블 행을 잠금 범위에 포함시킴.

해결:

// 수정 전: a.applicant까지 JOIN FETCH → applicant(User) 행도 락 범위에 포함
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM ApplyJpaEntity a JOIN FETCH a.post JOIN FETCH a.applicant WHERE a.id = :applyId")

// 수정 후: 실제 값이 필요한 a.post만 유지, 미사용 a.applicant 제거
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM ApplyJpaEntity a JOIN FETCH a.post WHERE a.id = :applyId")
fun findByIdWithPostAndApplicantForUpdate(...)
  • 락 쿼리는 실제 필드 사용 여부 기준으로 JOIN 제거 → 락 범위를 대상 행 자체로 한정
  • 같은 패턴이 락과 무관한 조회 7곳(DeleteStudyPostService, CloseStudyPostService, UpdateStudyPostService, FindAppliesService, UpdateCommentService, DeleteCommentService, PostScheduler) author.id/applicant.id만 비교하는데 JOIN FETCH 버전으로 조회하고 있어 기본 findById()로 교체해도 동작 동일
  • 반대로 응답 직렬화(FindStudyPostService), ES 색인(PostSyncKafkaConsumer), 실제 연관 필드 사용(TeamScheduleJpaRepository)은 JOIN FETCH가 정당해 유지
  • 재발 방지를 위해 "소유자 검증 전용" 경량 조회 메서드를 별도로 두고 재사용하도록 정리

14. 동시 최초 로그인 시 닉네임 충돌로 500

증상: 카카오 최초 로그인 시 동일 닉네임을 가진 사용자가 동시에 가입하면 DataIntegrityViolationException이 그대로 전파되어 500 에러

원인: 닉네임 중복 체크와 INSERT 사이에 다른 트랜잭션이 끼어들면 같은 닉네임으로 동시 저장이 시도됨. 유니크 제약 위반에 대한 재시도 로직이 없어 예외가 컨트롤러까지 그대로 전파됨

해결: 저장을 별도 트랜잭션으로 분리하고, 유니크 제약 위반 원인별로 분기 처리

private fun createUser(kakaoId: String, nickname: String, email: String): User =
    try {
        userRepository.saveNew(User.create(kakaoId, resolveUniqueNickname(nickname), email))
    } catch (e: DataIntegrityViolationException) {
        // kakaoId 충돌(동시 최초 로그인)이면 방금 생성된 유저를 반환하고,
        // 닉네임 충돌(다른 사용자와의 경쟁)이면 닉네임을 다시 채번해 한 번 더 시도한다.
        userRepository.findByKakaoId(kakaoId) ?: retryCreateUser(kakaoId, nickname, email)
    }
  • saveNewREQUIRES_NEW + saveAndFlush로 즉시 제약 위반을 확인해 호출자(kakaoLogin)의 영속성 컨텍스트를 오염시키지 않음
  • kakaoId 충돌(같은 사용자의 동시 최초 로그인)이면 이미 생성된 유저를 조회해 반환
  • 닉네임 충돌(다른 사용자와의 경쟁)이면 닉네임을 다시 채번해 1회 재시도

15. 배치 청크 롤백 시 스킵 감사 로그 유실

증상: 만료 게시글 마감 배치에서 ES 색인 실패 등으로 특정 항목이 스킵될 때, 같은 청크가 다른 이유로 롤백되면 스킵 기록(FailedPostSync)까지 함께 사라짐

원인: SkipListener.onSkipInWrite에서 저장한 감사 로그가 배치의 청크 트랜잭션에 소속되어 있어, 청크가 롤백되면 스킵 기록도 함께 롤백됨

해결: 감사 로그 저장만 별도의 REQUIRES_NEW 트랜잭션으로 분리

@Transactional(propagation = Propagation.REQUIRES_NEW)
override fun saveIndependently(failedPostSync: FailedPostSync): FailedPostSync =
    failedPostSyncJpaRepository.save(failedPostSync)
  • 청크 트랜잭션이 롤백되어도 감사 로그는 독립 트랜잭션으로 이미 커밋되어 남음
  • 같은 배치에 @SchedulerLock(PostScheduler_closeExpiredPosts)을 추가해 멀티 인스턴스 환경에서 두 인스턴스가 동시에 같은 배치를 실행하는 중복 실행도 함께 방지

Clone this wiki locally