- 레디스를 적용하는 단계로 넘어가기 이전, 가능한 최대한 데이터베이스의 락과 트랜잭션을 이용하여 동시성 문제를 해결하는 방법을 깊이 있게 연구해보려 합니다.
- 동시성 이슈 파악, 동시성 제어 방식 도입, 장단점 파악 및 비교를 진행하려합니다.
- k6는 Web Application, API, Microservices 등의 성능을 테스트하기 위한 오픈소스 도구입니다.
- 스크립트를 작성하여 테스트를 수행하고, 결과를 분석할 수 있습니다.
- 상황 1 : 두 명 이상의 사용자가 동시에 같은 좌석을 선택하고 예약 버튼을 누르는 경우. -> 좌석 중복 예약 문제
- 상황 2 : 한 명의 사용자가 동시에 본인의 계정으로 탭 3개를 띄워놓는다던지 하는 방식으로 포인트 사용을 시도하는 경우. -> 포인트 중복 사용 문제
- 데이터 무결성 위반: 동시성 이슈로 인해 좌석이 초과 예약되거나 포인트 잔액이 음수가 되는 등 데이터의 일관성이 깨질 수 있습니다.
- 고객 신뢰 하락: 사용자들은 시스템에 대한 신뢰를 잃을 수 있으며, 이는 서비스의 평판에 부정적인 영향을 미칩니다.
- 시스템 안정성 저하: 동시성 문제가 해결되지 않으면 시스템 오류와 비정상 동작이 발생하여 전체적인 안정성이 떨어집니다.
동시성 문제를 해결하기 위해 이번 주차에는 데이터베이스에서 제공하는 락과 트랜잭션을 활용합니다.
- 행 락(Row Lock): 특정 행(row)에 대해 락을 걸어 다른 트랜잭션이 해당 행을 수정하지 못하게 합니다.
- 테이블 락(Table Lock): 테이블 전체에 락을 걸어 다른 트랜잭션이 테이블에 접근하지 못하게 합니다.
- ACID 특성:
- 원자성(Atomicity): 트랜잭션 내의 모든 작업이 모두 수행되거나 모두 수행되지 않아야 합니다.
- 일관성(Consistency): 트랜잭션이 완료되면 데이터베이스는 일관성 있는 상태를 유지해야 합니다.
- 격리성(Isolation): 동시에 실행되는 트랜잭션들은 서로 간섭하지 않아야 합니다.
- 지속성(Durability): 트랜잭션이 커밋되면 그 결과는 영구적으로 반영되어야 합니다.
- 설명: 데이터에 접근할 때 락을 걸어 다른 트랜잭션이 접근하지 못하도록 하는 방식입니다.
- 적용 방법:
- SQL의
SELECT ... FOR UPDATE
문을 사용합니다. - JPA에서는
@Lock(LockModeType.PESSIMISTIC_WRITE)
어노테이션을 사용합니다.
- SQL의
- 장점:
- 동시성 제어가 확실하여 데이터의 정합성을 보장합니다.
- 단점:
- 락으로 인한 대기 시간이 발생하여 성능이 저하될 수 있습니다.
- 데드락의 위험이 있습니다.
- 설명: 데이터 충돌이 드물다고 가정하고, 업데이트 시 충돌 여부를 확인하여 처리하는 방식입니다.
- 적용 방법:
- 엔티티에
@Version
어노테이션을 사용하여 버전 관리를 합니다.
- 엔티티에
- 장점:
- 락을 사용하지 않아 성능이 우수합니다.
- 데이터 읽기 작업에 대한 병행성이 높습니다.
- 단점:
- 충돌 발생 시 예외가 발생하며, 재시도 로직이 필요합니다.
- 충돌 빈도가 높을 경우 오히려 성능이 저하될 수 있습니다.
- 설명: 분산 시스템 환경에서 여러 노드가 공유 자원에 동시에 접근하는 것을 제어하기 위한 락입니다.
- 적용 방법:
- Redis와 같은 외부 시스템을 이용하여 락을 관리합니다.
- Redis의
SETNX
명령어나 Redisson 라이브러리를 사용합니다.
- 장점:
- 분산 환경에서 동시성 제어가 가능합니다.
- 빠른 성능과 높은 처리량을 제공합니다.
- 단점:
- 추가적인 인프라가 필요하며, 구현 복잡도가 높습니다.
- 네트워크 지연이나 Redis 장애 시 문제가 발생할 수 있습니다.
- Redis SETNX 명령어:
SETNX
(Set if Not Exists)는 키가 존재하지 않을 때만 값을 설정합니다.- 이를 이용하여 락을 획득하고,
EXPIRE
명령어로 락의 유효 기간을 설정합니다.
- 락 획득 및 해제 절차:
- 락 획득:
SETNX
명령어로 락 키를 설정합니다. - 락 유지: 작업을 수행합니다.
- 락 해제: 작업 완료 후
DEL
명령어로 락 키를 삭제합니다.
- 락 획득:
- 주의 사항:
- 락의 유효 기간 설정으로 데드락을 방지해야 합니다.
- 원자성을 보장하기 위해 Lua 스크립트나 Redisson 등의 라이브러리를 사용할 수 있습니다.
- 구현 복잡도:
- 분산 락은 구현이 복잡하며, 잘못 구현하면 데이터 정합성이 깨질 수 있습니다.
- 추가 인프라 필요성:
- Redis 등의 외부 시스템을 구축하고 운영해야 합니다.
- 성능 영향:
- 네트워크 통신으로 인한 지연이 발생할 수 있습니다.
이번 주에는 데이터베이스의 락과 트랜잭션을 활용하여 동시성 문제를 해결하였습니다.
-
상황 설명:
- 좌석 예약 기능에서 다수의 사용자가 동시에 같은 좌석을 예약하려고 시도할 수 있습니다.
-
구현 방법:
- JPA의
@Lock
어노테이션을 사용하여 좌석 정보를 조회할 때 비관적 락을 겁니다.
- JPA의
-
코드 예시:
// 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) } }
-
설명:
- 트랜잭션 내에서 좌석을 조회하고, 좌석 상태를 확인한 후 점유합니다.
- 좌석 상태 변경과 예약 생성이 하나의 트랜잭션으로 처리되어 원자성을 보장합니다.
-
상황 설명:
- 사용자의 포인트를 차감할 때, 동시에 여러 요청이 들어와 포인트가 음수가 되는 것을 방지해야 합니다.
-
구현 방법:
- 사용자 정보를 조회할 때 비관적 락을 겁니다.
-
코드 예시:
// 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) } }
-
설명:
- 사용자 정보를 비관적 락으로 조회하여 다른 트랜잭션에서 접근하지 못하게 합니다.
- 잔액을 확인하고 차감하여 데이터 무결성을 보장합니다.
-
상황 설명:
- 좌석 정보나 사용자 정보에 대한 충돌이 빈번하지 않은 경우 낙관적 락을 사용하여 성능을 향상시킬 수 있습니다.
-
구현 방법:
- 엔티티에
@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
이 발생하며, 이를 캐치하여 재시도합니다. - 최대 재시도 횟수를 설정하여 무한 루프를 방지합니다.
- 낙관적 락 충돌이 발생하면
- k6 설치 방법:
- macOS:
brew install k6
- Windows: Chocolatey를 통해 설치하거나, 바이너리를 다운로드하여 사용
- Linux: 패키지 매니저를 통해 설치하거나, 바이너리를 다운로드하여 사용
- macOS:
- 기본 설정 방법:
- 테스트 스크립트를 JavaScript로 작성합니다.
options
객체를 통해 가상 사용자 수(Virtual Users), 테스트 지속 시간(Duration) 등을 설정합니다.
- 테스트 목표:
- 좌석 예약 기능의 동시성 제어를 검증합니다.
- 포인트 차감 기능의 동시성 문제를 확인합니다.
- 테스트 조건:
- 가상 사용자 수: 50명
- 테스트 지속 시간: 30초
- 동시 요청을 통해 좌석 예약 및 포인트 차감 시도를 합니다.
-
테스트 스크립트 예시:
// 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
명령어를 실행하여 테스트를 시작합니다. - 테스트 결과를 확인하고, 응답 시간, 성공률, 에러 발생률 등을 분석합니다.
- 터미널에서
- 추후 추가 예정
- 비관적 락:
- 효율성: 데이터 정합성을 확실히 보장하지만, 락으로 인한 대기 시간이 발생하여 성능 저하가 있을 수 있습니다.
- 구현 복잡도: JPA에서 쉽게 적용할 수 있어 구현이 비교적 간단합니다.
- 낙관적 락:
- 효율성: 락을 사용하지 않아 성능이 우수합니다.
동시성 제어 방식 | 장점 | 단점 |
---|---|---|
비관적 락 | - 데이터 정합성 보장 - 구현이 간단 |
- 락으로 인한 성능 저하 가능성 - 데드락 위험 존재 |
낙관적 락 | - 높은 성능 및 병행성 - 락 없이 데이터 정합성 유지 - 재시도를 안해도 되는 로직에서는 괜찮음(?) |
- 충돌 발생 시 재시도 필요 - 충돌 빈도 높을 시 성능 저하 |
- 좌석 예약 기능:
- 비관적 락을 적용하는 것이 데이터 정합성을 보장하는 데 효과적입니다.
- 동시성 충돌이 빈번하게 발생하는 영역이므로, 확실한 동시성 제어가 필요합니다.
- 포인트 차감 기능:
- 낙관적 락을 적용하여 성능을 향상시킬 수 있습니다.
- 종합 평가:
- 비즈니스 로직의 특성과 동시성 충돌 빈도를 고려하여 적절한 락 전략을 선택하는 것이 중요합니다.
- 분산 락 도입 검토:
- Redis를 활용한 분산 락을 도입할 예정입니다.
- 이를 통해 여러 서버 간의 동시성 문제를 해결하고, 시스템의 확장성을 높일 수 있습니다.
- 모니터링 및 로깅 강화:
- 동시성 문제 발생 시 빠르게 대응할 수 있도록 모니터링 시스템을 구축 예정입니다.
- 로깅을 통해 문제의 원인을 파악하고, 지속적인 성능 개선에 활용합니다.
- Spring Data JPA - Locking
- Hibernate ORM - Locking
- Optimistic vs Pessimistic Locking
- k6 Documentation