뽀모도로 메이트 프로젝트(동료들과 함께 온라인 스터디를 진행할 수 있는 서비스)를 진행하면서 다음과 같은 요구사항을 처리해야하는 상황이 발생했습니다.
처음에는 이러한 요구사항을 충족하기 위해 최대 인원 수를 넘어서 참가하는 경우에는 예외 처리되도록 스터디 참가 기능을 구현했습니다.
하지만 멀티스레드 환경인 웹에서는 동시에 참가 요청을 보낼 경우, 최대 인원 수를 초과해서 참가가 되는 현상이 발생했습니다.
그래서 이에 대한 해결책으로 Sycronized, 비관적 락, 낙관적 락 세 가지 방법을 시도해 보았습니다.
동시성 제어를 테스트하기 위해 여러 개의 스레드를 사용하여 동시에 동작하는 테스트를 작성했습니다.
@Test
void participateConcurrentVerification() throws InterruptedException {
int requestCount = 100;
StudyRoom studyRoom = StudyRoom.builder()
.id(1000L)
.maxParticipantCount(MaxParticipantCount.of(8))
.build();
studyRoomRepository.save(studyRoom);
for (int i = 1; i <= requestCount; i += 1) {
User user = User.builder()
.id((long) i)
.build();
userRepository.save(user);
}
int threadCount = 30;
// 스레드 풀 생성
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
//다른 스레드의 작업이 완료 될 때까지 기다리게 해주는 클래스
// countDown()을 통해 0까지 세어야 await()하던 thread가 다시 실행됨
CountDownLatch latch = new CountDownLatch(requestCount);
// 스레드 실행
LongStream.rangeClosed(1L, requestCount)
.forEach(id ->
executorService.submit(() -> {
try {
participateService.participate(UserId.of(id), studyRoom.id());
} finally {
latch.countDown();
}
}));
latch.await();
Long participantCount = participantRepository.countActiveBy(studyRoom.id());
assertThat(participantCount).isEqualTo(8L);
}
Java에서는 Synchronized 키워드를 사용하여 간단하게 동시성 문제를 해결할 수 있습니다.
Synchronized 키워드는 여러 스레드가 한 자원을 사용할 때 해당 자원에 접근하는 스레드를 제외하고 나머지 스레드들이 접근하지 못하도록 막습니다.
ParticipateService.java
@Transactional
public synchronized Long participate(UserId userId, StudyRoomId studyRoomId) {
User user = userRepository.findById(userId.value())
.orElseThrow(UnauthorizedException::new);
StudyRoom studyRoom = studyRoomRepository.findById(studyRoomId.value())
.orElseThrow(StudyRoomNotFoundException::new);
studyRoom.validateIncomplete();
Long participantCount = participantRepository.countActiveBy(studyRoomId);
// 현재 인원수가 최대 인원수 이상이면 예외처리하는 메서드
studyRoom.validateMaxParticipantExceeded(participantCount);
// 참가자를 생성하여 추가 or 재참가 메서드
return createOrUpdateParticipant(userId, studyRoomId, user, studyRoom);
}
메서드에 Synchronized를 적용한 결과, 테스트가 성공적으로 통과했습니다. 하지만 CI 환경에서는 랜덤하게 실패하는 현상이 발생했습니다.
테스트 결과는 성공이었기 때문에 일단 커밋을 올리고, 적용된 사항들을 PR에 올렸습니다. 하지만 Local에서는 문제가 없었던 동시성 테스트가 CI환경에서 실패하는 현상이 발생했습니다.
Local에서는 문제가 없었는데 갑자기 발생했기 때문에 일단 다시 CI를 돌려봤습니다. 그런데 이번에는 성공적으로 빌드가 완료되었습니다.
여러번 확인해본 결과, 성공할 때도 있고, 실패할 때도 있는 걸 확인할 수 있었습니다.
Local이 아닌 CI환경에서, 그것도 랜덤으로 터지는 문제였기 때문에 당황스러웠습니다.
하지만 CI 환경과 Local 환경의 차이, 그리고 Sycronized를 사용했을 때의 주의사항을 공부해본 결과 원인을 알아낼 수 있었습니다.
원인은 @Transactional 때문에 발생한 레이스 컨디션 이슈였습니다.
레이스 컨디션: 둘 이상의 스레드가 공유 데이터에 액세스할 수 있고, 동시에 변경하려고 할 때 발생하는 문제
Spring Framework에서 @Transactional을 사용하면 Spring AOP가 적용되어 트랜잭션을 처리하는 프록시 객체가 생성됩니다.
프록시 객체는 다음과 같은 매커니즘으로 동작합니다.
// Proxy class
class TransactionParticipateService {
private ParticipateService participateService;
public void participate(UserId userId, StudyRoomId studyRoomId) {
try{
tx.start();
participateService.participate();
} catch (Exception e) {
// ....
} finally {
tx.commit();
}
}
}
// Origin Class
class ParticipateService {
public synchronized void participate(UserId userId, StudyRoomId studyRoomId) {
// ....
}
}
프록시 객체는 원본 객체인 ParticipateService를 상속하여 생성됩니다. 그러나 synchronized는 메서드 시그니처(=메서드 이름 + 파라미터 타입과 개수)에 포함되지 않기 때문에, 프록시 객체의 participate() 메서드는 여러 스레드가 동시에 접근할 수 있게 됩니다.
따라서 프록시 객체가 commit을 수행할 때 synchronized가 적용되지 않습니다. 이로 인해 여러 스레드가 동시에 데이터를 변경하려고 시도할 때 레이스 컨디션이 발생할 수 있습니다.
레이스 컨디션은 @Transactional 때문에 발생한 것이었기 때문에 @Transactional을 지우고 직접 Save와 Flush를 진행해주는 것으로 해결할 수 있었습니다.
그렇다면 왜 Local 환경에서는 성공했던 테스트가 CI환경에서는 실패했을까요?
그 이유는 로컬 환경에서는 DB와의 통신이 빠르게 이루어져 문제가 발생하지 않았지만, CI 환경에서는 DB와의 통신이 느려서 인원수를 읽어오는 쿼리가 DB에 반영되는 시점보다 빠른 경우가 있었기 때문입니다.
다른분들의 글(재고 감소)을 보면 대부분 Local 환경에서부터 테스트가 실패했던 것과 다르게, 저는 CI환경에서만 실패를 했습니다. 그분들은 바로 재고를 불러와서 감소를 했던 반면, 저는 인원수 확인 이전에 실행되는 작업이 조금 더 있었기 때문에 CI 환경에서만 실패했던 것으로 추측됩니다.
Sycronized 키워드를 이용하여 어플리케이션 레벨에서 동시성 문제를 해결해보았습니다. 하지만 Sycronized는 단일 서버에서만 사용이 가능하기 때문에 실무에서는 잘 쓰이지 않습니다. 따라서 비관적 락과 낙관적 락을 적용하여 해결해보겠습니다.
비관적 락은 트랜잭션이 데이터에 접근하기 전에 락을 걸어 다른 트랜잭션의 접근을 제한하는 방식입니다. 주로 데이터 충돌이 자주 발생하거나 데이터의 일관성이 중요한 상황에서 사용됩니다.
이러한 방식은 데이터의 일관성을 유지하고 동시 수정을 방지하는 데 도움이 됩니다. 하지만 해당 데이터에 락을 걸어두기 때문에 동시 처리 성능 저하와 데드락이 발생할 수 있습니다.
비관적 락은 데이터를 가져오는 JpaRepository 메서드에 @Lock을 추가해서 구현할 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<StudyRoom> findById(Long id);
만약 저처럼 QueryDsl을 쓰신다면 아래와 같이 구현할 수 있습니다.
StudyRoomRepositoryImpl.java
@Override
public Optional<StudyRoom> findByIdForUpdate(Long id) {
QStudyRoom studyRoom = QStudyRoom.studyRoom;
return Optional.ofNullable(
queryFactory
.select(studyRoom)
.from(studyRoom)
.where(studyRoom.id.eq(id))
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.fetchOne());
}
ParticipateService.java
@Transactional
public Long participate(UserId userId, StudyRoomId studyRoomId) {
User user = userRepository.findById(userId.value())
.orElseThrow(UnauthorizedException::new);
StudyRoom studyRoom = studyRoomRepository.findByIdForUpdate(studyRoomId.value())
.orElseThrow(StudyRoomNotFoundException::new);
studyRoom.validateIncomplete();
Long participantCount = participantRepository.countActiveBy(studyRoomId);
studyRoom.validateMaxParticipantExceeded(participantCount);
return createOrUpdateParticipant(userId, studyRoomId, user, studyRoom);
}
비관적 락을 사용할 때는 Lock Timeout을 꼭 적용해주어야 합니다. 데드락을 방지하고 시스템 성능을 향상시키기 위해서입니다.
예를들어, 락을 획득한 트랜잭션이 다른 트랜잭션이 필요로 하는 자원을 계속해서 가지고 있는 상황이라면, 해당 자원을 필요로 하는 트랜잭션들은 대기 상태에 머무르게 됩니다. 이러한 대기 상태가 지속되면 시스템의 처리량이 감소하고 응답 시간이 늘어나는 문제가 발생할 수 있습니다.
이를 방지하기 위해 Lock Timeout을 설정하면, 락을 획득하기 위한 대기 시간을 제한할 수 있습니다.
Lock Timeout은 DB마다 기본값이 다릅니다. 참고 글
제가 사용하고 있는 PostgreSQL의 경우 기본값으로 무한정 대기하기 때문에 Lock Timeout을 지정해 주는 게 더욱 더 중요합니다.
MySQL의 경우에는 기본적으로 최대 50초를 기다리는데, 이 경우도 너무 길기 때문에 적절한 Lock Timeout을 설정해줘야 합니다.
JPA에서 Lock Timeout을 지정하는 방법으로 Hint를 사용하는 방법이 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})
Optional<StudyRoom> findById(Long id);
하지만 PostgreSQL은 해당 방식을 지원하지 않기 때문에 직접 작성해줘야합니다.
Lock Timeout은 앞으로도 자주 사용될 수 있기 때문에, Spring AOP를 활용하여 쿼리 수행 전 Lock Timeout을 걸도록 해보겠습니다.
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LockTimeout {
int timeout() default 5000;
}
먼저 어노테이션을 만들어 timeout 시간을 받아올 수 있도록 해주었습니다. 기본값을 5초로 해두었습니다.
@Component
public class LockTimeoutRepository {
private final EntityManager entityManager;
public LockTimeoutRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
public void setLockTimeout(int timeout) {
Query query = entityManager.createNativeQuery("SET lock_timeout = " + timeout);
query.executeUpdate();
}
}
Lock Timeout을 설정할 수 있는 Repository를 생성하고, Timeout 쿼리를 추가하는 메서드를 작성합니다.
@Aspect
@Component
@RequiredArgsConstructor
public class LockTimeoutAspect {
private final LockTimeoutRepository lockTimeoutRepository;
// com.pomodoro.pomodoromate.common.annotations.LockTimeout은 어노테이션 위치
@Before("@annotation(com.pomodoro.pomodoromate.common.annotations.LockTimeout)")
public void beforeLockTimeout(JoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
LockTimeout lockTimeout = methodSignature.getMethod().getAnnotation(LockTimeout.class);
lockTimeoutRepository.setLockTimeout(lockTimeout.timeout());
}
}
쿼리 수행 전에 수행되도록 Before 어노테이션을 활용하여 다음과 같이 작성해줍니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@LockTimeout(timeout = 3000)
Optional<StudyRoom> findById(Long id);
마지막으로 만들어진 @LockTimeout을 적용하면 아래와 같이 쿼리 수행 전에 Lock Timeout이 걸리는 걸 확인할 수 있습니다.
set lock_timeout = 3000;
select * from studyroom where studyroom.id ? for update
만약 Timeout 시간 전에 Lock을 획득하지 못하면 PessimisticLockingFailureException이 발생하게 됩니다.
낙관적 락은 데이터에 실제로 락을 걸지 않고, 데이터의 버전을 관리하여 충돌 여부를 확인하는 방식입니다. 주로 데이터 충돌이 드물거나 읽기 작업이 많은 상황에서 사용됩니다.
이러한 방식은 락으로 인한 성능 저하를 최소화하고 동시성을 높이는 데 도움이 됩니다. 하지만 충돌이 발생했을 때 데이터를 다시 처리해야 하는 단점이 있습니다. 또한 데이터의 순서를 보장해주지 않습니다.
StudyRoom.java
public class StudyRoom {
...
@Version
private Long version;
}
StudyRoomRepositoryImpl.java
@Override
public Optional<StudyRoom> findById(Long id) {
QStudyRoom studyRoom = QStudyRoom.studyRoom;
return Optional.ofNullable(
queryFactory
.select(studyRoom)
.from(studyRoom)
.where(studyRoom.id.eq(id))
.setLockMode(LockModeType.OPTIMISTIC)
.fetchOne());
}
ParticipateService.java
@Transactional
public Long participate(UserId userId, StudyRoomId studyRoomId) {
User user = userRepository.findById(userId.value())
.orElseThrow(UnauthorizedException::new);
StudyRoom studyRoom = studyRoomRepository.findById(studyRoomId.value())
.orElseThrow(StudyRoomNotFoundException::new);
studyRoom.validateIncomplete();
Long participantCount = participantRepository.countActiveBy(studyRoomId);
studyRoom.validateMaxParticipantExceeded(participantCount);
return createOrUpdateParticipant(userId, studyRoomId, user, studyRoom);
}
ParticipateFacade.java
public class ParticipateFacade {
private final ParticipateService participateService;
private static final int MAX_RETRY_COUNT = 5;
public ParticipateFacade(ParticipateService participateService) {
this.participateService = participateService;
}
@Transactional
public Long participate(UserId userId, StudyRoomId studyRoomId) throws InterruptedException {
int retryCount = 0;
while (retryCount < MAX_RETRY_COUNT) {
try {
Long participateId = participateService.participate(userId, studyRoomId);
return participateId;
} catch (Exception e) {
Thread.sleep(20);
retryCount += 1;
}
}
throw new MaxRetryExceededException(MAX_RETRY_COUNT);
}
낙관적 락의 경우 충돌이 발생한 경우 롤백을 해주기위해 위의 별도의 롤백 및 재시도 코드를 작성해줘야합니다. 따라서 충돌이 발생하면 로직의 롤백과 재시도가 반복되기 때문에 비관적 락에 비해 많은 비용이 발생합니다.
비관적 락과 낙관적 락은 각각의 장단점이 있으며, 적절한 전략의 선택은 애플리케이션의 요구 사항과 환경에 따라 달라집니다. 선택할 때는 데이터의 충돌 가능성, 요구 사항, 데이터의 중요성 등을 고려해야 합니다.
일반적으로, 데이터의 충돌 가능성이 높거나 데이터의 일관성이 매우 중요한 경우에는 비관적 락을, 충돌 가능성이 낮고 시스템의 성능을 최대한 유지해야 하는 경우에는 낙관적 락을 선택하는 것이 좋습니다.
뽀모도로 서비스의 경우에는 트래픽이 많지 않아 충돌 가능성이 낮고, 금전적인 데이터처럼 중요도가 높은건 아니었기 때문에 낙관적 락이 적합하다고 생각했습니다. 하지만 낙관적 락은 요청 순서를 보장해주지 않기 때문에 순서를 보장해야하는 요구사항에 맞지 않아 비관적 락을 적용하였습니다.
적절한 락 전략을 선택하고 구현하는 것은 데이터의 동시성 제어를 효과적으로 수행하고, 애플리케이션의 안정성과 성능을 보장하는 데 매우 중요합니다. 따라서 각각의 락에 대해 공부해보고, 요구사항에 맞게 적절한 락을 선택하는 것이 좋을 것 같습니다.