728x90
반응형

팀내 관리자용 웹 사이트의 SpringBoot 서버를 개발하면서 겪은 JPA 활용 시행착오를 공유하고자 합니다.
버전정보는 아래와 같습니다.
Java 1.8, SpringBoot 2.4

문제 상황

관리자용 웹 사이트에서 활용하는 정보를 DB로부터 조회하고 저장하는 CRUD API 개발을 사전에 진행한 상황이었습니다.
그 중, DB에 데이터를 update하는 코드는 아래와 같았습니다.

@Service
@RequiredArgsConstructor
public class InfoService {

    private final InfoRepository infoRepository;

    @Transactional
    public CommonResponse<String> updateInfo(InfoReq infoReq) {
        CommonResponse<String> response = new CommonResponse<>();

        try {

            Info info = Info.builder()
                .id(infoReq.getId())
                .name(infoReq.getName())
                .date(infoReq.getDate())
                .note(infoReq.getNote())
                .build();

            infoRepository.save(info);

        } catch (Exception e) {
            response.setSuccess(false);
            response.setData(e.toString());
            e.printStackTrace();
        }

        return response;
    }

}

DB에 update할 정보를 Rest API Controller 파라미터로부터 받고,
해당 파라미터 정보를 update할 엔티티 오브젝트에 빌더를 활용하여 세팅하고,
JPA의 save메서드를 활용하여 DB에 반영하는, 겉으로 보기에는 문제가 없어보이는 일반적인 코드로 보였습니다.

이후, 👉이전글에서 확인된 것처럼 Scheduler를 개발하게 되었고,
위 코드의 Info 엔티티에 필드가 추가됩니다.

이때, 사실 이미 작성된 기존코드를 크게 신경쓰지 않았습니다.
기존 update코드에서 update가 진행될 필요가 없는 데이터에 대한 필드가 추가되었고,
update 진행 시에 추가된 필드는 null로 세팅되어 JPA 자체적으로 값이 세팅되어 있는 필드만 실제 DB에 반영할 것이라고 생각했기 때문입니다. (완벽한 착각)

하지만 생각과는 달랐어요.
신규로 추가된 필드에 DB client 툴에서 쿼리를 실행하여 직접 데이터를 사전에 넣은 상태에서
기존 update코드를 실행시키는 update요청을 보내어 DB에 값을 update시키면,
사전에 직접 넣은 데이터는 null로 다시 update가 되는 현상을 확인하였습니다.

문제의 원인

앞서 언급했던 것처럼 JPA 자체적으로 값이 세팅되어 있는 필드만 실제 DB에 반영할 것이라고 생각했습니다.
하지만 이는 잘못된 생각이었어요.

공신력이 있는... 향로님의 👉참고글에서 확인해본 결과,
JPA는 기본값으로 update를 실행할 때, 전체 필드를 대상으로 진행되는 것으로 설정되어 있다고 합니다.

그래서 신규 필드에 대한 데이터 세팅 작업이 없는 기존 update 코드가 실행이 된다면,
신규 필드에는 자동으로 null로 세팅이 될 것이고,
JPA는 기본값으로 설정되어 있기 때문에 null 데이터를 포함한 전체 필드를 대상으로 Update를 진행하게 되는 것입니다.

그래서 Update가 필요한 컬럼에 대해서만 DB Update가 진행될 수 있도록 코드를 수정해야 했습니다.

변경된 부분만 Update하는 방법

변경된 부분만 실제 DB에 Update하는 방법으로 아래 세 가지 방법을 고려하였습니다.

1. 조회 후, 전체 데이터 세팅

신규로 추가된 필드가 null로 세팅되어 Update되는 것이라면...
신규 필드에 대한 데이터를 DB에서 조회해온 후에 값을 세팅해서 save하면 되는 문제아닌가?
간단히 생각할 수 있는 솔루션입니다.

하지만 update할 엔티티를 builder를 활용하여 새로 생성하여 save하고 있었습니다.
그렇기 때문에 엔티티에서 각 필드에 대한 값을 하나하나 정성스럽게 세팅해줘야 합니다.
이는 해당 엔티티에 새로 신규 필드가 추가된다면, builder를 활용한 모든 코드에 필드를 추가해줘야한다는 것을 의미했습니다.

향후 유지보수에 있어서 상당한 비효율이 예상되었기 때문에 pass!

@Service
@RequiredArgsConstructor
public class InfoService {

    private final InfoRepository infoRepository;

    @Transactional
    public CommonResponse<String> updateInfo(InfoReq infoReq) {
        CommonResponse<String> response = new CommonResponse<>();

        try {

            Info curInfo = infoRepository.findById(infoReq.getId()); // 엔티티에 세팅할 값을 조회

            Info info = Info.builder()
                .id(infoReq.getId())
                .name(infoReq.getName())
                .date(infoReq.getDate())
                .note(infoReq.getNote())
                .newField(curInfo.getNewField()) // 신규 필드에 값 세팅 추가
                .build();

            infoRepository.save(info);

        } catch (Exception e) {
            response.setSuccess(false);
            response.setData(e.toString());
            e.printStackTrace();
        }

        return response;
    }

}



2. @DynamicUpdate 어노테이션 선언

@DynamicUpdate 어노테이션을 선언하면, 해당 어노테이션이 선언된 entity에서 수정된 필드를 대상으로만 DB에 update를 실행하게 됩니다.
👉해당글 참고

@Table(name="tb_info")
@Entity
@Getter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
@DynamicUpdate // 어노테이션 추가
public class Info {

    @Id
    @Column(name = "id")
    private int id;

    @Column(name = "name", length = 10)
    private String name;

    @Column(name = "date")
    private LocalDateTime date;

    @Column(name = "note", length = 50)
    private String note;

}

앞서 언급했던 것처럼 JPA는 기본 설정으로 모든 필드 대상으로 Update 되게끔 설정되어 있다고 합니다.
그런데 @DynamicUpdate 어노테이션을 활용하면 변경된 필드 대상으로만 Update하는 쿼리를 실행하게 되죠.

하지만 JPA의 기본값으로 모든 필드가 Update 되게끔 설정된 이유를 알고나면, @DynamicUpdate 어노테이션의 활용을 한번 더 고민하게 될 것입니다.

JPA 설정으로 변경된 필드에 상관없이 모든 필드가 Update가 될 때의 장점은 아래와 같습니다.

  1. 생성 쿼리가 항상 동일하여 SpringBoot 서버 실행 시점에 쿼리를 미리 만들어 사용할 수 있습니다.
  2. DB 입장에서 쿼리 재사용이 가능합니다. (DB는 사전에 실행한 쿼리를 캐싱해놓고, 동일 쿼리가 실행되었을 때 캐싱된 쿼리를 실행함)

모든 필드를 Update할 때의 장점이 위와 같기 때문에 @DynamicUpdate 어노테이션을 사용했을 때,
위 장점들을 활용하지 못하고 쿼리를 매번 새로 생성하여 실행하게 됩니다.
즉, 성능상 비효율을 초래할 수 있습니다. 그래서 이 방식도 pass!

3. Dirty Checking 활용

Dirty Checking이란?

먼저 Dirty Checking에 대해 간단히 짚고 넘어가겠습니다.

Dirty Checking은 JPA에서 트랜잭션이 끝나는 시점에 변경이 있는 엔티티를 DB에 자동으로 반영하는 것을 의미합니다.
즉, 영속성 컨텍스트가 관리하는 엔티티에 setter등을 활용하여 필드에 값을 세팅하는 등 변경이 생긴다면,
트랜잭션이 끝나는 시점에 엔티티의 마지막 상태로 DB update를 진행하게 된다는 것입니다.

여기서 주목해야할 부분은 영속성 컨텍스트가 관리하는 엔티티의 의미입니다.
예제로 간단하게 표현해보면

  1. JpaRepository로 조회해온 Entity : 영속
  2. detach된 Entity : 준영속
  3. Builder, 생성자 등을 통해 새로 생성한 Entity : 비영속

여기서 영속상태인 Entity를 영속성 컨텍스트가 관리하는 엔티티로 볼 수 있고,
해당 Entity에서 변경이 생길 경우, 별도 save 메서드를 통해 Update를 수행하지 않아도
트랜잭션이 끝나는 시점에 DB에 변경사항이 반영됩니다.

Dirty Checking 수행을 원하지 않을 수도 있습니다.
해당 기능을 막기 위해서 @Transactional(readOnly = true) 어노테이션의 readOnly 옵션을 true로 설정해주면 됩니다.

Dirty Checking 활용

Dirty Checking 기능을 활용하려면 영속성 컨텍스트가 관리하는 엔티티를 활용해야합니다.
하지만 어차피 update를 하기 전에 모든 값을 세팅해주어야 하기 때문에 사전에 조회하는 작업이 필요하긴 하죠.
그렇기 때문에 값을 사전 조회한 Entity(영속성 컨텍스트가 관리하는 Entity)에 필드 값을 변경해주어 모든 값에 대해 Update가 될 수 있도록 코드를 수정할 것입니다.

해당 방식을 활용하면 종합적으로 아래와 같은 장점을 얻을 수 있습니다.

  1. 모든 필드를 Update하는 동일 쿼리를 사용하기 때문에 성능상 이점이 있음
  2. 모든 필드를 누락없이 Update시킬 수 있음

@Service
@RequiredArgsConstructor
public class InfoService {

    private final InfoRepository infoRepository;

    @Transactional
    public CommonResponse<String> updateInfo(InfoReq infoReq) {
        CommonResponse<String> response = new CommonResponse<>();

        try {

            Info info = infoRepository.findById(infoReq.getId()); // 영속성 컨텍스트가 관리하는 Entity

            info.setId(infoReq.getId());
            info.setName(infoReq.getName());
            info.setDate(infoReq.getDate());
            info.setNote(infoReq.getNote());

        } catch (Exception e) {
            response.setSuccess(false);
            response.setData(e.toString());
            e.printStackTrace();
        }

        return response;
    }

}

위 코드는 어느정도 보완이 된 코드이지만,
여전히 위 1번 방식에서 언급한 유지보수의 효율이 떨어지는 코드이고, Entity에서 지양되어야 하는 Setter 메서드가 활용되고 있습니다.
👉Setter지양이유 글 참고

이를 보완하기 위해 Entity 클래스에 메서드를 추가했습니다.

@Table(name="tb_info")
@Entity
@Getter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class Info {

    @Id
    @Column(name = "id")
    private int id;

    @Column(name = "name", length = 10)
    private String name;

    @Column(name = "date")
    private LocalDateTime date;

    @Column(name = "note", length = 50)
    private String note;

    @Column(name = "not_update_field1", length = 50)
    private String notUpdateField1;

    @Column(name = "not_update_field2", length = 50)
    private String notUpdateField2;

    @Column(name = "not_update_field3", length = 50)
    private String notUpdateField3;

    public void updateField(String name, LocalDateTime date, String note) {
        this.name = name;
        this.date = date;
        this.note = note;
    }

    public void autoUpdateDate(Info info, LocalDateTime date) {
        this.updateAll();
        this.date = date;
    }

    public void updateAll(Info info) {
        this.id = info.getId();
        this.name = info.getName();
        this.date = info.getDate();
        this.note = info.getNote();
        this.notUpdateField1 = info.getNotUpdateField1();
        this.notUpdateField2 = info.getNotUpdateField2();
        this.notUpdateField3 = info.getNotUpdateField3();
    }

}

전체 값을 조회해온 Entity에 위 코드에서 새로 추가한 메서드를 활용하여
실제 Update가 진행되어야할 필드의 값만 세팅되도록 할 것입니다.

이를 통해 Setter 메서드를 활용할 필요가 없고, 가독성도 높일 수 있게 되었습니다.

이 뿐만 아니라 Entity를 새로 생성해서 Update해야하는 경우도 있을 수 있습니다.
Entity를 새로 생성해야할 경우를 대비해서 autoUpdateDate, updateAll과 메서드를 활용하여 값이 세팅될 수 있도록 하였고,
신규 필드가 추가될 때마다 updateAll 메서드만 수정될 수 있도록 하여 유지보수의 효율성을 높였습니다.

그리고 아래는 위 Entity를 반영한 최종 Update 코드입니다.

@Service
@RequiredArgsConstructor
public class InfoService {

    private final InfoRepository infoRepository;

    @Transactional
    public CommonResponse<String> updateInfo(InfoReq infoReq) {
        CommonResponse<String> response = new CommonResponse<>();

        try {

            Info info = infoRepository.findById(infoReq.getId()); // 영속성 컨텍스트가 관리하는 Entity
            info.updateField(infoReq.getName(), infoReq.getDate(), infoReq.getNote());

            // 트랜잭션이 끝나면 info 엔티티 Update 수행

        } catch (Exception e) {
            response.setSuccess(false);
            response.setData(e.toString());
            e.printStackTrace();
        }

        return response;
    }

}
반응형

+ Recent posts