본문 바로가기

분류 전체보기

(143)
[티켓팅 #11] RefreshToken + Redis 블랙리스트 로그아웃 TASK-022, TASK-022-1 | PR #46, PR #48이 글은 PR #46에서 적용한 RefreshToken 저장 및 Redis 블랙리스트 로그아웃 구조와이후 PR #48에서 추가한 RefreshToken Rotation 보완 과정을 함께 정리한 글이다.1. 문제 상황이전 단계에서는 AccessToken 중심의 단순한 JWT 인증 구조를 먼저 적용했다.로그인하면 AccessToken 하나만 발급하고 이후 모든 요청은 그 토큰으로 인증하는 방식이었다.AccessToken 만료 시간을 15분으로 짧게 잡았기 때문에 보안 관점에서 큰 문제가 없을 것이라 생각했다.실제로 생각해보니 두 가지 문제가 있었다.하나는 토큰을 탈취당했을 때 만료 전까지 서버에서 아무것도 할 수 없다는 것다른 하나는 15분마..
[티켓팅 #10] JWT 인증 구현기 — Spring Security 필터 체인 설계 티켓팅 프로젝트를 진행하면서 인증은 의도적으로 뒤로 미뤘다.초반에는 HOLD, 예약 확정, 예약 취소 같은 핵심 흐름을 먼저 붙이는 데 집중했고그 과정에서 인증 없이도 기능 자체는 동작했다.이 글은 단일 TASK 정리가 아니다. 인증 구조의 시작은 TASK-021이었지만이후 RefreshToken 발급, Redis 블랙리스트 로그아웃(TASK-022), Rotation 적용(TASK-022-1)까지 확장됐다.필터 체인 중심의 인증 설계가 어떻게 시작됐고 이후 어떤 보완이 붙었는지를 함께 정리하는 글이다.1. 문제 상황상태를 바꾸는 API가 늘어나면서 문제가 드러났다.POST /showtimes/{id}/hold, POST /holds/{id}/reserve, DELETE /reservations/{id}..
[티켓팅 #9] 이벤트 목록에 Redis 캐시를 붙인 이유 1. 문제 상황티켓팅 서비스에서 이벤트 목록(/events) 조회는 가장 빈번하게 호출되는 API다.티켓팅 오픈 직전에는 수백 명이 동시에 같은 페이지를 새로고침한다.그런데 이 API는 매 요청마다 DB에 다음 쿼리를 날리고 있었다.SELECT e.id, e.name, e.venue, min(s.show_at), e.statusFROM event eJOIN showtime s ON s.event_id = e.idGROUP BY e.id, e.name, e.venue, e.statusORDER BY min(s.show_at) ASCEvent와 Showtime을 JOIN하고 GROUP BY로 집계한 뒤 정렬까지 수행한다.이 쿼리가 동시 요청마다 반복 실행되면 DB 커넥션 풀이 빠르게 소진된다.이벤트 목록은 ..
[티켓팅 #8] N+1 문제 해결과 인덱스 설계 1. 문제 상황HOLD 만료 스케줄러를 붙이고 나서 쿼리 로그를 보다가 이상한 걸 발견했다.만료 대상 Hold가 100개면 쿼리가 101번 나가고 있었다.스케줄러가 1분마다 실행되는데 만료 대상이 늘어날수록 DB 부하가 선형으로 증가하는 구조였다.Hibernate: select h from holds where status = 'ACTIVE' and expires_at 문제는 두 가지였다.첫째 HoldExpirationService의 만료 처리 루프에서 hold.getShowtimeSeat()를 호출할 때마다 추가 쿼리가 나가고 있었다.전형적인 N+1 문제다.둘째 ShowtimeSeat의 @ManyToOne이 fetch 타입을 명시하지 않아 JPA 기본값인 EAGER로 동작하고 있었다.EAGER는 연관 ..
[티켓팅 #7] 비관적 락에서 Redis 분산락으로 — 좌석 선점 동시성 제어 1. 문제 상황HOLD API를 구현하고 나서 한 가지 찜찜한 게 있었다."같은 좌석에 동시 요청이 오면 어떻게 되지?"티켓팅은 특정 시간에 트래픽이 몰리는 구조다.오픈 직후 수십 명이 동시에 같은 좌석을 선점하려 한다.당시 구현은 좌석 상태 검증(AVAILABLE 여부 확인)은 있었지만동시 요청이 들어오면 두 트랜잭션이 같은 시점에 AVAILABLE을 읽고 둘 다 HOLD를 생성할 수 있었다.스레드 A: SELECT showtime_seat → status = AVAILABLE ✅스레드 B: SELECT showtime_seat → status = AVAILABLE ✅ (동시에!)스레드 A: INSERT hold / UPDATE status = HELD스레드 B: INSERT hold / UPDATE ..
[티켓팅 #6] RESERVE API — HOLD 검증과 상태 전이 설계 1. 문제 상황HOLD API를 만들고 나서 바로 떠오른 질문이 있었다.HOLD를 잡았으면 그 다음에 어떻게 예약을 확정할 것인가?처음에는 단순하게 생각했다.좌석 상태를 HELD → RESERVED로 바꾸면 되는 것 아닌가.그런데 막상 설계를 시작하니 생각보다 고민이 많았다.HOLD가 이미 만료됐을 수도 있다HOLD 상태는 ACTIVE인데 좌석 상태가 이미 RESERVED일 수도 있다트랜잭션 범위를 어디까지 잡아야 하는가상태를 몇 개나 바꿔야 하는가단순히 상태 하나 바꾸는 API가 아니었다.2. 왜 이게 문제였는가RESERVE API의 핵심은 복수의 상태를 동시에 전이시킨다는 점이다.ShowtimeSeat.status : HELD → RESERVEDHold.status : ACTIVE → ..
[티켓팅 #5] HOLD 만료 해제 스케줄링 1. 문제 상황HOLD API를 만들고 나서 바로 다음 문제가 생겼다.사용자가 좌석을 선점한 뒤 결제 없이 그냥 나가버리면 그 좌석은 영원히 HELD 상태로 남는다.5분 만료 시간을 expiresAt 컬럼에 저장해두긴 했는데 저장만 해놓고 실제로 만료 처리를 하는 로직이 없었다.결과적으로 이런 상황이 발생한다.사용자 A가 1번 좌석을 HOLD5분 뒤 만료됐지만 DB에는 HELD 상태 그대로다른 사용자 B는 1번 좌석을 선택할 수 없음expiresAt은 그냥 기록용 데이터가 되어버렸다.2. 왜 이게 문제였는가처음 구현할 때는 "HOLD 생성 시 만료 시간을 저장하면 되겠지"라고 생각했다.근데 만료 판단을 어디서 하느냐가 문제였다.방법 1 — 조회 시점 만료 처리 좌석 조회 API에서 expiresAt 구현..
[티켓팅 #4] 좌석 선점(HOLD) API — 왜 HOLD 상태가 필요한가 1. 문제 상황 — 왜 이게 필요했는가조회 API를 만들고 나서 바로 다음 질문이 생겼다."사용자가 좌석을 선택했다. 그런데 결제하기 전에 다른 사람이 같은 좌석을 가져가면 어떻게 되지?"처음엔 단순하게 생각했다. 결제 시점에 좌석 상태를 확인하고 이미 예약됐으면 에러를 반환하면 되지 않나?근데 이건 문제가 있다.결제 페이지에 진입했을 때 이미 누군가가 같은 좌석을 선택하고 있는 중이라면사용자는 결제까지 완료하고 나서야 "이미 예약된 좌석입니다"라는 메시지를 받게 된다.결제까지 다 했는데 좌석을 못 받는 상황. 사용자 입장에서 이건 단순한 불편이 아니라 신뢰의 문제다.그래서 필요한 게 좌석 선점(HOLD) 이다.사용자가 좌석을 선택하는 순간, 일정 시간 동안 그 좌석을 "쓰는 중"으로 임시로 잠금 처리를..
[티켓팅 #3] Flyway로 DB 버전을 관리한 이유와 dev/test 환경 분리 전략 1. 문제 상황 — DB도 코드처럼 관리해야 한다는 걸 느꼈다티켓팅 서비스 개발을 진행하면서 초기엔 DB 스키마 변경을 이렇게 했다.IntelliJ에서 직접 SQL을 실행하거나 테이블 구조가 바뀌면 DROP TABLE 후 다시 만들거나 변경 내용을 따로 메모해두는 방식이었다.기능이 몇 개 없을 때는 큰 문제가 없었다.그런데 Member → Event → Showtime → Seat → Hold 순서로 도메인이 늘어나면서 이런 상황이 생겼다.컬럼 하나 추가하고 나서 테스트 DB에는 반영을 빠뜨린 적이 있었다로컬 dev DB랑 테스트 DB 스키마가 서로 달라서 테스트가 이유 없이 실패했다"이 컬럼 언제 추가한 거지?" 를 git log에서 찾아야 했다코드는 Git으로 버전을 관리하는데 DB 스키마는 버전 관..
[티켓팅 #2] Validation 상세 응답과 도메인별 에러 코드 설계 1. 문제 상황지난 글에서 GlobalExceptionHandler와 ApiResponse로 공통 예외 처리 구조를 잡았다.그런데 글을 마무리하면서 스스로 "이걸로 끝이 아니다"는 걸 느꼈다.당시 구조는 이런 식이었다.{ "success": false, "code": "COMMON-001", "message": "잘못된 요청입니다.", "data": null}문제는 세 가지였다. 첫째, @Valid로 요청 값 검증에 실패해도 어떤 필드가 왜 틀렸는지 응답에 안 나온다.프론트엔드 입장에서는 그냥 "잘못된 요청" 한 줄만 받는 셈이다. 둘째, 에러 코드가 COMMON-001, COMMON-002 같이 뭉뚱그려져 있어서"이게 회원 관련 에러인지, 좌석 관련 에러인지" 코드만 보고는 알 수가 없다. 셋째..