일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 정적중첩클래스
- 지네릭스
- 일급컬렉션
- 클라이언트사이드렌더링
- SOLID
- java
- Spring
- 서버사이드렌더링
- DI
- Velog
- 비정적중첩클래스
- 더티채킹
- 항해99
- 변경감지
- 인수테스트
- 스프링컨테이너
- 항해99 9기
- bean
- IoC
- refreshtoken
- 애너테이션
- 다형성
- 9기
- 인프콘
- 싱글톤패턴
- 스파르타코딩클럽
- 자바의정석
- privateapi
- github actions
- publicapi
- Today
- Total
멈재
[디자인 패턴] 싱글턴 패턴, 내가 알던 싱글턴이 아니야! 본문
싱글턴 패턴은 개념과 원리 그리고 구현 코드 자체도 간단하고, 스프링 컨테이너에 적용된 만큼 많은 사람들이 익히 알고 있는 패턴이다.
하지만 그만큼 다소 위험한 예시 코드들을 접하게 되면서 위험성을 가진 코드들을 알기도 쉬워졌다.
나 또한 그런 사람들 중 한 명이었고, 이번 기회에 싱글턴 클래스를 만들 때 주의해야 점을 다뤄보려 한다.
싱글턴 패턴이란
어떤 클래스의 인스턴스가 오직 하나임을 보장하며, 전역적으로 해당 인스턴스에 접근할 수 있도록 하는 제공하는 패턴
싱글턴 패턴을 쓰는 이유는
한 번의 new로 인스턴스를 사용하기 때문에 메모리 낭비를 방지할 수 있다.
싱글턴의 인스턴스는 전역 인스턴스이기 때문에 데이터를 공유하기 쉽다.
그렇다면, 싱글턴 패턴은 어떨 때 사용하는 것이 적합할까
애플리케이션에서 특정 객체가 단 하나만 존재해야 할 때
이 특정 객체를 여러 부분에서 공유하며 사용해야 할 때
예시
- 현실 세계: 사무실에 프린터기가 1대만 존재할 때, ...
- 객체 세계: JDBC 커넥션 풀, ...
흔히 아는 싱글턴 패턴의 문제점
일반적으로 싱글턴 패턴을 구현하는 코드
public class Bottle {
private static Bottle instance = new Bottle();
private Bottle() {}
public static Bottle getInstance() {
return instance;
}
}
아마 싱글턴 클래스를 만드는 방식으로 대부분 위와 같은 방식으로 알고 있을 것이다.
그러나 위 코드는 크나 큰 문제점을 안고 있다.
그것은 멀티 스레드 환경에서 Thread-Safe을 보장해주지 않는다는 것이다.
또 다른 흔히 아는 싱글턴 클래스를 만드는 방식이 있다.
public class Bottle {
private static Bottle instance;
private Bottle() {}
public static Bottle getInstance() {
if(instance == null) {
instance = new Bottle();
}
return instance;
}
}
위 방식 또한 멀티 스레드 환경에서 Thread-Safe를 보장하지 않아 동시성(Concurrency) 문제가 발생하게 된다.
테스트를 해보자.
public class BottleTest {
public static void main(String[] args) {
Runnable runnable = new Creator();
new Thread(runnable).start();
new Thread(runnable).start();
}
}
class Creator implements Runnable {
@Override
public void run() {
System.out.println("Bottle.hashCode = " + Bottle.getInstance().hashCode());
}
}
// hashCode: 동일한 객체는 동일한 해시 코드를 반환
Bottle.hashCode = 1538421013
Bottle.hashCode = 744423849
두 개의 스레드가 getInstance의 if조건문에 동시에 도달할 경우 인스턴스가 두 번 생성되는 문제점이 발생되게 된다.
이처럼 흔히 아는 싱글턴 패턴에는 멀티 스레드 방식에서 동시성 문제가 발생할 수 있는 위험성이 존재한다.
어떻게 멀티 스레드에서도 안전한(Thread-Safe) 싱글턴 클래스를 만들 수 있을까
앞으로 설명할 방식들의 결과는 아래 코드로 실행한 결과이다.
public class BottleTest { public static void main(String[] args) { Runnable runnable = new Creator(); new Thread(runnable).start(); new Thread(runnable).start(); new Thread(runnable).start(); new Thread(runnable).start(); new Thread(runnable).start(); new Thread(runnable).start(); new Thread(runnable).start(); new Thread(runnable).start(); new Thread(runnable).start(); new Thread(runnable).start(); } }
1. 이른 초기화 (Eager Initialization)
이른 초기화 방식은 static 키워드의 특징을 이용해서 클래스 로더가 초기화하는 시점에 정적 바인딩을 통해 해당 인스턴스를 메모리에 등록하는 방법이다.
정적 바인딩(static binding)이란 컴파일 타임에 성격이 결정되는 것을 의미한다.
class Bottle {
private static Bottle instance = new Bottle();
private Bottle() {}
public static Bottle getInstance() {
return instance;
}
}
즉, 클래스 로더에 의해 클래스가 최초로 로딩될 때 객체가 생성되기 때문에 Thread-safe 하다.
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
Bottle.hashCode = 123687385
그러나, static 멤버를 지금 당장 객체를 사용하지 않더라도 메모리에 적재하기 때문에 리소스가 크다면 공간, 자원 낭비로 이어질 수 있다.
2. 게으른 초기화 (Lazy Initialization With Synchronized)
게으른 초기화라는 이름처럼 컴파일 시점에 인스턴스를 생성하는 것이 아닌 인스턴스가 필요한 시점에 요청하여 동적 바인딩을 통해 인스턴스를 생성하는 방식이다.
동적 바인딩(dynamic binding)이란 런타임에 성격이 결정되는 것을 의미한다.
게으른 초기화 방식은 synchronized 키워드를 이용하여 메서드에 동기화 블럭을 지정해 Thread-safe를 보장한다.
class Bottle {
private static Bottle instance;
private Bottle() {}
public static synchronized Bottle getInstance() {
if(instance == null) {
instance = new Bottle();
}
return instance;
}
}
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
Bottle.hashCode = 891375376
그러나 동기화 블럭을 지정해서 Thread-Safe 하도록 만들었지만 인스턴스가 생성되었든, 안되었든 무조건 동기화 블럭을 거치게 된다. 이것은 synchronized 특성상 잠금을 획득해야 하기 때문에 비교적 큰 성능저하가 발생하므로 권장하지는 않는다.
[ 요약 설명 ]
synchronized
스레드의 동기화를 하는 기능으로 공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정하여 하나의 스레드만 해당 영역에 접근 가능하게 한다.
적용 방법
1. 메서드 전체에 임계 영역으로 지정
2. 특정 영역을 임계 영역으로 지정
3. 게으른 초기화 + DCL(Double-Checked Locking)
2번의 게으른 초기화 방식에서 동기화 블록을 개선한 것으로 인스턴스가 생성되지 않은 경우에만 동기화 블록이 실행되게끔 구현하는 방식이다.
class Bottle {
private static volatile Bottle instance;
private Bottle() {}
public static Bottle getInstance() {
if(instance == null) {
synchronized (Bottle.class) {
if(instance == null) {
instance = new Bottle();
}
}
}
return instance;
}
}
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
Bottle.hashCode = 296740810
메서드 전체를 임계 영역으로 지정하는 것이 아닌 첫 번째 if 조건문에서 인스턴스의 존재 여부를 확인하고, 두 번째 if 조건문에서 다시 한번 체크하는 영역을 임계 영역으로 지정해서 인스턴스를 생성하는 방식이다.
따라서 처음 생성 이후에 synchronized 블록을 타지 않기 때문에 성능 저하를 완화했다.
volatile (Java 1.4 이하 버전과 호환되지 X)
volatile는 캐시와 메모리 간에 값의 불일치를 해결하는 키워드이다.
해당 내용을 담기에는 포스팅의 주제와 맞지 않을 것 같아서 생략하였습니다.
4. 게으른 홀더 (LazyHolder)
싱글턴 구현 방식으로 가장 많이 사용되는 방식으로 volatile이나 synchronized 키워드 없이도 동시성 문제를 해결하기 때문에 성능이 뛰어나다. 즉, 자바 버전을 타지 않으며, 성능도 준수하다.
(이 방식은 LazyHolder라는 용어 말고도 Initialization on demand holder idion, static holder pattern, Bill Pugh Solution로도 불린다.)
클래스 안에 클래스(Holder)를 두어, JVM의 Class loader(static initializer) 메커니즘과과 클래스가 Load 되는 시점을 이용한 Lazy Loading 방식이다.
class Bottle {
private static class BottleHolder {
private static final Bottle instance = new Bottle();
}
private Bottle() {}
public static Bottle getInstance() {
return BottleHolder.instance;
}
}
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
Bottle.hashCode = 415091965
BottleHolder안에 선언된 인스턴스가 static이기 때문에 클래스 로딩시점에 한 번만 호출되게 되고, final을 사용해 다시 값이 할당되지 않게 된다.
그러나 지금까지의 방식들은 Reflection 공격이나 직렬화된 인스턴스를 역직렬화할 때마다 새로운 인스턴스가 만들어지는 문제가 존재한다.
private static void attackReflection() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Bottle getBottle = Bottle.getInstance();
Constructor<? extends Bottle> bottleConstructor = getBottle.getClass().getDeclaredConstructor();
bottleConstructor.setAccessible(true);
Bottle newBottle = bottleConstructor.newInstance();
System.out.println("getBottle.hashCode() = " + getBottle.hashCode());
System.out.println("newBottle.hashCode() = " + newBottle.hashCode());
}
[실행결과]
getBottle.hashCode() = 775522127
newBottle.hashCode() = 762218386
이러한 경우에는 Joshua Bloch의 Effective Java에서 소개된 열거 타입(Enum) 방식으로 싱글턴을 보장하면 된다.
이 방법도 좋은 방법 중 하나이고, 직렬화/역직렬화 문제와 Reflection 공격에도 안전하다는 장점이 있다.
이펙티브 자바 3/E 아이템 3 private 생성자나 열거 타입으로 싱글턴임을 보증하라를 참고
++ 23.04.27 예시 추가)
자바에서 열거형은 일종의 클래스로 취급되고, 기본적으로 열거 인자들이 public static final로 정의된다.
또한, 열거형은 기본 생성자로 private 생성자를 가지기 때문에 인스턴스를 생성할 수 없다.
마찬가지로 멀티쓰레드인 환경에서도 싱글톤을 보장한다.
[실행 결과]
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
EnumBottle.hashCode = 170632139
과연 열거형은 Reflection으로 새로운 인스턴스 생성을 방지할 수 있을까
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
public class EnumBottleTest {
public static void main(String[] args) throws InvocationTargetException, InstantiationException, IllegalAccessException, NoSuchMethodException {
EnumBottle bottle = EnumBottle.INSTANCE;
Constructor<?> bottleConstructor = bottle.getClass().getDeclaredConstructors()[0];
bottleConstructor.setAccessible(true);
EnumBottle newBottle = (EnumBottle) bottleConstructor.newInstance(); // line 12
System.out.println("getBottle.hashCode() = " + bottle.hashCode());
System.out.println("newBottle.hashCode() = " + newBottle.hashCode());
}
}
enum EnumBottle {
INSTANCE
}
열거형은 리플렉션을 통해 getConstructor -> newInstance의 흐름으로 객체 생성이 불가능하게 되어있다.
따라서 열거형을 인스턴스화할 때 IllegalArgumentException과 함께 열거형 객체를 생성할 수 없다는 예외 메시지를 받게 된다.
https://docs.oracle.com/javase/tutorial/reflect/special/enumTrouble.html
뿐만 아니라, 열거형은 직렬화/역직렬화 문제에도 안전하여 동일한 인스턴스를 반환한다.
public class EnumBottleSerializableTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
EnumBottle bottle = EnumBottle.INSTANCE;
String file = "bottle.obj";
// 직렬화
ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)));
out.writeObject(bottle);
out.close();
// 역직렬화
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(file)));
EnumBottle newBottle = (EnumBottle) in.readObject();
in.close();
System.out.println("bottle = " + bottle.hashCode());
System.out.println("newBottle = " + newBottle.hashCode());
}
}
[실행 결과]
bottle = 1908316405
newBottle = 1908316405
열거형은 자신을 복제하여 새로운 인스턴스를 생성하는 clone 메서드를 지원하지 않고 있다.
이러한 여러가지 이유들로 열거형은 싱글톤을 보장한다.
끝으로,
싱글턴을 학습하면서 궁금한 점이 있었다.
가장 마지막 예시였던 게으른 홀더 방식의 경우 클래스 안에 클래스가 존재하는 정적 중첩 클래스 방식이었다.
class Bottle {
private static class BottleHolder {
private static final Bottle instance = new Bottle();
}
private Bottle() {}
public static Bottle getInstance() {
return BottleHolder.instance;
}
}
이때, BottleHolder의 인스턴스는 Bottle이 클래스가 로딩되는 시점에 호출될지 아니면 Bottle#getInstance를 실행했을 때 호출될지에 대한 것이었다.
그래서 보다 명시적인 네이밍을 한 예시를 만들어서 테스트를 해보았다.
public class LoadTest {
public static void main(String[] args) {
OuterClass outerClass = new OuterClass();
}
}
class OuterClass {
public OuterClass() {
System.out.println("OuterClass!!");
}
private static class InnerClass {
public InnerClass() {
System.out.println("NestedClass!!");
}
}
}
놀랍게도,,,,,
[실행결과]
OuterClass!!
InnerClass는 호출되지 않았다는 것이다.
왜 이런 결과가 나온걸까??
클래스 로더가 클래스(xxx.class) 파일을 로딩하는 순서는 다음과 같이 3단계로 구성된다.
- Loading(로드): 클래스 파일을 가져와서 JVM의 메모리에 로드한다.
- Linking(링크): 클래스 파일을 사용하기 위해 검증하는 과정이다.
- Initialization(초기화): 클래스 변수들을 적절한 값으로 초기화한다.
그런데..
클래스 파일을 메모리에 올리는 Loading 기능은 한 번에 메모리에 올리지 않고, 애플리케이션에서 필요한 경우 동적으로 메모리에 적재한다는 것이다.
아마 이러한 이유 때문에 4번째 방식의 이름이 게으른(Lazy) 홀더라는 이름이 붙게 된 것 같다.
클래스 로더에 대한 건 갓파... 님이 잘 정리해놓으신 글이 있어서 아래 링크를 참고하면 더 잘 이해가 될 것 같다.
클래스는 언제 메모리에 로딩 & 초기화 되는가?
참고
'JAVA & Spring & JPA' 카테고리의 다른 글
[Spring/JPA] JPA flush, 편하게 써도 되는걸까 (0) | 2023.05.21 |
---|---|
[JAVA] 중첩 클래스, 중첩 클래스의 문단속 (0) | 2023.04.29 |
[JAVA] 자바 애너테이션과 활용 예시 (Annotation) (0) | 2023.03.26 |
[Java] 정확한 답이 필요하다면 float와 double은 피하라 (1) | 2023.03.10 |
[JAVA] 20. 동일성(identity)과 동등성(equality) (0) | 2023.01.08 |