허진혁Backend Developer
전체 프로젝트

2024.05 ~ 2024.06 / 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 Event, Outbox, Scheduler

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

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

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

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

AFTER 커밋 후 처리 + 상태 기반 복구
신고 저장
Commit
AFTER_COMMIT 이벤트Outbox PENDING 기록 → Slack 전송
SUCCESS
FAILED

Scheduler ↻ PENDING / FAILED 재처리

신고 저장과 Slack 전송은
성공 기준이 다른 작업이었습니다.

  • 장애 범위 분리: 신고 커밋 이후 이벤트로 외부 호출을 옮겨 저장과 알림 실패가 서로 영향을 주지 않게 했습니다.
  • 재처리 근거 확보: 발송 상태·실패 사유·payload를 Outbox에 남겨 실패한 작업을 다시 찾을 수 있게 했습니다.
  • 상태 영속화: Outbox 생성과 상태 변경은 별도 서비스의 REQUIRES_NEW 트랜잭션으로 처리했습니다.
재처리 조건과 횟수 기준

스케줄러는 1분 간격으로, 생성 후 5분이 지난 PENDING / FAILED 중 retry_count < 3인 작업을 조회합니다. 현재 카운터는 최초 전송을 포함해 실패할 때 증가하므로, 실패 누적 3회가 되면 자동 재처리를 중단합니다.

Kotlin · 발송 흐름 발췌
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun sendReportAlarm(event: ReportEvent) {
    val payload = event.toSpecificMap()
    val outbox = reportOutboxService.createPending(
        event.report, objectMapper.writeValueAsString(payload)
    )
    runCatching {
        alarmAdapter.sendAlarm(AlarmType.REPORT, payload)
    }.onSuccess {
        reportOutboxService.markSuccess(outbox.id)
    }.onFailure {
        reportOutboxService.markFailed(outbox.id, failureReason(it))
    }
}

바깥쪽 예외 로깅은 생략했습니다. 상태 변경 메서드는 별도 트랜잭션에서 커밋됩니다.

FAILEDWebhook 예외 발생 시 기록
SUCCESS스케줄러 재전송 성공 시 전환
3회 실패자동 재처리 중단 기준
Outbox 상태 전환 예시 · 아래 두 행은 동일 작업의 실패 후 복구를 표현합니다.
statusretry_countfailure_reason
FAILED1Webhook Timeout
SUCCESS1

Mock 기반 단위 테스트로 발송 성공·실패, 재처리 상태 전환과 조회 조건을 확인했습니다. 실제 DB 커밋 경계 및 프로세스 종료 복구는 별도 통합 검증 대상입니다.