티스토리 뷰

재고 처리 기능을 개발하는 과정에서 동시성 이슈가 발생하여 원인과 해결 방법에 대해 정리해 봤습니다.

내용은 "요구사항 구현" - "동시성 이슈 재현" - "원인 분석" - "해결 방법" 순서로 구성했습니다.

 

요구사항 구현

 

그림1. 요구사항

재고 처리 기능의 요구 사항은 다음과 같습니다.

  1. 사용자가 객실을 예약한다.
  2. 서버는 데이터베이스에서 관리중인 객실을 조회하고, -1 감소시킨 후 다시 저장한다.

 

Stock 클래스는 재고를 저장하고 처리합니다.

public class Stock {  
   private int quantity;  // 객실의 재고량

   public Stock(int quantity) {  
      this.quantity = quantity;  
   }  


   // 객실 재고를 1 감소시키는 기능
   public void minusQuantity() {  
      this.quantity--;  
   }  

   public int getQuantity() {  
      return this.quantity;  
   }  
}

 

웹 서버의 요청은 여러 개의 스레드가 처리합니다. 결론부터 말씀드리면, 동시성 이슈는 여러 개의 스레드가 동시에 동작하기 때문에 발생합니다. 하지만, 동시성 이슈를 재현하기 위해 웹 서버를 활용하지 않으려고 합니다. 프로젝트를 최대한 단순히 구성하기 위해서 입니다.
대신에 웹 서버의 스레드 역할을 해줄 클래스를 직접 작성해보겠습니다.

 

WebRequestHandleThread 클래스는 서버처럼 요청을 받고 처리하는 역할을 하는데 run() 메소드에 그 로직을 담았습니다.
스레드가 순차적으로 100번의 요청을 처리하는 상황을 가정하고, for 반복문을 이용해 100번의 재고 감소 기능을 호출합니다.

class WebRequestHandleThread extends Thread {  

   private final Stock stock;  

   public WebRequestHandleThread(Stock stock) {  
      this.stock = stock;  
   }  

   @Override  
   public void run() {  
      for (int i = 0; i < 100; i++) {  
         stock.minusQuantity();  
      }  
   }  
}

 

동시성 이슈 재현 - 코드레벨의 동시성

RaceConditionTest 클래스는 1,000개의 스레드가 동시에 동작하는 상황을 테스트하는 코드입니다.

public class RaceConditionTest {  

   @Test  
   @DisplayName("동시에 재고를 판매하면 동시성 이슈가 발생한다")  
   void multiThreadTest() throws Exception {  
      Stock stock = new Stock(100000);  

      // 재고 판매를 다수의 스레드가 동시에 처리  
      for (int i = 0; i < 1000; i++) {  
         Thread thread = new WebRequestHandleThread(stock);  
         thread.start();  
      }  

      Thread.sleep(5000);  

      // 결과 출력  
      System.out.println(stock.getQuantity());  
   }  
}

 

초기 재고량을 100,000 개로 설정했습니다. 1,000개의 스레드가 100 번의 재고를 감소시키니 100,000개의 재고가 감소할 것을 기대했습니다. 실행 결과, 남은 재고 값으로 "266"이 출력됩니다. 기대했던 출력 값은 "0"인데, 어떻게 된 걸까요?

그림2. 테스트코드 실행 결과

 

 

원인 분석 - 바이트코드 분석

여러 쓰레드가 하나의 자원을 동시에 공유하기 때문입니다. 테스트코드를 살펴보면, 다수의 쓰레드가 하나의 Stock 클래스를 동시에 접근하고 있습니다. 이 때, 재고를 감소하는 minusQuantity 메서드를 동시에 호출하면, 동시성 이슈가 발생하게 됩니다.

바이트코드를 통해 자세히 분석해보겠습니다. Intellij 에서 Shift 키를 두 번 누른 후, "show bytecode"를 입력하면 내가 작성한 코드의 바이트코드를 확인할 수 있습니다. 바이트코드 해석은 오라클 JVM 스펙 문서는를 참고하였습니다.

 

https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html

 

Chapter 6. The Java Virtual Machine Instruction Set

The wide instruction modifies the behavior of another instruction. It takes one of two formats, depending on the instruction being modified. The first form of the wide instruction modifies one of the instructions iload, fload, aload, lload, dload, istore,

docs.oracle.com

 

1. ALOAD 0
각 메서드들은 계산에 필요한 매개변수를 Local variables 라고 부르는 배열에 넣고 관리합니다. 일반적인 인스턴스 메서드의 경우, 이 배열의 0번째는 this 를 가리킨다. ALOAD 0은 Local variables 의 0번째 값 (=this) 를 피연산 스택에 push 하는 연산입니다.

그림3. ALOAD 0

 

2. DUP
피연산 스택의 최상단 값을 복사해서 push 합니다.

 

그림4. DUP

 

3. GETFIELD org/example/domain/Stock.quantity : I
피연산 스택의 최상단 값을 pop 하고, 그 값이 레퍼런스 참조 유형일 때, 그것의 필드 값을(=quantity) 피연산 스택에 push 합니다.

 

그림5. GETFIELD

 

4. ICONST_1
상수 1을 피연산 스택에 push 합니다.

그림6. ICONST_1

 

5. ISUB
피연산 스택의 상단에 있는 값 2개를 pop 하고 빼기 연산을 수행하고, 결과 값을 피연산 스택에 push 합니다.

그림7. ISUB

 

6. PUTFIELD org/example/domain/Stock.quantity : I
피연산 스택의 상단에 있는 값 2개를 pop 한다. 한 개의 값이 레퍼런스 참조 유형이고, 다른 값이 value 일 때, 레퍼런스 참조 객체(=this)의 필드(=quantity)에 value(=999)를 삽입합니다.

 

그림8. PUTFIELD

 

원인 분석 - 컨텍스트 스위칭

WebRequestHandleThread 기반의 스레드는 바이트코드 1~6번 동작을 수행합니다. 하지만, 1~6번까지의 동작을 연속적으로 한 번에 수행하는 것은 아닙니다. 일부 동작만 수행한 채, 컨텍스트 스위칭이 일어날 수도 있습니다. 이것이 동시성 이슈를 일으키는 원인입니다. 예를 들어, 다음의 과정처럼 동시성 이슈가 일어나게 됩니다.

 

1. 가장 먼저 수행 중인 t1 스레드가 바이트코드의 1~5번 동작을 수행합니다

 

2. 컨스트 스위칭이 일어나고, 다음 t2 스레드가 1~5번 동작을 수행합니다.

 

3. 컨텍스트 스위칭이 일어나고 t1 스레드의 6번 동작을 수행합니다.

 

4. 컨텍스트 스위칭이 일어나고 t2 스레드의 6번 동작을 수행합니다.

 

 

 

2개의 스레드가 Stock.minusQuantity() 메서드를 수행했으니, quantity 필드에 "998"이 저장될 것을 기대했지만, "999"가 저장된 것을 그림에서 확인할 수 있습니다. 이처럼 공유 자원을 다수의 스레드가 공유할 때, 나타나는 동시성 문제를 레이스 컨디션(Race COndition)이라고 부릅니다. 그럼 이제 이 문제를 어떻게 해결하는지 알아보겠습니다.

 

동시성 이슈 해결 방법

Synchronized

스레드가 minusQuntity() 메서드를 수행하고 있는데, 다른 스레드에게 제어권이 넘어감으로써 레이스 컨디션이 발생했습니다. 이러한 일을 방지하기 위해서 한 스레드가 특정 작업을 끝내기 전까지 다른 쓰레드에 의해 방해받지 않도록 도와주는 장치가 있습니다.

바로 잠금(lock)입니다.

 

공유 데이터를 사용하는 코드 영역을 임계 영역이라고 부릅니다. 잠금(lock)을 획득한 단 하나의 쓰레드만 임계 영역에 진입할 수 있도록 만들면 됩니다. Java에서는 Synchronized 블럭을 이용해서 임계 영역을 설정할 수 있습니다.

minusQuantity() 메서드에 synchronized 키워드를 붙여보겠습니다.

public class Stock {  
   private int quantity;  

   public Stock(int quantity) {  
      this.quantity = quantity;  
   }  

   public synchronized void minusQuantity() {  
      this.quantity--;  
   }  

   public int getQuantity() {  
      return this.quantity;  
   }  
}

 

스레드가 minusQunatity() 메서드를 수행하고 있다면, 다른 스레드들은 해당 메서드에 접근할 수 없습니다. 따라서 우리가 기대했던 0이 출력되는 것을 확인할 수 있습니다.

Synchronized 키워드를 붙이고 난 뒤 실행 결과

 

synchronized 키워드의 원리가 무엇일까요? 바로 동기화 처리 기법입니다. 동기화 처리 기법에는 세마포어, 뮤텍스, 모니터 등의 방식이 존재합니다. 이 내용이 꽤 복잡하기 때문에 길이 글어질 수 있어 학습에 참고한 유튜브 자료를 남겨놓겠습니다.

 

Java를 활용하는 방법 - Atomic

앞서 살펴본 임계영역(synchronized)은 프로그램의 성능을 좌우하기 때문에 최소화하도록 노력해야 합니다.

JDK1.5 부터 소개된 'java.util.concurrent.atmoic'는 synchronized 블록을 이용하지 않고도 동기화를 할 수 있도록 지원합니다.

Stock 클래스의 quantity 멤버 변수의 타입을 AtomicInteger 타입으로 수정해보겠습니다.

public class Stock {  
   private AtomicInteger quantity;  

   public Stock(int quantity) {  
      this.quantity = new AtomicInteger(quantity);  
   }  

   public synchronized void minusQuantity() {  
      this.quantity.getAndAdd(-1);  
   }  

   public int getQuantity() {  
      return this.quantity.get();  
   }  
}

 

minusQuantity 메서드를 살펴보면, AtomicInteger 타입의 getAndAdd() 메서드를 사용했습니다. 앞에서 살펴본 바이트코드에서는 값의 조회와 쓰기 동작 틈으로 다른 스레드가 진입할 수 있었습니다. 하지만, getAndAdd() 메서드의 이름에서 알 수 있듯이, 조회와 쓰기가 하나의 동작이기 때문에 다른 스레드가 진입할 틈을 주지 않습니다. 조회와 쓰기 동작이 하나로 동작하는 것을 '원자성을 보장한다' 라고 표현합니다.

 

Atomic 변수는 CAS(Compare-and-swap) 알고리즘을 사용하여, 원자성을 보장합니다. 이 알고리즘은 값을 변경하는 시점에 스레드가 알고있는 Stock.quantity 값과 메모리 상의 Stock.quantity 값을 비교합니다. 일치하면 동작을 그대로 진행시키고, 다르다면 동작을 재수행합니다.

 

실행 결과, 기대했던 0이 출력됐습니다.

AtomicInteger 타입으로 변경한 뒤 실행 결과

 

synchronized 블럭은 임계영역의 잠금을 획득 하기전까지 모든 스레드가 블록됩니다. 반면에 Atomic 변수를 사용할 경우, 해당 변수에 접근하는 스레드들이 블록되지 않습니다. 따라서 성능상 Atomic 변수가 더 나은 선택이라고 할 수 있습니다.

 

여기부터는 개인적인 생각입니다. 만약 CPU 자원이 모자랄 경우, synchronized 블록이 더 나을 것 같다는 생각을 했습니다. 왜냐하면 Atomic 변수를 사용하는 경우, 스레드들은 동작에 성공할 때까지 반복적으로 메모리에 접근하고 CPU로부터 확인을 받기 때문입니다. 

 

 

반면에 synchronized 는 이진 세마포어를 활용하는데, 락을 획득하지 못한 스레드들은 큐에 담겨 대기하게 되고, 락이 풀려났을 때, 큐에 있는 스레드들을 깨우는 구조이기 때문에 CPU의 부담을 줄인다고 생각합니다.

 

 

결론

  • 코드레벨에서 동시성 이슈가 발생하면, Java에서 지원하는 synchronized, Atomic Type 을 활용하자.
  • 동시성 이슈를 일으킬 것 같은 자료구조가 있다면, 공식문서를 참조하여 Thread Safe 한지 확인해보자.

 

참고 자료

 

댓글