Spring의 요청 처리 순서

Spring에서 요청이 왔을 때 어떻게 되는지 하나하나 보려고 합니다. 하기 전에 알아야할 부분이 있습니다.

1. OSIV가 항상 인터셉터인 것은 아님.

  • Spring boot MVC에서는 보통 OpenEntityManagerInViewInterceptor 형태로 동작한다고 보면 되고 직접 OpenEntityManagerInViewFilter를 등록할 수도 있습니다.

그래서 흐름은 2가지로 나누게 됩니다.

  • OSIV Interceptor 방식: [필터들] → [DispatcherServlet] → [OSIV Interceptor] → [Controller]
  • OSIV Filter 방식: [OSIV Filter] → [다른 필터들] → [DispatcherServlet] → [Controller]

2. Repository 호출마다 미니 트랜잭션이 생기는지는 상황에 따라 다르다.

  • 서비스 메서드에서 @Transactional이 없으면, Spring Data JPA Repository의 기본 메서드나 @Transactional(readOnly=true)가 붙은 조회 메서드는 자체적으로 짧은 트랜잭션을 열 수 있습니다.
  • 하지만 서비스에 큰 @Transactional이 있으면 Repository들은 전부 그 하나의 트랜잭션에 참여합니다.
  • 서비스에 @Transactional 없음: Repository 조회마다 짧은 트랜잭션 가능
  • 서비스에 @Transactional 있음: 서비스 시작 시 트랜잭션 1개 시작, 그 안에서 Repository 여러 개 호출, 서비스 종료 시 한 번에 commit

0단계: 애플리케이션이 떠 있는 상태

사용자 요청이 오기전에 Spring boot 애플리케이션은 이미 실행되어있습니다.

  1. main()
  2. SpringApplication.run()
  3. ApplicationContext 생성
  4. 내장 Tomcat 기동
  5. Spring MVC / Security / JPA / DataSource 준비

대략 다음과 같은 것들이 만들어집니다.

[웹 계층]

  • 내장 Tomcat
  • DispatcherServlet
  • HandlerMapping
  • HandlerAdapter
  • MessageConverter, 예: Jackson
  • Servlet Filter들
  • Spring Security FilterChain
  • HandlerInterceptor들

[스프링 빈]

  • Controller
  • Service
  • UseCase
  • Repository 프록시
  • TransactionManager
  • DataSource
  • EntityManagerFactory

[JPA/DB]

  • EntityManagerFactory
  • JpaTransactionManager
  • HikariCP 커넥션 풀
  • Hibernate 설정
  • 엔티티 메타데이터

[보안]

  • SecurityFilterChain
  • JWT 필터
  • AuthenticationProvider 등

 

여기서 중요한 객체가 2개가 있습니다.

  • EntityManagerFactory = EntityManager를 만드는 공장
  • EntityManager = 실제 요청 중 엔티티를 관리하는 영속성 컨텍스트 역할

 

1단계 : 톰캣이 요청을 받음

클라언트가 요청을 다음과 같이 보냈다고 가정해보겠습니다.

GET /page/search?keyword=회의
Authorization: Bearer xxx.yyy.zzz

그러면 OS 소켓을 통해 톰캣으로 들어옵니다.

  • Client → (TCP) → OS Socket → Tomcat 8080 Listen

톰캣은 요청을 처리할 워커 스레드를 하나 배정합니다.

요청 1개 → Tomcat Worker Thread 1개 배정

이 스레드가 요청 처리의 중심이 되됩니다.

  • Tomcat Worker Thread → Filter → DispatcherSErvlet → Controller → Service → Repository → JSON 직렬화 → 응답 완료

그래서 우리가 말하는 스레드에 바인딩 된다는 말은 이 톰캣 워커 스레드에 묶인다는 뜻입니다.

대표적으로 이런 것들이 스레드에 묶입니다.

  • SecurityContext = 현재 인증 사용자 정보
  • EntityManager = 현재 요청의 영속성 컨텍스트
  • Transaction 정보 = 현재 트랜잭션 상태
  • DB Connection = 현재 트랜잭션에 연결된 커넥션

 

2단계 : 톰캣이 HTTP 요청을 Servlet 요청 객체로 만듦

톰캣은 HTTP 텍스트를 파싱합니다.

GET /page/search?keyword=회의 HTTP/1.1
Host: example.com
Authorization: Bearer xxx.yyy.zzz

그리고 Servlet API 객체를 만듭니다.

  • HttpServletRequest
  • HttpServletResponse

이제 자바 코드에서는 HTTP 요청을 문자열로 직접 파싱하지 않고 request 객체로 다루게 됩니다.

그 다음 톰캣은 이 요청을 DispatcherServlet으로 보낼 준비를 합니다. 하지만 그 전에!!

Servlet Filter Chain을 먼저 탑니다.

 

3단계 : Servlet Filter Chain 실행

필터는 DispatcerServlet 보다 앞에 있습니다.

순서는 = HttpServletRequest → CharacterEncdoingFilter → CorsFilter → Spring Security FilterChain → Custom Filter → DispatcherServlet

  • CharacterEncodingFilter → 요청/응답 인코딩을 맞춥니다 ex) UTF-8 설정
  • CORS Filter → 프론트엔드 도메인이 다른 경우 CORS 정책을 검사합니다 ex) Origin 검사, Access-Control-Allow-Origin 설정, preflight OPTIONS 처리
  • Spring Security FilterChain → 보안 필터들이 실행됩니다.
    • 대략 == SecurityContextHolderFilter → JwtAuthenticationFilter → AUthorizationFilter
    • JWT 인증 = Authorization 헤더 읽기 → Barer 토큰 추출 → JWT 서명 검증 → 만료 시간 검증 → userId, role 추출 → Authentication 객체 생성 → SecurityContextHolder에 저장

이후 컨트롤러에서는 다음과 같은 형식으로 사용자 정보를 꺼낼 수 있습니다.

SecurityContextHolder.getContext().getAuthentication();
Long usersId = SecurityUtil.getCurrentUserId();

이 시점의 상태로 봤을 때 OSIV가 인터셉터 방식이면 아직 영속성 컨텍스트는 없습니다.

현재 상태:

  • Tomcat Worker Thread 있음
  • HttpServletRequest 있음
  • SecurityContext 있음
  • EntityManager 없음
  • 트랜잭션 없음
  • DB Connection 없음

즉, 일반적인 OSIV Interceptor 기준으로 필터 단계에서 JPA Lazy Loading을 하면 위험합니다.

  • Filter에서 Lazy Loading 시도 → EntityManager 없음 → LazyInitializationException 가능

단 OSIV를 Filter로 직접 등록하고 보안 필터보다 앞에 두면 필터에서도 EntityManager가 있을 수 있다. 하지만 일반적으으로는 필터 단계에는 아직 JPA 영속성 컨텍스트가 없다고 보는 게 안전합니다.

 

4단계 : DispatcherServlet 진입

필터를 다 통과하면 DispatcherServlet이 요청을 받습니다.

DispatcherServlet.doDispatch()

이 서블릿은 Spring MVC의 중앙 관제탑입니다. 하는 일은 다음과 같습니다.

  • 4.1 HandlerMapping으로 컨트롤러 찾기
  • 4.2 Interceptor preHandle 실행
  • 4.3 HandlerAdapter로 컨트롤러 호출
  • 4.4 컨트롤러 반환값 처리
  • 4.5 JSON 변환 또는 View 렌더링
  • 4.6 Interceptor afterCompletion 실행

HandlerMapping에서 요청 URL과 HTTP Method를 보고 어떤 컨트롤러 메서들르 호출할지 찾습니다.

GET /page/search
   ↓
PageSearchViewController.search()

이때 @GetMapping, @PostMapping, @RequestMapping 정보가 사용됩니다.

 

5단계 : OSIV Interceptor preHandle

HandlerMapping으로 컨트롤러를 찾은 뒤 인터셉터의 preHandle()이 실행됩니다.

OSIV가 켜져 있으면 여기서 EntityManager를 만듭니다.

OpenEntityManagerInViewInterceptor.preHandle()
   ↓
EntityManager 생성
   ↓
영속성 컨텍스트 생성
   ↓
현재 톰캣 워커 스레드에 바인딩

정확히는 Spring의 TransactionSynchronizationManager에 바인딩됩니다.

  • 현재 Thread → TransactionSynchronizationManager → EntityManagerHolder → EntityManager

이 요청 스레드에서 JPA를 사용하면 이 EntityManger를 재사용합니다.

 

이 시점에서 열리는 것이

열림: EntityManager, 영속성 컨텍스트

아직 안 열림: DB 트랜잭션, DB 커넥션

OSIV가 연 것은 상자라고 봐야합니다.

 

  • 영속성 컨텍스트 == 엔티티 담는 상자

하지만 아직 DB 작업 단위인 트랜잭션은 시작하지 않았습니다.

 

6단계 : 컨트롤러 호출 준비

컨트롤러 메서드를 호출하기 전에 Spring MVC가 파라미터를 준비합니다.

예를 들어 컨트롤러가 다음과 같다고 해보겠습니다.

@GetMapping("/page/search")
public ResponseEntity<PageSearchResponse> search(
        @RequestParam("keyword") String keyword
) {
    Long usersId = SecurityUtil.getCurrentUserId();
    return ResponseEntity.ok(pageSearchUseCase.search(usersId, keyword));
}

Spring MVC는 이런 일을 합니다.

  • 6.1 @RequestParam("keyword") 읽기
  • 6.2 request parameter에서 keyword 값 꺼내기
  • 6.3 String 타입으로 변환
  • 6.4 컨트롤러 메서드 인자로 넣기

만약 @RequestBody가 있으면 Jackson이 먼저 JSON을 객체로 바꾸게 됩니다.

HTTP Body JSON
   ↓
Jackson
   ↓
Request DTO

 

7단계 : 컨트롤러 실행

이제 컨트롤러가 실행됩니다.

public ResponseEntity<PageSearchResponse> search(String keyword) {
    Long usersId = SecurityUtil.getCurrentUserId();
    PageSearchResponse response = pageSearchUseCase.search(usersId, keyword);
    return ResponseEntity.ok(response);
}

흐름은 이렇게 됩니다.

  • 7.1 SecurityContext에서 userId 꺼냄
  • 7.2 UseCase 또는 Service 호출
  • 7.3 응답 객체를 ResponseEntity로 감쌈

현재 상태는 다음과 같습니다.

Tomcat Worker Thread 있음
SecurityContext 있음
EntityManager 있음
트랜잭션은 아직 없을 수 있음
DB Connection도 아직 없을 수 있음

이제 핵심인 서비스로 간다.

 

8단계 : Service/ UseCase 진입

여기서 2가지 경로로 봐야하는데

  • A. UseCase에 @Transactional이 있음
  • B. UseCase에 @Transactional이 없음

우선 A로 진행하고 마지막에 B경로를 보겠습니다.

예를 들어 이렇게 되어 있다고 하자.

@Transactional(readOnly = true)
public PageSearchResponse search(Long usersId, String keyword) {
    Users user = usersService.findById(usersId);
    List<FolderDto> folders = foldersService.searchFolders(user, keyword);
    List<ScheduleDto> schedules = schedulesService.searchSchedules(user, keyword);
    List<LinkDto> links = linksService.searchLinks(user, keyword);
    List<TextDto> texts = textsService.searchTexts(user, keyword);
    List<FileDto> files = attachMentsService.searchFiles(user, keyword);

    return new PageSearchResponse(folders, schedules, links, texts, files);
}

이 경우 컨트롤러가 실제 객체를 바로 호출하지 않습니다.

  • Controller → UseCase Proxy → TransactionInterceptor → 실제 UseCase 객체

이제 @Transactional AOP가 동작합니다.

 

9단계 : @Transactional AOP가 실제로 하는일

이 부분을 세부 단계로 나눠서 봐야 합니다.

  • 9.1 프록시가 호출을 가로챔

컨트롤러는 pageSearchUseCase.search()를 호출한다고 생각하지만, 실제로는 프록시가 먼저 받게 됩니다.

Controller → PageSearchUseCase Proxy → TransactionInterceptor

프록시는 @Transactional이 붙이 있는지 확인한다.

  • 9.2 트랜잭션 설정 읽음

@Transactional에는 여러 설정이 있습니다.

@Transactional(readOnly=true)

프록시는 이런 정보들을 읽습니다.

readOnly = true
propagation = REQUIRED // 이미 트랜잭션이 있으면 참여 없으면 새로 시작
isolation = DEFAULT
rollbackFor = RuntimeException/Error 기본

현재 컨트롤러에서 서비스로 처음 들어가는 상황이면 보통 트랜잭션이 없습니다. 그래서 새로 시작합니다.

  • 9.3 현재 스레드에서 EntityManager 확인

OSIV가 켜져 있다면 5단계에서 이미 EntityManager가 만들어져 있습니다.

현재 스레드 → TransactionSynchronizationManager → EntityManagerHolder 있음

그래서 @Transactional은 새 EntityManager를 만들지 않고 OSIV가 만들어 둔 EntityManager를 사용합니다. == OSIV가 만든 EntityManager → @Transactional이 트랜잭션을 붙임

만약 OSIV가 꺼져있다면 이 시점에 EntityManager를 새로 만든다.

OSIV OFF → @Transactional 시작 → EntityManager 생성 → 트랜잭션 종료 시 EntityManager close

  • 9.4 트랜잭션 시작

JpaTransactionManager가 트랜잭션을 시작합니다.

JpaTransactionManager → EntityManager에 트랜잭션 시작 요청 → DB Connection 준비

이제부터 이 서비스 메서드 안에서는 DB 변경을 하나의 작업 단위로 묶을 수 있습니다.

Service 시작 → 여러 Repository 호출 → 모두 성공하면 commit, 중간에 실패하면 rollback

  • 9.5 DB Connection 획득

트랜잭션이 시작되면서 또는 실제 SQL이 필요한 시점에 HikariCP에서 커넥션을 가져옵니다.

EntityManger/Hibernate → DataSource → HikariCP → DB Connection 획득

이 커넥션은 트랜잭션 동안 유지됩니다. 트랜잭션 시작 → Conntection 획득 → SELECT 여러 번 → 트랜잭션 종료 → Connection 반납

@Transactional이 UseCase 전체에 붙어 있으면, 내부 Repository 호출 5개가 같은 트랜잭션과 같은 커넥션 흐름 안에서 실행될 수 있습니다.

트랜잭션 1개
Connection 1개
   ├─ users 조회
   ├─ folders 조회
   ├─ schedules 조회
   ├─ links 조회
   ├─ texts 조회
   └─ files 조회

정확히는 커넥션 획득 시점은 설정과 실제 DB 접근 시점에 따라 약간 달라질 수 있지만 핵심은 트랜잭션 범위 동안 커넥션이 묶인다는 것입니다.

  • 9.6 실제 서비스 메서드 실행

이제 프록시가 실제 서비스 메서드를 호출합니다.

TransactionInterceptor
   ↓
실제 PageSearchUseCaseImpl.search()

서비스 코드가 실행됩니다.

Users user = usersService.findById(usersId);
List<FolderDto> folders = foldersService.searchFolders(user, keyword);
List<ScheduleDto> schedules = schedulesService.searchSchedules(user, keyword);
List<LinkDto> links = linksService.searchLinks(user, keyword);
List<TextDto> texts = textsService.searchTexts(user, keyword);
List<FileDto> files = attachMentsService.searchFiles(user, keyword);

이 서비스 내부에서 Repository 호출이 발생합니다.

  • 9.7 Repository/JPA 호출

Repository는 인터페이스처럼 보이지만 실제로는 스프링 데이터 JPA 프록시입니다.

textsRepository.searchByTextContent(...)
   ↓
Repository Proxy
   ↓
SimpleJpaRepository 또는 Query 실행 로직
   ↓
EntityManager

Repository는 EntityManager를 사용한다.

Repository
   ↓
EntityManager
   ↓
Hibernate Session
   ↓
JDBC Connection
   ↓
DB

JPQL or Query Method 해석은

List<Texts>findByContentContaining(String keyword);

Spring Data JPA는 메서드 이름을 보고 쿼리를 만듭니다.

findByContentContaining
   ↓
content like '%keyword%'

JPQL을 직접 썼다면 Hibernate가 JPQL을 SQL로 바꿉니다.

select t
from Texts t
where t.content like concat('%', :keyword, '%')

SQL은 대략 이렇게 됩니다.

select t.id, t.content, t.users_id
from texts t
where t.contentlike ?

PreparedStatement 생성은 Hibernate는 JDBC를 통해 SQL을 실행합니다.

SQL 생성
   ↓
PreparedStatement 생성
   ↓
파라미터 바인딩

예를 들어:

? = "%회의%"

DB로 SQL 전송

JDBC Connection
   ↓
DB에 SQL 전송

DB가 ResultSet 반환 → DB가 결과 row를 반환합니다.

ResultSet
   ├─ row 1
   ├─ row 2
   └─ row 3

Hibernate가 엔티티로 변환 → Hibernate는 ResultSet을 엔티티 객체로 바꾸게 됩니다.

ResultSet row
   ↓
Texts 엔티티 객체 생성
   ↓
필드 값 채움

이 과정을 하이드레이션이라고 보면 됩니다.

DB row
→ Java Entity

영속성 컨텍스트에 등록 → 엔티티는 영속성 컨텍스트에 등록됩니다.

영속성 컨텍스트
   ├─ EntityKey(Texts, 1) → Texts 객체
   ├─ EntityKey(Texts, 2) → Texts 객체
   └─ EntityKey(Texts, 3) → Texts 객체

그리고 변경 감지를 위해 스냅샷도 저장합니다.

현재 엔티티 값
Texts(id=1, content="회의 자료")

스냅샷
Texts(id=1, content="회의 자료")

나중에 flush 때 이 둘을 비교합니다.

현재 값 != 스냅샷
   ↓
UPDATE SQL 생성

LAZY 연관관계는 프록시로 둠 → 엔티티에 지연 로딩 관계가 있으면 실제 객체를 바로 가져오지 않고 프록시를 넣습니다.

@ManyToOne(fetch=FetchType.LAZY)
privateUsersusers;

조회 후 객체 상태는 이런 형태입니다.

Texts
   ├─ id = 1
   ├─ content = "회의 자료"
   └─ users = Users 프록시

나중에 text.getUsers().getName()을 호출하면 프록시 초기화가 일어납니다.

text.getUsers().getName()
   ↓
Users 프록시 초기화
   ↓
SELECT users ...

이때 영속성 컨텍스트가 살아 있어야 합니다. OSIV가 켜져 있으면 서비스 밖에서도 가능합니다.

  • 9.8 DTO 반환

Repository가 엔티티를 반환하면 서비스에서 DTO로 꾸게 됩니다.

List<TextResponse> result = texts.stream()
    .map(TextResponse::from)
    .toList();

여기서 조심할 점이 있습니다. DTO 변환 중 LAZY 필드를 건드리면 추가 SQL이 나가게 됩니다.

public static TextResponse from(Texts text) {
    return new TextResponse(
        text.getId(),
        text.getContent(),
        text.getUsers().getName()
    );
}

text.getUsers().getName() 때문에 Users 조회가 추가로 나갈 수 있습니다.

Texts 100개 조회
   ↓
각 Text마다 users.getName()
   ↓
Users SELECT 100번
   ↓
N+1

그래서 성능이 중요한 조회는 fetch join이나 DTO 직접 조회를 써야합니다.

select new TextResponse(t.id, t.content, u.name)
from Texts t
join t.users u
where t.content like :keyword
  • 9.9 실제 서비스 메서드 종료

서비스 메서드가 응답 DTO를 만들고 정상 종료합니다.

return new PageSearchResponse(folders, schedules, links, texts, files);

그러면 다시 TransactionInterceptor로 제어가 돌아가게 됩니다.

실제 Service 메서드 종료
   ↓
TransactionInterceptor로 복귀

이제 트랜잭션을 끝낼 차례입니다.

  • 9.10 정상 종료 시 flush

트랜잭션을 commit하기 전에 flush가 일어날 수 있습니다.

flush = 영속성 컨텍스트 변경 내용을 SQL로 DB에 보내는 것

flush 때 Hibernate는 변경 감지를 합니다.

영속성 컨텍스트 안의 엔티티
   ↓
현재 값과 스냅샷 비교
   ↓
다르면 UPDATE SQL 생성
   ↓
INSERT/UPDATE/DELETE SQL 실행

조회 전용이면 보통 변경이 없으므로 UPDATE는 없습니다. @Transactional(readOnly = true)면 Hibernate가 flush를 줄이거나 변경 감지 부담을 줄이는 최적화를 할 수 있ㅅ브니다

readOnly = true
   ↓
조회 전용으로 취급
   ↓
flush 최적화 가능
  • 9.11 commit

flush 후 문제가 없으면 commit 진행합니다.

commit = 트랜잭션 안에서 실행된 SQL 결과를 DB에 최종 확정
flush
   ↓
SQL 실행
   ↓
commit
   ↓
DB 반영 확정

flush와 commit 차이는 다시 정리

flush
= SQL을 DB에 보냄
= 아직 최종 확정 아님

commit
= 트랜잭션 결과를 최종 확정
= rollback 불가 상태가 됨
  • 9.12 예외 발생 시 rollback

서비스 실행 중 RuntimeException이 발생하면 rollback합니다.

@Transactional
public void logic() {
    member.setName("재윤");
    throw new RuntimeException();
}
예외 발생
   ↓
TransactionInterceptor가 예외 감지
   ↓
rollback 판단
   ↓
rollback
   ↓
DB 변경 취소

기본적으로 Spring은 RuntimeException, Error에 대해 rollback합니다. Checked Exception은 기본적으로 rollback하지 않는다.

  • 9.13 Connection 반납

트랜잭션이 끝나면 DB 커넥션을 HikariCP에 반납하게 됩니다.

commit 또는 rollback
   ↓
Connection 정리
   ↓
HikariCP에 반납

OSIV가 켜져 있어도 커넥션은 계속 잡고 있지 않는 것이 일반적입니다.

트랜잭션 종료
   ↓
Connection 반납
   ↓
EntityManager는 OSIV 때문에 계속 살아 있음

즉, 서비스 트랜잭션이 끝난 뒤 상태는 이렇게 됩니다.

EntityManager 있음
영속성 컨텍스트 있음
트랜잭션 없음
DB Connection 반납됨

 

10단계 : 컨트롤러로 복귀

서비스가 응답 객체를 반환하면 컨트롤러로 돌아오게 됩니다.

PageSearchResponse response = pageSearchUseCase.search(usersId, keyword);
return ResponseEntity.ok(response);

이 시점 상태는 다음과 같습니다,

Controller로 복귀
   ↓
ResponseEntity 생성
   ↓
트랜잭션은 끝남
   ↓
Connection 반납됨
   ↓
EntityManager는 아직 살아 있음

OSIV 때문에 영속성 컨텍스트는 아직 살아 있다.

 

11단계 : 컨트롤러 반환값 처리

DispatcherServlet이 컨트롤러 반환값을 받게 됩니다.

ResponseEntity<PageSearchResponse>
   ↓
HandlerMethodReturnValueHandler

ResponseEntity니까 HTTP 상태 코드와 Body를 준비합니다.

status = 200 OK
body = PageSearchResponse

그리고 HttpMessageConverter를 찾습니다. REST API라면 보통 Jackson이 선택된다.

MappingJackson2HttpMessageConverter

 

12단계 : JSON 직렬화

Jackson이 응답 객체를 JSON으로 바꾸게 됩니다.

PageSearchResponse
   ↓
Jackson
   ↓
JSON 문자열

DTO만 들어 있다면 보통 추가 쿼리가 없습니다,

DTO
   ↓
getter 호출
   ↓
단순 값 반환
   ↓
SQL 없음

하지만 DTO 안에 엔티티가 들어 있거나 엔티티를 직접 반환하면 위험합니다.

return order;

Jackson이 getter를 호출하면서 lazy loading이 발생할 수 있습니다.

Jackson
   ↓
order.getMember()
   ↓
Member 프록시 초기화
   ↓
EntityManager 살아 있음
   ↓
SELECT member ...

OSIV가 켜져 있기 때문에 JSON 직렬화 중에도 지연 로딩이 가능하다.

이게 OSIV의 장점이자 단점입니다.

장점:
LazyInitializationException이 덜 남

단점:
JSON 직렬화 중 예상치 못한 SQL 발생
N+1 문제를 숨김
성능 추적이 어려움

만약 OSIV가 꺼져 있다면 이 시점에 EntityManager가 없으므로 lazy loading은 터지게 됩니다.

OSIV OFF
   ↓
Jackson에서 lazy getter 호출
   ↓
EntityManager 없음
   ↓
LazyInitializationException

 

13단계 : OSIV afterCompletion

JSON 직렬화까지 끝나면 DispatcherServlet의 마무리 과정에서 인터셉터 afterCompletion()이 실행됩니다.

OpenEntityManagerInViewInterceptor.afterCompletion()
   ↓
현재 스레드에서 EntityManager unbind
   ↓
EntityManager.close()
   ↓
영속성 컨텍스트 종료

이제 요청 동안 살아 있던 “상자”가 닫히게 됩니다.

영속성 컨텍스트 close
   ↓
엔티티 관리 종료
   ↓
엔티티들은 준영속 상태
   ↓
참조 없으면 GC 대상

여기서 중요한 점:

OSIV afterCompletion에서 commit하는 게 아님.
commit은 이미 서비스 @Transactional 종료 시점에 끝났음.
OSIV는 EntityManager를 닫는 역할.

즉:

@Transactional
= 트랜잭션 commit/rollback 담당

OSIV
= 요청 끝에 EntityManager close 담당

 

14단계 : 필터 체인으로 복귀

DispatcherServlet이 끝나면 다시 필터 체인을 역순으로 빠져나가게 됩니다.

DispatcherServlet 종료
   ↓
Security Filter 복귀
   ↓
CORS Filter 복귀
   ↓
CharacterEncodingFilter 복귀

Spring Security는 마지막에 SecurityContext를 정리합니다.

SecurityContextHolder.clearContext()

왜 정리하냐면 톰캣 워커 스레드는 재사용되기 때문입니다.

요청 A: userId=1
   ↓
스레드 반납
   ↓
요청 B가 같은 스레드 사용 가능

만약 SecurityContext를 안 지우면 요청 B에서 요청 A의 사용자 정보가 남을 수 있다. 그래서 요청 끝에 반드시 정리한다.

 

15단계 : 톰캣 응답 전송

마지막으로 톰캣이 HTTP 응답을 클라이언트에게 보내게 됩니다.

HttpServletResponse
   ↓
HTTP Response
   ↓
TCP Socket
   ↓
Client

응답 예시는 이런 식입니다.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "folders": [],
  "schedules": [],
  "links": [],
  "texts": [],
  "files": []
}

응답 전송이 끝나면 톰캣 워커 스레드는 풀에 반납됩니다.

요청 처리 완료
   ↓
ThreadLocal 정리 완료
   ↓
워커 스레드 반납
   ↓
다음 요청 대기

 

전체 순서 요약

요청 도착

  1. Tomcat
    1. 소켓에서 HTTP 요청 수신
    2. HttpServletRequest/Response 생성
    3. Worker Thread 배정
  2. Servlet Filter Chain
    1. 인코딩 처리
    2. CORS 처리
    3. Spring Security
      1. JWT 검증
      2. SecurityContext 저장
  3. DispatcherServlet
    1. HandlerMapping
    2. 컨트롤러 메서드 찾기
  4. OSIV preHandle
    1. EntityManager 생성
    2. 영속성 컨텍스트 생성
    3. 현재 스레드에 바인딩
  5. Controller
    1. request parameter 바인딩
    2. SecurityContext에서 userId 조회
    3. Service/UseCase 호출
    1. @Transactional AOP
    2. 6.1 프록시가 호출 가로챔
    3. 6.2 트랜잭션 설정 확인
    4. 6.3 EntityManager 확인
    5. 6.4 트랜잭션 시작
    6. 6.5 Connection 획득
    7. 6.6 실제 서비스 메서드 실행
    8. 6.7 Repository/JPA 실행
    9. 6.8 flush
    10. 6.9 commit 또는 rollback
    11. 6.10 Connection 반납
  6. Repository / JPA
    1. Repository Proxy
    2. EntityManager 사용
    3. JPQL/Query Method → SQL
    4. SQL 실행
    5. ResultSet 수신
    6. 엔티티 생성
    7. 영속성 컨텍스트 등록
    8. 스냅샷 저장
    9. LAZY 프록시 생성
  7. Service
    1. 엔티티 → DTO 변환
    2. 응답 객체 생성
  8. Controller 반환
    1. ResponseEntity 반환
  9. JSON 직렬화
    1. Jackson
    2. DTO → JSON
    3. 엔티티 lazy 접근 시 추가 SQL 가능
  10. OSIV afterCompletion]
    1. EntityManager unbind
    2. EntityManager close
  11. Filter 정리
    1. SecurityContext clear
  12. Tomcat
    1. HTTP 응답 전송
    2. Worker Thread 반납

 

UseCase에 @Transactional이 없으면 어떻게 바뀌나

public PageSearchResponse search(Long usersId, String keyword) {
    usersService.findById(usersId);
    foldersService.searchFolders(...);
    schedulesService.searchSchedules(...);
    linksService.searchLinks(...);
    textsService.searchTexts(...);
    attachMentsService.searchFiles(...);
}

이 경우 큰 트랜잭션 하나가 없습니다. 대신 Repository나 하위 Service 메서드에 붙은 트랜잭션이 각각 동작할 수 있습니다.

UseCase @Transactional 없음
 │
 ├─ usersRepository.findById()
 │    ├─ 짧은 readOnly 트랜잭션 시작
 │    ├─ SELECT
 │    ├─ commit
 │    └─ Connection 반납
 │
 ├─ foldersRepository.search()
 │    ├─ 짧은 readOnly 트랜잭션 시작
 │    ├─ SELECT
 │    ├─ commit
 │    └─ Connection 반납
 │
 ├─ schedulesRepository.search()
 │    └─ 같은 패턴
 │
 ├─ linksRepository.search()
 │    └─ 같은 패턴
 │
 ├─ textsRepository.search()
 │    └─ 같은 패턴
 │
 └─ filesRepository.search()
      └─ 같은 패턴

하지만 OSIV가 켜져 있으면 EntityManager는 요청 전체 동안 하나일 수 있습니다.

EntityManager 1개
   ├─ 짧은 트랜잭션 1
   ├─ 짧은 트랜잭션 2
   ├─ 짧은 트랜잭션 3
   ├─ 짧은 트랜잭션 4
   └─ 짧은 트랜잭션 5

즉, 차이는

@Transactional 있음: UseCase 전체를 하나의 트랜잭션으로 묶음

@Transactional 없음: Repository/하위 Service 단위로 트랜잭션이 여러 번 생길 수 있음

 

제일 중요한 결론

스프링 요청 흐름에서 헷갈리면 이 네 개를 분리해서 보면 됩니다.

1. 톰캣 스레드
   = 요청을 실행하는 주체

2. SecurityContext
   = 현재 로그인 사용자 정보

3. EntityManager / 영속성 컨텍스트
   = 엔티티를 담고 관리하는 상자
   = OSIV ON이면 요청 끝까지 살아 있음

4. @Transactional / 트랜잭션
   = DB 변경을 flush, commit, rollback하는 작업 단위
   = 보통 서비스 메서드 범위

5. DB Connection
   = 실제 DB와 통신하는 통로
   = 트랜잭션 중 실제 SQL 실행 시 잡고 끝나면 반납

한 문장으로 정리하면:

요청이 오면 톰캣 스레드가 필터와 DispatcherServlet을 거쳐 컨트롤러를 호출하고, OSIV가 EntityManager를 요청 동안 열어두며, 서비스의 @Transactional 프록시가 그 EntityManager에 트랜잭션과 DB 커넥션을 붙여 Repository/JPA 작업을 실행하고, 서비스 종료 시 flush/commit 후 커넥션을 반납하고, JSON 직렬화까지 끝난 뒤 OSIV가 EntityManager를 닫는다.