2024.05 ~ 2024.06 / Backend · 팀 기여 30%
개발 한 스푼
하루 한 질문으로 CS를 학습하는 모바일 애플리케이션
사용자별 발급 정책을 지키고, 놓친 신고 알림을 복구했습니다.
01 / CONCURRENCY · MySQL Named Lock, Spring AOP
하루 한 번의 발급을 지키는 동시성 제어
문제동시 요청으로 사용자별 하루 1회 질문 발급 정책이 깨질 수 있었습니다.
조회와 저장 사이에 같은 판단이 중복 실행
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으로 설정했습니다.
검증 결과
멀티스레드 통합 테스트에서 반환된 질문 ID를 비교했습니다. 검증 범위는 같은 사용자의 동시 발급 시나리오입니다.
02 / RELIABILITY · Transactional Event, Outbox, Scheduler
누락된 신고 알림을 복구 가능한 작업으로
문제운영 중 신고 데이터는 저장됐지만 Slack 알림이 누락되는 사례가 발생했습니다.
운영에서 신고 저장 / 알림 누락 사례 확인
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))
}
}바깥쪽 예외 로깅은 생략했습니다. 상태 변경 메서드는 별도 트랜잭션에서 커밋됩니다.
검증 결과
| status | retry_count | failure_reason |
|---|---|---|
| FAILED | 1 | Webhook Timeout |
| SUCCESS | 1 | — |
Mock 기반 단위 테스트로 발송 성공·실패, 재처리 상태 전환과 조회 조건을 확인했습니다. 실제 DB 커밋 경계 및 프로세스 종료 복구는 별도 통합 검증 대상입니다.