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 애플리케이션은 이미 실행되어있습니다.
- main()
- SpringApplication.run()
- ApplicationContext 생성
- 내장 Tomcat 기동
- 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 정리 완료
↓
워커 스레드 반납
↓
다음 요청 대기
전체 순서 요약
요청 도착
- Tomcat
- 소켓에서 HTTP 요청 수신
- HttpServletRequest/Response 생성
- Worker Thread 배정
- Servlet Filter Chain
- 인코딩 처리
- CORS 처리
- Spring Security
- JWT 검증
- SecurityContext 저장
- DispatcherServlet
- HandlerMapping
- 컨트롤러 메서드 찾기
- OSIV preHandle
- EntityManager 생성
- 영속성 컨텍스트 생성
- 현재 스레드에 바인딩
- Controller
- request parameter 바인딩
- SecurityContext에서 userId 조회
- Service/UseCase 호출
-
- @Transactional AOP
- 6.1 프록시가 호출 가로챔
- 6.2 트랜잭션 설정 확인
- 6.3 EntityManager 확인
- 6.4 트랜잭션 시작
- 6.5 Connection 획득
- 6.6 실제 서비스 메서드 실행
- 6.7 Repository/JPA 실행
- 6.8 flush
- 6.9 commit 또는 rollback
- 6.10 Connection 반납
- Repository / JPA
- Repository Proxy
- EntityManager 사용
- JPQL/Query Method → SQL
- SQL 실행
- ResultSet 수신
- 엔티티 생성
- 영속성 컨텍스트 등록
- 스냅샷 저장
- LAZY 프록시 생성
- Service
- 엔티티 → DTO 변환
- 응답 객체 생성
- Controller 반환
- ResponseEntity 반환
- JSON 직렬화
- Jackson
- DTO → JSON
- 엔티티 lazy 접근 시 추가 SQL 가능
- OSIV afterCompletion]
- EntityManager unbind
- EntityManager close
- Filter 정리
- SecurityContext clear
- Tomcat
- HTTP 응답 전송
- 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를 닫는다.
'프로젝트 > toIT' 카테고리의 다른 글
| Docker 및 Docker Compose 도입 - 7/1 (0) | 2026.07.02 |
|---|---|
| 통합검색 성능 개선기 (0) | 2026.07.02 |
| OSIV (0) | 2026.07.02 |
| 톰캣 (0) | 2026.07.02 |
| 이미지 조회 시 S3 원본 직접 전송 구조를 Lambda 리사이즈 및 CloudFront 캐싱 구조로 전환 (0) | 2026.06.12 |