[디자인 패턴] 4. 싱글톤 패턴
지금까지 우리는 전략 패턴으로 유연성을 확보하고, 팩토리 패턴으로 생성을 관리하며, 옵저버 패턴으로 흐름을 제어하는 방법을 배웠습니다. 이제 우리 애플리케이션의 비즈니스 로직은 제법 탄탄해졌습니다.
하지만 시야를 조금 더 넓혀서, 이 로직들이 돌아가는 환경을 생각해볼까요? 데이터베이스(DB) 연결이나 환경 변수 설정(Config) 같은 것들 말입니다.
왜 '단 하나'여야만 할까요?
웹 애플리케이션이 요청을 받을 때마다 데이터베이스 연결 객체를 매번 새로 만든다고 가정해보세요.
// 요청이 들어올 때마다 실행되는 코드라면?
DatabaseConnection db = new DatabaseConnection();
db.query("SELECT * FROM users");
사용자가 1,000명이 동시 접속하면 연결 객체도 1,000개가 생성됩니다. 이는 엄청난 메모리 낭비일 뿐만 아니라, DB 서버에 과부하를 주어 전체 시스템을 마비시킬 수도 있습니다. 설정 파일 관리자(ConfigManager)도 마찬가지입니다. 설정값이 여기저기서 제각각 로드되어 서로 다른 값을 가리킨다면 시스템의 정합성이 무너지게 됩니다.
이처럼 시스템 전반에서 반드시 단 하나의 인스턴스만 존재해야 하고, 이를 어디서든 공유해서 사용해야 할 때 사용하는 것이 바로 **싱글톤 패턴(Singleton Pattern)**입니다.
싱글톤 구현하기
싱글톤의 핵심은 두 가지입니다.
- 외부에서
new키워드로 무분별하게 객체를 생성하지 못하도록 막는다. - 이미 만들어진 객체가 있다면 그것을 반환하고, 없다면 새로 만들어서 반환한다.
TypeScript로 가장 일반적인 형태의 싱글톤을 구현해보겠습니다.
public class Database {
// 1. 유일한 인스턴스를 저장할 정적 변수
private static Database instance;
// 2. 생성자를 private으로 막아 외부 생성 차단
private Database() {
System.out.println("데이터베이스 연결을 초기화합니다...");
// 실제 DB 연결 로직
}
// 3. 인스턴스를 얻는 유일한 통로
public static Database getInstance() {
if (instance == null) {
instance = new Database();
}
return instance;
}
public void query(String sql) {
System.out.println("SQL 실행: " + sql);
}
}
사용 예시
이제 클라이언트 코드는 직접 객체를 생성할 수 없으며, getInstance 메서드를 통해서만 접근할 수 있습니다.
public class Application {
public static void main(String[] args) {
// Database db = new Database(); // 컴파일 에러 (private 생성자)
Database db1 = Database.getInstance();
Database db2 = Database.getInstance();
db1.query("SELECT * FROM products");
// 두 객체는 완전히 동일한 인스턴스
System.out.println(db1 == db2); // true
}
}
최초 호출 시에만 "데이터베이스 연결을 초기화합니다..."라는 로그가 뜨고, 그 이후에는 이미 생성된 객체를 재사용하게 됩니다. 이를 통해 리소스를 효율적으로 관리하고 데이터의 일관성을 보장할 수 있습니다.
실무에서 꼭 알아야 할 포인트
- 멀티 스레드 환경 문제 위의 구현은 싱글 스레드 환경에서는 안전하지만, 멀티 스레드 환경에서는 동시에 getInstance()가 호출되면 인스턴스가 두 번 생성될 가능성이 있습니다.
이를 해결하는 대표적인 방법 중 하나가 이중 검사 락(Double-Checked Locking) 입니다.
public class Database {
private static volatile Database instance;
private Database() {}
public static Database getInstance() {
if (instance == null) {
synchronized (Database.class) {
if (instance == null) {
instance = new Database();
}
}
}
return instance;
}
}
- 안전한 방법: Enum 싱글톤 Java에서 권장되는 싱글톤 구현 방식입니다.
public enum Database {
INSTANCE;
Database() {
System.out.println("데이터베이스 연결을 초기화합니다...");
}
public void query(String sql) {
System.out.println("SQL 실행: " + sql);
}
}
// 사용 방법
Database.INSTANCE.query("SELECT * FROM users");
주의해야 할 점: 양날의 검
싱글톤 패턴은 매우 유용하지만, 경험 많은 개발자들 사이에서는 '안티 패턴(Anti-pattern)'으로 불리기도 합니다. 왜일까요?
- 높은 결합도: 싱글톤 객체는 전역 상태(Global State)를 만듭니다. 어디서든 접근할 수 있다는 건, 어디서든 데이터를 변경할 수 있다는 뜻이기도 합니다. 이는 예상치 못한 버그를 낳을 수 있습니다.
- 테스트의 어려움: 테스트는 독립적이어야 하는데, 싱글톤은 상태가 계속 유지되므로 이전 테스트의 결과가 다음 테스트에 영향을 줄 수 있습니다.
따라서 싱글톤은 DB 연결, 로깅, 설정 관리 등 리소스 관리가 필수적인 인프라 계층에서 신중하게 사용하는 것이 좋습니다. 비즈니스 로직 객체를 모두 싱글톤으로 만드는 것은 피해야 합니다.
마치며
우리는 이제 객체의 생성부터(팩토리), 행동(전략), 이벤트 처리(옵저버), 그리고 리소스 관리(싱글톤)까지 다루었습니다.
그런데 개발을 하다 보면 이미 잘 만들어진 외부 라이브러리나 레거시 시스템을 우리 코드에 끼워 맞춰야 하는 상황을 자주 만나게 됩니다.
코드는 멀쩡한데… 인터페이스가 안 맞네?
다음 글에서는 이러한 상황에서 서로 다른 인터페이스를 가진 객체들이 함께 동작할 수 있도록 연결해주는 **어댑터 패턴(Adapter Pattern)**에 대해 알아보겠습니다.