728x90
아이템 구매
시퀀스 다이어그램
- Item Service: 구매 로직과 도메인 흐름 관리
- Cash Service: 캐시 생성 및 차감에 대한 정합성 관리

- Client) 아이템 구매 위해 `Authorization: Bearer JWT` 헤더 함께 Item Serivce에 요청을 보냄
- Item Service) `JwtFilter` 통해 JWT 검증하고, 인증 정보를 `SecurityContext`에 저장해 `userId` 식별
- Item Service) Item DB에 접근해 구매 대상 아이템 조회하고 이 정보 기반으로 구매에 필요한 총 금액 계산
- Item Service) `FeignAtuhInterceptor` 통해 기존 `Authorization` 헤더 그대로 유지한 채
Cash Service에 캐시 차감 요청 보냄 - Cash Service) 다시 한 번 `JwtFilter` 통해 JWT 검증하고, 인증 정보를 `SecurityContext`에 저장해 `userId` 식별
- Cash Service) 해당 `userId`에 대한 Cash row를
`SELECT ... FOR ... UPDATE` 쿼리 통해 조회하며 Pessimistic Lock 획득- Cash row가 이미 존재 O -> 해당 row를 사용해 차감 로직 수행
- Cash row가 존재 X -> 최초 캐시 사용 시점에 Cash row를 lazy하게 생성한 뒤 차감 로직 수행
- Cash Service) `cash.decrease(amount)`를 호출해 캐시 잔액 차감하고, 변경 내용을 Cash DB에 반영
- 캐시 차감이 정상적으로 완료되면, Cash Service) 차감 결과를 Item Service에 응답
- Item Service) 캐시 차감 성공을 확인한 후, Item DB에 아이템 지급(or 수량 증가) 반영
- 모든 처리 완료되면, Item Service) 최종 구매 결과 Client에 반환
Cash Service
`userId`는 Cash 테이블에 하나만 존재해야 함 = 유저마다 캐시 하나 존재
`Cash`
@Entity
@Getter
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(columnNames = "userId")
}
)
public class Cash {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(unique = true, nullable = false)
private Long cashId;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private Long cashBalance;
...
}
`CashUserService` 중 `buyByCash` 메서드
@Override
@Transactional
public CashUserResponse buyByCash(CashUserRequest request) {
Long userId = userInfoProvider.getUserInfo().userId();
Cash cash;
try {
cash = cashRepository.findByUserIdForUpdate(userId)
.orElseGet(() -> cashRepository.save(
new Cash().init(userId, Type.USE)
));
} catch (DataIntegrityViolationException e) {
cash = cashRepository.findByUserIdForUpdate(userId)
.orElseThrow(() ->
new ApplicationException(CashErrorCode.CASH_RECORD_NOT_FOUND)
);
}
cash.decrease(request.amount());
cashRepository.save(cash);
return CashUserResponse.from(cash);
}
`DataIntegrityViolationException`
- DB 무결성 제약(`UNIQUE`, `NOT NULL`, `FK` 등)을 어겼을 때 Spring이 던져주는 상위 런타임 에러
- 이미 같은 `userId`를 가진 Cash row가 있는데 또 `INSERT` 하면 발생
- Case1: Cash 이미 있음
- `findByUserIdForUpdate` -> Cash 반환
- `save()` 실행 X
- `try` 블록 정상 종료
- Case2: Cash 없음 + 내가 최초
- `findByUserIdForUpdate` -> empty
- `save()` -> 성공
- `try` 블록 정상 종료
- Case3: Cash 없음 + 동시에 다른 트랜잭션도 생성
- 둘 다 `find` 결과 없음
- 나보다 다른 트랜잭션이 먼저 `INSERT`
- ex. 클라이언트) 중복 요청 - 더블 클릭, 네트워크 재시도 등
- `save()` 시도
- => DB 유니크 제약 위반 / `DataIntegrityViolationException` 발생
- => `catch` 블록에서 재조회해서 정상 흐름으로 복구
- Case1: Cash 이미 있음
<`getCurrentCash` 메서드에서 없으면 미리 생성?>
읽기 API가 쓰기를 함/ 트래픽 ↑ 조회에서 불필요한 `INSERT`/ 캐시를 안 쓰는 유저까지 전부 row 생성
=> 쓰기 시점에만 생성 (lazy init)
`CashRepository`
현재 구조에서 cashDB 하나, `userId` 기준 row 하나, 단일 서비스에서 차감 => Pessimistic Lock
public interface CashRepository extends JpaRepository<Cash, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Cash c where c.userId = :userId")
Optional<Cash> findByUserIdForUpdate(@Param("userId") Long userId);
Optional<Cash> findByUserId(Long userId);
}
테스트 코드 작성
트랜잭션 경계 테스트
- `@Transactional` 실제로 동작하는지
- 도메인 로직 중 예외 발생 시 DB 상태가 롤백되는지 검증
시나리오: 기존 캐시 잔액: 100 / 차감 요청: 200
기대 결과: `INSUFFICIENT_BALANCE` 예외 발생 / DB 변경 X
테스트 코드
@Test
@Transactional
void 트랜잭션_경계_테스트() {
// given
cashRepository.save(
new Cash().init(1L, Type.GRANT).increase(100)
);
em.flush();
em.clear();
// when
try {
cashUserService.buyByCash(new CashUserRequest(200L));
throw new AssertionError("예외가 발생해야 함");
} catch (ApplicationException e) {
// expected
}
em.clear();
// then
Cash cash = cashRepository.findByUserId(1L).orElseThrow();
assertThat(cash.getCashBalance()).isEqualTo(100);
}
- `@Transactional`: 서비스 메서드 전체를 하나의 트랜잭션으로 묶음
- `cash.decrease()` 내부에서 예외 발생 -> 트랜잭션 롤백
=> 테스트 종료 후에도 잔액 유지됨
최초 캐시 생성 - 동시성 테스트
- Cash row가 없는 유저에 대해 동시에 여러 요청이 들어와도 Cash row 1개만 생성되는지 검증
- DB `UNIQUE(userId`) + 재조회 패턴 검증
시나리오: Cash row 없음 / 동시에 10개 요청이 `buyByCash()` 호출
기대 결과: Cash row 1개 / 예외 X 정상 처리
테스트 코드
@Test
@Transactional
void 최초_캐시_생성_동시성_테스트() throws InterruptedException {
// given
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
cashUserService.buyByCash(new CashUserRequest(0L));
} finally {
latch.countDown();
}
});
}
latch.await();
em.clear();
// then
List<Cash> all = cashRepository.findAll();
assertThat(all).hasSize(1);
}
- `UNIQUE(userId)` 제약으로 동시에 `INSERT` 시동 -> 한 트랜잭션만 성공
- 실패한 트랜잭션
- `DataIntegrityViolationException` 발생
- `catch` 후 `findByUserIdForUpdate()` 재조회
=> Cash row 정확히 1개
캐시 차감 - 동시성 테스트
- `PESSIMISTIC_WRITE` 락이 실제로 동작하는지
- 동시에 여러 차감 요청이 들어와도 잔액이 음수가 되지 않는지, 차감 누락이 발생하지 않는지 검증
시나리오: 초기 `cashBalance` = 1,000 / 동시에 10개 요청이 각각 100 차감
기대 결과: 최총 `cashBalance` = 0 / 실패 요청 X / 음수 잔액 발생 X / Cash row 1개 유지
테스트 코드
@Test
void 캐시_차감_동시성_테스트() throws InterruptedException {
// given
cashRepository.save(
new Cash().init(1L, Type.GRANT).increase(1000)
);
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
cashUserService.buyByCash(new CashUserRequest(100L));
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Cash cash = cashRepository.findByUserId(1L).orElseThrow();
assertThat(cash.getCashBalance()).isEqualTo(0);
}
- `findByUserIdForUpdate()`: `PESSIMISTIC_WRITE` 락 사용해
동일한 `userId`의 Cash row에 대해 동시에 하나의 트랜잭션만 접근 가능 - Cash row를 락으로 선점 -> 잔액 차감 -> 트랜잭션 커밋 후 락 해제
=> 10개 요청 순차적으로 처리, Lost Update 발생 X, 잔액 음수 불가
참고: Pessimistic Lock vs Redis Lock
Pessimistic Lock
- DB row에 직접 락
- 트랜잭션과 강하게 결합
- 실패 시 롤백 자동
장점
- 구현 간단
- 정합성 강함
- JPA + @Transactional만으로 충분
단점
- DB 부하 증가
- 락 오래 잡으면 대기 발생
Redis Lock
- DB 밖에서 락
- 여러 서비스/DB에 걸쳐 사용 가능
- TTL 관리 필요
장점
- DB 락 안잡음
- 서비스 간 락 공유 가능
단점
- 구현 복잡
- 락 해제 실패 위험
- 네트워크 장애 고려 필요
<`@Transactional`만 있는 경우>
원자성 보장 O 동시성 보장 X
깃허브
플랜티파이
플랜티파이 has 21 repositories available. Follow their code on GitHub.
github.com
GitHub - hk-plantify/item-service: [Java] 숲꾸미기 아이템 서비스(+ cash repo)
[Java] 숲꾸미기 아이템 서비스(+ cash repo). Contribute to hk-plantify/item-service development by creating an account on GitHub.
github.com
GitHub - hk-plantify/cash-service
Contribute to hk-plantify/cash-service development by creating an account on GitHub.
github.com
728x90
반응형