한끼족보에서 발생하는 동시성 문제
'한끼족보'에도 동시성 문제(=Race Condition)가 존재한다.
(테스트코드에 아직 미숙하여 curl
명령어를 이용하여 동시 요청을 해보았다.)
위 사진에서 볼 수 있듯, 2명의 유저가 동시에 하나의 가게에 좋아요를 누른다면, 해당 가게의 전체 좋아요 수가 2가 아니라 1로 집계되는 문제가 발생했다. 왜 이런 문제가 발생하는 것일까?
문제 발생 원인
위는 현재 '한끼족보'의 테이블 구조이다. '좋아요 수'를 비정규화 하여 가게 테이블에 위치해 있는 상태이다.
@Transactional
public HeartCreateResponse createHeart(final HeartPostCommand heartPostCommand) {
User user = userFinder.getUserReference(heartPostCommand.userId());
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartPostCommand.storeId());
validateStoreHeartCreation(user, store);
saveStoreHeart(user, store);
store.increaseHeartCount();
return HeartCreateResponse.of(store);
}
때문에 구현 역시 사용자의 요청이 들어오면 Heart 테이블에 추가하고 Store테이블의 좋아요 수를 1씩 증가시키는 방식으로 동작한다.
여기서 문제가 발생한다.
전체적인 흐름을 정리하면 다음과 같다. 두 요청이 hearCount를 1씩 증가시키기 위해 hearCount 칼럼에 접근하면 먼저 최초의 값인 0을 읽게 될 것이다. 그 후 첫번째 트랜잭션이 1 증가시키기 위해 쓰기 잠금을 획득하여 값을 1로 수정 후 커밋을 완료하면 두번째 트랜잭션은 그 후에 값을 수정할 수 있다. 두번째 트랜잭션이 값을 1증가 시키기 위해 write하는 과정에서 문제가 발생하는데 2번째 트랜잭션이 직전에 읽은 좋아요 수는 1이 아니라 0이기 때문에 0에 +1을 하여 2가 아니라 1이 되는 현상이 발생하는 것이다.
이 문제를 어떻게 해결할 수 있을까?
동시성 해결 방법들
Synchronized
자바에서는 Synchronized 키워드를 통해 하나의 스레드만 접근이 가능하도록 만들어준다.
@Transactional
public synchronized HeartCreateResponse createHeart(final HeartPostCommand heartPostCommand) {
User user = userFinder.getUserReference(heartPostCommand.userId());
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartPostCommand.storeId());
validateStoreHeartCreation(user, store);
saveStoreHeart(user, store);
store.increaseHeartCount();
return HeartCreateResponse.of(store);
}
사용방법은 간단하다. 위와 같이 메소드 선언부에 synchronized 키워드를 붙여주면 된다.
하지만 @Transactional
어노테이션과 synchronized
를 함께 사용하는 경우 동시성 문제가 해결되지 않는다. 그 이유는 @Transactional
의 동작 원리에 있다. @Transactional이 붙은 메소드는 Proxy 객체를 생성하여 트랜잭션 관련 처리를 해준다. (AOP 동작 원리)
public class HeartServiceProxy {
private HeartService heartService;
public HeartServiceProxy(HeartService heartService) {
this.heartService = heartService;
}
public void increase() {
// 트랜잭션 시작 로직
...
// 비즈니스 로직 수행
...
store.increaseHeartCount();
// 트랜잭션 종료 로직
}
}
Synchronized는 해당 메소드가 종료되면 다른 스레드에서 해당 메소드를 실행할 수 있게 되기 때문에, store.increaseHeartCount()
라는 비즈니스 로직을 수행하고, 트랜잭션이 최종적으로 종료되기 전에, 다른 트랜잭션이 비즈니스 로직을 수행할 수 있게 되는 것이다. 즉, 트랜잭션의 경계는 메서드가 끝난 후 커밋이 이루어지기 때문에, 이때 접근한 다른 스레드는 이전의 스레드가 트랜잭션을 커밋하기 전이므로, 아직 반영이 안된 좋아요 개수를 읽어 여전히 동시성 이슈가 깔끔하게 해결되지 못하는 것이다.
또한 Synchronized는 하나의 프로세스 내에서만 동시성을 보장하기 때문에 다중 서버 환경에서는 동시성을 보장할 수 없다. 물론, 현재의 프로젝트에서 사용하는 서버의 개수는 1대이지만, 만약 사용자의 수가 늘어 서버를 증설해야하는 경우가 생긴다면, 다시 동시성 이슈가 발생하는 것이다.
비관적 락
비관적 락은 동일한 데이터가 동시에 수정될 가능성이 높다고 생각하고 데이터베이스에 락을 거는 방식이다. 즉, 데이터베이스 락을 통해 여러 트랜잭션이 동시에 데이터를 조작하는 것을 방지하여 데이터의 일관성을 유지한다. 트랜잭션1과 트랜잭션2가 동시에 동일 데이터에 접근하여 수정하는 상황을 가정해보자.
트랜잭션1은 로직을 수행하기 위해 쓰기 잠금을 걸고 때문에 트랜잭션2는 트랜잭션1이 모든 작업을 완료할 때까지 즉, 커밋이나 롤백을 수행하기 전까지는 작업을 수행할 수 없다. 그 후, 트랜잭션1의 작업이 모두 수행되면 트랜잭션2가 잠금을 획득하고 로직을 수행하게 된다.
이러한 비관적 락은 하나의 트랜잭션의 작업이 모두 완료되기 전까지는 다른 트랜잭션들은 작업을 수행할 수 없기 때문에 상대적으로 수행 시간이 느리다.
하지만, 실시간 티켓팅 작업과 같이 충돌이 빈번하게 발생하는 경우에는 낙관적 락에 비해 비관적 락의 수행시간이 빠를 수도 있다. 낙관적 락은, 충돌이 발생하여 로직을 수행하지 못한 트랜잭션들에 대해서는 로직을 다시 수행하도록 재시도 로직을 구현해야 하는데, 이러한 재시도 과정을 수행하면서 결과적으로 모든 트랜잭션들의 작업이 완료되는 시간이 굉장히 지연될 수 있기 때문이다. 때문에 공유 자원에 대해 동시에 접근하는 경우가 빈번하다면, 비관적 락을 거는 것이 좋다.
낙관적 락
낙관적 락은 ‘낙관적’이라는 말 그대로 충돌이 발생하지 않는다고 가정하는 것이다. DB에서 처음 읽어온 Version을 기억하고 update시 현재 DB의 버전과 다르다면 롤백을 시킨다. 쉽게 말해서 버저닝을 통한 동시성 제어 방식으로 애플리케이션 락이라고도 한다.
위의 사진과 같이 4개의 트랜잭션이 하나의 데이터에 동시에 접근하는 상황을 가정해보자. 낙관적 락 방식을 사용하면, 트랜잭션A는 버전이 같기 때문에 로직을 정상적으로 수행하여 commit을 한 후, 버전 정보를 수정한다. 그 후에 수행되는 트랜잭션 B~D는 버전 정보가 달라지기 때문에 로직을 수행하지 못하게 된다. 때문에 데이터의 일관성을 보장하기 위해서 낙관적 락에서는 이렇게 실패된 트랜잭션들에 대해 다시 로직을 수행하도록 트랜잭션 재시도 로직을 추가적으로 구현해야 한다.
지금까지 동시성 이슈를 해결하는 방법들에 대해 알아보았다.
현재 진행하고 있는 '한끼족보'에서의 좋아요 기능은 동시성 이슈가 생길 가능성이 정말 적다고 생각했고, 성능상의 이유로 인해 낙관적 락을 이용하여 동시성 이슈를 해결하기로 결심했다.
'대외 활동 > SOPT' 카테고리의 다른 글
스프링 트랜잭션과 전파 (0) | 2025.03.19 |
---|---|
중복 추가 이슈를 해결하기 위한 고민 (0) | 2025.03.19 |
애플 로그인 (0) | 2025.03.18 |
[아티클] SSH config를 통해 간편하게 SSH 연결하기 (0) | 2025.03.17 |
[아티클] JPA N+1 문제 (0) | 2025.03.17 |