독서

[디자인 패턴의 아름다움] ch 6. 생성 디자인 패턴

오렌지색 귤 2025. 4. 6. 21:06
반응형

A. 싱글턴, 명령어 재정렬

p. 257

CPU 명령이 재정렬되면 IdGenerator 클래스의 객체가 new 예약어를 통해 instance 멤버 변수가 지정된 후, 초기화가 이루어지기 전에 다른 스레드에서 이 객체를 사용하려고 할 수 있다. 이 문제를 해결하려면 volatile 키워드를 인스턴스 멤버 변수에 추가하여 명령어 재정렬을 방지하면 된다.

 

명령어 재정렬과 싱글턴의 안전한 초기화

 

1. 문제 제기: 멀티스레드 환경에서의 싱글턴 초기화 문제

 

싱글턴 패턴은 애플리케이션 전체에서 인스턴스를 하나만 유지해야 할 때 사용된다.
하지만 멀티스레드 환경에서는 인스턴스를 생성하는 과정에서 예상치 못한 문제가 발생할 수 있다.

대표적인 예는 다음과 같다

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();  // 💥 여기서 문제가 발생할 수 있음
        }
        return instance;
    }
}

 

위 코드는 여러 스레드가 동시에 getInstance()를 호출하면 동시에 인스턴스를 생성하려고 시도할 수 있다.
그로 인해 초기화되지 않은 객체가 다른 스레드에 노출되는 문제가 발생한다.

 

 

2. 근본 원인: CPU 명령어 재정렬 (Instruction Reordering)

 

현대 CPU와 JVM은 성능 최적화를 위해 명령어를 재배치할 수 있다.
다음과 같은 문장이 있다고 가정하자.

instance = new Singleton();

 

이 한 줄은 내부적으로 세 단계로 나뉜다:

  1. 메모리 할당
  2. 생성자 호출 (필드 초기화 등)
  3. instance 변수에 주소 저장

그러나 CPU나 JVM은 이를 다음과 같이 순서를 바꿔 실행할 수 있다:

  1. 메모리 할당
  2. instance에 주소 저장
  3. 생성자 호출

이 경우, 다른 스레드가 instance != null이라는 조건을 만족하여 접근하게 되면
초기화가 끝나지 않은 객체를 사용할 위험이 있다.

 

 

3. 해결 방법: volatile 키워드의 활용

 

Java에서 이러한 문제를 해결하려면 volatile 키워드를 활용한다.

private static volatile Singleton instance;

 

volatile은 다음 두 가지를 보장한다:

  1. 가시성(Visibility): 모든 스레드가 항상 최신 값을 읽도록 강제한다.
  2. 명령어 재정렬 금지(Reordering Ban): JVM과 CPU 모두 해당 변수에 대해 재정렬을 금지한다.

이로써, instance = new Singleton() 문장이 반드시 “할당 → 생성자 → 참조 저장”의 순서로 실행되도록 보장된다.

 

 

4. 보다 안전한 대안: Kotlin의 object 싱글턴

 

Kotlin은 싱글턴을 선언할 수 있는 object 키워드를 제공한다.

object IdGenerator {
    private var lastId = 0

    fun generateId(): Int {
        return ++lastId
    }
}

 

이 구조는 Java의 싱글턴보다 간단하며, 내부적으로 스레드 안전성도 보장된다.

 

 

5. JVM이 제공하는 보장: 클래스 초기화의 스레드 안전성

 

Kotlin의 object는 JVM에서 다음과 같이 컴파일된다:

public final class IdGenerator {
    public static final IdGenerator INSTANCE;

    static {
        INSTANCE = new IdGenerator();
    }
}

 

여기서 중요한 점은 JVM의 클래스 초기화 시점에 대한 보장이다.

JVM 스펙(JLS §12.4.2)에서는 다음과 같이 명시하고 있다:

“The Java Virtual Machine guarantees that a class's <clinit> method is executed at most once and that it is thread-safe.”

 

즉, 클래스 초기화 블록(<clinit>)은 JVM이 다음과 같이 처리한다:

  1. 클래스가 처음 참조될 때 <clinit> 실행
  2. JVM이 내부적으로 monitor enter/exit로 락을 건다
  3. 다른 스레드는 초기화가 끝날 때까지 대기
  4. 초기화가 끝나면 memory barrier를 통해 변경된 값을 공유 메모리에 반영
  5. 이후 접근하는 모든 스레드는 완전히 초기화된 객체만 보게 된다

이 모든 과정은 JVM이 자동으로 처리하므로, object는 volatile, synchronized, 더블 체크 락킹(DCL) 없이도 안전하게 사용할 수 있다.

 

 

 

B. 생각해보기

6.1.6

프로젝트에서 싱글턴 패턴을 사용한 코드가 다음과 같으면 리팩터링 시 코드의 변경을 최소화하면서 테스트 용이성을 향상시키는 방법에는 어떤 것이 있는지 생각해보자.
public interface UserCache {
    User getUser(long userId);
}

public class DefaultUserCache implements UserCache {
    @Override
    public User getUser(long userId) {
        return CacheManager.getInstance().getUser(userId);
    }
}

public class Demo {
    private final UserRepo userRepo;
    private final UserCache userCache;

    public Demo(UserRepo userRepo, UserCache userCache) {
        this.userRepo = userRepo;
        this.userCache = userCache;
    }

    public boolean validateCachedUser(long userId) {
        User cachedUser = userCache.getUser(userId);
        User actualUser = userRepo.getUser(userId);
        // 비교하는 로직
    }
}

public class DemoFactory {
    public static Demo createDefault() {
        return new Demo(new UserRepo(), new DefaultUserCache());
    }
}

 

 

6.2.5

이번 절에서는 싱글턴 패턴에서 유일성의 범위가 프로세스에 한정된다고 언급했는데, 사실 java의 경우 더 엄밀히 말하면 싱글턴 패턴의 유일성의 범위는 프로세스 안이 아닌 클래스 로더 안에 있다. 왜 그런지 생각해보자

 

Java에서 동일한 클래스를 서로 다른 클래스 로더가 로딩하면, 동일한 클래스처럼 보여도 전혀 다른 클래스로 취급된다.

→ 따라서 싱글턴 인스턴스도 클래스 로더마다 하나씩 생성될 수 있다.

 

대표 사례: Java EE / Servlet 컨테이너 / OSGi

  • 웹 애플리케이션(WAR)마다 서로 다른 ClassLoader를 사용함
  • 각 WAR 안에서 MySingleton.INSTANCE가 따로 존재함
  • 같은 서버 내 여러 WAR에서 동일한 클래스라도 싱글턴이 공유되지 않음

Spring Boot에서도

  • devtools 활성화하면 reload용 ClassLoader가 별도로 존재
  • reload 전후로 싱글턴 인스턴스가 달라질 수 있음

 

C. getBean 메서드

p. 291

Spring의 BeanFactory 혹은 ApplicationContext에서 getBean()을 호출하면 필요한 경우 내부적으로 createBean()을 통해 객체를 생성하는데, SRP 원칙을 위배하지는 않았는가? 네이밍이 잘못된 것은 아닌가?

 

문제 제기

Spring에서 다음과 같은 코드를 자주 사용한다.

MyBean bean = context.getBean(MyBean.class);

 

이때 getBean()은 내부적으로 다음과 같이 동작한다:

  • 빈이 이미 존재하면 → 반환
  • 빈이 존재하지 않으면 → createBean()을 호출해 새로 생성 후 반환

표면적으로는 getBean()이 조회(get) 역할처럼 보이지만, 실제로는 생성(create) 로직까지 포함하고 있다.

 

 

SRP (위배 X)

BeanFactory의 책임

  • 요청된 빈을 반환
  • 빈이 없을 경우 생성 및 초기화
  • 의존성 주입, 후처리기 적용 등 생명주기 관리

이 모든 기능은 "빈의 생명주기 관리"라는 하나의 책임 아래에 있다.

 

이름이 주는 혼란

프로그래머 입장에서는 getBean()이라는 이름이 단순 조회(get)로 느껴져서 "왜 생성(create)이 섞이지?"라는 혼란이 생길 수 있습니다.
이건 설계 상의 위배는 아니지만, API의 직관성과 관련된 Naming 문제로 볼 수 있습니다.

→ 하지만 "IoC 컨테이너에서 get은 필요 시 create도 포함한다"는 것이 일반적인 컨벤션입니다.

 

 

반응형