본문 바로가기
설계/디자인패턴

[디자인패턴] 싱글톤(Singleton) 패턴 이해하기

by J4J 2024. 3. 30.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 싱글톤(Singleton) 패턴에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

Singleton 패턴이란?

 

singleton 패턴은 객체를 생성할 때 클래스 별로 각 하나의 객체만 생성하여 사용될 수 있도록 도와주는 디자인 패턴입니다.

 

많은 분들이 경험해 보셨겠지만 개발을 할 때 클래스 내부에 정의되어 있는 기능을 사용하기 위해 다양한 곳에서 객체를 생성하게 됩니다.

 

그리고 생성된 객체를 이용하여 내부에 정의되어 있는 여러 메서드 및 함수들을 활용하게 됩니다.

 

 

반응형

 

 

하지만 여러 장소에서 각각 객체를 생성하는 행위는 모두 자원을 필요로 합니다.

 

객체가 생성될 때마다 각 객체의 정보들이 담겨야 하는 메모리 공간을 차지하기 때문입니다.

 

또한 다양한 곳에서 생성된 객체들은 모두 동일한 기능들을 담게 됩니다.

 

즉, 만약 하나의 객체만 생성하고 객체를 필요로 하는 곳에 추가 생성을 하지 않고 생성되어 있는 객체를 공유할 수 있다면 속도 및 리소스 측면에서 더 효율적이게 될 수 있습니다

 

 

 

일반적으로 singleton 패턴을 적용하는 대표적인 예시가 데이터베이스 연결을 위한 객체 / 로깅 처리를 위한 객체 / 캐싱 처리를 위한 객체 등이 있습니다.

 

이런 객체들은 초기 객체를 생성할 때 많은 비용을 필요로 하거나 또는 한번 생성된 이후 다시 생성될 필요가 없는 객체들입니다.

 

이처럼 특정 상황에서 singleton 패턴을 적용하는 것은 효율적인 결과를 만들어내지만 모든 경우에 대해 항상 singleton 패턴이 정답이라고는 말할 수 없습니다.

 

어떠한 특징들이 있기에 항상 정답이라고 말할 수 없는지 간단히 정리해 보겠습니다.

 

 

 

 

Singleton 패턴 특징

 

singleton 패턴의 특징에 대해서 정리해 보면 다음과 같습니다.

 

 

 

[ 장점 ]

 

  • 한 개의 객체만 생성하여 메모리 자원 공유
  • 객체가 하나로 유지되기 때문에 전역 상태 관리에 용이
  • 전역으로 관리되기 때문에 어디서든 쉽게 접근해서 활용 가능

 

 

 

[ 단점 ]

 

  • 객체를 활용해야 하는 모든 곳에서 생성된 한 개의 인스턴스를 바라보기 때문에 결합도 증가 ( = 의존성 증가)
  • 전역으로 관리되기 때문에 어디서든 상태에 영향을 받을 수 있음
  • 단위 테스트를 수행할 때 mock 객체의 생성과 자원 공유 측면에서 테스트를 어렵게 만들 수 있음
  • 객체가 사용되지 않는 상황에서도 인스턴스를 생성하여 메모리에 적재될 수 있음 ( → 소스 코드로 개선 가능)
  • 멀티 스레드 환경에서 패턴이 적용되지 않을 수 있음 (thread safe 하지 않음) ( → 소스 코드로 개선 가능)

 

 

 

 

위의 내용처럼 singleton 패턴은 명확한 장점을 가지고 있지만 생각보다 많은 단점들을 가지고 있는 부분이 있습니다.

 

그래서 모든 상황에 대해 singleton 패턴을 사용하는 것은 오히려 독이 될 수 있고 사용되어야 하는 객체의 특징에 따라 적절하게 singleon 패턴을 사용하는 것이 바람직합니다.

 

 

 

그리고 자바 기준에서는 일반적으로 spring과 같은 프레임워크를 활용한 개발을 하게 됩니다.

 

spring을 사용하여 개발을 할 때는 일반적으로 객체를 관리할 때 singleton 패턴을 적용하기 위한 코드를 매번 작성하지는 않습니다.

 

spring에서 제공해 주는 IoC를 이용하여 객체 관리를 하게 된다면 singleton 패턴이 가지고 있는 장점을 누리며 단점이 보완될 수 있기 때문에 보통 프레임워크를 활용하여 객체 관리를 하시게 될 겁니다.

 

 

 

 

Singleton 패턴 예시 (1) - Eager Initialization

 

단점 부분에서 얘기했던 내용 중 하나는 소스 코드로 개선 가능한 단점이 있다는 것입니다.

 

소스 코드로 개선 가능한 부분이 있는 이유는 singleton 패턴을 적용할 때 한 가지의 방법만 사용하는 것이 아니기 때문입니다.

 

singleton 패턴을 적용하기 위해 다양한 방법들이 존재하지만 그중 가장 대표적인 케이스와 단점을 보완할 수 있는 케이스에 대해서 소개드리겠습니다.

 

 

 

 

제일 먼저 가장 대표적인 방법에 대해 소개드리겠습니다.

 

해당 방법은 여러 방법들 중 가장 단순하게 singleton 패턴을 적용할 수 있습니다.

 

패턴을 적용하는 방법은 다음과 같습니다.

 

  • 생성자를 private로 선언하여 클래스 내부에서만 생성자를 호출할 수 있도록 관리
  • 클래스 내부에서 생성자를 호출하여 유일한 인스턴스 객체를 private로 생성
  • 인스턴스 객체를 반환하는 메서드를 정의하여 객체가 필요한 곳에 전달

 

 

 

소스 코드로 보시면 다음과 같습니다.

 

// singleton class
package com.jforj.singleton.eager;

public class EagerSingleton {
    private static EagerSingleton instance = new EagerSingleton(); // 클래스의 유일한 인스턴스 객체

    /**
     * 클래수 내부에서만 생성자를 호출할 수 있도록 private 설정
     */
    private EagerSingleton() {
        System.out.println("[constructor] ======> call singleton constructor");
    }

    /**
     * 유일한 인스턴스 객체 반환
     */
    public static EagerSingleton getInstance() {
        return instance;
    }

    public void print() {
        System.out.println("[print] ======> singleton object");
    }
}


// main
package com.jforj.singleton.eager;

public class Main {
    public static void main(String[] args) {
        printSingleton();
        printSingleton();
        printSingleton();
    }

    public static void printSingleton() {
        EagerSingleton eagerSingleton = EagerSingleton.getInstance();
        eagerSingleton.print();
    }
}

 

실행 결과

 

 

 

 

소스 코드를 확인해 보면 객체를 생성하여 print() 메서드를 호출하는 코드가 3개가 있는 것을 볼 수 있습니다.

 

하지만 실행 결과를 보면 생성자는 1번밖에 호출되지 않았지만 정상적으로 모두 print() 메서드가 호출된 것이 확인됩니다.

 

즉, 객체를 한 번만 생성하고 생성된 객체가 다양한 곳에 공유되어 사용되고 있는 것을 의미합니다.

 

 

 

위의 방법은 singleton 패턴을 무리 없이 적용할 수 있는 것으로 보이지만 명확한 단점을 가지고 있습니다.

 

그것이 바로 특징에서 얘기했던 객체를 사용하지 않는 상황에서도 인스턴스를 생성하여 메모리에 적재하는 것입니다.

 

위의 코드는 객체기 사용되는 시점과 상관없이 애플리케이션이 시작되면 위와 같이 구현된 모든 singleton 패턴은 객체를 생성하는 문제를 발생시킵니다.

 

 

 

 

Singleton 패턴 예시 (2) - Lazy Initialization

 

다음 방법은 eager initialization에서 제시되었던 애플리케이션 구동 시점에 객체를 생성하는 문제를 해결하는 방법입니다.

 

어떤 식으로 구성할 수 있는지 바로 소스 코드로 확인해 보겠습니다.

 

// singleton class
package com.jforj.singleton.lazy;

public class LazySingleton {
    private static LazySingleton instance; // 클래스의 유일한 인스턴스 객체

    /**
     * 클래수 내부에서만 생성자를 호출할 수 있도록 private 설정
     */
    private LazySingleton() {
        System.out.println("[constructor] ======> call singleton constructor");
    }

    /**
     * 유일한 인스턴스 객체 반환
     */
    public static LazySingleton getInstance() {
        // 생성된 인스턴스 객체가 없는 경우에만 새롭게 생성
        if(instance == null) {
            instance = new LazySingleton();
        }

        return instance;
    }

    public void print() {
        System.out.println("[print] ======> singleton object");
    }
}


// main
package com.jforj.singleton.lazy;

public class Main {
    public static void main(String[] args) {
        printSingleton();
        printSingleton();
        printSingleton();
    }

    public static void printSingleton() {
        LazySingleton lazySingleton = LazySingleton.getInstance();
        lazySingleton.print();
    }
}

 

실행 결과

 

 

 

 

소스 코드에서 변경된 부분은 인스턴스 객체를 생성하는 시점입니다.

 

기존에는 static instance에 바로 할당을 했었지만 변경된 코드에서는 객체를 반환해 주는 메서드가 호출될 때 인스턴스 존재 여부를 확인한 뒤 생성하고 있습니다.

 

즉, 애플리케이션 구동을 할 때 생성되지 않고 실제로 객체가 사용되어야 하는 시점에 메모리에 할당되는 것을 볼 수 있습니다.

 

 

 

하지만 해당 방법도 다른 단점을 가지고 있습니다.

 

여기서 발생되는 단점이 바로 특징에서 얘기했던 멀티 스레드 환경에서 패턴이 적용되지 않을 수 있다는 것입니다.

 

멀티 스레드 관점에서 동시에 객체를 반환해주는 메서드를 호출하게 될 경우 모든 스레드에서 객체가 존재하지 않는 것으로 판단하게 됩니다.

 

결국 singleton 패턴을 적용하기는 했지만 동시에 접근된 스레드의 개수만큼 인스턴스를 생성시키기 때문에 하나의 객체만 가지고 있다는 singleton 패턴의 특징이 적용되지 않게 됩니다.

 

관련되어 코드로 확인해 보면 다음과 같은 결과를 볼 수 있습니다.

 

// singleton class
package com.jforj.singleton.lazy;

public class LazySingleton {
    private static LazySingleton instance; // 클래스의 유일한 인스턴스 객체

    /**
     * 클래수 내부에서만 생성자를 호출할 수 있도록 private 설정
     */
    private LazySingleton() {
        System.out.println("[constructor] ======> call singleton constructor");
    }

    /**
     * 유일한 인스턴스 객체 반환
     */
    public static LazySingleton getInstance() {
        // 생성된 인스턴스 객체가 없는 경우에만 새롭게 생성
        if(instance == null) {
            instance = new LazySingleton();
        }

        return instance;
    }

    public void print() {
        System.out.println("[print] ======> singleton object");
    }
}


// main
package com.jforj.singleton.lazy;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        // 스레드 풀 생성
        ExecutorService executor = Executors.newFixedThreadPool(3);
        for(int i=0; i<5; i++) {
            executor.submit(() -> {
                printSingleton();
            });
        }

        // 스레드 풀 종료
        executor.shutdown();
    }

    public static void printSingleton() {
        LazySingleton lazySingleton = LazySingleton.getInstance();
        lazySingleton.print();
    }
}

 

thread safe 이슈 발생 결과

 

 

 

 

멀티 스레드로 변경된 코드의 실행 결과를 확인해보면 문제점을 바로 확인할 수 있습니다.

 

분명 싱글 스레드로 동작되었던 이전 코드들에서는 생성자를 호출하는 로그가 1번 밖에 발생되지 않았습니다.

 

하지만 멀티 스레드로 동작시켰더니 생성자 호출 로그가 그 이상으로 발생되는 것을 볼 수 있습니다.

 

이처럼 애플리케이션 구동 시점에 인스턴스를 생성하지는 않지만 thread safe한 구성이 이루어지지 않기 때문에 해당 방법도 멀티 스레드 환경에서는 문제점을 발생시키게 됩니다.

 

 

 

 

Singleton 패턴 예시 (3) - Bill Pugh Solution

 

다음 방법은 eager initialization에서 발생됬었던 객체 생성 시점과 lazy initialization에서 발생되었던 thread safe 문제에 대해서도 해결할 수 있는 방법 중 하나입니다.

 

코드로는 다음과 같이 작성할 수 있습니다.

 

// singleton class
package com.jforj.singleton.billpugh;

public class BillPughSingleton {
    /**
     * 클래수 내부에서만 생성자를 호출할 수 있도록 private 설정
     */
    private BillPughSingleton() {
        System.out.println("[constructor] ======> call singleton constructor");
    }

    /**
     * static class를 활용한 인스턴스 holding
     */
    private static class InstanceHolder {
        private static final BillPughSingleton Instance = new BillPughSingleton();
    }

    /**
     * 유일한 인스턴스 객체 반환
     */
    public static BillPughSingleton getInstance() {
        return InstanceHolder.Instance;
    }

    public void print() {
        System.out.println("[print] ======> singleton object");
    }
}


// main
package com.jforj.singleton.billpugh;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        // 스레드 풀 생성
        ExecutorService executor = Executors.newFixedThreadPool(3);
        for(int i=0; i<5; i++) {
            executor.submit(() -> {
                printSingleton();
            });
        }

        // 스레드 풀 종료
        executor.shutdown();
    }

    public static void printSingleton() {
        BillPughSingleton billPughSingleton = BillPughSingleton.getInstance();
        billPughSingleton.print();
    }
}

 

실행 결과.

 

 

 

 

해당 방법은 클래스 내부에 static class를 두어 애플리케이션 구동 시점에서도 객체를 생성하지 않고 멀티 스레드 환경에서도 thread safe 될 수 있는 환경을 제공하는 방법입니다.

 

지금까지 singleton 패턴을 적용하기 위해 등장했던 방법 중 가장 이상적인 방법이기에 만약 singleton 패턴을 적용하고자 하신다면 해당 방법을 이용하는 것을 권장드립니다.

 

 

 

 

 

 

 

 

 

이상으로 싱글톤(Singleton) 패턴에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글