13. 웹 애플리케이션과 영속성 관리

이주호·2025년 1월 19일
0

스프링이나 J2EE 컨테이너 환경에서 JPA를 사용하면 컨테이너가 트랜잭션과 영속성 컨텍스트를 관리해주므로 쉽게 애플리케이션을 개발할 수 있다. 그러나 정확한 동작방법을 모르면 문제가 발생했을 때 해결하기가 쉽지 않으므로 동작원리를 살펴보자.

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

스프링 컨테이너의 기본 전략

스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
이 전략은 트랜잭션 범위와 영속성 컨텍스트의 생존 범위가 같다는 뜻이다. 즉, 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝났을 때 영속성 컨텍스트를 종료한다. 그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.
스프링 프레임워크를 사용하면 보통 비즈니스 로직을 시작하는 서비스 계층에 @Transactional 을 사용해서 트랜잭션을 시작한다. 외부에서는 단순히 서비스 계층의 메서드를 호출하는 것처럼 보이지만 이 어노테이션이 있으면 호출한 메서드를 실행하기 직전에 스프링의 트랜잭션 AOP가 먼저 동작한다.


스프링 트랜잭션 AOP는 대상 메서드를 호출하기 직전에 트랜잭션을 시작하고 메서드가 정상 종료되면 해당 트랜잭션을 커밋하면서 종료한다. 이 때, 트랜잭션을 커밋하면 JPA는 먼저 영속성 컨텍스트를 플러시해서 변경 내용을 DB에 반영하고 DB 트랜잭션을 커밋한다. 만약 예외가 발생하면 트랜잭션을 롤백하고 종료하는데 이때는 플러시를 호출하지 않는다.

트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.


트랜잭션 범위의 영속성 컨텐스트 전략은 다양한 위치에서 엔티티 매니저를 주입받아도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용한다.

트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다.

여러 스레드에서 동시에 요청이 와서 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다.
스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당한다. 즉, 같은 엔티티 매니저를 호출해도 접근하는 영속성 컨텍스가 다르므로 멀티스레드 상황에 안전하다.

준영속 상태와 지연 로딩

앞서 설명했듯이 보통 서비스 계층에서 트랜잭션을 시작하고 종료하므로 영속성 컨텍스트도 서비스 계층에서 종료된다. 따라서 조회한 엔티티는 서비스 계층에서는 영속성 컨텍스트에서 관리되지만 컨트롤러나 뷰 같은 프리젠테이션 계층에서는 준영속 상태가 된다.

예를 들어 뷰를 렌더링할 때 연관된 엔티티도 함께 사용해야 하는데 연관된 엔티티를 지연 로딩으로 설정해서 프록시 객체로 조히했다고 가정하자. 아직 초기화하지 않은 프록시 객체를 사용하면 실제 데이터를 불러오려고 초기화를 시도한다. 하지만 준영속 상태는 영속성 컨텍스트가 없으므로 지연로딩을 할 수 없어 문제가 발생한다.

이럴 때 해결하는 방법은 크게 2가지가 있다.

  • 뷰가 필요한 엔티티를 미리 로딩해두는 방법
  • OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 방법

먼저 뷰가 필요한 엔티티를 미리 로딩해두는 3가지 방법을 살펴보자.

  • 글로벌 페치 전략 수정
  • JPQL 페치 조인
  • 강제로 초기화

글로벌 페치 전략 수정

가장 간단한 방법으로 엔티티의 글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경하는 것이다.

@Entity
public class Order {

	@Id @GeneratedValue
    private Long id;
    
    @ManyToOne(fetch = FetchType.EAGER)	//즉시 로딩 전략
   	private Member member; // 주문 회원
    ...
}

//프레젠테이션 로직
Order order = orderService.findOne(orderId);
Member memer = order.getMember();
member.getName(); // 이미 로딩된 엔티티

글로벌 페치 전략에 즉시 로딩 사용 시 단점

  • 사용하지 않는 엔티티를 로딩한다.
    다른 화면에서 order만 필요해도 member가 조회되어 필요없는 엔티티도 로딩하는 일이 벌어진다.
  • N+1 문제
    엔티티를 조회할 때 연관된 엔티티를 로딩하는 전략이 즉시 로딩이면 데이터베이스에 JOIN 쿼리를 사용해서 한 번에 연관된 엔티티까지 조회한다. 이 때 처음 조회한 데이터 수만큼 다시 SQL을 사용해서 조회하는 것을 N+1 문제라고 한다. (N+1 문제는 JPQL 페치 조인으로 해결할 수 있다.)

JPQL 페치 조인

JPQL을 호출하는 시점에 함께 로딩할 엔티티를 선택할 수 있는 페치 조인을 알아보자.

//JPQL
select o
from Order o
join fetch o.member

//SQL
select o.*, m.*
from Order o
join Member m on o.MEMBER_ID=m.MEMBER_ID

페치 조인은 조인 명령어 마지막에 fetch를 추가하면 된다. 이렇게 페치 조인을 사용하면 SQL JOIN을 사용해서 페치 조인 대상까지 조회한다. 연관된 엔티티를 이미 로딩했으므로 N+1 문제가 발생하지 않는다.

JPQL 페치 조인의 단점

가장 현실적인 대안이지만 화면에 맞춘 리포지토리 메서드가 증가할 수 있다는 단점이 있다. 예를 들어, 화면 A에서는 order엔티티만 필요하고 화면 B에서는 order, member 엔티티가 필요하다고 하면 엔티티를 모두 지연 로딩으로 설정하고 order만 조회하는 메서드, order와 연관된 member를 페치 조인으로 조회하는 메서드 2가지가 필요하다. 이는 최적화를 했다고 할 수 있지만 뷰와 리포지토리 간에 논리적인 의존관계가 발생한다는 문제점이 있다.

강제로 초기화

영속성 컨텍스트가 살아있을 때 프리젠테이션 계층이 필요한 엔티티를 강제로 초기화해서 반환하는 방법이다.

//프록시 강제 초기화 - 글로벌 페치 전략 지연 로딩이라고 가정
class OrderService {
	@Transactional
    public Order findOrder(id) {
    	Order order = orderRepository.findOrder(id);
        order.getMember().getName();	// 프록시 강제 초기화
        return order;
    }
}

프록시 객체는 실제 사용하기 전까지 초기화되지 않는다. 그래서 위처럼 필요한 데이터를 order.getMember().getName()을 이용하여 미리 초기화를 해두는 방법이 프록시 강제 초기화이다. 이렇게 강제 초기화를 하면 준영속 상태가 되어도 사용할 수 있다.
그러나 이처럼 뷰에서 필요한 데이터를 서비스 계층이 담당하게 되면 프레젠테이션 계층이 서비스 계층을 침범하는 상황이 벌어지게 된다. 서비스 계층은 비즈니스 로직만을 담당하는 것이 좋으며 프레젠테이션 계층을 위한 프록시 객체 초기화는 분리하는 것이 좋다. 분리하기 위해 FACADE 계층이 그 역할을 담당해 줄 것이다.

FACADE 계층 추가


프레젠테이션 계층과 서비스 계층 사이에 FACADE 계층을 하나 더 두고 여기에서 프록시 초기화를 담당하게 둔다. 프록시를 초기화하려면 영속성 컨텍스트가 필요하므로 FACADE에서 트랜잭션을 시작해야 한다.

FACADE 계층의 역할과 특징

  • 프레젠테이션 계층과 도메인 모델 계층 간의 논리적 의존성을 분리한다.
  • 프레젠테이션 계층에서 필요한 프록시 객체를 초기화한다.
  • 서비스 계층을 호출해서 비즈니스 로직을 실행한다.
  • 리포지토리를 직접 호출해서 뷰가 요구하는 엔티티를 찾는다.
//FACADE 계층 추가
class OrderFacade {
	@Autowired OrdeerService orderService;
    
    @Transactional
    public Order findOrder(id) {
    	Order order = orderService.findOrder(id);
        //프리젠테이션 계층이 필요한 프록시 객체를 강제로 초기화한다.
        order.getMember().getName();
        return order;
    }
}

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

FACADE 계층을 만들면서 하나의 계층이 더 추가되었다는 단점과 단순히 서비스 계층을 호출만 하는 위임 코드가 상당히 많을 것이라는 단점이 존재한다.

준영속 상태와 지연 로딩의 문제점

준영속 상태와 지연 로딩 문제를 극복하기 위해 여러 가지 방법을 살펴보았는데 결국 모든 문제는 엔티티가 프리젠테이션 계층에서 준영속 상태이기 때문에 발생한다. 영속성 컨텍스트를 뷰까지 살아있게 열어두어 뷰에서도 지연 로딩을 사용할 수 있게 하자. 이것을 OSIV라고 한다.


OSIV (Open Session In View)

OSIV는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다. 영속성 컨텍스트가 살아있으면 엔티티는 계속해서 영속 상태로 유지되고 뷰에서도 지연 로딩을 사용할 수 있다.

과거 OSIV : 요청 당 트랜잭션

클라이언트에 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션을 종료하는 방식을 요청 당 트랜잭션 방식의 OSIV라고 한다.
영속성 컨텍스트가 처음부터 끝까지 살아있으므로 뷰에서도 지연 로딩을 할 수 있으므로 엔티티를 미리 초기화 할 필요가 없다.

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

해당 방법의 문제점은 컨트롤러나 뷰 같은 프리젠테이션 계층이 엔티티를 변경할 수 있다는 점이다.

예를 들어 보안 상의 이유로 고객의 이름을 "XXX"로 렌더링할 뷰로 넘겨주었다고 하자. 트랜잭션이 프리젠테이션계층에서도 살아있으므로 뷰를 렌더링한 후 트랜잭션이 커밋된다. 즉, 영속성 컨텍스트가 플러시 되고 변경 감지 기능이 동작되어 데이터베이스의 고객 이름이 "XXX"로 변경되는 심각한 문제가 발생한다.
이런 문제를 해결하기 위해서 프리젠테이션 계층에서 엔티티 수정을 막는 3가지 방법이 존재한다.

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

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

interface MemberView {
	public String getName();
}

@Entity
class Member implements MemberView {
	...
}

class MemberService {

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

실제 엔티티가 존재하지만 서비스 계층에서 읽기 전용 메서드만 있는 MemberView 인터페이스를 반환해서 프리젠테이션 계층에서 엔티티를 수정할 . 수없다.

엔티티 래핑

읽기 전용 인터페이스를 제공한 것과 마찬가지로 읽기 전용 메서드만 가지고 있는 엔티티를 감싼 객체를 만들고 이것을 프리젠테이션 계층에 반환하는 방법이다.

DTO(Data Transfer Object, 데이터 전송 객체)만 반환

가장 전통적인 방법으로 DTO를 생성해서 반환하는 방법이 있다. 하지만 이 방법은 OSIV를 사용하는 장점을 살릴 수 없고 엔티티를 거의 복사한 듯한 DTO 클래스도 하나 더 만들어야 한다.
(DTO는 OSIV와 관계없이 독립적인 데이터 객체로 설계되기 때문에, 뷰 계층에서의 데이터 로딩 및 추가 조회를 지원하지 않기 때문에 DTO는 OSIV가 제공하는 장점(엔티티의 지연 로딩 활용)을 살릴 수 없음)

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

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

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

동작 원리는 다음과 같다.
클라이언트 요청이 들어오면 서블릿 필터나, 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 이 때 트랜잭션은 시작하지 않는다.
서비스 계층에서 트랜잭션을 시작하면 생성한 영속성 컨텍스트에 트랜잭션을 시작한다.
비즈니스 로직을 실행하고 서비스 계층이 끝나면 트랜잭션을 커밋하면서 영속성 컨텍스트를 플러시한다.
이 때, 트랜잭션만 종료하고 영속성 컨텍스트는 살려둔다.
이 후 서블릿 필터나, 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다.

트랜잭션 없이 읽기 Nontransactional reads

  • 영속성 컨텍스트는 트랜잭션 범위 안에서 엔티티를 조회하고 수정할 수 있다.
  • 영속성 컨텍스트는 트랜잭션 범위 밖에서 엔티티를 조회만 할 수 있다. 프록시를 초기화하는 지연로딩도 조회 기능이다. 이것을 트랜잭션 없이 읽기라고 한다.

스프링이 제공하는 OSIV는 프리젠테이션 계층에서는 트랜잭션이 없으므로 엔티티를 수정할 수 없지만 트랜잭션 읽기를 사용해서 프리젠테이션 계층에서 지연 로딩 기능을 사용할 수 있다.

또한 프리젠테이션 계층에서 em.flush()를 호출해서 강제로 플러시를 하려고 해도 트랜잭션 범위 밖이므로 데이터를 수정할 수 없다는 예외를 만난다.

스프링 OSIV 주의사항

컨트롤러에서 데이터를 변경하고 비즈니스 로직을 실행하는 경우 문제가 생길 수 있다. 아래 예시를 보자.

class MemberController {
	public String viewMember(Long id) {
    	Member member = memberSevice.getMember(id);
        member.setName("XXX");	//보안상의 이유로 고객 이름 가리기
        
        memberService.biz();	//비즈니스 로직
        return "view";
    }
}

class MemberService {

	@Transactional
    public void biz() {
    	// ..비즈니스 로직 실행
    }
}

  1. 컨트롤러에서 회원 엔티티를 조회하고 이름을 "XXX"로 수정했다.
  2. 이 후 biz() 메서드를 실행하여 트랜잭션이 있는 비즈니스 로직을 실행.
  3. 트랜잭션 AOP가 동작하면서 영속성 컨텍스트에 트랜잭션을 시작한다. 그리고 biz() 메서드를 실행한다.
  4. biz() 메서드가 끝나면 트랜잭션 AOP는 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 변경 감지가 동작하면서 회원 엔티티의 수정 사항을 데이터베이스에 반영한다.

이는 컨트롤러에서 엔티티를 수정하고 바로 뷰를 호출하는 것이 아니라 수정 수 비즈니스 로직을 실행하기 때문에 일어나는 문제이다. 이 문제를 해결하기 위해서는 간단하게 비즈니스 로직을 먼저 호출하고 마지막에 엔티티를 수정, 뷰를 호출하면 된다.

// 수정 로직 - 비즈니스 로직을 먼저 수행
memberService.biz();	//비즈니스 로직 먼저 실행

Member member = memberService.getMember(id);
member.setName("XXX");	// 마지막에 엔티티 수정하기

스프링 OSIV는 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있으므로 이런 문제가 발생한다. OSIV를 사용하지 않는 트랜잭션 범위의 영속성 컨텍스트 전략은 트랜잭션의 생명주기와 영속성 컨텍스트의 생명주기가 같으므로 이런 문제가 일어나지 않는다.

OSIV 정리

스프링 OSIV 특징

  • 스프링 OSIV는 클라이언트의 요청이 들어오면 영속성 엔티티를 생성해서 요청이 끝날 때까지 같은 영속성 컨텍스트를 유지한다. 그래서 한 번 조회한 엔티티는 요청이 끝날 때까지 영속 상태를 유지한다.
  • 엔티티 수정은 트랜잭션이 살아있는 비즈니스 계층에서만 가능하다. 프리젠테이션 계층에서는 지연 로딩을 포함한 조회만 할 수 있다.

스프링 OSIV 단점

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

OSIV는 만능이 아니다.

OSIV를 사용하면 화면을 출력할 때 엔티티를 유지하면서 객체 그래프를 마음껏 탐색할 수 있다. 그러나 처음부터 JPQL로 필요한 데이터들만 조회해서 DTO로 반환하는 것이 더 나은 해결책일 수 있다.

OSIV는 같은 JVM을 벗어난 원격 상황에서는 사용할 수 없다.

예를 들어 JSON이나 XML을 생성할 때는 지연로딩을 사용할 수 있지만 원격지인 클라이언트에서 연관된 엔티티를 지연 로딩하는 것은 불가능하다. 결국 클라이언트가 필요한 데이터를 모두 JSON으로 생성해서 반환해야 한다. 보통 Jackson이나 Gson 같은 라이브러리를 사용해서 객체를 JSON으로 변환하는데, 변환 대상 객체로 엔티티를 직접 노출하거나 또는 DTO를 사용해서 노출한다.
이렇게 JSON으로 생성한 API는 한 번 정의하면 수정하기 어려운 외부 API와 언제든지 수정할 수 있는 내부 API로 나눌 수 있다.

  • 외부 API : 외부에 노출한다. 한 번 정의하면 수정하기 어렵다. 서버와 클라이언트를 동시에 수정하기 어렵다.
    ex) 타팀과 협업하기 위한 API, 타 기업과 협업하는 API

  • 내부 API : 외부에 노출하지 않는다. 언제든지 변경할 수 있으며 서버와 클라이언트를 동시에 수정할 수 있다.
    ex) 같은 프로젝트에 있는 화면을 구성하기 위한 AJAX 호출

엔티티는 변경되기 쉽기 때문에 엔티티를 JSON 변환 대상 객체로 사용하면 엔티티를 변경할 때 노출하는 JSON API도 함께 변경된다. 따라서 외부 API는 엔티티를 직접 노출하기보다 DTO를 사용하는 것이 안전하다.

너무 엄격한 계층


OSIV를 사용하기 전에는 프리젠테이션 계층에서 사용할 지연 로딩된 엔티티를 미리 초기화 했어야 한다. 또한 초기화는 아직 영속성 컨텍스트가 살아있는 서비스 계층이나 FACADE 계층이 담당했다.
하지만 OSIV를 사용하면 영속성 컨텍스트가 프리젠테이션 계층까지 살아있으므로 미리 초기화할 필요가 없다. 따라서 단순한 엔티티 조회는 컨트롤러에서 리포지토리를 직접 호출해도 아무런 문제가 없다.

참조 : [자바 ORM 표준 JPA 프로그래밍]

profile
코드 위에서 춤추고 싶어요

0개의 댓글