클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다.
이유는 타입을 인터페이스
로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면 싱글턴 인스턴스를 가짜(MOCK) 구현으로 대체할 수 없기 때문이다.
두 방식 모두 생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로
public static 멤버
를 하나 마련
public class Elvis {
// 유일한 인스턴스를 생성
public static final Elvis INSTANCE = new Elvis();
// 생성자를 private으로 설정하여 외부에서 인스턴스를 생성하지 못하게 함
private Elvis() {
// 초기화 코드 (필요한 경우)
}
// Elvis가 떠나는 행동을 정의하는 메서드
public void leaveTheBuilding() {
// 구현 코드
}
}
private 생성자는 public static final 필드인 Elvis.INSTANCE를 초기화할 때 딱 한 번만 호출된다. public이나 protected 생성자가 없으므로 Elvis 클래스가 초기화될 때 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장된다.
private
또는 protected
로 설정된 필드나 메서드에 접근할 수 있게 해준다. 즉, 일반적으로 접근이 제한된 요소에 접근할 수 있도록 허용한다.private
생성자를 호출할 수 있다. 이 경우, 싱글턴 패턴의 의도가 무시되고, 여러 개의 인스턴스가 생성될 수 있다.이러한 공격을 방어하려면, 생성자를 수정하여 두 번째 객체가 생성되려 할 때 예외를 던지게 하면 됨
public class Elvis {
// 유일한 인스턴스를 생성
private static final Elvis INSTANCE = new Elvis();
// private 생성자
private Elvis() {
// 초기화 코드 (필요한 경우)
}
// 인스턴스를 반환하는 메서드(새로)추가됨
public static Elvis getInstance() {
return INSTANCE;
}
// Elvis가 떠나는 행동을 정의하는 메서드
public void leaveTheBuilding() {
// 구현 코드
}
}
Elvis.getInstance
는 항상 같은 객체의 참조를 반환하므로 제2의 Elvis 인스턴스란 결코 만들어지지 않다(역시 리플렉션을 통한 예외는 똑같이 적용된다)
import java.util.HashMap;
import java.util.Map;
public class Elvis {
// 인스턴스를 저장할 맵
private static final Map<Long, Elvis> instances = new HashMap<>();
// private 생성자
private Elvis() {
// 초기화 코드
}
// 정적 메서드: 스레드 ID를 기준으로 인스턴스를 반환
public static Elvis getInstance() {
long threadId = Thread.currentThread().getId();
return instances.computeIfAbsent(threadId, id -> new Elvis());
}
public void leaveTheBuilding() {
// 행동 구현
}
}
instances
라는 Map
을 사용하여 각 스레드에 대한 인스턴스를 저장한다. 스레드 ID를 키로 사용한다.getInstance()
메서드는 현재 스레드의 ID를 가져와서, 해당 스레드에 대한 인스턴스가 이미 존재하는지 확인한다. 존재하지 않으면 새 인스턴스를 생성하고 저장Elvis
인스턴스를 가질 수 있다.제네릭 싱글턴 팩터리
로 만들 수 있다. : 정적 팩터리의 메서드 참조를 공급자(supplier)
로 사용할 수 있다는 점이다.import java.util.function.Supplier;
public class Elvis {
private static final Map<Long, Elvis> instances = new HashMap<>();
private Elvis() {
// 초기화 코드
}
public static Elvis getInstance(Supplier<Elvis> supplier) {
long threadId = Thread.currentThread().getId();
return instances.computeIfAbsent(threadId, id -> supplier.get());
}
public void leaveTheBuilding() {
// 행동 구현
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Elvis elvis = Elvis.getInstance(Elvis::new); // 공급자로서의 메서드 참조
elvis.leaveTheBuilding();
}
}
🧐 참고로 왜 API를 바꾸지 않고도 싱글턴을 변경하는 방법, API를 바꾸지 않고도 여러 인스턴스를 반환할 수 있도록 변경하는 것은 결국 코드를 바꾸는 건데 의미가 있어?
AI 답변
좋은 질문입니다. "API를 바꾸지 않고도"라는 표현은 기존 인터페이스(메서드 시그니처 등)를 유지하면서 내부 구현을 변경할 수 있다는 의미입니다. 즉, 외부에서 사용하는 코드에 영향을 주지 않고, 클래스의 동작 방식을 바꿀 수 있다는 점에서 의미가 있습니다.
getInstance()
호출 방식은 그대로 유지됩니다.예를 들어, 기존에 Elvis.getInstance()
를 사용하던 코드가 있다고 가정해 보겠습니다. 이 코드는 변경하지 않고도, 내부 구현을 통해 스레드별로 다른 인스턴스를 관리하게 할 수 있습니다.
Elvis elvis1 = Elvis.getInstance();
Elvis elvis2 = Elvis.getInstance();
위 코드는 여전히 동일하게 작동하지만, 내부 구현에서는 이제 각 스레드에 대해 다른 Elvis
인스턴스를 제공하게 됩니다. 외부 코드는 전혀 변경되지 않지만, 내부 동작은 훨씬 더 유연해진 것입니다.
결론적으로, API를 변경하지 않고도 내부 구현을 수정하는 것은 코드의 유연성과 유지보수성을 높이는 좋은 방법입니다. 이는 장기적으로 시스템의 확장성과 안정성을 보장하는 데 큰 도움이 됩니다.
메모리 주소가 똑같은 한 개의 인스턴스만 있어야 하는데 직렬화 후 역직렬화를 하게 되면, 완전 다른 인스턴스가 생성되게 되버리는 거임
Serializable 구현: 싱글턴 클래스를 직렬화하려면 Serializable
인터페이스를 구현해야 한다. 그러나 이 인터페이스를 구현하는 것만으로는 충분하지 않다.
transient 필드: 모든 인스턴스 필드를 transient
로 선언해야 하며, 이는 직렬화 시 해당 필드가 저장되지 않도록 한다.
readResolve 메서드: 직렬화된 객체를 역직렬화할 때마다 새로운 인스턴스가 생성되는 것을 방지하기 위해 readResolve
메서드를 제공한다. 이 메서드는 역직렬화 과정에서 호출되어, 진짜 싱글턴 인스턴스를 반환하게 된다.
//싱글턴임을 보장해주는 readResolve 메서드
private Object readResolve() {
return INSTANCE; // 진짜 Elvis 인스턴스를 반환 가짜 Elvis는 가비지 컬렉터에게
}
이 메서드는 가짜 Elvis
인스턴스가 생성되는 것을 방지하고, 기존의 INSTANCE
를 반환하여 원래의 싱글턴을 유지한다. 특정 클래스에서 사용할 수 있으며, 직렬화와 역직렬화를 지원하는 클래스에서 readResolve
메서드를 정의하면, 해당 클래스의 인스턴스가 역직렬화될 때 호출된다.
✔️ "진짜 Elvis 인스턴스를 반환하고 가짜 Elvis는 가비지 컬렉터에게"라는 표현
주로 싱글턴 패턴을 설명할 때 사용된다. 여기서 "진짜 Elvis"는 실제로 사용해야 하는 유일한 인스턴스를 의미하고, "가짜 Elvis"는 불필요하게 생성된 인스턴스를 의미합
이 문구는
readResolve
메서드의 역할을 강조한다. 역직렬화 과정에서readResolve
메서드가 호출되면, 가짜 인스턴스가 아닌 진짜 인스턴스를 반환하여, 불필요한 인스턴스가 생성되는 것을 방지한다. 가짜 인스턴스는 더 이상 필요 없게 되어 가비지 컬렉터에 의해 수거될 수 있다. 즉, 메모리 관리 측면에서 불필요한 객체를 제거하여 자원을 효율적으로 사용할 수 있게 된다.
열거 타입: 싱글턴을 구현하는 세 번째 방법으로, 원소가 하나인 열거 타입을 사용하는 것입니다. 아래와 같이 선언할 수 있습니다.
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() {
// 메서드 구현
}}
장점:
제한사항: 열거 타입은 다른 클래스를 상속할 수 없기 때문에, 싱글턴이 다른 클래스를 상속해야 할 경우에는 사용할 수 없다. 그러나 열거 타입은 다른 인터페이스를 구현할 수 있다.
결론적으로, readResolve
메서드를 사용하여 직렬화 시 진짜 인스턴스를 반환함으로써 싱글턴을 보장하고, 원소가 하나인 열거 타입을 사용하여 간결하고 안전하게 싱글턴을 구현하는 방법이 있다. 이 두 가지 방법은 각각의 상황에 맞게 사용할 수 있으며, 특히 열거 타입은 복잡한 직렬화와 리플렉션 공격에 대한 안전성을 제공하는 최선의 선택으로 여겨진다.
++ 스터디에 추가
class Singleton {
// 싱글톤 클래스 객체를 담을 인스턴스 변수
private static Singleton instance;
// 생성자를 private로 선언 (외부에서 new 사용 X)
private Singleton() {}
// 외부에서 정적 메서드를 호출하면 그제서야 초기화 진행 (lazy)
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 오직 1개의 객체만 생성
}
return instance;
}
}
제공하신 Singleton
클래스는 기본적인 싱글톤 패턴을 구현하고 있지만, 멀티스레드 환경에서 안전하지 않은 문제가 있다. 여러 스레드가 동시에 getInstance()
메서드를 호출할 경우, instance
가 여러 번 초기화될 수 있는 위험이 있다.
저 코드에서 문제
해결방법
가장 간단한 방법은 getInstance()
메서드에 synchronized
키워드를 추가하여 동기화하는 것입니다. 이렇게 하면 한 번에 하나의 스레드만 메서드에 접근할 수 있습니다.
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
class Singleton {
// 싱글톤 클래스 객체를 담을 인스턴스 변수
private static Singleton instance;
// 생성자를 private로 선언 (외부에서 new 사용 X)
private Singleton() {}
// 외부에서 정적 메서드를 호출하면 그제서야 초기화 진행 (lazy)
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 오직 1개의 객체만 생성
}
return instance;
}
}
매번 동기화하면... 성능 문제가 생길 수 있음 모든 스레드가 동기화를 하게 됨..
성능을 개선하기 위해 이중 검사 잠금을 사용할 수 있습니다. 이 방법은 synchronized
블록을 최소화하여 성능을 향상시킵니다
여기서 질문?
동기화
를 선택적으로 하기 위해? 처음 instance는 여러 스레드가 대기상태에 걸릴 수 있는데... 그 다음 부터 들어오는 스레드들은 대기 상태가 아니고 빠르게 넘어갈 수 있어서..?public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) { //최신 값으로 동기화가 가능
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
class Singleton {
private static volatile Singleton instance; // volatile 키워드 적용
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
// 메서드에 동기화 거는게 아닌, Singleton 클래스 자체를 동기화 걸어버림
synchronized (Singleton.class) {
if(instance == null) {
instance = new Singleton(); // 최초 초기화만 동기화 작업이 일어나서 리소스 낭비를 최소화
}
}
}
return instance; // 최초 초기화가 되면 앞으로 생성된 인스턴스만 반환
}
}
정적 초기화 블록을 사용하여 클래스가 로드될 때 인스턴스를 생성하는 방법도 있습니다. 이 방법은 스레드 안전하며, 간단하게 구현할 수 있습니다.
public class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
class Singleton {
private Singleton() {}
// static 내부 클래스를 이용
// Holder로 만들어, 클래스가 메모리에 로드되지 않고 getInstance 메서드가 호출되어야 로드됨
private static class SingleInstanceHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingleInstanceHolder.INSTANCE;
}
}
Java에서는 enum
을 사용하여 싱글톤을 구현하는 방법도 있습니다. 이 방법은 직렬화와 리플렉션 공격에 대해 안전합니다.
public enum Singleton {
INSTANCE;
// 필요한 메서드 추가
public void someMethod() {
// ...
}
}
리플렉션까지 방어한 것
import java.io.ObjectStreamException;
import java.io.Serializable;
public class Singleton implements Serializable {
// 직렬화 방지를 위해 serialVersionUID를 명시적으로 선언
private static final long serialVersionUID = 1L;
// 생성자에서 이미 객체가 생성된 경우 예외를 던져 Reflection을 통한 추가 인스턴스 생성을 방지
private Singleton() {
if (SingleInstanceHolder.INSTANCE != null) {
throw new IllegalStateException("Singleton instance already created.");
}
}
// static 내부 클래스를 이용한 Singleton 구현
private static class SingleInstanceHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingleInstanceHolder.INSTANCE;
}
// 역직렬화 시 Singleton 보장 (기존 인스턴스를 반환)
private Object readResolve() throws ObjectStreamException {
return SingleInstanceHolder.INSTANCE;
}
}