개발하는 햄팡이

[Spring][JPA][뉴스피드 만들기] JPA의 변경 감지 (set메소드) vs save() 호출 - 무엇을 선택할까? 본문

Back-End/Spring

[Spring][JPA][뉴스피드 만들기] JPA의 변경 감지 (set메소드) vs save() 호출 - 무엇을 선택할까?

hampangee 2025. 6. 14. 18:40

https://github.com/GyeongSe99/team-newsfeed-project

 

GitHub - GyeongSe99/team-newsfeed-project: [ 내일배움캠프 Spring 7기 ] Chapter5. 스프링 팀 프로젝트 - 뉴스피

[ 내일배움캠프 Spring 7기 ] Chapter5. 스프링 팀 프로젝트 - 뉴스피드 프로젝트 - GyeongSe99/team-newsfeed-project

github.com

 

이번에 끝난 과제는 첫 팀 프로젝트인 뉴스피드 만들기.

이전 과제를 하면서 팀원들이랑 논의했던 부분에서 배운것들이 많아 쭉 정리를 해보려고 한다.

 

 

일단 프로젝트에 대해서 간단하게 설명하면

 

우리는 페이스북을 모티브로해서 와이어프레임을 간단하게 작성하고,

깃허브로 협업하는 방식을 익히는 것을 목표로 프로젝트를 진행했다.

나는 그래도 깃허브나 이슈, PR같은게 익숙했는데 다른 팀원들은 다들 프로젝트가 처음이라고 해서 다같이 연습해볼겸 PR도 철저하게하고.. 어떤 방법이 괜찮은 방법인지 고민도해보고 재미있었다.

일단 프로젝트 전반적인 내용은 나중에 다른 글에서 설명하도록 하고!

 


 

 

프로젝트를 하면서 PR를 좀 열심히 했었는데 아래와 같은 리뷰가 달렸다.

 

 

나는 코드를 언어라고 생각하는 사람 입장에서 save()를 작성하는게 좀 더 "나 여기서 저장했다!!!" 라고 말해주는 느낌이 들어서 save()를 작성하는 것을 좋아해서 그냥 매번 save()를 쓰고 살아왔고,

save()에서 반환해주는 객체를 리턴하는게 더 정확하지 않나? 라고 생각해서 막연하게 save()를 호출해주고 있었다.

그래서 막연하게 저렇게 대답했고...

 

 

근데 이제 PR에는 없지만 우리끼리 토론을 하게 되었다!!


1. save() 메서드를 호출하여 저장하기

>> 내 의견은 위에서 말했듯이

    @Transactional
    public CommentDto updateComment(Long commentId, String content, Long loginUserId) {
        Comment findComment = commentRepository.findByIdOrElseThrow(commentId);

        // 작성자 검증
        authorValidator.validateOwner(findComment.getUserId(), loginUserId);

        findComment.setContent(content);
        Comment saved = commentRepository.save(findComment);

        return CommentDto.toDto(saved);
    }

save를 호출하면 저때 DB에 반영되고 반영된 결과를 객체로 가져오기 때문에 더 정확한 결과 값을 리턴하려면 save를 호출해야된다.

(위처럼 말하긴 했지만 안정확함)

 

 

2. 엔티티의 필드를 수정하고 변경 감지(Dirt Checking)에 맡기기

상대방의 의견은
현재 우리가 구현한 update메소드는 간단한 구조이기 때문에 우리가 입력한 정보 그대로 저장이 되고 그냥 리턴해줘도 되지 않을까...?

였다.

그래서 생겨난 예시코드

@Transactional
public CommentDto updateComment(Long commentId, String content, Long loginUserId) {
    Comment findComment = commentRepository.findByIdOrElseThrow(commentId);

    // 작성자 검증
    authorValidator.validateOwner(findComment.getUserId(), loginUserId);

    findComment.setContent(content);

    return CommentDto.toDto(findComment);
}

 

 

→ (나) Dto가 제대로 된 Dto일까?
→ (상대방) 그대로 저장했다면 값은 똑같으니깐 괜찮지 않나? 중간 과정에서 오류가 나면 롤백도 되니깐 괜찮지 않을까?

 

오....

나는 느낌적으로는 안될 것 같다고 생각했지만 정확하게 알고있지 않다보니 설명을 할 수 없었고

다같이 찾아보기로 했다.

 

 


 

 

 

일단 내 의견이 save를 호출하면 그때 flush가 호출되니깐 DB에 실제로 저장되는 값인거고 그걸 리턴하니깐 Dto가 정확하다였다.

근데 로직을 모르다보니 저장하는지 안하는지 모르겠어서 찾아봤다.

 

일단

1. 변경 감지란?

JPA에서는 트랜잭션 안에서 영속성 컨텍스트에 의해 관리되는 엔티티의 값을 setXXX() 등으로 바꾸면,
트랜잭션 커밋 시점 혹은 flush 시점에 JPA가 변경된 내용을 감지하여 자동으로 UPDATE 쿼리를 실행한다.

그 예시가 2번 의견이다.

@Transactional
public void updateUserName(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName); // 변경 감지
    // save() 호출하지 않아도 DB에 반영됨
}

 

 

2. save()가 꼭 필요할까?

save()가 정확하다 라고 말하기 위해선 flush()가 뭔지 commit()이 뭔지 등등에 대해서 설명해야하고 Transactional에 대해서도 알아야한다.

위에서 트랜잭션안에서 영속성 컨텍스트에 올려놓으면 자동으로 업데이트 해준다 라고 했는데

 

flush()는 DB에 쓰기 작업을 수행하는 것.
-  flush를 하더라도 트랜잭션이 끝나지 않았을 경우에는 commit을 하지 않았기 때문에 rollback을 할 수 있다.
- 그리고 commit()을 하게되면 DB에 쓰기 작업을 수행하는 것이 실제로 반영이되는 것이다. -> 롤백 할 수 없음. 값을 원래대로 되돌리려면 update같은 쿼리를 다시 날려줘야한다.

 

 

이걸 DB에서 쿼리를 날려보면서 작업한 사람들은 "아 내가 UPDATE쿼리를 날리고 commit이 안되면 반영된게 아니구나~" 를 알기때문에 JPA 이해가 쉽고,
그냥 쿼리 날리기랑 commit도 하기랑 다르다는 것을 알고 있는데 DB 쿼리를 날려본적 없는 분들은 이해가 힘들 수 있다..

뭐든 해봐야 이해가 쉽다

 

뭐 여튼 flush()이후 반환되는 값이 실제로 저장될 데이터는 맞으나 완전히 저장된 것은 아니라는 것!

 

 

 

그럼 다음으로는

JPA의 save()메소드는 flush()를 호출하는가?

나는 당연히 호출하는 줄 알았는데 무조건 호출하는 것이 아니었다..

 

*JPA 내부에서의 flush가 발생하는 주요 요건

  1. 트랜잭션 커밋 시
    트랜잭션 커밋 전에 flush가 자동 실행 → 변경사항을 DB에 반영한 후 커밋
  2. JPQL 실행 전
    @Transactional
    public void updateThenSelect() {
        User user = em.find(User.class, 1L);
        user.setName("세경");
    
        // 아래 쿼리를 실행하기 전에 flush 발생
        List<User> list = em.createQuery("SELECT u FROM User u", User.class).getResultList();
    }
     
  3. 수동 flush
  4. save() 호출시 상황에 따라 flush()
    Spring Data JPA는 상황에 따라 flush를 자동으로 호출하거나 생략한다.
    save()코드를 보면
    @Transactional
    public <S extends T> S save(S entity) {
        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }
     이렇게 구성되어있는데

    entity자체가 새로운 객체라면 em.persist()에서 해당 엔티티를 영속성 컨텍스트에 등록하고 INSERT쿼리를 준비한다.

    기존에 있던 객체라면 em.merge()를 수행하는데
    - 같은 ID를 가진 영속 엔티티가 있는지 조회한 후
    - 없으면 DB에서 SELECT로 찾아온다
    - 그 영속 엔티티에 파라미터로 들어온 entity의 필드 값을 복사
    - 복사된 새 영속 객체를 반환한다.


    즉,  save()과정에서는 flush가 발생하지 않는다..

나는 BaseEntity의 Auditing기능이 flush 또는 커밋 직전에 반영된다고 알고 있었고, 그래서 해당 필드를 업데이트한 결과를 리턴받을려면 save()를 호출해서 flush를 부르는 건줄 알았다..

 

왜냐하면

공식문서의 맨 앞에
JPA entity listener to capture auditing information on persisting and updating entities. To get this one flying be sure you configure it as entity listener in your orm.xml as follows:
JPA 엔티티 리스너를 사용하여 엔티티가 저장(persist)되거나 수정(update)될 때 감사(auditing) 정보를 캡처할 수 있습니다.

이렇게 써있다..
https://docs.spring.io/spring-data/jpa/docs/current/api/org/springframework/data/jpa/domain/support/AuditingEntityListener.html?utm_source=chatgpt.com

 

AuditingEntityListener (Spring Data JPA Parent 3.5.1 API)

java.lang.Object org.springframework.data.jpa.domain.support.AuditingEntityListener JPA entity listener to capture auditing information on persisting and updating entities. To get this one flying be sure you configure it as entity listener in your orm.xml

docs.spring.io

그리고 나는 계속 반영된 값을 받아왔으니 그렇게 생각할 수 밖에 없었던 것..

 

그래서 코드 중간에 로그를 찍어본 결과 set만 하던 save만 하던 updatedAt는 잘 반영이 되어있고 결과는 똑같았다.

UPDATE 쿼리도 같은 타이밍에 발생했다.

 

이유인 즉,

Hibernate 에서 

DefaultFlushEventListener 내부에서 dirty checking과 함께 PreUpdateEvent를 트리거 한다는 것을 알게 됐다

실제로 org.hibernate.event.internal.DefaultFlushEntityEventListener && flushEntities() 를 보면 dirty checking 시점에 @PreUpdate를 실행한다.

 

정리하자면, set메소드를 작성하면
실행시 Hibernate에서 영속성 컨텍스트에 올라가있는 객체들을 순회하며 dirty check를 하고
이 시점에서 변경된 사항이 있으면 @PreUpdate를 실행하고 그때 @LastModifiedDate 등의 필드가 업데이트 되는것..

 

이 부분은 도저히 찾을 수 없어서 chat GPT의 도움을 받았다..
(아니 gpt없으면 개발 어캐함...?)

 

 

 

 

 

그렇다면 save()는 왜 쓰는 것일까?


1. 명시적인 의도 표현을 위해

“이 객체는 최종적으로 이 부분에서 저장될 거야”라는 의미를 정확하게 전달하기 위함이 제일 크다.

save()를 써두면 팀원들이 코드를 볼 때 최종적으로 여기까지의 변경사항이 DB에 반영이 되겠구나~를 알게 되는 것이다. 

2. 객체가 영속 상태인지 확신이 없을 때

메소드에 @Transactional이 적용되지 않았을때 객체를 영속 상태로 등록하지 않는다.
뭐 이런 경우도 있다고 하는데 이건 메소드에 @Transactional을 붙여서 방지할 수 있다고 생각한다.

 

3. 리턴 값을 바로 써야 할 때

save()는 반환값이 있어서 → 바로 DTO 변환 가능하지만
변경 감지 방식은 반환값 없이 처리해야 하므로 return 시점에서 애매함.

얘도 의미상의 문제인 것 같다.

공식문서에서는 이렇게 말하고 있다.

++ 

Note that the call to save is not strictly necessary from a JPA point of view, but should still be there in order to stay consistent to the repository abstraction offered by Spring Data.

JPA 관점에서 보면 save호출은 꼭 필요한 것은 아니지만 Spring Data에서 제공하는 저장소 추상화와 일관성을 유지하기 위해서는 여전히 존재해야 합니다.

 

결론으로는 우리 코드에서는 save()가 필요 없다는 것..!

 


 

 

그렇지만 우리는 협업을 해야하는 입장이고 save()가 쓰여있는 것이 좀 더 이 타이밍에 저장이 되는구만!을 알게되어 명확해서 좋다는 의견이 많아 save를 작성하기로 했다.

-> PR을 열심히 하면서 코드 이해가 안되는 것이 많이 불편하다는 것을 느끼고 있다. 뇌를 두번 거쳐야한다는게 한두개만 있으면 괜찮지만 계속 코드 리뷰를 하다보면 거슬린다는 것....

 

협업하면서 이런 명시적인 것을 중요하게 생각하게 되고 점점 코드라는 것이 언어라는 말이 와닿는것 같다.

기능적으로는 아무런 문제가 없지만 이해하기 쉽도록 코드를 작성하는 것...

짧은 코드를 선호하는 사람들에겐 이해가 안될수도 있지만
나는 조금 길어지더라도 기능에 크게 문제가 없으면 이해하기 쉬운 코드가 좋은 것 같다.

 

 

 

그리고 이번에  프로젝트를 하면서 팀원들이 너무 좋았던게
이런 건설적인 토론할 수 있게 되는것도 너무 좋고 각자 당연하게 생각했던게 누군가에게는 당연하지 않을 수 있고

이걸 기점으로 찾아볼 수 있게 된다는 것..

너모 재밋다 ^-^...
(팀원들도 재밌었는지는 모르겠다...)