Java & Spring/Spring

OSIV · 낙관적 락 · 비관적 락

승주우에요 2026. 5. 9. 19:04

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