Fitpass는 체육관 PT 예약 시스템으로, 다수의 사용자가 동시에 같은 트레이너의 같은 시간대를 예약하려고 할 때 발생할 수 있는 동시성 문제를 해결해야 했습니다.
시나리오 : 인기 트레이너의 오후 2시 예약
사용자 1 : 14:00 예약 요청 -> 성공
사용자 2 : 14:00 예약 요청 -> 성공 (중복예약 발생)
동시성 제어를 위한 다양한 방법을 검토했습니다.
// Synchronized 키워드 사용
public synchronized ReservationResponseDto createReservation(...) {
// 예약 로직
}
문제점 : 단일 서버에서만 동작, 확장성 제한
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT t FROM Trainer t WHERE t.id = :trainerId")
Trainer findByIdWithLock(@Param("trainerId") Long trainerId);
문제점 : 성능 저하, 데드락 위험
@Version
private Long version;
문제점 : 재시도 로직 복잡성, 사용자 경험 저하
장점: 간단하고 확실한 제어, 높은 성능
단점: 예외 기반 제어, 사용자 경험 제한
장점: 정교한 제어, 좋은 사용자 경험
단점: 구현 복잡성, 추가 인프라 필요
중복이 발생하면 무조건 실패로 처리해야하기에 예약 시간 중복방지의 유니크 조건을 걺.
무조건 중복 금지이면 유니크 제약과 예외처리를 하는 것이 좋고, 동시성 문제로 데이터 손상을 우려하면 비관적 락을 해야하고, 충돌 거의 없고 성능이 중요하면 낙관적 락을 해야하기에 나는 유니크 조건을 사용함.
@Entity
@Table(name = "reservations",
uniqueConstraints = @UniqueConstraint(
name = "uk_trainer_date_time",
columnNames = {"trainer_id", "reservation_date", "reservation_time"}
))
public class Reservation extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, columnDefinition = "DATE")
private LocalDate reservationDate;
@Column(nullable = false, columnDefinition = "TIME")
private LocalTime reservationTime;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "trainer_id", nullable = false)
private Trainer trainer;
// 기타 필드들...
}
@Service
@Transactional
public class ReservationService {
public ReservationResponseDto createReservation(
ReservationRequestDto request,
Long userId, Long gymId, Long trainerId) {
try {
// 1. 포인트 차감
PointUseRefundRequestDto pointRequest = new PointUseRefundRequestDto();
pointRequest.setAmount(trainer.getPrice());
pointService.usePoint(userId, pointRequest);
// 2. 예약 생성 및 저장 (유니크 제약조건으로 중복 방지)
Reservation reservation = ReservationRequestDto.from(request, user, gym, trainer);
Reservation savedReservation = reservationRepository.save(reservation);
return ReservationResponseDto.from(savedReservation);
} catch (DataIntegrityViolationException e) {
// 3. 중복 예약 시도 시 예외 처리
throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
}
}
}
public enum ExceptionCode {
RESERVATION_ALREADY_EXISTS(409, "이미 예약된 시간입니다.");
private final int status;
private final String message;
}
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class ReservationConcurrencyTest {
@Autowired
private ReservationService reservationService;
// H2 인메모리 데이터베이스 사용으로 빠른 테스트 실행
}
@Test
void 동시_예약_테스트() throws Exception {
// Given: 2명의 사용자가 같은 시간 예약 시도
User user1 = createTestUser("user1@test.com");
User user2 = createTestUser("user2@test.com");
LocalDate reservationDate = LocalDate.now().plusDays(3);
LocalTime reservationTime = LocalTime.of(14, 0);
// When: 동시 실행
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(2);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
// 사용자1, 2 동시 예약 시도
// ... 멀티스레드 실행 로직
// Then: 1명만 성공해야 함
assertThat(successCount.get()).isEqualTo(1);
assertThat(failCount.get()).isEqualTo(1);
}
=== 동시 예약 테스트 결과 ===
사용자1 예약 성공! ID: 1
사용자2 예약 실패: 해당 시간에 이미 예약이 존재합니다.
총 성공: 1
총 실패: 1
실행 시간: 63ms
동시성 제어 성공!
여러 번 시도를 해본 결과 같은 결과가 나옴.
@Test
void 다중_사용자_동시_예약_테스트() throws Exception {
// Given: 테스트 데이터 준비
LocalDate reservationDate = LocalDate.now().plusDays(3);
LocalTime reservationTime = LocalTime.of(15, 0);
// 10명이 동시에 같은 시간 예약 시도
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
List<String> results = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < threadCount; i++) {
final int userIndex = i;
executor.submit(() -> {
try {
// 각 스레드마다 새로운 사용자 생성
User user = createTestUser("multiUser" + userIndex + "@test.com");
ReservationRequestDto request = createReservationRequest(reservationDate, reservationTime);
// 예약 시도
ReservationResponseDto result = reservationService.createReservation(
request, user.getId(), testGym.getId(), testTrainer.getId()
);
successCount.incrementAndGet();
results.add("사용자" + userIndex + " 예약 성공! 예약ID: " + result.getReservationId());
} catch (Exception e) {
failCount.incrementAndGet();
results.add("사용자" + userIndex + " 예약 실패: " + e.getClass().getSimpleName() + " - " + e.getMessage());
} finally {
latch.countDown();
}
});
}
// 모든 스레드가 완료될 때까지 대기
latch.await(30, TimeUnit.SECONDS);
executor.shutdown();
// Then: 결과 확인
System.out.println("\n=== 다중 사용자 동시 예약 테스트 결과 ===");
System.out.println("총 시도: " + threadCount + "명");
System.out.println("성공: " + successCount.get() + "명");
System.out.println("실패: " + failCount.get() + "명");
System.out.println("\n상세 결과:");
results.forEach(System.out::println);
// 검증: 정상적이라면 1명만 성공해야 함
assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount);
if (successCount.get() == 1) {
System.out.println("\n 동시성 제어 완벽!");
} else {
System.out.println("\n 동시성 문제! " + successCount.get() + "명이 동시 예약 성공");
}
}
=== 다중 사용자 동시 예약 테스트 결과 ===
총 시도: 10명
성공: 1명
실패: 9명
실행 시간: 87ms
상세 결과:
사용자9 예약 성공! 예약ID: 1
사용자0 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
사용자4 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
사용자5 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
사용자2 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
사용자1 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
사용자8 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
사용자6 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
사용자7 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
사용자3 예약 실패: BaseException - 해당 시간에 이미 예약이 존재합니다.
동시성 제어 완벽!
@Test
void 극한_동시성_테스트() throws Exception {
// 50명이 동시에 같은 시간 예약 시도
int threadCount = 50;
ExecutorService executor = Executors.newFixedThreadPool(20);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
// 모든 스레드가 동시에 시작하도록 동기화
startLatch.countDown(); // 동시 시작!
// 결과: 1명만 성공, 49명 실패
assertThat(successCount.get()).isEqualTo(1);
}
=== 극한 동시성 테스트 결과 ===
총 시도: 50명
성공: 1명
실패: 49명
실행 시간: 143ms
극한 동시성 테스트 통과! 동시성 제어 완벽!
동시 요청 수 | 성공 | 실패 | 처리 시간 | 메모리 사용량 |
---|---|---|---|---|
2명 | 1 | 1 | 63ms | 정상 |
10명 | 1 | 9 | 87ms | 정상 |
50명 | 1 | 49 | 143ms | 정상 |
Redis를 이용해 여러 프로세스 혹은 서버 간에 임계 구역을 보호하기 위한 락 메커니즘.
DB 유니크 제약조건은 동시성 제어가 아니라, 데이터 무결성을 위한 마지막 방어선 역할을 합니다. 진짜 동시성 제어는 Redis 분산 락이나 DB락처럼 요청 자체를 선제적으로 제어하는 방식입니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' // ⭐ Redisson
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + "localhost:6379");
return Redisson.create(config);
}
}
포트번호는 Redis 서버 포트번호를 입력!
RedissonClient를 Bean으로 등록해줌.
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockService {
private final RedissonClient redissonClient;
/**
* 분산 락을 사용하여 임계 구역 실행
* @param lockKey 락 키
* @param waitTime 락 획득 대기 시간
* @param leaseTime 락 유지 시간
* @param supplier 실행할 로직
* @return 실행 결과
*/
public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime,
TimeUnit timeUnit, Supplier<T> supplier) {
RLock lock = redissonClient.getLock(lockKey);
try {
// 락 획득 시도
boolean acquired = lock.tryLock(waitTime, leaseTime, timeUnit);
if (!acquired) {
throw new BaseException(ExceptionCode.LOCK_ACQUISITION_FAILED);
}
log.info("분산 락 획득 성공: {}", lockKey);
// 임계 구역 실행
return supplier.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BaseException(ExceptionCode.LOCK_INTERRUPTED);
} finally {
// 락 해제
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("분산 락 해제: {}", lockKey);
}
}
}
}
@Service
@Transactional
@RequiredArgsConstructor
public class ReservationService {
private final DistributedLockService distributedLockService;
private final ReservationRepository reservationRepository;
private final PointService pointService;
/**
* Redis 분산 락을 적용한 예약 메서드
*/
public ReservationResponseDto createReservationWithLock(
ReservationRequestDto request,
Long userId, Long gymId, Long trainerId) {
// 락 키 생성: trainer_id + date + time
String lockKey = String.format("reservation:lock:%d:%s:%s",
trainerId, request.getReservationDate(), request.getReservationTime());
return distributedLockService.executeWithLock(
lockKey,
5, // 5초 대기
10, // 10초 유지
TimeUnit.SECONDS,
() -> createReservationInternal(request, userId, gymId, trainerId)
);
}
/**
* 실제 예약 로직 (임계 구역)
*/
private ReservationResponseDto createReservationInternal(
ReservationRequestDto request,
Long userId, Long gymId, Long trainerId) {
// 1. 선제적 중복 체크 (성능 최적화)
boolean exists = reservationRepository.existsByTrainerIdAndReservationDateAndReservationTime(
trainerId, request.getReservationDate(), request.getReservationTime());
if (exists) {
throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
}
// 2. 엔티티 조회
User user = userRepository.findById(userId)
.orElseThrow(() -> new BaseException(ExceptionCode.USER_NOT_FOUND));
Gym gym = gymRepository.findById(gymId)
.orElseThrow(() -> new BaseException(ExceptionCode.GYM_NOT_FOUND));
Trainer trainer = trainerRepository.findById(trainerId)
.orElseThrow(() -> new BaseException(ExceptionCode.TRAINER_NOT_FOUND));
// 3. 포인트 차감
PointUseRefundRequestDto pointRequest = new PointUseRefundRequestDto();
pointRequest.setAmount(trainer.getPrice());
pointService.usePoint(userId, pointRequest);
// 4. 예약 생성 및 저장
Reservation reservation = ReservationRequestDto.from(request, user, gym, trainer);
Reservation savedReservation = reservationRepository.save(reservation);
return ReservationResponseDto.from(savedReservation);
}
}
public enum ExceptionCode {
RESERVATION_ALREADY_EXISTS(409, "이미 예약된 시간입니다."),
LOCK_ACQUISITION_FAILED(423, "현재 다른 사용자가 예약 중입니다. 잠시 후 다시 시도해주세요."),
LOCK_INTERRUPTED(500, "예약 처리 중 오류가 발생했습니다.");
private final int status;
private final String message;
}
@Test
void Redis_분산락_동시_예약_테스트() throws Exception {
// Given
LocalDate reservationDate = LocalDate.now().plusDays(3);
LocalTime reservationTime = LocalTime.of(16, 0);
// When: 2명이 동시에 같은 시간 예약 시도 (Redis 분산 락 사용)
// ... 테스트 로직
// Then
assertThat(successCount.get()).isEqualTo(1);
assertThat(failCount.get()).isEqualTo(1);
}
=== Redis 분산락 동시 예약 테스트 결과 ===
사용자1 예약 성공! ID: 1
사용자2 예약 실패: 현재 다른 사용자가 예약 중입니다. 잠시 후 다시 시도해주세요.
총 성공: 1
총 실패: 1
실행 시간: 95ms
Redis 분산락 동시성 제어 성공!
동시 요청 수 | 성공 | 실패 | 평균 처리 시간 | 메모리 사용량 |
---|---|---|---|---|
2명 | 1 | 1 | 184ms | 정상 |
10명 | 1 | 9 | 246ms | 정상 |
50명 | 1 | 49 | 527ms | 정상 |
"Defense in Depth" 전략을 채택하여 이중 보안 체계를 구축:
1차 방어선: Redis 분산 락 (성능 + 사용자 경험)
2차 방어선: DB 유니크 제약조건 (데이터 무결성 보장)
상황 | Redis만 | Redis + DB 유니크 |
---|---|---|
정상 상황 | 동작 | 동작 |
Redis 장애 | 중복 예약 발생 | DB가 차단 |
네트워크 지연 | 타임아웃으로 중복 가능 | DB가 보장 |
극한 동시성 | 락 누락 가능성 | DB가 100% 보장 |
@Entity
@Table(name = "reservations",
uniqueConstraints = @UniqueConstraint(
name = "uk_trainer_date_time",
columnNames = {"trainer_id", "reservation_date", "reservation_time"}
))
public class Reservation extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, columnDefinition = "DATE")
private LocalDate reservationDate;
@Column(nullable = false, columnDefinition = "TIME")
private LocalTime reservationTime;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "trainer_id", nullable = false)
private Trainer trainer;
// 기타 필드들...
}
implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' // ⭐ Redisson
redisson 스타터 라이브러리를 설치해주자.
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + "localhost포트번호");
return Redisson.create(config);
}
}
포트번호라고 적혀있는 곳에 Redis서버 포트번호를 입력하기!
RedissonClient를 Bean으로 등록해줌.
@Service
@Transactional
@RequiredArgsConstructor
public class ReservationService {
/**
* 완벽한 하이브리드 동시성 제어
* 1차: Redis 분산 락으로 동시성 제어
* 2차: DB 유니크 제약조건으로 최종 보장
*/
public ReservationResponseDto createReservation(
ReservationRequestDto request,
Long userId, Long gymId, Long trainerId) {
String lockKey = String.format("reservation:lock:%d:%s:%s",
trainerId, request.getReservationDate(), request.getReservationTime());
return distributedLockService.executeWithLock(
lockKey, 5, 10, TimeUnit.SECONDS,
() -> {
try {
// 기존 예약 로직 실행 (DB 유니크 제약조건 포함)
return createReservationInternal(request, userId, gymId, trainerId);
} catch (DataIntegrityViolationException e) {
// 만약 Redis 락이 실패해도 DB 레벨에서 최종 차단
log.warn("DB 유니크 제약조건에 의한 중복 차단: {}", e.getMessage());
throw new BaseException(ExceptionCode.RESERVATION_CONFLICT);
}
}
);
}
}
1단계: DB 유니크 제약조건으로 기본 안전성 확보
2단계: Redis 분산 락으로 사용자 경험 개선
3단계: 하이브리드 방식으로 완벽한 시스템 구축
항목 | DB 유니크만 | Redis 분산락만 | 하이브리드 |
---|---|---|---|
동시성 보장 | 100% | 99.9% | 100% |
사용자 경험 | 즉시 실패 | 대기 후 안내 | 대기 후 안내 |
성능 | 87ms | 95ms | 125ms |
안정성 | 매우 높음 | Redis 의존 | 매우 높음 |
확장성 | 우수 | 우수 | 우수 |
장애 대응 | DB만 정상이면 OK | Redis 장애시 위험 | Redis 장애시에도 안전 |
구현 복잡도 | 매우 간단 | 보통 | 보통 |
=== 하이브리드 방식 최종 테스트 결과 ===
- 2명 동시 테스트: 성공 1명, 실패 1명
- 10명 동시 테스트: 성공 1명, 실패 9명
- 50명 극한 테스트: 성공 1명, 실패 49명
- Redis 장애 시뮬레이션: DB 백업으로 안전
모든 시나리오에서 완벽한 동시성 제어 달성!
Fitpass 동시성 제어 시스템은 단계적 접근을 통해 완벽한 솔루션을 구축했다.
DB 제약조건만으로는 동시성 제어를 한 것이 아니고 Redis와 같이하여 안정성도 올리고,
Redis로 락과 트랜잭션 관리도 배웠다. 기술의 완성도보다는 문제를 파악하고 어떻게 구현할건지 고민하며 확장 가능한 아키텍처로 발전시켰다.