쿠폰 조회 API에 두 요청이 거의 동시에 들어왔다고 생각해 보자. 두 요청 모두 같은 CouponService를 호출하지만, 한쪽은 123번 쿠폰을 찾고 다른 한쪽은 456번 쿠폰을 찾는다. 서비스 객체가 힙 메모리에 단 하나만 존재한다면, 그 안에서 두 요청의 실행 과정이 서로 뒤섞이지는 않을까?
이 의문을 명쾌하게 풀려면 서버가 처음 켜질 때 일어나는 일과 개별 요청이 들어올 때 일어나는 일을 구분해서 바라봐야 한다. 서버 시작 단계가 애플리케이션이 오랫동안 재사용할 객체와 참조 관계를 미리 조립해 두는 시간이라면, 요청 처리 단계는 그렇게 준비된 객체를 빌려 쓰면서 호출마다 독립된 실행 상태를 잠시 만들었다가 지우는 시간이다. 즉, '서비스 객체 하나를 공유하는 것'과 '요청마다 달라지는 작업 데이터를 공유하는 것'은 완전히 다른 차원의 이야기다.
프로세스와 메모리로 이해하는 싱글톤프로세스와 메모리로 이해하는 싱글톤에서 객체의 공유 범위를 살펴봤다면, 이번 글에서는 서버가 기동되는 순간부터 클라이언트에게 응답이 돌아갈 때까지 메모리에 무엇이 생기고 사라지는지 구체적인 흐름을 따라가 본다. (이 글은 동기 방식의 Spring MVC, 내장 Tomcat, 플랫폼 스레드 풀 구성을 기준으로 설명하며, 코드와 도표는 원리 이해를 돕기 위한 교육용 예시로 실제 운영 서비스의 스택 깊이나 할당량을 실측한 결과는 아니다.)1. 개요
프로세스·컨테이너·스레드
Spring Boot 애플리케이션을 실행하면 운영체제로부터 할당받은 하나의 JVM 프로세스가 뜬다. 그리고 그 프로세스 안에서 스프링 컨테이너(ApplicationContext)가 빈(Bean) 객체들을 관리하고, 내장 Tomcat의 작업 스레드들이 들어오는 HTTP 요청을 나누어 처리한다. 흔히 이 모든 것을 뭉뚱그려 “서버가 요청을 처리한다”고 표현하지만, 각 구성 요소가 언제 태어나서 언제까지 살아남는지는 저마다 다르다.
구성 요소 | 생성 시점 | 유지 기간 |
JVM 프로세스 | 애플리케이션 실행 시점 | 프로세스가 종료될 때까지 |
Spring singleton 빈 | 기본 비지연(Non-lazy) 빈 기준, 컨테이너 초기화 과정 | 해당 스프링 컨테이너가 관리하는 동안 |
Tomcat 작업 스레드 | 스레드 풀 정책에 따라 시작 시 또는 부하 발생 시 | 스레드 풀에 머물며 여러 요청에 걸쳐 재사용 |
메서드 호출 프레임 | 각 메서드가 호출되는 순간 | 해당 메서드가 반환되거나 예외로 빠져나올 때까지 |
요청·조회 결과·응답 객체 | 요청 처리 중 코드에서 생성(new)하는 시점 | 누군가 참조를 유지하고 있는 동안 (이후 GC 대상) |
싱글톤 범위와 생성 시점
여기서 다시 한번 짚고 넘어갈 점은, 스프링의 singleton 스코프가 **“하나의 스프링 컨테이너 안에서, 하나의 빈 정의(BeanDefinition)당 인스턴스 하나”**를 뜻한다는 사실이다. 만약 같은 클래스를 이름으로 구분해 두 개의 빈으로 등록하면 인스턴스도 두 개가 생기고, 서버를 수평 확장해 JVM 프로세스를 두 개 띄우면 컨테이너와 서비스 객체도 각각 따로 만들어진다. 또한 지연 초기화 빈은 컨테이너에서 처음 필요해질 때 생성된다. 비지연 싱글톤의 의존성이라면 시작 중에도 생성될 수 있고, HTTP 요청까지 생성이 미뤄지는지는 의존성 구성에 따라 달라진다. 따라서 “모든 빈 객체가 무조건 서버 시작 시점에 만들어진다”고 외우면 곤란하다.
2. 서버 시작과 빈 초기화
빈 정의와 인스턴스
애플리케이션의 진입점인 SpringApplication.run(...)이 실행되면 스프링 부트는 실행 환경을 구성하고 스프링 컨테이너를 띄워 본격적인 초기화에 들어간다. 우리가 작성한 설정과 자동 구성(Auto-configuration)을 바탕으로 웹 서버가 요청을 받을 준비를 마치는 과정이다. 내부 초기화 이벤트의 세밀한 순서보다는, 첫 번째 HTTP 요청을 받기 전에 메모리 위에 무엇이 준비되는지에 초점을 맞춰 보자.
이 시점에 가장 먼저 구분해야 할 개념이 바로 빈 정의(BeanDefinition)와 실제 빈 인스턴스다. BeanDefinition은 “어떤 클래스를, 어떤 스코프로, 어떤 의존성을 주입해 생성할 것인가”를 적어 둔 일종의 설계도(메타데이터)다. 설계도 자체가 요청을 처리하는 실제 CouponService 객체는 아니다. 스프링 컨테이너 내부의 BeanFactory가 이 설계도를 읽어들인 뒤, 필요한 의존성을 찾아 연결하고 생성자나 팩토리 메서드를 호출해야 비로소 힙 메모리에 실제 인스턴스가 만들어진다.
의존성 주입과 프록시
이해를 돕기 위해 간단한 쿠폰 조회 서비스 코드를 살펴보자. (설명 목적상 Repository와 DTO의 세부 구현, 예외 처리는 생략했다.)
@Service
class CouponService {
private final CouponRepository repository;
CouponService(CouponRepository repository) {
this.repository = repository;
}
CouponResponse getCoupon(long couponId, long userId) {
Coupon coupon = repository.findOwnedCoupon(couponId, userId);
return new CouponResponse(coupon.id(), coupon.name());
}
}
Java
복사
이 CouponService 클래스에서 서버가 켜질 때 결정되는 값은 생성자를 통해 주입받아 final 필드에 고정되는 repository 참조뿐이다. 반면 어떤 쿠폰을 찾을지(couponId), 누구의 요청인지(userId), 그리고 DB에서 찾아온 결과(coupon)는 요청이 들어와야만 알 수 있는 값이다. 바로 이 지점이 '애플리케이션이 공유하는 상태'와 '호출마다 격리되는 상태'를 가르는 핵심 경계가 된다.
컨테이너는 빈 인스턴스를 생성한 뒤 의존성 주입(DI)과 초기화, 각종 후처리(BeanPostProcessor) 과정을 거쳐 바로 사용할 수 있는 상태의 빈을 완성한다. 만약 트랜잭션이나 로깅 같은 AOP가 적용된다면, 실제로 주입되는 참조는 원본 객체가 아니라 이를 감싼 프록시(Proxy) 객체일 수도 있다. 흔히 도표에서 new Service()라고 적는 것은 이러한 인스턴스화 과정을 이해하기 쉽게 압축한 표현일 뿐, 실제 스프링의 빈 준비 과정에는 단순 생성자 호출 이상의 단계들이 포함된다.
이렇게 준비된 CouponService(또는 그 프록시)의 참조는 CouponController의 필드에 주입된 채로 유지된다. 덕분에 이후 요청이 들어와 컨트롤러가 서비스를 호출할 때는 매번 BeanDefinition을 다시 뒤져 객체를 새로 조립할 필요 없이, 이미 연결되어 있는 참조를 따라 메서드를 곧바로 호출하면 된다.
3. 시작과 요청 시퀀스
초기화와 요청 처리 흐름
그렇다면 서버가 켜질 때 한 번 만들어 두고 계속 보관하는 영역과, 클라이언트 요청이 들어올 때마다 새로 실행되는 영역은 시간 순서상 어떻게 이어질까?
단계마다 번호와 한 줄 설명으로 따라가는 Spring Boot 초기화와 요청 처리 시퀀스
시퀀스 다이어그램의 상단 STARTUP 구간은 스프링 컨테이너가 빈 정의를 읽고 싱글톤 객체들을 조립하는 준비 단계다. 하단 API REQUEST 구간부터는 이미 조립을 마친 빈들을 활용해 실제 요청을 처리한다. (각 장면 아래에는 해당 단계에서 일어나는 핵심 변화가 한 줄로 표시된다. 세로 생명선은 각 구성 요소의 역할을 구분하기 위한 것이며, 내부 이벤트의 물리적 측정 시간표를 뜻하지는 않는다.)
단계별 실행 주체
빈 준비와 참조 유지
1~4단계에서는 컨테이너가 BeanDefinition을 바탕으로 기본 비지연 서비스 빈을 생성·초기화하고, 컨트롤러에 서비스 참조를 주입한다. 그림의 Retain singleton refs는 컨테이너가 생성된 싱글톤 빈들의 참조를 계속 붙들고 관리한다는 결과를 요약한 표현이다(실제 캐시 등록 시점이 맨 마지막에 일어난다는 뜻은 아니다).
스레드 배정과 메서드 호출
5~8단계에서는 내장 Tomcat이 HTTP 요청을 받으면 스레드 풀(Worker pool)에서 대기 중인 플랫폼 스레드 하나에 작업을 배정하고, Spring MVC의 DispatcherServlet이 알맞은 컨트롤러 핸들러를 찾아 호출한다. 여기서 컨트롤러가 service.getCoupon(...)을 호출할 때 새로운 서비스 객체나 새로운 스레드가 만들어지는 것이 아니다. 별도의 비동기 전환을 하지 않는 한, 처음 요청을 맡은 바로 그 작업 스레드가 컨트롤러부터 서비스, 리포지토리까지 메서드 호출을 쭉 이어간다. (물론 스레드 풀 자체는 설정과 부하에 따라 스레드 수를 늘리거나 줄일 수 있으므로, 요청 처리 중에 새 스레드가 절대 생성되지 않는다는 뜻은 아니다. Tomcat Executor)
조회·DTO 생성·응답 직렬화
9~13단계에서는 리포지토리를 통해 DB에서 쿠폰 데이터를 조회한 뒤, 이번 요청의 결과를 담을 새로운 응답 객체(CouponResponse DTO)를 생성해 반환한다. 반환된 DTO는 Spring MVC의 메시지 컨버터(HttpMessageConverter)를 거치며 JSON 바이트로 직렬화되어 클라이언트에게 전송된다. (다이어그램에서는 전체 흐름을 한눈에 볼 수 있도록 서블릿 필터, 트랜잭션 프록시, 리포지토리의 세부 구간을 일부 축약해 표현했다. 또한 Spring MVC가 요청 URL에 맞는 컨트롤러 핸들러를 찾는 과정과, 컨트롤러가 이미 주입된 서비스 참조의 메서드를 부르는 과정은 서로 다른 단계다. DispatcherServlet 처리 흐름)
4. 호출 프레임과 참조
스레드 스택과 힙
이제 처음의 질문으로 돌아와 보자. 두 요청이 동시에 같은 CouponService.getCoupon()을 호출했는데 왜 데이터가 섞이지 않을까?
JVM의 논리적 실행 모델을 보면, 각 스레드는 자신만의 전용 **JVM 스택(Stack)**을 가진다. 그리고 스레드가 메서드를 하나 호출할 때마다 그 스택 위에 새로운 **스택 프레임(Stack Frame)**이 쌓인다. 이 프레임 안에는 해당 호출에 전달된 매개변수, 메서드 내부에서 선언한 지역 변수, 그리고 연산을 수행하기 위한 피연산자 스택(Operand Stack)이 들어간다. 반면 new로 생성한 객체 인스턴스와 배열은 모든 스레드가 공유하는 힙(Heap) 영역에 놓인다.
(이는 JVM 명세가 정의하는 논리적 구분이며, JVM 스택과 네이티브 C 스택, 혹은 JIT 컴파일러의 인라인·레지스터 최적화 결과를 물리적으로 동일시해서는 안 된다.)
호출별 슬롯과 공유 참조
앞서 본 getCoupon(long couponId, long userId) 코드에 요청 A(123번 쿠폰, 77번 사용자)와 요청 B(456번 쿠폰, 88번 사용자)가 동시에 들어온 상황을 표로 비교해 보자. (S, C-A, D-A 같은 표기는 실제 메모리 주소가 아니라 객체를 구분하기 위한 설명용 이름표다.)
프레임 내 항목 | 요청 A (Thread A의 프레임) | 요청 B (Thread B의 프레임) | 메모리상의 의미 |
this | 공유 서비스 S를 가리키는 참조 | 동일한 공유 서비스 S를 가리키는 참조 | 두 스레드가 같은 서비스 인스턴스를 바라봄 |
couponId | 123 | 456 | 각 프레임의 독립된 슬롯에 저장된 기본형 값 |
userId | 77 | 88 | 각 프레임의 독립된 슬롯에 저장된 기본형 값 |
coupon | 조회 결과 객체 C-A를 가리키는 참조 | 조회 결과 객체 C-B를 가리키는 참조 | 참조 변수는 각 프레임에, 실제 객체는 힙에 따로 존재 |
반환값 | 새 응답 DTO D-A를 가리키는 참조 | 새 응답 DTO D-B를 가리키는 참조 | 요청마다 새로 만든 별개의 DTO 인스턴스를 반환 |
그렇다면 두 작업 스레드(Thread A, Thread B)의 호출 프레임에서 뻗어 나가는 참조 화살표 중, 힙의 같은 객체로 모이는 화살표와 서로 다른 객체로 갈라지는 화살표는 각각 무엇일까?
두 스레드의 호출 프레임이 동일한 싱글톤 서비스를 참조하면서 각자의 인자 슬롯과 요청별 DTO를 분리해 보유하는 구조
도표 좌측(WORKER THREAD STACKS)과 우측(JVM HEAP)을 비교해 보면, 두 호출의 this 화살표(노란색)가 힙에 있는 똑같은 CouponService S (Singleton)를 가리킨다고 해서 매개변수 슬롯(couponId, userId)까지 하나로 합쳐지는 것이 아님을 한눈에 확인할 수 있다. 두 스레드는 같은 메서드의 바이트코드(실행 규칙)를 읽으며 동작하지만, 각자의 인자(123 vs 456)와 현재 실행 위치를 담는 프레임은 자기 스레드 스택에 따로 챙겨 두고 진행한다.
초록색 화살표(REFS A, REFS B)로 표시된 지역 변수 coupon과 반환값(ret) 역시 마찬가지다. 조회된 Coupon 엔티티나 응답 DTO 알맹이가 스택에 통째로 들어가는 것이 아니라, 힙에 요청별로 새로 생성된 객체(Coupon C-A & DTO D-A, Coupon C-B & DTO D-B)를 가리키는 **참조값(Reference)**이 각 스레드의 프레임에 저장된다. (예제의 long 타입 인자는 기본형 값 자체가 복사되어 전달되며, 만약 객체를 인자로 넘긴다면 객체 전체가 깊은 복사되는 대신 참조값만 복사되어 전달된다.)
결국 “메서드가 싱글톤 객체 안에 들어 있으니 한 번에 한 요청만 그 메서드에 들어갈 수 있다”거나 “두 요청이 들어가면 지역 변수가 덮어써진다”는 상상은 실제 메모리 동작과 맞지 않는다. 우리가 동시성 환경에서 진짜로 보호해야 할 대상은 '메서드의 입구'가 아니라, '여러 호출이 동시에 접근해 읽고 쓰는 공유 가변 상태'다.
5. 응답과 메모리 정리
프레임 제거와 GC 대상
그렇다면 한 건의 요청이 들어와 필터부터 리포지토리까지 호출이 깊어졌다가 응답이 나갈 때, 스레드 스택의 프레임들이 쌓이고 빠지는(Push / Pop) 시점과 힙 객체(Service S, Coupon C-A, Response DTO D-A)가 유지되거나 회수 대상이 되는 시점은 어떻게 다를까?
요청 처리 단계별로 스택 프레임이 Push 및 Pop되고, 서비스 프레임이 사라진 뒤에도 직렬화가 끝날 때까지 응답 DTO가 힙에 유지되는 흐름
위 단계별 흐름은 호출과 객체 수명을 구분하기 위한 논리적 모델이다. 그림의 Controller & JSON Serializer는 응답 처리 구간을 묶은 라벨이다. 실제 컨트롤러 메서드의 프레임은 반환 시 끝나고, 이후 Spring MVC의 반환값 처리기와 메시지 컨버터가 DTO 참조를 받아 직렬화한다. 그림의 ThreadLocal.clear() 역시 정리를 뜻하는 개념 표기이며, Java ThreadLocal의 실제 메서드는 인스턴스의 remove()다. 아래 설명은 영속성 컨텍스트나 캐시가 조회 객체를 추가로 보관하지 않는 경우를 가정한다.
1.
호출 진입과 프레임 적재 (STEP 1 ~ 2): 작업 스레드가 요청을 맡으면 스택 바닥부터 1. Filter & DispatcherServlet → 2. Controller → 3. Service.getCoupon → 4. Repository.find 순으로 프레임이 위로 차곡차곡 쌓인다(PUSH). 이때 필터에서 인증한 사용자 문맥(userId = 77)은 스레드 전용 슬롯인 ThreadLocal에 바인딩되고, 리포지토리 조회 결과로 힙에 Coupon Entity C-A가 할당된다.
2.
하위 프레임 해제와 DTO 생존 (STEP 3 ~ 4): 리포지토리 메서드가 반환되면 4. Repository 프레임이 먼저 스택에서 빠져나가고(POP), 서비스가 Response DTO D-A를 만들어 반환하면 3. Service 프레임도 즉시 사라진다. 여기서 가장 눈여겨볼 장면이 **STEP 4**다. 3. Service 프레임이 스택에서 사라져 더 이상 아무도 가리키지 않는 Coupon Entity C-A는 곧바로 UNREACHABLE (GC eligible) 상태가 되지만, 방금 반환한 Response DTO D-A는 사라지지 않고 LIVE 상태로 남는다. 컨트롤러에서 Spring MVC의 반환값 처리기로 DTO 참조가 전달되고, 메시지 컨버터가 그 참조를 이용해 JSON 바이트 변환을 수행하기 때문이다.
3.
최종 프레임 해제와 ThreadLocal 정리 (STEP 5 ~ COMPLETE): 컨트롤러 반환 뒤 응답 직렬화와 전송 처리가 끝나면 필터 호출도 마무리된다. 필터의 정리(finally) 구간에서 해당 ThreadLocal 인스턴스의 remove()가 호출되어 스레드 문맥이 깨끗이 비워진다. 그제야 Response DTO D-A를 가리키던 마지막 참조가 끊어지면서 D-A도 가비지 컬렉터(GC)의 **회수 대상(GC eligible)**이 된다.
단, 회수 대상이 되었다는 사실과 GC가 실제로 메모리를 수거해 가는 시점은 별개다. 반대로 만약 요청 처리 도중 그 DTO나 조회 객체를 전역 캐시(Map), 메모리 로그 버퍼, 혹은 아직 끝나지 않은 비동기 작업에 담아 두었다면, HTTP 응답이 끝난 뒤에도 참조가 살아 있으므로 객체는 힙에 계속 남게 된다. (코드에 적힌 new의 개수나 논리적 프레임 구조도 JIT 최적화에 따라 실제 물리적 메모리 할당과 달라질 수 있으므로, 도표를 물리적 주소 배치나 실측 할당 횟수로 읽어서는 안 된다.)
스레드 재사용과 외부 자원
한편 싱글톤인 CouponService는 스프링 컨테이너가 여전히 참조를 쥐고 있으므로 다음 요청을 위해 힙에 그대로 남는다. 요청을 처리한 Tomcat 작업 스레드 역시 죽지 않고 스레드 풀로 돌아가 다음 요청을 기다린다. 요컨대 메서드 프레임의 종료, 힙 객체의 GC 회수, 그리고 작업 스레드의 풀 반환은 모두 각자의 규칙에 따라 일어나는 서로 다른 사건이다.
여기에 더해 데이터베이스 커넥션(Connection) 반납이나 파일·네트워크 스트림을 닫는(close) 작업은 메모리 회수와는 완전히 분리된 별도의 약속이다. 스프링의 트랜잭션 매니저나 Java의 try-with-resources가 책임져야 할 외부 자원 정리를 언젠가 실행될 GC에 맡겨 두어서는 안 된다. 마찬가지로 요청 처리 중 ThreadLocal에 사용자 인증 정보나 요청 문맥을 넣어 두었다면, 작업 스레드가 풀로 돌아가 다른 사용자의 요청에 재사용되기 전에 반드시 직접 정리(remove)해 주어야 이전 요청의 흔적이 섞이는 사고를 막을 수 있다.
6. 공유 힙과 스레드 안전성
공유 필드와 경쟁 상태
만약 누군가 앞서 본 CouponService를 수정하면서, 매개변수로 받은 couponId를 서비스의 인스턴스 필드에 잠시 저장하도록 바꾸면 어떻게 될까?
// 의도적으로 잘못 작성한 예: 요청마다 달라지는 값을 공유 서비스의 필드에 저장한다.
this.currentCouponId = couponId;
Coupon coupon = repository.findOwnedCoupon(this.currentCouponId, userId);
Java
복사
요청 A가 this.currentCouponId에 123을 적어 넣은 직후, 요청 B가 끼어들어 같은 필드를 456으로 덮어썼다고 해보자. 이어서 요청 A가 다음 줄을 실행하며 this.currentCouponId를 읽으면, A는 원래 찾으려던 123 대신 456번 쿠폰을 조회하게 된다. 허용되지 않은 메모리 주소를 침범해 프로세스가 뻗는 일은 일어나지 않았지만, 사용자에게 다른 사람의 쿠폰 정보가 넘어가는 심각한 비즈니스 오류가 발생한 것이다.
동시성 설계 개념
이처럼 잘못된 메모리 접근 자체를 막아주는 런타임 차원의 **메모리 안전성(Memory safety)**과, 여러 스레드가 동시에 호출해도 비즈니스 로직의 의도와 결과의 정확성을 지켜내는 **스레드 안전성(Thread safety)**은 명확히 구분해야 한다. 백엔드 애플리케이션에서 공유 힙을 안전하게 다루려면 다음 개념들을 하나의 흐름으로 이해해 두는 것이 좋다.
핵심 개념 | 쿠폰 조회 API 맥락에서의 의미와 주의점 |
경쟁 상태(Race condition)와 데이터 레이스(Data race) | 실행이 겹치는 타이밍에 따라 결과가 틀어지는 문제(경쟁 상태)와, 적절한 동기화 없이 같은 메모리 위치에 충돌하는 읽기·쓰기를 수행하는 문제(데이터 레이스)를 구분한다. JVM 명세에서 데이터 레이스는 두 접근 중 최소 하나가 쓰기이면서 happens-before 순서가 보장되지 않을 때 발생한다. |
무상태 설계(Statelessness) | 공유되는 CouponService 필드에 요청별 진행 상태(currentCouponId 등)를 남겨두지 않는다. 변하지 않는 설정값이나 repository 참조를 필드로 갖는다고 해서 무상태가 깨지는 것은 아니다. |
스레드 한정(Thread confinement) | 특정 가변 객체를 오직 하나의 스레드만 접근하도록 제한한다. 객체가 물리적으로 공유 힙에 놓여 있더라도, 접근하는 참조가 한 스레드 안에만 머문다면 안전하다(힙을 물리적으로 쪼갠다는 뜻이 아니다). |
요청 한정(Request confinement) | 요청마다 생성한 DTO나 작업용 컬렉션을 해당 요청의 처리 경로 안에서만 쓰고 끝낸다. 단, 한 요청 내부에서 다시 병렬 스트림이나 비동기 하위 스레드를 띄운다면 그 사이에서도 공유 여부를 따로 점검해야 한다. |
객체 탈출(Object escape) | 원래 메서드 내부나 단일 요청 안에서만 쓰려고 만든 객체의 참조를 전역 캐시, 공유 필드, 다른 스레드 등에 넘겨 외부에서 접근할 수 있게 만드는 현상이다. |
불변성(Immutability) | 여러 요청이 함께 읽어야 하는 객체라면 생성된 이후 내부 상태가 절대 바뀌지 않게 설계한다. 필드에 final을 붙였다고 해서 그 필드가 가리키는 리스트나 맵의 내부 요소 변경까지 막아주지는 않는다는 점에 주의해야 한다. |
안전한 공개(Safe publication) | 한 스레드가 만든 객체를 다른 스레드들이 참조할 때, 초기화가 덜 끝난 반쪽짜리 상태가 아니라 온전히 초기화된 상태를 관측할 수 있도록 전달하는 규칙이다. 단, 안전하게 공개했다고 해서 공개 이후에 일어나는 가변 상태 변경까지 자동으로 보호해 주지는 않는다. |
원자성(Atomicity)·가시성(Visibility)·happens-before | 연산이 중간에 끼어들 수 없는 한 덩어리로 실행되는 성질(원자성), 한 스레드가 쓴 값을 다른 스레드가 제대로 볼 수 있는지의 문제(가시성), 그리고 스레드 간 메모리 작업의 순서 보장 계약(happens-before)을 구분한다. 단순히 시계 시간상 먼저 값을 썼다고 해서 다른 스레드에게 즉시 보이는 보장이 생기는 것은 아니다. |
불변식(Invariant)과 상호 배제(Mutual exclusion) | 비즈니스적으로 항상 함께 성립해야 하는 조건(불변식)을 정의하고, 그 조건에 관여하는 모든 읽기·쓰기 경로를 동일한 락(상호 배제) 규칙으로 보호한다. |
동기화 도구와 하위 자원 경계
실무에서 전체 누적 조회수를 세는 공유 카운터가 필요하다면 AtomicLong을, 여러 요청이 함께 갱신하는 인메모리 맵이 필요하다면 ConcurrentHashMap처럼 동시성 계약이 명확한 도구를 선택할 수 있다. 하지만 여기서도 흔히 빠지는 함정이 있다. 스레드 안전한 컬렉션의 get()을 호출한 뒤 그 결과에 따라 put()을 호출하면, 개별 메서드는 안전할지 몰라도 '조회 후 삽입'이라는 두 단계의 조합 전체가 원자적으로 묶이지는 않는다. 마찬가지로 필드에 volatile을 선언하면 가시성은 확보되지만 count++(읽기 → 더하기 → 쓰기) 연산 전체를 원자적으로 만들어 주지는 못한다. 이러한 판단의 밑바탕이 되는 규칙이 바로 Java 메모리 모델(Java Memory Model, JMM)이다.
마지막으로 내가 작성한 CouponService가 완벽히 무상태라 하더라도, 그 아래에서 호출하는 CouponRepository와 하위 자원들의 동시성 계약은 끝까지 확인해야 한다. 스프링이 주입해 준 리포지토리 프록시 참조를 여러 스레드가 안전하게 공유할 수 있는 것과, 실제 데이터베이스와 대화하는 JPA EntityManager나 JDBC Connection 인스턴스를 서비스 필드에 직접 빼돌려 여러 스레드가 공유하는 것은 전혀 다른 이야기다. @Transactional 어노테이션 역시 데이터베이스 트랜잭션 경계를 묶어 줄 뿐, 서비스 객체의 일반 필드에 자동으로 멀티스레드 락을 걸어 주는 마법이 아니다.
7. 전체 요약과 판단 기준
수명과 공유 범위 요약
지금까지 따라온 서버 시작 단계의 빈 조립과 요청 처리 단계의 실행 프레임을 한 화면에 겹쳐 놓으면, 응답이 끝난 뒤에도 남는 참조와 사라지는 참조가 어떻게 구분될까?
컨테이너가 보관한 공유 서비스와 요청별 스레드 프레임·응답 객체의 관계
도표 상단에서 스프링 컨테이너가 만들어 둔 빈 참조 그래프(Controller -> Service S -> R)는 애플리케이션이 살아 있는 동안 계속 재사용된다. 반면 중단과 하단에 보이는 Thread A와 Thread B의 호출 프레임은 각 요청을 처리하는 동안만 유지된다. 두 프레임의 this는 힙에 있는 동일한 Shared Service S를 가리키지만, 각자의 couponId(123, 456)는 독립된 프레임 슬롯에 담겨 있고, 결과로 만든 응답 객체(Response DTO D-A, D-B) 역시 요청별로 따로 참조한다. 결국 핵심은 오래 쓸 Service는 싱글톤으로 재사용하되, 요청마다 달라지는 값과 DTO는 각 호출과 요청의 경계 안에 가두는(Confinement) 것이다. (그림에서 HEAP 점선 박스는 객체들이 모두 같은 힙 메모리 공간에 놓인다는 뜻이지, 힙에 있는 모든 객체를 모든 스레드가 다 같이 공유해서 쓴다는 뜻은 아니다.)
가상 스레드와 점검 기준
한 가지 덧붙이자면, 이 글과 도표에서 설명한 작업 스레드 재사용 모델은 전통적인 플랫폼 스레드 풀(Platform thread pool) 구성을 기준으로 한 것이다. Java 21부터 도입된 가상 스레드(Virtual threads) 구성을 활성화하면, 스레드를 풀에 넣어 재사용하는 대신 요청 작업마다 가벼운 가상 스레드를 새로 만들어 실행하고 JVM이 이를 소수의 플랫폼 스레드 위에서 스케줄링하는 방식으로 동작한다. 따라서 “스레드는 언제나 풀에서 재사용된다”는 설명을 JVM의 절대적인 법칙으로 일반화해서는 안 된다. 다만 가상 스레드를 쓰더라도 여러 가상 스레드가 동일한 싱글톤 서비스 객체(Service S)에 접근한다는 사실은 변하지 않으므로, 공유 가변 상태를 조심해야 한다는 설계 원칙은 똑같이 적용된다.
앞으로 백엔드 서비스 코드를 작성하거나 리뷰할 때는 클래스에 필드가 몇 개 있는지 세어 보는 데서 그치지 말고, 데이터의 흐름과 수명을 따라가 보자. 이 객체를 누가 언제 만들었는지, 그 참조를 누가 붙들고 있는지, 어떤 실행 흐름이 그 안의 값을 바꾸는지, 그리고 응답이 나갈 때 참조와 외부 자원이 어떻게 정리되는지를 짚어낼 수 있다면, 수많은 요청이 동시에 몰려오는 환경에서도 왜 내 코드가 안전한지(혹은 어디가 위험한지)를 명확한 근거로 설명할 수 있을 것이다.






