[내일배움캠프Spring-65일차] 동시성제어

2025. 5. 21. 15:46·백엔드 부트캠프/TIL

1️⃣ Race Condition 이 발생하는 이유

- 일단 해당 부분은 Thread 블로그를 보고 오는게 좋다. 

2025.03.06 - [백엔드 부트캠프/TIL] - [내일배움캠프Spring-13일차] Thread

 

[내일배움캠프Spring-13일차] Thread

1️⃣ 프로세스와 쓰레드- 프로세스: 실행중인 프로그램, 자원과 쓰레드로 구성(공장으로 비유)- 쓰레드: 프로세스 내에서 실제 작업을 수행, 모든 프로세스는 최소한 하나의 쓰레드를 가지고 있

sintory-04.tistory.com

1. JVM 의 멀티쓰레드 실행

- 먼저, 우리 자바 JVM은 멀티스레드 실행과 스레드 스케줄링의 불확실성을 가지고 있다.

- 멀티 스레드란 하나의 프로그램 내에서 여러 개의 스레드를 동시에 실행하는 방식 이다. 하나의 쓰레드가 끝나고, 다음 쓰레드가 실행되는 것이 아닌, A 쓰레드와 B 쓰레드가 동시에 실행되는 것이다. 아래의 사진을 보면 조금 더 이해가 될 것이다.

- 이때 멀리 스레드가 문제인 것이다. 여러개의 스레드가 하나의 작업이 끝나고 접근하는 것이 아닌, 동시에 병렬적으로 처리되기 때문에 문제인 것이다. 이렇게 되면 아래의 문제에 도달한다.

- 간단하게 설명하자면, a 가 1 일때 +1 더하는 요청이 두개가 들어왔다. 우리가 원하는 결과는  a= 3 이지만, 실제 결과는 a= 2 가 나온 것이다.

- 여러 스레드가 이 과정 (조회, 증가, 저장) 을 동시에 수행하면, 한 스레드의 작업이 완료되기 전에 다른 스레드가 개입할 수 있다는 것이다. 요청은 a가 2가 되어야 하지만, 실제로는 한번만 증가하게 되는 현상이 일어난다.

- 우리는 이러한 현상을 Race Condition 이라고 말한다. 사전적 의미로는 " 공유 자원 " 에 대해 " 여러 개의 프로세스 " 가 동시에 접근을 시도할 때 접근의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태 를 뜻한다.

🤔 공유자원

- 여기서 말하는 공유 자원이라는 것은, 여러 스레드가 동시에 접근/수정하는 대상 (변수, 객체 필드, DB 등) 을 뜻한다. 그러니까 사진 상에서는 A 라는 데이터 로 받아드리면 될 거 같다.

🫡 정리

- 1. 공유 자원에 대한 동시 접근 : 여러 스레드가 동일한 변수나 객체에 동시에 발생할 때 발생한다.

- 2. 원자적이지 않은 연산 : 하나의 작업이 완료되기 전에 다른 스레드가 끼어들어 작업을 수행할 수 있다.

- 3. 메모리 가시성 문제 : 한 스레드의 변경사항이 다른 스레드에 즉시 보이지 않을 수 있다.

-> 스프링 애플리케이션에서는 특히 싱글톤 빈을 기본으로 사용하기 때문에, 여러 요청이 동시에 처리될 때 Race Condition이 발생하기 쉽습니다. 이런 문제를 해결하기 위해 synchronized 키워드, Lock 인터페이스, Atomic 클래스, ThreadLocal, @Transactional(isolation=...) 등 다양한 동기화 메커니즘을 활용할 수 있습니다.

2. 싱글톤 빈

- 해당 내용은 알면 좋을 거 같아서 추가적으로 정리한다.

- 스프링의 빈 스코프는 싱글톤 이다.

- 애플리케이션 컨텍스트 내에서 해당 빈의 인스턴스가 딱 하나만 생성되고 공유된다는 의미 이다.

🤔 무슨 말이지 ?

- 우리가 컨트롤러나 서비스에 하나의 어노테이션을 붙인다. 바로 @controller 와 @Service.

- 우리는 이런 어노테이션을 붙이게 되면 스프링에서는 해당 클래스 단위를 스프링에서 하나의 인스턴스로만 존재하게 관리하게 된다.

- 컨트롤러는 여러개의 Http 요청이 들어 올 때, 여러 요청이 하나의 컨트롤러 인스턴스에 의해서 처리된다. 

- 서비스는 여러개의 요청이 있어도, 하나의 서비스 단위에서 여러 요청을 처리한다.

- 이렇게 되면, 여러 요청이 멀티스레드로 동시에 처리되지만, 하나의 같은 컨트롤러로 처리된다.

🤔 장점

  • 메모리 효율성: 컨트롤러 인스턴스를 매번 생성하지 않음
  • 초기화 비용 절감: 의존성 주입 등이 한 번만 발생

🤔 단점

  • 인스턴스 변수를 사용하면 Race Condition 위험
  • 상태를 저장하면 요청 간 데이터 오염 가능성

- 여기서 단점을 유의 깊게 보아야하는데, Race Condition 위험이 있다는 것이다 !

조금더 자세히 살펴보자 !

🫡 그래서 왜 Race Condition 이랑 싱글톤 빈이 관계가 있는 건데? 

(1) 공유 자원의 존재 (단일 인스턴스)

- 싱글톤 패턴은 클래스당 하나의 인스턴스만 존재하도록 보장한다. 이 인스턴스가 애플리케이션 전체에 공유가 된다.

@Service
public class UserService {
    private int userCount = 0;  // 인스턴스 변수 = 모든 요청 간에 공유되는 상태
    
    public void registerUser() {
        userCount++;  // 이 변수는 모든 요청 간에 공유됨
    }
}

- 코드가 이렇게 될 시, 우리는 userCount 를  모든 요청에서 공유가 되어 사용하게 된다.

(2) 여러 스레드의 동시 접근

- 스프링 웹 애플리케이션은 HTTP 요청을 처리할 때 멀티스레드 환경에서 작동한다.

- 각 요청은 별도의 스레드에서 처리되지만, 모든 스레드가 동일한 싱글톤 인스턴스에 접근하게 된다. 아래의 사진과 같이 말이다.

(3) 원자적이지 않은 연산

- userCount ++ 는 실제로는 3가지 단계로 이루어진다.

  1. 현재값 읽기
  2. 값 증가
  3. 새 값 저장

- 여러 스레드가 해당 과정을 동시에 수행하면, 1,2,3 순서 중에 다른 스레드가 개입할 수 있다는 것이다.

✅ 그래서 근본적인 이유는 ?

  1. 공유 상태의 기본값: 싱글톤은 기본적으로 상태 공유를 의미합니다. 멀티스레드 환경에서 공유 상태는 동시성 이슈의 시작점입니다.
  2. 기본 스코프: 스프링에서 싱글톤은 기본 스코프이므로, 별도로 지정하지 않으면 모든 빈이 싱글톤이 됩니다. 따라서 개발자가 이를 인식하지 못하면 자연스럽게 동시성 문제에 노출됩니다.
  3. 웹 환경의 기본 동작: 스프링 웹 애플리케이션에서는 요청별로 새로운 스레드가 생성되는 것이 기본 동작입니다. 이 멀티스레드 환경과 싱글톤의 조합이 Race Condition을 만듭니다.

=> 하나의 인스턴스를 여러 스레드가 동시에 접근하기 때문에 일어나는 현상이라고 생각하면 된다 !

2️⃣ Synchronized

1. 개념

- 스레드간 동기화를 시켜 data의 thread-safe를 가능하게 한다.

- 자바에서 지원하는 Synchronized 키워드는 여러개의 스레드가 한 개의 자원을 사용하고자 할 때,

- 현재 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들은 데이터에 접근 할 수 없도록 막는 개념이다.

- 이렇게 되면, 여러개의 동시 요청이 들어와도 하나의 스레드만 처리하는 것이다. (멀티 스레드가 아닌, 싱글 스레드 처럼 처리한 다는 것)

- 그래서 우리는 이 Synchronized 방법으로 동시성 제어가 가능하다.

(1) 메서드에 Synchronized 키워드 추가

public synchronized void decrease() {
		entity.decrease();
}

(2) @Scynchronzied 어노테이션 추가

@Synchronized
public void decrease() {
		entity.decrease();
}

(3) Scynchronzied 블럭 추가

public void decrease() {
		synchronized(this) {
			entity.decrease();
		}
}

2. 실습

(1) 동시성 이슈 발생

- 먼저, 실제 동시성 이슈가 발생하는 걸 확인 해보자

    @Test
    @DisplayName("동시성 이슈 발생")
    void concurrencyTest() {
        System.out.println("\n\n\n\n[concurrencyTest]\n");

        synchronizedCounterService.printCount();    // 초기 값 출력

        IntStream.range(0, 1000).parallel().forEach(i -> {
            synchronizedCounterService.decreaseCount();
        });

        synchronizedCounterService.printCount();
    }
[concurrencyTest]

count = 1000
count = 848

- 848 번의 Racecondition 이 발생한 걸 확인 할 수 있다.

(2) Synchronzied 발생 -> 동시성 제어 

- 1번과 같은 방식(synchronized를 메서드에 추가) 으로 코드를 짠 후, 

    public synchronized void decreaseCountWithSynchronized() {
        SynchronizedCounter synchronizedCounter = synchronizedCounterRepository.findById(1L).orElseThrow();
        synchronizedCounter.decreaseCount();
        synchronizedCounterRepository.save(synchronizedCounter);
    }

- Test Code 를 돌리면

    @Test
    @DisplayName("Synchronized를 이용한 동시성 제어")
    void concurrencyTestWithSynchronized() {
        System.out.println("\n\n\n\n[concurrencyTestWithSynchronized]\n");

        synchronizedCounterService.printCount();    // 초기 값 출력

        IntStream.range(0, 1000).parallel().forEach(i -> {
            synchronizedCounterService.decreaseCountWithSynchronized();
        });

        synchronizedCounterService.printCount();
    }
[concurrencyTestWithSynchronized]

count = 1000
count = 0

- Count 가 0 이 되는 걸 볼 수 있다.

- 메서드에 Synchronized 를 붙이면 이렇게 동시성 제어가 되는 걸 확인 할 수 있다 !

- 하지만, 여기서 생각해야 할 것은 Synchronized 는 메서드 진입을 막는 것이지,  DB 트랜잭션이 없어 데이터 정합성 문제나 롤백 불가 하다.

(3) Synchronzied + Transactional

    @Transactional
    public synchronized void decreaseCountWithSynchronizedAndTransactional() {
        SynchronizedCounter synchronizedCounter = synchronizedCounterRepository.findById(1L).orElseThrow();
        synchronizedCounter.decreaseCount();
        synchronizedCounterRepository.save(synchronizedCounter);
    }

- @Transactional 만 추가 된 것이다 

    @Test
    @DisplayName("Synchronized & Transactional 를 이용한 동시성 제어")
    void concurrencyTestWithSynchronizedAndTransactional() {
        System.out.println("\n\n\n\n[concurrencyTestWithSynchronizedAndTransactional]\n");

        synchronizedCounterService.printCount();    // 초기 값 출력

        IntStream.range(0, 1000).parallel().forEach(i -> {
            synchronizedCounterService.decreaseCountWithSynchronizedAndTransactional();
        });

        synchronizedCounterService.printCount();
    }
[concurrencyTestWithSynchronizedAndTransactional]

count = 1000
count = 494

- 갑자기 Transactioanl 을 넣으니 RacCondition 이 발생했다 !

🤔 이유가 뭘까 ?

- @Transactional이 붙으면서 실제로는 스프링 프록시 객체가 대신 실행하게 된다. 이 프록시는 synchronized가 적용된 원래 메서드를 우회하게 되어 동기화가 깨지는 것 이다.

- commit 이 먼저 될 지, 다음 스레드가 실행 될지 순서가 불분명해서 그런 것이다.

synchronized 자바 레벨에서 메서드에 진입하는 스레드를 1개로 제한하여 동시성 문제를 막음
@Transactional 스프링이 프록시 객체를 생성해서 트랜잭션 로직을 자동으로 관리 (AOP 방식)

- 조금 더 자세히 설명하자면, 

1. synchronized를 붙인 메서드는 해당 인스턴스 객체(this)에 lock을 건다.

2. 그런데 @Transactional을 붙이면 스프링은 프록시 객체(다른 객체)를 만들어 트랜잭션을 감싼다.

3. 그래서 우리가 생각한 synchronized는 프록시 객체에는 적용되지 않고, 내부 원래 객체(this)에서만 적용된다.

4. 결과적으로 synchronized가 걸린 메서드는 직접 호출되지 않기 때문에 동기화 효과가 사라짐 → 다시 Race Condition 발생하게 된다.

우리가 기대한 실행 흐름:

  •   스레드 1 -----> [synchronized + @Transactional 메서드] (lock O)
  •   스레드 2 -----> 대기 중 (lock O)

실제 실행 흐름:

  •   스레드 1 -----> [프록시 객체가 트랜잭션 시작] -----> [원래 객체 메서드 실행] (lock X)
  •   스레드 2 -----> [프록시 객체 또 진입] -----> [원래 객체 또 실행] (lock X)

그래서 @Transactional이 붙으면, 우리가 호출하는 것은 프록시 객체의 메서드이고, 그 안에서 원본 객체의 synchronized 메서드가 호출되므로 → 락이 걸리지 않게 된다는 것.

3. 결론

메서드가 종료되면

1. 트랜잭션 커밋 (DB 에 변경사항 반영)

2. 대기하던 다음 쓰레드가 들어와서 로직 처리

- 이게 트랜잭션의 경우 프록시이기 때문에 동시다발적으로 발생한다. 어떤게 먼저할지는 순서가 정해져있지 않다.

=> 그래서 Synchronzied 랑 Transactional 이랑 같이 사용하면 안된다.

@Transactional만 트랜잭션 단위로 묶이긴 하지만, 여러 스레드가 동시에 진입할 수 있어 Race Condition 발생 가능
synchronized만 메서드 진입은 막지만, DB 트랜잭션이 없어 데이터 정합성 문제나 롤백 불가
둘 다 사용 진입 자체를 막고, DB 정합성도 보장 ⇒ 실무에서 필요한 "완전한 동시성 보호" 시나리오

=> Synchronized 하다보면, 어플리케이션 속도가 너무 느리다. 그래서 현업에서 쓰지 않는다.

3️⃣ DB 에서 Lock 사용

1. 비관적 락

"다른 트랜잭션이 동시에 접근할 수도 있다"는 racecondition 을 가정하고, 데이터에 먼저 락을 걸고 작업을 진행하는 방식입니다.

-> Synchronized 는 메서드에 Lock 걸은 느낌이라면, 비관적락의 경우에는 DB 에 Lock 을 걸은 거라고 생각하면 된다.

- 비관적 락은 DB 수준에서 레코드(row) 에 락을 걸어 다른 트랜잭션의 접근을 차단

- 동시성 충돌이 자주 발생하는 상황에 적합하다.

✅ 종류

1. PESSIMISTIC_READ | 공유 락(shared lock)

  • 읽기는 가능하지만 다른 트랜잭션이 해당 데이터를 수정하거나 삭제하는 것을 막는다.
  • 즉, "나는 읽기만 할게. 다른 사람은 수정하지 마! "
  • 사용 목적: 읽는 중에 데이터가 바뀌는 걸 원하지 않을 때

2. PESSIMISTIC_WRITE | 배타적 락(exclusive lock)

  • 다른 트랜잭션이 해당 데이터를 읽기, 수정, 삭제 모두 못 하게 막는다.
  • 즉, "내가 쓰고 있을 때는 아무도 손대지 마!"
  • 사용 목적: 데이터를 안전하게 수정할 때

3. PESSIMISTIC_FORCE_INCREMENT | PESSIMISTIC_WRITE와 유사

  • 추가로 @Version 필드를 명시적으로 1 증가시킨다.
  • 버전이 올라가므로 낙관적 락과도 연동되며, 나중에 병합 시 충돌 감지를 쉽게 합니다.
  • 즉, "나만 쓰고, 끝나고 나면 변경되었다고 표시도 해!"
  • 사용 목적: 변경이 일어난 것을 명확히 기록하고 싶을 때, 낙관적 락 기반 시스템과 병행할 때 유용
락 모드 다른 트랜잭션의 읽기 다른 트랜잭션의 수정/삭제 버전 증가
PESSIMISTIC_READ ⭕ 가능 ❌ 불가 ❌ 아님
PESSIMISTIC_WRITE ❌ 불가 ❌ 불가 ❌ 아님
PESSIMISTIC_FORCE_INCREMENT ❌ 불가 ❌ 불가 ⭕ 증가

 ✅ 장단점

⭕ 장점

  • 테이블에 락을 걸어 다른 트랜잭션에서의 접근을 하지 못하게 하기 때문에 데이터의 일관성이 보장된다.
  • 트랜잭션에서 데이터를 사용하기 전 락을 걸기 때문에, 데이터를 변경 중에 다른 트랜잭션과 충돌 가능성이 낮다.

❌ 단점

  • 데이터 일관성을 보장하지만 동시 접속자가 많은 환경에서는 락 대기 시간으로 인해 성능에 영향을 줄 수 있다.
  • 다수의 트랜잭션이 서로 다른 순서로 여러 데이터에 락을 요청하면 데드락이 발생할 수 있다.

✅ 주의할 점

비관적 락은 특정 레코드를 대상으로 충돌이 자주 발생할 것으로 추측하는 경우 사용하기 적합하다.

그러나 레코드 자체에 락을 걸기 때문에 동시성이 크게 저하될 수 있고 반드시 타임아웃을 지정하여 무한 대기에 따른 데드락이 발생하지 않도록 주의가 필요합니다.

🤔 데드락

트랜잭션 1 - A Entity 수정하고, B Entity 수정해야지 

트랜잭션 2 - B Entity 수정하고, A Entity 수정해야지

A 가 Entity 를 수정하고 그 후 B 를 수정하고 싶을때 트랜잭션 2의 락이 풀릴 때 까지 기다려야 함.

B 가 Entity 를 수정하고 그 후 A 를 수정하고 싶을 때 트랜잭션 1이 락이 풀릴 때 까지 기다려야 함.

이런 동시에 요청을 처리하는 멀티스레드 환경이기 때문에 무한대기가 생기는 것임.

서로 무한정 대기하다가 timeout 걸려서 메서드 죽음. -> 그래서 트랜잭션에 시간을 정해두어야 함.

원인 두 개 이상의 트랜잭션이 서로 상대가 점유한 자원을 기다림
문제 둘 다 상대방이 락을 해제할 때까지 기다리므로 무한 대기 발생
해결책 트랜잭션에 timeout 설정을 통해 일정 시간 대기 후 예외를 발생시켜 종료시킴

📝 실습코드

- 서비스 코드

    @Transactional
    public void decreaseCountWithLock() {
        PessimisticCounter pessimisticCounter = pessimisticCounterRepository.findByIdWithPessimisticLock(1L).orElseThrow();
        pessimisticCounter.decreaseCount();
        pessimisticCounterRepository.save(pessimisticCounter);
    }

- Repository : 여기서 어노테이션만 붙이면 되는 것이다 : >

    @Lock(LockModeType.PESSIMISTIC_READ)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})  // timeout 설정 (3초)
    @Query("select p from PessimisticCounter p where p.id in :id")
    Optional<PessimisticCounter> findByIdWithPessimisticLock(Long id);

- Testcode

    @Test
    @DisplayName("비관적 락을 이용한 동시성 제어")
    void concurrencyTestWithPessimisticLock() {
        System.out.println("\n\n\n\n[concurrencyTestWithPessimisticLock]");

        pessimisticCounterService.printCount();

        IntStream.range(0, 1000).parallel().forEach(i -> {
            pessimisticCounterService.decreaseCountWithLock();
        });

        pessimisticCounterService.printCount();
    }
[concurrencyTestWithPessimisticLock]
count = 1000
count = 0

- RaceCondition 이 일어나지 않고 동시성 제어가 잘 된걸 확인할 수 있다.

- 하지만, 우리는 Repository 에서 데드락의 유효시간을 정했었다. 해당 시간초를 벗어나게 된다는 것은 Race Condition을 미리 막기 위한 비관적 락(Pessimistic Lock)이 실패한 상황이라는 것이다.

- 그러면, 우리는 이러한 경우를 낙관적 락에서 해결하듯이 해당 Exception 를 처리해야 한다. 

처리하는 방법으로는 

  • 사용자에게 "현재 다른 요청 처리 중입니다. 잠시 후 다시 시도해주세요." 같은 메시지를 반환하거나
  • 자동으로 재시도 로직을 구현하거나
  • 로그 남기고 무시할 수도 있다 (비추천).
비관적 락 LockTimeoutException 락을 잡지 못했음 → 작업을 막음 사용자 안내 or 재시도
낙관적 락 OptimisticLockException 데이터가 변경되었음 → 충돌 발생 다시 읽고 재시도 (merge)

2. 낙관적 락

- "동시 충돌은 잘 안 일어날 거야" 라고 낙관하고 데이터를 수정할 때 실제로 충돌이 발생했는지만 확인하는 방식이다.

- 데이터를 읽을 땐 락을 걸지 않고,

-> 수정(업데이트) 시점에, 버전(version)을 비교해서 충돌을 감지

-> 충돌하면 OptimisticLockingFailureException 등을 던지는 것이다.

😎 작동 방식

1. 엔티티에 @Version 필드를 둠 → 예: int version

2. 조회 시 해당 버전도 같이 읽음

3. 저장할 때 WHERE id = ? AND version = ? 조건으로 업데이트 시도

4. 누군가 중간에 수정해서 버전이 달라졌다면? → 업데이트 실패 -> exception !

낙관적 락은 "충돌은 거의 없을 것이다"라는 가정 하에, 수정 시점에만 충돌을 감지하는 방식이다. 성능상 유리하며, 대부분의 시스템에서 기본값으로 많이 사용되는 락 전략이다.

⭕ 장점

  • 동시 요청(많은 재시도 횟수가 아님)에 대해 DB에 락을 걸지 않기 때문에, 비관적 락보다 성능 향상에 이점이 있다.
  • 낙관적 락은 충돌이 자주 발생하지 않는다고 가정하기 때문에, 많은 사용자가 동시에 데이터에 접근할 수 있습니다. 즉, 처리량을 향상시킬수 있다.

❌ 단점

  • 동시에 요청하여 데이터 충돌이 발생했을 때 이를 해결하기 위한 추가적인 로직(재시도 로직)이 필요하여 구현 복잡성이 있다.
  • 데이터의 변경 빈도가 높은 시스템에서는 충돌이 자주 발생하기 때문에, 이를 해결하기 위한 추가적인 시간이 필요하다.

📝 실습 코드

- 서비스 코드

    @Transactional
    public void decreaseCountWithLock() {
        OptimisticCounter optimisticCounter = optimisticCounterRepository.findByIdWithOptimisticLock(1L).orElseThrow();
        optimisticCounter.decreaseCount();
        optimisticCounterRepository.save(optimisticCounter);
    }

- Repsotiory : Lock 어노테이션으로 쉽게 구현 가능하다.

    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query("select p from OptimisticCounter p where p.id in :id")
    Optional<OptimisticCounter> findByIdWithOptimisticLock(Long id);

- Test Code

    @Test
    @DisplayName("낙관적 락을 이용한 동시성 제어")
    void concurrencyTestWithOptimisticLock() {
        System.out.println("\n\n\n\n[concurrencyTestWithOptimisticLock]");
        AtomicInteger optimisticLockFailures = new AtomicInteger(0);
        optimisticCounterService.printCount(); // 초기값 출력

        IntStream.range(0, 1000).parallel().forEach(i -> {
            try {
                optimisticCounterService.decreaseCountWithLock();
            } catch (ObjectOptimisticLockingFailureException e) {
                System.out.println("낙관적 락으로 인한 데이터 변경 실패 !");
                optimisticLockFailures.incrementAndGet();
            }
        });

        optimisticCounterService.printCount();
        System.out.println("decreaseCount() 실패 횟수: " + optimisticLockFailures.get());
    }
count = 683, version = 634
decreaseCount() 실패 횟수: 683

- 낙관적 락만 사용하게 되면 동시성 제어가 안 되는 모습을 볼 수 있다.

- 이럴 때는, 재시도를 하게끔 설정하면 된다.

- 버전이 달라 ? 그러면 Exception 발생 => exception 이 발생했어 ? 그러면 ObjectOptimisticLockingFailureException Catch 해서 재시도해 ! 이런 맥락이다.

@Test
    @DisplayName("낙관적 락을 이용한 동시성 제어 및 재시도 로직")
    void concurrencyTestWithOptimisticLockAndRetry() {
        System.out.println("\n\n\n\n[concurrencyTestWithOptimisticLockAndRetry]");
        final int maxRetries = 100; // 최대 재시도 횟수
        AtomicInteger totalAttempts = new AtomicInteger(0);
        AtomicInteger successfulDecrements = new AtomicInteger(0);
        optimisticCounterService.printCount(); // 초기값 출력

        IntStream.range(0, 1000).parallel().forEach(i -> {
            boolean updated = false;
            int attempts = 0;
            while (!updated && attempts < maxRetries) {
                try {
                    optimisticCounterService.decreaseCountWithLock();
                    successfulDecrements.incrementAndGet();
                    updated = true;
                } catch (ObjectOptimisticLockingFailureException e) {
                    attempts++;
                }
                totalAttempts.incrementAndGet();
            }
            if (!updated) {
                System.out.println("최대 재시도를 " + attempts + "회 하였으나 실패. (for iteration " + i + "번째에서 발생.)");
                // throw Exception
            }
        });

        optimisticCounterService.printCount();
        System.out.println("성공적으로 감소된 횟수: " + successfulDecrements.get());
        System.out.println("총 시도 횟수: " + totalAttempts.get());
    }
[concurrencyTestWithOptimisticLockAndRetry]
count = 1000, version = 0
count = 0, version = 2000
성공적으로 감소된 횟수: 1000
총 시도 횟수: 3086

- 이렇게 되면, 실패 횟수는 없지만, 총 시도횟수가 3086 으로 엄청 많은 걸 볼 수 있다.

- 이게 데이터의 수가 1000개 여서 다행이지, 대용량 트래픽일 경우 머리 아파지게 되는 것이다 🫠 이래서 낙관적 락과 비관적 락을 용도에 맞게 적절히 사용해주어야 한다. 대용량 데이터를 자주 수정하는 경우에는 낙관적 락을 피해야겠다 !

3. 결론

낙관적 락 적합 비관적 락 적합
조회가 많고, 수정 충돌이 드문 경우 동시에 같은 데이터를 자주 수정하는 경우
성능 중요 (락 없으므로 빠름) 데이터 정확성이 더 중요
웹/모바일 앱 등 은행 시스템, 재고/주문 관리 등

낙관적 락은 실패 시 예외처리 + 재시도로 인한 성능 저하의 우려가 있고,

비관적 락은 row 자체에 락을 걸기 때문에 DeadLock 발생 우려와 단순 읽기 작업일 경우 성능 저하가 있다.

4️⃣ REDIS의 싱글 스레드 특성 이용

1. Redis 싱글 스레드 설명

- Lock을 사용하지 않고, redis 의 싱글 스레드 특성을 이용한 동시성 제어를 하는 것이다.

- Redis 에 대해서는 아래의 자료를 확인해보자 !

Your connected workspace for wiki, docs & projects | Notion

- Redis 의 경우에는 싱글 스레드 환경을 가지고 있다.( 네트워크 I/O 등은 멀티 스레드로 보조 가능하며, CPU 병목을 줄이는 데 도움을 줌) 우리가 쓰는 로컬 DB 와 다르게, Redis 는 별도의 DB 라고 보면 된다.

- 싱글 스레드 환경이기 때문에, 하나의 인스턴스를 동시에 접근하지 않기 때문에 동시성 문제가 일어나지 않는 것이다. "공용 자원을 하나의 프로세스가 접근하기" 때문이다.

- 하지만, 여기서 전제는 필요하다.

  • 모든 데이터 상태는 Redis 내부에만 있음.
  • 연산은 Redis 명령어 수준에서 완결됨. (예: INCR, SETNX, HINCRBY, EXISTS 등) -> 원자성을 보장하는 작업 단위를 실행할 것.
  • 클라이언트가 데이터를 읽고 처리한 후 다시 Redis에 저장하는 방식은 사용하지 않음. -> 그러니까, 읽는 단위 하나, 처리하는 단위 하나, 저장하는 단위 하나 이런식으로 하나로 묶인 원자 작업 단위가 아니라면 하지 말라는 것.

-> Redis는 기본적으로 명령어를 싱글 스레드로 처리하기 때문에, INCR, SETNX 등 원자 명령어를 사용할 경우 동시성 문제가 발생하지 않는다. 따라서 데이터 상태가 Redis에만 존재하고, 모든 연산을 Redis 내부 명령어로 처리하는 경우에는 락 없이도 안전한 동시성 제어가 가능하다. 단, 여러 명령을 클라이언트 측에서 조합하거나, 외부 로직과 결합할 경우에는 Race Condition이 발생할 수 있으므로 주의해야 한다.

2. 실습

- 서비스 코드

public void decrementCounter() {
      ValueOperations<String, Long> ops = redisTemplate.opsForValue();
      ops.decrement("counter");
}

- Reids 확인

public void getCounter() {
    ValueOperations<String, Long> ops = redisTemplate.opsForValue();
    Long value = ops.get("counter");
    System.out.println(value);
}

- 테스트 코드

@Test 
@DisplayName("Redis의 싱글 스레드 특성을 이용한 동시성 제어")
void concurrencyTestWithRedis() {
    counterService.initializeCounter();
    System.out.println("\n\n\n\n[concurrencyTestNoLockWithRedis]");
    IntStream.range(0, 1000).parallel().forEach(i -> counterService.decrementCounter());
    counterService.getCounter();
}
[concurrencyTestNoLockWithRedis]
0

- 이렇게 되면 동시성 제어가 되는 것이다.

3. 주의점

-  REDIS 로 데이터 처리를 하게되면 알아야 될 것이 있다.

✅ Redis로 동시성 제어는 OK

- Redis의 SETNX, INCR, Redisson Lock 등을 이용해 락이나 카운터처럼 "잠깐 쓰고 마는" 데이터의 동시성을 제어하는 건 일반적이고 좋은 방식이다.

❌ 하지만 Redis 안에 DB 값을 '관리 목적으로' 저장하면 발생할 수 있는 문제들

(1) 데이터 영속성 부족

Redis는 기본적으로 메모리 기반이라 장애나 재시작 시에 데이터 유실 가능성이 있다.

AOF나 RDB 설정을 통해 디스크에 저장 가능하지만, 완전한 영속성 보장은 어렵다.

즉, DB와 같은 영구 저장소로 쓰기엔 불안정하다는 것.

(2) 데이터 정합성 이슈

DB와 Redis 간에 이중 데이터 관리가 발생하면 동기화 문제가 생길 수 있다.

예: DB에 업데이트는 됐는데 Redis 반영이 안 됨. 또는 Redis에만 반영되고 DB에는 안 되는 경우.

결국 데이터 일관성 보장을 위한 별도 전략이 필요 (예: Write-through, Write-behind, Cache Aside 등)

(3) 메모리 사용량 증가 및 비용 문제

Redis는 메모리에 데이터를 저장하므로, 데이터량이 많아지면 메모리 사용량 급증 → 비용 증가하게 됨.

큰 데이터를 Redis에 저장하는 건 매우 비효율적이다.

(4) 복잡한 트랜잭션 처리 어려움

Redis는 관계형 DB처럼 복잡한 트랜잭션, 조인, 제약조건 등을 지원하지 않아요.

비즈니스 로직이 복잡할수록 DB를 완전히 대체하기엔 제약이 많음.

(5) 장기 데이터 보관에 부적합

Redis는 일반적으로 캐시 또는 일시적인 세션 관리에 적합하지,

수년간 보존해야 할 로그, 이력, 계약정보 등의 영구 데이터 보관에는 적합하지 않음.

4. 추가적인 의견

- REDIS 에 중요한 데이터를 저장하기 보단, 캐시나 단순 카운트 데이터만 저장해야겠다...

5️⃣ 분산락 - Redis

1. 설명

- 여러 서버가 공유데이터를 제어하기 위한 기술

(1) Lettuce : 계속해서 락 점유 하고 싶어요 ~ 말하는 것.

  • Java Redis 클라이언트 라이브러리 중 하나
  • 비동기(Async), 논블로킹(Non-blocking) 방식 지원
  • 스핀락(Spinlock) 방식 분산락 구현에 자주 사용됨
  • 상대적으로 가볍고, 넷티(Netty) 기반으로 고성능 네트워크 처리 가능
  • 단순 Redis 명령어 실행 중심이며, 분산락 기능을 직접 구현하거나 사용자가 제어해야 함

(2) Redisson : 락 점유 해제 했는데, 누구 할 사람 ~

  • Redis 기반 고수준 분산 데이터 구조 및 서비스를 제공하는 Java 라이브러리
  • 분산락, 분산 세마포어, 분산 큐, 분산 맵 등 다양한 추상화 기능 내장
  • 분산락 구현이 쉽고 견고함 (재시도, 자동 만료, 락 확장 등 기능 포함)
  • Lettuce보다 기능이 풍부하고, 개발자가 분산락을 직접 구현하지 않아도 됨
  • 내부적으로 Lettuce 또는 Jedis를 사용하여 Redis와 통신
  Lettuce Redisson
유형 Redis 클라이언트 라이브러리 Redis 기반 분산 데이터 구조 & 서비스
분산락 스핀락 방식 직접 구현 필요 견고한 분산락 기능 내장
특징 비동기, 논블로킹 고성능 네트워크 풍부한 고수준 API 제공
사용 난이도 상대적으로 낮음 (락 직접 구현) 쉽고 편리함 (락 자동 관리)

2. 실습

- 여기서는 Redisson 을 사용할 것이다 : >

(1) 동시성 제어를 못했을 경우 Case 1 : finally에서 Lock 해제

- 서비스 코드

    @Transactional
    public void decreaseCountUsingLock() {
        // 1. Redis의 공정 락(Fair Lock) 객체를 가져옴
        RLock lock = redissonClient.getFairLock(LOCK_KEY);
        try {
            // 2. 최대 10초 동안 락을 얻기 시도, 락 획득 시 최대 60초 유지
            boolean isLocked = lock.tryLock(10, 60, TimeUnit.SECONDS);
            if (isLocked) {
                // 3. DB에서 카운터 엔티티 조회
                Counter counter = counterRepository.findById(1L).orElseThrow();
                counter.decreaseCount();
                counterRepository.save(counter);
            }
        } catch (InterruptedException e) {
            // 6. 락 획득 대기 중 인터럽트 발생 시 스레드 상태 유지
            Thread.currentThread().interrupt();
        } finally {
            // 7. 락 해제 (락을 획득했을 경우에만 해제됨)
            lock.unlock();
        }
    }

- 테스트 코드

    @Test
    void concurrencyTestUsingLock() {
        System.out.println("\n\n\n\n[concurrencyTestUsingLock]");
        IntStream.range(0, 1000).parallel().forEach(i -> counterService.decreaseCountUsingLock());
        counterService.printCount();
    }
[concurrencyTestUsingLock]



====================

4

====================

 

- 4번의 실패 경우가 생긴다. 동시성 제어가 안 된 것.

🤔 왜 그러한가 ?

1. 트랜잭션 시작

2. 락 점유

3. 트랜잭션 종료 (커밋)

4. 락 점유 해제

이런 순서가 보장 된다면, RaceCondition 문제가 발생하지 않는다.

그런데 위의 코드에서는 3번과 4번 순서가 보장되지 않기때문에, Transaction 이 먼저 실행될지 ? 아니면 락 점유가 먼저 해제될지 ? 모른다.

만약 락 점유가 먼저 해제 된 후에 Transaction 이 실행 되면 ? 동시성 문제가 발생할 수 있다는 것이다. 멀티쓰레드의 특징이라고 생각하면 된다. 멀티 쓰레드의 경우에는, 쓰레드의 순서가 정해져 있지 않기 때문이다 !

🫡 결과적으로는

락 해제와 트랜잭션 커밋 간에 정확한 동기화가 없어서 멀티 쓰레드 환경에서는 순서가 예측 불가능하다는 것

 

  • 락이 먼저 해제되면 다른 쓰레드가 동시에 접근해 버릴 수 있고
  • 트랜잭션 커밋이 아직 안 된 상태에서 다른 쓰레드가 데이터를 읽거나 수정하는 일이 발생할 수 있다.
  • 그래서 Race Condition이 발생한 것

(2) 동시성 제어를 못했을 경우 Case 1 : lock.isHeldByCurrentThread() 체크 후 해제

@Transactional
public void decreaseCountUsingLock() {
    // 1. Redis의 공정 락(Fair Lock) 객체를 가져옴
    RLock lock = redissonClient.getFairLock(LOCK_KEY);
    try {
        // 2. 최대 10초 동안 락을 얻기 시도, 락 획득 시 최대 60초 유지
        boolean isLocked = lock.tryLock(10, 60, TimeUnit.SECONDS);
        if (isLocked) {
            // 3. DB에서 카운터 엔티티 조회
            Counter counter = counterRepository.findById(1L).orElseThrow();
            counter.decreaseCount();
            counterRepository.save(counter);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        // 4. 현재 실행 중인 스레드가 이 락을 획득(소유)하고 있으면, 해제
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

 

- 위 코드와의 차이점은 락 해제 시점의 차이이다.

  • 락 해제 전에 lock.isHeldByCurrentThread() 조건문으로 락 보유 여부를 체크해, 현재 스레드가 락을 가지고 있을 때만 해제.

- 현재 스레드가 락을 소유하고 있으면 해제해 주는 식으로 접근한 것 !

 

 

- 테스트 코드는 같으므로, 생략하고 결과만 보자면

[concurrencyTestUsingLock]



====================

5

====================

- 똑같이 RaceCondition 이 발생한 걸 볼 수있다.

🤔 왜 그러한가 ?

- 위에서 언급했다시피 아래의 이유때문이다.

  • 그러나 여전히 락 해제는 트랜잭션 커밋과 동기화되어 있지 않기 때문에 락이 먼저 해제될 가능성은 존재.

(3) AOP 를 이용한 Lock 과 트랜잭션 커밋을 동기화

- AOP 

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAspect {

    private final RedissonClient redissonClient;
    private final PlatformTransactionManager transactionManager;

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
        String key = distributedLock.key();
        long waitTime = distributedLock.waitTime();
        long leaseTime = distributedLock.leaseTime();

        RLock lock = redissonClient.getLock(key);

        boolean isLocked = false;
        try {
            isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new IllegalStateException("락 획득 실패: key = " + key);
            }

            log.info("락 획득 성공: key = {}", key);
            TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
            transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);

            return transactionTemplate.execute(status -> {
                try {
                    return joinPoint.proceed(); // 비즈니스 로직 실행
                } catch (Throwable throwable) {
                    // 트랜잭션 롤백을 명시적으로 설정
                    status.setRollbackOnly();
                    throw new RuntimeException(throwable);
                }
            });
        } finally {
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
                log.info("락 해제: key = {}", key);
            }
        }
    }
}

- 실제로 트랜잭션을 구현하는 것이다. 이렇게 되면 방금 말했던 순서가

1. 트랜잭션 시작

2. 락 점유

3. 트랜잭션 종료 (커밋)

4. 락 점유 해제

 - 이렇게 정해지는 것이다.

- 어노테이션 인터페이스 코드

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();              // 락 키
    long waitTime() default 5; // 락 대기 시간 (초)
    long leaseTime() default 10; // 락 점유 시간 (초)
}

- 서비스 코드

    @DistributedLock(key = "'counter:' + #counterId")
    public void decreaseCountWithAop(Long counterId) {
        // 락이 적용된 로직
        Counter counter = counterRepository.findById(counterId).orElseThrow();
        counter.decreaseCount();
        counterRepository.save(counter);
    }

- 테스트 코드

    @Test
    @DisplayName("AOP 를 활용한 분산락")
    void concurrencyTestWithAop() {
        System.out.println("\n\n\n\n[concurrencyTestWithAOP]");
        IntStream.range(0, 1000).parallel().forEach(i -> counterService.decreaseCountWithAop(1L));
        counterService.printCount();
    }
====================

0

====================

- 이럴 때 race Condition 이 발생하지 않은 걸 볼 수 있다 : >

2. 트랜잭션 직접 관리 코드에서 Race Condition 문제는 완전히 없을까?

락과 트랜잭션을 한 블록 안에서 묶어서 실행하면 락이 트랜잭션 커밋 또는 롤백 이후에 해제되므로 다른 스레드가 동시에 해당 자원에 접근해서 변경하는 상황을 방지할 수 있어 Race Condition 위험이 크게 줄어든다.

하지만, 이게 무조건 100% 완벽하게 Race Condition을 없애는 건 아니라고 한다.

🤔 왜 아니지 ? 

1. 락 획득 실패 시 재시도 로직이 없으면 락을 못 얻은 쓰레드는 작업을 못 하고 실패하거나 예외가 발생한다.

-> 락획득 실패란 것은, 동시에 여러 스레드가 같은 자원을 락 시도 했을 때, 한개 빼고 나머지는 대기 할 텐데 대기시간 내에 락을 받지 못하면 락 획득 실패가 일어나는 것 !

-> 이 경우 재시도 없이 실패 처리하면 원하는 결과가 아닐 수 있다.

= 재시도 전략, 실패할 수록 대기 시간을 늘리는 백오프 전략, 사용자에게 실패를 안내

2. 락 범위 밖에서 데이터에 접근하는 다른 코드가 있다면 해당 자원에 대한 동시 접근이 생겨 Race Condition이 일어날 수 있다.

3. 분산 환경에서 여러 서버 간 락 관리가 완벽하지 않으면 락 해제 지연, 네트워크 지연 등으로 예상치 못한 충돌 가능성 존재한다.

4. 트랜잭션 격리 수준 문제

-> DB 트랜잭션 격리 수준에 따라 다른 트랜잭션의 중간 상태를 읽는 '비정상적 읽기' 문제가 발생할 수도 있습니다.

🫡 정리

락과 트랜잭션을 명확히 묶으면 대부분의 Race Condition을 방지 가능하다.

그러나 완전한 동시성 제어를 위해서는 락 획득 실패시 재시도, 전체 자원 접근 경로 관리, 분산락 안정성, DB 격리 수준도 고려해야 한다.

 

 ✅ 오늘의 회고

- 이번 과제가 동시성 제어 부분이라 확실히공부하고 들어가고 싶어서 정리했더니, 너무 양이 방대해졌다...

- 정리도 꽤나 오래 걸리기도 했고,, 얼른 과제 시작해야겠다 🫠

'백엔드 부트캠프 > TIL' 카테고리의 다른 글

[내일배움캠프Spring-67일차] 동시성 제어 프로젝트  (1) 2025.05.23
[내일배움캠프Spring-66일차] Lettuc 가 뭐야 ?  (0) 2025.05.22
[내일배움캠프Spring-64일차] JPQL 생성자 표현식에서 record 사용 시 직렬화 에러 발생  (1) 2025.05.20
[내일배움캠프Spring-63일차] SA 피드백  (3) 2025.05.19
[내일배움캠프Spring-62일차] TCP 와 UDP - 네트워크 (1)  (0) 2025.05.16
'백엔드 부트캠프/TIL' 카테고리의 다른 글
  • [내일배움캠프Spring-67일차] 동시성 제어 프로젝트
  • [내일배움캠프Spring-66일차] Lettuc 가 뭐야 ?
  • [내일배움캠프Spring-64일차] JPQL 생성자 표현식에서 record 사용 시 직렬화 에러 발생
  • [내일배움캠프Spring-63일차] SA 피드백
sintory-04
sintory-04
🚀🚀🚀
  • sintory-04
    Sintory Dev Blog
    sintory-04
    글쓰기 관리
  • 전체
    오늘
    어제
    • 분류 전체보기 (289)
      • 백엔드 부트캠프 (111)
        • TIL (97)
        • WIL (0)
        • 문제풀이 (7)
        • 기타 (6)
      • 백엔드 부트캠프[사전캠프] (35)
        • TIL (16)
        • 문제풀이 (17)
        • 기타 (1)
      • Troubleshooting (11)
      • 코딩 공부 (118)
        • Java (28)
        • Baekjoon-Java (24)
        • Programmers-Java (40)
        • Spirngboot (11)
        • typescript (1)
        • JavaScript (6)
        • Spring 입문 (8)
      • 프로젝트 (8)
        • ToDoApp(FireBase) (3)
        • ToDoApp(Spring) (5)
      • 기타 (4)
  • 블로그 메뉴

    • 소개
    • Github
  • 최근 글

  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.3
sintory-04
[내일배움캠프Spring-65일차] 동시성제어
상단으로

티스토리툴바