동시성 문제 - Redisson(분산 락)으로 해결해보기

자라나는 ㅇㅅㅇ개발자·2024년 10월 14일
0

트러블슈팅

목록 보기
4/9

문제 상황

예매를 생성하는 api 기능에 동일한 1000개의 요청을 동시에 보낼 시

이처럼 예외처리가 되어있지만


10번의 성공과 990번의 500 에러를 반환한다.


db를 확인해보면 동일한 좌석에 대해 10건의 예매가 생성되어있다.

200코드로 성공한 10건의 예매는 동시성 문제로 예매가 성성되었고,
500코드로 실패한 990건의 요청은 예외처리 구간에서 db 조회 시

Optional<Booking> findByScheduleIdAndSeat(Long scheduleId, String seat);

이처럼 Booking 타입으로 조회가 되어야하지만 10건의 List가 조회되어 발생한 에러이다.


해결 방법 1 - @Transactional(isolation = Isolation.SERIALIZABLE)

동시성을 해결하는 간단한 방법으로 Service 코드에 적용되어있는 Transactional 어노테이션을 활용하는 것이다.

  • Isolation Level: 트랜잭션 격리 수준은 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션이 서로에게 얼마나 영향을 미치는지를 정의한다.
  • SERIALIZABLE: 가장 높은 격리 수준으로, 트랜잭션이 실행되는 동안 다른 트랜잭션이 해당 데이터에 접근할 수 없다. 이는 모든 트랜잭션이 순차적으로 실행되는 것처럼 보이게 만든다.

같은 조건으로 1000번의 요청을 보낼 시

테스트 결과를 확인하기 위해 200 성공 코드와 400 잘못된 요청 코드는 failed가 아니도록(check하도록) 하였다.

이처럼 983개의 요청은 성공(200, 400)하고, 17건의 요청은 동시성 문제로 failed가 된 것을 확인할 수 있다.


해결 방법 2 - Redis 분산락

더 확실하게 동시성 문제를 해결하고, 속도 개선을 위해 Redis를 적용해 테스트해보기로 했다.

의존성 추가

(다른 client 모듈에서 redis 모듈을 의존하고 있기 때문에 redis의 build.gradle을 사용하기 위해 implementation이 아니라 api로)

RedissonConfig 클래스를 작성

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();

        config.useSingleServer()
                .setAddress("redis://localhost:6379")
                .setPassword("systempass");

        return Redisson.create(config); // RedissonClient 인스턴스 생성
    }
}

BookingService의 createBooking 메서드에 Lock 설정을 해준다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BookingService {
    private final BookingRepository bookingRepository;
    private final BookingMapper bookingMapper;
    private final UserClient userClient;
    private final ConcertClient concertClient;
    private final PaymentService paymentService;
    private final RedissonClient redissonClient;

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public List<CreateBookingRes> createBooking(Long userId, CreateBookingReq createBookingReq) {
        // 분산 락 이름 정의 (예약 스케줄 ID 기반)
        String lockKey = "bookingLock:" + createBookingReq.scheduleId();
        RLock lock = redissonClient.getLock(lockKey); // 락 객체 생성

        // 락을 획득하고, 예외 발생 시 락 해제
        try {
            lock.lock(); // 락 획득

            // TODO : 대기열
            if (!userId.equals(createBookingReq.userId())) {
                throw new GlobalException(ErrorCase.NOT_AUTHORIZED);
            }

            // 이미 예약된 좌석인지 확인
            List<Booking> bookings = new ArrayList<>();
            createBookingReq.seat().forEach(seat -> {
                bookingRepository.findByScheduleIdAndSeat(createBookingReq.scheduleId(), seat)
                        .ifPresent(booking -> {
                            throw new GlobalException(ErrorCase.ALREADY_BOOKED_SEAT);
                        });
                Booking booking = createBookingReq.toEntity(seat);
                bookings.add(booking);
            });

            // 예매 정보 저장
            bookingRepository.saveAll(bookings);

            return bookings.stream().map(bookingMapper::toCreateBookingRes).toList();

        } finally {
            // 락 해제
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    // ...
}
  • RLock lock = redissonClient.getLock(lockKey);를 통해 특정 자원(여기서는 예약 스케줄 ID에 기반한 락)을 위한 락 객체를 생성한다.
  • lock.lock();를 호출하면, 현재 스레드(트랜잭션)가 락을 획득하려고 시도한다.
  • 만약 다른 스레드가 같은 락을 이미 획득한 상태라면, 현재 스레드는 락을 획득할 때까지 대기하게된다.
  • 락을 획득하지 못한 요청들은 Redisson의 내부 대기 큐에 대기한다.
  • 이 큐는 락을 요청한 순서대로 요청을 처리하며, 락이 해제되면 대기 중인 요청 중 하나가 락을 획득하게 되고, 대기 중인 요청들은 해당 락이 해제될 때까지 블로킹 상태로 대기한다.

전부 다 성공

DB에는 당연히 하나의 데이터만 저장되어있다.


1000건의 요청으로는 뭔가 찝찝한 느낌이 들어 요청을 5000건으로 늘려서 다시 테스트해보기로 했다.

@Transactional(isolation = Isolation.SERIALIZABLE)만 적용한 테스트

분산 락을 적용한 테스트



추가 글 1

order-service의 ComponentConfig 클래스에

@Configuration
@ComponentScan(basePackages = {
        "com.fortickets.exception",
        "com.fortickets.jpa",
        "com.fortickets.security",
        "com.fortickets.redis"
})
public class ComponentConfig {

}

이처럼 레디스 스캔 범위를 지정해줘야 order-service에서 Redis를 사용할 수 있다.

추가 글 2

Redis 적용 후 order-service application 실행 시 Redis에 설정된 비밀번호를 찾지 못하는 경우

터미널 명령어로 CLI에 접속

redis-cli -h localhost -p 6379 -a systempass

  • config get requirepass
    : 설정된 비밀번호(requirepass)를 알려줘
  • 1) "requirepass"
    2) ""
    : 비밀번호(requirepass)가 비어있어
  • config set requirepass systempass
    : 비밀번호를 systempass로 지정해줘
  • OK
    : 구랭!

0개의 댓글