기록

[자바 ORM 표준 JPA 프로그래밍] 일반적인 트랜잭션 관리와 뷰에서 필요한 데이터에 접근(13장) 본문

교육/책

[자바 ORM 표준 JPA 프로그래밍] 일반적인 트랜잭션 관리와 뷰에서 필요한 데이터에 접근(13장)

youngyin 2024. 12. 7. 10:00

시작하면서

JPA를 사용하면서 트랜잭션과 영속성 컨텍스트가 어떻게 상호작용하는지, 그리고 이를 서비스와 프리젠테이션 계층에서 어떻게 관리할지에 대한 이해는 매우 중요합니다. 이번 글에서는 트랜잭션 범위의 영속성 컨텍스트, 준영속 상태에서 발생하는 지연 로딩 문제, 그리고 OSIV(Open Session In View)를 활용해 문제를 해결하는 방법을 알아보겠습니다.

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

1.1 트랜잭션과 영속성 컨텍스트의 관계

스프링 프레임워크는 기본적으로 트랜잭션 범위의 영속성 컨텍스트 전략을 사용합니다. 이 전략은 트랜잭션의 생명주기와 영속성 컨텍스트의 생명주기가 동일하다는 의미입니다. 즉, 트랜잭션이 시작되면 영속성 컨텍스트도 생성되고, 트랜잭션이 종료될 때 함께 종료됩니다. 같은 트랜잭션 안에서는 동일한 영속성 컨텍스트를 사용하므로 일관된 데이터를 유지할 수 있습니다.

1.2 트랜잭션의 단계별 진행 과정

일반적으로 서비스 계층의 메소드에 @Transactional 어노테이션을 붙여 트랜잭션을 관리합니다. 트랜잭션의 주요 단계는 다음과 같습니다:

  1. 트랜잭션 시작: @Transactional이 선언된 메소드가 호출되면 트랜잭션이 시작됩니다.
  2. 플러시(Flush): 트랜잭션이 커밋되기 전 JPA는 영속성 컨텍스트를 플러시하여 변경 내용을 데이터베이스에 반영합니다.
  3. 커밋: 데이터베이스 트랜잭션이 커밋되어 영속성 컨텍스트의 변경 사항이 최종적으로 저장됩니다.
  4. 롤백: 예외가 발생하면 트랜잭션을 롤백하고 영속성 컨텍스트도 종료됩니다.

2. 준영속 상태와 지연 로딩 문제: 뷰에 필요한 데이터에 접근이 어려움

2.1 준영속 상태와 발생 가능한 문제

서비스 계층에서 트랜잭션이 끝나면 영속성 컨텍스트도 함께 종료됩니다. 이로 인해 엔티티는 준영속 상태가 되며, 더 이상 영속성 컨텍스트에서 관리되지 않게 됩니다. 이렇게 되면 컨트롤러나 뷰 계층에서 해당 엔티티를 사용할 때 **지연 로딩(Lazy Loading)**이 동작하지 않아 데이터를 제대로 가져올 수 없는 문제가 발생합니다.

2.2 지연 로딩 문제의 해결 방법

지연 로딩 문제를 해결하기 위한 주요 방법은 다음 두 가지입니다:

  1. 뷰에서 필요한 엔티티를 미리 로딩하기
  2. OSIV를 사용하여 영속성을 유지하기
2.2.1 뷰에서 필요한 엔티티 미리 로딩하기
2.2.1.1 글로벌 페치 전략 수정
  • 연관된 엔티티를 항상 로딩하도록 @ManyToOne(fetch = FetchType.EAGER)와 같이 글로벌 페치 전략을 수정할 수 있습니다. 그러나 이 방법은 사용하지 않는 데이터까지 가져오는 비효율성이 있을 수 있습니다.
2.2.1.2 JPQL 페치 조인
  • JPQL에서 **페치 조인(Fetch Join)**을 사용해 필요한 엔티티를 한 번에 로딩할 수 있습니다. 페치 조인은 성능에 유리하지만, 화면에 맞춘 리포지토리 메소드가 증가하면서 데이터 접근 계층이 프리젠테이션 계층에 의존할 위험이 있습니다.

JPQL 페치 조인 예제:

select m from Member m join Team t on m.team.id = t.id;

select m from Member m join fetch Team t on m.team.id = t.id;

일반 조인은 단순히 MemberTeam을 조인하여 가져오지만, 연관된 엔티티는 프록시로 남아 지연 로딩이 발생합니다. 반면 페치 조인은 Team 엔티티까지 한 번에 가져와 이후 추가 쿼리 없이 사용할 수 있습니다.

2.2.1.3 강제로 초기화
  • 서비스 계층에서 영속성 컨텍스트가 활성화된 동안 필요한 엔티티를 강제로 초기화하는 방법입니다.
@Transactional
public Member getMemberWithTeam(Long memberId) {
    Member member = memberRepository.findById(memberId).orElseThrow();
    // 강제로 팀 초기화
    member.getTeam().getName(); // 프록시 초기화
    return member;
}

위 예제에서 member.getTeam().getName()을 호출해 Team 엔티티를 강제로 초기화합니다. 이는 프리젠테이션 계층에서 지연 로딩 문제를 방지하기 위함입니다.

2.2.2 OSIV를 사용하여 영속성을 유지하기

**OSIV(Open Session In View)**는 영속성 컨텍스트를 프리젠테이션 계층까지 열어두어 엔티티가 준영속 상태가 되지 않도록 합니다. 이를 통해 컨트롤러와 뷰에서도 지연 로딩이 가능해집니다.

3. OSIV(Open Session In View)

3.1 OSIV의 방식

3.1.1 요청당 트랜잭션

요청이 들어오면 서블릿 필터나 스프링 인터셉터를 통해 트랜잭션을 시작하고, 요청이 끝날 때 트랜잭션과 영속성 컨텍스트를 함께 종료하는 방식입니다. 이 경우 프리젠테이션 계층에서 엔티티를 수정할 수 있는 위험이 있습니다.

이를 방지하는 방법은 다음과 같습니다:

  • 읽기 전용 인터페이스 제공: 엔티티를 읽기 전용으로 제공하여 수정하지 못하도록 합니다.
  • 엔티티 래핑: 엔티티를 DTO나 별도의 읽기 전용 객체로 변환해 제공합니다.
  • DTO만 반환: 프리젠테이션 계층에서는 엔티티 대신 DTO만 사용하도록 강제합니다.
3.1.2 스프링 OSIV

스프링에서 제공하는 OSIV(Open Session In View) 방식은 영속성 컨텍스트를 프리젠테이션 계층까지 확장하여 유지하는 방법입니다. 이를 통해 컨트롤러나 뷰 계층에서도 엔티티를 영속 상태로 유지하며, 지연 로딩(Lazy Loading)을 사용할 수 있게 됩니다. OSIV는 복잡한 화면을 구성할 때 뷰 계층에서 연관 데이터를 손쉽게 가져올 수 있도록 해주기 때문에 편리합니다.

예를 들어, 사용자가 웹 페이지에서 회원의 상세 정보를 보고자 할 때, OSIV가 활성화되어 있으면 해당 회원 엔티티와 연관된 데이터를 지연 로딩을 통해 쉽게 가져올 수 있습니다. 영속성 컨텍스트가 뷰까지 열려 있기 때문에 추가적인 쿼리가 필요한 시점에 데이터베이스로부터 값을 가져와 사용할 수 있는 것입니다.

그러나, OSIV를 사용할 때는 같은 영속성 컨텍스트가 여러 트랜잭션에서 공유되기 때문에 롤백에 신중해야 합니다. 예를 들어, 사용자가 웹 페이지에서 회원의 정보를 수정하고, 이후 서비스 계층에서 비즈니스 로직을 수행하면서 다시 트랜잭션이 시작되면 문제가 발생할 수 있습니다. 프리젠테이션 계층에서 수정된 데이터가 영속성 컨텍스트에 반영된 상태로 새로운 트랜잭션에 영향을 미치게 되면, 의도치 않은 데이터 일관성 문제가 발생할 수 있습니다. 이는 컨트롤러에서 수정된 후 다시 비즈니스 로직을 수행하면, 같은 영속성 컨텍스트가 유지되기 때문에 롤백과 커밋이 예상대로 동작하지 않는 위험이 있습니다.

3.1.3 현업에서 Bulk 연산 처리 예제

OSIV를 사용하지 않는 상황에서는 하나의 요청에 대해 트랜잭션을 나누어 각각 커밋과 롤백을 처리하는 것이 가능합니다. 예를 들어, 여러 요청을 받아서 N개 중 M개만 성공시키고 나머지는 롤백해야 하는 Bulk 연산을 처리할 수 있습니다. 아래는 이를 자바 코드로 구현한 OSIV를 사용하지 않는 상황에서는 하나의 요청에 대해 트랜잭션을 나누어 각각 커밋과 롤백을 처리하는 것이 가능합니다. 간단한 예제입니다:

@Service
public class MemberBulkService {

    @Autowired
    private MemberRepository memberRepository;

    @Transactional
    public void processMembers(List<Long> memberIds) {
        for (Long memberId : memberIds) {
            try {
                updateMemberStatus(memberId);
            } catch (Exception e) {
                // 부분적으로 실패한 경우 로그 처리하고 다음 멤버로 진행
                System.out.println("Failed to update member: " + memberId);
            }
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateMemberStatus(Long memberId) {
        Member member = memberRepository.findById(memberId).orElseThrow();
        member.setStatus("UPDATED");

        if (someConditionFails(member)) {
            throw new RuntimeException("Update failed for member: " + memberId);
        }
    }

    private boolean someConditionFails(Member member) {
        // 조건에 따라 실패 여부 결정
        return member.getStatus().equals("INVALID");
    }
}

위 코드에서 processMembers 메소드는 여러 회원을 한 번에 업데이트하는 Bulk 연산을 처리합니다. 각 updateMemberStatus 호출은 Propagation.REQUIRES_NEW 속성을 사용해 별도의 트랜잭션으로 실행됩니다. 이 방식으로 각 멤버의 업데이트 작업이 독립적으로 커밋되거나 롤백될 수 있으며, 전체 요청이 하나의 영속성 컨텍스트를 공유하지 않습니다.

이와 같은 방식으로 OSIV의 제약을 피하면서 각 요청이 독립적으로 처리되도록 하는 것이 가능해집니다. OSIV는 편리하지만, 이러한 특수한 요구사항을 가진 상황에서는 적절하지 않을 수 있으므로 상황에 맞는 설계를 고려해야 합니다.

5. 너무 엄격한 계층 분리의 문제

DTO를 사용해 뷰에서 영속성 컨텍스트를 직접 사용하지 않는 것이 이상적입니다. 하지만 OSIV를 사용하면 데이터 접근의 유연성을 높일 수 있습니다. 상황에 맞게 OSIV와 DTO를 적절히 사용해 균형을 맞추는 것이 중요합니다.

 
Comments