프로젝트 성능개선, 안티패턴 개선을 하면서 한 가지 의문이 들었다.
우선 현재 로직을 간단하게 설명하자면, @JwtLogin이 붙여져 있는 곳은 HandlerMethodArgumentResolver를 구현하여 헤더의 정보를 읽어 로그인한 멤버의 정보를 넘기는 방식이다.
코드의 흐름은 아래와 같다.
Controller
@PostMapping
public ResponseEntity<BoardResponse> create(@JwtLogin final Member member,
@RequestBody @Valid final BoardRequest boardRequest) {
Board board = boardService.create(boardRequest, member);
return ResponseEntity.status(HttpStatus.CREATED)
.body(BoardResponse.from(board));
}
해당 url로 요청이 온다.
이때 @JwtLogin 어노테이션에 의해 JwtLoginResolver에서 멤버를 받아온다.
JwtLoginResolver
public JwtLoginResolver(final JwtAuthService jwtAuthService) {
this.jwtAuthService = jwtAuthService;
}
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(JwtLogin.class);
}
@Override
public Member resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory){
String authorizationHeader = webRequest.getHeader(HttpHeaders.AUTHORIZATION);
validateAuthorization(authorizationHeader);
return jwtAuthService.findMemberByJwtPayload(
getJwtPayload(Objects.requireNonNull(authorizationHeader)));
}
현재 로직은 JwtAuthService에서 멤버를 리턴해주는 역할을 한다.
JwtAuthService
@Transactional(readOnly = true)
public Member findMemberByJwtPayload(final String jwtPayload) {
String jwtPayloadOfEmail = jwtTokenProvider.getPayload(jwtPayload);
return memberRepository.findByEmail(jwtPayloadOfEmail)
.orElseThrow(MemberNotFoundException::new);
}
jwtPayload의 구문을 분석하고 이를 이메일로 변환해 준다. 이후 이메일을 통해 멤버를 반환해 준다.
BoardService
@Transactional
public Board create(final BoardRequest boardRequest, final Member member) {
Board board = Board.of(boardRequest.getTitle(), boardRequest.getContent(), member);
return boardRepository.save(board);
}
멤버 정보를 가지고 게시판을 만들고 이를 리턴해준다.
로직 자체는 매우 간단하다.
다만 여기서 생각한 문제는 컨트롤러가 Member의 존재를 알게 된다는 점이다.
이는 JwtLoginResovler에서 멤버가 아닌 memberId를 리턴해준다면 쉽게 해결할 수 있는 부분이었다.
하지만 의존성을 제거하는 대신 디비 접근에 있어서 효율성 문제가 있을 것이라 생각했다.
이렇게 생각한 이유는 영속성 컨텍스트는 트랜잭션 단위로 관리되기 때문이다.
흐름과 함께 정리하면 나의 생각은 아래와 같았다.
@JwtLogin에서 resolveArgument가 실행되고 여기서 jwtAuthService.findMemberByJwtPayLoad가 실행되면 트랜잭션 A가 실행되고 멤버를 반환해 줌. 이후 트랜잭션 A가 종료. 이제 이 정보를 id로 받는 경우 넘어오는 id를 서비스로 넘겨주게 되는데 이 서비스에서 트랜잭션 B가 실행되고 이때 id를 통해 멤버를 조회하는 상황이 생길 수 있음. 이때 조회는 영속성 컨텍스트에 없기에 다시 디비를 접근하기에 효율이 떨어질 거라고 생각했다.
그래서 이를 검증하기 위해 JwtLoginResovler로 반환될 때의 member값과 service에서 디비를 통해 다시 조회한 member값을 비교해 봤다.
//jwtLoginResolver
@Override
public Member resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory)
throws Exception {
String authorizationHeader = webRequest.getHeader(HttpHeaders.AUTHORIZATION);
validateAuthorization(authorizationHeader);
Member member = jwtAuthService.findMemberByJwtPayload(
getJwtPayload(Objects.requireNonNull(authorizationHeader)));
System.out.println("jwtLoginResolver " + member.hashCode());
return member;
}
//boardService
@Transactional
public Board create(final Long memberId, final BoardRequest boardRequest) {
Member member = memberRepository.findById(memberId).get();
System.out.println("service " + member.hashCode());
Board board = Board.of(boardRequest.getTitle(), boardRequest.getContent(), member);
return boardRepository.save(board);
}
그 결과 같은 객체가 나오게 되었다.
분명 각각 다른 트랜잭션에서 실행되었는데 왜 같은 객체가 나오게 된 걸까?
우선 이유를 먼저 말하면 OSIV의 기본 설정값 때문이다.
이 설정을 off로 하고 다시 실행하면 아래와 같은 결과가 나온다.
이번에는 다른 객체가 나온다.
또한 쿼리를 자세히 보면 다시 select 하는 것을 볼 수 있다.
결론부터 말하면 영속성 컨텍스트의 범위가 달라서 결과가 달라졌다.
이유가 뭘까?
추가로 OSIV에 대해 자세히 알아보자.
스프링 컨테이너의 기본 전략
- 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
- 즉 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.
- 스프링 프레임워크를 사용하면 보통 비즈니스 로직을 시작하는 서비스 계층에 @Transcational 어노테이션을 선언해서 트랜잭션을 시작함
- 이때 서비스 메서드가 실행하기 직전에 스프링 트랜잭션 AOP가 먼저 동작함
- 대상 메서드가 정상 종료되면 트랜잭션을 커밋하고 종료함
- 트랜잭션을 커밋하면 JPA는 먼저 영속성 컨텍스트를 플러시해서 변경 내용을 DB에 반영한 후에 DB 트랜잭션을 커밋함
- 만약 예외가 발생하면 트랜잭션을 롤백하고 종료하는데 이때는 플러시를 호출하지 않음
트랜잭션이 같으면 같은 영속성 컨텍스를 사용한다.
- 트랜잭션 범위의 영속성 컨텍스트 전략은 다양한 위치에서 엔티티 매니저를 주입받아 사용해도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용한다.
트랜잭션이 다르면 다른 영속성 컨텍스를 사용한다.
- 여러 스레드에서 동시에 요청이 와서 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다.
- 같은 엔티티 매니저를 호출해도 접근하는 영속성 컨텍스트가 다르므로 멀티스레드 상황에 안전하다.
준영속 상태와 지연 로딩
- 앞서 설명한 대로 트랜잭션은 보통 서비스 계층에서 시작하므로 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다.
- 따라서 조회한 엔티티가 서비스와 리포지토리 계층에서는 영속성 컨텍스트에 관리되면서 영속 상태를 유지하지만 컨트롤러나 뷰 같은 프레젠테이션 계층에서는 준영속 상태가 된다.
준영속 상태와 변경 감지
변경 감지 기능은 영속성 컨텍스트가 살아있는 서비스 계층까지만 동작하고 영속성 컨텍스트가 종료된 프레젠테이션 계층에서는 동작하지 않는다. 하지만 오히려 변경 감지 기능이 프레젠테이션 계층에서도 동작하면 애플리케이션 계층이 가지는 책임이 모호해진다.
따라서 변경 감지 기능이 프레젠테이션 계층에서 동작하지 않는 것은 특별히 문제가 되지 않는다.
준영속 상태와 지연 로딩
준영속 상태의 가장 골치 아픈 문제는 지연 로딩 기능이 동작하지 않는다는 점이다.
뷰를 렌더링 할 때 연관된 엔티티도 함께 사용해야 하는데 연관된 엔티티를 지연로딩으로 설정해서 프록시 객체가 조회되었다면 아직 초기화하지 않은 프록시 객체를 사용하면 실제 데이터를 불러오려고 초기화를 시도한다.
하지만 준영속 상태는 영속성 컨텍스트가 없으므로 지연 로딩을 할 수 없다.
이때 org.hibernate.LazyInitializationException이 발생한다.
준영속 상태의 지연 로딩 문제를 해결하는 방법은 크게 2가지가 있다.
- 뷰가 필요한 엔티티를 미리 로딩해 두는 방법
- OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 방법
엔티티를 미리 로딩해 두는 방법
글로벌 페치 전략 수정
@Entity
public class Order {
...
@ManyToOne(fetch = FetchType.EAGER)
private Member member;
...
}
지연 로딩을 즉시 로딩으로 변경한다.
이제 Order를 조회할 때마다 Member도 함께 로딩한다.
즉 준영속 상태가 되어도 member를 사용할 수 있다.
단점
사용하지 않는 엔티티를 등록한다.
- 어떠한 화면은 order엔티티만 필요하더라도 즉시 로딩에 의해 member도 함께 조회된다.
N+1 문제가 발생한다.
- 만약 조회한 order 엔티티가 10개라면 member를 조회하는 SQL도 10번 실행된다.
JPQL 페치 조인
JPQL을 호출하는 시점에 함께 로딩할 엔티티를 선택할 수 있는 페치 조인을 알아보자.
페치 조인은 조인 명령어 마지막에 fetch를 넣어주면 된다.
페치 조인은 N+1문제를 해결하면서 화면에 필요한 엔티티를 미리 로딩하는 현실적인 방법이다.
단점
화면에 맞춘 리포지토리 메서드 증가
- order만 필요한 메서드 findOrder(), order, member가 필요한 findOrderWithMember() 이런 식으로 2개가 만들어져야 한다.
다른 대안으로는 findOrder만 사용하고 페치 조인으로 함께 로딩하는 방법이 있다.
물론 order엔티티만 필요한 화면은 약간의 로딩 시간이 증가하겠지만 영향은 미비하다.
강제로 초기화
@Transcational
public Order findOrder(id){
Order order = orderRepository.findById(id);
order.getMember().getName(); // 프록시 객체를 강제로 초기화
return order;
}
}
프록시 객체는 실제 사용하는 시점에 초기화된다. getMember()까지만 호출하면 단순히 프록시 객체만 반환하고 아직 초기화를 하지 않음
강제로 초기화를 해서 반환하면 이미 초기화를 했으므로 준영속 상태에서도 사용할 수 있다.
org.hibernate.Hibernate.initialize(order.getMember));를 통해 프록시를 강제로 초기화할 수 있음
하지만 이는 은근슬쩍 프레젠테이션 계층이 서비스 계층을 침범한다.
FACADE 계층 추가
FACADE 계층을 도입해서 서비스 계층과 프레젠테이션 계층 사이에 논리적인 의존성을 분리할 수 있다.
- 프레젠테이션 계층과 도메인 모델 계층 간의 논리적 의존성을 분리해 줌
- 프레젠테이션 계층에서 필요한 프록시 객체를 초기화한다.
- 서비스 계층을 호출해서 비즈니스 로직을 실행한다.
- 리포지토리를 직접 호출해서 뷰가 요규하는 엔티티를 찾는다.
중간에 계층이 하나 더 끼어든다는 단점이 있다.
class OrderFacade{
...
public Order findOrder(id){
Order order = orderService.findOrder(id);
order.getMember().getName();
return order;
}
}
class OrderService{
public Order findOrder(id){
return orderRepository.findById(id);
}
}
뷰를 개발할 때 필요한 엔티티를 미리 초기화하는 방법은 생각보다 오류가 발생하기 쉽다.
보통 뷰를 개발할 때는 엔티티 클래스를 보고 개발하지 초기화여부를 확인하기 위해 FACADE나 서비스 클래스까지 열어보는 것이 상당히 번거롭고 귀찮기 때문이다.
모든 문제는 엔티티가 프레젠테이션 계층에서 준영속 상태이기 때문에 발생한다.
OSIV
OSIV는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.
OSIV는 하이버네이트에서 사용하는 용어이다. JPA에서는 OEIV라 한다. 하지만 관례상 모두 OSIV로 부른다.
동작과정
- 클라이언트의 요청이 들어오면 서블릿 필터나, 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 단 이때 트랜잭션은 시작하지는 않는다.
- 서비스 계층에서 @Transcational로 트랜잭션을 시작할 때 1번에서 미리 생성해 둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
- 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시 한다. 이때 트랜잭션은 끝내지만 영속성 컨텍스트는 종료하지 않는다.
- 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
- 서블릿 필터나, 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 이때 플러시를 호출하지 않고 바로 종료한다.
트랜잭션 없이 읽기
영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야 한다. 만약 트랜잭션 없이 엔티티를 변경하고 영속성 컨텍스트를 플러시 하면 TransacationRequiredExeception 예외가 발생한다.
단순 조회는 트랜잭션이 없어도 가능하다. 이를 트랜잭션 없이 읽기라 한다.
OSIV를 사용하면 프레젠테이션 계층에서는 트랜잭션이 없으므로 엔티티를 수정할 수 없다. 프레젠테이션 계층에서 엔티티를 수정할 수 있는 기존 OSIV의 단점을 보완함
트랜잭션 없이 읽기를 사용해서 프레젠테이션 계층에서 지연로딩 기능을 사용할 수 있다.
OSIV 주의 사항
프레젠테이션 계층에서 엔티티를 수정해도 수정내용을 데이터베이스에 반영하지 않는다.
하지만 프레젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다.
해결법은 트랜잭션이 있는 비즈니스 로직을 모두 호출하고 나서 엔티티를 변경하면 된다.
정리
스프링 OSIV의 특징
- OSIV는 클라이언트의 요청이 들어올 때 영속성 컨텍스트를 생성해서 요청이 끝날 때까지 같은 영속성 컨텍스를 유지한다. 따라서 한 번 조회한 엔티티는 요청이 끝날 때까지 영속 상태를 유지한다.
- 엔티티 수정은 트랜잭션이 있는 계층에서만 동작한다. 트랜잭션이 없는 프레젠테이션 계층은 지연 로딩을 포함해서 조회만 할 수 있다.
스프링 OSIV의 단점
- OSIV를 적용하면 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다는 점을 주의해야 한다.
- 프린젠테이션 계층에서 엔티티를 수정하고 나서 비즈니스 로직을 수행하면 엔티티가 수정될 수 있다.
- 프레젠테이션 계층에서 지연 로딩에 의한 SQL이 실행된다. 따라서 성능 튜닝 시에 확인해야 할 부분이 많다.
- 영속성 컨텍스트와 DB커넥션을 1:1로 물고 있는 관계이기 때문에 프레젠테이션 로직까지 DB 커넥션 자원을 낭비하게 된다.
실무 활용 팁
- 고객 서비스 기반의 트래픽이 많은 실시간 API - OSIV를 끈다.
- ADMIN처럼 커넥션을 많이 사용하지 않는 곳 - OSIV를 켠다.
커멘드와 쿼리 분리
- 실무에서 OSIV를 끈 상태로 복잡성을 관리하려면 커멘드와 쿼리를 분리한다.
- 성능 문제는 주로 조회에서 발생한다.
- 비즈니스 로직
- 정책적인 것이라 잘 변경되지 않는다.
- 특정 Entity 몇 개를 등록하거나 수정하는 것이 전부라서 성능이 크게 문제되진 않는다.
- 화면에 뿌리기 위한 조회 API
- 자주 바뀌고 라이프 사이클이 빠르다.
- 복잡한 화면을 출력해야 하므로 성능 최적화가 중요하다.
- 이 둘 사이의 라이프 사이클이 다르기 때문에 명확하게 분리하는 것이 좋다.
출처
자바 ORM표준 JPA 프로그래밍
'JPA' 카테고리의 다른 글
QueryDSL (0) | 2023.01.15 |
---|---|
페치 조인 (2) | 2023.01.10 |
JPQL 사용법 (3) | 2023.01.09 |
JPA의 데이터 타입 (3) | 2023.01.07 |
프록시 & 지연로딩 (0) | 2023.01.07 |