-정원 초과 문제를 해결하기 위한 동시성 제어와 적용 과정-
1. 도입 배경: 선착순 수강 신청에서 발생한 정원 초과 문제
온라인 강의나 교육 플랫폼에서는 특정 강좌의 정원을 제한하고 선착순으로 수강 신청을 받는 기능을 자주 사용합니다. 예를 들어 정원이 100명이라면 100번째 신청자까지 허용하고 이후 요청은 마감 처리해야 합니다.
일반적인 상황에서는 현재 수강 인원을 조회한 뒤 정원보다 작으면 신청을 저장하는 방식으로 구현할 수 있습니다. 하지만 여러 사용자가 거의 동시에 신청하면 문제가 발생합니다. 정원이 100명이고 현재 99명인 순간 두 요청이 동시에 99명을 읽으면 모두 신청에 성공하여 최종 인원이 101명이 될 수 있습니다.
이는 여러 트랜잭션이 동일한 데이터를 동시에 읽고 수정하면서 발생하는 동시성 문제입니다. 따라서 단순한 CRUD 로직만으로는 정원이라는 비즈니스 규칙을 안정적으로 보장하기 어렵습니다.
2. 단순한 구현의 한계
가장 쉽게 구현할 수 있는 방식은 현재 인원을 조회하고 정원을 확인한 후 신청을 저장하는 것입니다.
@Transactional
public void enroll(Long courseId, Long studentId) {
Course course = courseRepository.findById(courseId)
.orElseThrow();
if (course.getCurrentCount() >= course.getCapacity()) {
throw new IllegalStateException("Course is full.");
}
course.increaseCount();
enrollmentRepository.save(new Enrollment(courseId, studentId));
}
이 코드의 문제는 여러 요청이 같은 currentCount를 읽고 동시에 조건을 통과할 수 있다는 점입니다. @Transactional을 적용했다고 해서 트랜잭션 사이의 동시 접근이 자동으로 차단되는 것은 아닙니다. 따라서 별도의 동시성 제어가 필요합니다.
3. 동시성 해결 방안 비교
3.1 synchronized
synchronized는 하나의 JVM 안에서 동일 코드의 동시 실행을 막을 수 있습니다. 그러나 서버가 여러 대로 확장되면 각 서버가 별도의 JVM을 사용하므로 서버 간 동시성은 제어하지 못합니다. 따라서 분산 환경에서는 적합하지 않습니다.
3.2 비관적 락
비관적 락은 데이터베이스 행을 잠근 뒤 수강 인원을 확인하고 변경하는 방식입니다. JPA에서는 PESSIMISTIC_WRITE를 사용할 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Course c where c.id = :id")
Optional<Course> findByIdForUpdate(@Param("id") Long id);
여러 서버에서도 동작한다는 장점이 있지만 특정 강좌에 요청이 집중되면 락을 기다리는 요청이 증가할 수 있습니다.
3.3 낙관적 락
낙관적 락은 버전 정보를 이용하여 다른 트랜잭션의 변경 여부를 확인하는 방식입니다.
@Version
private Long version;
충돌 시 예외가 발생하므로 재시도 등의 추가 처리가 필요합니다. 선착순처럼 짧은 시간에 충돌이 집중되는 상황에서는 재시도 정책까지 고려해야 합니다.
4. 최종 선택: 조건부 원자적 UPDATE
이번 문제에서는 데이터베이스의 조건부 원자적 UPDATE 방식을 선택했습니다. 현재 인원을 조회한 뒤 애플리케이션에서 판단하는 대신, 정원 조건을 UPDATE 문에 포함시키는 방식입니다.
UPDATE course
SET current_count = current_count + 1
WHERE id = :courseId
AND current_count < capacity;
조건 확인과 값 변경을 하나의 데이터베이스 연산으로 처리하는 것이 핵심입니다. 정원이 남아 있으면 UPDATE가 성공하고, 정원에 도달한 이후 요청은 WHERE 조건을 만족하지 않아 변경 행 수가 0이 됩니다.
애플리케이션에서는 변경 행 수를 확인하여 성공 여부를 판단할 수 있습니다.
@Transactional
public void enroll(Long courseId, Long studentId) {
int updated = courseRepository.increaseCountIfAvailable(courseId);
if (updated == 0) {
throw new IllegalStateException("Course is full.");
}
enrollmentRepository.save(
Enrollment.create(courseId, studentId)
);
}
Spring Data JPA에서는 @Modifying과 @Query를 이용해 조건부 UPDATE를 정의할 수 있습니다.
@Modifying
@Query("update Course c " +
"set c.currentCount = c.currentCount + 1 " +
"where c.id = :courseId " +
"and c.currentCount < c.capacity")
int increaseCountIfAvailable(@Param("courseId") Long courseId);
5. 이 방식을 선택한 이유
가장 중요한 기준은 정원이라는 비즈니스 규칙을 데이터베이스 수준에서 보장할 수 있는지 여부였습니다. synchronized는 다중 서버 환경에서 한계가 있고, 낙관적 락은 충돌이 많은 상황에서 재시도 로직이 필요합니다. 비관적 락은 안정적이지만 특정 강좌에 요청이 집중되면 락 경합이 발생할 수 있습니다.
조건부 원자적 UPDATE는 정원 검증과 인원 증가를 하나의 연산으로 결합할 수 있습니다. 별도의 분산 락 시스템을 추가하지 않고 기존 데이터베이스 기능을 활용할 수 있다는 점도 장점입니다. 현재 요구사항처럼 단순한 수량 증가 문제에는 비교적 간결한 해결책입니다.
6. 중복 신청과 데이터 정합성
정원 초과를 막았더라도 동일 학생의 중복 신청은 별도로 방지해야 합니다. 빠른 버튼 클릭이나 네트워크 재전송으로 같은 요청이 중복될 수 있기 때문입니다. 따라서 학생과 강좌의 조합에 유니크 제약 조건을 두는 것이 안전합니다.
ALTER TABLE enrollment
ADD CONSTRAINT uk_enrollment_student_course
UNIQUE (student_id, course_id);
또한 정원 증가와 수강 신청 저장은 하나의 트랜잭션으로 처리해야 합니다. 두 작업이 함께 성공하거나 오류 발생 시 함께 롤백되도록 구성하면 정원만 증가하고 신청이 저장되지 않는 문제를 방지할 수 있습니다.
7. 도입 효과
동시성 제어를 적용하면 이벤트 시작 직후처럼 요청이 집중되는 상황에서도 정원 초과를 방지할 수 있습니다. 데이터베이스의 원자적 연산을 사용하므로 여러 애플리케이션 서버가 동작하는 환경에서도 동일한 정원 규칙을 적용할 수 있습니다.
또한 별도의 분산 락 시스템을 추가하지 않아 인프라 복잡도를 크게 늘리지 않는다는 장점이 있습니다. 조회 후 판단하고 저장하는 과정을 줄일 수 있어 코드도 단순해집니다. 다만 외부 결제나 메시지 발행 등이 포함된다면 별도의 트랜잭션 및 이벤트 처리 전략을 검토해야 합니다.
8. 마무리
선착순 수강 신청의 정원 초과 문제는 정상적인 단일 요청에서는 쉽게 드러나지 않지만 특정 순간에 요청이 집중되면 바로 나타나는 동시성 문제입니다. 단순히 현재 인원을 조회하고 정원보다 작은지 확인하는 방식만으로는 여러 요청을 안전하게 처리하기 어렵습니다.
이번 문제에서는 여러 방식을 비교한 뒤 조건부 원자적 UPDATE를 선택했습니다. 정원 검증과 인원 증가를 하나의 데이터베이스 연산으로 처리하여 정원 초과를 방지하고, 여러 서버 환경에서도 일관된 결과를 얻을 수 있기 때문입니다. 여기에 유니크 제약 조건과 트랜잭션을 함께 적용하면 중복 신청과 데이터 불일치도 방지할 수 있습니다.
결국 동시성 문제의 핵심은 어떤 데이터가 동시에 변경될 수 있는지, 반드시 지켜야 하는 비즈니스 규칙은 무엇인지, 어느 계층에서 이를 보장해야 하는지를 명확하게 정의하는 것입니다. 정원, 재고, 쿠폰 수량, 좌석처럼 한정된 자원을 동시에 획득하는 기능에서는 이러한 관점이 특히 중요합니다.
dwmoon