Spring/Spring Data

[JPA] 수정은 어떻게 하는 게 좋을까?

제우제우 2024. 8. 20. 15:51

여기 블로그 글 엔티티가 있다. 

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    @Lob
    private String content;
    @Builder
    public Post(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

 

해당 엔티티의 수정에 대한 방식과 각 방식의 장단점을 정리해 보겠다. 

참고로 이번 수정은 JPA의 변경 감지(Dirty Checking) 방식을 사용하겠다. 

 

+ 엔티티에 Setter를 사용하는 방식을 지양해야 하는 건 기본이기 때문에 설명을 pass 하겠습니다. 

 

JPA 변경 감지(Dirty Checking)

간단하게 변경 감지에 대해서 설명을 하면 직접 내가 updae 쿼리를 작성하거나 수정을 하고 다시 repository의 save()를 하지 않아도 같은 트랜잭션에서 조회한 엔티티의 상태가 변경이 되면 영속성 컨텍스트 1차 캐시의 해당 식별자를 가지는 엔티티 상태와 다르기 때문에  JPA는 이를 감지하고 플러시 시점에 update문을 날리는 방식이다. 

참고로 저장이나 수정, 삭제 같은 작업이 아닌 단순 조회는 @Transactional(readOnly = true) 옵션을 통해서 약간의 성능상 이점을 얻을 수 있다. 

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
    private final PostRepository postRepository;
    @Transactional
    public Post write(PostCreate postCreate){
        // postCreate -> Entity
        Post post = Post
                .builder()
                .title(postCreate.getTitle())
                .content(postCreate.getContent())
                .build();

        return postRepository.save(post);
    }
    public PostResponse get(Long id) {
        Post post = postRepository
                .findById(id)
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 글입니다."));

        return PostResponse.builder()
                .title(post.getTitle())
                .content(post.getContent())
                .id(post.getId())
                .build();
    }
    public List<PostResponse> getList(PostSearch postSearch) {
        return postRepository.getList(postSearch)
                .stream()
                .map(PostResponse::new)
                .collect(Collectors.toList());
    }
    @Transactional
    public void edit(Long id, PostEdit postEdit){
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 글입니다."));

        post.setTitle(postEdit.getTitle());
        post.setContent(postEdit.getContent());
    }
}
  • default: @Transactional(readyOnly = false)
  • 기본이 readyOnly false이기 때문에 클래스 레벨에는 true 적용 / 수정, 삭제 작업이 있는 메서드는 false 적용 

엔티티에 비즈니스 메서드 추가

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    @Lob
    private String content;
    @Builder
    public Post(String title, String content) {
        this.title = title;
        this.content = content;
    }
    public void update(String title, String content){
        this.title = title;
        this.content = content;
    }
}
  • update() 메서드를 추가했다.
@Transactional
public void edit(Long id, PostEdit postEdit){
    Post post = postRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 글입니다."));

    post.update(postEdit.getTitle(), postEdit.getContent());
}

이 방식의 문제점은 무엇일까?

개발자는 사람이기 때문에 실수를 한다. 

update() 메서드의 두 매개변수는 둘 다 String type 이다. 실수로 글 제목과, 글 본문이 바뀔 수 있다. 

현재는 필드가 2개지만 필드가 엄청 많다고 가정해 보자. 분명 이런 실수가 나온다. 

 

엔티티에 비즈니스 메서드 추가 방식 개선 

 public void update(PostEdit postEdit){
    this.title = postEdit.getTitle();
    this.content = postEdit.getContent();
}

PostEdit를 넘겨서 update 메소드 안에서 수정을 하는 방식이다. 

이렇게 하면 Post 엔티티가 PostEdit 즉 DTO에 의존하게 되어 엔티티와 DTO 간의 결합도가 높아진다. 

이는 좋은 설계가 아닐 수 있다. 

PostEdit가 변경될 때마다 Post 엔티티도 영향을 받을 수 있다. 

 

빌더 패턴 활용하기 

 

PostEditor - DTO & 엔티티 중간 

@Getter
public class PostEditor {
    private final String title;
    private final String content;
    @Builder
    public PostEditor(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

 

Post - 엔티티 

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    @Lob
    private String content;
    @Builder
    public Post(String title, String content) {
        this.title = title;
        this.content = content;
    }
    public PostEditor.PostEditorBuilder toEditor(){
         return PostEditor.builder()
                .title(title)
                .content(content);
    }
    public void edit(PostEditor postEditor){
        title = postEditor.getTitle();
        content = postEditor.getContent();
    }
}

 

Sevice - edit() 메서드 

@Transactional
public void edit(Long id, PostEdit postEdit){
    Post post = postRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 글입니다."));

    PostEditor.PostEditorBuilder editor = post.toEditor();

    PostEditor postEditor = editor.title(postEdit.getTitle())
            .content(postEdit.getContent())
            .build();

    post.edit(postEditor);
}

 

장점

  • 엔티티의 특정 필드만 선택적으로 수정할 수 있다. 
  • 필드가 많거나 업데이트할 필드가 상황에 따라 달라질 수 있는 경우 유용 
  • 엔티티는 이제 PostEdit DTO에 직접 의존하지 않는다. 즉 엔티티의 독립성이 유지
  • 메서드를 통해 수정할 필드를 명확하게 전달할 수 있더 코드의 의도와 로직이 명확해진다.

단점 

  • 추가적인 클래스 PostEditor, 메서드 요구 즉 복잡성을 증가시킨다. 
  • 프로젝트가 간단한 경우 이러한 복잡성은 오버헤드이다. 
  • PostEditor 객체를 매번 생성 이 또한 오버헤드