가장 논란이 많은 디자인 패턴인 Singleton의 장단점을 객관적으로 분석합니다. 전역 상태의 위험성, 테스트의 어려움, 멀티스레드 환경에서의 문제점과 대안 패턴을 탐구합니다.
서론: 사랑받으면서도 미움받는 패턴의 역설
“Singleton은 디자인 패턴의 양날의 검이다. 올바르게 사용하면 시스템을 단순화하지만, 잘못 사용하면 시스템을 파괴한다.”
개발자들 사이에서 Singleton 패턴만큼 극명하게 갈리는 의견을 보이는 패턴은 드뭅니다. 어떤 이들은 “간단하고 효율적"이라며 자주 사용하고, 다른 이들은 “Anti-pattern의 대표주자"라며 완전히 피하려 합니다.
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
| // 겉보기에는 단순해 보이는 코드
public class ConfigurationManager {
private static ConfigurationManager instance;
private Properties config;
private ConfigurationManager() {
// 설정 파일 로드
config = new Properties();
// ...
}
public static ConfigurationManager getInstance() {
if (instance == null) {
instance = new ConfigurationManager();
}
return instance;
}
public String getProperty(String key) {
return config.getProperty(key);
}
}
// 어디서든 쉽게 접근 가능
String dbUrl = ConfigurationManager.getInstance().getProperty("db.url");
|
이 코드는 언뜻 보기에 완벽해 보입니다. 전역적으로 접근 가능하고, 메모리 효율적이며, 구현도 간단합니다. 하지만 여기에는 보이지 않는 함정들이 도사리고 있습니다.
Singleton이 논란의 중심에 있는 이유:
- 편의성 vs 설계 원칙: 사용하기는 쉽지만 좋은 설계 원칙들을 위반
- 성능 vs 안전성: 빠른 접근 vs Thread Safety 보장의 딜레마
- 단순성 vs 테스트: 구현은 간단하지만 테스트하기 어려움
- 전역 접근 vs 의존성 관리: 편한 접근 vs 명시적 의존성
이 글에서는 Singleton 패턴의 기술적 구현부터 철학적 논쟁까지, 그리고 언제 사용해야 하고 언제 피해야 하는지에 대한 명확한 가이드라인을 제시하겠습니다.
Singleton 패턴의 본질과 동기
GoF의 원래 의도
Gang of Four가 처음 Singleton 패턴을 제시했을 때의 목적은 명확했습니다:
“클래스의 인스턴스가 단 하나만 존재하도록 보장하고, 이에 대한 전역 접근점을 제공한다.”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // GoF가 제시한 전형적인 사례
public class PrinterSpooler {
private static PrinterSpooler instance;
private Queue<PrintJob> jobQueue;
private PrinterSpooler() {
jobQueue = new LinkedList<>();
}
public static PrinterSpooler getInstance() {
if (instance == null) {
instance = new PrinterSpooler();
}
return instance;
}
public void addJob(PrintJob job) {
jobQueue.offer(job);
}
// 물리적으로 하나의 프린터만 존재하므로 여러 인스턴스가 있으면 안 됨
}
|
“단 하나"가 필요한 진짜 상황들
물리적 제약이 있는 리소스:
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
| // 파일 시스템 접근 관리자
public class FileSystemManager {
private static FileSystemManager instance;
private final Map<String, FileLock> lockMap;
private FileSystemManager() {
lockMap = new ConcurrentHashMap<>();
}
public static FileSystemManager getInstance() {
if (instance == null) {
synchronized (FileSystemManager.class) {
if (instance == null) {
instance = new FileSystemManager();
}
}
}
return instance;
}
public boolean acquireLock(String filePath) {
// 동일한 파일에 대한 중복 락 방지
return lockMap.putIfAbsent(filePath, new FileLock(filePath)) == null;
}
}
|
시스템 전역 상태 관리:
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
31
32
33
34
| // 애플리케이션 설정 관리자
public class ApplicationConfig {
private static volatile ApplicationConfig instance;
private final Properties properties;
private ApplicationConfig() {
properties = new Properties();
loadConfiguration();
}
public static ApplicationConfig getInstance() {
if (instance == null) {
synchronized (ApplicationConfig.class) {
if (instance == null) {
instance = new ApplicationConfig();
}
}
}
return instance;
}
private void loadConfiguration() {
// 설정 파일 로드 - 한 번만 실행되어야 함
try (InputStream input = getClass().getResourceAsStream("/app.properties")) {
properties.load(input);
} catch (IOException e) {
throw new RuntimeException("Failed to load configuration", e);
}
}
public String getProperty(String key) {
return properties.getProperty(key);
}
}
|
다양한 Singleton 구현 방식 심화 분석
Eager Initialization (이른 초기화)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public class EagerSingleton {
// 클래스 로딩 시점에 인스턴스 생성
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton() {
// 생성자에서 복잡한 초기화 작업 수행
initializeResources();
}
public static EagerSingleton getInstance() {
return INSTANCE;
}
private void initializeResources() {
// 데이터베이스 연결, 파일 로드 등
System.out.println("Initializing singleton resources...");
}
}
|
장점:
- Thread-Safe: 클래스 로더가 Thread Safety 보장
- 단순함: 구현이 매우 간단
- 성능: 동기화 오버헤드 없음
단점:
- 메모리 낭비: 사용하지 않아도 메모리 점유
- 초기화 시간: 애플리케이션 시작 시 부담
- 예외 처리 어려움: 생성자 예외 처리 복잡
Lazy Initialization (늦은 초기화)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {
// 필요할 때까지 초기화 지연
System.out.println("Lazy singleton created");
}
// synchronized 키워드로 Thread Safety 보장
public static synchronized LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
|
장점:
- 메모리 효율: 필요할 때만 생성
- 지연 초기화: 애플리케이션 시작 시간 단축
단점:
- 성능 저하: 매번 동기화 오버헤드
- 확장성 제한: 멀티스레드 환경에서 병목
Double-Checked Locking (DCL)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class DCLSingleton {
// volatile 키워드 필수!
private static volatile DCLSingleton instance;
private DCLSingleton() {
System.out.println("DCL singleton created");
}
public static DCLSingleton getInstance() {
// 첫 번째 체크 - 동기화 블록 진입 최소화
if (instance == null) {
synchronized (DCLSingleton.class) {
// 두 번째 체크 - 실제 인스턴스 생성 보장
if (instance == null) {
instance = new DCLSingleton();
}
}
}
return instance;
}
}
|
DCL의 미묘한 문제 - Reordering:
1
2
3
4
5
6
7
8
9
10
11
| // JVM이 최적화를 위해 코드 순서를 바꿀 수 있음
// instance = new DCLSingleton(); 는 실제로 3단계:
// 1. 메모리 할당
// 2. 생성자 호출
// 3. instance 변수에 할당
// 2와 3의 순서가 바뀌면 문제 발생!
// Thread A: 메모리 할당 → instance 할당 → 생성자 호출 (진행 중)
// Thread B: instance != null로 판단하고 미완성 객체 사용!
// volatile이 이 문제를 해결함
|
Bill Pugh Solution (Initialization-on-demand holder)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class BillPughSingleton {
private BillPughSingleton() {
System.out.println("Bill Pugh singleton created");
}
// 내부 클래스는 getInstance() 호출 시점에 로드됨
private static class SingletonHelper {
private static final BillPughSingleton INSTANCE = new BillPughSingleton();
}
public static BillPughSingleton getInstance() {
return SingletonHelper.INSTANCE;
}
}
|
작동 원리:
1
2
3
4
5
| // JVM의 클래스 로딩 메커니즘을 영리하게 활용
// 1. BillPughSingleton 클래스 로드 시 SingletonHelper는 로드되지 않음
// 2. getInstance() 호출 시 SingletonHelper 클래스 로드
// 3. 클래스 로더가 Thread Safety 보장
// 4. 지연 초기화 + Thread Safety + 성능 최적화 모두 달성!
|
장점:
- Lazy Loading: 필요할 때만 초기화
- Thread-Safe: JVM 클래스 로더가 보장
- 성능: 동기화 오버헤드 없음
- 우아함: 복잡한 로직 없이 간단
Enum Singleton - Joshua Bloch의 권장사항
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
| public enum EnumSingleton {
INSTANCE;
private final Properties config;
// Enum 생성자는 private으로 제한됨
EnumSingleton() {
config = new Properties();
loadConfiguration();
}
public void doSomething() {
System.out.println("Enum singleton working...");
}
public String getProperty(String key) {
return config.getProperty(key);
}
private void loadConfiguration() {
// 설정 로드 로직
}
}
// 사용법
EnumSingleton.INSTANCE.doSomething();
String value = EnumSingleton.INSTANCE.getProperty("key");
|
Enum Singleton의 특별한 장점:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // 1. 직렬화 안전
// 일반 Singleton은 직렬화/역직렬화 시 새 인스턴스 생성 위험
// Enum은 JVM이 직렬화 시 단일성 보장
// 2. 리플렉션 공격 방지
// 일반 Singleton은 리플렉션으로 private 생성자 호출 가능
try {
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton hackInstance = constructor.newInstance(); // 가능!
} catch (Exception e) {
// ...
}
// Enum은 리플렉션으로 인스턴스 생성 불가능
try {
Constructor<EnumSingleton> constructor = EnumSingleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
EnumSingleton hackInstance = constructor.newInstance(); // 런타임 에러!
} catch (Exception e) {
System.out.println("Cannot instantiate enum: " + e.getMessage());
}
|
Thread Safety와 성능 최적화 심화
성능 벤치마크 분석
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| @BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class SingletonPerformanceBenchmark {
@Benchmark
public EagerSingleton testEager() {
return EagerSingleton.getInstance();
}
@Benchmark
public LazySingleton testLazy() {
return LazySingleton.getInstance();
}
@Benchmark
public DCLSingleton testDCL() {
return DCLSingleton.getInstance();
}
@Benchmark
public BillPughSingleton testBillPugh() {
return BillPughSingleton.getInstance();
}
@Benchmark
public EnumSingleton testEnum() {
return EnumSingleton.INSTANCE;
}
}
/*
JMH 벤치마크 결과 (나노초/operation):
구현 방식 | 평균 시간 | 표준편차 | Throughput
Eager Initialization | 2.1 | ±0.1 | 매우 높음
Bill Pugh Solution | 2.3 | ±0.1 | 매우 높음
Enum Singleton | 1.8 | ±0.1 | 가장 높음
Double-Checked Locking | 2.7 | ±0.2 | 높음
Lazy Synchronized | 45.2 | ±2.1 | 낮음 (병목!)
결론:
- Enum Singleton이 가장 빠름
- Lazy Synchronized는 심각한 성능 저하
- 초기화 후에는 대부분 비슷한 성능
*/
|
Singleton이 Anti-pattern으로 여겨지는 이유
전역 상태의 문제점 - 숨겨진 의존성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // 겉보기에는 깔끔해 보이는 코드
public class OrderService {
public void processOrder(Order order) {
// 숨겨진 의존성들!
String dbUrl = ConfigManager.getInstance().getDbUrl();
Logger logger = LoggerManager.getInstance();
PaymentGateway gateway = PaymentGatewayFactory.getInstance().getGateway();
logger.log("Processing order: " + order.getId());
// 비즈니스 로직
gateway.processPayment(order.getPayment());
logger.log("Order processed successfully");
}
}
// 문제점 분석:
// 1. 의존성이 명시되지 않음 - 메서드 시그니처만 보고는 알 수 없음
// 2. 테스트 시 Mock 객체 주입 불가능
// 3. 설정 변경이 전역적으로 영향
// 4. 코드 추적이 어려움
|
테스트의 어려움
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| // 테스트하기 어려운 Singleton 의존 코드
public class EmailService {
public void sendEmail(String to, String subject, String body) {
EmailConfig config = EmailConfig.getInstance();
SmtpClient client = SmtpClient.getInstance();
Email email = new Email(to, subject, body);
email.setFrom(config.getFromAddress());
client.send(email);
}
}
// 테스트 코드 - 문제가 많음
@Test
public void testSendEmail() {
// 문제 1: Singleton 상태 초기화 어려움
EmailConfig.reset(); // 이런 메서드가 있다면...
SmtpClient.reset(); // 하지만 보통 없음!
// 문제 2: Mock 객체 주입 불가능
// Mockito로 static 메서드 mocking은 복잡함
// 문제 3: 테스트 간 격리 실패
// 이전 테스트의 상태가 영향을 줄 수 있음
EmailService service = new EmailService();
service.sendEmail("test@example.com", "Test", "Body");
// 검증도 어려움 - Mock이 없으면 실제 이메일이 발송됨!
}
// 더 나은 설계
public class TestableEmailService {
private final EmailConfig config;
private final SmtpClient client;
// 의존성 주입
public TestableEmailService(EmailConfig config, SmtpClient client) {
this.config = config;
this.client = client;
}
public void sendEmail(String to, String subject, String body) {
Email email = new Email(to, subject, body);
email.setFrom(config.getFromAddress());
client.send(email);
}
}
// 테스트 코드 - 깔끔함
@Test
public void testSendEmail() {
// Mock 객체 생성
EmailConfig mockConfig = mock(EmailConfig.class);
SmtpClient mockClient = mock(SmtpClient.class);
when(mockConfig.getFromAddress()).thenReturn("noreply@example.com");
// 테스트
TestableEmailService service = new TestableEmailService(mockConfig, mockClient);
service.sendEmail("test@example.com", "Test", "Body");
// 검증
verify(mockClient).send(any(Email.class));
}
|
확장성 저해 - 분산 시스템의 한계
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| // 단일 JVM에서만 작동하는 Singleton
public class DistributedCacheManager {
private static DistributedCacheManager instance;
private final Map<String, Object> cache;
private DistributedCacheManager() {
cache = new ConcurrentHashMap<>();
}
public static DistributedCacheManager getInstance() {
if (instance == null) {
instance = new DistributedCacheManager();
}
return instance;
}
public void put(String key, Object value) {
cache.put(key, value);
}
public Object get(String key) {
return cache.get(key);
}
}
// 문제점:
// 1. 서버 A의 캐시와 서버 B의 캐시가 다를 수 있음
// 2. 로드 밸런싱 환경에서 데이터 불일치
// 3. 마이크로서비스 간 상태 공유 불가능
// 4. 수평 확장 시 각 인스턴스마다 별도의 "싱글톤"
// 더 나은 접근법: 외부 캐시 시스템 사용
@Service
public class RedisBasedCacheManager {
private final RedisTemplate<String, Object> redisTemplate;
public RedisBasedCacheManager(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void put(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
}
|
Singleton의 올바른 사용 시나리오
진정한 단일 리소스
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
| // 물리적으로 하나만 존재하는 리소스
public class HardwareManager {
private static HardwareManager instance;
private HardwareManager() {
// 하드웨어 초기화
initializeGPU();
initializeSensors();
}
public static synchronized HardwareManager getInstance() {
if (instance == null) {
instance = new HardwareManager();
}
return instance;
}
public void controlMotor(int speed) {
// 물리적 모터 제어 - 동시에 여러 명령이 오면 안 됨
}
public SensorData readSensors() {
// 센서 데이터 읽기
return new SensorData();
}
}
|
무상태 유틸리티
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
31
32
33
34
35
| // 상태가 없는 유틸리티 클래스
public class MathUtils {
private static final MathUtils INSTANCE = new MathUtils();
private MathUtils() {}
public static MathUtils getInstance() {
return INSTANCE;
}
public double calculateDistance(Point p1, Point p2) {
double dx = p1.getX() - p2.getX();
double dy = p1.getY() - p2.getY();
return Math.sqrt(dx * dx + dy * dy);
}
public double calculateArea(double radius) {
return Math.PI * radius * radius;
}
}
// 하지만 이런 경우는 static 메서드가 더 적합할 수 있음
public class BetterMathUtils {
private BetterMathUtils() {} // 인스턴스화 방지
public static double calculateDistance(Point p1, Point p2) {
double dx = p1.getX() - p2.getX();
double dy = p1.getY() - p2.getY();
return Math.sqrt(dx * dx + dy * dy);
}
public static double calculateArea(double radius) {
return Math.PI * radius * radius;
}
}
|
시스템 전반의 공통 기능
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
31
32
33
34
35
36
37
38
39
40
| // 로깅 시스템 - 실제로 많이 사용되는 패턴
public class ApplicationLogger {
private static volatile ApplicationLogger instance;
private final PrintWriter logWriter;
private final String logFilePath;
private ApplicationLogger() {
this.logFilePath = "application.log";
try {
this.logWriter = new PrintWriter(new FileWriter(logFilePath, true));
} catch (IOException e) {
throw new RuntimeException("Failed to initialize logger", e);
}
}
public static ApplicationLogger getInstance() {
if (instance == null) {
synchronized (ApplicationLogger.class) {
if (instance == null) {
instance = new ApplicationLogger();
}
}
}
return instance;
}
public synchronized void log(String level, String message) {
String timestamp = LocalDateTime.now().toString();
logWriter.println(String.format("[%s] %s: %s", timestamp, level, message));
logWriter.flush();
}
public void info(String message) {
log("INFO", message);
}
public void error(String message) {
log("ERROR", message);
}
}
|
현대적 대안들
Dependency Injection
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
31
32
33
34
35
36
37
38
| // Spring의 관리하는 Singleton
@Component
@Scope("singleton") // 기본값이므로 생략 가능
public class ConfigurationService {
private final Properties properties;
public ConfigurationService() {
properties = new Properties();
loadConfiguration();
}
public String getProperty(String key) {
return properties.getProperty(key);
}
private void loadConfiguration() {
// 설정 로드
}
}
// 사용하는 쪽
@Service
public class OrderService {
private final ConfigurationService configService;
private final Logger logger;
// 의존성이 명시적으로 주입됨
public OrderService(ConfigurationService configService, Logger logger) {
this.configService = configService;
this.logger = logger;
}
public void processOrder(Order order) {
String dbUrl = configService.getProperty("db.url");
logger.info("Processing order: " + order.getId());
// ...
}
}
|
Static Factory Methods
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 인스턴스화를 방지하는 유틸리티 클래스
public class DateUtils {
private DateUtils() {} // 인스턴스화 방지
public static String formatDate(LocalDate date) {
return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
public static LocalDate parseDate(String dateString) {
return LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE);
}
public static boolean isWeekend(LocalDate date) {
DayOfWeek dayOfWeek = date.getDayOfWeek();
return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY;
}
}
|
Functional Approach
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // JavaScript에서의 모듈 패턴
const configModule = (() => {
let config = {};
return {
setConfig: (newConfig) => {
config = { ...config, ...newConfig };
},
getConfig: () => ({ ...config }),
getProperty: (key) => config[key]
};
})();
// 사용법
configModule.setConfig({ dbUrl: 'localhost:5432' });
const dbUrl = configModule.getProperty('dbUrl');
|
실무 적용 가이드라인
Singleton 사용 결정 트리
1
2
3
4
5
6
7
8
9
10
11
12
13
| Singleton을 고려하는 상황인가?
├─ 물리적으로 하나만 존재해야 하는가?
│ ├─ YES → Singleton 고려 (하드웨어, 파일 시스템 등)
│ └─ NO → 계속 확인
├─ 상태가 없는 유틸리티인가?
│ ├─ YES → Static Methods 고려
│ └─ NO → 계속 확인
├─ 시스템 전반에서 공유되는 상태인가?
│ ├─ YES → DI Container 관리 Singleton 고려
│ └─ NO → 일반 객체 사용
└─ 테스트 가능성이 중요한가?
├─ YES → DI 사용
└─ NO → Singleton 고려 (신중하게)
|
구현 방식 선택 가이드
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
31
32
33
34
35
36
37
38
39
| // 상황별 최적 구현 선택
public class SingletonChoiceGuide {
// 1. 성능이 중요하고 즉시 초기화해도 되는 경우
public class EagerCase {
private static final EagerCase INSTANCE = new EagerCase();
public static EagerCase getInstance() { return INSTANCE; }
}
// 2. 메모리 효율이 중요하고 복잡한 초기화가 없는 경우
public class BillPughCase {
private static class Helper {
private static final BillPughCase INSTANCE = new BillPughCase();
}
public static BillPughCase getInstance() { return Helper.INSTANCE; }
}
// 3. 직렬화가 중요한 경우
public enum EnumCase {
INSTANCE;
public void doSomething() { /* ... */ }
}
// 4. 대부분의 일반적인 경우
public class GeneralCase {
private static volatile GeneralCase instance;
public static GeneralCase getInstance() {
if (instance == null) {
synchronized (GeneralCase.class) {
if (instance == null) {
instance = new GeneralCase();
}
}
}
return instance;
}
}
}
|
Singleton 리팩토링 전략
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| // 기존 Singleton 코드
public class LegacySingleton {
private static LegacySingleton instance;
public static LegacySingleton getInstance() {
if (instance == null) {
instance = new LegacySingleton();
}
return instance;
}
public void doSomething() {
// 비즈니스 로직
}
}
// 1단계: 인터페이스 추출
public interface BusinessService {
void doSomething();
}
public class LegacySingleton implements BusinessService {
private static LegacySingleton instance;
public static LegacySingleton getInstance() {
if (instance == null) {
instance = new LegacySingleton();
}
return instance;
}
@Override
public void doSomething() {
// 비즈니스 로직
}
}
// 2단계: 일반 클래스로 변환
public class RefactoredBusinessService implements BusinessService {
@Override
public void doSomething() {
// 동일한 비즈니스 로직
}
}
// 3단계: DI로 관리
@Component
public class FinalBusinessService implements BusinessService {
@Override
public void doSomething() {
// 동일한 비즈니스 로직
}
}
|
한눈에 보는 Singleton 패턴
Singleton 구현 방식 비교표
| 구현 방식 | Thread-Safe | Lazy Loading | 성능 | 직렬화 안전 | 구현 복잡도 |
|---|
| Eager Initialization | O | X | 최상 | X | 낮음 |
| Synchronized Method | O | O | 나쁨 | X | 낮음 |
| Double-Checked Locking | O | O | 좋음 | X | 중간 |
| Bill Pugh (Holder) | O | O | 최상 | X | 중간 |
| Enum Singleton | O | X | 최상 | O | 낮음 |
구현 방식별 성능 벤치마크
| 구현 방식 | 평균 시간 (ns) | 표준편차 | 권장 사용 |
|---|
| Enum Singleton | 1.8 | ±0.1 | 직렬화 필요, 리플렉션 방지 |
| Eager Initialization | 2.1 | ±0.1 | 즉시 초기화 허용 |
| Bill Pugh Solution | 2.3 | ±0.1 | 지연 초기화 필요 |
| Double-Checked Locking | 2.7 | ±0.2 | volatile 이해 가능한 팀 |
| Synchronized Method | 45.2 | ±2.1 | 사용 비권장 |
Singleton vs 대안 패턴 비교
| 비교 항목 | Singleton | Dependency Injection | Static Methods |
|---|
| 테스트 용이성 | 낮음 | 높음 | 중간 |
| 의존성 명시성 | 숨겨짐 | 명시적 | 해당 없음 |
| 분산 환경 지원 | 불가 | 가능 | 해당 없음 |
| 전역 접근 | 가능 | 제어됨 | 가능 |
| 상태 관리 | 가변 | 유연함 | 무상태 |
| 생명주기 제어 | 어려움 | 컨테이너 관리 | 불필요 |
Singleton 사용 결정 가이드
| 상황 | 권장 여부 | 대안 |
|---|
| 물리적으로 하나만 존재해야 하는 리소스 | O 사용 | - |
| 무상태 유틸리티 | △ 고려 | Static Methods |
| 시스템 전반 설정 관리 | △ 고려 | DI + @Scope(“singleton”) |
| 비즈니스 로직이 포함된 서비스 | X 피해야 함 | DI 사용 |
| 테스트가 중요한 컴포넌트 | X 피해야 함 | DI + Mock |
| 분산/마이크로서비스 환경 | X 피해야 함 | 외부 상태 저장소 |
Anti-pattern으로서의 Singleton 문제점
| 문제 유형 | 설명 | 영향 |
|---|
| 숨겨진 의존성 | 메서드 시그니처에 드러나지 않음 | 코드 이해도 저하 |
| 테스트 어려움 | Mock 객체 주입 불가 | 단위 테스트 복잡 |
| 전역 상태 | 예측 불가능한 부작용 | 버그 발생 위험 |
| 확장성 저해 | 단일 JVM에서만 동작 | 수평 확장 불가 |
| 결합도 증가 | 구체 클래스 직접 참조 | 유지보수 어려움 |
적용 체크리스트
| 체크 항목 | 확인 내용 |
|---|
| 진정한 필요성 | 정말로 “하나"여야 하는가? |
| 테스트 가능성 | Mock으로 대체 가능한가? |
| 의존성 명시 | 사용처에서 의존성이 드러나는가? |
| 확장성 | 분산 환경에서도 동작하는가? |
| 대안 검토 | DI, Static Methods로 충분하지 않은가? |
결론: Singleton 패턴의 현명한 사용
Singleton 패턴은 강력하지만 위험한 도구입니다. 올바르게 사용하면 시스템을 단순화하고 효율성을 높일 수 있지만, 잘못 사용하면 코드의 품질과 유지보수성을 크게 떨어뜨릴 수 있습니다.
Singleton 패턴의 핵심 교훈:
- 진정한 필요성 검토: 정말로 “하나"여야 하는지 신중히 판단
- 테스트 가능성 우선: 테스트하기 어려우면 설계를 재고
- 의존성 명시: 숨겨진 의존성은 코드를 취약하게 만듦
- 현대적 대안 고려: DI Container, Static Methods 등 검토
- 확장성 고려: 분산 환경에서도 작동할지 검토
실무자를 위한 권장사항:
1
2
3
4
5
6
7
8
9
10
11
| Singleton을 사용해도 되는 경우:
- 물리적으로 하나만 존재해야 하는 리소스
- 무상태 유틸리티
- 시스템 전반에서 공유되는 상태
- 로깅, 캐싱
Singleton을 피해야 하는 경우:
- 비즈니스 로직이 포함된 서비스
- 상태를 가지는 객체
- 테스트가 중요한 컴포넌트
- 분산 환경에서 동작하는 시스템
|
미래의 관점에서 보면, 클라우드 네이티브와 마이크로서비스 아키텍처가 주류가 되면서 전통적인 Singleton 패턴의 활용도는 줄어들 것입니다. 대신 외부 상태 저장소(Redis, Database)와 DI Container가 Singleton의 역할을 더 안전하고 확장 가능한 방식으로 대체하고 있습니다.
그럼에도 불구하고 Singleton 패턴을 이해하는 것은 중요합니다. 왜냐하면 기존 레거시 시스템을 이해하고, 올바른 설계 판단을 내리며, 더 나은 대안을 선택하기 위해서는 Singleton의 장단점을 명확히 알고 있어야 하기 때문입니다.
다음 글에서는 Builder와 Prototype 패턴을 살펴보겠습니다. 복잡한 객체를 생성하는 두 가지 서로 다른 접근법과 그들의 현대적 활용을 깊이 있게 탐구해보겠습니다.
핵심 메시지:
“Singleton은 강력하지만 위험한 도구이다. 사용 전에 신중히 고려하고, 사용 후에는 지속적으로 그 필요성을 검토해야 한다. 때로는 사용하지 않는 것이 더 나은 설계일 수 있다.”
평가 기준
독자가 이 글을 읽은 후 달성해야 할 목표:
핵심 메시지:
“Singleton은 강력하지만 위험한 도구이다. 사용 전에 신중히 고려하고, 사용 후에는 지속적으로 그 필요성을 검토해야 한다. 때로는 사용하지 않는 것이 더 나은 설계일 수 있다.”