본문 바로가기
Old Posts/Java

[Java] 스레드 안전성(Thread Safety)

by A6K 2022. 3. 31.

이전 포스트에서 자바는 멀티 스레드를 지원하여 여러 작업들을 동시에 실행할 수 있다고 했다. (링크 : [Java] 자바 병렬 프로그래밍 - 멀티 스레드의 장단점) 요즘 출시되는 CPU들은 기본적으로 여러개의 코어를 탑재하고 있다. 따라서 멀티 스레드들은 동시에 다른 코어에서 병렬적으로 실행될 수 있다.

문제는 이 스레드들이 동시에 같은 데이터에 접근할 때 발생한다.

스레드 안전(Thread-Safe)

조금 따분한 주제일 수도 있지만 '스레드 안전(Thread-safe)'에 대해서 정리를 하고 넘어가야 한다.

자바 클래스는 어떻게 동작해야하는지에 대한 '클래스 명세(Class Specification)'를 가지고 있다. 잘 정의된 클래스 명세는 객체의 상태를 제약하는 '불변조건(Invariants)'와 메서드를 수행한 다음 발생하는 효과에 대한 '후조건(Postcondition)'을 정의한다.

클래스가 잘 동작한다는 것의 의미는 정의된 메서드나 클래스에 대한 연산을 수행한 이후에도 객체의 불변조건이 여전히 만족하며 연산의 후조건에도 잘 부합한다는 것을 의미한다.

예를 들어

public class PositiveInteger {
    private int value;
    
    public PositiveInteger() {
        this.value = 0;
    }
    
    public int increment() {
        this.value += 1;
        
        return this.value;
    }
    
    public int subtract() {
        int myValue = this.value - 1;
        
        if (myValue < 0)
          throw new MyException("Value cannot be negative");
        
        this.value = myValue;
        
        return this.value;
    }
}

양의 정수에 1씩 더하거나 1씩 빼는 작업을 하는 PositiveInteger가 있다고 생각해보자. 이 클래스의 객체는 0보다 큰 value 값을 갖는 불변조건이 있다. 그리고 increment() 메서드는 수행후 value 값이 1 증가하고, subtract() 메서드는 수행 후 value 값이 1 감소한다는 후조건이 있다.

클래스가 잘 동작한다는 것은 이런 불변조건과 연산들의 후조건이 정확하게 항상 만족함을 의미한다. 어렵게 설명했지만 설계시 의도한 대로 잘 동작한다는 것을 의미한다.

클래스가 '스레드 안전(Thread-safe)'하다는 것은 여러 스레드에서 클래스 혹은 클래스의 객체에 동시에 접근해서 사용하더라도 정확하게 동작함을 의미한다.

단일 스레드 역시 멀티 스레드 프로그램의 한 종류라고 볼 수 있기 때문에 단인 스레드 환경에서도 제대로 동작하지 않는 클래스는 스레드 안전하다고 할 수 없다.

스레드에 안전한 코드를 작성하는 것은 근본적으로 여러 스레드에서 공유되는 객체나 클래스의 상태에 대한 접근을 관리하는 것이다. 객체나 클래스의 상태란 인스턴스나 static 변수 같은 상태 변수에 저장된 객체의 데이터를 의미하며 공유되었다는 것은 여러 스레드가 그 변수에 접근하고 값을 변경할 수 있다는 의미다.

객체를 스레드에 안전하게 만들려면 변경할 수 있는 상태에 접근하는 과정을 동기화를 통해 조율해야한다. 동기화가 제대로 되어 있지 않으면 객체의 데이터가 손상되거나 바람직하지 않은 여러 결과들이 생길 수 있다. 즉, 클래스가 정확하게 동작하지 않을 수 있다.

동기화라는 용어는 자바의 'synchronized' 키워드를 통해 락으로 특정 블럭을 보호하는 기능을 의미하기도하지만 넒게는 volatile 변수, 명시적인 락 객체, Atomic 변수를 사용하는 경우에도 쓸 수 있다.

만약 여러 스레드가 변경할 수 있는 상태 변수를 적절한 동기화 없이 접근하면 정말 다양한 문제들이 발생할 수 있다. 동기화에 대한 지식이 없다면 절대 분석할 수 없는 버그들도 나타나게 된다. 일반적으로 스레드 안전하지 않은 클래스에서 발생한 문제를 고치기 위해서는 세 가지 방법이 사용된다.

  1. 공유되는 상태 변수를 스레드 간 공유되지 않게 바꾼다
  2. 공유되는 상태 변수를 변경할 수 없게 만든다
  3. 공유되는 상태 변수를 사용할 때에 항상 동기화를 사용한다

스레드 안전한 클래스는 그 클래스를 사용하는 쪽에서 별도의 동기화를 생각할 필요가 없도록 동기화 기능을 캡슐화 한다. 두 스레드에서 안전하게 데이터를 주고 받을 수 있는 ArrayBlockingQueue 같은 클래스를 사용할 때, 별도의 동기화를 생각하지 않아도 되는 것처럼 말이다.

스레드 안전하지 않은 예제

그러면 스레드 안전하지 않은 예제를 몇 가지 살펴보겠다.

value++

변수의 값을 하나 증가시키는 가장 간단한 형태의 연산이다. 자바에서 작성할 수 있는 가장 짧은 연산 중 하나인 이 연산은 스레드 안전하지 않다. 두 스레드에서 같은 값에 대해 value++을 동시에 수행했을 때, value 값이 2 증가할 수도 있고 아닐 수도 있다.

value++ 연산은 단일 작업으로 보이지만 사실 별도로 3개의 작업이 동작한다.

  1. 메모리에서 value 값을 CPU 레지스터로 가져오는 작업
  2. value 값을 1 증가시키는 작업
  3. 1 증가된 value 값을 다시 메모리로 내리는 작업

두 스레드에서 이 3개의 작업이 '인터리빙(Interleaving)' 된다면 문제가 발생하게 된다. 첫 번째 스레드가 1번 작업을 수행하고, 두번째 스레드가 1번 작업을 수행한다. 두 스레드 모두 0이라는 값을 읽었다. 그리고 동시에 각각 1을 증가시키는 2번 작업을 하고, 3번 작업을 실행한다. 분명히 두 스레드에서 value++를 수행했으므로 2가 증가해야하지만 인터리빙되어 1만 증가한 상태가 된다.

이런 종류의 문제는 'check-then-act' 형태의 코드에서 많이 발생한다. 늦은 초기화(LazyInit)에 대한 예제를 보자.

public class LazyInitRace {
  private ExpensiveObject instance = null;

  public ExpensiveObject getInstance() {
    if (instance == null)
	    instance = new ExpensiveObject();
  
    return instance;
  }
}

instance가 null이면 ExpensiveObject 객체를 만들어서 달아두고 다음번 호출시에는 만들어 둔 객체를 재사용하는 일종의 싱글턴 패턴이다. 이 클래스는 getInstance()를 호출하면 항상 같은 객체를 리턴하도록 설계되었을 것이다. 하지만 이 클래스는 스레드 안전하지 않다.

두 스레드가 동시에 getInstance() 메서드를 수행한다. 두 스레드 모두 if 구문에서 instance 변수가 null이라고 판단하고, 각자 ExpensiveObject()를 생성한다. 그리고 instance 변수에 할당해둔다. 이 경우 ExpensiveObject 객체가 두 개 생기게 되고, 두 스레드가 각자 자신이 만든 객체를 보게 된다. 싱글턴 패턴이라고 가정하고 코드가 작성되었다면 이 부분에서 생각지 못한 버그가 발생할 수 있다.

이런 상황을 경쟁조건(Race Condition)이라고 한다. 이런 문제는 대부분의 경우 잘 동작하다가 가끔 한번씩 발생해서 시스템에 치명적인 문제를 일으킨다. 경쟁조건에서 비롯된 문제는 재현도 잘 안되기 때문에 버그를 잡기가 매우 어렵다. 따라서 초기 클래스를 설계할 때부터 스레드 안전성에 대해 고민하고 잘 잘성하는게 중요하다.

이런 경쟁조건을 피하려면 메서드 앞에 synchronized 키워드를 붙여서 하나의 스레드만 instance 변수를 보고 판단해서 객체를 생성하도록 강제하고, 다른 스레드는 메서드 진입을 늦추도록 해야한다.

value++ 같은 연산의 경쟁조건은 synchronized 키워드를 이용한 락으로 풀어도 되지만 AtomicLong 같은 단일변수(atomic variable) 클래스를 이용해서 동작을 단일로 만들 수도 있다. java.util.concurrent.atomic 패키지에는 숫자나 객체 참조 값에 대해 상태를 단일 연산으로 변경할 수 있도록 다양한 단일 연산 변수(atomic variable) 클래스가 준비되어있다.

복잡한 시스템을 구현할 수록 이런 병렬처리에 대한 지식이 중요하며, 작성하는 클래스가 멀티 스레드 환경에서 공유되어 사용된다면 스레드 안전한지를 고민해야한다. 그곳에서 자바 개발자의 실력이 들어나게 된다.

Reference

 

Java Concurrency in Practice

For the past 30 years, computer performance has been driven by Moore's Law; from now on, it will be driven by Amdahl's Law. Writing code that effectively exploits multiple processors can be very challenging. Java Concurrency in Practice provides you with t

jcip.net

 

댓글