싱글톤 패턴 (Singleton Pattern)

niireymik·2024년 8월 19일



👻 GoF 디자인 패턴

오늘의 주제인 싱글톤 패턴은 GoF 디자인 패턴 중 하나이다. GoF 디자인 패턴? 그게 무엇일까?

디자인 패턴이란?

GoF 디자인 패턴이 무엇인지 알기 위해, 우선 디자인 패턴이 무엇인지 알아야 한다.

디자인 패턴은 쉽게 말하자면 선배들의 경험이 담긴 문제 해결 방법이다. 반복되는 실수와 수정사항을 막기 위해 특정 상황에서 발생하는 문제 패턴을 발견하고 해결방안을 기록으로 남겼는데, 이를 디자인 패턴이라고 부르는 것이다!✌️

우리는 라이브러리와 프레임워크를 자연스레 사용한다. 멋진 틀에 맞춰서 멋진 부품으로 개발할 수 있도록 도와주므로 개발하는 일 자체가 한결 편해진다. 그러나 라이브러리와 프레임워크가 더 이해하기 쉽고, 관리하기 쉬운 유연한 방법으로 애플리케이션 구조를 만드는 데 도움을 주진 못한다. 이러한 부분을 도와주는 것이 바로 디자인 패턴이다.

디자인 패턴을 완전히 익혀두면, 어떤 코드가 유연성 없이 엉망으로 꼬여 있는 🍝스파게티 코드인지 금방 깨달을 수 있으며 그 코드를 수정할 때 패턴을 적용해서 코드를 개선할 수 있다! :>


어떤 경우에 패턴을 써야 할까?

디자인을 할 때, 지금 디자인상의 문제에 적합하다는 확신이 든다면 패턴을 도입해야 한다. 만약 더 간단한 해결책이 있다면 패턴을 적용하기 전에 그 해결책의 사용을 고려해 봐야 한다. (언제 패턴을 적용할지 올바르게 결정하려면 경험과 지식이 축적되어야 가능하긴 하다...!)

→ 즉, 발생한 문제가 간단한 해결책으로 해결되지 않으며 특정 디자인 패턴에 적합한 문제 상황일 때 디자인 패턴을 사용한다! 이러한 문제 해결은 디자인 단계와 더불어 리팩토링을 할 때에도 고려할 수 있다 :>


GoF (Gang of Four) 디자인 패턴

GoF 디자인 패턴은 Gang of Four라고 알려져 있는 에리히 감마, 리차드 헬름, 랄프 존슨, 존 브리시디스 : 4명의 저자가 정리한 23개의 디자인 패턴을 말한다.

GoF 디자인 패턴은 생성 패턴, 구조 패턴, 행위 패턴의 3가지로 분류된다.

1. 생성 패턴

: 객체 생성에 관련된 패턴으로, 객체의 생성과 조합을 캡슐화해 특정 객체가 생성되거나 변경되어도 프로그램 구조에 영향을 크게 받지 않도록 유연성을 제공한다. 생성 패턴 종류는 5가지가 있다.

  • 생성 패턴
    • 싱글톤 (Singleton)
    • 추상 팩토리 (Abstract Factory)
    • 빌더 (Builder)
    • 팩토리 메서드 (Factor Method)
    • 프로토타입 (Prototype)

2. 구조 패턴

: 클래스나 객체를 조합해 더 큰 구조를 만드는 패턴이다. 예를 들어 서로 다른 인터페이스를 지닌 2개의 객체를 묶어 단일 인터페이스를 제공하거나 객체들을 서로 묶어 새로운 기능을 제공하는 패턴이다. 구조 패턴의 종류는 7가지이다.

  • 구조 패턴
    • 어댑터 (Adapter)
    • 브리지 (Bridge)
    • 컴포지트 (Composite)
    • 데코레이터 (Decorator)
    • 퍼사드 (Facade)
    • 플라이웨이트 (Flyweight)
    • 프록시 (Proxy)

3. 행위 패턴

: 객체나 클래스 사이의 알고리즘이나 책임 분배에 관련된 패턴이다. 가령 한 객체가 혼자 수행할 수 없는 작업을 여러 개의 객체로 어떻게 분배하는지, 또 그렇게 하면서도 객체 사이의 결합도를 최소화하는 것에 중점에 둔다. 행위 패턴의 종류는 11가지이다.

  • 행위 패턴
    • 책임 연쇄 (Chain of Responsibility)
    • 커맨드 (Command)
    • 인터프리터 (Interpreter)
    • 이터레이터 (Iterator)
    • 미디에이터 (Mediator)
    • 메멘토 (Memento)
    • 옵저버 (Observer)
    • 스테이트 (State)
    • 스트래티지 (Strategy)
    • 탬플릿 메서드 (Template Method)
    • 비지터 (Visitor)



💡
우리가 소프트웨어를 만들다 보면, 어떤 클래스의 객체를 딱! 하나만 만들어야 하는 상황이 온다. 예를 들면, 내가 모바일 어플리케이션에서 다크모드를 설정해 두면 어느 화면에서든 다크모드로 보여야 하기에, 설정 객체는 Single : 딱 한 개만 가지고 사용해야 한다.

바로 이런 경우에 사용되는 패턴이 오늘의 주제, Singleton 패턴이다👍
싱글톤 패턴의 정의와 활용 코드까지 한 번에 알아보자!😋




🐤 Singleton Pattern

싱글톤 패턴은 GoF 디자인 패턴의 생성 패턴 중 하나로, 특정 클래스의 인스턴스가 단 1개만 생성되는 것을 보장하는 디자인 패턴이다!✨

쉽게 말해, 여러 곳에서, 여러 번 생성자가 호출되더라도 맨 처음에 생성된 한 개의 클래스만 보존하고 더이상 생성되지 않도록 하는 것이다.


🎯싱글톤 패턴 용례

  • 애플리케이션의 구성(configuration) 정보와 같이 런타임 내에 공유되어야 하는 정적인 정보를 가지고 있는 클래스의 인스턴스를 만들 때 이 패턴을 사용할 수 있다.
  • 생성 비용이 큰 인스턴스가 있을 때, 이 인스턴스를 한번 만들고 계속 재활용이 가능하다면 이 인스턴스를 싱글톤으로 공유하는 것을 생각해볼 수 있다.

싱글톤 패턴의 장점

  1. 메모리 낭비 방지
    한 개의 인스턴스만을 고정 메모리 영역에 생성해 사용하므로, 메모리 낭비를 방지할 수 있다! 10번의 요청이 들어올 때 매번 똑같은 인스턴스를 생성할 필요 없이 한 개의 인스턴스만 사용해 메모리 낭비를 줄인다고 이해하면 되겠다.
  2. 속도 향상
    이미 생성된 인스턴스를 사용할 때에는 새로운 인스턴스가 생성되는 시간이 없으므로 속도가 더 빠르다.
  3. 데이터 공유
    전역으로 사용하는 인스턴스이기에 서로 다른 클래스에서 데이터를 공유하며 사용할 수 있다.

→ 사실 데이터를 공유함은 싱글톤 패턴의 사용 이유가 되기도 하지만, 이는 동시성 문제를 야기한다. 관련 문제의 해결은 아래에서 다루겠다!


🐥싱글톤 패턴 구현

Before

Chick🐣 이라는 클래스가 이렇게 있다고 하자.

public class Chick {

    private String name = "글루따띠온";
    public String getName () { return name; }
    public void setName (String name) { this.name = name; }

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

        Chick chick1 = new Chick(); // 병아리 첫 번째 호출
        Chick chick2 = new Chick(); // 병아리 두 번째 호출

        System.out.println(chick1);
        System.out.println(chick2);

    }
}

📌 출력 결과
Singleton.Chick@3b07d329
Singleton.Chick@41629346

→ 두 객체의 해시코드를 출력해 보면 당연히 서로 다른 값을 보인다.

After

public class Chick {

    // ststic으로 선언
    private static Chick instance;

    // private 생성자로 외부에서 객체를 생성하는 것 방지
    private Chick() {}

    // 외부에서 인스턴스를 얻기 위한 메서드
    public static Chick getInstance() {
        // instance가 null일 때 즉, 최초에만 생성
        if (instance == null) {
            instance = new Chick();
        }
        return instance;
    }

    private String name = "글루따띠온";
    public String getName () { return name; }
    public void setName (String name) { this.name = name; }

}

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

        Chick chick1 = Chick.getInstance(); // Chick 첫 번째 호출
        Chick chick2 = Chick.getInstance(); // Chick 두 번째 호출

        System.out.println(chick1);
        System.out.println(chick2);

    }
}

📌 출력 결과
Singleton.Chick@3b07d329
Singleton.Chick@3b07d329

→ 출력된 두 객체의 해시코드가 동일하다! 두 번째로 Chick을 호출할 때 이미 instance가 존재하므로 새로운 instance가 생성되지 않고 기존의 인스턴스가 반환된 것이다 :>

위 코드에 적용된 싱글톤 패턴의 기본 구현 방법을 정리한 것은 다음과 같다.


싱글톤 패턴의 기본적인 구현 방법

  1. private static으로 싱글톤 객체의 인스턴스를 선언
  2. getInstance() 메서드 : 처음 실행될 때만 생성되고 그 후로는 이미 생성된 인스턴스를 반환하도록 메서드 작성
  3. 기본 생성자를 private으로 선언 → 외부에서 새로운 객체를 생성하지 못하도록 막음



🐣싱글톤 패턴의 문제와 해결방안

싱글톤 패턴은 멀티 스레딩 환경에서 사용할 경우 발생할 수 있는 문제들이 있다.

문제 : 여러 개의 인스턴스 생성

멀티 스레드 환경에서 안전하지 않다.
즉, instance가 존재하지 않을 때 다중 스레드가 동시에 if(instance == null) 에 접근하게 된다면 여러 개의 인스턴스가 생성될 수도 있다.


해결1. synchronized 키워드 사용

한 번에 하나의 스레드만 실행 가능하도록 하는 synchronized 키워드를 사용해 멀티 스레드에서의 동시성 문제를 해결하는 방법이다. (lock을 이용하는 방법)

public class Chick {

    private static Chick instance;

    private Chick() {}

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

→ 하지만 이 방법은 Thread-safe를 보장하기 위해 앞서 해당 메서드를 점유한 스레드의 작업이 끝나기 전까지는 메서드를 사용할 수 없다. 이 과정에서 성능 저하가 발생할 수 있다는 점을 고려해야 한다.

해결2. 미리 만들기 (이른 초기화 : eager initialization)

동시에 getInstance()가 호출되어 두 개의 인스턴스가 생기는 문제는 인스턴스를 생성하는 부분 → new Chick()을 생성자 내부에서 빼내고 정적 변수 선언 시 생성하도록 함으로써 해결할 수 있다!

public class Chick {
    
    private static Chick instance = new Chick();
    
    private Chick() {}

    public static Chick getInstance() {
        return instance;
    }
    
}

→ 이렇게 하면 getInstance()의 호출 시점과 관계 없이, 클래스가 처음 사용되거나 로드될 때 단 한 개의 인스턴스만 생성되기에 성능이나 멀디스레드 문제 없이 싱글톤 패턴을 구현할 수 있다.
→ 인스턴스의 생성 비용이 그렇게 크지 않다면 괜찮다.
→ 다만, 이렇게 초기에 만들어 놓고 사용하지 않는다면 낭비인 것은 반드시 알아두어야 한다.


해결3. double checked locking 이용하기

인스턴스가 만들어져있는지 한 번 먼저 확인한 후에 synchronized 블럭에서 2차로 확인하는 방식이다. 매번 synchronized가 걸리지 않고 인스턴스 생성 시 한 번만 걸린다.

public class Chick {

    private static volatile Chick instance;

    private Chick() {}

    public static Chick getInstance() {
        if (instance == null) {
            synchronized (Chick.class) {
                if (instance == null) {
                    instance = new Chick();
                }
            }
        }

        return instance;
    }
}

→ 이전에 메서드 자체제 synchronized 키워드를 사용한 것과 달리, 성능 상의 문제도 없다!
volatile 키워드까지 적어주어야 서로 다른 스레드라도 같은 메모리를 참조한다는 보장을 할 수 있다. (단순 synchronized 키워드를 사용할 경우 메서드 전체가 동기화되기 때문에 스레드 간 메모리 일관성이 자동으로 보장되는데, 이는 아님.)
→ 다만, 코드가 비교적 복잡해질 뿐더러 volatile 키워드의 사용 이유에 대해 정확히 이해하려면 자바 1.5 이하의 메모리 활용 방식부터 이해해야 하는 복잡함이 있다.

volatile 키워드
volatile 키워드는 쓰레드들에 대한 변수의 변경의 가시성을 보장한다.
: 멀티쓰레드 어플리케이션에서의 non-volatile 변수에 대한 작업은 성능상의 이유로 CPU 캐시를 이용하는데, 이는 다음과 같은 문제를 야기한다.
Thread1 은 counter 변수를 증가시키고, Thread1 과 Thread2 가 때에 따라서 counter 변수를 읽는다. 만일 counter 변수에 volatile 키워드가 없다면, counter 변수가 언제 CPU 캐시에서 메인 메모리로 쓰일지(written) 보장할 수 없다. CPU 캐시의 counter 변수와 메인 메모리의 counter 변수가 다른 값을 가질 수 있다는 것이다.
이렇게 쓰레드가 변경한 값이 메인 메모리에 저장되지 않아서 다른 쓰레드가 이 값을 볼 수 없는 상황을 '가시성' 문제라 한다. 이러한 counter 변수에 volatile 키워드를 선언하면 이 변수에 대한 쓰기 작업은 즉각 메인 메모리로 이루어질 것이고, 읽기 작업 또한 메인 메모리로부터 다이렉트로 이루어질 것이다.


해결4. static inner class 사용하기 (권장👍)

stasic inner class는 구조상 애플리케이션 시작 시 클래스 로더에서 초기화되지 않고, getInstance()가 호출되었을 때 JVM 메모리에 로드되고 객체를 생성한다. 즉, static inner 클래스를 선언하고 그 안에서 static 변수로 인스턴스를 생성하도록 한다.

public class Chick {

    private Chick() {}

    public static class ChickHolder {
        private static final Chick INSTANCE = new Chick();
    }

    public static Chick getInstance() {
        return ChickHolder.INSTANCE;
    }
    

}

→ 인스턴스가 static으로 선언되어있으므로 한 개만 생성되도록 보장되며, static inner class 내에서 인스턴스가 생성되므로 해결2와 달리 실제 사용할 경우에만 인스턴스가 생성된다.

싱글톤을 깨는 방법
이렇게 해결 4번까지 생각해봐도 이들은 여전히 싱글톤이 깨질 수 있다는 약간의 문제가 있다.
물론 협업하는 개발자라면 싱글톤 클래스가 만들어진 의도대로 사용할 테지만, 우리는 언제든 '대비'를 해야 한다.🥲
싱글톤을 코드 상에서 깨도록 하는 방법은 다음의 두 가지이다.

  • Reflection 사용하기
    : 리플렉션을 사용하면 런타임에 private 생성자에 접근하여 새로운 인스턴스를 생성할 수 있다.
  • 직렬화 / 역직렬화 사용하기
    : 클래스를 역직렬화할 때 새로운 인스턴스가 생성되어 싱글턴 속성을 위반한다. 이는 역직렬화 시 필수적으로 호출되는 readResolve() 메서드가 이미 생성된 인스턴스를 반환하도록 수정하면 해결할 수 있긴 하다.

→ 이러한 추가적인 문제까지 모두 해결할 수 있는 방법이 5번 방법이다!


해결5. 절대 깰 수 없는 싱글톤을 만드는 방법 : enum 사용하기 (권장👍)

싱글톤 클래스를 enum 타입으로 구현하는 방법이다.
: enum 타입은 Reflection으로 생성자를 찾아서 억지로 생성할 수 없으므로 리플렉션 문제도 발생하지 않는다. enum은 내부적으로 Enum 클래스를 상속하여 직렬화/역직렬화가 가능하지만 역직렬화 시에 자동으로 싱글톤을 보장하도록 설계되었기에 역직렬화 시 싱글톤이 깨지는 문제도 발생하지 않는다.
enmm 타입은 인스턴스가 JVM 내에 하나만 존재한다는 것이 100% 보장되므로, Java에서 싱글톤을 만드는 가장 좋은 방법으로 권장된다!✨

public enum SingletonEnum {
    INSTANCE;
    String name;
    Integer count;

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }
}

→ 단, enum 열거형을 직렬화할 때 필드 변수는 소실된다. 즉, 위 코드에서 name, count 변수는 직렬화되지 않고 소실된다는 점을 주의해서 사용해야 한다!
→ 기본적으로 상속하는 Enum 클래스 외에 다른 클래스를 상속하지 못한다는 점을 유의하자.
→ 추가적으로, 추후 enum을 일반형 타입으로 변경해야 하는 경우 대체가 쉽지 않으므로 싱글톤 클래스로서 enum은 안정성이 보장되어야 하는 경우에 신중히 사용하는 것이 좋겠다 :)




0개의 댓글