배경
스프링 프레임워크를 사용하는 개발자라면 싱글턴 패턴에 익숙하다. 스프링의 코어 DI구현 기술로 스프링 빈(Bean)이 싱글턴 스코프를 디폴트로 갖기 때문이다. 싱글턴 패턴은 "단 하나의 인스턴스를 전역에 제공"하기 때문에 인스턴스의 생성과 사용에 아우르는 동시성 이슈가 발생할 수 있다.
동시성 이슈는 하나의 공유자원을 여러 연산 주체가 접근하면서 발생하는 문제로 여겨진다. 헤드 퍼스트 디자인 패턴 책을 통해 싱글턴 패턴을 학습할때 동시성 이슈의 초점은 인스턴스 생성제한에 맞춰져 있음을 알게되었다. 따라서, 이 글에서는 싱글턴 패턴의 개념과 발생할 수 있는 동시성 이슈 및 해결 방법에 대해 서술해보려고 한다.
싱글턴 패턴

먼저 싱글턴(singleton)의 사전적 의미를 살펴본다. 해석해 보면 "고려 중인 종류(kind) 중에 단 하나(thing)"를 의미한다. 싱글턴 패턴의 정의도 이와 크게 다르지 않다.
"싱글턴 패턴은 클래스의 인스턴스를 단 하나만 생성하도록 보장하고, 그 인스턴스를 전역적으로 접근할 수 있는 방법을 제공하는 패턴이다."
이 정의에는 다음 두 가지 핵심 키워드들이 있다:
- 인스턴스 생성 제한
- 전역 접근
인스턴스 생성 제한
클래스는 본래 여러 인스턴스를 만들기 위한 청사진, 붕어빵 뜰에 비유된다. 하지만, 하나를 초과하는 인스턴스 생성을 제한함으로 이를 의도적으로 깨서 만들어진 패턴이 바로 싱글턴 패턴이다.
도대체 무슨 이점이 있기 때문에 인스턴스 생성을 제한하는걸까? 질문을 반대로 돌려 묻는다면, 언제 하나의 인스턴스만 필요할까?
동일한 클래스로 부터 생성된 인스턴스A와 인스턴스B을 구분할 유일한 차이점을 묻는다면 상태 차이다. 상태가 동일한 인스턴스는 동일한 책임(기능)을 수행할 수 밖에 없기 때문이다.
예를 들어, 데이터베이스에 접근하는 인스턴스를 만들었다고 가정한다. 데이터베이스에 접근하기 위해 무엇이 필요할까?
데이터베이스 클라이언트 프로그램을 사용해보면 주소, 아이디, 비밀번호 세 가지를 설정하면 DB와의 커넥션을 성공적으로 맺는것을 확인할 수 있다. 그러면, 동일한 데이터베이스과 커넥션을 맺는다면 하나의 서버에서 굳이 여러 인스턴스를 생성해서 제공할 필요가 있을까?
이 처럼 싱글턴 패턴은 굳이 둘 이상의 인스턴스를 생성할 필요 없는 클래스에 적용할 수 있는 패턴이며, 로직과 초점이 인스턴스 생성에 맞춰져있습니다.
전역 접근
생성된 단 하나의 인스턴스를 호출해서 사용될 수 있도록 전역적으로 접근할 수 있는 포인트를 제공해줘야 합니다.

싱글턴 패턴의 클래스 다이어그램을 살펴봅니다. 자기 자신을 타입으로 가지는 uniqueInstance 정적 변수와 자기 자신을 반환 타입으로 가지는 getInstance() 정적 메서드를 가집니다. 변수와 메서드가 static 키워드를 갖는다는것을 알 수 있습니다. static 키워드를 사용한 이유는 static 키워드가 선언된 변수나 메서드가 인스턴스가 아닌 클래스에 종속되는 개념이라는 사실을 알고 있다면 이해하는데 도움될 수 있습니다. static은 클래스를 인스턴스화할 필요 없이 클래스만으로 접근할 수 있게 해줍니다.
public class Singleton {
private static Singleton uniqueInstance;
// 기타 인스턴스 변수
public static Singleton getInstance() {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
// 기타 메서드
}
Java 코드를 이용해서 고전적인(Classic) 싱글턴 패턴을 구현하면 위와같이 구현해볼 수 있습니다.
주의깊게 살펴볼 부분은 getInstance()메서드입니다. if문에서 uniqueInstance의 참조를 체크하여 null인 경우, 초기화하고 이를 반환합니다. 이 다음 부터 접근하는 스레드들은 이때 생성된 인스턴스를 반환받게 됩니다. 그러면 이러한 구현 방법이 단 하나의 인스턴스 생성을 보장할까요?
동시성 이슈 - 인스턴스 생성
싱글턴 패턴의 정의에 따르면 "단 하나의 인스턴스..." 만 생성되도록 제한을 해줘야 하는데요. 위에서 보여준 전통적인 구현 방법은 이 조건을 쉽게 깨뜨릴 수 있습니다. 이를 이해하기 위해서 동시성 이슈가 없는 케이스와 있는 케이스, 두가지를 아래에서 살펴보려고 합니다.
먼저 동시성 이슈가 없는 케이스 입니다. 시간은 위에서 아래로 흐르며, getInstance() 메서드에 Thread1과 Thread2가 동시에 접근한다고 가정합니다.

Thread1이 메서드에 접근해서 uniqueInstance가 null인지 체크하고, null이기 때문에 Singleton 인스턴스를 생성해서 초기화를 하게 됩니다.Thread2가 메서드에 접근해서 uniqueInstance가 null인지 체크합니다. 앞에서 이미 초기화되었기 때문에 uniqueInstance1을 참조하고 있음을 알 수 있습니다. 따라서, uniqueInstance1을 반환받습니다.
여기서는 아무런 동시성 이슈가 없습니다. 각 스레드가 getInstance()메서드에 차례로 접근하기 때문입니다. 모든 동시성 문제는 이 방법을 이용해서 해결할 수 있습니다. 그럼에도 불구하고 동시성을 고려하는 이유가 무엇일까요? 성능과 동시성의 trade off가 존재하기 때문입니다. 현대 컴퓨터의 CPU는 대부분 멀티코어를 갖춥니다. 이는 여러 연산 주체가 동시다발적으로 처리할 수 있는 능력을 갖고 있다는 의미입니다. 동일한 코드에 여러 연산 주체가 접근하여 동시성을 높여 성능이 좋아질 수 있지만, 부차적으로 발생할 수 있는 이슈들이 있죠.

만약 Thread1과 Thread2가 동시에 접근한다고 가정합니다. 처음 uniqueInstance는 null임을 쉽게 알 수 있죠. 두 스레드가 if 문을 동시에 통과하는 순간 문제가 발생합니다. 이미 if 문 시점에 두 스레드는 모두 uniqueInstance가 null임을 체크했기 때문에 객체 초기화 로직을 각각 겪게 됩니다. 결과적으로 서로 다른 인스턴스가 생성, 할당되어 싱글턴 패턴이 깨져버리는것이죠.
자바에서는 리플렉션을 사용하거나, 역직렬화-직렬화과정에서 이러한 일이 발생합니다. 모든 원인은 의도치 않게 둘 이상의 인스턴스를 생성해서 싱글턴이 깨지는 결과를 낳게 됩니다. 자세한 내용은 이 블로그에서 구현 실험과 결과를 잘 나타내고 있습니다.
해결 방법1 - synchronized 키워드 사용
가장 간단한 해결방법은 synchronized 키워드를 사용하는것입니다. synchronized 키워드는 메서드에 사용될 수 있는데요. synchronized 키워드가 선언된 메서드에 서로 다른 두 메서드가 접근할 수 없게 됩니다. 하나의 스레드가 먼저 도착하게 된다면 락(Lock)이라는 접근 권한을 갖고 로직에 접근할 수 있게 되며, 다른 스레드가 나중에 도착하면 이 락을 획득할때까지 기다리는 상태가 됩니다.
public class Singleton {
private static Singleton uniqueInstance;
public static synchronized Singleton getInstance() {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
// 기타 메서드
}
이 synchronized로 묶인 영역을 임계영역(Critical Section)이라고 하는데요. 만약 데이터베이스에 접근하는 상황이라면 Transaction어노테이션을 사용하게 됩니다. 최소한의 임계영역을 설정해서 데드락 확률을 낮추고 동시성으로 성능을 높이는것이 백엔드 개발자가 고민해야 하는 부분입니다.
단순히 synchronized 키워드를 메서드 레벨에 사용한다면 직관적이고 쉽게 해결되지만 성능이 좋지 않습니다. 한 번에 하나의 스레드만 방문에서 사용할 수 있기 때문인데요. 이때 등장하는것이 DCL(Double Checked Locking) 입니다.
해결 방법2 - DCL(Double-Checked Locking)
이번에는 volatile 키워드를 정적 변수에 선언을 하였고, synchronized 키워드를 메서드 내부로 가져가서 블록을 만들었습니다. 차이점을 자세히 살펴보도록 하겠습니다.
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {}
public static Singleton getInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
volatile 키워드에 대한 이해가 먼저 필요합니다. volatile 키워드는 두 가지만 기억하면 됩니다:
- 메모리 가시성
- 명령어 재배열 방지
JVM과 OS는 우리가 모르는 최적화 작업을 진행합니다. 최적화 전과 후의 결과가 다르지 않다면 당연히 빠르게 수행되는 최적화 로직을 선택하겠죠.
[메모리 가시성]
최적화 방법중 하나는 캐싱입니다. 캐싱의 큰 문제점은 무엇일까요? 최신화가 되지 않은 값을 읽어와서 사용할때 발생합니다. uniqueInstance 캐싱해두고 제때 최신화를 하지 않으면 서로 다른 두 스레드가 실수로 서로 다른 인스턴스를 생성할 수 있습니다. 이때 항상 최신값만 보도록 강제하는 것을 메모리 가시성을 보장한다고 합니다.
[명령어 재배열 방지]
또, 명령어를 수행할때 결과가 같다면 재배열하여 수행이 빠른 쪽을 선택하는 경우가 있습니다. 예를 들어, 아래와 같은 순서대로 로직이 수행된다고 가정해봅니다:
- 메모리 할당 (메모리 공간 확보)
- 생성자 호출 (객체 초기화)
- instance 변수 할당 (instance에 주소 저장)
2번 보다 3번을 먼저 실행했을때, 결과가 같으면서 속도가 더 빨랐다면 이 방법을 채택할것입니다. 이는 싱글 스레드 환경에서 논리적인 최적화를 했기 때문인데요. 멀티 스레딩 환경에서 동시성이 고려하게 된다면 문제가 발생합니다.
만약 instance 변수를 먼저 주소할당을 했지만, 초기화를 하지 않았다면 null을 보게됩니다. 이때 다른 스레드가 instance변수를 체크한다면 null임을 확인하겠죠. 이 또한 메모리 가시성과 유사한 문제이며, 이 두 가지를 보장하는것이 volatile키워드 입니다.
그럼 항상 최신화된 데이터를 보고 명령어 재배열을 방지하여 순차적으로 실행된다면 동시성 문제가 해결될까요? 전혀 그렇지 않습니다. 여기서 synchronized 키워드를 사용해서 임계영역을 보호해야 완전히 동시성 문제를 해결할 수 있어요.
변수를 읽고, 업데이트하고, 쓰기하는 과정을 하나의 원자적 프로세스로 묶어서 하나의 스레드가 접근할 수 있도록 해야합니다. 이 기능을 synchronized 키워드가 하는것이에요.
synchronized vs. DCL
synchronized만 사용한 해결방법에서 DCL을 도입한 이유는 성능 차이라고 이야기 했습니다. 성능이 차이 나는 이유가 무엇일까요?

여러 스레드가 동시에 getInstance() 메서드에 접근한다고 가정해봅시다. 임계영역인 synchronized 블록은 하나의 스레드만 접근 가능하다고 했습니다. 대신 블록 바깥의 if문은 volatile 변수의 메모리 가시성이 보장되어 항상 최신의 데이터만 볼 수 있으며, 여러 스레드가 접근할 수 있습니다.
여기서 성능 차이가 발생합니다. 한 번에 하나씩 접근할 수 있는 synchronized 방법과 달리, 한 번이라도 초기화된다면 멀티 스레드가 접근하여 if문으로 체크를 하고 하나의 인스턴스만 받아갈 수 있게 되는 DCL가 더 빠르게 여러 스레드를 처리할 수 있게 되는것이죠.
package decorator.singletone;
import java.util.concurrent.CountDownLatch;
public class SingletonPerformanceTest {
private static final int THREAD_COUNT = 100;
private static final int CALLS_PER_THREAD = 100_000;
public static void main(String[] args) throws InterruptedException {
System.out.println("Testing SingletonV1...");
testPerformanceV1();
System.out.println("\nTesting SingletonV2...");
testPerformanceV2();
}
private static void testPerformanceV1() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
long start = System.currentTimeMillis();
for (int i = 0; i < THREAD_COUNT; i++) {
new Thread(() -> {
for (int j = 0; j < CALLS_PER_THREAD; j++) {
SingletonV1.getInstance();
}
latch.countDown();
}).start();
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("SingletonV1 time: " + (end - start) + " ms");
}
private static void testPerformanceV2() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
long start = System.currentTimeMillis();
for (int i = 0; i < THREAD_COUNT; i++) {
new Thread(() -> {
for (int j = 0; j < CALLS_PER_THREAD; j++) {
SingletonV2.getInstance();
}
latch.countDown();
}).start();
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("SingletonV2 time: " + (end - start) + " ms");
}
}
Testing SingletonV1...
SingletonV1 time: 389 ms
Testing SingletonV2...
SingletonV2 time: 19 ms
위와 같이 테스트코드를 작성해서 성능을 비교해볼 수 있습니다. 100개의 스레드가 각각 10만건의 getInstance를 호출하고 종료되는 로직입니다. 수행 결과 큰 성능 차이를 확인해볼 수 있습니다.
결론
이 글에서는 싱글턴의 개념과 발생할 수 있는 동시성 이슈에 대해 다뤘습니다. volatile, synchronized 키워드의 개념을 이해하고 이를 적용해서 싱글턴 패턴에서 발생할 수 있는 동시성 이슈를 해결했습니다. 또, synchronized 키워드만 사용한 방법과 DCL 방법의 차이점을 성능을 비교하며 알 수 있었습니다.
동시성에서 발생할 수 있는 이슈와 trade off를 이해한다면 적절한 임계 범위를 결정해서 성능과 안정성을 고려해야하는 것이 백엔드 개발에 있어서 중요한 점입니다.