이번 글에서는 동시성 문제를 해결하는 과정에서 나의 실수를 통해 배웠던 점을 소개하고자한다. 특히, 스프링에서 동시성 문제를 해결하면서 어려웠던 점과 주의 해야할 점을 적어보려고한다. 동시성 문제를 접해보았거나 해결 하실 분이라면 도움이 될 것 이라고 생각한다.
동시성 문제는 동일한 데이터에 대해 여러 프로세스나 스레드에서 동시에 접근 할 때 발생하는 문제이다. 운영체제에서 나오는 Race Condition 이 동시성 문제 중 하나이다. 데이터에 따라 DB, 메모리, 파일 등에서 발생할 수 있다.
동시에 데이터에 접근하면서 예상치 못한 문제가 발생한다. 내가 겪은 문제로 예를 들어보겠다. 아래 예시는 사용자들이 땅을 방문 할 때 땅의 소유주를 바꾸고 점수를 바꾸는 로직이다.
간단하게 거의 동시에 동일한 2개의 요청이 왔다고 가정해보자. 위 그림처럼 스프링은 2개의 스레드를 사용해서 요청을 처리 할 것이다. 편의를 위해 시간은 1ms, 2ms.. 로 가정했다. 실제 시간은 아니다.
스레드1
은 DB에 데이터를 조회하여 메모리에 가지고 있는다. 이때 DB에 user_id 가 null 이므로 소유주가 없는 상태로 조회된다.스레드2
는 이제 요청을 받았다.스레드1
는 픽셀에 소유주가 null
이므로 Redis에 1을 추가 한다.스레드1
에서 아직 DB에 소유주를 업데이트 하는 요청을 보내지 않았기에 스레드2
에서는 업데이트 되지 않은 user_id 가 null
인 정보를 조회하여 메모리에 갖게 된다.여기서 문제가 터진다. 스레드1이 업데이트를 하지 않았는데 스레드2에서 조회를 하게 되니 원래 의도했던 대로 동작하지 않는 것이다.
스레드1
은 DB에 소유주 업데이트 쿼리를 날린다.스레드2
가 데이터를 읽어서 처리해야 정상적으로 동작한다.스레드2
입장에서는 픽셀 소유주가 null
인 정보를 조회했으니 Redis에 1을 추가해서 총 점수가 2가 된다.스레드1
은 응답을 한다스레드2
는 다시 한번 DB에 소유주 업데이트 쿼리를 날린다. 같은 정보여서 바뀌진 않는다.위 문제는 이전에 분산락을 구현하여 해결했다. 하지만 이 당시에 나의 잘못된 코드로 인해 새로운 기능을 추가하며 새로운 동시성 문제가 생겼다. 지금 부터 알아보자.
@Transactional
public void occupyPixelWithLock(PixelOccupyRequest pixelOccupyRequest) {
String lockName = REDISSON_LOCK_PREFIX + pixelOccupyRequest.getX() + pixelOccupyRequest.getY();
RLock rLock = redissonClient.getLock(lockName);
long waitTime = 5L;
long leaseTime = 3L;
TimeUnit timeUnit = TimeUnit.SECONDS;
try {
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
if (!available) {
throw new AppException(ErrorCode.LOCK_ACQUISITION_ERROR);
}
// 비즈니스 로직 메서드
occupyPixel(pixelOccupyRequest);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
rLock.unlock();
}
}
이 코드는 동시성 문제가 발생하는 부분에 락을 걸기 위한 코드이다. redis 의 pub/usb 구조를 사용하는 Redisson
이라는 라이브러리를 활용하여 구현했다. 대부분은 분산락 관련 로직이고 occupyPixel
이 비즈니스 로직 부분이다.
이 코드를 보면 아주 큰 문제가 있다. 락을 거는 메서드에 @Transactional
을 붙여 놓았기 때문에 락이 해제된 후 트랜잭션이 커밋된다.
왜 이것이 문제가 될까? 락을 풀고 트랜잭션 commit 하게 되면 곧 바로 다른 스레드가 데이터에 접근하게 된다. 스레드가 접근 하는 동시의 기존 트랜잭션의 commit 도 일어나기 때문에 동시에 데이터에 접근하게된다. 이러면 또 동시성 문제가 발생한 것이다.
JPA의 flush 기능을 활용하면 @Transactional
이 끝나기 전에 강제로 커밋을 할 수 있지만 추천 하지 않는다. 왜냐하면 개발하면서 데이터를 flush 하기 위한 코드를 추가 해야하는데 처음 구현 할 때는 기억이 나서 추가 할 수 있다. 하지만 일정 기간이 지난후 로직을 변경하다보면 flush 코드를 써야하는 부분을 까먹고 작성하지 않을 가능성이 있다. 나의 경험담이다 ㅎㅎ
유지 보수성을 위해서라도 확실히 Lock 이 풀리기 전에 @Transactional
의 커밋이 일어나도록 구현하는 것이 중요하다.
비즈니스 로직인 occupyPixel
메서드에 @Transactinal
을 붙이면 안되냐 할 수 있다. 안타깝게도 불가능하다. @Transactinal
은 AOP 를 사용해 동작한다. @Transactional
어노테이션이 붙으면 프록시 객체를 사용하여 객체가 새로 생성되고 스프링은 프록시 객체를 사용하여 실제 메서드를 호출한다.
하지만 같은 클래스내의 @Transactinal
이 붙은 메서드를 호출하게 되면 외부에서 호출하는 것이 아니다. 따라서 Transaction이 적용된 프록시 객체를 거치지 않아 Transaction이 적용되지 않게 된다.
해결할 방법으로는 트랜잭션 메서드를 호출 할 분산락 메서드를 다른 클래스로 옮기고 occupyPixel
에 @Transactional
어노테이션을 다는 것으로 해결할 수 있다.
public class PixelManagerWithLock {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final PixelManager pixelManager;
private final RedissonClient redissonClient;
public void occupyPixelWithLock(PixelOccupyRequest pixelOccupyRequest) {
String lockName = REDISSON_LOCK_PREFIX + pixelOccupyRequest.getX() + pixelOccupyRequest.getY();
RLock rLock = redissonClient.getLock(lockName);
long waitTime = 5L;
long leaseTime = 3L;
TimeUnit timeUnit = TimeUnit.SECONDS;
try {
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
if (!available) {
throw new AppException(ErrorCode.LOCK_ACQUISITION_ERROR);
}
pixelManager.occupyPixel(pixelOccupyRequest);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
rLock.unlock();
}
}
}
public class PixelManager {
@Transactional
public void occupyPixel(PixelOccupyRequest pixelOccupyRequest) {
// 비즈니스 로직
}
}
이런 식으로 클래스를 분리 함으로써 락이 해제되기 전에 트랜잭션을 커밋 할 수 있다. 스프링은 트랜잭션 커밋 요청을 DB 에 보내고 커밋이 적용되는 것 까지 확인한다고 하니 락의 해제와 커밋이 동시에 진행 되는 걱정은 안해도 될 것 같다.
분산락 관련 코드를 AOP 처리 하여 해결하는 방법도 있다고 한다. @Transactional
처럼 프록시 객체를 사용하여 깔끔하게 분산락과 트랜잭션을 어노테이션 1개로 해결하는 방법이다. 분산락을 적용할 일이 많다면 아래 블로그를 참고하는 것도 좋은 선택일 것 같다.
private void occupyPixel(PixelOccupyRequest pixelOccupyRequest) {
// pixel_user 테이블을 조회하여 로직을 결정
// 비즈니스 로직
// 이벤트 리스너를 사용한 삽입 로직 분리 코드 (pixel_user 테이블에 삽입하는 이벤트)
eventPublisher.publishEvent(
new PixelUserInsertEvent(targetPixel.getId(), occupyingUserId, occupyingCommunityId));
}
이 코드는 occupyPixel
메서드의 부담을 줄이기 위해 비동기 처리를 도입했다. PixelUserInsertEvent
는 occupyPixel
과 별도로 동작하며, 이를 통해 pixel_user
테이블에 데이터를 삽입하는 작업을 처리한다. 이러한 분리를 통해 occupyPixel
의 실행 시간이 단축될 수 있었다. 하지만 동시성 문제가 다시 발생한다.
비동기로 처리되는 PixelUserInsertEvent
는 occupyPixel
메서드와 별개로 작동하며, occupyPixel
에서 설정된 Lock이 걸리지 않은 상태에서 데이터베이스에 접근한다.
이로 인해 여러 스레드가 동시에 occupyPixel
메서드를 호출할 경우, 기존 비즈니스 로직은 락을 통해 한번에 하나의 작업을 실행하는 반면, pixel_user
테이블에 삽입하는 작업은 동시에 작업을 시도하며 충돌 할 수 있다.
예를 들어, 한 스레드가 데이터를 조회하고 있는 동안 다른 스레드가 데이터를 삽입하거나 수정하려고 하면, 데이터의 일관성이 깨질 수 있다.
해결방법으로는 PixelUserInsertEvent
를 Lock 안에서 실행되게 하는 것이다. 이벤트 기반으로 비동기로 분리한 것을 제거 하고 occupyPixel
안에 로직을 다시 집어넣었다.
private void occupyPixel(PixelOccupyRequest pixelOccupyRequest) {
// pixel_user 테이블을 조회하여 로직을 결정
// 비즈니스 로직
// pixel_user 테이블에 삽입하는 로직
PixelUser pixelUser = PixelUser.builder()
.pixel(targetPixel)
.user(userRepository.getReferenceById(occupyingUserId))
.community(communityRepository.getReferenceById(occupyingCommunityId))
.build();
pixelUserRepository.save(pixelUser);
}
지금 까지 나의 잘못된 코드를 통해 동시성 문제를 해결 할 때 절대 하지 말아야 할 부분들을 소개했다. 정리 하자면,
동시성 문제를 해결하기 위해서는 락안에서 데이터를 처리하는 로직이 전부 실행되어야한다!
DB 에서 발생했다면 트랜잭션 커밋이 되어야 할 것이고 메모리라면 락안에서 메모리 접근 로직이 수행되어야 할 것이다.
만약 락안에서 실행되는 로직과 연관 없이 db에 삽입되는 로직이라면 분리해도 될 것이다. 하지만 락안에서 접근되는 데이터라면 반드시 수정과 삽입 작업도 락안에서 일어나야 동시성 문제가 발생하지 않는다.
이 글에서는 스프링과 레디스를 사용한 예제를 사용했지만 모든 방법에서 똑같이 적용되는 사실이니 주의하면서 구현해야 할 것이다.
특히, Transactional 문제 처럼 Lock 안에서 일일히 수동으로 커밋하도록 코드를 짜면 지금 당장은 쉬울지 몰라도 나중에 기능을 추가 할 때 반드시 까먹고 실수를 하게 될 것이다. 자신의 기억력을 너무 믿지 말자! 반드시 공통되는 작업은 공통으로 분리하는 습관을 들이는 것이 좋겠다.
마지막으로 다시 한번 강조하지만
그래서 저는 Save나 Update를 하는 메서드를 CommandService로 분리해두고 거기만 @Transactional적용해요. 조회할때는 트랜잭션이 필요없기도 하고 더티체킹도 무시할 수 있어서요