Search

[JVM vs Python] 프로세스와 메모리로 이해하는 싱글톤

카테고리
StudyForCareer
태그
Python
JVM
게시일
2026/10/23
수정일
2026/10/23 08:31
시리즈
1 more property
Spring Boot로 서버를 만들다 보면 “서비스는 싱글톤이므로 무상태로 만들어야 한다”는 말을 듣는다. 이 문장을 이해하려면 먼저 두 가지를 나눠야 한다. 싱글톤은 객체의 개수와 공유 범위에 관한 개념이고, 무상태는 요청 데이터를 어디에 보관하는지에 관한 설계다.
이 글은 JVM에서 실행하는 Java·Kotlin 기반 Spring Boot 애플리케이션을 중심으로 설명하고, Python의 여러 워커 프로세스 구성과 비교한다. 두 JVM 언어의 코드 표현은 함께 보되, 같은 객체에 누가 접근하는지에 집중한다. Kotlin은 JVM 외의 실행 대상도 지원하지만, 여기서는 Kotlin/JVM만 다룬다.
Spring Boot는 Spring Framework를 기반으로 자동 설정과 내장 서버 등 애플리케이션의 구성·실행 기능을 제공한다. 빈을 관리하는 주체는 Spring 컨테이너이며, 프로세스 경계와 JVM 메모리 공유 원리는 그대로 적용된다. 따라서 이 글에서는 실행 환경을 Spring Boot로, 빈 관리 규칙을 Spring의 규칙으로 구분해 설명한다.

1. 개요

개념
확인할 질문
서비스 예시
싱글톤
어느 범위에서 인스턴스가 하나인가?
같은 Spring 컨테이너에서 같은 빈 정의의 객체를 재사용한다.
무상태
요청별 데이터를 공유 객체에 남기는가?
현재 사용자나 계산 중간값을 서비스 필드에 보관하지 않는다.
싱글톤 객체도 상태를 가질 수 있다. 호출 횟수를 누적하거나 결과를 캐시하면 상태가 있는 것이다. 동시성 처리를 올바르게 했더라도 그 객체가 무상태로 바뀌지는 않는다.
반대로 무상태 서비스에 필드가 전혀 없어야 하는 것은 아니다. 변경하지 않는 설정이나 의존성을 보관할 수 있다. 다만 의존성 내부의 공유 상태가 안전한지는 별도로 확인해야 한다.
여기서 무상태는 서비스가 요청별 작업 상태를 인스턴스에 보관하지 않는다는 의미다. DB를 조회하는 서비스는 같은 인자를 받아도 DB 값이 바뀌면 다른 결과를 낼 수 있다. 무상태와 순수 함수는 같은 개념이 아니다.
그렇다면 왜 서비스 객체를 재사용할까? 요청마다 서비스와 의존성 관계를 새로 구성하지 않고, 이미 준비된 객체에 작업을 맡길 수 있기 때문이다. 다만 객체 수가 줄어드는 것과 처리 속도가 빨라지는 것은 별개의 문제다. 실제 처리 시간은 DB 조회나 외부 API 호출 같은 작업에도 좌우된다. 싱글톤의 의미를 단순한 성능 최적화로만 이해하면 공유 상태의 책임을 놓치기 쉽다.

2. 싱글톤(Singleton)

프로세스·스레드·메모리

프로세스는 실행 중인 프로그램의 자원·주소 공간 경계이고, 스레드는 그 안에서 실행되는 흐름이다. 같은 JVM의 스레드들은 힙을 공유한다. 따라서 여러 스레드가 같은 서비스 객체를 참조할 수 있다. 이 공유 구조는 Java와 Kotlin/JVM에 공통으로 적용된다. Java 필드를 Kotlin 프로퍼티로 바꾸더라도, 같은 객체에 보관한 가변 값을 여러 스레드가 읽고 쓰는 문제는 남는다.
반면 메서드 호출의 프레임과 지역 변수는 호출별로 구분된다. 같은 객체의 같은 메서드를 호출해도 각 호출의 매개변수와 지역 값은 별개다. 이는 JVM 명세의 논리적인 메모리 모델이며, 실제 물리 배치나 최적화 방식까지 뜻하지는 않는다.
예를 들어 두 요청이 greet()를 실행하면, 각 호출의 userName은 서로 다른 변수다. 하지만 두 호출에서 this가 가리키는 서비스 객체는 같을 수 있다. 그러면 this.currentUser는 두 호출이 함께 읽고 쓰는 저장 공간이 된다. 같은 메서드라는 점보다 값을 호출 안에 두었는지, 공유 객체에 두었는지가 중요하다.
두 스레드가 같은 서비스를 호출할 때 무엇을 공유하며, 프로세스가 하나 더 생기면 무엇이 달라질까?
싱글톤 전체 요약
왼쪽 JVM의 두 호출은 같은 Service A를 사용한다. 공유 서비스에 요청별 값을 넣으면 서로 덮어쓸 수 있지만, 호출별 값으로 유지하면 해당 필드 공유를 피할 수 있다. 오른쪽의 별도 JVM과 Python 워커에는 각각 별도 객체가 있다. 명시적 공유 메모리 같은 특수 구성은 생략했다.

공유 범위

Spring Boot가 사용하는 Spring 컨테이너에서 빈의 기본 스코프는 singleton이며, 그 범위는 컨테이너마다, 빈 정의마다 하나다. 같은 클래스를 별도 빈 정의로 등록하거나 별도 컨테이너에서 관리하면 인스턴스가 여럿일 수 있다.
같은 클래스의 빈을 다른 이름이나 컨테이너로 등록하면, 어떤 요청끼리 같은 객체를 사용할까?
빈 정의와 컨테이너의 공유 범위
Context A의 두 요청은 greetingService의 Instance 1을 공유한다. 같은 클래스라도 별도로 등록한 otherService는 Instance 2이고, Context B의 빈은 Instance 3이다. 점선은 빈 관리 범위이며 물리적인 메모리 격리 경계가 아니다.
클래스의 static 필드로 구현한 싱글톤도 JVM 전체에서 무조건 하나라고 단정할 수 없다. JVM에서 클래스의 정체성에는 정의한 클래스 로더가 포함되므로, 같은 이름의 클래스를 서로 다른 로더가 정의하면 각각의 정적 상태를 가질 수 있다. JVMS 25 §5.3
여기서 Spring 컨테이너는 빈의 생성과 연결, 수명을 관리하는 논리적인 범위다. 별도의 프로세스나 격리된 메모리 공간을 의미하지 않는다. 따라서 “컨테이너가 객체를 관리한다”는 말과 “요청마다 객체 사용을 격리한다”는 말도 구분해야 한다.

Kotlin object와 Spring 싱글톤

Java에서는 정적 필드 등에 인스턴스를 보관해 싱글톤을 구현할 수 있고, Kotlin은 object GreetingService처럼 이름 있는 객체 선언을 제공한다. 하지만 Spring Boot 애플리케이션에서 싱글톤 서비스를 만들기 위해 Kotlin의 object를 사용할 필요는 없다. Java와 Kotlin 모두 일반 클래스를 빈으로 등록하고, 컨테이너가 singleton 스코프로 관리하게 할 수 있다.
object는 언어 차원의 객체 선언이고 Spring singleton은 컨테이너의 빈 관리 규칙이다. Kotlin/JVM의 object도 클래스 로더·프로세스 경계를 넘어 하나가 되지는 않는다. 또한 객체 초기화가 스레드 안전하다는 보장과, 초기화 후 프로퍼티 변경이 안전하다는 보장은 다르다. object 안에 요청별 값을 저장해도 공유 상태 문제는 그대로다.

3. 무상태(Stateless)

요청별 데이터와 공유 상태

다음은 같은 문제를 Java와 Kotlin으로 작성한 예시다. Spring Boot 애플리케이션에서 각 클래스를 Spring 컨테이너의 singleton 빈으로 등록하고, 두 요청이 같은 객체를 서로 다른 스레드에서 호출한다고 가정한다. 빈 등록 설정과 어노테이션은 생략했다.
Java 필드
class GreetingService { private String currentUser; String greet(String userName) { this.currentUser = userName; return "Hello, " + this.currentUser; } }
Java
복사
Kotlin 프로퍼티
class GreetingService { private var currentUser: String = "" fun greet(userName: String): String { currentUser = userName return "Hello, $currentUser" } }
Kotlin
복사
요청 간 데이터 오염
Java의 currentUser 필드와 Kotlin의 currentUser 프로퍼티는 이 예시에서 같은 역할을 한다. 두 코드 모두 요청별 값을 공유 객체에 저장했다.
A가 저장한 이름은 어느 순간 B의 이름으로 바뀔까?
요청별 값 덮어쓰기 (GIF)
요청 A가 Alice를 저장한 뒤 요청 B가 Bob으로 덮어쓰고, 그다음 A가 필드를 읽는 순서가 가능하다. 이때 A는 자기 입력과 다른 인사말을 받는다. 객체를 하나만 생성했다는 사실은 메서드 실행을 순서대로 처리해 주지 않는다.
그림은 가능한 실행 순서 하나를 설명한 것이며, 장면 사이의 대기 시간은 읽기 위한 간격이다. 실제 스케줄링 순서나 발생 빈도를 뜻하지 않는다. 마지막 장면 뒤의 Start of example은 예시의 반복 시작이다.

요청별 값의 분리

이 예시에서 사용자 이름은 공유할 이유가 없다. 호출의 매개변수를 그대로 사용하면 된다.
Java 매개변수
class GreetingService { String greet(String userName) { return "Hello, " + userName; } }
Java
복사
Kotlin 매개변수
class GreetingService { fun greet(userName: String): String = "Hello, $userName" }
Kotlin
복사
두 언어 모두 서비스 인스턴스는 하나로 유지하면서 요청별 값을 분리했다. 공유 필드 자체를 제거했으므로, 이 값을 보호하기 위한 락도 필요하지 않다.
문제가 되는 것은 요청이 끝난 뒤까지 값을 오래 보관하는 경우만이 아니다. 위의 currentUser처럼 메서드 안에서 잠깐 저장하고 바로 읽더라도, 그 사이에 다른 호출이 같은 필드를 변경할 수 있다. “곧 지울 임시 값”이라는 의도는 공유 범위를 바꾸지 않는다.

락과 상태 관리

위 예시의 greet() 전체를 같은 객체의 락으로 보호하면 두 호출이 그 구간을 동시에 실행하지 못하게 할 수 있다. 하지만 호출을 직렬화하면서도 currentUser라는 공유 상태는 남는다. 이 예시에서는 요청별 이름을 매개변수로 전달하는 것만으로 문제가 사라지므로, 공유할 필요가 없는 값부터 없애는 편이 단순하다.
반면 모든 호출의 합계를 세는 카운터처럼 실제로 함께 관리해야 하는 값도 있다. 이런 값에는 적절한 동기화가 필요하다. 무상태를 기본으로 삼는다는 것은 필요한 상태까지 숨기라는 뜻이 아니라, 공유가 필요한 값과 호출에만 필요한 값을 구분하라는 뜻이다. 상태의 소유자와 갱신 규칙이 명확해야, 객체를 재사용하는 이점과 여러 요청의 정확성을 함께 유지할 수 있다.

지역 참조와 객체 불변성

호출마다 지역 변수가 따로 있어도, 두 화살표의 끝이 같은 목록이면 무엇이 공유될까?
지역 변수와 공유 객체
Call A와 Call B의 localRef는 별개지만, 두 참조가 가리키는 Shared mutable list는 하나다. 한 호출이 이 목록을 변경하면 다른 호출이 사용하는 대상도 바뀐다. 그림은 참조 관계이며 두 호출의 실행 순서를 나타내지는 않는다.
Java의 List<Order> orders = sharedOrders;나 Kotlin의 val orders = sharedOrders처럼 공유 목록을 지역 변수에 대입하면 변수만 별개이고 목록 객체는 그대로 공유된다.
Java의 final 지역 변수와 Kotlin의 val 지역 변수는 참조 재할당을 막을 뿐, 가리키는 객체를 자동으로 불변으로 만들지는 않는다. 예를 들어 Kotlin의 val names = mutableListOf<String>()는 names에 다른 목록을 대입할 수 없지만 names.add("Alice")는 가능하다. Kotlin에서 val 프로퍼티는 읽기 전용이라는 뜻이며, 커스텀 getter가 있다면 읽을 때마다 다른 값을 반환할 수도 있다. val이라는 선언만으로 객체 불변성이나 무상태를 판단하지 않는다.
따라서 “지역 변수인가?”에서 끝내지 말고 “그 변수가 가리키는 객체를 다른 요청도 변경하는가?”까지 확인해야 한다.

4. Python 비교

실행 구성과 메모리 공유

Spring Boot 애플리케이션을 JVM 프로세스 하나에서 실행하고 여러 스레드가 서비스 객체 하나를 공유하는 구성과, Python 서버를 여러 워커 프로세스로 실행하는 구성을 비교해 보자. 여기서 여러 스레드의 요청 처리는 이 예시의 실행 조건이다. 실제 요청 처리 방식은 웹 서버와 스레드 설정 등에 따라 달라진다.
실행 구성
일반적인 객체 공유 범위
JVM 프로세스 하나, 여러 스레드
같은 서비스 객체를 공유할 수 있다.
JVM 프로세스 여러 개
각 프로세스에 별도 서비스 객체가 존재한다.
Python 워커 프로세스 여러 개
각 워커에 별도 모듈·애플리케이션 객체가 존재한다.
Python에서 모듈 전역에 만든 객체도 같은 인터프리터에서 해당 모듈을 재사용하는 코드 사이에서는 공유할 수 있다. 일반적인 import는 sys.modules의 모듈 캐시를 확인한다. 하지만 이것이 여러 워커 전체에서 객체 하나를 공유한다는 뜻은 아니다.

전역 객체와 워커

다음은 일반적인 모듈 import를 가정한 설명용 예시다.
# services.py class GreetingService: def greet(self, user_name): return f"Hello, {user_name}" service = GreetingService()
Python
복사
같은 인터프리터에서 services.service를 사용하는 코드는 같은 객체를 재사용할 수 있다. 그러나 워커 두 개를 실행했다면 각 워커의 일반 객체는 별개다. 한 워커에서 객체의 필드를 변경해도 다른 워커의 필드가 함께 갱신되지는 않는다. 이 코드는 객체를 재사용하는 방식이지, 클래스의 다른 인스턴스 생성을 금지하는 구현도 아니다.
또한 워커 사이의 메모리가 분리되어 있다고 해서 워커 안의 공유 상태까지 안전한 것은 아니다. 한 워커가 여러 스레드로 요청을 처리하고 같은 객체를 사용한다면, 앞서 본 공유 필드 문제를 다시 검토해야 한다.
이는 “JVM은 스레드, Python은 프로세스”라는 언어 규칙이 아니다. JVM도 여러 프로세스로 배포하고 Python도 스레드를 사용한다. 비교할 것은 실제로 실행한 프로세스 수와 그 안에서 객체를 공유하는 방식이다.

5. 다중 서버

프로세스별 상태와 락(Lock)

웹 서버 포트 등 자원이 충돌하지 않도록 설정한 뒤 같은 Spring Boot 애플리케이션을 java -jar로 두 번 실행하면 별도 JVM 프로세스 두 개가 만들어진다. 각 프로세스에 Spring 컨테이너와 힙이 있으므로 싱글톤 빈, 메모리 캐시, 일반적인 객체 락도 각각 존재한다. 한쪽의 synchronized로 다른 프로세스의 실행까지 막을 수는 없다.

공통 저장소와 일관성

여러 서버가 함께 지켜야 하는 재고나 주문 상태는 DB 같은 외부 저장소에서 공유하고, 트랜잭션·제약 조건 등으로 업무 규칙을 보장해야 한다. 외부에 저장했다는 사실만으로 동시성 문제가 해결되지는 않는다.
예를 들어 각 프로세스의 싱글톤에 remainingStock = 1을 보관한다고 하자. 두 프로세스가 자신의 값을 보고 각각 주문 하나를 허용하면, 실제 재고가 하나여도 주문은 둘이 될 수 있다. 각 프로세스 내부에서 락을 정확히 사용해도 두 재고 값이 하나로 합쳐지지는 않는다. 이 경우에는 모든 주문이 따를 재고의 기준과 차감 규칙을 공통 저장소에서 정해야 한다.

설계 기준

싱글톤을 설계할 때는 세 가지를 확인하면 된다.
•
범위: 이 객체는 어느 컨테이너·프로세스에서 하나인가?
•
공유: 어떤 스레드나 요청이 같은 객체를 사용하는가?
•
상태: 요청별 값이 공유 객체에 남거나, 다른 요청에 의해 변경되는가?
싱글톤 서비스를 무상태로 만드는 이유는 여기에 있다. 오래 사용하는 객체는 재사용하되, 요청마다 달라지는 작업 값은 각 호출이 갖도록 하는 것이다.