[FitPass] 예약 시스템 락(Lock) 기술적 의사결정

김현정·2025년 6월 27일
0

문제 : 동시 예약 요청에서 발생하는 레이스 컨디션

동시성 제어 도입 배경

  • 비즈니스 크리티컬: 체육관 PT 예약은 1:1 매칭으로 중복 예약이 절대 발생하면 안 됨
  • 레이스 컨디션 발생: 동일한 트레이너의 같은 시간대에 여러 사용자가 동시 예약 시도
  • 데이터 무결성 위험: 중복 예약 발생 시 고객 불만 및 운영상 문제 야기
  • 확장성 고려: 서비스 성장 시 더 많은 동시 요청 처리 필요

문제 시나리오

시나리오: 인기 트레이너 김코치의 오후 2시 예약
1. 사용자A: 14:00 예약 요청 → 중복 체크 → 없음 → 예약 진행
2. 사용자B: 14:00 예약 요청 → 중복 체크 → 없음 → 예약 진행 (동시 실행)
3. 결과: 같은 시간에 2개의 예약이 생성됨

의사결정 배경 및 요구사항

비즈니스 요구사항

  • 완벽한 동시성 제어: 1:1 예약 특성상 100% 중복 방지 필요
  • 사용자 경험: 실패 시 명확한 안내 메시지 제공
  • 성능: 예약 과정이 지나치게 느려지면 안 됨

기술적 요구사항

  • 확장성: 멀티 서버 환경에서도 동작
  • 성능: 동시 처리량 확보
  • 안정성: 락 해제 실패 시에도 시스템 정상 동작
  • 유지보수성: 코드 복잡도 최소화

여러가지 락(Lock) 방식들

1. Application Level - Synchronized

@Service
public class ReservationService {
    
    // 문제가 있는 접근
    public synchronized ReservationResponseDto createReservation(...) {
        // 예약 로직
    }
}

장점:

  • 구현이 매우 간단
  • JVM 레벨에서 보장되는 안정성

단점:

  • 멀티 서버 환경 미대응: 서버 확장 시 동시성 제어 불가, 단일서버만 가능
  • 성능 병목: 모든 예약이 순차 처리되어 처리량 저하
  • 세밀한 제어 불가: 트레이너별, 시간별 분리 불가능

2. DB Level - 비관적 락 (Pessimistic Lock)

@Repository
public interface TrainerRepository extends JpaRepository<Trainer, Long> {
    
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT t FROM Trainer t WHERE t.id = :trainerId")
    Trainer findByIdWithLock(@Param("trainerId") Long trainerId);
}

@Service
public class ReservationService {
    
    @Transactional
    public ReservationResponseDto createReservation(...) {
        // 트레이너에 배타 락 걸기
        Trainer trainer = trainerRepository.findByIdWithLock(trainerId);
        
        // 예약 로직 수행
        // ...
    }
}

장점:

  • 확실한 동시성 제어: DB 레벨에서 완벽 보장
  • 트랜잭션과 통합: Spring @Transactional과 자연스러운 연동
  • 데드락 감지: DB가 데드락 상황 자동 감지 및 해결

단점:

  • 성능 저하 심각: 트레이너 전체가 락 대상이 되어 병목 발생
  • 락 범위 과도: 다른 시간대 예약도 모두 블록됨
  • 데드락 위험: 여러 리소스 동시 접근 시 데드락 가능성
  • 타임아웃 설정 복잡: 적절한 락 타임아웃 조정 어려움

3. DB Level - 낙관적 락 (Optimistic Lock)

@Entity
public class Reservation {
    
    @Version
    private Long version;
    
    // 기타 필드들...
}

@Service
public class ReservationService {
    
    @Transactional
    public ReservationResponseDto createReservation(...) {
        try {
            // 예약 생성 시도
            Reservation reservation = new Reservation(...);
            return reservationRepository.save(reservation);
        } catch (OptimisticLockingFailureException e) {
            // 충돌 감지 시 재시도 또는 실패 처리
            throw new BaseException(ExceptionCode.RESERVATION_CONFLICT);
        }
    }
}

장점:

  • 높은 성능: 읽기 작업에 락이 걸리지 않음
  • 멀티 서버 대응: DB 기반으로 서버 확장성 확보
  • 자동 충돌 감지: 동시 수정 시 자동으로 예외 발생

단점:

  • 재시도 로직 복잡: 실패 시 사용자에게 재입력 요구
  • 사용자 경험 저하: "다시 시도해주세요" 메시지로 불편함 증가
  • 예약 특성에 부적합: 1:1 예약에서 재시도는 의미 없음 (이미 다른 사람이 예약)

4. DB Level - 유니크 제약조건

@Entity
@Table(name = "reservations",
    uniqueConstraints = @UniqueConstraint(
        name = "uk_trainer_date_time",
        columnNames = {"trainer_id", "reservation_date", "reservation_time"}
    ))
public class Reservation {
    // 필드들...
}

@Service
public class ReservationService {
    
    @Transactional
    public ReservationResponseDto createReservation(...) {
        try {
            Reservation reservation = new Reservation(...);
            return reservationRepository.save(reservation);
        } catch (DataIntegrityViolationException e) {
            throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
        }
    }
}

장점:

  • 구현 단순: 최소한의 코드로 중복 방지
  • DB 레벨 보장: 어떤 상황에서도 중복 데이터 생성 불가
  • 성능 우수: 락 대기 시간 없음

단점:

  • 예외 기반 제어: 비즈니스 로직이 예외에 의존
  • 사용자 경험 한계: "이미 예약됨" 메시지만 가능
  • 진정한 동시성 제어 아님: 예방이 아닌 사후 처리

5. Application Level - ReentrantLock

@Service
public class ReservationService {
    
    // 트레이너별로 락을 관리하는 Map
    private final ConcurrentHashMap<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();
    
    public ReservationResponseDto createReservation(
        LocalDate reservationDate, LocalTime reservationTime,
        Long userId, Long gymId, Long trainerId) {
        
        // 락 키 생성
        String lockKey = String.format("%d:%s:%s", trainerId, reservationDate, reservationTime);
        
        // 락 인스턴스 가져오기 (없으면 생성)
        ReentrantLock lock = lockMap.computeIfAbsent(lockKey, k -> new ReentrantLock());
        
        try {
            // 락 획득 시도 (10초 대기)
            if (!lock.tryLock(10, TimeUnit.SECONDS)) {
                throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
            }
            
            // 예약 로직 실행
            return reservationCreate(...);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BaseException(ExceptionCode.RESERVATION_INTERRUPTED);
        } finally {
            // 락 해제
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
            
            // 메모리 누수 방지: 사용하지 않는 락 정리
            if (!lock.hasQueuedThreads() && !lock.isLocked()) {
                lockMap.remove(lockKey);
            }
        }
    }
}

장점:

  • JVM 내 고성능: 네트워크 통신 없이 빠른 락 처리
  • 세밀한 제어: 트레이너별, 시간별 독립적 락 가능
  • 타임아웃 지원: tryLock으로 대기 시간 제한
  • 재진입 가능: 같은 스레드에서 중복 락 획득 가능
  • 외부 의존성 없음: Redis 등 외부 서버 불필요

단점:

  • 멀티 서버 환경 한계: 서버별로 독립적인 락 → 분산 환경에서 동시성 제어 불가
  • 메모리 관리 복잡: lockMap 크기 증가, 가비지 컬렉션 이슈
  • 서버 재시작 시 락 정보 손실: 휘발성 메모리 기반
  • 확장성 제한: 수평 확장 시 각 서버가 독립적으로 동작

6. Redis 분산 락 (Distributed Lock)

@Service
@RequiredArgsConstructor
public class ReservationService {
    
    private final RedissonClient redissonClient;
    
    public ReservationResponseDto createReservation(
        LocalDate reservationDate, LocalTime reservationTime,
        Long userId, Long gymId, Long trainerId) {
        
        // 세밀한 락 키 생성
        String lockKey = String.format("reservation:lock:%d:%s:%s",
            trainerId, reservationDate, reservationTime);
        
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 락 획득 시도 (10초 대기, 30초 후 자동 해제)
            if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
            }
            
            // 안전한 예약 로직 실행
            return reservationCreate(reservationDate, reservationTime, 
                ReservationStatus.PENDING, userId, gymId, trainerId);
                
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BaseException(ExceptionCode.RESERVATION_INTERRUPTED);
        } finally {
            // 안전한 락 해제
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

장점:

  • 멀티 서버 대응: 분산 환경에서 완벽한 동시성 제어
  • 세밀한 제어: 트레이너별, 날짜별, 시간별 독립적 락
  • 높은 성능: 관련 없는 예약은 동시 처리 가능
  • 좋은 사용자 경험: 대기 후 명확한 결과 안내
  • 자동 해제: 서버 다운 시에도 락 자동 해제로 데드락 방지

단점:

  • 외부 의존성: Redis 서버 필요
  • 구현 복잡도: 락 관리 로직 추가
  • 네트워크 지연: Redis 통신으로 인한 약간의 지연

"세밀한 제어가 가능하면서도 확장성과 성능을 모두 확보할 수 있는 Redis 분산 락을 선택"

선택한 이유

1. 비즈니스 요구사항 최적 부합

// 예약 시스템의 특징
- 1:1 매칭: 중복 절대 불허
- 높은 동시성: 인기 트레이너에 몰리는 요청
- 실시간 처리: 사용자는 즉시 결과를 원함
- 확장성: 서비스 성장에 따른 서버 확장 필요

→ Redis 분산 락이 이 모든 요구사항을 만족

2. 세밀한 락 범위 제어

// 락 키 설계 전략
"reservation:lock:{trainerId}:{date}:{time}"

예시:
"reservation:lock:123:2024-01-15:14:00" // 김코치, 1월 15일, 오후 2시
"reservation:lock:123:2024-01-15:15:00" // 김코치, 1월 15일, 오후 3시 (독립적)
"reservation:lock:456:2024-01-15:14:00" // 이코치, 1월 15일, 오후 2시 (독립적)

효과:

  • 서로 다른 트레이너: 완전 독립적 처리
  • 같은 트레이너, 다른 시간: 독립적 처리
  • 같은 트레이너, 같은 시간: 순차 처리 (의도된 동작)

3. 사용자 경험 최적화

// 사용자 관점에서의 플로우
1. 예약 요청 클릭
2. "처리 중..." 표시 (락 대기)
3. 성공: "예약이 완료되었습니다!"
   실패: "다른 사용자가 먼저 예약했습니다. 다른 시간을 선택해주세요."

vs. 다른 방식들:
- DB 락: "시스템이 느려요..."
- 낙관적 락: "다시 시도해주세요" (재입력 필요)
- 유니크 제약: "에러가 발생했습니다"

구현 세부사항

1. Redisson 설정

@Configuration
public class RedissonConfig {
    
    @Value("${spring.redis.host}")
    private String redisHost;
    
    @Value("${spring.redis.port}")
    private int redisPort;
    
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://" + redisHost + ":" + redisPort)
            .setRetryAttempts(3) // 재시도 횟수
            .setRetryInterval(1500) // 재시도 간격
            .setTimeout(3000) // 타임아웃
            .setConnectTimeout(10000); // 연결 타임아웃
            
        return Redisson.create(config);
    }
}

2. 락 키 생성

// 서비스에 직접 구현
String lockKey = String.format("reservation:lock:%d:%s:%s",
    trainerId, reservationDate, reservationTime);

선택 이유:

  • 단순성: 별도 클래스 없이 즉시 이해 가능
  • 충분성: 현재 요구사항에 과부족 없이 정확히 부합
  • 유지보수성: 코드가 한 곳에 있어서 변경 시 추적 용이
  • YAGNI 원칙: 복잡한 키 생성 로직은 필요할 때 추가

3. 락 범위의 정교함

// 효과적인 락 분리
- 서로 다른 트레이너: 완전 독립적 처리
- 같은 트레이너, 다른 시간: 독립적 처리 
- 같은 트레이너, 같은 시간: 순차 처리 (의도된 동작)

4. 타임아웃 설정

// 실용적인 타임아웃 설정
lock.tryLock(10, 30, TimeUnit.SECONDS)
//           ↑   ↑
//    대기시간   락유지시간

// 설정 근거:
// - 10초 대기: 사용자가 기다릴 수 있는 합리적 시간
// - 30초 유지: 예약 로직 완료 시간의 2-3배 (안전 마진)

이중 보안 체계(Defense in Depth)

1차 방어선 : Redis 분산 락

// 사용자 경험 최적화 + 성능 확보
if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) {
    throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
}

2차 방어선 : DB 유니크 제약조건

// 최종 안전장치 (Redis 장애 시에도 데이터 무결성 보장)
@Entity
@Table(uniqueConstraints = @UniqueConstraint(
    columnNames = {"trainer_id", "reservation_date", "reservation_time"}
))
public class Reservation { ... }

상황별 동작

상황Redis 락DB 제약조건결과사용자 경험
정상 상황사용사용 안함빠른 처리"예약 완료!"
Redis 장애실패동작느리지만 안전"잠시 후 재시도"
극한 동시성주요 방어백업 방어완벽한 보호"다른 시간 선택"

성능 검증 결과

동시성 테스트

// 실제 테스트 시나리오
- 10명 동시 → 성공 1명, 실패 9명 ✅
- 50명 동시 → 성공 1명, 실패 49명 ✅  
- 100% 동시성 제어 달성 ✅

(성능 테스트는 다른 URL로 작성)

배운점 및 교훈

  • 100% 중복 방지: 어떤 상황에서도 중복 예약 차단
  • 이중 보안 체계: Redis + DB 유니크 제약조건
  • 사용자 경험 개선: 대기 및 재시도 로직

DB 제약조건만으로는 동시성 제어를 한 것이 아니고 Redis와 같이하여 안정성도 올리고, 락의 다양함을 배웠다.

0개의 댓글