개발 일기

[이해하기] JVM 환경에서의 싱글톤 패턴

Pleasant Pain 2025. 3. 2. 21:22

개요

  • JVM 환경에서 싱글톤 패턴 구현 방식에 대해 알아본 과정을 통해,
    JVM 작동 방식에 대해 이해하게 된 내용을 이 문서에 정리한다

 

목적

  • 싱글톤 패턴을 구현하는 과정에서 예상치 못한 문제들을 분석하면서,
    JVM의 객체 생성 순서와 메모리 관리 방식을 깊이 이해하고자 한다.
    • 즉, 이 글의 핵심은 JVM 메모리 모델이며, 싱글톤 패턴은 이를 탐구하는 좋은 예제일 뿐이다

 

싱글톤 패턴

  • 싱글톤 패턴 구현 방법을 파악하는 게 이 글의 주된 목적은 아니지만,
    이 글의 주된 목적으로 나아가는 데 있어 `싱글톤 패턴`은 굉장히 맛 좋은 재료

  • 따라서 싱글톤 패턴이 뭔지, 여기서 간단하게 정리한다
    • 이 글은 개인의 실력 쌓기를 위해 작성된 글 임으로,
      최대한 스스로 고찰하기 위해 글 솜씨가 부족하지만 필자 본인이 기존 느꼈던 내용 그대로 정의한다

 

싱글톤 패턴이란?

  • 프로세스(=어플리케이션)에서 정적으로 단 1개의 인스턴스만 생성될 수 있도록 강제하는 구현 방식이다
    • 즉, 적어도 싱글톤 변수에 값이 변경될 수 있을지 몰라도 (보통 그러면 안 되겠지만)
      프로세스 내부에서 싱글톤 패턴이 적용된 class의 객체를 생성할 수 있는 변수는 단 1개뿐이어야 한다
      (2025년 3월 16일 수정)
      (멘토님 피드백을 받고 보니, 싱글톤에서 가장 중요한 것이 객체의 상태 보장이라는 점을 명확히 이해하게 되었다)

싱글톤 패턴 구현 방식

필자의 기존 지식

1. 정적 변수로 선언한 후 최상단 main 스레드에서 생성하는 방식

  • 업무상 Node.js 기반으로 개발했던 필자가 실제로 몇 번 활용했던 방식이다
    (보통 작성된 코드가 Main 싱글스레드, 이벤트 루프 기반으로 작동되는)
public class Singleton {
    // private static 필드에 인스턴스를 보관
    private static final Singleton instance = new Singleton();
    
    // private 생성자로 외부에서 인스턴스 생성을 막음
    private Singleton() {
    }
    
    // public static 메서드로 인스턴스에 접근할 수 있는 방법 제공
    public static Singleton getInstance() {
        return instance;
    }
}

public class Main {
    public static void main(String[] args) {
        // 싱글톤 인스턴스 가져오기
        Singleton singleton1 = Singleton.getInstance();
        Singleton singleton2 = Singleton.getInstance();
        
        // 동일한 인스턴스인지 확인
        System.out.println("singleton1 == singleton2: " + (singleton1 == singleton2));
    }
}
  • 위와 같이 어플리케이션 실행 후 바로
    (2025년 3월 16일 수정)
    (어플리케이션 실행 시점과 객체 초기화 시점을 명확히 구분지어 설명해야한다)

    Main 쓰레드에서 코드가 실행되는 시점에 싱글톤 정적 변수에 객체가 생성
    되기 때문에,
    이후 다른 쓰레드에서 싱글톤 변수에 접근한다고 하더라도 항상 싱글톤이 보장된다고 판단했다
    • 사실 이땐 가장 쉬운 방식을 택한 것이다
    • JVM의 클래스 로더에 대한 개념도 부족했고.. 이는 뒤에 얘기하겠다

 

2. 정적 변수로 선언한 후 메서드를 활용하여 필요한 시점에 싱글톤 객체를 생성하는 방식

  • 이 방식에 대해서는 과거의 1번 구현에 대한 학습을 통해, 알고 있었던 방식이다
    • 그러나 멘토님께 이에 대한 설명이 매끄럽지 않았던 것을 보면, 이에 대한 이해도 부족했던 것 같다
  • 1번 방식과의 주된 차이점은 소제목에 명시했 듯,
    싱글톤 객체가 필요한 시점에 생성함으로써 메모리를 절약하는 방식이다
  • 그러나, 싱글톤 객체의 생성 요청이 여러 쓰레드에서 동시에 일어난다면 동시성 문제가 발생될 수 있음으로
    synchronized 동기화 키워드를 통해, 객체 생성에 대한 요청이 쓰레드 하나씩 순서대로 처리되도록 강제한다
public class LazyInitializationSingleton {
    // private static 필드 선언 (초기값은 null)
    private static LazyInitializationSingleton instance;
    
    // private 생성자
    private LazyInitializationSingleton() {
    }
    
    // synchronized 키워드를 사용하여 thread-safe 보장
    public static synchronized LazyInitializationSingleton getInstance() {
        // 인스턴스가 없을 때만 생성
        if (instance == null) {
            instance = new LazyInitializationSingleton();
        }
        return instance;
    }
}

public class Main {
    public static void main(String[] args) {
        // 처음 호출할 때 인스턴스가 생성됩니다
        LazyInitializationSingleton singleton1 = LazyInitializationSingleton.getInstance();
        
        // 이미 생성된 인스턴스가 반환됩니다
        LazyInitializationSingleton singleton2 = LazyInitializationSingleton.getInstance();
        
        // 동일한 인스턴스인지 확인
        System.out.println("singleton1 == singleton2: " + (singleton1 == singleton2));
    }
}
  • 위 방식은 어플리케이션 실행 시의 메모리가 절약될지 몰라도,
    싱글톤 객체를 접근하기 위한 메서드 자체에서 성능 저하가 생길 수밖에 없다
    • WHY? 싱글톤 객체가 생성이 완료된 시점에서도, 계속해서 여러 쓰레드의 요청 순서를 보장하기 위해,
      특정 쓰레드가 메서드 완료될 때까지 메서드가 Locking 되어 다른 쓰레드에서 그만큼 대기해야 하기 때문이다
      • 사실, 이러한 성능 저하를 감소시킬 수 있는 방식을 희미하게 기억하고 있었으나,
        명확한 이해가 부족했기에 매끄럽게 설명하지 못하고 멘토님의 설명을 통해 다시 듣게 됐다

3. 2번의 성능 저하 문제를 해결하는 DCL(Double-Checked Locking) 방식

  • 메서드 자체 동기화 처리를 하는 게 아닌, 메서드 내부 부분적으로 동기화 블록을 활용하는 방식이다
public class DoubleCheckedSingleton {
    private static DoubleCheckedSingleton instance;
    
    private DoubleCheckedSingleton() {
    }
    
    public static DoubleCheckedSingleton getInstance() {
        // 첫 번째 검사 (동기화 없이 검사)
        if (instance == null) {
            // 동기화 블록
            synchronized (DoubleCheckedSingleton.class) {
                // 두 번째 검사 (동기화 된 상태에서 다시 검사)
                if (instance == null) {
                    instance = new DoubleCheckedSingleton();
                }
            }
        }
        return instance;
    }
}

public class Main {
    public static void main(String[] args) {
        // 처음 호출할 때 인스턴스가 생성됩니다
        DoubleCheckedSingleton singleton1 = DoubleCheckedSingleton.getInstance();
        
        // 이미 생성된 인스턴스가 반환됩니다
        DoubleCheckedSingleton singleton2 = DoubleCheckedSingleton.getInstance();
        
        // 동일한 인스턴스인지 확인
        System.out.println("singleton1 == singleton2: " + (singleton1 == singleton2));
    }
}
  • 위와 같이 동기화 블록을 싱글톤 객체 생성 로직으로 범위를 지정하고,
    싱글톤 객체 생성 여부에 대한 체킹을 동기화 로직 밖에서 처리하여,
    여러 쓰레드에서 동기화로 인한 성능 저하 문제를 해결하자는 것이다

  • 그러나 아직 싱글톤 객체가 생성되지 않았을 때,
    여러 쓰레드가 한 번에 생성 요청하여 처음 if문의 조건을 여러 쓰레드가 통과했다고 가정하면,
    동기화 블록에 대한 진입 또한 여러 쓰레드에서 가능해짐으로,
    동기화 블록 안에서도 한번 더 싱글톤 객체의 생성 여부를 확인하는 것이다

  • 하지만, 동기화 성능저하 문제까지 해결한 위 구현도 JVM 환경에서 문제를 초래할 수 있다
    • 사실 2~3년 전 이에 대한 문제와 volatile 키워드에 대해서 글을 읽어봤던 기억이 있다
    • 그런데 기억이 희미한 것으로 보아, 이에 대한 중요성을 인지 못 한채 소중한 학습 기회를 제 발로 날려 버렸다
    • 따라서 어떠한 문제가 왜 발생하는가를 중점적으로 알아보자
    • 어쩌면 문제가 왜 발생했는가를 아는 것이 문제를 해결하는 방식을 아는 것보다 더 중요한 가치가 있을 수 있기 때문이다
    • 문제가 왜 발생됐는지를 알면, 이 문제를 어떻게 해결해야 할지 고민하는 게 오히려 재밌는 과정이 될 수 있기 때문이다

 

싱글톤 생성 여부 체크까지 했음에도 어떤 문제가 왜 발생하는가?

😲 메모리 가시성 문제 (Memory Visibility Problem)

  • 객체 생성과 참조가 분리되어 이루어질 수 있다
    • 객체의 생성과 참조가 분리되어 이루어진다는 뜻이 정확히 무엇인가?
    • 객체의 생성이 어떻게 참조와 분리된다는 것인지 명확히 이해하지 못하고 있었다 
  • 이에 대해 알아본 바, 객체 생성 과정 아래와 같이 단계별로 쪼갤 수 있었다
    1. 메모리 할당 => WHY? 메모리 공간이 확보되야 객체 데이터를 저장할 수 있음으로
    2. 객체 초기화 => 메모리 공간이 확보됐으니 이제 데이터를 메모리 공간에 저장하는 과정
    3. 변수에 메모리 주소 할당 => 메모리 공간이 할당됐으니 당연히 메모리 주소를 알아야 접근 가능하지
  • 1번부터 3번까지 과정이 내가 알고 있던 new 키워드를 활용해 객체를 생성하는 코드 한 줄의 명령일 것이다
    • 여기서 1번부터 3번까지의 과정의 순서가 과연 보장되는가?
      • 사실, 기존에 당연히 저 순서대로 진행될 것이라 생각했다.
      • 좀 더 솔직히 표현하면, 객체 생성이 코드를 작성하는 내 입장에서는 하나의 명령으로 느껴지니,
        객체가 메모리에 할당되고 주소 값을 갖게되는 일련의 과정이 하나의 묶음으로 느껴졌다

      • 그런데 조금 고민해보니,
        new 키워드를 통해 객체를 생성하는 과정이 단일 쓰레드 입장에서는 하나의 묶음이 맞으나,
        다중 쓰레드 입장에서는 공유하는 변수를 객체가 생성하는 과정에서 (메모리에 적재되는 과정)
        아직 생성이 완료된 것이 아닌데 접근하려 할 수도 있겠다는 생각까지 떠올리게 됐다
        (2025년 3월 14일 수정)
        자바 코드로 봤을 때는 단 하나의 명령으로 느껴지겠으나,
        직접적인 CPU 연산이 이뤄지는 어셈블리어 수준으로는 여러 명령으로 쪼개지는 것이다 

      • 그런데 사실 여기서도 아직 객체가 생성 완료된 것이 아니라면,
        변수의 주소 값은 null 임으로 아무 문제가 없어야 할 것 아닌가?

      • 설마 아직 객체가 생성되기 전인데도 불구하고, 변수가 메모리 주소 값을 갖게 되는 것인가? 😲
    • 명확히 인식하지 못했던 중요한 문제를 여기서 깨닫게 된다
      • 객체 생성 과정 중에 1번 메모리 할당은 (메모리 공간 확보는) 가장 우선시 되어야 함이 맞다

      • 그러나 객체의 데이터가 메모리에 저장되는 과정과,
        해당 주소를 변수에 할당하는 과정은 반드시 순서를 지킬 필요가 없다.
        • 데이터에 접근하기 위해서 변수를 활용해야하는 개발자 입장에서는
          2번, 3번의 순서를 지키지 않는게 이해가 가지 않을 수 있다

        • 그러나 Node.js를 활용해 비동기 프로그래밍에 익숙했던 내 입장에서는,
          순서를 지킬 필요가 없는 경우에서, 굳이 2번 이후 3번을 처리하는 게 아니라
          2번과 3번을 동시에 처리하는게 훨씬 효율적이라는 것을 잘 안다

        • 그러므로 나보다 더 똑똑한 JVM, OS 설계자들은 불필요한 순서를 지키도록 설계하는 게 아니라,
          객체 생성 작업이 빠르게 처리 될 수 있도록 병렬적으로 처리하는 방식을 택했을 것이다
    • 위와 같은 고찰 끝에,
      확보된 메모리 공간에 객체의 데이터가 아직 적재 중인데도 불구하고,
      변수는 확보된 메모리 공간의 주소 값을 이미 가리키고 있을 확률이 매우 크다는 점을 인식할 수 있게 되었다

    • 따라서, 특정 쓰레드가 싱글톤 객체를 new 키워드를 통해 생성하고 있는 와중에
      다른 쓰레드가 객체의 생성 여부를 변수의 주소 값으로 체크한다면, 
      (명확히 하려면 객체의 데이터를 메모리에 적재하는 와중이라고 생각된다)
      이미 생성이 완료된 객체라고 판단할 것이며,
      메모리 주소 값에 따라 데이터에 접근하겠지만 그때는 제대로 된 데이터가 저장되기 전일 수 있다는 것이다.

    • 드디어 왜 이러한 문제가 발생될 수 있는지 스스로 납득할 수 있게 되었다
      • 그렇다면 이 문제를 해결 할 수 있는 방안에 대해 알게 된 내용으로 넘어간다

 

Volatile 키워드 활용 (볼래틸)

public class DoubleCheckedSingleton {
    // volatile 키워드를 사용하여 멀티스레드 환경에서 변수의 원자성 보장
    private static volatile DoubleCheckedSingleton instance;
    
    private DoubleCheckedSingleton() {
    }
    
    public static DoubleCheckedSingleton getInstance() {
        // 첫 번째 검사 (동기화 없이 검사)
        if (instance == null) {
            // 동기화 블록
            synchronized (DoubleCheckedSingleton.class) {
                // 두 번째 검사 (동기화 된 상태에서 다시 검사)
                if (instance == null) {
                    instance = new DoubleCheckedSingleton();
                }
            }
        }
        return instance;
    }
}

public class Main {
    public static void main(String[] args) {
        // 처음 호출할 때 인스턴스가 생성됩니다
        DoubleCheckedSingleton singleton1 = DoubleCheckedSingleton.getInstance();
        
        // 이미 생성된 인스턴스가 반환됩니다
        DoubleCheckedSingleton singleton2 = DoubleCheckedSingleton.getInstance();
        
        // 동일한 인스턴스인지 확인
        System.out.println("singleton1 == singleton2: " + (singleton1 == singleton2));
    }
}
  • JVM 환경에서는 위에서 언급한 메모리 가시성 문제를 volatile 키워드를 활용하면 해결된다고 한다
    • 그래서 저 키워드로 어떻게 해결되는 건데?
    • chatGPT의 답변으로는 변수의 쓰기와 읽기가 모두 메인 메모리에 직접 이루어지기 때문이라고 한다
    • 또한, 명령 재정렬을 방지하여 객체 초기화가 완료된 후에만 객체가 참조될 수 있도록 보장한다고 한다
      • 여기서, 솔직히 이해가 안 갔다. 여기서 다시 이해가 안 가는 부분에 대해 쪼개본다
        1. 변수의 쓰기와 읽기가 모두 메인 메모리에 직접 이루어진다?
          여기서 메인 메모리의 정확한 뜻은?
          그럼 volatile 키워드를 안 쓰면 메인 메모리를 사용하지 않는 거야?

        2. 명령 재정렬 방지?
          메모리 가시성 문제에서 언급했던 객체 생성 과정 2, 3번에 대해 순서를 보장해 준다는 말 맞나?
    • 볼래틸 키워드에 대해 납득이 가도록 정리해 보자
      • Spring 학습할 시간이 부족하지만, 이 부분을 대충 넘어가면 안 될 것 같다는 판단이 들었다

 

🤔 메인 메모리라는 단어가 왜 나왔을까

  • 이해를 위해 내가 풀어야 할 명제는 다음과 같다
    • volatile 키워드를 사용하면 변수의 읽기/쓰기가 모두 메인 메모리에서 이뤄진다고 한다
      그렇다면 volatile 키워드를 사용하지 않은 변수들은 읽기/쓰기가 어떤 메모리 영역에서 처리되는데?
  • 먼저, 메인 메모리에 대한 chatGPT의 답변은 다음과 같았다
    • 메인 메모리는 모든 스레드가 공유하는 실제 물리적 메모리이다
    • 반대로 각 스레드 마다 고유의 영역이 있는 캐시 메모리가 있다
      • 캐시 메모리는 각 스레드(혹은 각 CPU 코어)가 메모리 접근 속도를 높이기 위해 사용하는 임시 메모리
    • 멘토링 필독 도서 "자바의 신"을 통해,
      각 스레드 마다 고유의 메모리 영역을 가진다라는 언급을 기억하고 있긴 했으나,
      그게 명확히 어떤 부분인지 제대로 파악하지 못했던 것 같다
      • 내가 기억했던 것은 각 스레드마다 메서드 호출에 대한 고유의 스택 프레임이 존재한다는 것 정도였다..
    • (2025년 3월 14일 수정) 필자가 완전히 착각하고 있었다
      • 캐시 메모리, 메인 메모리에 대한 구분은 JVM에서의 스레드가 아닌 OS에서의 스레드를 뜻하는 것이다
        그러나 결국 유저 스레드가 OS의 스레드에 종속되는 관계이기 때문에,
        JVM에서의 각 스레드 영역의 데이터가 불일치 되는 문제가 발생되는 것이다

      • 필자가 메인 메모리를 마치 힙 영역으로 얘기하고,
        각 스레드의 스택은 마치 메인 메모리가 아니고 캐시 메모리에 가까운 것 처럼 정리를 했었다
        이는 완전히 잘못된 착각이었다

      • 유저 스레드. 즉, JVM 수준에서 관리하는 스레드는,
        결국 JVM에서 관리하는 메모리 구조에서 노는 것 뿐이고, 이는 모두 RAM(메인 메모리) 영역이다
        (스택을 포함하여 메타 스페이스, 힙 모두 말이다)

      • 이와 별개로 OS 스레드 수준에서는 CPU Core와 밀접한 관계가 있고,
        각 CPU Core 당 본인만의 캐시 메모리 영역이 있다(L1,L2,L3)
        그리고 모든 OS 스레드는 기본적으로 CPU 캐시에서 데이터를 읽어오려 한다
        하지만, 다른 스레드와 일관성을 유지하려면 메인 메모리(RAM)에서 최신 데이터를 읽어야 한다
        → 그렇기에 volatile, 메모리 배리어가 등장했다

      • 다시 정리하면 OS 스레드 기준에서 보면 CPU는 캐시 메모리 ↔ RAM(메인 메모리) 간 동기화 문제를 가진다
        따라서 volatile이나 메모리 배리어가 필요하다

        JVM 기준에서 보면 힙/메타스페이스/스택 등은 모두 RAM에 존재하는 논리적 영역 구분일 뿐,
        결국 JVM의 메모리 관리도 OS의 메모리 관리에 종속된다
        CPU 캐시 ↔ RAM 동기화 문제는 JVM에서도 동일하게 발생될 수 밖에 없는 구조다
         
  • 위와 같은 chatGPT의 답변을 통해 어렴풋이 느끼게 된 게 있다
    어쩌면 볼래틸 키워드는 객체의 생성 과정(메모리 적재 과정)에서의 문제에서
    더 확장된 범위의 문제를 해결해 주는 키워드가 아닐까

  • 그렇다면 쓰레드에서 볼래틸 키워드가 적용되지 않은 변수를 어떻게 읽고/쓰는지,
    반대로 쓰레드에서 볼래틸 키워드가 적용된 않은 변수를 어떻게 읽고/쓰는지 정리해 보자

변수에 볼래틸 키워드를 사용하지 않았을 경우

  • static 키워드로 정적 공유되는 변수라 할지라도 쓰레드는 다음과 같이 메모리를 읽고/쓴다
  1. 쓰기: 
    • 정적 변수는 모든 쓰레드가 공유해야 함으로, 원시 타입일지라도 메인 메모리에 적재되어야함
      • 따라서 공유되는 메인 메모리(힙)에 데이터를 적재한다(쓴다)
    • 메인 메모리에 적재된 데이터를 쓰레드 본인 고유의 영역인 캐시 메모리에도 저장한다
      • 이후 읽기 작업은 고유의 영역인 캐시 메모리에서 읽어드린다
  2. 읽기:
    • 변수의 데이터가 쓰레드 고유의 캐시 메모리에 존재하는가?
      => 있다면 캐시 메모리에서 읽음 => 끗

    • 캐시 메모리에 존재하지 않으면 모든 쓰레드가 공유하는 메인 메모리에서 데이터를 읽음
      => 끗 (메인 메모리에서 읽은 데이터를 캐시 메모리에 적재)
      • 이후 같은 변수의 데이터를 캐시 메모리에서 읽음
  • 위와 같이 쓰레드가 공유하는 변수에 대해 값을 읽고 쓸 때,
    다음과 같은 문제가 발생될 수 있다고 여겨진다

    (2025년 3월 14일 수정)

    여기서 얘기하는 캐시 메모리는 CPU Core의 캐시 메모리이며,
    그에 따라 스레드 수준도 JVM 레벨의 스레드가 아닌, OS 스레드 수준으로 인식하는 것이 좋다
     
    1. n이라는 int 타입 정적 변수가 10으로 값 초기화가 되어있다고 가정하자

    2. 특정 쓰레드 A가 정적 변수 n을 처음 읽어드려,
      메인 메모리(RAM 힙)에서 데이터를 읽고, 이를 (CPU Core)고유의 영역인 캐시 메모리에 저장했다  

    3. 이후 특정 쓰레드 B가 n이라는 int 타입 정적 변수를 0으로 저장했다고 가정하면,
      메인 메모리에 변수 n이 가르키는 공간에 데이터 0이 적재되며
      쓰레드 B의 고유 영역인 -> 쓰레드 B의 고유 영역이 아니라 CPU Core의 고유 영역이라고 보는게 맞다
      캐시 메모리에 마찬가지로 변수 n의 데이터 0이 저장된다

    4. 여기서 특정 쓰레드A가 다시 정적 변수 n을 읽어드리려 한다면?
      (특정 "CPU Core의 캐시 메모리에 이미 저장되었던" 이라는 가정하에서다)
      캐시 메모리에 존재하는 데이터 10을 읽어 드릴 것이다
      • 그러므로 쓰레드에서 공유하는 변수의 데이터 값이 바뀌었을 때,
        이전에 변수를 읽었던 쓰레드들은, (정적 변수 임에도 불구하고) 과거 데이터를 조회하게 된다
  • 위와 같은 문제를 해결할 수 있는 게 볼래틸 키워드 인 것이다

변수에 볼래틸 키워드를 사용할 경우

  • 이제 chatGPT가 언급해 준 대로, 볼래틸 키워드를 활용한 변수가 메인 메모리에서만 관리될 수 있다고 하자
    (2025년 3월 14일 수정) 필자가 완전히 착각하고 있었다
    볼래틸 키워드를 활용해도 CPU의 캐시 메모리를 활용한다
    다만, 볼래틸 키워드의 변수 값이 수정 됐을 때,
    캐시 무효화를 통해 다시금 메인 메모리(RAM)에서 데이터를 읽어 오도록 하는 것이다

    따라서 CPU Core의 캐시 메모리를 아에 사용하지 않는게 아니라.
    메인 메모리의 변수 값이 변경됐을 때, 캐시 메모리의 데이터도 동기화 시켜주는 것에 가깝다

  • 그렇다면 모든 스레드는 항상 공유되고 있는 메모리인 힙에서 변수의 데이터를 쓰고 읽을 것이기 때문에
    다중 쓰레드에서 공유하는 변수의 데이터가 수정된다 하더라도, 항상 최신화된 데이터를 읽을 수 있다

  • 따라서 볼래틸 키워드를 활용하면, 여러 쓰레드에서 진정으로 동기화된 변수를 가질 수 있게 된다
    • 그러나, 내가 그동안 볼래틸 키워드를 쓰는 경우가 있었나?
      • 안타깝지만 스스로 볼래틸 키워드를 활용한 경우가 떠오르지 않는다
      • 왜 그럴까?
    • 볼래틸 키워드를 활용하면 변수의 값이 메인 메모리에서만 관리되니 장점도 있지만,
      반대로 쓰레드 고유 영역의 캐시 메모리 활용을 포기해야 한다는 단점도 된다 (일단 캐시는 비싸도 빠르잖아)
      (2025년 3월 14일 수정)
      캐시 메모리 활용을 안하는게 아니다, 메인 메모리의 값과 동일하게 동기화를 해주는 것이다

    • 따라서, 여러 쓰레드에서 공유하는 변수의 값이 자주 변경될 수 있다는 가정하에,
      볼래틸 키워드를 활용해야겠다는 판단이 들었다

🤔 명령 재정렬 방지. 확실한가

  • chatGPT의 답변으로 `명령 재정렬 방지`라는 키워드가 나왔는데,
    과연 내가 추측하는 객체 생성 시 메모리에 적재되는 과정을 (1, 2, 3) 순서대로 지킨다는 말이 맞을까?
  • 다시 근본적인 데이터 가시성 문제로 돌아가서, 객체 생성 명령에 따라 JVM에서는
    1. 객체가 생성될 메모리 공간을 먼저 확보하고
    2. 확보된 메모리 공간에 객체 데이터를 저장하고
    3. 변수에 생성된 객체의 메모리 주소 값을 할당한다
  • 여기서 성능 최적화를 위해 2, 3번이 동시에 처리되어야 하는데 이걸 강제한다는 말인가?
    • chatGPT의 답변으로는 명확히 맞다고 한다..
    • 볼래틸 키워드가 붙은 변수는 객체를 생성할 때 위와 같은 순서를 필히 지키는 것이다
  • 또한, 여기서 메모리 베리어라는 개념으로 위 1번부터 3번까지의 객체 생성이 다 처리되기 전에,
    다른 스레드에서의 변수 접근을 제한한다는 GPT 선생의 말이 있었다..
    • 사실 여기서 잠깐 고비가 왔었다

    • 이미 객체의 생성 1번부터 3번까지의 과정을 순서대로 처리하도록 보장해 주는데,
      저 과정이 완료되기 전에 다른 스레드에서의 변수 접근까지 제한한다고..?
      • 무엇 때문에? 특히 쓰레드 동기화 방식하고 비슷한 개념인 건가..? 하고 고비가 왔었다
  • 그런데 계속 워딩을 반복해서 읽고, 비슷한 질문을 계속 주고받은 사이에 조금씩 이해가 가기 시작했다
    • 만약 여러 쓰레드가 해당 변수에 접근이 가능한 상황이라면..?
  • 싱글톤 패턴 구현에서의 메모리 가시성 문제는
    객체의 데이터가 메모리에 적재 중인 상황에서 먼저 변수가 메모리의 주소 값을 갖게 되어 생기는 문제였다
    • 따라서 메모리의 주소 값이 저장되는 시점이, 할당된 메모리에 데이터 적재가 완료된 시점만 맞추면,
      싱글톤 패턴에서의 발생된 문제는 해결됨이 맞다

    • 그러나.. 정말 잦게 해당 변수가 변경될 수 있다면..?
      • 그니까 쓰레드A가 변수 값 변경을 시도했고, 찰나에 쓰레드B도 변수를 읽는다면..?

      • 즉, 이미 메모리에 데이터가 적재된 상태라고 할지라도,
        또한 생성에서 1~3번의 순서가 보장된다고 할지라도,
        변수의 데이터 읽기나 변경이 발생되고 있다면,
        쓰레드 A에 의해 변수의 데이터 변경이 완료되는 시점까지,
        쓰레드B가 값을 수정하거나 읽지 않는다는 뜻이다
    • 이 개념을 스레드 간의 블로킹 개념과 혼동되면 안 된다
      • 내가 내린 결론은
        스레드 개념보다 더 상위에 가까운 JVM 레벨에서
        독립적으로 볼래틸 키워드 변수에 메모리 작업이 완료 됐을 보장하기 위함이라고 느껴진다
  • 추가로 GTP 선생이 언급해 준 문제점이 하나 있다
    • 변수의 바로 복합 연산 과정에서도 값이 쓰레드 안전할 것이라 착각하지 말라는 점
    • 이 문제는 생각보다 단순하다
      • 특정 쓰레드A에서 볼래틸 변수 n에 대해 값을 쓰거나 읽었다고 해도
        그다음 연산에 대한 데이터는 함수 내부이니 만큼 스택 메모리 영역에서 관리된다

      • 즉, 쓰레드A의 다음 연산 과정이 일어나기 전에 다른 쓰레드B가 n의 값을 수정한다면,
        쓰레드A는 본인이 이전에 조회한 n의 값을 스택에 들고 있을 테니 동기화는 이뤄질 수 없다
        • WHY? 각 쓰레드마다 고유의 스택 영역을 가짐으로
  • 이러한 생각 정리를 통해 볼래틸 키워드 활용은,
    단순히 메모리 가시성 안전한 싱글톤 패턴 구현을 넘어서는 의미를 담고 있다고 느껴졌다

  • 다중 쓰레드 환경인지의 여부와 정적 변수의 값 수정이 잦은 지 혹은 복잡한 연산을 하는지, 성능이 중요한지 등
    충분히 고려하고 활용해야 한다

 

Initialization-on-demand Holder Idiom 활용

public class Singleton {
    // private 생성자
    private Singleton() {}
    
    // static inner class (Holder)
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    // public static 인스턴스 반환 메서드
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

public class Main {
    public static void main(String[] args) {
        Singleton instance1 = Singleton.getInstance();
        Singleton instance2 = Singleton.getInstance();
        
        System.out.println(instance1 == instance2);  // true 출력
    }
}
  • 이 패턴은 JVM의 클래스 초기화 과정을 활용하여 스레드 안전한 싱글톤을 구현한다고 GPT가 얘기한다
  • 또한, 클래스 로더가 클래스 초기화를 스레드 안전하게 처리하기 때문에 별도의 동기화가 필요 없다고 한다
  • 일단 내부 구현 방식부터 정리해 보자
    • 내부 정적 클래스로 Holder 클래스를 설계하고,
    • Holder 클래스가 정적 싱글톤 클래스 객체를 즉시 초기화하도록 설계되어 있으며,
    • 외부 클래스는 동일하게 정적 메서드로 Holder의 정적 객체를 return 한다
  • 위 방식이 분명 싱글톤 패턴을 구현해 주는 것 같기는 한데..
    • 아직 애매모호하게 받아들여지는 개념이 있다
    • 클래스 로더가 클래스 초기화를 스레드 안전하게 처리하기 때문에라는 문장
      • 분명 멘토님께서 1회 차 멘토링에 언급해 주셨던 개념으로 기억한다
      • 그 덕분에 일단 틀린 말 같지는 않은데.. 이상하게 와닿지가 않는다
        • 왜 저렇게 처리하는지에 대해 스스로 고찰해보지 않았기 때문인 것 같다
  • 조금이라도 고찰 해보려는 노력 정도는 해야한다 
    • GPT 형의 도움으로 클래스 로더란 JVM이 클래스 정보를 메모리에 로드하고 초기화하는 것까지 알게 됐다
    • 또한, 여기서 클래스 정보를 메모리에 로드하고 초기화하는 시점은 클래스가 처음으로 사용될 때라는 점도..
      • 멘토님께서 언급해주신 부분과 정확히 일치한다
    • 이 과정은 클래스 당 한 번만 수행되며, 스레드 세이프하게 이뤄진다
  • 자, 그럼 이제 JVM이 왜 저렇게 동작할까에 대한 생각을 해보자
    • 따지고 보면 클래스 정보 그 자체는 변경될 수 없는 정적인 정보로 한 번 로드 하는 것으로 충분하다

    • 실행 과정에서 불필요한 클래스 정보를 굳이 메모리에 적재하지 않아,
      빠르게 어플리케이션을 실행할 수 있는 이점도 있겠다

    • 또한, 클래스 정보이니 만큼 모든 스레드가 이 정보를 공유하는게 너무나 타당하다

    • JVM이 스스로 쓰레드 안전하게 처리해준다는 것도 충분히 필요하다 판단된다  
      • 왜? 쓰레드A가 K클래스를 처음으로 생성해서 K클래스의 대한 정보가 로드되고 있을 때,
        다른 스레드B가 같은 K클래스를 생성해서 클래스 로드가 중복되는 것을 막아야 하니까
    • 또한, 클래스 로더에 의해 클래스가 처음 활용 될 때 싱글톤 객체를 생성하면 되니,
      메모리 낭비 또한 막을 수가 있다
      물론 지연 로딩은 처음 생성 시 그만큼 시간이 걸린다는 의미로 양날의 검 같긴하다
      • 지난 1회차 멘토링 과정 중 멘토님께서 코드 영역(메타 스페이스), 클래스 로더,
        그리고 어플리케이션 초기 실행 시 지연에 대해 언급해주신 부분이 점점 그림이 맞춰지기 시작했다
  • 개인적으로 싱글톤 객체 생성 이후 값 변경이 필요 없다면 volatile 키워드 활용보다 더 합리적인 방법이라 생각된다
    • WHY?
      • volatile 키워드 활용은 싱글톤 변수의 데이터가 각 스레드의 캐시 메모리 영역을 사용할 수 없고
      • 데이터 변경이 없는 변수 접근 시에도 불필요한 메모리 배리어가 일어나기 때문이다

 

enum 활용

public enum SingletonEnum {
    INSTANCE;
   
    private final LocalDateTime createTime;
    
    // enum 생성자 (항상 private)
    SingletonEnum() {
        this.createTime = LocalDateTime.now();
        System.out.println(this.name + " 인스턴스 생성됨: " + this.createTime);
    }

    public LocalDateTime getCreateTime() {
        return createTime;
    }
}

public class Main {
    public static void main(String[] args) {
        // 싱글톤 인스턴스 가져오기
        SingletonEnum singleton1 = SingletonEnum.INSTANCE;
        
        // 다른 참조로 가져와도 같은 인스턴스
        SingletonEnum singleton2 = SingletonEnum.INSTANCE;
        
        // 동일 인스턴스 검증 true
        System.out.println("singleton1 == singleton2: " + (singleton1 == singleton2));
        
        // 생성 시간이 같은지 확인
        System.out.println("singleton1 생성시간: " + singleton1.getCreateTime());
        System.out.println("singleton2 생성시간: " + singleton2.getCreateTime());
    }
}
  • 위와 같이 enum을 활용해도 싱글톤을 구현할 수 있다
    • enum을 활용해 싱글톤 구현을 할 수 있다는 것 또한 1회차 멘토링 시간에 언급해주셨던 부분이다
    • 애초에 enum class는 정적으로 관리되니, 그도 그럴 것 같긴 한데..
  • 하나씩 생각을 정리해 보자
    • enum도 결국 class 임으로 Initialization-on-demand Holder Idiom 활용과 마찬가지로
      지연 로딩은 동일하게 적용될 것 같다
    • Initialization-on-demand Holder Idiom 활용과 거의 같다고 느껴진다면, 깔만 한 게 정말 없는데..?
    • 애초에 정적 클래스인 점과 클래스 로더 덕분에 싱글톤이 보장된다는 것이 이해가 된 상황이니
      먼저 단점에 대해 알아보는 게 낫다고 판단됐다
  • enum을 활용한 싱글톤 구현은 과연 단점이 없을까
    • 우선 enum 생성자에서 throws를 사용하지 못해 예외 처리가 불편하다는 점
      (사실 이 단점은 예외 처리를 바라보는 관점에서 나에겐 크게 와닿지 않았다)

    • enum은 상속이 불가하다는 점
      (사실 이 부분도 싱글톤 패턴 구현시 상속이 필요할 일이 제 수준에서는 크게 와닿지 않았다)

      • 흠.. 현재 내 수준으로선(부족한 시야로) 단점도 단점이라 느껴지지 않은데..
      • GPT 형의 도움을 통해 진짜 단점이라 느껴지는 부분을 찾았다
        • 싱글톤 객체 초기화 시, 동적 초기화가 불가능하다
    • enum은 기본적으로 정적인 정보를 담는 클래스이기 때문에
      생성자를 통해 상황에 따라 유연한 싱글톤 객체를 생성하는 것은 당연히 어렵겠다

    • 물론 enum에 미리 여러 값을 세팅해 두는 방식으로 다양한 버전으로 싱글톤을 초기화할 수도 있겠으나..
      이것이 Initialization-on-demand Holder Idiom의 활용보다 더 유연할 수는 없다고 판단된다

    • 유연한 싱글톤 객체를 생성할 필요가 없는 경우 enum을 활용한 싱글톤 설계가 매우 합리적인 선택이지 않을까 싶다

    • 반대로 상황에 따라 유연한 싱글톤 객체를 생성해야 할 경우, enum은 명백한 한계가 있다

 

요약

JVM 환경에서의 싱글톤 패턴 핵심 정리

  • Eager Initialization: 프로그램 시작 시 생성 (메모리 사용 ↑, 안전함)
  • Lazy Initialization: 필요할 때 생성 (메모리 절약, 동기화 이슈) 
  • DCL (Double-Checked Locking): 동기화 비용 최적화 (volatile 필요)

 

JVM 메모리 모델과 관련된 문제

  • 객체 생성 과정: 메모리 할당 → 초기화 → 참조 변수에 주소 할당
  • 다중 쓰레드 환경에서는 할당과 초기화 순서가 보장되지 않을 수 있음
  • 이를 해결하기 위해 volatile 키워드를 활용

 

*️⃣ 결론

  • 위에 정리된 내용을 토대로,
    어떠한 상황에서 어떻게 싱글톤 구현을 하는 게 '가장 적절한지' 시야가 좀 더 넓어지게 됐다

  • 돌이켜보니 멘토링 1회 차 때 멘토님께서 첫 질문을 static으로 시작해 주셨고
    부드러운 과정으로 싱글톤 패턴까지 상세하게 답변 주셨다
    • 위에 정리한 개념보다 자세하면 자세했지, 정말 모든 걸 말씀해 주셨다

    • 내가 이렇게까지 다시 개념을 정리해야 할 필요성이 있는 것은
      애초에 현재 내가 멘토링을 잘 흡수할 수 있는 능력이 부족한 상황이기 때문이다
  • 여기서 명심해야 할 것은 대부분의 정리가 문제에 대한 원인을 파악한 것뿐이지,
    내가 스스로 해결 방식을 찾아낸 게 아니기 때문에 온전히 이해했다고 착각하면 안 된다

  • static 키워드로 시작해 싱글톤 패턴 구현에 대한 질문은 최소한 이 정도의 깊이까지는 답변할 수 있도록 준비하자
    (마치 내가 위와 같은 문제들을 맞닥뜨려 이러한 해결을 해본 것처럼)