예약 시스템 락(Lock) 기술적 의사결정 - fitpassTeam/fitpass GitHub Wiki
문제 : 동시 예약 요청에서 발생하는 레이스 컨디션
동시성 제어 도입 배경
- 비즈니스 크리티컬: 체육관 PT 예약은 1:1 매칭으로 중복 예약이 절대 발생하면 안 됨
- 레이스 컨디션 발생: 동일한 트레이너의 같은 시간대에 여러 사용자가 동시 예약 시도
- 데이터 무결성 위험: 중복 예약 발생 시 고객 불만 및 운영상 문제 야기
- 확장성 고려: 서비스 성장 시 더 많은 동시 요청 처리 필요
문제 시나리오
시나리오: 인기 트레이너 김코치의 오후 2시 예약
1. 사용자A: 14:00 예약 요청 → 중복 체크 → 없음 → 예약 진행
2. 사용자B: 14:00 예약 요청 → 중복 체크 → 없음 → 예약 진행 (동시 실행)
3. 결과: 같은 시간에 2개의 예약이 생성됨
의사결정 배경 및 요구사항
비즈니스 요구사항
- 완벽한 동시성 제어: 1:1 예약 특성상 100% 중복 방지 필요
- 사용자 경험: 실패 시 명확한 안내 메시지 제공
- 성능: 예약 과정이 지나치게 느려지면 안 됨
기술적 요구사항
- 확장성: 멀티 서버 환경에서도 동작
- 성능: 동시 처리량 확보
- 안정성: 락 해제 실패 시에도 시스템 정상 동작
- 유지보수성: 코드 복잡도 최소화
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 분산 락을 선택”
Redis 분산 락 결정사항
- 비즈니스 요구사항 최적 부합
- 세밀한 락 범위 제어
// 락 키 설계 전략
"reservation:lock:{trainerId}:{date}:{time}"
- 사용자 경험 최적화
// 사용자 관점에서의 플로우
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);
3. 락 범위의 정교함
: 효과적으로 락을 분리하여 독립적으로 관리
4. 타임아웃 설정
// 실용적인 타임아웃 설정
lock.tryLock(10, 30, TimeUnit.SECONDS)
// ↑ ↑
// 대기시간 락유지시간
이중 보안 체계(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 { ... }
성능 검증 결과
동시성 테스트
// 실제 테스트 시나리오
- 10명 동시 → 성공 1명, 실패 9명 ✅
- 50명 동시 → 성공 1명, 실패 49명 ✅
- 100% 동시성 제어 달성 ✅
(성능 테스트는 따로 올렸습니다)
배운점
- 100% 중복 방지: 어떤 상황에서도 중복 예약 차단
- 이중 보안 체계: Redis + DB 유니크 제약조건
- 사용자 경험 개선: 대기 및 재시도 로직
DB 제약조건만으로는 동시성 제어를 한 것이 아니고 Redis와 같이하여 안정성도 올리고, 락의 다양함을 배웠다.