Java & Spring

응답 DTO, N+1, Open Session In View

리차드 2022. 11. 27. 14:11

 

open-in-view WARNING 로그


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

 

  • 관련하여 별도 설정을 하지 않았다면, 스프링 부트 애플리케이션을 실행할 때마다 위와 같은 로그를 보게 됩니다
  • spring.jpa.open-in-view 라는 설정이 기본 설정값에 의해 활성화 되어있습니다.
  • view 렌더링 과정에서 쿼리가 수행될 수 있음을 경고하고 있습니다.
    • view 렌더링 과정까지 쿼리가 수행될 수 있기 때문에, 그 과정까지 DB 커넥션을 풀에 반환하지 않습니다.
    • 테스트나 간단한 애플리케이션 수준에선 활성화 되어있는 것이 편의성을 제공하는 측면이 있습니다.
    • 꼭 필요하지 않다면 이 설정을 해제하는 것은 성능 개선을 위해 유의미한 행동입니다.

 

 

 

open-in-view 활성화하기


spring:
  jpa:
    open-in-view: true
  • spring.jpa.open-in-view 설정의 기본값은 true입니다.
  • 그러나 true로 직접 명시를 한다면 WARNING 로그도 발생하지 않습니다.

 

 

 

예제 코드 구성


@RequestMapping("/api/teams")
@RestController
public class TeamController {
    private final TeamService teamService;

    public TeamController(final TeamService teamService) {
        this.teamService = teamService;
    }

    @GetMapping
    public List<Team> teams() {
        return teamService.findAll();
    }
}
@Transactional(readOnly = true)
@Service
public class JpaTeamService implements TeamService {
    private final TeamRepository teamRepository;

    public JpaTeamService(final TeamRepository teamRepository) {
        this.teamRepository = teamRepository;
    }

    @Override
    public List<Team> findAll() {
        return teamRepository.findAll();
    }
}
public interface TeamRepository extends Repository<Team, Long> {
    void saveAll(Iterable<Team> teams);
    List<Team> findAll();
}
@Getter
@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL)
    private List<Member> members = new ArrayList<>();
}
@Getter
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @ManyToOne
    @JoinColumn(name = "team_id", foreignKey = @ForeignKey(name = "fk_member_team_id"))
    private Team team;
}

 

 

 

View-Rendering 과정에서 N+1


@GetMapping
public List<Team> teams() {
    return teamService.findAll();
}

 

  • Team은 Member를 OneToMany로 알고 있습니다.
  • Member도 Team을 ManyToOne으로 알고 있습니다.
  • 컨트롤러에서 Team을 직접 반환한다면, Team 안에 Member를 렌더링하려다가 Member 안의 Team을...
    • 순환참조가 발생하며 StackOverFlow 에러가 발생합니다.
  • 이러한 현상은 응답 DTO를 별도로 만드는 것을 권장하는 이유 중에 하나입니다.

 

 

 

open-in-view 를 false로 준다면


Resolved [org.springframework.http.converter.HttpMessageNotWritableException:
Could not write JSON: failed to lazily initialize a collection of role:
com.study.cache.domain.Team.members: could not initialize proxy - no Session]

 

  • open-in-view 옵션을 false로 주더라도 N+1 이 발생하지 않는 것은 아닙니다.
  • 다만 view-rendering 과정에서 N+1 쿼리를 내보낼 세션이 없어서 No Session 오류가 발생한 것입니다.
  • Team이 List로 가지고 있는 Member는 OneToMany 기본 옵션에 따라 Lazy로 fetch 합니다.
    • 즉 프록시 객체로 가져오게 되는 것입니다.
    • 뷰 렌더링 과정에서 이 프록시 객체의 값을 꺼내려하게 되고, 이때 세션이 없어서 오류가 발생한 것입니다.
    • 세션이 없는 이유는 open-in-view를 껐기 때문입니다.
  • 즉, 기존에는 개발자가 직접 작성한 영역인 컨트롤러의 return teamService.findAll(); 를 떠나서도
    DB 커넥션이 유지되고 있었다는 것입니다.
  • 여기까지 알아보면 open-in-view 설정이 어떤 역할인지 파악이 되었네요!

 

 

 

open-in-view: false + join fetch


@Query("select t from Team t join fetch t.members")
List<Team> findAll();

 

  • open-in-view를 false로 해서 뷰 렌더링 과정에서 프록시 객체를 초기화할 수 없어서 안된거라면..
    • join fetch 를 이용해 한번에 초기화를 한다면 어떨까요?
  • @Query 애너테이션을 이용해 Team을 조회할 떄 Member도 한번에 조회하도록 했습니다.
    • 프록시 객체가 아니라 실제 엔티티가 한번에 조회됩니다.
    • 즉, 뷰 렌더링 과정에서 추가적으로 세션을 통한 쿼리 호출이 필요하지 않게 됩니다.
  • 이렇게 함으로써 N+1 문제는 해결이 됐습니다.
  • 그러나 순환 참조의 문제는 여전히 발생하고 있습니다.
  • 데이터는 이미 있어서 getter를 이용해 데이터를 꺼내는 데에는 문제가 없으나,
    Team안에 Member안에 Team 안에.. 이렇게 계속 렌더링해야하기 때문입니다.

 

 

 

open-in-view:false + join fetch + 응답 DTO


 

 

  • open-in-view 를 false로 주었고, N+1문제도 해결되었고, 정상 응답도 완료되었습니다.
  • open-in-view와 N+1은 직접적인 연관이 있는 것은 아닙니다.
  • 다만, open-in-view가 기본값이 true상태이고, 컨트롤러에서 프록시 객체를 리턴하게 된다면,
    뷰 렌더링 과정에서 프록시 초기화를 위해 DB 쿼리를 호출하게 되는데,
    이때 List로 프록시 객체를 가지고 있다면 뷰 렌더링 시점에 N+1이 발생하는 것입니다.

 

 

 

각각의 설정에 따른 변화 살펴보기


open-in-view : 뷰 렌더링 시점까지 DB 커넥션을 유지할 것인지
join fetch : 연관 관계에 있는 엔티티를 조회 시점에 같이 가져오기
응답DTO의 적용 : 엔티티 그대로 반환 시 순환 참조 발생 가능

 

open-in-view join fetch 응답 no Session 순환참조 N+1 DB커넥션 유지 비고
true X List<Team> X O O 최종응답까지  
false X List<Team> O O O 트랜잭션 범위까지 DB커넥션 빠른 반환
false O List<Team> X O X 트랜잭션 범위까지 N+1 해소되며 no Session도 해소
false O List<TeamResponse> X X X 트랜잭션 범위까지 응답 DTO로 순환참조 해소

 

 

 

정리


응답DTO 도입, N+1 없애기, open-in-view는 다르면서도 약간씩 연관되어 있습니다.

 

  • 도메인 엔티티 대신 응답 DTO를 반환해야겠습니다.
    • 응답 DTO는 View 영역의 변경과 도메인을 분리해주는 역할을 수행합니다.
    • 추가적으로 순환참조를 방지해줍니다.
    • 여기까지만 해도 애플리케이션이 굴러가는 데에는 문제가 없을 수 있습니다.
  • N+1 문제는 애플리케이션 성능에 치명적입니다.
    • 소규모에선 아무 문제가 없는 것처럼 느껴질 수도 있으나…
    • 애플리케이션 성능을 좌우하는 가장 큰 병목지점은 역시나 DB I/IO입니다.
    • 불필요한 쿼리가 나가지 않게 N+1을 해결해야 DB I/O를 최소화하여 성능을 개선할 수 있습니다.
    • N+1해결에는 @Query + join fetch 또는 batchSize 애너테이션 또는 전역속성, EntityGraph 등이 사용됩니다.
      • 대부분 db의 in절 최대가 1000이어서 batchSize는 1000 이하를 권장합니다.
      • EntityGraph는 left outer join이 기본이므로 유의해야 합니다.
      • join fetch 는 한번에 둘 이상 컬렉션에 사용할 순 없습니다.
  • open-in-view false 는 트랜잭션 범위를 벗어나면 즉시 DB 커넥션을 반환하는 설정입니다.
    • 기본값은 true이고 경고 로그를 띄웁니다.
    • 명시적으로 true를 설정해주면 경로 로그가 나오지 않습니다.
    • 간단한 테스트 개발중이 아니라면 가급적 false를 주는 것이 나을 듯 합니다.
    • true를 두고 개발하더라도 뷰렌더링 시점까지도 DB 커넥션이 끝까지 유지된다는 사실을 유념해야겠습니다.

위 예제에서는 최종적으로 응답 DTO 도입 + join fetch 선언 + open-in-view:false 를 사용함으로 마무리했습니다.