static에 대해 정리하던중 멀티쓰레드 환경에서 static 변수 사용에 주의가 필요하겠다는 생각이 들었고 이를 정리해둬야겠다는 생각이 들었습니다.

1. static

static은 new 키워드로 생성하여 Heap에 저장되는 일반 객체와는 달리 JVM의 메서드 영역 (Method Area) 또는 Metaspace에 저장됩니다. 이 영역은 클래스 자체의 정보를 담는 곳입니다.

큰 특징은 Garbage Collector의 관리 대상이 아니라는 점입니다. 즉, 이는 어플리케이션 종료가 되지 않으면 메모리에서 해제되지 않음을 의미합니다.

잘 사용하면 매우 유용하지만, 잘못 사용하거나 남발하게 되면 성능에 악영향을 미치기 때문에 적절하게 사용해야 합니다.

2. Thread-safety 문제 : 경쟁 상태 (Race Condition)

static은 접근하는 모든 Thread에서 공유가 가능합니다.

특히, 멀티쓰레드 환경에서 여러 쓰레드가 같은 static 변수에 동시에 접근하여 값을 변경하게 되면 원치않은 결과를 얻을 수 있습니다. 이를 Thread-safe 하지 않다합니다.

현대의 서버 애플리케이션은 여러 사용자의 요청을 동시에 처리하기 위해 멀티쓰레드 환경에서 동작합니다. 각 요청은 쓰레드 풀(Thread Pool)에서 할당된 별개의 쓰레드에 의해 처리됩니다.

이때 여러 쓰레드가 static 변수와 같은 공유 자원(Shared Resource)에 동시에 접근하여 값을 변경하려고 하면 경쟁 상태(Race Condition)에 빠지게 됩니다.

아래 코드는 10개의 쓰레드가 static 변수인 count를 각각 10,000번씩 증가시키는 예제입니다. 예상 결과는 100,000이지만, 실제로는 매번 다른 값이 나옵니다.

public class UnsafeCounter {
    public static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
             for (int i = 0; i < 10000; i++) {
                 count++; // 이 연산은 원자적(Atomic)이지 않다!
             }
        };

        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(task);
            threads[i].start();
        }

        for (Thread t : threads) {
            t.join(); // 모든 쓰레드가 종료될 때까지 대기
        }

        // 최종 결과는 100,000이 아닐 확률이 매우 높다.
        System.out.println("Final count: " + count);
    }
}

count++ 연산은 내부적으로 (1) 값을 읽고, (2) 1을 더하고, (3) 결과를 쓰는 3단계로 이루어집니다.

한 쓰레드가 1번 단계를 마친 직후 다른 쓰레드가 끼어들어 전체 연산을 끝내버리면, 이전 쓰레드의 연산 결과는 덮어써져 유실됩니다.

이처럼 멀티쓰레드 환경에서 안전하지 않은 코드를 Thread-Unsafe 하다고 말합니다. 이 문제의 핵심은 공유 객체가 변경 가능한 상태(State)를 가지고 있느냐(Stateful) 없느냐(Stateless)입니다. Spring Bean을 설계할 때 Stateless하게 만드는 것이 권장되는 이유가 바로 이 때문입니다.

3. synchronized

가장 간단한 방법은 synchronized 키워드를 사용해 해당 변수나 코드 블록, 메서드에 Lock을 거는 것입니다. Lock을 획득한 하나의 쓰레드만 해당 영역에 접근할 수 있어 데이터의 원자성을 보장합니다.

public static synchronized void increment() {
    count++;
}

그러나 명확한 단점이 존재합니다.

Lock을 획득하고 해제하는 과정, 그리고 Lock을 기다리는 쓰레드들이 대기하고 깨어나는 과정 (Context Switching)에서 상당한 성능저하가 발생합니다.

4. 더 나은 대안

synchronized는 강력하지만 성능 이슈 때문에 항상 최선의 선택은 아닙니다.

  • Stateless 설계: 가장 근본적인 해결책은 공유 객체를 상태가 없도록(Stateless) 설계하는 것입니다. 객체 내부에 변경 가능한 멤버 변수를 두지 않고, 필요한 값은 모두 메서드의 파라미터로 전달받아 처리하면 동시성 문제가 원천적으로 발생하지 않습니다.
  • (참고) Atomic 변수 사용: java.util.concurrent.atomic 패키지의 클래스를 사용하면 Lock 없이도 변수 값을 원자적으로 변경할 수 있습니다.

5. 정리

  • static 변수와 싱글톤 객체는 공유 자원이며, 멀티쓰레드 환경에서 Stateful하게 사용될 경우 심각한 동시성 문제를 야기합니다.
    • synchronized는 강력하지만 성능 저하를 감수해야 하는 최후의 수단으로 고려해야 합니다.
    • 대부분의 경우, Atomic 변수를 사용하거나 Stateless하게 설계하는 것이 훨씬 효율적이고 안전한 방법입니다.

+ Recent posts