허진혁Backend Developer
전체 프로젝트

개발 2024.03 ~ 2024.06
운영 2024.01 ~ 2025.01
Backend · 팀 기여 30%

개발 한 스푼

하루 한 질문으로 CS를 학습하는 모바일 애플리케이션

사용자별 발급 정책을 지키고, 놓친 신고 알림을 복구했습니다.

Kotlin · Spring Boot · MySQLGitHub

01 / CONCURRENCY · MySQL Named Lock, Spring AOP

하루 한 번의 발급을 지키는 동시성 제어

문제동시 요청으로 사용자별 하루 1회 질문 발급 정책이 깨질 수 있었습니다.

발급 판단의 보호 범위 · 동일 사용자 기준
BEFORE 두 요청이 같은 빈 이력을 조회
요청 A
오늘 이력 없음
신규 발급
요청 B
오늘 이력 없음
신규 발급

조회와 저장 사이에 같은 판단이 중복 실행

AFTER 사용자별 MySQL Named Lock
락 획득다음 요청은 대기
트랜잭션 시작
조회 → 판단 → 저장을 하나의 임계구역으로
최신 이력 재조회
기존 반환 / 신규 발급

Commit 이후 락 해제 → 다음 요청 진입

발급 여부를 판단하는 전체 과정이
사용자 단위 임계구역이었습니다.

  • 보호 범위: 질문 선택, 발급 이력 저장, 발급 카운트 증가까지 함께 보호해야 했습니다.
  • 락 범위: 같은 사용자만 순차 처리하는 Named Lock을 선택해 다른 사용자의 발급은 분리했습니다.
  • 실행 순서: Lock AOP가 트랜잭션보다 먼저 실행되도록 설정하고, 락 획득 후 최신 이력을 다시 조회했습니다.
Serializable · 비관적 락 · UNIQUE를 비교한 이유
선택지이 요구에서의 판단
Serializable격리 수준을 높이면 경합과 재시도 비용까지 고려해야 합니다.
비관적 락미발급 이력은 잠글 행이 없습니다. 사용자 행을 잠그는 대안은 가능하지만 관련 작업의 경합 범위도 검토해야 합니다.
UNIQUE중복 저장의 최종 방어선입니다. 발급 판단 전체를 순차 처리하는 역할은 별도로 필요했습니다.
Named Lock · 선택발급 이력 유무와 관계없이 사용자 키로 전체 흐름을 보호할 수 있었습니다.
Kotlin · 핵심 발췌
// @Order(1)인 DistributedLockAdvisor의 메서드
fun atTarget(joinPoint: ProceedingJoinPoint): Any? {
    val lockAnnotation = getAnnotation(joinPoint)
    val lockKeyList = getLockKeyList(joinPoint.args, lockAnnotation.keyClass)
    return lockRepository.withAllLock(lockKeyList) {
        joinPoint.proceed()
    }
}

@Transactional
@DistributedLock(keyClass = [GetTodayQuestion::class])
fun getOrCreateTodayQuestion(request: GetTodayQuestion): QuestionInfo {
    val user = getMember(request.memberId)
    val latestIssuedQuestion = questionOpenRepository.findLatestWithQuestionAndAnswer(user)
    val isTodayQuestion = latestIssuedQuestion?.openDate?.isToday() ?: false
    return if (isTodayQuestion) makeQuestionInfo(latestIssuedQuestion!!)
    else questionOpenDomainService.issueQuestion(request.memberId, request.today)
}

Advisor와 서비스의 핵심 부분 발췌. Advisor의 @Around는 생략했습니다. 트랜잭션 AOP 순서는 LOWEST_PRECEDENCE - 10으로 설정했습니다.

3개동일 사용자의 동시 요청
동일 ID세 요청의 질문 반환값
이력 재사용후속 요청의 처리 방식

멀티스레드 통합 테스트에서 반환된 질문 ID를 비교했습니다. 검증 범위는 같은 사용자의 동시 발급 시나리오입니다.

02 / RELIABILITY · Transactional Outbox, Scheduler

누락된 신고 알림을 복구 가능한 작업으로

문제운영 중 신고 데이터는 저장됐지만 Slack 알림이 누락되는 사례가 발생했습니다.

신고 저장과 외부 알림의 경계
BEFORE 실패한 외부 호출이 기록되지 않음
신고 저장 로직
Slack Webhook 실패
알림 누락재처리할 발송 상태 없음

운영에서 신고 저장 / 알림 누락 사례 확인

AFTER 커밋 후 처리 + 상태 기반 복구
신고 + Outbox 저장동일 트랜잭션
Commit
AFTER_COMMIT 이벤트조건부 선점 → Slack 전송
SUCCESS
FAILED

Scheduler ↻ 1·5·15분 간격 · 최초 포함 최대 4회

실패를 감지하는 것과
작업을 잃지 않는 것은 별개의 문제였습니다.

  • 원인 확인: 리스너 미실행·예외 은폐·외부 API 실패로 가설을 나눠 검증한 결과, Slack의 HTTP 오류 응답(4xx·5xx)을 검사하지 않아 실패를 감지하지 못했습니다.
  • 구조적 한계: 전송 작업이 메모리에만 존재해 외부 API 장애나 서버 재시작 시 미완료 작업을 복구할 수 없었습니다.
  • 동일 트랜잭션 저장: AOP 실행 순서를 조정해 신고 데이터와 Outbox를 같은 트랜잭션에 저장하고, 전송만 커밋 이후로 미뤘습니다.
  • 중복 선점 방지: 조건부 UPDATE와 행 잠금으로 다중 인스턴스가 같은 작업을 동시에 처리하지 않게 했습니다.
재처리 조건과 횟수 기준

스케줄러는 1분 간격으로 재처리 대상을 조회합니다. 최초 전송을 포함해 최대 4회 시도하며, 실패 후 다음 시도는 1분·5분·15분 뒤입니다. 408·429·5xx는 재시도하고 429 응답의 Retry-After는 다음 시도 시각에 반영합니다. 그 외 4xx는 재시도해도 같은 결과이므로 즉시 최종 실패로 처리합니다.

Kotlin · 발췌
// 신고를 저장하는 @Transactional 메서드 안에서 실행되는 AOP
private fun bugReportEventPublish(result: Any?) {
    val event = (result as? ReportEvent) ?: throw ReportEventInvalidReturnException()
    val outbox = reportOutboxService.createPending(
        report = event.report,
        slackPayload = objectMapper.writeValueAsString(event.toSpecificMap())
    )
    eventPublisher.publishEvent(ReportOutboxCreatedEvent(outbox.id))
}

// 커밋 이후 최초 전송. 스케줄러 재처리도 같은 attemptSend를 거친다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun sendReportAlarm(event: ReportOutboxCreatedEvent) {
    reportOutboxService.attemptSend(event.reportOutboxId)
}

예외 로깅은 생략했습니다. attemptSend는 조건부 UPDATE로 작업을 선점한 뒤 전송하고, 결과(성공·재시도 예정·최종 실패)를 같은 트랜잭션에서 기록합니다.

22개단위·HTTP·통합 테스트 통과
8개 스레드동시 처리 시 단일 발송 검증
최대 4회최초 전송 포함 자동 재처리 한도
Outbox에 남기는 상태 · 전송 상태, 시도 횟수, HTTP 상태, 실패 사유
검증 항목결과
동일한 HTTP 500 응답기존: 실패를 감지하지 못함 → 개선: 오류 기록 및 재시도 대상으로 전환 (Before/After 테스트)
트랜잭션 원자성Testcontainers MySQL 기반으로 검증
전체 흐름신고 저장부터 커밋 후 알림 전송, 실패 재처리까지 검증

MockWebServer로 HTTP 500·404 응답을 재현해 기존 코드의 오류 감지 누락을 확인했고, 같은 시나리오로 개선 후 동작을 비교했습니다.