Skip to content

Latest commit

 

History

History
434 lines (334 loc) · 17.1 KB

콘서트 예약 시스템에서의 동시성 이슈와 해결책.md

File metadata and controls

434 lines (334 loc) · 17.1 KB

콘서트 예약 시스템에서의 동시성 이슈와 해결책

1. 서론

1.1 과제 목적

  • 레디스를 적용하는 단계로 넘어가기 이전, 가능한 최대한 데이터베이스의 락과 트랜잭션을 이용하여 동시성 문제를 해결하는 방법을 깊이 있게 연구해보려 합니다.
  • 동시성 이슈 파악, 동시성 제어 방식 도입, 장단점 파악 및 비교를 진행하려합니다.

1.2 테스트 도구 소개

  • k6는 Web Application, API, Microservices 등의 성능을 테스트하기 위한 오픈소스 도구입니다.
  • 스크립트를 작성하여 테스트를 수행하고, 결과를 분석할 수 있습니다.

2. 동시성 이슈 분석

2.1 시나리오 설명

  • 상황 1 : 두 명 이상의 사용자가 동시에 같은 좌석을 선택하고 예약 버튼을 누르는 경우. -> 좌석 중복 예약 문제
  • 상황 2 : 한 명의 사용자가 동시에 본인의 계정으로 탭 3개를 띄워놓는다던지 하는 방식으로 포인트 사용을 시도하는 경우. -> 포인트 중복 사용 문제

2.2 문제의 영향

  • 데이터 무결성 위반: 동시성 이슈로 인해 좌석이 초과 예약되거나 포인트 잔액이 음수가 되는 등 데이터의 일관성이 깨질 수 있습니다.
  • 고객 신뢰 하락: 사용자들은 시스템에 대한 신뢰를 잃을 수 있으며, 이는 서비스의 평판에 부정적인 영향을 미칩니다.
  • 시스템 안정성 저하: 동시성 문제가 해결되지 않으면 시스템 오류와 비정상 동작이 발생하여 전체적인 안정성이 떨어집니다.

3. 동시성 제어 방식 소개

3.1 데이터베이스 락과 트랜잭션

동시성 문제를 해결하기 위해 이번 주차에는 데이터베이스에서 제공하는 락과 트랜잭션을 활용합니다.

3.1.1 락의 종류

  • 행 락(Row Lock): 특정 행(row)에 대해 락을 걸어 다른 트랜잭션이 해당 행을 수정하지 못하게 합니다.
  • 테이블 락(Table Lock): 테이블 전체에 락을 걸어 다른 트랜잭션이 테이블에 접근하지 못하게 합니다.

3.1.2 트랜잭션 원리

  • ACID 특성:
    • 원자성(Atomicity): 트랜잭션 내의 모든 작업이 모두 수행되거나 모두 수행되지 않아야 합니다.
    • 일관성(Consistency): 트랜잭션이 완료되면 데이터베이스는 일관성 있는 상태를 유지해야 합니다.
    • 격리성(Isolation): 동시에 실행되는 트랜잭션들은 서로 간섭하지 않아야 합니다.
    • 지속성(Durability): 트랜잭션이 커밋되면 그 결과는 영구적으로 반영되어야 합니다.

3.1.3 적용 방법

a) 비관적 락(Pessimistic Lock)
  • 설명: 데이터에 접근할 때 락을 걸어 다른 트랜잭션이 접근하지 못하도록 하는 방식입니다.
  • 적용 방법:
    • SQL의 SELECT ... FOR UPDATE 문을 사용합니다.
    • JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 사용합니다.
  • 장점:
    • 동시성 제어가 확실하여 데이터의 정합성을 보장합니다.
  • 단점:
    • 락으로 인한 대기 시간이 발생하여 성능이 저하될 수 있습니다.
    • 데드락의 위험이 있습니다.
b) 낙관적 락(Optimistic Lock)
  • 설명: 데이터 충돌이 드물다고 가정하고, 업데이트 시 충돌 여부를 확인하여 처리하는 방식입니다.
  • 적용 방법:
    • 엔티티에 @Version 어노테이션을 사용하여 버전 관리를 합니다.
  • 장점:
    • 락을 사용하지 않아 성능이 우수합니다.
    • 데이터 읽기 작업에 대한 병행성이 높습니다.
  • 단점:
    • 충돌 발생 시 예외가 발생하며, 재시도 로직이 필요합니다.
    • 충돌 빈도가 높을 경우 오히려 성능이 저하될 수 있습니다.

3.2 분산 락

3.2.1 분산 락의 원리

  • 설명: 분산 시스템 환경에서 여러 노드가 공유 자원에 동시에 접근하는 것을 제어하기 위한 락입니다.
  • 적용 방법:
    • Redis와 같은 외부 시스템을 이용하여 락을 관리합니다.
    • Redis의 SETNX 명령어나 Redisson 라이브러리를 사용합니다.
  • 장점:
    • 분산 환경에서 동시성 제어가 가능합니다.
    • 빠른 성능과 높은 처리량을 제공합니다.
  • 단점:
    • 추가적인 인프라가 필요하며, 구현 복잡도가 높습니다.
    • 네트워크 지연이나 Redis 장애 시 문제가 발생할 수 있습니다.

3.2.2 Redis를 활용한 분산 락 구현

  • Redis SETNX 명령어:
    • SETNX(Set if Not Exists)는 키가 존재하지 않을 때만 값을 설정합니다.
    • 이를 이용하여 락을 획득하고, EXPIRE 명령어로 락의 유효 기간을 설정합니다.
  • 락 획득 및 해제 절차:
    1. 락 획득: SETNX 명령어로 락 키를 설정합니다.
    2. 락 유지: 작업을 수행합니다.
    3. 락 해제: 작업 완료 후 DEL 명령어로 락 키를 삭제합니다.
  • 주의 사항:
    • 락의 유효 기간 설정으로 데드락을 방지해야 합니다.
    • 원자성을 보장하기 위해 Lua 스크립트나 Redisson 등의 라이브러리를 사용할 수 있습니다.

3.2.3 제한 사항

  • 구현 복잡도:
    • 분산 락은 구현이 복잡하며, 잘못 구현하면 데이터 정합성이 깨질 수 있습니다.
  • 추가 인프라 필요성:
    • Redis 등의 외부 시스템을 구축하고 운영해야 합니다.
  • 성능 영향:
    • 네트워크 통신으로 인한 지연이 발생할 수 있습니다.

4. 구현 및 테스트 방법

4.1 구현 과정

이번 주에는 데이터베이스의 락과 트랜잭션을 활용하여 동시성 문제를 해결하였습니다.

4.1.1 데이터베이스 락 적용

a) 좌석 예약 시 비관적 락 적용
  • 상황 설명:

    • 좌석 예약 기능에서 다수의 사용자가 동시에 같은 좌석을 예약하려고 시도할 수 있습니다.
  • 구현 방법:

    • JPA의 @Lock 어노테이션을 사용하여 좌석 정보를 조회할 때 비관적 락을 겁니다.
  • 코드 예시:

    // SeatRepository.kt
    
    interface SeatRepository : JpaRepository<Seat, Long> {
        @Lock(LockModeType.PESSIMISTIC_WRITE)
        @Query("SELECT s FROM Seat s WHERE s.concertScheduleId = :concertScheduleId AND s.seatNumber = :seatNumber")
        fun findByConcertScheduleIdAndSeatNumberWithLock(
            @Param("concertScheduleId") concertScheduleId: Long,
            @Param("seatNumber") seatNumber: Int
        ): Seat?
    }
  • 설명:

    • @Lock(LockModeType.PESSIMISTIC_WRITE)를 사용하여 해당 좌석에 대해 쓰기 락을 겁니다.
    • 다른 트랜잭션에서 해당 좌석에 접근하려고 하면 락이 해제될 때까지 대기합니다.
  • 서비스 계층에서의 적용:

    // ReservationService.kt
    
    @Service
    class ReservationService(
        private val seatRepository: SeatRepository,
        // 생략된 코드
    ) {
        @Transactional
        fun reserveSeat(userId: Long, concertScheduleId: Long, seatNumber: Int): Reservation {
            // 좌석 정보 조회 (비관적 락 적용)
            val seat = seatRepository.findByConcertScheduleIdAndSeatNumberWithLock(concertScheduleId, seatNumber)
                ?: throw ResourceNotFoundException("Seat not found")
    
            if (seat.seatStatus != SeatStatus.AVAILABLE) {
                throw SeatAlreadyOccupiedException("Seat is already occupied")
            }
    
            // 좌석 점유
            seat.occupy(userId)
            seatRepository.save(seat)
    
            // 예약 생성
            val reservation = Reservation.create(
                userId = userId,
                concertScheduleId = concertScheduleId,
                seatId = seat.id,
                seatNumber = seat.seatNumber,
                expirationMinutes = EXPIRATION_MINUTES
            )
            return reservationRepository.save(reservation)
        }
    }
  • 설명:

    • 트랜잭션 내에서 좌석을 조회하고, 좌석 상태를 확인한 후 점유합니다.
    • 좌석 상태 변경과 예약 생성이 하나의 트랜잭션으로 처리되어 원자성을 보장합니다.
b) 포인트 사용 시 비관적 락 적용
  • 상황 설명:

    • 사용자의 포인트를 차감할 때, 동시에 여러 요청이 들어와 포인트가 음수가 되는 것을 방지해야 합니다.
  • 구현 방법:

    • 사용자 정보를 조회할 때 비관적 락을 겁니다.
  • 코드 예시:

    // UserRepository.kt
    
    interface UserRepository : JpaRepository<User, Long> {
        @Lock(LockModeType.PESSIMISTIC_WRITE)
        @Query("SELECT u FROM User u WHERE u.id = :userId")
        fun findByIdForUpdate(@Param("userId") userId: Long): User?
    }
  • 서비스 계층에서의 적용:

    // PaymentService.kt
    
    @Service
    class PaymentService(
        private val userRepository: UserRepository,
        // 생략된 코드
    ) {
        @Transactional
        fun makePayment(userId: Long, reservationId: Long, amount: Int): Payment {
            // 사용자 정보 락 처리
            val user = userRepository.findByIdForUpdate(userId)
                ?: throw ResourceNotFoundException("User not found")
    
            // 잔액 확인 및 차감
            if (user.balance < amount) {
                throw InsufficientBalanceException("Insufficient balance")
            }
            user.balance -= amount
            userRepository.save(user)
    
            // 결제 정보 생성
            val payment = Payment.create(
                userId = userId,
                reservationId = reservationId,
                amount = amount
            )
            return paymentRepository.save(payment)
        }
    }
  • 설명:

    • 사용자 정보를 비관적 락으로 조회하여 다른 트랜잭션에서 접근하지 못하게 합니다.
    • 잔액을 확인하고 차감하여 데이터 무결성을 보장합니다.
c) 낙관적 락 적용 및 재시도 로직 구현
  • 상황 설명:

    • 좌석 정보나 사용자 정보에 대한 충돌이 빈번하지 않은 경우 낙관적 락을 사용하여 성능을 향상시킬 수 있습니다.
  • 구현 방법:

    • 엔티티에 @Version 어노테이션을 사용하여 버전 관리를 합니다.
    • 충돌 발생 시 재시도 로직을 구현합니다.
  • 코드 예시:

    // Seat.kt
    
    @Entity
    data class Seat(
        // 기존 필드들...
        @Version
        var version: Long = 0
    ) {
        // 좌석 점유 메서드
        fun occupy(userId: Long) {
            if (this.seatStatus != SeatStatus.AVAILABLE) {
                throw SeatAlreadyOccupiedException("Seat is already occupied")
            }
            this.seatStatus = SeatStatus.OCCUPIED
            this.userId = userId
            this.updatedAt = LocalDateTime.now()
        }
    }
  • 재시도 로직 (미적용):

    @Transactional
    fun reserveSeatWithRetry(userId: Long, concertScheduleId: Long, seatNumber: Int): Reservation {
        val maxRetries = 3
        var attempt = 0
    
        while (attempt < maxRetries) {
            try {
                val seat = seatRepository.findByConcertScheduleIdAndSeatNumber(concertScheduleId, seatNumber)
                    ?: throw ResourceNotFoundException("Seat not found")
    
                seat.occupy(userId)
                seatRepository.save(seat)
    
                // 예약 생성
                val reservation = Reservation.create(
                    userId = userId,
                    concertScheduleId = concertScheduleId,
                    seatId = seat.id,
                    seatNumber = seat.seatNumber,
                    expirationMinutes = EXPIRATION_MINUTES
                )
                return reservationRepository.save(reservation)
            } catch (e: OptimisticLockingFailureException) {
                attempt++
                if (attempt >= maxRetries) {
                    throw ConcurrencyException("Failed to reserve seat after $maxRetries attempts")
                }
            }
        }
        throw ConcurrencyException("Failed to reserve seat")
    }
  • 설명:

    • 낙관적 락 충돌이 발생하면 OptimisticLockingFailureException이 발생하며, 이를 캐치하여 재시도합니다.
    • 최대 재시도 횟수를 설정하여 무한 루프를 방지합니다.

4.2 테스트 환경 설정

4.2.1 k6 설치 및 설정

  • k6 설치 방법:
    • macOS: brew install k6
    • Windows: Chocolatey를 통해 설치하거나, 바이너리를 다운로드하여 사용
    • Linux: 패키지 매니저를 통해 설치하거나, 바이너리를 다운로드하여 사용
  • 기본 설정 방법:
    • 테스트 스크립트를 JavaScript로 작성합니다.
    • options 객체를 통해 가상 사용자 수(Virtual Users), 테스트 지속 시간(Duration) 등을 설정합니다.

4.2.2 테스트 시나리오 정의

  • 테스트 목표:
    • 좌석 예약 기능의 동시성 제어를 검증합니다.
    • 포인트 차감 기능의 동시성 문제를 확인합니다.
  • 테스트 조건:
    • 가상 사용자 수: 50명
    • 테스트 지속 시간: 30초
    • 동시 요청을 통해 좌석 예약 및 포인트 차감 시도를 합니다.

4.3 테스트 수행

  • 테스트 스크립트 예시:

    // k6 스크립트: reservation_test.js
    
    import http from 'k6/http';
    import { check } from 'k6';
    
    export let options = {
      vus: 50, // 가상 사용자 수
      duration: '30s', // 테스트 지속 시간
    };
    
    export default function () {
      const payload = JSON.stringify({
        concertScheduleId: 1,
        seatNumber: 10
      });
    
      const params = {
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer user-token', // 인증 토큰 필요 시
        },
      };
    
      const res = http.post('http://localhost:8080/api/reservations', payload, params);
    
      check(res, {
        'status is 200 or 400': (r) => r.status === 200 || r.status === 400,
        'seat reserved or error handled': (r) => r.json().status === 'RESERVED' || r.json().error !== undefined,
      });
    }
  • 테스트 수행 방법:

    • 터미널에서 k6 run reservation_test.js 명령어를 실행하여 테스트를 시작합니다.
    • 테스트 결과를 확인하고, 응답 시간, 성공률, 에러 발생률 등을 분석합니다.

5. 결과 및 분석

5.1 성능 비교

  • 추후 추가 예정

5.2 효율성 및 구현 복잡도 비교

  • 비관적 락:
    • 효율성: 데이터 정합성을 확실히 보장하지만, 락으로 인한 대기 시간이 발생하여 성능 저하가 있을 수 있습니다.
    • 구현 복잡도: JPA에서 쉽게 적용할 수 있어 구현이 비교적 간단합니다.
  • 낙관적 락:
    • 효율성: 락을 사용하지 않아 성능이 우수합니다.

5.3 장단점 정리

동시성 제어 방식 장점 단점
비관적 락 - 데이터 정합성 보장
- 구현이 간단
- 락으로 인한 성능 저하 가능성
- 데드락 위험 존재
낙관적 락 - 높은 성능 및 병행성
- 락 없이 데이터 정합성 유지
- 재시도를 안해도 되는 로직에서는 괜찮음(?)
- 충돌 발생 시 재시도 필요
- 충돌 빈도 높을 시 성능 저하

6. 결론

6.1 최종 평가

  • 좌석 예약 기능:
    • 비관적 락을 적용하는 것이 데이터 정합성을 보장하는 데 효과적입니다.
    • 동시성 충돌이 빈번하게 발생하는 영역이므로, 확실한 동시성 제어가 필요합니다.
  • 포인트 차감 기능:
    • 낙관적 락을 적용하여 성능을 향상시킬 수 있습니다.
  • 종합 평가:
    • 비즈니스 로직의 특성과 동시성 충돌 빈도를 고려하여 적절한 락 전략을 선택하는 것이 중요합니다.

6.2 향후 개선 사항

  • 분산 락 도입 검토:
    • Redis를 활용한 분산 락을 도입할 예정입니다.
    • 이를 통해 여러 서버 간의 동시성 문제를 해결하고, 시스템의 확장성을 높일 수 있습니다.
  • 모니터링 및 로깅 강화:
    • 동시성 문제 발생 시 빠르게 대응할 수 있도록 모니터링 시스템을 구축 예정입니다.
    • 로깅을 통해 문제의 원인을 파악하고, 지속적인 성능 개선에 활용합니다.

7. 참고 문헌 및 자료