eunnnn 2023. 4. 23. 23:53

싱글톤 패턴이 사용되는 이유

웹 애플리케이션은 수많은 클라이언트에서 서비스를 요청받게 되는데, 만약 서버에서 클라이언트의 요청을 받을때마다 클래스 인스턴스를 생성하게 되면 JVM 메모리의 사용량이 증가하게 되고 서버는 부하를 감당할 수 없게 될 것이다.

@Configuration
public class AppConfig {

    @Bean
    public MemberRepository getMemberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy getDiscountPolicy() {
        return new RateDiscountPolicy();
    }

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(getMemberRepository());
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(getMemberRepository(), getDiscountPolicy());
    }
}
public class SingletonTest {

    @Test
    @DisplayName("스프링 없는 순수한 DI 컨테이너")
    void pureContainer() {
        AppConfig appConfig = new AppConfig();

        //1. 조회 : 호출할때마다 객체를 생성
        MemberService memberService1 = appConfig.memberService();

        //2. 조회 : 호출할때마다 객체를 생성
        MemberService memberService2 = appConfig.memberService();

        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);

        Assertions.assertThat(memberService1).isNotSameAs(memberService2);
    }
}

결과

memberService1 = com.example.springdemostudy.member.MemberServiceImpl@29526c05
memberService2 = com.example.springdemostudy.member.MemberServiceImpl@42b02722

SingletonTest 클래스의 pureContainer 메서드에서는 Appconfig 객체를 생성하여 MemberService 클래스의 인스턴스를 만들고 있다. 이 때 Appconfig를 통해 만들어진 2개의 Memberservice 인스턴스의 참조값이 다르다.

즉, 스프링이 아닌 순수 Java 코드를 이용한 AppConfig는 사용자의 요청이 들어올 때마다 새로운 MemberService 클래스의 인스턴스를 생성하게 되고 그만큼 JVM의 메모리 사용량이 증가하게 된다.

이러한 상황을 해결하기 위해 등장한 것이 싱글톤 패턴이며, AppConfig에서 생성한 MemberService 객체를 공유함으로써 문제를 해결할 수 있다.

 

그래서 싱글톤 패턴이란?

하나의 클래스에 오직 하나의 인스턴스만 갖도록 하고, 생성된 객체를 어디에서든지 참조할 수 있도록 하는 패턴이다.

싱글톤으로 이용할 클래스를 외부에서 new 생성자를 통해 인스턴스화 하는 것을 제한하기 위해 클래스 생성자 메서드에 private 키워드를 붙여주면 된다.

 

싱글톤 패턴의 사용처

보통 싱글톤 패턴이 적용된 객체가 필요한 경우는 그 객체가 리소스를 많이 차지하는 역할을 하는 무거운 클래스일때 적합하다.

대표적으로 데이터베이스 접속, 디스크 연결, 네트워크 통신, DBCP 커넥션풀, 스레드풀, 캐시, 로그 기록 객체 등에 이용된다.

 

싱글톤 패턴의 장단점

장점

  • 한번의 new 연산자를 통해서 고정된 메모리 영역을 사용하기 때문에 추후 해당 객체에 접근할 때 메모리 낭비를 방지할 수 있다. 
  •  다른 클래스 간에 데이터 공유가 쉽다.

단점

  • 테스트가 어렵다.
    싱글톤 인스턴스는 자원을 공유하고 있기 때문에 테스트가 결정적으로 격리된 환경에서 수행되려면 매번 인스턴스의 상태를 초기화시켜주어야 한다. 그렇지 않으면 어플리케이션 전역에서 상태를 공유하기 때문에 테스트가 온전하게 수행되지 못한다.
  • 멀티쓰레드 환경에서 컨트롤이 어렵다.
  • 모듈 간 의존성이 높아진다.
    하나의 싱글톤 클래스를 여러 모듈들이 공유를 하니까, 만일 싱글톤의 인스턴스가 변경되면 이를 참조하는 모듈들도 수정이 필요하게 된다.
  • S.O.L.I.D 원칙이 위배되는 경우가 많다.
    의존 관계상 클라이언트가 구체 클래스에 의존 -> DIP를 위반하며, OCP 또한 위반할 가능성이 높다.
    DIP : 의존 관계상 클라이언트가 인터페이스와 같은 추상화가 아닌, 구체 클래스에 의존하게 된다.
    OCP : 싱글톤 인스턴스가 혼자 너무 많은 일을 하거나, 많은 데이터를 공유시키면 다른 클래스들 간의 결합도가 높아지게 된다.

    SRP :  싱글톤 인스턴스 자체가 하나만 생성기 때문에 여러가지 책임을 지니게 되는 경우가 많다.

 

Spring에서의 싱글톤

스프링에서는 사용자가 이렇게 일일이 구현하지 않아도 싱글톤 패턴의 문제점들도 보완해주면서 싱글톤 패턴으로 클래스의 인스턴스를 사용하게 해 주는데 이것을 싱글톤 컨테이너라고 한다.

일반적으로 Singleton Object는 어플리케이션에서 글로벌하게 유일해야하지만, Spring에서는 이러한 제약이 완화된다. Spring에서는 하나의 Spring IoC Container 당 하나의 Singleton Object를 갖도록 제한하여 Spring Framework는 모든 Bean들을 Singleton으로 생성한다.

위 그림과 같은 형태로 고객의 요청이 올 때마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다.

 

싱글톤 패턴 설계의 주의점

싱글톤 패턴은 여러 클라이언트가 객체를 공유하므로 공유되는 객체는 어떠한 상태를 유지(stateful) 되게 설계하면 안 되고, 다음과 같은 주의사항을 반드시 지켜야 한다.

  • 특정 클라이언트에 의존적인 필드가 있으면 안 된다.
  • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안 된다.
  • 읽기만 가능해야 한다.
  • 필드 대신 Java에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다. 
public class StatefulService {

    private int price; // 상태를 유지하는 필드

    public void order(String name, int price) {
        System.out.println("name = " + name + " price = " + price);
        this.price = price; // 여기가 문제!
    }

    public int getPrice() {
        return price;
    }
}
class StatefulServiceTest {

    @Test
    void statefulServiceSingleton() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

        // ThreadA : A 사용자 10000원 주문
        statefulService1.order("userA",10000);
        // ThreadB : B 사용자 20000원 주문
        statefulService2.order("userB",20000);

        // ThreadA : 사용자 A 주문금액 조회
        int price = statefulService1.getPrice();
        System.out.println("price = " + price);

        Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
    }

    static class TestConfig {
        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }

}

결과

name = userA price = 10000
name = userB price = 20000
userA price = 20000

userA는 10,000원을 주문하였지만 주문금액을 조회해보니 20,000원이 나오는 문제가 발생한다. 원인은 userA와 userB는 각각 statefulService1, statefulService2 객체를 사용하지만 스프링 컨테이너에서 실제로 받은 클래스의 인스턴스는 공유되고 있고 userB의 주문금액 값이 공유되고 있는 StatefulService 클래스 인스턴스의 price 값을 20000으로 변경하기 때문이다.

 

해결방법은 공유되는 필드인 price를 제거하고 order 메서드에서 값을 반환하는 것이다.

public class StatefulService {

    public int order(String name, int price) {
        System.out.println("name = " + name + " price = " + price);
        return price;
    }
}
class StatefulServiceTest {

    @Test
    void statefulServiceSingleton() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

        // ThreadA : A 사용자 10000원 주문
        int userAPrice = statefulService1.order("userA",10000);
        // ThreadB : B 사용자 20000원 주문
        int userBPrice = statefulService2.order("userB",20000);

        // ThreadA : 사용자 A 주문금액 조회
        System.out.println("userA price = " + userAPrice);

        Assertions.assertThat(userAPrice).isEqualTo(10000);
    }

    static class TestConfig {
        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }
}

결과

name = userA price = 10000
name = userB price = 20000
userA price = 10000

 

 

 

출처

더보기