지난 글에서 동시성의 개념과 쓰레드로컬을 이용한 해결방법에 대해 알아봤다. 오늘은 synchronized, lock, Redis를 이용한 해결방법에 대해 알아본다.
동시성 문제는 같은 데이터 필드에 동시에 접근하려고 할 때 일어나는 문제로, 값이 비정상적으로 조회되거나 저장될 수 있다. 지역변수에서는 발생하지 않으며 주로 static으로 선언된 공용 필드나, 싱글톤 객체의 필드를 변경하려 할 때 발생하기 쉽다. [참고]
자바에서 제공하는 동기화 키워드로, 동기화가 필요한 메소드나 코드블럭 앞에 사용하여 동기화할 수 있다. synchronized로 지정된 임계 영역은 한 스레드가 이 영역에 접근하여 사용할 때 lock이 걸림으로써 다른 스레드가 접근할 수 없게 된다. 이후 해당 스레드가 이 임계영역의 코드를 모두 실행 후 벗어나게되면 unlock상태가 되어 그때서야 대기하고 있던 다른 스레드가 이 임계영역에 접근하여 다시 lock을 걸고 사용할 수 있게 된다.
1) 메소드에 임계영역(synchronized) 설정하기
synchronized void increase() {
count++;
System.out.println(count);
}
2) 코드블럭에 임계영역(synchronized) 설정하기
void increase() {
synchronized(this) {
count++;
}
System.out.println(count);
참조변수 this?
인스턴스 자신을 가리키는 참조 변수. 인스턴스의 주소가 저장되어있다.
그러나, Synchronized는 @Transactional 어노테이션과 함께 사용할 경우 동기화 문제가 여전히 발생할 수 있다.
@Transactional 어노테이션을 사용하면 Spring AOP로 인해 원래의 객체를 상속한 새로운 프록시 객체를 생성한다.
begin Transaction -> method -> commit Transaction
위와 같이 메소드를 감싸, 메소드 실행 전/후로 새로운 코드를 호출하게 된다.
이 때, begin / commit; 즉 Spring에서 추가하는 Transactional 코드는 우리가 synchronized를 이용해 동기화로 감싼 코드가 아닌 별도의 코드이다. 따라서 이 별도의 코드는 우리가 의도한 동기화 기능을 수행하지 않을 수 있다.
T1: |--B--|--M--|--C-->
T2: |--B---------|--M--|--C-->
위와 같은 문제 발생 시, @Transactional 호출 전 Synchronized를 호출하는 것이 아니라, Synchronized 안에서 @Transactional 메소드를 호출하게 되면 아래처럼 우리가 원하는 방식으로 메소드를 동기화할 수 있다.
T1: |--B--|--M--|--C-->T2: |--B--|--M--|--C-->
예제 코드
public class SynchronizedService() {
@Autowired
private TransactionService transactionService;
public synchronized void onRequest(Request request) {
transactionService.onRequest(request);
}
}
public class TransactionService() {
@Transactional
public void onRequest(Request request) {
...
}
}

다만, synchronized는 한 프로세스 내에서만 동시성 제어가 가능하다. 그러나 실무에서는 대개 여러 대의 서버를 가지고 운용한다. 즉 두 개 이상의 프로세스를 실행하므로 synchronized는 잘 사용하지 않는다고 한다.
따라서 환경에 따라 애플리케이션 레벨에서의 동시성 제어가 아닌, 데이터베이스 레벨에서의 동시성 제어가 필요하다. 데이터에 직접 Lock을 걸어야 한다는 뜻이다.
데이터베이스의 table 또는 row에 락을 걸어 다른 스레드에서 접근하지 못하게 하여 동시성 이슈를 해결한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
낙관적 락은 version 개념을 사용한다.
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
jakarta.persistence의 @Version 어노테이션을 사용하여 version 필드를 객체 필드에 추가해주어야 한다.@Version
private Long version;
또한, 낙관적 락은 version에 변경이 감지될 경우 재요청을 하는 로직이 필요하므로, 해당 로직 또한 직접 작성해주어야 한다.
낙관적 락은 데이터 조회 시 Lock을 사용하지 않으므로 비관적 락보다 성능이 좋다. 그러나 version충돌이 자주 발생할 경우 오히려 비관적 락보다 성능이 떨어질 수 있다.
별도의 공간에 Lock을 생성하고 반환하여 동시성 문제를 해결한다. Named Lock은 트랜잭션이 끝날 때 자동으로 반환되지 않으므로, 반환하는 로직을 직접 구현해주어야 한다.
예제 (MySQL의 NamedLock 기능을 사용하기 위한 새로운 LockRepository 작성)
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
먼저 redis 사용을 위해서는 의존성을 추가해야 한다.
build.gradle : implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Lettuce는 spin lock 방식을 이용해 동시성 문제를 해결한다.
Lettuce 기능을 사용하기 위해 RedisLockRepository를 만들고, lock/unlock 메소드를 구현한다.
while문을 사용해 락을 얻을 때까지 재요청하는 로직을 구현하고, 락을 얻었다면 실행하고자 하는 메소드를 실행한다. 그 후 작업이 완료되면 unlock 메소드를 통해 락을 해제한다.
spring-data-redis 기본 라이브러리를 사용하여, 별도의 라이브러리를 사용하지 않아도 된다.Thread.sleep을 이용해 부하를 줄일 수 있다.)
락 획득 요청 재시도가 필요한 경우 : Redisson
출처
https://kadosholy.tistory.com/123
https://kdhyo98.tistory.com/59
https://velog.io/@balparang/Transactional과-synchronized를-같이-사용할-때의-문제점
https://velog.io/@evan523/SpringBoot-동시성-이슈-해결방법