[Java] 다양한 낙관락/비관락 처리(동일한 사용자의 동일 자원에 대한 동시 접근 문제 해결방안)

Hyo Kyun Lee·2025년 4월 21일
0

Java

목록 보기
92/93

1. 개요

동시성 문제에 대한 생각의 폭과 깊이를 일전에 비해 더 심화하여 접근하였다.

애플리케이션 서버를 구축하면서 발생할 수 있는 동시성 문제는 여러 형태가 존재할 것이다.

  • 동일한 사용자가 동일한 자원에 동일 요청을 다수 발생하여 동시에 접근 및 처리할 경우
  • 다수의 사용자가 동일한 자원에 다수 요청을 발생하여 동시에 접근 및 처리할 경우

이 모든 문제 상황을 유발하는 공통적인 고민점은 자원의 최종적인 상태를 충분하게 예측할 수 있어야 한다는 것, 즉 멱등성이다.

처리를 하는 과정은 하나의 단위이자 기능적 흐름을 유지해야 하며, 모든 사용자는 자신이 원하는 결과를 확보하며 최종적인 자원의 상태도 예상할 수 있어야 한다.

본 동시성 기록은 동일한 사용자가 다수의 요청을 발생하였을때, 이에 대한 결과를 보장하기 위한 고민을 하고 해결하기 위한 방안을 탐색하는 과정을 기록한 글이다.

2. 동시성 제어를 적용해야 하는 상황 가정

나는 동시성 문제를 적용해야 하는 상황을 포인트 충전에 적용해보았다.

  • 사용자의 포인트 충전은 주문 및 결제를 하기 위한 필수 조건이므로 동시성 테스트를 선행해야 한다.
  • 충전에 적용할 수 있는 동시성 제어 방안은 "포인트"라는 자원에 적용하는 방안으로 모두 동일하게 적용할 수 있다.

상기와 같은 사유로 포인트 충전이라는 상황에 동시성 문제를 적용해보았고, 동시성 문제의 핵심을 "자원"으로 생각하였다.

2-1. 적용할 락의 종류 고민

가장 대표적인 락은 낙관락, 비관락으로 총 두가지 종류의 락이 있겠다.

두가지 락을 선택하는 고려요인 중 가장 중점이 될 수 있는 상황은 "실패가 일어났을 때의 처리 상황"이다.

  • 비관락

데이터를 불러오고 처리하기까지, 해당 "데이터/자원"에 대해 lock을 건다.
lock을 걸고 있는 시점에서는 어떠한 프로세스도 해당 자원에 접근할 수 없다.
다른 프로세스가 접근을 하지 못하므로, 해당 프로세스의 처리는 다른 프로세스가 lock을 점유하고 있는 기간 만큼 대기한다(=latency).

  • 낙관락

데이터를 불러오고 처리하기까지, 해당 "데이터/자원"에 대해 lock을 걸긴 하는데 프로세스가 접근할 수도 있고 처리까지 할 수도 있다.
다른 프로세스가 해당 자원을 변경하여 "버전 변경이 발생하였을때", 다른 프로세스가 이 버전 변경을 감지하고 fail처리, 트랜잭션에 대한 Exception을 발생시킨다.

2-2. [ASIS] Spring framework에서 제공하는 동시성처리의 한계 1 - trade-off가 높다.

기본적으로 제공하는 concurrentHashMap, synchronized 등의 방식은 구현 자체가 복잡할 수 있고, 이에 따라 trade-off가 매우 높다.

이 trade-off는 여러 의미가 있을 수 있다.

  • 성능
  • 락의 범위
  • 확장성(도메인이 바뀌거나 로직을 변경하여 키워드 사용하는 지점을 변경해야 하는 등)

여기서 synchronized를 사용하면 성능 문제가 발생한다는 것은 알겠는데, 왜 synchronized 키워드를 사용하면 성능 문제가 발생하는지 생각해본 적이 있는가?

아래와 같이, synchronized를 통해 메소드를 호출하는 서비스 객체에 락을 걸어준다.

@Service
public class PointWriterService {

	@Transactional
	public synchronized void charge(PointDTO pointDTO) {
		pointWriterRepository.charge(pointDTO);
	}
	
}

하지만 서비스 객체 자체가 락이 걸어져 동시성 제어 상황이 아님에도, 지나치게 넓은 동시성 제어의 범위로 인해 불필요한 동시성 제어가 적용되어 성능적으로 매우 불리한 상황이 발생한다.

이는 DBMS의 비관락에서도 연결할 수 있는 trade-off이며, 요청이 많아진다면 동시성 제어를 각 사용자가 접근하는 자원이 아닌, 각각의 사용자 모두를 잠궈버리므로 상당한 성능 저하가 일어날 수 있다.

2-3. [ASIS] Spring framework에서 제공하는 동시성처리의 한계 2 - 구현이 복잡하며 이에 따라 확장성에 한계가 있다.

동일한 사용자가 동일한 자원을 접근하고 처리하는 상황은 아래와 같이 멀티 스레드 환경 상에서 해결방안을 고민하고 구현할 수 있다.

하지만 기존의 방식대로는 구현 자체가 어렵다.

예를 들어 atomic 자료구조를 활용한다고 할 경우, concurrentHashMap 등 여러 방안이 있을 수 있겠지만 처리의 원자성뿐 만 아니라 접근의 원자성까지 고려를 해야 한다.

동시성의 핵심은 자원이므로, 동시성 제어를 위한 최소 범위를 보장해야 하는데 자료구조로는 구현하기가 쉽지 않다.

즉, 다수의 요청이 하나의 자원에 접근하여 최초 데이터를 보는 시점도 원자성이 유지되어야 한다. 동시에 접근하여 최초 상태를 동시에 같은 상태로 나타나서는 곤란하다.

아래처럼 concurrentHashMap으로 원자성 처리를 보장하여도,

//while MAX_THREAD = 5;
for (int i = 0; i < MAX_THREAD; i++) {
            executorService.execute(() -> {
                try {
                	//서비스 동작에 대한 확인
                    successCount.getAndIncrement();
                   pointService.syncCharge3(userId, chargePoint);
                } catch(Exception e){
                	
                }finally {
                	//Thread 실행 횟수 확인
                    doneSignal.countDown();
                }
            });
        }
public long syncCharge3(long id, long amount) {	
		//메소드 실행시간 확인을 위함
		long startTime = System.nanoTime();
		
		/*
		 * cocurrentHashMap이 보장해주는 동기화 처리 함수를 충전 연산에 활용한다.
		 * 값이 있으면 누적
		 * 값이 없으면 POINT + amount
		 * */
		if(concurrentHashMap.containsKey(id))
			concurrentHashMap.put(id, concurrentHashMap.get(id) + amount);
		else
			concurrentHashMap.put(id, POINT + amount);
		
		//메소드 실행시간 확인을 위함
		long endTime = System.nanoTime();
				
		//메소드 실행시간
		log.info("case 3 실행 시간 : {}", String.valueOf((endTime-startTime)/10L));
		return concurrentHashMap.get(id);
	}

자원의 접근에 대해선 아무런 조치를 취하지 않았기 때문에 동시성 테스트는 실패한다.

concurrentHashMap으로 완전한 원자성과 멱등성을 보장할 수 있는가?

3-1. [TOBE] 해결방안 1 - ReentracneLock

일단 가장 간단히 해결할 수 있는 방법은 ReentranceLock이다.

가장 기본적인 스레드락으로 스레드를 하나의 객체로 취급하여, 해당 스레드 자체가 진행하는 동안 자원 접근 및 이 이후 처리에 대한 원자성을 보장할 수 있는 방법이다.

아래와 같이 동시성 테스트를 먼저 작성한 후,

//while MAX_THREAD = 5;
for (int i = 0; i < MAX_THREAD; i++) {
            executorService.execute(() -> {
                try {
                	//서비스 동작에 대한 확인
                    successCount.getAndIncrement();
                    pointService.syncCharge4(userId, chargePoint);
                } catch(Exception e){
                	log.info(e.getMessage());
                }finally {
                	//Thread 실행 횟수 확인
                    doneSignal.countDown();
                }
            });
        }

별도의 ThreadLock을 제공하기 위한 ThreadLock 클랙스와 ReentranceLock 정적 패턴을 제공하여 준다.

public class ThreadLock {
	//charge lock을 static화 하여 charge lock을 사용하는 모든 스레드의 전역적 적용을 가능하도록 함
	public final static ReentrantLock chargeLock = new ReentrantLock();
}

이 ReentranceLock을 정적으로 사용하여 스레드의 처리과정을 락을 획득하고 상실함으로써 동시성을 제어할 수 있다.

	
    @Override
	public void charge(PointDTO pointDTO) {	
		
        //thread lock start
        threadLock.lock();
        
		//point entity
		PointDTO pointEntity = new PointDTO.PointBuilder(pointReaderRepository.findByUserId(pointDTO.getUserId()))
										.setChargedPointBuilder(pointDTO.getPoint())
										.build();
		
		
		//point entity -> user entity
		User userEntity = UserMapper.toUserEntityFromPointDomain(pointEntity);
		
		//charge
		userWriterRepository.save(userEntity);
		
		/*
		 * 이 부분은 동시성 제어 대상이 아님
		 * 다만 Transactional 어노테이션에 의해 최종적으로 commit 후 lock까지 모두 풀림
		 * */
		//point entity -> user history entity
		UserHistory userHistoryEntity = UserMapper.toUserHistoryEntityFromPointDomain(pointDTO, TransactionType.CHARGE.toString());
		
		//insert
		userWriterHistoryRepository.save(userHistoryEntity);
        
        //thread lock finished
        threadLock.unlock();
	}

이 결과 아래와 같이 비교적 손쉽게 동시성 제어 테스트를 성공할 수 있었지만, 이 역시 synchronized와 마찬가지로 이 기능을 호출하는 스레드들이 모두 lock이 걸리므로 상당히 비효율적인 방법이라 할 수 있겠다.

그러나 ReentranceLock은

  • 스레드 락, 즉 스레드 자체에 락을 획득하고 상실하므로 과정에 대한 원자성을 보장하기 위해 과부하적인 동시성 제어를 사용하는 것으로 느낄 수 있다.
  • Lock 획득과 상실을 직접 구현해야 하므로 로직이 복잡해진다면 구현이 어려워질 수 있다.
  • 기존 락범위를 특정한 상태에서 로직 변경을 대응해야 한다면, 락 범위를 변경하는 등의 추가 작업으로 인해 확장성과 유연한 대응이 어렵다.

3-2. [TOBE] 해결방안 2 - JPA 비관락

JPA 비관락을 사용하여 동시성 문제를 해결할 경우, 위와 같이 user 혹은 기능 전체에 lock을 거는 것보다 필요한 자원 자체에 락을 거는 것이 훨씬 효과적이고 그렇게 진행해야 바람직하다.

아래와 같이 수정할 데이터를 조회해오는 과정에서 JPA에서 제공하는 비관락(entityManager / PESSIMISTIC_WRITE)을 걸어 commit이 일어나기 전까지 다른 트랜잭션이 수정을 하지 못하도록 한다.

@Override
	public void charge(PointDTO pointDTO) {
		
		/*
		 * find하여 불러온 데이터에 비관적 락을 걸어 트랜잭션의 동시 수정을 방지한다.
		 * */
		PointDTO pointEntity = entityManager.find(new PointDTO.PointBuilder(pointReaderRepository.findByUserId(pointDTO.getUserId()))
															  .setChargedPointBuilder(pointDTO.getPoint())
															  .build().getClass()
												  , pointDTO.getUserId()
												  , LockModeType.PESSIMISTIC_WRITE
												  );
		
		
		//point entity -> user entity
		User userEntity = UserMapper.toUserEntityFromPointDomain(pointEntity);
		
		//charge
		userWriterRepository.save(userEntity);
		
		/*
		 * 이 부분은 동시성 제어 대상이 아님
		 * 다만 Transactional 어노테이션에 의해 최종적으로 commit 후 lock까지 모두 풀림
		 * */
		//point entity -> user history entity
		UserHistory userHistoryEntity = UserMapper.toUserHistoryEntityFromPointDomain(pointDTO, TransactionType.CHARGE.toString());
		
		//insert
		userWriterHistoryRepository.save(userHistoryEntity);

	}

동시성 해결은 되었으나, 이를 낙관락을 통해서도 접근해보았다.

3-3. [TOBE] 해결방안 3 - JPA 낙관락

낙관락은 DBMS level이 아닌 Backend level에서 락을 제어하며, 버전에 대한 확인이 필요하므로 VERSION Column을 추가해주어야 한다.

나의 경우 포인트 내역에 대한 버전 관리를 위해 USER 테이블에 VERSION 컬럼을 추가하였다.

그 후 Entity에도 Version 속성을 추가하여 JPA 적용 시 버전관리를 할 수 있도록 구성하였다.

@Entity
@Data
public class User {
	/*
	 * 영속성 계층에 대한 Entity
	 * */
	@Id
	@OneToMany(mappedBy = "user" )
	private Long userId;
	
	@ColumnDefault("LEEHYOKYUN")
	private String userName;
	
	private Long point;
	
	@Version
	private Long version;
	
	private Timestamp createdAt;
	
	private Timestamp modifiedAt;
	
	/*
	 * User Entity를 정적으로 생성하여 Mapper에서 엔티티 변환이 이루어지도록 구성
	 * */
	private User(Long userId, String userName, Long point, Timestamp createdAt, Timestamp modifiedAt) {
		this.userId = userId;
		this.userName = userName;
		this.point = point;
		this.createdAt = createdAt;
		this.modifiedAt = modifiedAt;
	}
	
	public static User standardUserEntityOf(Long userId, String userName, Long point, Timestamp createdAt, Timestamp modifiedAt) {
		return new User(userId, userName, point, createdAt, modifiedAt);
	}
}

낙관락은 애초에 다른 프로세스의 트랜잭션을 허용하므로 테스트에서 실패가 발생할 수 있다. 낙관락의 핵심은 이 실패처리를 어떻게 할 것인가, 재시도할 것인가 그대로 유지할 것인가에 대한 고민에서 시작할 것이다.

3-4. [TOBE] 해결방안 4 - Mybatis 비관락

하는 김에 Mybatis을 통한 비관락을 적용하여 동시성 문제를 해결해보았다.

생각해보니, 비관락은 DBMS 차원의 락으로 보고있었는데 낙관락처럼 backend 차원의 락으로 간주하면 될 것으로 보인다.

먼저 비관락을 적용할 자원에 대해 조회 mapper를 구성해주었다.

@Mapper
public interface PointReaderMapper {
	PointDTO searchPoint(Long userId);
}
<mapper namespace="point.infra.mybatis">
  <select id="searchPoint" resultType="PointDTO">
    SELECT A.POINT
    FROM USER A
    WHERE 1=1
      AND A.USER_ID = #{userId}
    for update
  </select>
</mapper>

그 후 비관락을 적용한 쿼리를 활용하여 트랜잭션에 그대로 적용, 동시성 제어가 이루어질 수 있도록 구성하였다.

	@Override
	public void charge(PointDTO pointDTO) {
		
		/*
		 * 비관락을 적용한 마이바티스 쿼리를 적용하여 트랜잭션의 원자성을 보장한다.
		 * */
		//point entity
//		PointDTO pointEntity = new PointDTO.PointBuilder(pointReaderRepository.findByUserId(pointDTO.getUserId()))
//										.setChargedPointBuilder(pointDTO.getPoint())
//										.build();
		PointDTO pointEntity =  pointReaderMapper.searchPoint(pointDTO.getUserId());
		
//		PointDTO pointEntity = entityManager.find(new PointDTO.PointBuilder(pointReaderRepository.findByUserId(pointDTO.getUserId()))
//															  .setChargedPointBuilder(pointDTO.getPoint())
//															  .build().getClass()
//												  , pointDTO.getUserId()
//												  , LockModeType.PESSIMISTIC_WRITE
//												  );
		
		
		//point entity -> user entity
		User userEntity = UserMapper.toUserEntityFromPointDomain(pointEntity);
		
		/*
		 * 데이터를 수정하는 시점에서 버전 변경을 감지할 경우 Exception을 발생하여
		 * 최초 트랜잭션만 성공, 나머지 트랜잭션은 모두 실패로 처리한다.
		 * */
		//charge
		userWriterRepository.save(userEntity);
		
		/*
		 * 이 부분은 동시성 제어 대상이 아님
		 * 다만 Transactional 어노테이션에 의해 최종적으로 commit 후 lock까지 모두 풀림
		 * */
		//point entity -> user history entity
		UserHistory userHistoryEntity = UserMapper.toUserHistoryEntityFromPointDomain(pointDTO, TransactionType.CHARGE.toString());
		
		//insert
		userWriterHistoryRepository.save(userHistoryEntity);

확실히 비관락은 확실하게 동시성 제어를 보장하고 exception을 발생하지 않으므로 깔끔한 동시성 제어 방안이다. 하지만 락의 범위가 그만큼 넓으므로 주의해서 사용하는 것이 좋을 것 같다.

3-5. [TOBE] 해결방안 5 - 동시성 제어를 고려할 필요가 없는 구조적 재설계 방안

동시성 제어가 필요없도록 설계를 진행하는 것도 하나의 방법이다.

현재는 한명의 user에 대해 다수의 요청이 발생할 경우 자원이 하나이므로 동시성 상황이 발생할 수 밖에 없는데, 자원을 특정할 조건에 userId와 다른 조건을 추가하여 동시성 상황이 일어나지 않도록 재설계를 하는 방안도 존재할 것이다.

4. 참고자료

(synchronized의 성능이 안좋은 또다른 이유) - JVM 가상스레드, Context Switching - https://belief-driven-design.com/looking-at-java-21-virtual-threads-bd181/
https://stackoverflow.com/questions/72116652/what-exactly-makes-java-virtual-threads-better

기존 동시성 제어의 성능적 한계/DBMS에서 제공하는 비관락의 한계 - https://velog.io/@a01021039107/%EB%B9%84%EA%B4%80%EC%A0%81pessimistic-%EB%9D%BD%EC%9C%BC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%ED%95%B4%EA%B2%B0%ED%95%B4%EB%B3%B4%EC%9E%90

기존 Spring framework에서 제공하는 락/동시성 제어의 한계 -
https://velog.io/@developerwan/Synchronized-vs-CAS%EA%B0%80-%EC%86%8D%EB%8F%84-%EC%B0%A8%EC%9D%B4%EA%B0%80-%EB%82%98%EB%8A%94-%EC%9D%B4%EC%9C%A0%EA%B0%80-%EB%AD%98%EA%B9%8C
https://stackoverflow.com/questions/72116652/what-exactly-makes-java-virtual-threads-better
https://velog.io/@mdy0102/Java%EC%97%90%EC%84%9C-%EC%93%B0%EB%A0%88%EB%93%9C%EB%9E%80

JPA를 활용한 낙관락, 비관락 -
https://mozzi-devlog.tistory.com/37
https://cookie-dev.tistory.com/30

0개의 댓글