시나리오: 인기 트레이너 김코치의 오후 2시 예약
1. 사용자A: 14:00 예약 요청 → 중복 체크 → 없음 → 예약 진행
2. 사용자B: 14:00 예약 요청 → 중복 체크 → 없음 → 예약 진행 (동시 실행)
3. 결과: 같은 시간에 2개의 예약이 생성됨
@Service
public class ReservationService {
// 문제가 있는 접근
public synchronized ReservationResponseDto createReservation(...) {
// 예약 로직
}
}
장점:
단점:
@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);
// 예약 로직 수행
// ...
}
}
장점:
단점:
@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);
}
}
}
장점:
단점:
@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);
}
}
}
장점:
단점:
@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);
}
}
}
}
장점:
단점:
@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 분산 락을 선택"
// 예약 시스템의 특징
- 1:1 매칭: 중복 절대 불허
- 높은 동시성: 인기 트레이너에 몰리는 요청
- 실시간 처리: 사용자는 즉시 결과를 원함
- 확장성: 서비스 성장에 따른 서버 확장 필요
→ Redis 분산 락이 이 모든 요구사항을 만족
// 락 키 설계 전략
"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시 (독립적)
효과:
// 사용자 관점에서의 플로우
1. 예약 요청 클릭
2. "처리 중..." 표시 (락 대기)
3. 성공: "예약이 완료되었습니다!"
실패: "다른 사용자가 먼저 예약했습니다. 다른 시간을 선택해주세요."
vs. 다른 방식들:
- DB 락: "시스템이 느려요..."
- 낙관적 락: "다시 시도해주세요" (재입력 필요)
- 유니크 제약: "에러가 발생했습니다"
@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);
}
}
// 서비스에 직접 구현
String lockKey = String.format("reservation:lock:%d:%s:%s",
trainerId, reservationDate, reservationTime);
선택 이유:
// 효과적인 락 분리
- 서로 다른 트레이너: 완전 독립적 처리
- 같은 트레이너, 다른 시간: 독립적 처리
- 같은 트레이너, 같은 시간: 순차 처리 (의도된 동작)
// 실용적인 타임아웃 설정
lock.tryLock(10, 30, TimeUnit.SECONDS)
// ↑ ↑
// 대기시간 락유지시간
// 설정 근거:
// - 10초 대기: 사용자가 기다릴 수 있는 합리적 시간
// - 30초 유지: 예약 로직 완료 시간의 2-3배 (안전 마진)
// 사용자 경험 최적화 + 성능 확보
if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) {
throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
}
// 최종 안전장치 (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로 작성)
DB 제약조건만으로는 동시성 제어를 한 것이 아니고 Redis와 같이하여 안정성도 올리고, 락의 다양함을 배웠다.