CEOS 23기 백엔드 스터디 - CGV 클론 코딩 프로젝트
2주차 DB 접근기술, MVC 아키텍처
영화관 ||--o{ 상영관
상영관 ||--o{ 좌석
상영관 ||--o{ 상영일정
영화 ||--o{ 상영일정
상열일정 ||--o{ 예매내역
좌석 ||--o{ 상영일정
사용자 ||--o{ 예매내역
연동 성공
3주차 보안 & Spring Security
(저번주에 이어서..)
erd 다시 그림
h2 실행
connect 하면 이렇게 뜸
테이블 명 한 번 더 보고 인서트 준비
테스트 데이터 insert
영화관 post
영화 post
매점 post
JWT -> 로그인한 사용자인지 확인하기 위해 사용하는 토큰 방식
사용자가 로그인하면 서버가 토큰을 만들어 주고, 사용자는 이후 요청마다 이 토큰을 함께 보내서 자신을 인증함
- 실제 API 요청할 때 쓰는 토큰
- 보통 유효기간이 짧다
- Access Token이 만료됐을 때 새 Access Token을 받기 위해 쓰는 토큰
- Access Token보다 보통 더 오래 간다
- 서버가 로그인 상태를 따로 많이 저장하지 않아도 된다
- 모바일 앱이나 프론트/백 분리 구조에 잘 맞는다
- 토큰이 탈취되면 위험할 수 있다
- 한 번 발급된 토큰은 바로 무효화하기가 어렵다
쿠키 -> 브라우저에 저장되는 작은 데이터
로그인 정보를 유지할 때 많이 사용된다.
세션 -> 서버가 로그인 상태를 저장하는 방식
브라우저는 보통 쿠키에 담긴 세션 ID를 보내고, 서버는 그걸 보고 누가 로그인했는지 확인
- 서버가 로그인 상태를 직접 관리해서 제어하기 쉽다
- 로그아웃 처리나 강제 만료가 비교적 쉽다
- 사용자가 많아지면 서버가 세션을 계속 관리해야 해서 부담이 생긴다
OAuth -> 다른 서비스의 계정을 이용해 로그인하거나 권한을 위임하는 방식 ex)
- 카카오 로그인
- 구글 로그인
- 네이버 로그인
즉, 우리 서비스가 사용자의 비밀번호를 직접 받지 않고 다른 서비스의 인증 기능을 빌려 쓰는 것
- 회원가입/로그인이 편하다
- 비밀번호를 우리 서비스가 직접 관리하지 않아도 된다
- 구현이 조금 더 복잡하다
- 외부 서비스 정책에 영향을 받을 수 있다
| 방식 | 설명 | 장점 | 단점 | 주로 쓰는 곳 |
|---|---|---|---|---|
| JWT | 토큰을 이용해 사용자를 인증하는 방식 | 서버 부담이 비교적 적고, 프론트/백 분리 구조에 잘 맞음 | 토큰 탈취 시 위험하고, 바로 무효화하기 어려움 | 모바일 앱, REST API, SPA |
| 세션 + 쿠키 | 서버가 로그인 상태를 저장하고, 브라우저는 쿠키로 세션 ID를 보냄 | 서버가 로그인 상태를 직접 관리해서 제어하기 쉬움 | 서버가 세션을 계속 관리해야 해서 부담이 생길 수 있음 | 전통적인 웹 서비스 |
| OAuth | 다른 서비스 계정으로 로그인하거나 권한을 위임하는 방식 | 소셜 로그인이 가능하고, 비밀번호를 직접 관리하지 않아도 됨 | 구현이 더 복잡하고 외부 서비스에 영향을 받을 수 있음 | 카카오 로그인, 구글 로그인, 네이버 로그인 |
- JWT: 토큰으로 로그인 상태를 확인하는 방식
- Access Token: 실제 요청에 사용하는 토큰
- Refresh Token: Access Token을 다시 발급받기 위한 토큰
- 세션: 서버가 로그인 상태를 저장하는 방식
- 쿠키: 브라우저에 저장되는 데이터
- OAuth: 구글/카카오 같은 외부 계정으로 로그인하는 방식
회원가입
4주차 Java와 OOP
-
synchronized- JVM 내부에서만 동작하는 락 방식
- 단일 서버에서는 사용할 수 있지만, 서버가 여러 대면 한계가 있다.
-
DB Lock
- DB row에 락을 걸어 동시에 수정되지 않도록 제어한다.
- 재고 차감처럼 같은 데이터를 동시에 수정하는 경우에 적합하다.
-
Unique 제약
- DB에서 중복 저장 자체를 막는 방식이다.
- 같은 상영 회차의 같은 좌석처럼 절대 중복되면 안 되는 데이터에 적합하다.
-
Redis 분산락
- 여러 서버 간 동시성 제어에 사용할 수 있다.
- 다만 Redis 인프라가 필요해 현재 프로젝트 규모에서는 과하다고 판단했다.
CGV 서비스에서는 여러 사용자가 동시에 상품을 주문하거나 좌석을 예매할 수 있다.
이때 재고가 중복 차감되거나 같은 좌석이 중복 예약되는 문제가 발생할 수 있다.
| 대상 | 적용 방법 | 이유 |
|---|---|---|
| 상품 재고 | 비관적 락 PESSIMISTIC_WRITE |
같은 재고에 동시 주문이 들어올 수 있기 때문 |
| 좌석 예매 | (screening_id, seat_id) unique 제약 |
같은 상영 회차의 같은 좌석은 한 번만 예약되어야 하기 때문 |
TheaterItemStockRepository에 락 조회 메서드 추가ItemOrderService에서 재고 조회 시 락 메서드 사용TheaterItemStock에서 재고 검증과 차감 처리Reservation에 unique 제약 추가ReservationService에서 중복 예약 예외 처리- 동시성 테스트 추가
| 테스트 | 결과 |
|---|---|
| 재고 1개에 동시 주문 2건 요청 시 1건만 성공 | 성공 |
| 같은 좌석에 동시 예매 2건 요청 시 1건만 성공 | 성공 |
| 구분 | 대상 | 특징 |
|---|---|---|
| 티켓팅 | 좌석 예매 Reservation |
좌석을 먼저 선점하고, 결제 성공 시 예매 확정 |
| 커머스 | 매점 주문 ItemOrder |
결제 성공 후 재고 차감 및 주문 확정 |
티켓팅과 커머스는 결제 흐름이 다르기 때문에 각각 다른 방식으로 처리했다.
외부 결제 서버 연동에는 OpenFeign을 사용했다.
| 방식 | 특징 |
|---|---|
| Feign | 인터페이스 기반으로 외부 API를 간단하게 호출할 수 있음 |
| RestClient/WebClient | 세밀한 제어는 가능하지만 요청/응답 코드가 많아짐 |
이번 프로젝트에서는 결제 서버 명세가 정해져 있고, API 호출 구조가 단순하기 때문에 Feign을 선택했다.
적용 파일:
PaymentFeignClientPaymentGatewayPaymentGatewayImplPaymentPropertiespayment/dto/*
매점 주문은 결제가 성공한 뒤 재고를 차감하도록 구성했다.
주문 요청
→ 주문 생성(PENDING_PAYMENT)
→ 결제 서버 즉시 결제 요청
→ 결제 성공 시 재고 차감
→ 주문 상태 PAID 변경
| 방식 | 장점 | 단점 |
|---|---|---|
| Feign Client | 선언형 인터페이스 방식이라 코드가 간결하고 API 명세를 매핑하기 쉽다. | 리트라이, 커넥션 제어 등 세밀한 설정은 추가 구성이 필요하다. |
| RestClient / WebClient | 요청 흐름을 직접 제어하기 쉽고, 세밀한 커스터마이징에 유리하다. | 요청/응답 처리와 에러 파싱 코드를 직접 작성해야 해서 구현량이 많아진다. |
이번 과제 적합:
- Feign Client: 명세 기반 연동이 빠르다.
- RestClient / WebClient: 학습에는 좋지만 구현량이 많다.
5주차 Deploy
- 외부 결제 API 연동 방식을 OpenFeign에서 RestClient로 변경했다.
- RestClient의
onStatus()를 사용해 결제 API 응답 상태 코드별 예외 처리를 추가했다. - 결제 API Secret Key를 캐싱하도록 변경하고, 동시 요청 상황을 고려해
volatile과synchronized기반 더블 체크 락킹을 적용했다. - 결제 관련 DTO를
record로 변경해 불변 객체로 관리하도록 수정했다. - 결제 API 호출이 트랜잭션 내부에서 실행되지 않도록 예약 결제 로직의 트랜잭션을 분리했다.
- 만료된 예약 삭제 로직을 별도 트랜잭션으로 분리하고,
RESERVATION_EXPIRED에러 코드를 추가했다. - 매점 주문에서도 재고 선점/차감, 결제 호출, 결제 결과 반영을 분리했다.
- 결제 실패 시 주문 상태를 실패로 변경하고, 선점한 재고를 복구하도록 처리했다.
- 여러 상품 주문 시 데드락을 줄이기 위해 itemId 기준으로 정렬한 뒤 재고 락을 획득하도록 수정했다.
- 재고 락 조회에
PESSIMISTIC_WRITE와 락 타임아웃을 적용했다. IllegalArgumentException으로 처리하던 예외를CustomException과ErrorCode기반으로 통일했다.- 컨트롤러에서 반복되던
Long.parseLong(userDetails.getUsername())로직을 제거하고,CustomUserDetails의getUserId()를 사용하도록 변경했다. - JWT subject가 숫자가 아닐 경우를 대비해
INVALID_TOKEN_SUBJECT예외 처리를 추가했다. - 좌석 예매 중복 방지를 위해 좌석 조회 시
PESSIMISTIC_WRITE비관적 락을 적용했다.
- 팸키들을 폴더 내 ssh에 저장해두는 게 마음이 편해서 좋은 방법 같았다
- EC2안에서 컨테이너가 생성이 잘 안 됐는데 arm이랑 x86 아키텍처가 맞지 않았던 문제였다
- -> 이미지 다시 만들면 해결 됨
- 펨키 안 보이면 명령어로
cat ~/.ssh/ceos-cgv-key.pem하고-----BEGIN ... PRIVATE KEY-----줄도 다 포함해서 전체 다 복사해야함
6주차 부하테스트
k6 실행
테스트 API: GET /api/movies

- 최대 VU: 200
- 총 요청 수: 13,620건
- 처리량: 75.4 req/s
- 평균 응답시간: 147.02ms
- p95 응답시간: 156.78ms
- 최대 응답시간: 909.71ms
- CPU: 0.13%
- Memory: 310.1MiB / 908.7MiB
- Memory %: 34.13%
- PIDS: 50
- NET I/O: 16.2MB / 28MB
- Iteration Rate: 36/s
- HTTP Request Rate: 30.5/s
- HTTP Request Duration: 149ms
- HTTP Request Failed: 0.0%
- Received Rate: 14.5 kB/s
- Sent Rate: 10.6 kB/s
- k6실행할 때 EC2의 .env안에 JWT_SECRET값이 없었어서 애를 먹었다
7주차 캐싱 & 로깅
기존에는 GET /api/movies 요청이 들어올 때마다 DB에서 영화 목록을 다시 조회하는데,
영화 목록은 (다른 데이터들 보다) 자주 바뀌는 데이터가 아니라고 생각하여, 매번 DB를 조회하는 것보다 한 번 조회한 결과를 잠깐 저장해두고 다시 사용하는 것이 더 효율적이라고 판단하였다.
그래서 영화 조회를 담당하는 MovieService에 캐시를 적용했다.
캐시는 MovieService에 적용했다.
| 파일 | 메서드 | 적용 내용 | 설명 |
|---|---|---|---|
MovieService.java |
findAll() |
@Cacheable |
전체 영화 목록을 캐시에 저장 |
MovieService.java |
findById(Long id) |
@Cacheable |
영화 하나를 조회한 결과를 캐시에 저장 |
MovieService.java |
create() |
@CacheEvict |
새 영화가 추가되면 기존 캐시 삭제 |
- Caffeine을 사용하여 서버 내부에 데이터를 임시로 저장하는 방식으로 캐시 적용
- 처음 영화 목록 조회 시 DB에서 데이터 조회
- 조회한 영화 목록 결과를 캐시에 저장
- 이후 같은 영화 목록 조회 요청이 들어오면 DB를 다시 조회하지 않음
- 캐시에 저장된 영화 목록 데이터를 바로 반환
- 영화가 새로 추가될 경우 기존 캐시에 새 영화가 반영되지 않을 수 있음
create()메서드에@CacheEvict적용- 새 영화 추가 시 기존 영화 캐시 삭제
- 다음 영화 목록 조회 요청 시 DB에서 최신 영화 목록을 다시 조회
- 다시 조회한 최신 결과를 캐시에 저장
GlobalExceptionHandler 로그 추가
CustomException이 발생했을 때 errorCode와 message가 로그에 남도록 수정했다.
- 기존: 에러 응답만 반환
- 개선: 에러 응답 반환 + 서버 로그 기록
- API 요청 수
- API 평균 응답 시간
- 4xx / 5xx 에러 수
- ErrorCode별 발생 횟수
- 매점 주문 성공 수
- 결제 실패 수
- 락 획득 실패 수














