JPA 를 이용해 서비스를 개발 중에 마주친 문제점과 해결방안에 대해서 공유합니다. 실제 개발 중에 겪었던 문제 상황을 간략한 도메인을 만들어서 재연해 보도록 하겠습니다. 사용한 기술 스택은 아래와 같습니다.
먼저 문제가 발생한 이유는 @Transactional 의 커밋과 JPA 의 영속성 컨텍스트의 관계를 제대로 이해하지 못했기 때문이었습니다. 제가 해결한 내용은 JpaRepository
를 이용해 엔티티를 삭제할 때 의도했던 범위에 예외를 반환하지 않았던 문제였습니다. 간단한 예시 코드를 통해서 문제를 재연해보고 해결 방법을 공유하겠습니다.
예시에서 사용할 아주 단순하게 재구성한 도메인 입니다. 서비스 계층에서 단순한 기능만 사용할 것이기 때문에 아무 비즈니스 로직을 포함하지 않도록 하겠습니다.
User 엔티티와 Post 엔티티가 있을 때 User 1 : N Post 의 구조를 갖습니다.
// entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
public User(String name) {
this.name = name;
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private User user;
public Post(String title, User user) {
this.title = title;
this.user = user;
}
}
// repository
public interface UserRepository extends JpaRepository<User, Long> {
}
public interface PostRepository extends JpaRepository<Post, Long> {
}
먼저 삭제를 하는 로직은 단순합니다.
위 로직을 서비스 계층에 구현하면 아래와 같습니다. 그리고 아래 코드는 처음 문제가 발생했던 코드입니다.
@AllArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
@Transactional
public void deleteUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(IllegalArgumentException::new);
try {
userRepository.delete(user);
} catch (DataIntegrityViolationException exception) {
throw new IllegalArgumentException();
}
}
}
User 삭제를 시도할 때 try catch 구문을 이용한 이유는 만약 User 에 연관된 Post 가 있을 때 삭제가 되지 않기 때문에 해당 예외를 처리하기 위함입니다.
다음은 서비스 계층을 테스트 해보겠습니다. 테스트 코드를 작성할 때는 catch 구문에서 예상한 DataIntegrityViolationException
이 반환되어 IllegalArgumentException
이 throw 될 것이라 생각하였습니다. 따라서 작성한 테스트 코드는 아래와 같습니다.
@DisplayName("외래키 제약사항에 포함된 user 를 삭제 시 예외를 반환한다")
@Test
public void deleteUserWithForeignKeyConstraints() {
// given
User savedUser = userRepository.save(new User("user1"));
Post savedPost = postRepository.save(new Post("post1", savedUser));
Long userId = savedUser.getId();
// then
assertThatThrownBy(() -> userService.deleteUserById(userId))
.isInstanceOf(IllegalArgumentException.class);
}
하지만, 위 테스트 코드는 실패했고 반환된 예외는 DataIntegrityViolationException
이었습니다.
해당 예외는 스프링에서 제공하는 예외인데 데이터베이스의 다양한 제약조건을 어겼을 때 반환되는 예외입니다. 하지만 서비스 계층에서 해당 예외가 발생했을 때 try catch 로 처리한 후 IllegalArgumentException 을 반환하도록 했는데 왜 해당 예외가 그대로 반환이 되었을까요?
문제가 되었던 코드를 보면 서비스 계층의 deleteUserById 메소드에 @Transactional
어노테이션이 붙어있습니다. 따라서 해당 메소드가 끝날 때 까지 영속성 컨텍스트가 유지되고 있습니다. 레포지토리 계층은 JpaRepository 의 구현체를 이용하고 있고 해당 구현체의 delete~
메소드들에는 메소드 레벨에 @Transactional 어노테이션이 붙어있습니다. 제가 기본적으로 사용한 구현체는 SimpleJpaReposiotry
이고 이 클래스의 내부의 delete(T entity) 메소드를 확인해보면 아래와 같습니다.
@Transactional 이 메소드에 설정되어있고 Propagation(트랜잭션의 전파 수준) 이 기본값으로 설정되어 있습니다. 기본값은 Propagation.REQUIRED
이고 이 설정 값은 상위 트랜잭션이 실행 중이라면 해당 트랜잭션 내부에서 메소드를 실행하는 것입니다. 그렇기 때문에 delete 쿼리가 레포지토리 계층에서 시작되어 종료 되는 것이 아닌 서비스 계층의 메소드가 종료될 때 delete 쿼리가 실행되는 것입니다. 따라서 서비스 계층 내부의 try catch 에서는 예외를 반환하는 것이 아닌 서비스 계층의 메소드를 호출한 쪽에서 예외가 반환되게 되겠죠!
위와 같은 문제가 발생한 이유는 JPA 에서 삭제 쿼리는 먼저 영속성 컨텍스트의 쓰기 지연 저장소에 저장되었다가 실행되기 때문입니다. 쓰기 지연 저장소의 쿼리가 실행되는 시점은 트랜잭션이 커밋되는 시점입니다. 지금 코드는 레포지토리 계층에서 삭제 메소드가 실행되었을 때가 아닌 서비스 계층의 삭제 메소드가 실행되었을 때 트랜잭션이 커밋됩니다. 그래서 서비스 계층의 메소드가 종료되었을 때 DataIntegrityViolationException
이 던져지는 것입니다.
문제점을 알았으니 수정하기 위해서는 트랜잭션의 커밋 시점을 레포지토리 계층의 메소드가 실행 시점으로 변경해주면 됩니다. 아주 간단하게 서비스 계층의 메소드에 @Transactional 어노테이션을 제거함으로써 처음 의도대로 서비스 계층의 메소드 내부에서 예외를 try catch 로 처리할 수 있습니다.
@AllArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
// @Transactional -> 트랜잭션을 없앱니다.
public void deleteUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(IllegalArgumentException::new);
try {
userRepository.delete(user);
} catch (DataIntegrityViolationException exception) {
throw new IllegalArgumentException();
}
}
}
위와 같이 수정한 후 테스트 코드를 실행하면 테스트가 통과하게 됩니다.
위의 문제에서 알아본 내용을 정리하겠습니다.
GenerationType.IDENTITY
인 경우에 해당)과 엔티티를 수정 / 삭제 하는 쿼리가 실행되는 시점의 차이를 인지하고 있어야 합니다.또 다른 주의점으로는 테스트 코드를 작성할 때 입니다. 만약 User 엔티티를 삭제하는 문제점을 개선한 코드를 테스트할 때 테스트 코드에 @Transactional 어노테이션을 붙이면 어떻게 동작할까요? 이 경우에 기존 문제점과 마찬가지로 테스트 코드 메소드(상위 메소드)에서 트랜잭션이 시작되기 때문에 의도한 대로 예외를 처리할 수 없습니다.
@Transactional
@DisplayName("외래키 제약사항에 포함된 user 를 삭제 시 예외를 반환한다")
@Test
public void deleteUserWithForeignKeyConstraints() {
// given
User savedUser = userRepository.save(new User("user1"));
Post savedPost = postRepository.save(new Post("post1", savedUser));
Long userId = savedUser.getId();
// then
assertThatThrownBy(() -> userService.deleteUserById(userId))
.isInstanceOf(IllegalArgumentException.class);
}
따라서 저는 서비스 계층을 테스트할 때 테스트 코드에는 @Transactional 어노테이션을 붙이는 것을 피하고 있습니다. 위와 같이 의도와 다른 동작을 테스트 할 수 있기 때문입니다.
테스트 db는 h2 사용하시나요?
테스트 코드에 @Trasnactional 안붙이면 데이터 롤백은 어떻게 하시나요?