본문 바로가기
캠프/항해 플러스 4기

콘서트 예약 서비스에서 발생할 수 있는 동시성 이슈와 처리

by 핏차 2024. 5. 3.

[항해 플러스 백엔드 4기 7주차 과제]

서버 구축 시나리오에서 발생할 수 있는 동시성 이슈와 그 처리 방안에 대한 비교, 선택

 

 

0. 현재 구축된 콘서트 예약 서비스에서 발생할 수 있는 동시성 이슈

  • 좌석 예약 (선점)
  • 유저의 포인트 충전 및 사용

 

1. 적용해볼 수 있는 동시성 제어 방법

  • DB Unique Index
    : 중복 데이터의 입력(insert)을 방지하는 용도
    - 장점: DB의 유니크 인덱스 기능을 이용해 로직이 매우 간단함, 인덱스 사용으로 많은 요청도 빠른 처리 가능
    - 단점: DB에 의존함, 중복 입력만을 방지하므로 다양한 로직에 한계가 존재, DB의 수평적 확장이 어려움, DB 하나에 모든 데이터가               집중되어야 함
  • Database Lock
    : DB 의 데이터에 대해 동시에 접근하는 것을 제어, 데이터 수정(update)를 방지
      DB Lock 을 이용해 동시성 제어하는 방식에서 중요한 점은 트랜잭션 간 “충돌” 의 빈도!
    • Optimistic Lock (낙관적 락)
      : 충돌이 드물게 발생할 것이라 가정하고, 실제로 데이터 저장 시 충돌 여부를 검사
        트랜잭션, Lock 설정 없이 데이터 정합성을 보장할 수 있으므로 성능적으로 우위
      - 장점: 자원에 대한 충돌이 적을 때 높은 성능, 데드락 가능성이 없음
      - 단점: 충돌 감지 후 처리 로직 필요, 충돌 발생 시 롤백 처리가 복잡할 수 있음
    • Pessimistic Lock (비관적 락)
      : 충돌이 잦은 상황으로 가정
        트랜잭션이 특정 데이터를 사용하는 동안 다른 트랜잭션들이 해당 데이터에 엑세스하지 못하도록 미리 잠금을 거는 방식
        특정 자원에 대해 Lock 설정으로 선점해 정합성을 보장
      - 장점: 높은 데이터 일관성, 처리 과정에서 발생할 수 있는 충돌이나 문제 미리 예방 가능
      - 단점: 자원에 대한 접근을 과도하게 제한할 수 있어, 성능 저하 우려
                 데드락 위험 존재, 리소스를 오랜 시간 점유하여 리소스 소비가 비효율적
  • 분산 락
    • Redis
      : 키-값 형태의 고성능 비관계형 데이터베이스, 인메모리 데이터 구조 저장소, 네트워크를 통해 저장 및 접근
      - 장점: 빠른 성능, 유연성(다양한 종류의 데이터 타입 지원), 간단한 확장성(데이터의 저장&부하 분산), 지속성
      - 단점: 메모리 양=데이터 최대 용량 제한, 데이터 보안과 지속성의 취약점 존재, 비용 문제
    • Kafka
      : Apache Software Foundation에서 관리하는 오픈 소스 스트림 처리 플랫폼
        대량의 데이터 스트림을 신속하게 처리 - 분산 시스템 설계
        메시지 큐의 일종으로 볼 수 있지만, 메시지 큐의 메시지 저장, 전달의 기본을 넘어서 보다 광범위한 데이트 스트리밍 및 처리
      - 장점: 높은 처리량, 확장성, 내구성, 고가용성, 다양한 사용 사례
      - 단점: 관리가 복잡함, 여러 파티션 간에는 순서 보장이 어려움, 높은 리소스 사용량

좌석 예약 (선점)


1. DB Unique Index 사용 - No

현재 데이터 구조 상 좌석 예약을 하려면

1. Reservation 테이블에 concertDateId(콘서트 날짜 PK), seatNum(좌석번호) 등으로 콘서트 예약 정보를 insert

2. Seat 테이블에서 해당 concertDateId(콘서트 날짜 PK), seatNum(좌석번호)가 동일한 로우를 찾아 status(좌석 상태)를 update

이렇게 두 가지의 과정을 거친다.

 

콘서트 예약에서는 콘서트 날짜 당 좌석번호에 각 하나의 예약만이 가능하다. concertDateId, seatNum 두 컬럼이 Reservation 테이블에 중복 insert가 되면 안되는 조건을 이용하여 콘서트 예약 정보를 등록할 때 DB Unique Index로 중복 방지 처리가 가능하다.

// 엔티티
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@Table(name = "reservation", uniqueConstraints = {
	// unique index 조건
        @UniqueConstraint(columnNames = {"concertDateId", "seatNum"})
})
public class Reservation extends BaseDateTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long reservationId;

    // ...생략
}

// 서비스
@Override
@Transactional
public ReserveResponse reserve(ReserveRequest request) {
    try {
        // validator
        reservationValidator.checkReserved(request.concertDateId(), request.seatNum());

	// insert 시 유니크 인덱스 키로 제약
        Reservation reservation = reservationRepository.save(request.toEntity(concertReader, userReader));
        concertService.patchSeatStatus(reservation.getConcertDateId(), reservation.getSeatNum(), Seat.Status.DISABLE);

	// ...생략

    } catch (DataIntegrityViolationException e) {
        // 유니크 제약 조건(concertDateId, seatId) 위반 시
        throw new CustomException(ReservationExceptionEnum.ALREADY_RESERVED, null, LogLevel.INFO);
    }
}
  • 구현의 복잡도
    : 매우 쉬움
  • 성능
    : index로 정렬 저장되어 validator 조회 성능은 향상하지만, 나머지 insert, update, delete 작업에서는 index 중복 데이터가 없는지 확인하고, index 구조를 업데이트해야 한다. 이 과정에서 디스크 I/O가 발생하여 성능 저하가 있다.
    수만 건의 동시 요청이 발생할 경우, index 고유성 검증 과정에서 많은 양의 I/O와 비용이 발생하여 많은 부하가 생긴다.
    Unique index 컬럼에 동시 쓰기가 시도될 때, 락 경합이 발생하여 트랜잭션 대기 시간이 증가할 수 있다.
  • 효율성
    : index 데이터를 별도로 유지해야 하므로, 더 많은 리소스가 필요하여 데이터 저장 용량 관리에 비효율적이다.
    index 고유성 제약 조건 위배로 인해 발생하는 rollback 자원이 필요해 부가적인 시스템 부하를 발생시킨다.
    DB에 직접적으로 통신해야 하므로, DB 자체의 부하가 가해질 뿐만 아니라 서버가 DB와 통신하는 과정에서도 병목 현상이 생길 수 있다.
  • 판단
    : 어드민과 같이 동시적 요청이 거의 없거나, 중복 등록 요청을 제어해야 하는 간단한 처리일 경우 이 방식을 사용해도 유용할 듯 하다.
    하지만 실제로 많은 양의 요청이 동시에 온다고 가정했을 경우, 서비스 로직 중 대규모 트래픽이 예상되고, 삽입+갱신+삭제 작업이 빈번한 시스템에서 동시성을 제어하는 용도로 사용하기에는 insert만을 제어하므로 사용이 제한적이며, 상당한 부하가 예상되므로 사용하지 않는 것이 좋을 것 같다.

2. 비관적 락 (Pessimistic Lock) 사용 - No

좌석 예약 자체가 동시 요청이 많이 들어오고, 충돌이 잦은 상황이라고 가정되기 때문에 DB 락 중 비관적 락을 사용해 보았다.

데이터에 우선 락을 걸어 정합성을 보장하고, 배타락을 걸어 읽기와 쓰기 모두 잠근다.

@Override
@Transactional
public ReserveResponse reserve(ReserveRequest request) {
    try {
        // 동시성 제어 - 비관적 락 적용
        concertService.patchSeatStatus(request.concertDateId(), request.seatNum(), Seat.Status.DISABLE);

        // validator
        reservationValidator.checkReserved(request.concertDateId(), request.seatNum());

        Reservation reservation = addReservation(request);

        Concert concert = concertReader.findConcert(reservation.getConcertId());
        ConcertDate concertDate = concertReader.findConcertDate(reservation.getConcertDateId());
        Seat seat = concertReader.findSeat(reservation.getConcertDateId(), reservation.getSeatNum());

        // 예약 임시 점유 event 발행
        eventPublisher.publishEvent(new ReservationOccupiedEvent(this, reservation.getReservationId()));

        return ReserveResponse.from(reservation, concert, concertDate, seat);

    } catch (PessimisticLockException e) {
        // 락 획득 실패 시
        throw new CustomException(ReservationExceptionEnum.ALREADY_RESERVED, null, LogLevel.INFO);
    }
}
    
// SeatJpaRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Seat s where s.concertDate.concertDateId = :concertDateId and s.seatNum = :seatNum")
Seat findSeatByConcertDate_concertDateIdAndSeatNum(@Param("concertDateId") Long concertDateId, @Param("seatNum") int seatNum);

100건의 동일 좌석 예약 요청 시

  • 구현의 복잡도
    : 쉬움
  • 성능
    : Race Condition이 아주 빈번하게 일어나도 성능이 괜찮게 유지가 된다. 반면 충돌이 없으면 오버헤드가 있다.
    데이터의 일관성이 보장된다.
  • 효율성
    : DB 단에서 Lock 을 설정하여 다른 트랜잭션의 읽기 및 쓰기를 제어하기 때문에 한 트랜잭션 작업이 정상적으로 끝날 때까지 다른 트랜잭션 작업들이 대기하여야 한다.
    데드락의 위험성이 있다.
  • 판단
    : 구현도 쉽고 성능 유지도 되지만, DB 단의 앞선 Lock이 해제되기를 기다렸다가 동작을 수행해야 하기 때문에 콘서트 예약 상황을 가정했을 때, 동시성 측면에서 읽기 비율이 많을 것으로 예상되어 적합하지 않을 것 같다.
    ** 읽기가 가능하도록 할 수 있지만, 트랜잭션 대기는 있다.

 


3. 낙관적 락 (Optimistic Lock) 사용 - No

낙관적 락은 데이터를 읽을 때는 락을 걸지 않고, 업데이트할 때만 이전 데이터와 버전을 비교하여 충돌 여부를 판단한다.

앞서 대기열로 진입량을 조절하면 좌석 예약 충돌이 아주 많지 않고, 충돌 시 재시도 로직 없이 예외를 리턴하면 부하가 괜찮지 않을까 생각했다.

@Override
@Transactional
public ReserveResponse reserve(ReserveRequest request) {
    try {
        // validator
        reservationValidator.checkReserved(request.concertDateId(), request.seatNum());

        // 동시성 제어 - 낙관적 락 적용
        concertService.patchSeatStatus(request.concertDateId(), request.seatNum(), Seat.Status.DISABLE);

        Reservation reservation = reservationRepository.save(request.toEntity());

        Concert concert = concertReader.findConcert(reservation.getConcertId());
        ConcertDate concertDate = concertReader.findConcertDate(reservation.getConcertDateId());
        Seat seat = concertReader.findSeat(reservation.getConcertDateId(), reservation.getSeatNum());

        // 예약 임시 점유 event 발행
        eventPublisher.publishEvent(new ReservationOccupiedEvent(this, reservation.getReservationId()));

        return ReserveResponse.from(reservation, concert, concertDate, seat);

    } catch (ObjectOptimisticLockingFailureException e) {
        // 락 획득 실패 시
        throw new CustomException(ReservationExceptionEnum.ALREADY_RESERVED, null, LogLevel.INFO);
    }
}

// entity
// 낙관적 락 적용
@Version
private Long version;

100건의 동일 좌석 예약 요청 시

  • 구현의 복잡도
    : 쉬움
  • 성능 & 효율성
    : 다른 작업이 영향을 받지 않아서 읽기 성능은 좋지만 충돌이 빈번하게 일어날 시 트랜잭션을 롤백하는 데 리소스가 있다.
    실제로 100건의 동일 요청을 보냈을 때, 비관적 락을 적용했을 때보다 오히려 작업을 종료하는 데 오래 걸린 것을 볼 수 있다.
  • 판단
    : 사용자가 다른 작업에 영향 없이 좌석 예약 정보를 읽을 수 있어 좋지만, 만약 인기 있는 좌석이 정해져 있어서 한 좌석에 많은 요청이 동시에 들어온다면 하나의 요청을 제외하고 롤백해야 한다.
    공유 데이터에 대하여 읽기 비율이 높고, 충돌 빈도가 적거나 충돌에 대해서 재시도 없이 실패로 처리하는 로직이라면 사용을 고려할 수 있을 것 같다. 하지만 충돌 빈도가 증가한다면, 재시도 로직이 없더라도 성능이 오히려 저하될 수 있을 것 같다.

4. Redis 사용 - Yes

key-value 기반의 원자성을 이용한 Redis 를 통해 DB 부하를 최소화하고,

메모리에 저장되는 방식덕분에 고속으로 처리가 가능하다.

DB connection은 자원이 비싸기 때문에 빈번하게 데이터 lock을 잡는것이 성능, 비용상 비효율적이었는데, redis는 고속의 메모리 처리 방식이며 여기서 처리된 결과를 이용해 DB에 작업한다면 이상적인 동시성 제어가 가능할 것이라고 생각하였다.

 

시작하기 전에 자바에서 Redis를 쓸 수 있게 해주는 클라이언트 선택을 고려해야 한다.

4-1. Lettuce - 스핀 락 방식
setnx(SET if Not eXist) 메소드를 활용하여 사용자가 직접 분산락을 구현해야 하는 방식
비동기 방식으로, Jedis보다 성능이 우월하다.
Lock의 획득에 실패했을 경우 계속 lock을 점유하려고 시도하므로, 요청이 많을수록 redis 부하가 커진다.
Lock의 timeout이 지정되어 있지 않아 무한 루프의 위험성이 존재한다. 이를 피하기 위해 최대 허용시간 또는 횟수를 지정해주는 방법이 있다.

4-2. Redisson - Pub/Sub 방식
라이브러리에서 Lock 구현체의 형태로 분산락을 제공한다.
메시지에 대한 publish와 subscribe 기능을 지원하여 락을 획득 및 해제하는 로직 구현
"대기 없이 tryLock 메소드를 이용해서 lock 획득에 성공하면 true를 반환한다."
"Lock이 해제되면 lock을 구독하는 클라이언트는 해제 신호를 받고 lock 획득을 시도한다."
이렇게 pub/sub 방식으로 스핀 락 방식에 비해 redis에 지속적으로 lock 획득 요청을 보내는 과정이 사라져 redis에 부하가 적어진다.

 

Spring Data Redis를 사용하면 기본적으로 Lettuce를 지원하기 때문에 좀 더 사용하기 편하다는 장점이 있지만, Lettuce로 분산 락을 구현하려면 반드시 스핀 락의 형태로 구현해야 한다는 단점이 있다. 때문에 필연적으로 Redis에 많은 부하를 가하게 되고, lock의 타임아웃을 직접 구현하여 처리해야만 한다. 반면에 Redisson은 부하와 타임아웃에 대한 문제를 해결할 수 있다.

 

처음에는 선착순 한 명만 획득해서 예약 가능하기 때문에, 스핀 락의 형태에서 획득 재시도를 포기하도록 구현하면 부하를 해결하면서 동시성 처리가 잘 될 것 같다고 생각하기도 했다.

여기서
1. Lock을 획득한 1번 유저가 예약을 실패하면 오히려 2,3번이 아닌 그 뒤의 9번 유저가 해당 Lock을 잡을 수도 있어서 불공정하지 않을까? 고려할 점도 있지만 예약 요청 이후 결제 로직은 요청이 따로 분리되어 있기 때문에 Lock 획득에 성공하면 예약이 성공한다고 가정할 수 있고,
2. 만약 시스템 에러로 예약 과정 중 실패한다면? 이미 예약을 선점한 이후 잃어버렸다고 생각하여 바로 다음의 유저가 아닌 그 시점에 진입한 유저가 획득해도 괜찮을 것 같았다.

 

하지만 결국 콘서트 좌석 예약 로직은 재시도가 중요한 비즈니스이다. 사용자 입장에서 락을 획득하지 못하였다고 튕겨져버리면 안 되고, 재시도 시에 좌석이 이미 다른 사용자에 의해 예약 처리되었다는 것을 반환받아야 한다.

따라서 Redisson을 활용하여 재시도 시 부하를 덜 주는 방식으로 구현해보았다.

Redisson 으로 차주에 구현해 볼 대기열 시스템( = 진입 가능한 상태를 획득할 때까지 재시도 필요)을 구현해보면 좋을 것 같다.

AOP 방식으로 (AopForTransaction 메서드로 별도 트랜잭션으로 항상 분리) Lock -> transaction -> commit -> unLock 의 과정을 간편하게 적용함.

@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.host}")
    private String host;
    @Value("${spring.data.redis.port}")
    private Integer port;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        RedissonClient redisson;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
        redisson = Redisson.create(config);
        return redisson;
    }
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLock {

    /**
     * 락의 이름
     */
    String key();

    /**
     * 락의 시간 단위
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 락을 기다리는 시간 (default - 5s)
     * 락 획득을 위해 waitTime 만큼 대기한다
     */
    long waitTime() default 5L;

    /**
     * 락 임대 시간 (default - 3s)
     * 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
     */
    long leaseTime() default 3L;
}

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RedissonLockAspect {

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(io.hhplus.server.base.redis.RedissonLock)")
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RedissonLock redissonLock = method.getAnnotation(RedissonLock.class);

        RLock lock = redissonClient.getLock(redissonLock.key());
        try {
            // 락 획득 시도
            boolean available  = lock.tryLock(redissonLock.waitTime(), redissonLock.leaseTime(), redissonLock.timeUnit());
            if (!available) {
                throw new IllegalStateException("Unable to acquire lock");
            }
            return aopForTransaction.proceed(joinPoint);
        } finally {
            lock.unlock();
        }
    }
}

// 사용
@Override
@Transactional
@RedissonLock(key = "userLock")
public ReserveResponse reserve(ReserveRequest request) {
    // validator
    reservationValidator.checkReserved(request.concertDateId(), request.seatNum());

    concertService.patchSeatStatus(request.concertDateId(), request.seatNum(), Seat.Status.DISABLE);
    Reservation reservation = reservationRepository.save(request.toEntity());

    Concert concert = concertReader.findConcert(reservation.getConcertId());
    ConcertDate concertDate = concertReader.findConcertDate(reservation.getConcertDateId());
    Seat seat = concertReader.findSeat(reservation.getConcertDateId(), reservation.getSeatNum());

    // 예약 임시 점유 event 발행
    eventPublisher.publishEvent(new ReservationOccupiedEvent(this, reservation.getReservationId()));

    return ReserveResponse.from(reservation, concert, concertDate, seat);
}


// 예약 임시 점유 모니터링 클래스도 Redisson 사용
// ...생략
    // Redisson 지연 큐 선언
    private RBlockingQueue<OccupyTempReservationDto> tempReservationQueue;
    private RDelayedQueue<OccupyTempReservationDto> delayedReservationQueue;

    @PostConstruct
    public void init() {
        // Redisson 큐 초기화
        tempReservationQueue = redissonClient.getBlockingQueue("tempReservationQueue");
        delayedReservationQueue = redissonClient.getDelayedQueue(tempReservationQueue);
    }

    public void occupyReservation(Long reservationId) {
        // 예약 요청을 지연 큐에 추가, 5분 후에 처리되도록 설정
        OccupyTempReservationDto occupyDto = OccupyTempReservationDto.toOccupy(reservationId);
        delayedReservationQueue.offer(occupyDto, 5, TimeUnit.MINUTES);
    }

    @PreDestroy
    public void destroy() {
        delayedReservationQueue.destroy();
    }

    public void reservationMonitoring() {
        new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    // 요소가 있을 때까지 블록
                    OccupyTempReservationDto occupyDto = tempReservationQueue.take();
                    // 예약, 결제 상태 확인 및 처리 로직
                    processReservation(occupyDto);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }).start();
    }
// ...생략
  • 구현의 복잡도
    : 생각보다 간단
  • 성능 & 효율성
    : 락을 획득해야 트랜잭션, 데이터 처리가 되기 때문에 DB 부하가 줄고, 락을 획득하기 위해 대기하는 동안 풀링 방식으로 자원 소모를 하지 않기 때문에 효율적이다.
    메모리를 사용하기 때문에 디스크를 사용하는 데이터베이스보다 더 빠르게 락 획득, 해제 처리가 가능하여 성능상 이점이 있다.

    무엇보다 락 획득, 해제가 가장 큰 범위의 과정이기 때문에 처리가 매우 안전하다.
  • 판단
    : Redisson을 이용하면 구현도 쉽고 In-Memory 캐싱, Pub/Sub 방식으로 인한 부하 절감 등 이점이 많다.
    콘서트 예매 서비스의 사용자가 많다고 가정했을 경우, Redis 방식을 선택하면 좋을 듯 하다.

5. Kafka 사용 - 보류

  • 구현의 복잡도
    : 다른 방식에 비해 난이도 있음
  • 성능
    : 좋음
  • 판단
    : 우선 현재 kafka를 사용해보기에는
    1. 설정, 관리, 모니터링이 복잡하여 지금 규모에서는 시스템의 오버헤드를 증가시킬 수 있다.
    2. 러닝 커브가 크다.
    라는 문제가 있으며, 
    시스템 구조상 과한 선택이라고 생각하여 시스템 상 더 간단하거나 적합한 대안을 고려하는 것이 좋다고 생각하였다.

kafka 역시 차주에 구현해 볼 대기열 시스템( = 진입 가능한 상태를 획득할 때까지 재시도 필요)의 구현 방식으로 고려해보면 좋을 것 같다.

 


유저 포인트 사용 & 충전


1. 비관적 락 (Pessimistic Lock) 사용 - Yes

포인트 사용 및 충전 로직은 각각의 유저 당 업데이트되는 로우가 각각 존재하므로, 많은 충돌이 일어날 것이라 예상되지 않는다.

하지만 금융 관련 트랜잭션 처리에서는 데이터 일관성과 안정성이 중요하기 때문에 비관적 락을 사용하는 것도 좋다고 생각하였다.

락을 장시간 유지하여 시스템의 처리량이 저하되어도 괜찮을 것 같다.

@Override
@Transactional
public GetBalanceResponse charge(Long userId, ChargeRequest request) {
    // 비관적 락 적용
    Users users = userRepository.findByIdWithPessimisticLock(userId);
    users = users.chargeBalance(BigDecimal.valueOf(request.amount()));
    return GetBalanceResponse.from(users);
}

@Override
@Transactional
public GetBalanceResponse use(Long userId, UseRequest request) {
    // 비관적 락 적용
    Users users = userRepository.findByIdWithPessimisticLock(userId);

    userValidator.insufficientBalance(users.getBalance(), BigDecimal.valueOf(request.amount()));

    users = users.useBalance(BigDecimal.valueOf(request.amount()));
    return GetBalanceResponse.from(users);
}

// UserJpaRepository
public interface UserJpaRepository extends JpaRepository<Users, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select u from Users u where u.userId = :userId")
    Users findByIdWithPessimisticLock(@Param("userId") Long userId);
}
  • 구현의 복잡도
    : 쉬움
  • 성능&효율성
    :  데이터의 일관성이 보장되며, 잔액의 충전 및 사용이 안정적으로 순차적으로 작업된다.
  • 판단
    : 구현도 쉽고 데이터의 일관성이 잘 보장되어, 동시적이 요청이 많이 들어오지 않을 것으로 예상되어 배타락으로 락을 점유하고 있어도 큰 문제가 없을 것 같다. 

2. 낙관적 락 (Optimistic Lock) 사용 - No

동시성 충돌이 자주 발생하지 않을 것으로 예상되어 낙관적 락이 효율적일 수 있으며, 처리 성능이 좋다.

// UserFacade 파사드 생성
@Slf4j
@Component
@RequiredArgsConstructor
public class UserFacade implements UserInterface {

    private final UserService userService;
    private static final int MAX_RETRIES = 5;

    @Override
    public GetBalanceResponse chargeWithRetry(Long userId, ChargeRequest request) {
        int attempt = 0;
        while (true) {
            try {
                return userService.charge(userId, request);
            } catch (ObjectOptimisticLockingFailureException | OptimisticLockException ex) {
                if (++attempt >= MAX_RETRIES) throw ex;
                log.info(":: user charge 충돌 감지, 재시도 시도: {}", attempt);
            }
        }
    }

    @Override
    public GetBalanceResponse useWithRetry(Long userId, UseRequest request) {
        int attempt = 0;
        while (true) {
            try {
                return userService.use(userId, request);
            } catch (ObjectOptimisticLockingFailureException | OptimisticLockException ex) {
                if (++attempt >= MAX_RETRIES) throw ex;
                log.info(":: user use 충돌 감지, 재시도 시도: {}", attempt);
            }
        }
    }
}

// UserJpaRepository
public interface UserJpaRepository extends JpaRepository<Users, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select u from Users u where u.userId = :userId")
    Users findByIdWithOptimisticLock(@Param("userId") Long userId);
}

// entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@Table(name = "users")
public class Users {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    @Column(nullable = false)
    private BigDecimal balance;

    @Version
    private Long version;
    
//...생략
}
  • 구현의 복잡도
    : 쉬움
  • 성능&효율성
    성능은 좋지만 포인트 사용 및 충전이 실패했을 경우 재시도 로직을 짠다던가, 충돌 감지와 해결 로직을 애플리케이션 레벨에서 구현하는 것이 조금 비효율적으로 느껴졌다.
  • 판단
    : 구현도 쉽고 성능도 좋지만 충돌 발생 시 로직을 구현해야 하는 것이 불필요하게 느껴졌다.
    만약 재시도가 계속 실패한다면?
    또한 동시 요청 중 한건만 성공해야 하는 케이스가 아니라고 생각되었다. 유저가 여러번 충전하면 여러번 다 순차적으로 충전이 되고, 여러번 사용 요청하면 여러번 다 사용되어야 하지 않을까? 

3. Redis 사용 - Yes

유저 포인트 충전 & 사용 서비스는 충돌이 일어났을 때, 재시도가 필요하다고 생각하여 재시도 시 부하가 덜 있는 Redisson의 분산락을 선택하였다. AOP 방식으로 Lock -> transaction -> commit -> unLock 의 과정을 간편하게 구현하였다.

// Redisson 설정 코드는 위의 좌석 예약 로직 코드와 같다.

// 사용
@Override
@Transactional
@RedissonLock("userLock")
public GetBalanceResponse charge(Long userId, ChargeRequest request) {
    Users users = userRepository.findByIdWithPessimisticLock(userId);
    users = users.chargeBalance(BigDecimal.valueOf(request.amount()));
    return GetBalanceResponse.from(users);
}

@Override
@Transactional
@RedissonLock("userLock")
public GetBalanceResponse use(Long userId, UseRequest request) {
    Users users = userRepository.findByIdWithPessimisticLock(userId);

    userValidator.insufficientBalance(users.getBalance(), BigDecimal.valueOf(request.amount()));

    users = users.useBalance(BigDecimal.valueOf(request.amount()));
    return GetBalanceResponse.from(users);
}
  • 구현의 복잡도
    : 생각보다 간단
  • 성능&효율성
    데이터의 정합성도 잘 보장되며, 안정적이다.
  • 판단
    : 충돌이 잦지 않다고 가정되는 서비스 로직이기 때문에, 만약 이 충전&사용 로직만 본다면 Redis까지 구현하지는 않을 것 같다. 하지만 이미 프로젝트 내부에서 Redis 분산락을 구현하여 이용하고 있다면, 함께 사용해도 좋을 것 같다.

 

4. Kafka 사용 - 보류

위의 좌석 예약 기능과 같은 이유로 kafka 사용은 적합하지 않다고 판단하였다.

 

 

728x90

'캠프 > 항해 플러스 4기' 카테고리의 다른 글

쿼리 성능 개선을 위한 DB index 처리  (0) 2024.05.08
[7주차] WIL  (0) 2024.05.04
[6주차] WIL  (0) 2024.04.27
[5주차] Trouble Shooting  (0) 2024.04.18
[5주차] WIL  (0) 2024.04.18