본문 바로가기
Design Pattern/생성 패턴(Creational Patterns)

싱글톤(Singleton) - 생성 패턴(Creational Patterns)

by 김 민 준 2024. 5. 10.

가장 많이 사용하는 패턴 중 하나인 싱글톤 패턴은 특정 클래스의 인스턴스가 프로그램 전체에 하나만 존재하도록 보장하는 것이다. 싱글톤 패턴을 사용하면 동일한 개체를 여러 번 생성하지 않고 일관된 접근성을 제공할 수 있다. 클래스가 스스로 자신의 유일한 인스턴스를 관리하고 이에 대해서 전역 접근 지점을 제공하는데 주로 리소스를 공유하거나 구성을 설정하는 데 사용한다. 

 

싱글턴에 대해 간단하게 예시로 설명하면 아파트 단지에 하나만 있는 관리사무소로 이해하면 쉬울 것 같다. 아파트단지에 공통적으로 사용하는 관리사무소는 단지 내에 유일하고, 아파트 주민들은 관리 사무소를 통해 다양한 서비스를 받는다. 이 때문에 아파트 주민들은 관리 사무소를 직접 만들거나 복제할 필요가 없고, 이미 존재하는 관리사무소를 이용하면 된다. 

 

 

1. 클래스의 생성자를 프라이빗(Private)으로 선언하고 외부에서 직접 인스턴스를 성성할 수 없게 한다.

 

public class Singleton {
    private Singleton() {
        // 생성자 내부 로직
    }
}

 

2. 클래스 자신이 유일한 인스턴스를 Private Static 변수로 선언한다. 클래스가 로드될 때 초기화 되고, 클래스의 유일한 인스턴스를 참조함으로써 변수는 클래스 내부에서만 접근 가능하고 외부에서 접근할 수 없다.

public class Singleton {
    private static Singleton instance;

    private Singleton() {
        // 생성자 내부 로직
    }
}

 

3. 외부에서 인스턴스에 접근할 수 있는 퍼블릭 메서드를 제공하고 이 메서드는 내부적으로 인스턴스가 생성되어 있는지 확인하고 없으면 생성하고 이를 반환한다. 

 

public class Singleton {
    private static Singleton instance;

    private Singleton() {
        // 생성자 내부 로직
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

 

 

그렇다면 싱글턴 패턴의 장점은 무엇일까?

 

인스턴스를 한 번만 생성하기 때문에 메모리 사용을 최소화할 수 있다. 공유 자원에 대해 중복 생성을 방지하였기 때문에 그만큼 리소스를 효율적으로 사용할 수 있는 것이다. 그리고 어디서나 접근할 수 있는 전역적인 지점을 제공함으로써 일관된 접근 방법을 갖고 갈 수 있다. 개체 생성 시점과 방법을 제어해 주기 때문에 Lazy Initialization (초기화를 지연시켜 주는)과 같은 기법으로 구현할 수도 있다. 

 

단점으로는 싱글턴은 유연하지 못하다는 것이다. 전역상태를 가지고 있다는 것은 누군가 수정하면 예상하지 못한 문제가 발생할 수 있는 것이다. 또한 멀티스레드 환경에서 사용할 때 동기화문제가 발생할 수 있다. 여러 스레드가 동시에 인스턴스를 초기화하려 할 때 한 번만 생성되도록 관리하는 게 어려울 수 있다. 물론 이를 해결하기 위해 동기화를 추가하면 될 수 있지만 성능에 영향이 끼칠 수 있는 부분이다. 그리고 의존성을 코드에 직접 숨기기 때문에 어떤 개체들이 싱글톤을 사용하는지 명확하지 않을 수 있다. 이를 해소하기 위해 의존성을 주입하는 방법을 사용할 수 있다. 

 

싱글톤 패턴은 가장 많이 사용하고 있다고 앞서 설명했는데 그중에서도 가장 대표적인 사례는 무엇일까?

 

내 생각에는 로깅을 구현할 때가 아닐까 싶다. 일반적으로 로깅 하나의 로그 파일에 메시지를 기록하는데 모든 컴포넌트에서 로거 인스턴스에 접근할 수 있게 해야 하기 때문이다. 로깅을 싱글톤으로 구현하면 로거 인스턴스가 중복 생성되는 것을 방지할 수 있고 전체적으로 일관된 방식으로 사용할 수 있게 된다.

 

public class Logger {
    private static Logger instance;
    private File logFile;

    private Logger() {
    
        // 파일 로거 설정
        try {
            this.logFile = new File("app.log");
            
            // 파일이 없다면 새 파일 생성
            if (!logFile.exists()) {
                logFile.createNewFile();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

 	// 멀티 스레드 환경에서 안전하게 사용하기 위해서 synchronized로 선언
    public static synchronized Logger getInstance() {
        if (instance == null) {
            instance = new Logger();
        }
        return instance;
    }

    public void log(String message) {
        try {
            // FileWriter를 사용하여 파일에 로그 메시지를 기록
            FileWriter fileWriter = new FileWriter(this.logFile, true);
            BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
            bufferedWriter.write(message);
            bufferedWriter.newLine();
            bufferedWriter.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

이와 같이 로깅에 대한 로직을 만들었다면 이것을 사용할 때는 아래와 같이 코드를 작성할 수 있다.

 

public class Application {
    public static void main(String[] args) {
        Logger logger = Logger.getInstance();
        logger.log("시작");
        logger.log("실행중");
        logger.log("종료");
    }
}

 

이 외에도 알게 모르게 싱글턴 패턴을 적용한 부분이 많을 것이다. 특히 서비스 구성 부분의 소스 코드들을 보면 많이 보일 것이다. 알고 보면 참 많이 쓰이는 패턴이다. 아니 그냥 너무 많이 쓰이는 패턴이다.

 

그러면 Java나 C# 같은 언어가 아니고 싱글프로세스로 동작하는 Node.js 로도 가능할까?

 

당연히 가능하다. 

 

Javascript에서 모듈은 자체적으로 싱글톤처럼 동작한다. 한번 로드되면 Node.js 는 그 모듈의 인스턴스를 캐시 하므로 동일한 모듈을 요청할 때마다 동일한 인스턴스를 반환한다. 

 

// logger.js
const fs = require('fs');
const path = require('path');

class Logger {
    constructor() {
        this.logFile = path.join(__dirname, 'app.log');
    }

    log(message) {
        fs.appendFile(this.logFile, `${new Date().toISOString()} - ${message}\n`, (err) => {
            if (err) {
                console.error('Error writing to log file', err);
            }
        });
    }
}

module.exports = new Logger();  // 모듈을 요청할 때마다 같은 Logger 인스턴스 반환

 

Logger 클래스를 정의하고 인스턴스를 생성해서 모듈로 바로 내보낸다. require()를 통해서 logger.js 모듈을 어디서든 가져와서 Logger 인스턴스를 받을 수 있다. 

 

// app.js
const logger = require('./logger');

logger.log("시작");
logger.log("실행중");
logger.log("종료");

 

그런데 Node.js에서 싱글턴  패턴을 사용해서 로깅을 구현할 때 주의할 부분이 많다. 

가장 큰 것은 비동기로 파일을 쓸 때 동시성 관리가 필요하다는 것이다. 이를 해결하기 위해서 메모리 내에 Queue를 사용하여 순차적으로 기록하게 하거나 스트림을 이용하여 파일을 작성하는 방법을 사용할 수 있다. 

 

물론 Java나 C# 그리고 Node.js에서 일어날 수 있는 문제점들은 더 있긴 하다. 예를 들어 파일의 크기가 너무 커지거나 하는 것 들이다. 하지만 이번 글은 싱글턴에 대해서만 작성했기 때문에 여기서 글을 정리한다.