JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

카테고리 없음

스프링의 싱글톤 패턴과 무상태 설계의 필요성

lamarcK 2025. 6. 1. 01:25

스프링 프레임워크는 기본적으로 싱글톤 패턴을 사용한다. 즉, 어떤 클래스의 인스턴스를 단 하나만 생성하도록 보장하는 디자인 패턴을 사용한다는 것이다.

그렇다면 왜 인스턴스를 하나만 만들까?

요청마다 인스턴스를 만들면 안 되는 이유 (싱글톤의 필요성)

만약 웹 애플리케이션에서 사용자의 웹 요청이 올 때마다, 해당 요청을 처리하는 객체(예: 회원 가입을 처리하는 UserService, 주문을 처리하는 OrderService 등)를 매번 새로 생성한다고 가정해 보자. 이는 다음과 같은 심각한 문제를 야기한다.

식당에서 손님 올 때마다 '새 주방'을 만드는 것

손님이 식당에서 들어와서 주문을 할 때마다 그 손님 전용의 주방을 새로 만들고 요리가 끝나면 주방을 없앤다면 어떻게 될까? 이는 정말로 비효율적인 방법일 것이다. 만약 손님의 수가 10명 정도라면 이정도 부하는 감수할 수 있을지도 모르지만 손님의 수가 1000명, 10000명을 넘는다면? 감당이 불가능한 부하가 걸릴 것이다.

스프링이라는 프레임 워크가 애초에 엔터프라이즈급 애플리케이션 개발을 지원하는 목적으로 만들어졌다는 것을 생각하면 좀 더 이해가 쉽다. 다수의 사용자를 관리하는 것을 기본 전제로 두기 때문에 사용자가 아무리 많아도 인스턴스를 1개만 만들어서 공통적으로 관리하는 방식을 선택한 것이다.

하나의 '공용 주방'을 사용하는 것

때문에 스프링의 싱글톤 설계는 현실적인 문제점을 해결하기 위해서 사용된 것이라 할 수 있다. 간단하게 말하면 메모리 낭비, 성능 저하, 자원 관리의 복잡성 등을 극복하기 위한 방법인 것이다. 주방을 하나만 사용한다면 손님마다 새롭게 주방을 만들 필요도 없고 추가적으로 인스턴스를 생성하기 위해 메모리를 소비할 필요도 없다.


그런데 여기서 하나의 문제점이 생겨난다. 인스턴스가 하나라는 것은 이 단 하나의 인스턴스가 가지고 있는 필드 변수(멤버 변수)를 애플리케이션의 모든 사용자와 모든 요청이 공유한다는 것을 의미한다. 만약에 인스턴스에서 사용되는 공통 변수에 사용자가 개입할 수 있다면 많은 문제가 생기기 때문에 싱글톤의 인스턴스는 기본적으로 무상태 설계로 만들어져야 한다.

왜 필드 변수를 공유하지 않아야 할까? (무상태 설계의 필요성)

싱글톤 방식으로 단 하나의 인스턴스를 공유하는 것은 효율적이지만, 웹 애플리케이션의 본질적인 특성 때문에 잠재적인 위험이 발생한다. 웹 애플리케이션은 동시에 여러 사용자의 요청을 처리해야 하며, 각 요청은 별도의 쓰레드(Thread)에 의해 처리된다. 즉, 웹 애플리케이션은 멀티쓰레드(Multi-thread) 환경이다.

여러 손님의 주문을 모두 한 주문서에 추가하는 것

예를 들어서 손님 A가 스테이크를 시키고 B가 샐러드를 시켰다고 해보자. 그런데 그런 주문을 기록하는 곳이 한군데라서 주문 내역에 스테이크 1, 샐러드 1이 찍혀버렸다. 그렇다면 누가 뭘 시켰는지 알 수있는가? 또한 어찌어찌 각각의 손님에게 알맞은 음식을 서빙했다 쳐도 계산 시점에서 손님 A와 B 모두에게 스테이크와 샐러드 가격 만큼을 계산하라고 한다면 어떻게 될까? 그런 식당은 바로 망해버릴 것이다.

사용자간에 공유하는 '상태'를 가지지 않아야 하는 이유

상태란 객체가 가지고 있는 현재의 데이터 값을 의미한다. 이러한 데이터 값은 주로 필드 변수(멤버 변수, 인스턴스 변수)에 저장된다. 그런데 싱글톤으로 만들어진 서비스 객체가 사용자마다 달라지는 '상태'를 필드 변수에 저장한다면, 여러 쓰레드가 동시에 이 필드 변수에 접근하여 값을 덮어쓰거나 잘못 읽을 수 있다. 싱글톤은 인스턴스 1개를 모든 사용자가 공유하기 때문에 필드 변수도 모두 공유하기때문이다.

 

이를 경쟁 조건(Race Condition)이라고 하며, 결과적으로 데이터가 엉키거나, 다른 사용자의 정보가 노출되는 등 심각한 동시성 문제(Concurrency Issue)가 발생한다. 이는 애플리케이션의 안정성을 해치고 예측 불가능한 버그를 유발한다.


따라서 싱글톤으로 관리되는 인스턴스는 기본적으로 무상태(Stateless) 설계로 만들어져야 한다. 즉, 필드 변수에 사용자마다 달라지는 상태를 저장하지 않고, 순수한 기능을 제공하는 방식으로 설계해야 한다.

// ⚠️ 위험한 코드 (Stateful 한 싱글톤 서비스 예시)
public class DangerousOrderService {
    private String customerId; // ❗️여러 쓰레드가 공유하는 '상태' 필드 (공용 화이트보드)

    public void processOrder(String id, int amount) {
        this.customerId = id; // 첫 번째 손님(쓰레드1)이 자신의 ID "A"를 여기에 저장
        // 잠시 후 두 번째 손님(쓰레드2)이 자신의 ID "B"를 여기에 저장 -> customerId가 "B"로 바뀐다!
        System.out.println("현재 처리 중인 고객 ID: " + this.customerId);
        // ... amount를 이용한 주문 처리 로직
    }
}

위 DangerousOrderService가 싱글톤으로 관리될 경우, A 사용자가 processOrder("A", 1000)을 호출하여 customerId를 "A"로 설정하는 순간, B 사용자가 processOrder("B", 2000)을 호출하면 customerId는 "B"로 덮어씌워진다. 이때 A 사용자의 요청 처리가 계속된다면, 실제로는 B 사용자의 ID가 읽히거나 엉뚱한 결과가 나올 수 있다.

해결책 : 무상태 설계 (Stateless Design)

이러한 동시성 문제를 방지하고 싱글톤의 장점을 안전하게 활용하기 위한 핵심적인 해결책이 바로 무상태 설계이다. 말 그대로 상태가 없는 설계를 뜻한다. 싱글톤으로 관리되는 객체(스프링 빈)는 클라이언트별로 달라지는 데이터를 객체의 멤버 변수(필드)에 직접 저장하지 않아야 한다. 객체의 필드는 주로 다른 객체에 대한 의존성(예: UserRepository 객체)을 주입받거나, 고정된 설정 값을 저장하는 용도로만 사용해야 한다. 이러한 의존성 객체나 설정 값은 여러 쓰레드가 공유해도 문제가 되지 않는다.

무상태 설계의 구체적인 구현 방법

  • 메서드 파라미터 활용: 사용자별로 달라지는 데이터는 객체의 필드(멤버 변수)에 저장하지 않고, 각 메서드의 파라미터(매개변수)로 전달하여 사용한다. 이렇게 하면 각 요청의 데이터가 다른 요청의 데이터와 섞일 일이 없어진다.
public class SafeOrderService {
    // 필드에 상태를 저장하지 않는다.
    // private String customerId; (X)

    public void processOrder(String customerId, int amount) {
        // customerId와 amount는 메서드 호출 시마다 새로 전달받는 데이터이다.
        System.out.println("현재 처리 중인 고객 ID: " + customerId);
        // ... amount를 이용한 주문 처리 로직
    }
}

이런식으로 메서드 등을 호출해서 생겨난 '메모리에 저장된 데이터'를 "스택 프레임(Stack Frame)" 또는 "메서드 스택 프레임"이라고 부른다.

// 실행 예시
OrderService orderService = new OrderService();  // 싱글톤 인스턴스

// 다른 쓰레드에서 동시에 실행되는 경우
Thread1: orderService.processOrder("user1", 1000);  // 스택 프레임 1
Thread2: orderService.processOrder("user2", 2000);  // 스택 프레임 2
Thread3: orderService.processOrder("user3", 3000);  // 스택 프레임 3
  • 지역 변수(Local Variable) 사용: 메서드 내부에서만 사용되는 데이터는 지역 변수로 선언하여 활용한다. 지역 변수는 해당 메서드 호출이 끝나면 사라지므로 다른 쓰레드에 영향을 주지 않는다.
  • 쓰레드 로컬(ThreadLocal) 사용 (예외적인 경우): 매우 제한적인 경우에만 사용한다. ThreadLocal은 각 쓰레드(Thread)마다 독립적인 저장 공간을 제공하여 데이터를 공유하지 않고도 특정 쓰레드에만 고유한 데이터를 저장할 수 있도록 한다. 하지만 잘못 사용하면 메모리 누수나 디버깅의 어려움을 야기할 수 있으므로, 일반적인 무상태 설계에서는 권장하지 않으며, 특정 목적(예: 트랜잭션 컨텍스트, 사용자 인증 정보)으로만 활용한다.