참고

https://github.com/donghyeon0725/jpaTest

 

GitHub - donghyeon0725/jpaTest

Contribute to donghyeon0725/jpaTest development by creating an account on GitHub.

github.com

 

JPA 에서 변경 감지가 동작하려면 다음과 같은 조건이 필요합니다.

  • 명시적인(선언적인) 트랜잭션을 열어야 한다. (이 때 선언적인 @Transactional 이 존재해야하고, 이 트랜젝션이 commit 되는 시점에 변경 감지가 일어난다.)
  • 영속성 관리중인 객체에 데이터 변경이 일어나야 한다.

일반적으로 Service 에서 트랜잭션(@Transactional)을 열고 Service 에서 변경감지로 JPA Entity 를 수정 한 뒤, Service 를 빠져 나왔을 때, 더 이상의 어떤 변경 감지도 동작하지 않을 것을 기대합니다.

다음은 샘플 소스 입니다.

 

User Entity
@Getter
@Setter
@Entity
public class User {

    @Id
    @GeneratedValue
    private Long userId;

    private String name;

    private String profileUrl;

    private Integer age = 0;
}

단순한 유저 엔티티

 

UserController
...
public class UserController {
		// 유저 정보 조회 api
    @GetMapping("/my/{userId}")
    public Map getMyInfo1(@PathVariable Long userId) {
        Map<String, Object> info = new HashMap<>();

        User user = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("유저가 없습니다."));
        user.setProfileUrl("https://test.com/" + user.getProfileUrl());

        info.put("user", user);

        return info;
    }
}

이 코드의 의도는 user 의 정보를 보여주는 것입니다. 이 때, 프로필 url 앞에 도메인 주소에 해당 하는 값을 붙여서 보여줍니다.

 

여기서 질문.

JPA 의 변경 감지가 동작해서 유저의 프로필이 "https://test.com/" + user.getProfileUrl() 로 변경된 값이 반영 될까?

 

정답은 No. 왜냐하면, 명시적으로 트랜잭션을 열지 않았기 때문입니다. 명시적으로 연 트랜잭션이 커밋되는 시점에 변경감지가 일어나기 때문에 위 코드에서는 변경감지는 일어나지 않습니다.

에초에 조회용도로 method가 GET 인 API 에서 유저의 profile_url 필드 값을 변경하고 싶진 않았을 것입니다.

여기서, 추가 사항이 생겼다고 가정합니다. 이 사용자가 주문을 한 내역을 확인하고 싶다고 합니다. 요구 사항에 따라서 다음과 같은 Service 로직이 나왔습니다.

 

@Service
@RequiredArgsConstructor
public class OrdersService {
    private final OrdersRepository ordersRepository;
		// 주문 내역이 있는지 확인
    @Transactional
    public boolean hasOrderThatDoesNotStart(User user) {

        return ordersRepository.countByUserAndDeliveryStatus(user, DeliveryStatus.WAIT) > 0;
    }
}

 

유저가 주문한 내역이 있는지 확인하는 간단한 로직입니다. (정확하게는 주문한 것중 아직 배송 전인)

주문을 했으면 true, 안했으면 false 로 결과를 리턴 합니다.

 

사용자는 유저 정보를 조회하는 api 에 다음과 같이 hasOrderThatDoesNotStart 라는 필드로, 주문 여부를 확인할 수 있도록 API 에 다음과 같은 소스를 추가했습니다.

 

...
public class UserController {
		
    @GetMapping("/my/{userId}")
    public Map getMyInfo1(@PathVariable Long userId) {
        Map<String, Object> info = new HashMap<>();

        User user = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("유저가 없습니다."));
        user.setProfileUrl("https://test.com/" + user.getProfileUrl());

        info.put("user", user);
				// 추가
				info.put("hasOrderThatDoesNotStart", ordersService.hasOrderThatDoesNotStart(user));
        
				return info;
    }
}

이 API를 호출하면 어떻게 될까?

1번 호출할 때 마다, profileUrl 의 값은 "https://test.com/" 가 붙어 계속 길이가 늘어난 값이 DB 에 저장됩니다.

왜 변경 감지가 일어났을까? 다음과 같은 조건이 마련되었기 때문 입니다.

  • user 를 영속성 컨텍스트에서 관리 중이다.
  • user 의 값이 변경 되었다.
  • ordersService.hasOrderThatDoesNotStart(user) 를 호출하면서 명시적 트랜젝션이 열렸고 커밋 되는 시점에 user 가 영속성 컨텍스트에 있었다.

그런데, 이상하다.

트랜잭션이 열리는 시점은 ordersService.hasOrderThatDoesNotStart(user) 가 호출되는 시점이고 이 전에 이미 트랜잭션(묵시적)이 끝난 user 를 왜 영속성 컨텍스트에서 관리중인 것일까?

 

이유는 Open-Session-In-View (OSIV) 옵션이 켜져 있기 때문입니다. JPA 에서는 default 설정이 OSIV true입니다.

OSIV 는 트랜잭션이 끝나고도 프록시 엔티티의 Lazy 로딩을 사용할 수 있도록 트랜잭션을 열어 놓습니다.

 

따라서 여전히 트랜잭션이 끝나도 엔티티를 영속 상태로 유지시키는데, 이렇게 관리중인 엔티티가 있는 상태에서 명시적인 트랜잭션(@Transactional)이 열렸고 이 트랜잭션이 커밋될 때 JPA 의 변경 감지가 동작했기 때문에 이와 같은 결과를 보이는 것입니다.

 

다음과 같은 코드를 사용하면 엔티티가 영속 상태인지 확인할 수 있습니다.

entityManager.contains(user)

 

아래와 같이 로그를 찍어 확인하면 다음과 같은 결과를 얻을 수 있습니다.

...
public Map getMyInfo(@PathVariable Long userId) {
    Map<String, Object> info = new HashMap<>();


    User user = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("유저가 없습니다."));
    user.setProfileUrl("https://test.com/" + user.getProfileUrl());

    System.out.println("영속성 컨텍스트에서 관리중인가? => " + entityManager.contains(user));

    info.put("user", user);

    return info;
}
  • OSIV 를 켰을 때 : 영속성 컨텍스트에서 관리중인가? => true
  • OSIV 를 껐을 때 : 영속성 컨텍스트에서 관리중인가? => false

 

트랜잭션을 읽기 전용으로 해두었을 때에도 JPA 변경감지가 일어나지 않습니다.

@Transactional(readOnly = true)

하지만, 이 코드의 가장 근본적인 문제는 Entity 를 리턴 한다는 점입니다.

 

Entity 는 변경감지가 언제든 일어날 가능성이 있는 객체 입니다. 우리는 JPA 를 사용하면서 예상치 못한 동작을 막기 위해서 Command 와 Query 를 분리해야 합니다. 엔티티를 다룰 때, 같은 API 에서 Entity 는 데이터 변경 & 조회 두가지 용도로 사용 되서는 안됩니다.

데이터 변경이 있는 api 이고 조회 용 데이터도 보여주어야 하는 API 라면 return 용도의 DTO 클래스를 하나 만드는 것이 맞다. Entity 를 리턴 해서는 안됩니다. 같은 맥락으로 서비스단의 메소드를 설계할 때에는 데이터 변경 또는 입력이 있는 메소드의 경우 대상 Entity 를 리턴해서는 안됩니다.

 

DTO 를 별도로 만들어 관리하는 것이 정 어렵다면, 영속성 관리를 하지 않는 Entity (new 로 생성한, 또는 엔티티 매니저에서 detach 를 통해서 영속성 관리를 떼어낸) 에 BeanUtils.copyProperties 으로 필드를 복사해서 리턴하는 것이 훨씬 더 낫습니다

 

사실 OSIV 옵션은 Lazy 로딩이라는 강력한 기능을 사용하기 위해서 (편리함을 위해서) 켜 놓는 경우가 많이 있습니다. 따라서, 근본적인 해결책은 Command 와 Query 를 분리하는 것입니다. 엔티티는 언제는 변경 감지가 일어날 수 있다는 사실을 늘 고려해야 합니다.