1. OSIV (Open Session In View)
OSIV란?
기본적으로 영속성 컨텍스트는 @Transactional 메서드 단위로 살아있다. OSIV가 켜지면 이 생명주기가 HTTP 요청 단위로 확장된다. Filter/Interceptor 레벨에서 영속성 컨텍스트를 열고, HTTP 응답이 클라이언트에게 보내질 때까지 유지한다.
Spring Boot는 OSIV를 기본적으로 켜놓는다 (spring.jpa.open-in-view=true).
OSIV가 켜져 있을 때
HTTP Request 도착
→ OpenEntityManagerInViewInterceptor.preHandle()
→ EntityManager 생성 (영속성 컨텍스트 시작)
→ Controller → Service (@Transactional)
→ 트랜잭션 시작/종료
→ 트랜잭션은 끝났지만 EntityManager는 살아있음
→ 다시 Controller로
→ 응답 직렬화 (Jackson이 LAZY 필드 접근 가능)
→ OpenEntityManagerInViewInterceptor.afterCompletion()
→ EntityManager 종료
핵심은 트랜잭션과 영속성 컨텍스트의 생명주기가 다르다는 점이다. 트랜잭션은 Service에서 끝나지만, 영속성 컨텍스트는 응답이 나갈 때까지 살아있다.
덕분에 아래 코드가 LazyInitializationException 없이 동작한다.
@RestController
public class UserController {
@GetMapping("/users/{id}")
public UserResponse get(@PathVariable Long id) {
User user = userService.findById(id); // @Transactional 종료
// user는 이미 detached여야 정상인데,
// OSIV 덕분에 still managed
return new UserResponse(user.getId(), user.getOrders().size());
// ↑
// LAZY인데 이 시점에 SELECT가 나가서 동작함
}
}
OSIV의 문제
Spring Boot 자체가 startup 로그에 경고를 띄운다.
spring.jpa.open-in-view is enabled by default.
Therefore, database queries may be performed during view rendering.
Explicitly configure spring.jpa.open-in-view to disable this warning
문제는 두 가지다.
① 트랜잭션 외부에서 SQL이 발생
직렬화 시점에 LAZY 접근이 일어나면 이미 트랜잭션은 끝났는데도 SQL이 실행된다. 이 SQL은 autocommit 모드로 실행된다. @Transactional 안에서는 여러 SQL이 하나의 트랜잭션으로 묶이지만, 직렬화 단계 SQL은 매번 별개 트랜잭션으로 실행된다.
② Connection 점유 문제
직렬화 중 LAZY 접근이 발생하면 Connection을 다시 풀에서 빌려온다. 이 시점에 외부 API 호출처럼 시간이 오래 걸리는 작업이 끼어 있으면 Connection을 오래 들고 있게 된다.
@GetMapping("/orders/{id}")
public OrderResponse get(@PathVariable Long id) {
Order order = orderService.findById(id);
// 외부 API 호출 (3초 소요)
ExternalData ext = externalApiClient.fetchData(order);
return new OrderResponse(order, ext);
// 직렬화 중 LAZY 접근 → Connection 다시 획득
}
트래픽이 낮을 때는 문제가 없다가, 부하가 올라가면 Connection 풀 고갈로 갑자기 폭발하는 특성이 있다.
OSIV를 끌 것인가
상황 권장
| 소규모 CRUD, 빠른 개발이 우선 | 켜둔 채로 진행 가능 |
| 외부 API 호출이 많은 서비스 | 끄는 것을 강력 권장 |
| 트래픽이 많고 성능이 중요한 서비스 | 끄는 것을 강력 권장 |
| 새 프로젝트 | 처음부터 끄고 시작 |
새 프로젝트라면 처음부터 끄고 시작하는 것이 좋다. 끄고 시작하면 LazyInitializationException이 개발 단계에서 즉시 드러나서 데이터 로딩 전략을 명확히 설계하게 된다. 켜놓고 나중에 끄려면 예외가 사방에서 터지므로 마이그레이션 비용이 크다.
spring:
jpa:
open-in-view: false # 명시적으로 끔
OSIV를 끈 후 대응
OSIV를 끄면 LAZY 접근은 반드시 트랜잭션 안에서 끝나야 한다.
① Service 레이어에서 DTO로 변환
@Service
@Transactional(readOnly = true)
public class UserService {
public UserResponse findById(Long id) {
User user = userRepository.findById(id).orElseThrow();
return new UserResponse(
user.getId(),
user.getName(),
user.getOrders().size() // 트랜잭션 안이라 LAZY 접근 가능
);
}
}
② Fetch Join으로 미리 로딩
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
이 두 가지가 OSIV 없이 사는 정석이다.
2. 락 — 동시성 제어
동시 수정 문제
JPA는 기본적으로 마지막 쓰기가 이긴다(Last-Write-Wins).
[T1] [T2]
SELECT user (balance=1000)
SELECT user (balance=1000)
balance = 1000 → 900
COMMIT
balance = 1000 → 800
COMMIT ← T1의 변경이 사라짐
원래 900원이 되어야 할 잔액이 800원으로 덮어써진다. 이것이 Lost Update 문제다.
해결 방법은 두 가지다. 낙관적 락과 비관적 락.
3. 낙관적 락 (@Version)
충돌은 거의 없겠지만, 만약 일어나면 그때 감지하자는 접근이다.
@Entity
public class Account {
@Id Long id;
Long balance;
@Version
Long version; // JPA가 자동 관리
}
JPA가 UPDATE할 때 자동으로 version 조건을 추가한다.
UPDATE account
SET balance = ?, version = version + 1
WHERE id = ? AND version = ? -- 로드 시점의 version
version 조건이 맞지 않으면 0행이 갱신되고, JPA는 OptimisticLockException을 던진다.
[T1] [T2]
SELECT (version=5)
SELECT (version=5)
UPDATE WHERE version=5 → 성공 (version=6)
COMMIT
UPDATE WHERE version=5 → 0행 갱신
→ OptimisticLockException
T2는 자기가 본 데이터가 stale임을 알게 되고, 재시도하거나 사용자에게 충돌을 알릴 수 있다.
장단점
- 장점: DB에 락을 잡지 않으므로 성능이 좋다. 충돌이 드문 시스템에 적합하다.
- 단점: 충돌 시 OptimisticLockException을 처리하는 로직이 필요하다. 충돌이 잦으면 재시도 비용이 커서 오히려 비관적 락이 나을 수 있다.
4. 비관적 락 (@Lock)
충돌이 자주 일어날 것 같으니 미리 락을 잡자는 접근이다. DB의 SELECT ... FOR UPDATE를 사용한다.
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findByIdForUpdate(@Param("id") Long id);
}
SELECT * FROM account WHERE id = ? FOR UPDATE;
이 락은 트랜잭션이 끝날 때까지 유지된다. 다른 트랜잭션이 같은 행에 UPDATE를 시도하면 블로킹된다.
LockModeType SQL 의미
| PESSIMISTIC_READ | LOCK IN SHARE MODE | 공유 락. 다른 트랜잭션 읽기 가능, 쓰기 불가 |
| PESSIMISTIC_WRITE | FOR UPDATE | 배타 락. 다른 트랜잭션 읽기/쓰기 모두 차단 |
| PESSIMISTIC_FORCE_INCREMENT | FOR UPDATE + version 증가 | 비관적 락 + 낙관적 락 결합 |
장단점
- 장점: 충돌 자체를 막는다. 재시도 로직이 불필요하다.
- 단점: 락이 길어지면 처리량이 떨어진다. 데드락 위험이 있다.
비관적 락 + 인덱스의 중요성
SELECT ... FOR UPDATE가 인덱스를 사용하지 못하면 테이블 락이 걸린다. InnoDB는 인덱스 레코드에 락을 거는 구조라, 인덱스 없는 쿼리는 모든 행을 잠근다.
// 위험한 패턴
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.userName = :name") // userName에 인덱스 없으면?
Optional<Account> findByUserNameForUpdate(@Param("name") String name);
비관적 락을 쓸 때는 반드시 WHERE 조건에 인덱스가 있는 컬럼을 사용해야 한다. 가능하면 PK로 조회하는 것이 가장 안전하다.
5. 낙관적 vs 비관적 — 언제 무엇을 쓸까
상황 권장
| 충돌 빈도 낮음 (게시글 수정, 프로필 변경) | 낙관적 락 |
| 충돌 빈도 높음 (재고 차감, 좌석 예약) | 비관적 락 |
| 단일 행 충돌 + 짧은 트랜잭션 | 비관적 락 |
| 분산 환경, 여러 인스턴스 동시 접근 | 비관적 락 또는 분산 락(Redis) |
| 사용자 세션이 길고 충돌 시 UX를 챙겨야 할 때 | 낙관적 락 + 재시도 |
판단 기준을 단순하게 정리하면 이렇다. "이 데이터가 동시에 수정될 가능성이 얼마나 되는가?" 가능성이 낮으면 낙관적 락, 높으면 비관적 락이다.
6. 정리
주제 핵심
| OSIV | 영속성 컨텍스트를 HTTP 요청 단위로 확장. Spring Boot 기본 ON |
| OSIV 문제 | 트랜잭션 외부 SQL 발생, Connection 점유 시간 증가 |
| OSIV 권장 | 새 프로젝트는 처음부터 OFF. Service에서 DTO 변환으로 대응 |
| 낙관적 락 | @Version으로 버전 비교. 충돌 시 OptimisticLockException. 충돌 빈도 낮을 때 |
| 비관적 락 | SELECT FOR UPDATE. 충돌 빈도 높을 때. 인덱스 없으면 테이블 락 주의 |
'Java & Spring > Spring' 카테고리의 다른 글
| Spring Batch 개념, 사용 (0) | 2026.04.29 |
|---|---|
| 페이지네이션의 원리? 필요성? (0) | 2026.04.10 |
| [MapStruct] 내부 구현 방식 - 설계 문제 해결 (0) | 2026.04.09 |
| Spring 프록시 원리 (0) | 2026.04.03 |
| MapStruct (0) | 2026.03.22 |