OSIV

1. 트랜잭션 범위의 영속성 컨텍스트

  • 트랜잭션이 시작될 때 영속성 컨텍스트가 시작되고 트랜잭션이 끝날 때 영속성 컨텍스트도 끝난다.
  • 스프링에서는 보통 서비스 메서드에 @Transactional을 붙인다.
@Transactional
public Order findOrder(Long id) {
    return orderRepository.findOrder(id);
}

그러면 흐름은 다음과 같다.

 

서비스 안에서는 엔티티가 영속상태 였는데, 서비스가 끝나고 트랜잭션이 종료되면 컨트롤러에 반환된 엔티티는 준영속 상태가 된다.

 

2. 문제점

  • 위 흐름에서 지연로딩 시에 문제가 생김

예를 들어 Order 엔티티가 있다고 가정해보자

@Entity
public class Order {

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;
}

LAZY는 처음 Order를 조회할 때 Member를 바로 조회하지 않는 전략이다. 대신 order.getMember()를 하면 실제 Member가 아니라 프록시 객체를 넣어둔다.

Order 조회
   │
   ├─ Order 실제 데이터 조회
   └─ Member는 실제 데이터가 아니라 프록시로 둠

프록시는 쉽게 말하면 이런 느낌이다.

  • “진짜 Member는 아직 안 가져왔고, 나중에 member.getName() 같은 실제 값이 필요하면 DB에서 조회할게.”

문제는 프록시가 DB에서 실제 데이터를 가져오려면 영속성 컨텍스트가 필요하다.

프록시 초기화 필요 → 영속성 컨텍스트 필요 → DB 조회

그런데 컨트롤러나 뷰 계층에서는 트랜잭션이 이미 끝났고 영속성 컨텍스트도 닫혔다…

그래서 프록시가 초기화 되지 못한다.

  • Controller/View → order.getMember().getName() → 프록시 초기화 시도 → 영속성 컨텍스트 없음 → LazyInitializationException

 

3. 해결하기 위한 방법

3.1 글로벌 FetchType.EAGER

  • LAZY를 EAGER로 바꾸는 것
@ManyToOne(fetch = FetchType.EAGER)
private Member member;

위와 같이 했을 때 Order를 조회할 때 Member도 같이 조회한다.

Order 조회
   │
   └─ Member도 즉시 조회

그러면 컨트롤러에서 order.getMember().getName()을 호출해도 이미 Member가 로딩되어있기 때문에 LazyInitializationException은 안난다.

 

하지만 이 방법은 추천하지 않는다. 이유는 두 가지 있다.

 

1. 사용하지 않는 엔티티까지 로딩한다.

화면 A는 Order만 필요하다고 해보자

  • 화면 A : Order만 필요
  • 화면 B : Order + Member 필요

그런데 Order.member를 글로벌로 EAGER로 설정하면 화면 A에서도 Member를 항상 같이 조회함. 안 쓰는 데이터를 매번 가져옴

 

2. JPQL에서 N + 1 문제가 발생할 수 있다.

em.find(Order.class, 1L)처럼 단건 조회할 때는 즉시 로딩이면 조인해서 가져올 수 있다.

select o.*, m.*
from orders o
left join member m on o.member_id = m.member_id
where o.id = 1

여기까지는 괜찮아 보이지만 그런데 JPQL을 쓰면 문제가 생긴다.

List<Order> orders =
    em.createQuery("select o from Order o", Order.class)
      .getResultList();

JQPL은 기본적으로 JPQL 자체를 먼저 SQL로 번역한다.

select * from orders

그 다음 Order.member가 EAGER니까 JPA는 각 Order의 Member를 추가로 조회해야한다.

select * from member where id = ?
select * from member where id = ?
select * from member where id = ?
select * from member where id = ?
...

만약 Order가 10개면,

Order 목록 조회 SQL 1번
각 Order의 Member 조회 SQL 10번
총 11번

그래서 N + 1 문제라고 한다.

이게 치명적인 이유는 데이터가 많아질 수록 SQL이 폭증하기 때문이다.

Order 10개  → SQL 11번
Order 100개 → SQL 101번
Order 1000개 → SQL 1001번

 

3.2 JPQL Fetch Join

  • 현실적인 대안이 fetch join이다.

기존 JPQL은 다음과 같다.

select o
from Order o

이건 Order 만 조회한다.

하지만 fetch join을 쓰면 다음과 같이 진행된다.

select o
from Order o
join fetch o.member

뜻은 → Order를 조회할 때 연관된 Member도 SQL Join으로 같이 가져와라

실행 SQL은 다음과 같다.

select o.*, m.*
from orders o
join member m on o.member_id = m.member_id

그러면 Order와 Member를 한 번에 가져온다

  • Order 목록 조회 → SQL Join 한번 → Order + Member 모두 로딩

그래서 컨트롤러나 뷰에서 order.getMember().getName()을 호출해도 이미 Member가 로딩 되어있다. 그리고 N + 1도 줄일 수 있다.

기존:
Order 1번 조회 + Member N번 조회

fetch join:
Order와 Member를 JOIN으로 1번 조회

하지만 단점도 존재한다.

 

예를 들어 화면 마다 필요한 데이터가 다르다고 해보자

  • 화면 A: order만 필요
  • 화면 B: order + member 필요
  • 화면 C: order + orderItems 필요
  • 화면 D: order + member + orderItems 필요

그러면 Repository에 이런 메서드가 늘어날 수 있다.

findOrder()
findOrderWithMember()
findOrderWithOrderItems()
findOrderWithMemberAndOrderItems()

즉 화면 요구사항에 맞춘 조회 메서드가 계속 증가한다. → 프레젠테이션 계층이 알게 모르게 데이터 접근 계층을 침범하는 것

쉽게 말하면 화면에서 필요한 데이터 모양 때문에 Repository 메서드가 화면 별로 계속 생김

그래도 이게 어느 정도는 필요하다고 볼 수 있다. 왜냐하면 성능 최적화는 결국 화면 별로 필요한 데이터가 다르기 떄문.

  • 화면 A는 order만 필요
  • 화면 B는 member까지 필요

둘을 무조건 하나로 합치면 한쪽은 불필요한 데이터를 가져오고 너무 쪼개면 메서드가 많아진다. → 무조건 일반화하지말고 적절한 선에서 타협점을 찾아야함

 

3.3 강제로 초기화 및 Facade 계층

@Transactional
public Order findOrder(Long id) {
    Order order = orderRepository.findOrder(id);

    order.getMember().getName(); // 강제로 프록시 초기화

    return order;
}

여기서 중요한 건 order.getMember()만 호출하면 안될 수도 있다는 점이다. order.getMember()는 프록시 객체만 반환할수도 있다.

진짜 초기화는 실제 값을 사용할 때 일어남 이렇게 해야 Member 프록시가 초기화된다. 흐름은 다음과 같다.

  • Service 트랜잭션 안 → Order 조회 → order.getMember().getName() → Member 프록시 초기화 → 트랜잭션 종료 → Controller로 반환 → 이미 Member가 로딩되어 있으므로 사용 가능

but 단점도 있다. 서비스 계층이 프레젠테이션 계층을 의식하는 것임 즉, 서비스가 원래는 비즈니스 로직에 집중해야하는데 “뷰에서 member.name을 쓸 테니까 미리 초기화해야지” 이런 생각이 든다.

그래서 서비스 로직이 화면 요구사항에 오염될 수도 있다. == 좋지 않다.

 

그래서 나온 게 Facade 계층

Facade는 컨트롤러와 서비스 사이에 하나의 계층을 더 두는 방식이다. 원래 구조는 다음과 같다.

  • Controller → Service → Repository

Facade를 넣으면 다음과 같다.

  • Controller → Facade → Service → Repository

Facade의 역할은 다음과 같다.

  • 프레젠테이션 계층과 서비스 계층 사이의 논리적 의존성 분리
  • 프레젠테이션 계층에서 필요한 엔티티 초기화
  • 서비스 계층 호출
  • 레포지토리 호출 조합

 

예를 들어보면 다음과 같다.

class OrderFacade {

    @Autowired
    OrderService orderService;

    public Order findOrder(Long id) {
        Order order = orderService.findOrder(id);

        // 프레젠테이션 계층이 필요한 프록시 객체를 강제로 초기화
        order.getMember().getName();

        return order;
    }
}

그러면 서비스는 비즈니스 로직에 집중

class OrderService {

    public Order findOrder(Long id) {
        return orderRepository.findOrder(id);
    }
}

즉 강제 초기화 코드를 서비스에서 빼고 Facade로 옮긴 것

 

장점

  • Controller는 Service를 직접 알 필요가 줄어든다.
  • Service는 화면용 초기화 로직을 몰라도 된다.
  • 화면에 필요한 데이터 준비는 Facade가 담당한다.
  • 비즈니스 로직과 프레젠테이션용 로직을 분리할 수 있다.

흐름을 보면 다음과 같다.

  • Controller
  • OrderFacade
    • 트랜잭션 시작
    • Service 호출
    • 필요한 프록시 초기화
    • 엔티티 반환
  • Controller/View

주의할 점은 Facade에서 프록시를 초기화하려면 Facade도 트랜잭션 범위 안에 있어야 한다.

@Transactional
public Order findOrder(Long id) {
    Order order = orderService.findOrder(id);
    order.getMember().getName();
    return order;
}

왜냐하면 프록시 초기화는 영속성 컨텍스트가 살아 있을 때만 가능하기 때문

 

단점 → 계층 하나가 더 생긴다는 것 자체가 단점임 == 그래서 코드가 더 늘어남.

 

4. 준영속 상태와 지연 로딩 문제의 본질

  • 3번에서 해결책을 보긴 했지만 문제의 본질은 엔티티가 프레젠테이션 계층에서 준영속 상태이기 때문에 발생하는 것

즉 컨트롤러나 뷰에서 엔티티를 사용할 때 영속성 컨텍스트가 이미 닫혀 있기 때문에 문제가 생기는 것

그래서 근본적으로 보았을 때 → 그러면 영속성 컨텍스트를 뷰까지 열어두면 되지 않을까? 이게 바로 OSIV이다.

 

5. OSIV란?

Open Session in View이며 Hibernate 용어로는 Session 이고 JPA에서는 EntityManager니까 정확히는 Open EntityManager in View라고 함.

 

뜻은 영속성 컨텍스트를 뷰까지 열어두는 것

즉, 서비스 계층이 끝났다고 바로 영속성 컨텍스트를 닫지 않고, 요청이 끝날 때까지 살려둔다.

 

6. 과거 OSIV : 요청 당 트랜잭션

과거 방식의 OSIV를 보자

이 방식은 요청이 들어오자마자 트랜잭션을 시작하고, 요청이 끝날 때 트랜잭션을 커밋한다.

이 방식의 장점은 다음과 같다.

  • 요청 전체에서 영속성 컨텍스트가 살아 있음
  • 뷰에서도 지연 로딩 가능
  • 엔티티는 요청 끝까지 영속 상태

그래서 다음과 같은 것들이 가능하다.

Member member = memberService.getMember(id);
model.addAttribute("member", member);

뷰에서 member.orders 같은 지연 로딩 컬렉션을 접근해도 가능함.

 

하지만 치명적인 문제가 있었다.

  • 문제는 요청 전체가 트랜잭션이라는 점

컨트롤러에서 단순히 화면 표시용으로 이름을 바꾼다고 가정

Member member = memberService.getMember(id);
member.setName("XXX"); // 화면에만 XXX로 보여주고 싶음
model.addAttribute("member", member);

개발자의 의도는 DB를 바꾸려는 게 아니라 화면에만 이름을 XXX로 보여주고 싶다라는 것

그런데 요청 끝에서 트랜잭션이 커밋됨 커밋 전에 Flush가 일어나고 변경 감지가 동작함.

  1. member.setName("XXX")
  2. 영속 상태 엔티티 변경됨
  3. 요청 끝
  4. flush
  5. dirty checking
  6. update member set name='XXX'

결과 적으로 DB이름까지 XXX로 바뀔 수 있음 그래서 과거 OSIV의 가장 큰 문제는

→ 프레젠테이션 계층에서 엔티티를 수정했는데 그 수정이 DB에 반영될 수 있다.

프러젠테이션 계층에서 엔티티 수정을 막기 위해 여라가지 방법들이 생겼다.

 

6.1 읽기 전용 인터페이스 제공

interface MemberView {
    String getName();
}

@Entity
class Member implements MemberView {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

컨트롤러에는 Member가 아니라 MemberView 타입으로 넘긴다.

public MemberView getMember(Long id) {
    return memberRepository.findById(id);
}

그러면 프레젠테이션 계층에서는 getName()만 보이고 setName()은 못 쓴다.

  • Member 타입이면: getName() 가능, setName() 가능
  • MemberView 타입이면: getName() 가능, setName() 불가능

장점은 엔티티를 직접 수정하지 못하게 막을 수 있다.

단점은 인터페이스를 따로 설계해야해서 완전히 깔끔한 해결은 아니다.

 

6.2 엔티티 래핑

엔티티를 직접 넘기지 않고 읽기 메서드만 가진 래퍼 객체로 감싼다.

class MemberWrapper {

    private Member member;

    public MemberWrapper(Member member) {
        this.member = member;
    }

    public String getName() {
        return member.getName();
    }
}

컨트롤러에는 MemberWrapper를 넘긴다.

그러면 외부에서는 getName()만 호출할 수 있고, setName()은 호출할 수 없다.

  • MemberWrapper
    • 내부에는 member를 가지고 있음 하지만 읽기 메서드만 외부에 제공

장점은 엔티티 직접 수정을 막을 수 있다.

단점은 래퍼 클래스를 계속 만들어야해서 번거롭다.

 

6.3 DTO만 반환

가장 전통적이고 실무에서 많이 쓰는 방식

class MemberWrapper {

    private Member member;

    public MemberWrapper(Member member) {
        this.member = member;
    }

    public String getName() {
        return member.getName();
    }
}

서비스나 컨트롤러에서 엔티티를 DTO로 변환해서 변환한다.

Member member = memberService.getMember(id);

MemberDTO dto = new MemberDTO(member.getName());

return dto;

DTO는 엔티티가 아니기 때문에 영속성 컨텍스트가 관리하지 않는다.

그래서 DTO 값을 바꿔도 DB 변경 감지가 일어나지 않는다.

  • Entity 수정 → 영속성 컨텍스트가 관리 중이면 DB 반영 가능
  • DTO 수정 → 그냥 일반 객체 수정, DB와 무관

단점은 코드량이 늘어난다는 것

MemberDTO
OrderDTO
OrderDetailDTO
OrderWithItemsDTO
...

그래서 API 서버에서는 보통 이 방식이 가장 안전함.

 

7. 스프링 OSIV: 비즈니스 계층 트랜잭션

과거 OSIV는 요청 전체가 트랜잭션이라 위험했음 그래서 스프링이 제공하는 OSIV는 조금 다르게 동작한다.

→ 영속성 컨텍스트는 요청 끝까지 유지하지만, 트랜잭션은 비즈니스 계층에서만 사용한다.

즉 서비스 계층에서 @Transactional이 붙은 메서드가 실행될 때만 트랜잭션을 시작한다.

흐름은 다음과 같다.

 

그림으로 보면 다음과 같음

그러면 영속성 컨텍스트는 요청 시작 부터 끝까지 살아있는 거고 트랜잭션이 Service 시작 후와 Service 종료까지만 살아있는데 트랜잭션 역할을 좀 더 봐보자 영속성 컨텍스트와 트랜잭션의 역할을 나누어보자

  • 영속성 컨텍스트 = JPA가 엔티티를 관리하는 메모리 공간
  • 트랜잭션 = DB에 변경 내용을 안전하게 반영하거나 취소하는 작업 단위

영속성 컨텍스트의 역할

  • JPA의 작업장이라고 말할 수 있으며
    • 조회한 엔티티, 1차 캐시, 변경 감지용 스냅샷, 프록시 객체, 지연 로딩에 필요한 연결 정보

트른잭션의 역할

  • DB에 실제로 반영할지 말지를 결정하는 경계

예를 들어 서비스를

@Transactional
public void changeName(Long id) {
    Member member = memberRepository.findById(id);
    member.setName("재윤");
}

코드에서 save()가 없어도 DB에 반영이 가능함 이유는 트랜잭션 커밋 시점에 다음과 같은 일이 일어남

  1. @Transactional 시작
  2. DB 조회
  3. 엔티티 영속 상태
  4. member.setName(”재윤”)
  5. @Transactional 종료
  6. flush
  7. 변경 감지
  8. UPDATE SQL 실행
  9. commit

여기서 트랜잭션의 역할은 이것임.

1. 이 작업 단위를 시작한다.
2. 변경 내용을 DB에 반영할 수 있는 상태로 만든다.
3. 커밋 시점에 flush를 통해 SQL을 실행한다.
4. 성공하면 commit한다.
5. 예외가 나면 rollback한다.

즉 영속성 컨텍스트는 트랜잭션 밖에서도 엔티티를 들고 있을 수 있지만, DB에 변경을 반영하는 flush/commit은 트랜잭션 안에서만 정상적으로 동작하도록 설계되어 있다.

 

스프링 OSVI의 핵심 특징을 보자

  1. 클라이언트 요청이 들어올 때 영속성 컨텍스트를 생성한다.
  2. 요청이 끝날 때까지 같은 영속성 컨텍스트를 유지한다.
  3. 한 번 조회한 엔티티는 요청이 끝날 때까지 영속 상태를 유지한다.
  4. 엔티티 수정은 트랜잭션이 있는 계층에서만 동작한다.
  5. 트랜잭션이 없는 프레젠테이션 계층은 지연 로딩을 포함한 조회만 가능하다.

중요한 문장 → 엔티티 수정은 트랜잭션이 있는 계층에서만 동작한다. 트랜잭션이 없는 프레젠테이션 계층은 징녀 로딩을 포함해서 조회만 할 수 있다.

즉, 컨트롤러에서 지연 로딩은 가능함.

Stringname=order.getMember().getName();

하지만 컨트롤러에서 수정해도 바로 DB에 반영되지는 않음

member.setName("XXX");

왜냐하면 컨트롤러에는 트랜잭션이 없기 때문

그러면 스프링 OSIV에서 트랜잭션 없이 읽기를 보자

  • 영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야한다. 그런데 단순 조회는 트랜잭션 없이도 가능함. 이것을 Nontransactional reads 즉, 트랜잭션 없이 읽기라고 함

트랜잭션 있음: 조회 가능, 수정 가능, flush 가능, commit 가능

트랜잭션 없음: 조회 가능, 지연 로딩 가능, 수정은 DB 반영 불가

스프링 OSIV에서는 컨트롤러/뷰 계층에 트랜잭션은 없지만 영속성 컨텍스트는 살아있다. 그래서 지연로딩이 가능함.

 

8. 스프링 OSIV 주의 사항 : 수정 후 트랜잭션 서비스 호출

정말 중요한 부분

스프링 OSIV는 프레젠테이션 계층에서 수정해도 DB에 반영되지 않는다.

Member member = memberService.getMember(id);
member.setName("XXX");
return "view";

이것만 있으면 DB에 반영되지 않는다 왜냐하면 컨트롤러에는 트랜잭션이 없고 요청 끝에서 OSIV는 flush()를 호출하지 않고 em.close()만 하기 때문

그런데 문제가 되는 경우가 있다.

Member member = memberService.getMember(id);
member.setName("XXX");

memberService.biz(); // @Transactional

이 흐름을 보면

  1. 요청 시작 → 영속성 컨텍스트 생성
  2. memberService.getMember(id)
    1. 트랜잭션 안에서 Member 조회
    2. 트랜잭션 종료
    3. 영속성 컨텍스트는 살아 있음
    4. Member는 계속 영속 상태
  3. 컨트롤러에서 member.setName("XXX") → 트랜잭션은 없지만 영속 상태 엔티티 값이 바뀜
  4. memberService.biz() 호출 → @Transactional 때문에 트랜잭션 시작 → 기존 영속성 컨텍스트에 트랜잭션이 붙음
  5. biz() 종료 → 트랜잭션 커밋, flush 발생, 변경 감지 동작, 컨트롤러에서 바꾼 XXX도 DB에 반영됨

문제는 → OSIV에서는 같은 요청 안에서 하나의 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다.

그래서 컨트롤러에서 바꿔둔 엔티틱밧이 나중에 실행된 트랜잭션에서 flush 될 수 있다.

이 해결 방법은 트랜잭션이 있는 비즈니스 로직을 모두 먼저 호출하고 그 다음에 엔티티를 변경해야한다.

 

9. OSIV에서 Repository 직접 호출

기존에는 계층을 엄격하게 나누면 Controller는 Service만 호출하고 Repository는 직접 호출하지 않는 게 일반적이다.

그런데 OSIV를 쓰면 영속성 컨텍스트가 컨트롤러까지 살아있다.

그래서 단순 조회하면 컨트롤러에서 Repository를 직접 호출해도 기술적으로 문제가 없다고 한다.

class OrderController {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    public String order(Order order, Model model) {
        Long id = orderService.order(order); // 상품 구매 비즈니스 로직

        Order orderResult = orderRepository.findOne(id); // 단순 조회
        model.addAttribute("order", orderResult);

        return "view";
    }
}
  • OSIV 사용 전 : 프레젠테이션 계층에서 사용할 지연 로딩 엔티티를 미리 초기화해야했다. 초기화는 서비스나 Facade가 담당했다.
  • OSIV 사용 후 : 영속성 컨텍스트가 프레젠테이션 계층까지 살아 있으므로 단순 엔티티 조회는 컨트롤러에서 Repository를 직접 호출해도 동작한다.

하지만 조심해야할 부분은 계층형 아키텍처 관점에서는 Controller가 Repository를 직접 아는 게 별로 좋지 않을 수 있다.

→ 너무 엄격하게 Controller는 무조건 Service만 호출해야한다라고 하면 오히려 단순 조회 코드도 불필요하게 복잡해질 수 있다.

즉, 비즈니스 로직은 Service에서 처리하고 단순 조회는 유연하게 볼 수 있다는 관점

 

10. Spring OSIV의 단점

크게 3가지가 있다.

10.1 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다.

  • 요청 하나
    • 영속성 컨텍스트 하나
    • 트랜잭션 A
    • 컨트롤러에서 엔티티 수정
    • 트랜잭션 B

같은 영속성 컨텍스트 안에서 엔티티 상태가 유지 되기 때문에 나중 트랜잭션에서 예상치 못한 변경이 flush 될 수 있음

10.2 프레젠테이션 계층에서 수정 후 비즈니스 로직 호출하면 수정될 수 있다.

이건 예제로 본 내용

member.setName("XXX");
memberService.biz();// @Transactional

이 순서가 위험하다. 안전한 순서는 보통 다음과 같이

memberService.biz();// 먼저 트랜잭션 비즈니스 로직

member.setName("XXX");// 마지막 화면용 변경

하지만 제일 좋은 것은

컨트롤러에서 엔티티를 수정하지 않는다.
DTO로 변환해서 화면/API에 넘긴다.

10.3 프레젠테이션 계층에서 지연 로딩 SQL이 실행된다.

OSIV가 켜져 있으면 컨트롤러나 뷰에서도 지연 로딩이 가능함

그런데 이 말은 반대로 컨트롤러나 JSON 직렬화 중에도 SQL이 나갈 수 있다.는 뜻이다.

예를 들어:

return order;

Jackson이 JSON으로 변환하면서 getter를 호출

getMember()
getOrderItems()
getDelivery()

이때 지연 로딩이 터질 수 있어.

JSON 직렬화 -> getMember() -> SQL 실행

문제는 개발자가 Repository나 Service에서 쿼리를 명확히 호출한 게 아니라, 응답을 만드는 과정에서 쿼리가 나간다는 점

그래서 성능 추적이 어려워짐

"왜 이 API가 느리지?" -> Service 쿼리는 1개인데, JSON 직렬화 중 Lazy Loading으로 쿼리 수십 개 발생

이런 일이 생길 수 있음.

 

11. OSIV vs Facade vs DTO

OSIV를 쓰지 않는 대안으로 Facade나 DTO를 말한다.

11.1 OSIV 사용

장점:
뷰/컨트롤러에서 지연 로딩 가능
엔티티 그래프 탐색이 편함
코드가 줄어듦

단점:
쿼리 발생 위치가 불명확
프레젠테이션 계층에서 엔티티 수정 위험
성능 튜닝 지점이 흐려짐
원격 클라이언트에는 적용 불가

11.2 Facade 사용

장점:
서비스와 프레젠테이션 계층 분리
필요한 엔티티를 트랜잭션 안에서 초기화 가능

단점:
계층 하나 추가
위임 코드 증가
화면별 초기화 코드 증가

11.3 DTO 사용

장점:
API 응답 구조 명확
엔티티 노출 방지
프레젠테이션 계층에서 수정해도 DB와 무관
성능 최적화 쿼리와 응답 모델을 분리 가능

단점:
DTO 클래스와 변환 코드 증가

어떤 방법을 쓰든 OSIV를 사용하는 것과 비교해서 지루한 코드를 많이 작성해야 한다.

즉, OSIV는 편하다. 하지만 편한 만큼 조심해야 한다.

 

12. OSIV를 사용하는 방법이 항상 좋은 건 아니다

복잡한 화면에서는 OSIV가 오히려 효과적이지 않을 수 있다라는 것 예를 들어 관리자 통계 화면처럼 여러 테이블을 조인하고 집계해야 하는 경우

회원 수
주문 수
매출 합계
카테고리별 판매량
일자별 통계

이걸 엔티티 그래프 탐색으로 만들면 비효율적일 수 있다.

List<Order>orders=orderRepository.findAll();

for (Orderorder :orders) {
		order.getMember().getName();
		order.getOrderItems().size();
		order.getDelivery().getAddress();
}

이러면 지연 로딩이 계속 발생하고 N+1 문제가 생길 수 있음. 이런 경우에는 처음부터 필요한 데이터를 JPQL로 조회해서 DTO로 받는 게 낫다.

select new OrderStatsDto(
    o.id,
    m.name,
    sum(oi.price)
)
from Order o
join o.member m
join o.orderItems oi
group by o.id, m.name

즉, 복잡한 조회 화면은 이게 낫다.

엔티티 조회 후 그래프 탐색 보다 처음부터 DTO 조회

 

13. Spring Boot 기준으로 이해

Spring Boot + JPA에서는 보통 OSIV가 기본으로 켜져 있는 경우가 많음

spring.jpa.open-in-view=true

이 상태에서는 이런 일이 가능

@GetMapping("/orders/{id}")
publicOrderResponsegetOrder(@PathVariable Long id) {
	Order order=orderService.findOrder(id);
	
	// 서비스 트랜잭션은 끝났지만 OSIV 때문에 지연 로딩 가능
	String memberName=order.getMember().getName();
	
	return new OrderResponse(order.getId(),memberName);
}

하지만 API 서버에서는 보통 이렇게 하는 게 더 안전

spring.jpa.open-in-view=false

그리고 필요한 데이터를 서비스/리포지토리에서 명확히 가져온다.

@Transactional(readOnly=true)
public OrderResponsegetOrder(Long id) {
	Order order=orderRepository.findOrderWithMember(id);
	
	return new OrderResponse(
			order.getId(),
			order.getMember().getName()
	   );
}

또는 아예 DTO로 조회한다.

public OrderResponse findOrderDto(Long id) {
    return em.createQuery(
        "select new com.example.OrderResponse(o.id, m.name) " +
        "from Order o join o.member m " +
        "where o.id = :id",
        OrderResponse.class
    ).setParameter("id", id)
     .getSingleResult();
}

14. 결론

OSIV는 영속성 컨텍스트를 요청 끝까지 열어두어 컨트롤러/뷰에서도 지연 로딩을 가능하게 하지만 그만큼 쿼리 발생 위치와 엔티티 수정 위험을 조심해야 하는 전략이다.