개발 2024.03 ~ 2024.06
운영 2024.01 ~ 2025.01
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 Outbox, Scheduler
누락된 신고 알림을 복구 가능한 작업으로
문제운영 중 신고 데이터는 저장됐지만 Slack 알림이 누락되는 사례가 발생했습니다.
운영에서 신고 저장 / 알림 누락 사례 확인
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로 작업을 선점한 뒤 전송하고, 결과(성공·재시도 예정·최종 실패)를 같은 트랜잭션에서 기록합니다.
검증 결과
| 검증 항목 | 결과 |
|---|---|
| 동일한 HTTP 500 응답 | 기존: 실패를 감지하지 못함 → 개선: 오류 기록 및 재시도 대상으로 전환 (Before/After 테스트) |
| 트랜잭션 원자성 | Testcontainers MySQL 기반으로 검증 |
| 전체 흐름 | 신고 저장부터 커밋 후 알림 전송, 실패 재처리까지 검증 |
MockWebServer로 HTTP 500·404 응답을 재현해 기존 코드의 오류 감지 누락을 확인했고, 같은 시나리오로 개선 후 동작을 비교했습니다.