JPA - 예상치 못한 변경 감지가 동작하는 경우
참고
https://github.com/donghyeon0725/jpaTest
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 를 분리하는 것입니다. 엔티티는 언제는 변경 감지가 일어날 수 있다는 사실을 늘 고려해야 합니다.
'프레임워크 > JPA' 카테고리의 다른 글
JPA Enum Type 사용하는 방법 (0) | 2021.12.08 |
---|---|
JPA ToOne 관계 초기화 방법 vs 컬렉션 초기화 방법 (강제 초기화 되지 않는 문제 해결) (0) | 2021.08.27 |
자바 ORM 표준 JPA 프로그래밍 - JPA의 예외 처리 (0) | 2021.08.08 |
JPA In 절 사용하기 (where) (0) | 2021.05.24 |
댓글
이 글 공유하기
다른 글
-
JPA Enum Type 사용하는 방법
JPA Enum Type 사용하는 방법
2021.12.08 -
JPA ToOne 관계 초기화 방법 vs 컬렉션 초기화 방법 (강제 초기화 되지 않는 문제 해결)
JPA ToOne 관계 초기화 방법 vs 컬렉션 초기화 방법 (강제 초기화 되지 않는 문제 해결)
2021.08.27 -
자바 ORM 표준 JPA 프로그래밍 - JPA의 예외 처리
자바 ORM 표준 JPA 프로그래밍 - JPA의 예외 처리
2021.08.08 -
JPA In 절 사용하기 (where)
JPA In 절 사용하기 (where)
2021.05.24