[Java] JVM 관점에서 static을 왜 조심해서 써야할까?

juiuj·2026년 5월 22일

JVM 작동 방식

1. 자바로 개발된 프로그램을 실행하면 JVM은 운영체제로부터 메모리를 할당함

2. 자바 컴파일러(javac)가 자바 소스코드(.java)를 자바 바이트코드(.class)로 컴파일함

3. Class Loader를 통해 JVM Runtime Data Area로 로딩함

4. Runtime Data Area에 로딩 된 .class들은 실행 엔진(Execution Engine)을 통해 바이트 코드를 해석함

5. 해석된 바이트 코드는 Runtime Data Area의 각 영역에 배치되어 수행하며 이 과정에서 실행 엔진에 의해 GC의 작동과 스레드 동기화가 이루어짐.

static이 무엇일까?

class = 설계도
object = 설계도로 만든 실제 물건
static = 실제 물건마다 따로 있는 게 아니라, 설계도에 붙어 있는 공용 값/기능
  • static은 객체가 아니라 클래스에 속하는 공유 상태
  • static 객체를 만들지 않아도 사용할 수 있다는 장점이 있음
    • 클래스가 로딩된 뒤에 오래 살아 있고, 여러 객체와 여러 스레드가 함께 공유하는 값이 될 수 있음.

      ⇒ 메모리 문제, 동시성 문제, 테스트 문제, 초기화 문제를 만들기가 쉬움.

JVM에서 static 필드는 언제 초기화될까?

  • JVM은 클래스를 사용할 때 Loading → Linking → initializing 과정을 거치는데, 이 중 Linking 의 preparing단계에서 static 변수를 위한 메모리를 JVM이 확보하게 됨
    • 변수들은 기본값으로 초기화됨

    • 이후 initializing 단계에서 static int count = 10; 과 같은 명시적 초기화 코드와 static { } 블록이 실행됨

      class Example {
          static int number = 10;
      
          static {
              System.out.println("static block 실행");
          }
      }

      JVM 관점에서는 ⬇️ 이렇게 볼 수 있음

      preparation 단계:
      number = 0
      
      initialization 단계:
      number = 10
      static block 실행

그렇다면, static을 왜 조심해서 사용해야 할까?

1. 오래 살아서 메모리 누수처럼 보일 수 있음

  • static필드 는 객체가 생성될 때마다 만들어지는 값이 아니라, 클래스가 JVM에 로딩되고 초기화되는 과정에서 준비되는 클래스 단위의 값
    • 일반적으로 애플리케이션에서 한 번에 로딩된 클래스는 JVM이 종료될 때까지 살아 있는 경우가 많음
  • static 은 공통으로 오래 유지되어도 괜찮은 값에만 조심해서 쓰는 것이 좋음
    • 예시 만약 static 필드가 큰 객체나 컬렉션을 계속 참조하게 된다면 어떻게 될까?
      import java.util.ArrayList;
      import java.util.List;
      
      class BadCache {
          private static final List<byte[]> cache = new ArrayList<>();
      
          public static void add() {
              cache.add(new byte[10 * 1024 * 1024]); // 10MB
          }
      }
      BadCache.add()를 한 번 호출하면 10MB 배열이 cache에 들어감:
      cache
       └── 10MB 배열
       
       두 번 호출하면:
       cache
       ├── 10MB 배열
       └── 10MB 배열
       
       세 번 호출하면:
       cache
       ├── 10MB 배열
       ├── 10MB 배열
       └── 10MB 배열
      • BadCache.add()가 호출될 때마다 10MB 배열이 cache에 쌓임 → cache가 static이기 때문에 계속 참조가 유지되고 GC가 해당 객체들을 쉽게 회수하지 못함
        • GC가 왜 쉽게 회수되지 못하는거지?
          • GC 는 안쓰는 객체를 회수하는 것이 아니라 더 이상 참조되지 않는 객체를 회수함

            위 배열들은 계속 cache 가 붙잡고 있기 때문에 GC 입장에서는 cache가 해당 배열을 계속해서 참조하고 있다고 판단하여 GC가 회수하지 못함 → 메모리 누수처럼 보이는 현상

static 으로 데이터를 저장하면 잠깐 쓰고 버리는 값이 아니라 애플리케이션 전체에서 오래 들고 있는 값이 될 수 있음

2. 변경 가능한 static 필드는 여러 스레드가 동시에 접근하는 공유 상태가 될 수 있음

(동시성 문제)

  • static 필드는 클래스에 속한 공용 값 ⇒ 여러 스레드가 같은 static 값을 동시에 읽고 쓸 수 있음
    class Counter {
        static int count = 0;
    }
    count 는 클래스에 속한 공용 값임 → 여러 스레드가 동시에 접근할 수 있음
    Thread A ─┐
    Thread B ─┼──> Counter.count
    Thread C ─┘
    → 이 구조로 인해서 동시성 문제 / Race condition 이 발생할 수 있음 ⇒ 여러 스레드가 같은 static 값을 동시에 수정하면 값이 깨질 수 있음

3. 요청마다 달라져야 하는 값과 어울리지 않음 (데이터의 소유 범위가 잘못된 문제)

  • 예시
    class LoginContext {
        static Long currentUserId;
    }
    서버는 보통 여러 사용자의 요청을 여러 스레드로 동시에 처리하기 때문에 아래와 같이 흐름이 이어질 수 있음.
    사용자 A의 id가 1, 사용자 B의 id가 2라고 가정했을 때,
    
    1. 스레드 A가 사용자 A 요청 처리 시작
       LoginContext.currentUserId = 1;
    
    2. 아직 사용자 A 요청이 끝나기 전에
       스레드 B가 사용자 B 요청 처리 시작
       LoginContext.currentUserId = 2;
    
    3. 다시 스레드 A가 currentUserId를 읽음
       그런데 값이 1이 아니라 2가 나올 수 있음
  • static 필드는 클래스 단위로 하나만 존재하기 때문에 모든 요청과 스레드가 같은 값을 공유할 수 있음
    ⇒ 로그인 사용자 ID, 현재 사용자 정보처럼 사용자별·요청별로 달라져야 하는 데이터static에 저장하면, 한 사용자의 요청 처리 중 다른 사용자의 값으로 덮어써지는 문제가 발생할 수 있음

4. static 초기화 순서가 헷갈릴 수 있음

  • static 필드는 객체가 생성될 때마다 초기화되는 값이 아니라, ****클래스가 JVM에 의해 처음 사용되고 초기화될 때 한 번 준비되는 값
    • 여러 static 필드나 블록이 있다면 코드에 작성된 순서대로 초기화됨 -> static 변수끼리 서로 의존하고 있으면 아직 초기화되지 않은 값을 참조하거나, 예상과 다른 값이 들어갈 수 있음
  • 예시
    class InitOrder {
        static int a = getB();
        static int b = 10;
    
        static int getB() {
            return b;
        }
    }
    public class Main {
        public static void main(String[] args) {
            System.out.println(InitOrder.a); // 0
            System.out.println(InitOrder.b); // 10
        }
    }
    a10 이 아니라 0 인 이유?
    preparation 단계:
    a = 0
    b = 0
    
    initialization 단계:
    a = getB(); // 이 시점의 b는 아직 0
    b = 10;

static은 클래스 단위로 먼저 준비되는 값이기 때문에 초기화 순서를 명확히 이해하지 못하면 버그를 만들기 쉬움. 특히 복잡한 객체 생성, 설정값 로딩, 다른 static 값 참조를 static 초기화 과정에 넣는 것은 조심해야 함

5. static final도 항상 안전한 것은 아님

  • static final변수에 다른 값을 다시 대입할 수 없다는 뜻으로, 그 변수가 참조하는 객체 내부까지 변경할 수 없다는 뜻은 아님
    static final List<Object> data = new ArrayList<>();
    static final Map<String, Object> cache = new HashMap<>();
    static final User currentUser = new User();
    참조 타입의 경우 객체 내부 변경까지 막지는 못함

static final 컬렉션이나 객체는 JVM에서 클래스 단위로 오래 유지될 수 있고, 여러 스레드가 함께 접근할 수 있으므로 변경 가능한 객체를 static final 로 둘 때는 메모리 증가, 데이터 오염, 동시성 문제 등을 함께 고려해야 함

0개의 댓글