OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.

지연 로딩된 객체를 초기화하는 것은 영속성 컨텍스트의 도움을 받아서 이루어진다.

즉, 영속성 컨텍스트가 열려 있어야 가능한 작업인데, Service 계층이 종료되는 시점에 Transaction이 닫힌다.

과거 OSIV: 요청 당 트랜잭션

OSIV의 핵심은 뷰에서도 지연 로딩이 가능하도록 하는 것이다.

https://user-images.githubusercontent.com/2491418/84342949-d3586b80-abe1-11ea-99ff-e67647db8354.png

그림과 같이 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 만들면서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션과 영속성 컨텍스트를 함께 종료한다.

요청 당 트랜잭션 방식의 OSIV 문제점

요청 당 트랜잭션 방식의 OSIV가 가지는 문제점은 컨트롤러나 뷰 같은 프리젠테이션 계층이 엔티티를 변경할 수 있다는 점이다. 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막는 방법들은 다음과 같다.

  • 엔티티를 읽기 전용 인터페이스로 제공
  • 엔티티 래핑
  • DTO만 반환

엔티티를 읽기 전용 인터페이스로 제공

엔티티를 직접 노출하는 대신에 다음 예제와 같이 읽기 전용 메소드만 제공하는 인터페이스를 프리젠테이션 계층에 제공하는 방법이다.

interface MemberView {
    public String getName();
}

@Entity
class Member implements MemberView {
    ...
}

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

엔티티 래핑

엔티티의 읽기 전용 메소드만 가지고 있는 엔티티를 감싼 객체를 만들고 이것을 프리젠테이션 계층에 반환하는 방법이다.

class MemberWarpper {
    private Member member;
    public MemberWrapper(member) {
        this.member = member;
    }
    //읽기 전용 메소드만 제공
    public String getName() {
        return member.getName();
    }
}

DTO만 반환

가장 전통적인 방법으로 프리젠테이션 계층에 엔티티 대신에 단순히 데이터만 전달하는 객체인 DTO를 생성해서 반환하는 것이다. 하지만 이 방법은 OSIV를 사용하는 장점을 살릴 수 없고 엔티티를 거의 복사한 듯한 DTO 클래스도 하나 더 만들어야 한다.

지금까지 설명한 OSIV는 요청 당 트랜잭션 방식의 OSIV다.

최근에는 이런 문제점을 어느정도 보완해서 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV를 사용한다. 스프링 프레임워크가 제공하는 OSIV가 바로 이 방식을 사용하는 OSIV다.

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

스프링 Boot에서는 Open Session In View 패턴OpenEntityManagerInViewInterceptor를 통해 default로 지원을 해주고 있습니다.

OSIV를 서블릿 필터에서 적용할지 스프링 인터셉터에서 적용할지에 따라 원하는 클래스를 선택해서 사용하면 된다. 예를 들어) JPA를 사용하면서 서블릿 필터에 OSIV를 적용하려면 OpenEntityManagerInViewFilter를 서블릿 필터에 등록하면 되고, 스프링 인터셉터에 OSIV를 적용하려면 OpenEntityManagerInViewInterceptor를 스프링 인터셉터에 등록하면 된다.

참고) spring의 OSIV default value는 true이다.

스프링 OSIV 분석

스프링 프레임워크가 제공하는 OSIV는 비즈니스 계층에서 트랜잭션을 사용하는 OSIV다.

https://user-images.githubusercontent.com/2491418/84342960-dce1d380-abe1-11ea-91b1-e513dd7a4bac.png

클라이언트의 요청이 들어오면 영속성 컨텍스트를 생성한다. 이때 트랜잭션은 시작하지 않는다. 비즈니스 로직을 실행하고 서비스 계층이 끝나면 트랜잭션을 커밋하면서 영속성 컨텍스트를 플러시한다. 이때 트랜잭션만 종료하고 영속성 컨텍스트는 살려둔다. 이후 클라이언트의 요청이 끝날 때 영속성 컨텍스트를 종료한다.

  1. 클라이언트의 요청이 들어오면 서블릿 필터나, 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 단 이때 트랜잭션은 시작하지는 않는다.
  2. 서비스 계층에서 @Transactional로 트랜잭션을 시작할 때 1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
  3. 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 트랜잭션은 끝내지만 영속성 컨텍스트는 종료하지 않는다.
  4. 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
  5. 서블릿 필터나, 스프링 인터셉터로 요청이 들어오면 영속성 컨텍스트를 종료한다. 이때 플러시를 호출하지 않고 바로 종료한다.

트랜잭션 없이 읽기

엔티티를 변경하지 않고 단순히 조회만 할 때는 트랜잭션이 없어도 되는데 이것을 트랜잭션 없이 읽기라 한다. 프록시를 초기화하는 지연 로딩도 조회 기능이므로 트랜잭션 없이 읽기가 가능하다.

OSIV는 다음과 같은 특징이 있다.

  • 영속성 컨텍스트를 프리젠테이션 계층까지 유지한다.
  • 프리젠테이션 계층에는 트랜잭션이 없으므로 엔티티를 수정할 수 없다.
  • 프리젠테이션 계층에는 트랜잭션에 없지만 트랜잭션 없이 읽기를 사용해서 지연로딩을 할 수 있다.

예제 코드

@Entity
@Table(name = "MEMBERS")
@NoArgsConstructor
@Getter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;

    public void setName(String name) {
        this.name = name;
    }
}
@Entity
@Table(name = "TEAMS")
@NoArgsConstructor
@Getter
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

}
@Service
@RequiredArgsConstructor
public class OSIVService {
    private final MemberRepository memberRepository;
    private final TeamRepository teamRepository;

    @Transactional
    public Member findMemberById(Long memberId) {
        return memberRepository.findById(memberId)
                .orElseThrow(RuntimeException::new);
    }

    @Transactional
    public Team findTeams(Long teamId){
        return teamRepository.findById(teamId)
                .orElseThrow(RuntimeException::new);
    }
}
@RestController
@RequiredArgsConstructor
public class MemberController {
    private final OSIVService osivService;

    @GetMapping("/member/{memberId}")
    public MemberResponse getAllMembers(@PathVariable Long memberId){
        Member member = osivService.findMemberById(memberId);
        return MemberResponse.of(member);
    }
}
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class MemberResponse {
    private Long id;
    private String name;
    private TeamResponse team;

    public static MemberResponse of(Member member) {
        return new MemberResponse(
                member.getId(),
                member.getName(),
                TeamResponse.of(member.getTeam())
        );
    }
}

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class TeamResponse {
    private Long id;
    private String name;

    public static TeamResponse of(Team team) {
        return new TeamResponse(team.getId(), team.getName());
    }
}

//data.sql
INSERT INTO TEAMS VALUES(1, 'BARCA');
INSERT INTO TEAMS VALUES(2, 'REAL MADRID');
INSERT INTO TEAMS VALUES(3, 'PARIS SAINT');

INSERT INTO MEMBERS VALUES(1, 'MESSI', 1);
INSERT INTO MEMBERS VALUES(2, 'RONALDO', 2);

/member/1을 호출을 하면 다음과 같은 응답이 내려온다.

GET /member/1
{
  "id": 1,
  "name": "MESSI",
  "team": {
    "id": 1,
    "name": "BARCA"
  }
}

하나씩 살펴보자.

MemberController에서 요청이 이루어질 때 osivService.findMemberById(memberId) 를 한 시점에서는 Team은 initialize 되어있지않고 proxy객체로 남아있다.

Member member = osivService.findMemberById(memberId);

//console
Hibernate: select member0_.id as id1_0_0_, member0_.name as name2_0_0_, member0_.team_id as team_id3_0_0_ from members member0_ where member0_.id=?

그러다가 MemberResponse.of(...)에서 TeamResponse.of(...)team.getName()을 호출 할 때 initialize된다

return new TeamResponse(team.getId(), team.getName());

//console
Hibernate: select team0_.id as id1_1_0_, team0_.name as name2_1_0_ from teams team0_ where team0_.id=?

이렇듯 Presentation계층에는 Transaction이 없지만 OSIV를 통한 지연로딩을 처리할 수 있다.

spring.jpa.open-in-view=false 로 설정하면 team.getName()을 할 때 에러가 발생한다.

org.hibernate.LazyInitializationException: 
could not initialize proxy [com.example.osiv.persistence.Team#1] 
- no Session

컨트롤러에선 플러시가 동작하지 않는 이유는 무엇일까?

  • 트랜잭션을 사용하는 서비스 계층이 끝날 때 트랜잭션이 커밋되면서 이미 플러시해버렸다. 스프링이 제공하는 OSIV 서블릿 필터나 OSIV 스프링 인터셉터는 요청이 끝나면 플러시를 호출하지 않고 em.close()로 영속성 컨텍스트만 종료해 버리므로 플러시가 일어나지 않는다 .
  • 프리젠테이션 계층에서 em.flush()를 호출해서 강제로 플러시해도 트랜잭션 범위 밖이이므로 데이터를 수정할 수 없다는 예외를 만난다.

스프링 OSIV 주의사항

그런데 여기에는 한 가지 예외가 있다.

프레젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 포함하는 서비스 계층을 호출하면 문제가 발생한다.

class MemberController {
@GetMapping("/member/{memberId}")
    public MemberResponse getAllMembers(@PathVariable Long memberId){
        Member member = osivService.findMemberById(memberId);
        member.setName("***"); //추가
        Team team = osivService.findTeams(member.getTeam().getId());  //추가
        return MemberResponse.of(member);
    }
}

위의 코드는 osivService.findTeams() 메소드가 끝나면 트랜잭션 AOP는 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 변경 감지가 동작하면서 회원 엔티티의 수정 사항을 데이터베이스에 반영한다.

이런 문제를 해결하는 단순한 방법은 트랜잭션이 있는 비즈니스 로직을 모두 호출하고 나서 엔티티를 변경하면 된다.

스프링 OSIV는 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있으므로 이런 문제가 발생한다.

OSIV 정리

스프링 OSIV의 특징

  • 한 번 조회한 엔티티는 요청이 끝날 때까지 영속 상태를 유지한다.
  • 엔티티 수정은 트랜잭션이 있는 게층에서만 동작한다.

스프링 OSIV의 단점

  • OSIV를 적용하면 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다는 점을 주의해야 한다.
  • 프리젠테이션 계층에서 엔티티를 수정하고나서 비즈니스 로직을 수행하면 엔티티가 수정될 수 있다.
  • 프리젠테이션 계층에서 지연 로딩에 의한 SQL이 실행된다. 따라서 성능 튜닝시에 확인해야 할 부분이 넓다.

참고 자료