프로젝트 리팩토링 중 역방향으로 발생하는 N + 1 문제를 확인하게 되었다.

역 N + 1이라고 명칭하기에는 정식 명칭이 아니라고 하지만, 그렇다고 마땅한 명칭이 있는것도 아니라고 하기에 그냥 편하게 역 N + 1이라고 생각하고자 한다.

 

 

문제점과 해결방안

기본적으로 N + 1 문제가 발생하는 이유는 상위 엔티티 리스트를 조회한 뒤 연관관계에 있는 하위 엔티티 필드에 접근 시 상위 엔티티 리스트 크기만큼의 하위 엔티티 조회 쿼리가 발생하는 것을 말한다.

 

N + 1이 발생하는 원인으로는 상위 엔티티 리스트 조회 시 하위 엔티티들에 대한 프록시 객체를 생성해두었다가 접근 시 초기화하는 구조로 동작하게 된다.

N + 1 문제는 FETCH JOIN으로 한번에 조회를 해오는 방법을 택하거나, 상위 엔티티 리스트에서 id 값들만을 리스트화 해 하위 엔티티에 대한 IN 절을 사용한 쿼리로 문제를 해결할 수 있는 방법이 있다.

로직을 어떻게 작성하느냐에 따라 동일한 조회를 하면서도 N + 1을 회피할 수 있는 방법이 존재한다는건데 이 역 N + 1 문제는 상황이 조금 달랐다.

 

우선 문제가 된 부분을 보기 위해 간단한 엔티티 코드를 예시로 든다.

 

@Entity
@Getter
//...
public class ProductOrder {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    @OneToMany(mappedBy = "productOrder", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    private final List<ProductOrderDetail> productOrderDetailList = new ArrayList<>();
    
    //...
}


@Entity
@Getter
//...
public class ProductOrderDetail{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "productId")
    private Product product;
    
    @ManyToOne
    @JoinColumn(name = "productOptionId")
    private ProductOption productOption;
    
    @ManyToOne
    @JoinColumn(name = "orderId")
    private ProductOrder productOrder;
    
    //...
}

 

이렇게 주문 관련된 두 엔티티가 존재한다.

서로 양방향 매핑 구조이며, 1 : N 관계에 있다.

그리고 ProductOrderDetail 엔티티의 경우 단방향 매핑으로 Product, ProductOption을 참조하고 있는 상황이다.

 

여기서 기능상 List<ProductOrderDetail> 을 조회하는 쿼리가 있었다.

SELECT *
FROM productOrderDetail
WHERE orderId IN (?, ?, ?, ?, ?)

 

그럼 여기서 예상할 수 있는 JPA의 쿼리 수행은 위 쿼리 단 하나였다.

하지만 실제 로그에 찍히는 쿼리는 달랐다.

 

orderId 리스트 크기에 해당하는 만큼 productOrder를 조회하는 쿼리가 발생했고, 이후 Product, ProductOption을 조회하는 쿼리가 발생했다.

즉, 조회하는 orderId 리스트 크기가 20이었다면 productOrder를 20번 조회하고, Product, ProductOption을 조회하는 쿼리가 발생해 실제 수행된 쿼리 개수는 23개가 됐다.

 

이런 현상때문에 역 N + 1이라는 생각을 하게 된 것이다.

이 문제는 일반적인 N + 1 문제와 다르게 코드레벨에서의 접근이 전혀 없었는데도 불구하고 쿼리 수행 하나만으로 이런 문제가 발생했다는 것이다.

 

N + 1은 코드레벨에서 접근하지 않는 방법 또는 애초에 같이 조회하는 방법을 통해 문제를 회피할 수 있었지만 이건 쿼리 실행 즉시 발생하기 때문에 당황스러웠다.

 

하지만 문제 해결은 의외로 간단하게 처리할 수 있는데 엔티티 리스트를 조회하는 것이 아닌 DTO 리스트로 매핑하도록 처리하면 이 문제는 해결된다.

일반적인 N + 1처럼 엔티티 자체를 조회하는 경우에 발생하는 문제이기 때문에 DTO 매핑으로 처리한다면 이런 문제는 발생하지 않는다.

 

 

발생 원인

그럼 이 문제는 왜 발생하는걸까?

 

이건 영속성 컨텍스트의 특징때문에 발생하는 문제다.

우선 N + 1을 예로 들어보자면 10의 크기를 갖는 ProductOrder 리스트를 조회했고 그 리스트에서 productOrderDetailList 필드에 접근하려고 하면 조회한 모든 ProductOrder의 하위 엔티티를 조회하게 된다.

이때 ProductOrder 리스트 조회 시점에서 이미 각 ProductOrderDetailList에 대한 프록시 객체가 생성되고 접근 시 그 프록시 객체가 초기화 되면서 발생하는 이슈다.

 

그럼 결과적으로 리스트만 조회한 시점에서 영속성 컨텍스트는 10개의 각 ProductOrder를 관리하면서 하위 엔티티인 ProductOrderDetail에 접근할때를 대비해 준비를 하고 있는 상태까지 만들어놓는 것이다.

 

각 ProductOrder 하나 당 3개의 ProductOrderDetail을 갖고 있다고 가정한다면

N + 1 문제가 발생한 이후 영속성 컨텍스트에서는 10개의 ProductOrder와 30개의 ProductOrderDetail의 id와 필드들을 갖고 있는 상태로 관리하게 되는 것이다.

 

그럼 하위 엔티티를 조회하는 시점에서는 영속성 컨텍스트가 어떻게 관리하려고 할까? 라는 것을 알아야 한다.

IN절을 제외하고 하나의 ProductOrderDetail을 조회한다고 가정해보자.

그럼 ProductOrderDetail에서는 ProductOrder, Product, ProductOption을 참조하고 있는 상황이다.

 

ProductOrderDetail이 조회된 시점에 영속성 컨텍스트에서는 이 엔티티에 대한 관리를 시작하게 된다.

근데 이 내부에는 연관관계에 있는 상위 엔티티가 3개가 존재한다.

그럼 영속성 컨텍스트는 이 상위 엔티티 데이터가 무결성을 위배하지 않는지 확인하기 위해 전부 조회해서 체크해보게 된다.

그래서 이렇게 리스트 크기만큼의 추가적인 조회가 발생하게 되는 것이다.

 

여기서 좀 더 들어가보면, N + 1문제의 경우 상위 엔티티 리스트 조회 시 하위 엔티티들에 대한 프록시 객체를 생성하고 접근 시 초기화를 수행해 조회를 처리한다.

역 N + 1의 경우도 동일하게 하위 엔티티를 조회했을 때 상위 엔티티들에 대한 프록시 객체를 생성한다.

그러나, 무결성 위배를 체크하기 위해 프록시 객체를 즉시 초기화하고 체크하기 때문에 접근이라는 트리거가 없더라도 프록시 객체가 초기화 되는 것이다.

 

 

이건 JSON으로 작성해보니까 좀 더 이해하기가 편했다.

 

{
    "productOrderId1" : {
        "productOrderField1" : "productOrderField1 value",
        "productOrderField2" : "productOrderField2 value",
        "productOrderDetailList" : [
            ?
        ]
    }
}


{
    "productOrderId1" : {
        "productOrderField1" : "productOrderField1 value",
        "productOrderField2" : "productOrderField2 value",
        "productOrderDetailList" : [
            "productOrderDetailId1" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            },
            "productOrderDetailId2" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            },
            "productOrderDetailId3" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            }
        ]
    }
}

 

이렇게 보면 영속성 컨텍스트에서 어떻게 관리하는지 이해하기가 좀 편하다.

 

ProductOrder를 조회한다면 첫번째 블록과 같은 구조로 관리하게 된다.

? 부분을 프록시 객체라고 볼 수 있으며 접근 시 초기화 되어 조회가 발생하고 영속성 컨텍스트에 올라가게 되는 것이다.

 

하지만 ProductOrderDetail을 조회한다면?

구조상 productOrder 블록 부분이 전부 ? 가 될수는 없다.

 

이런 구조처럼 영속성 컨텍스트에서도 엔티티 관리를 하기 위한 필수적인 절차를 거치는 것이라고 볼 수 있다.

애초에 테이블 구조만 놓고 보더라도 상위 테이블인 ProductOrder 테이블에서는 ProductOrderDetail의 값이 뭔지 관심도 없다. 데이터 자체도 안갖고 있고.

단지 JPA Entity에서만 양방향 매핑으로 ' 나 이 하위 엔티티 리스트 갖고 있을거야 ' 라고 정의한 것 뿐.

그렇기에 상위 엔티티 조회시에는 당장 이 데이터를 확인해야 할 필요가 없지만,

ProductOrderDetail 테이블에서 외래키로 ProductOrder의 id를 갖고 있는 것 처럼 영속성 컨텍스트에서도 이 상위 데이터가 있는 데이터 맞나? 하고 바로 확인할 수 밖에 없으므로 발생하는 문제다.

 

ProductOrder에 대한 예시만 들었지만 마찬가지로 Product, ProductOption도 같은 이유에서 조회한다고 볼 수 있다.

 

 

마무리

제목은 뭔가 거창하게 역 N + 1이라고 지었지만 이해하고 보면 당연한 처리구나 싶기도 한 문제였다.

하지만 분명히 지연시간이 발생할 수 있는 충분한 원인이 되기 때문에 확실하게 이해하고 왜 이런 문제가 발생하는지에 대해 알아야 한다고 생각했다.

이전에 정리했던 CascadeType에 대한 문제, 그리고 이번 역 N + 1 문제를 거치면서 JPA 사용에 대한 생각이 많아졌다.

분명 JPA를 사용함으로써 편의성에서 오는 이점이 크긴 하지만, 무분별하게 사용했다가는 오히려 독이 될 수 있겠다는 생각을 많이 하게 되었다.

특히 엔티티 자체를 조회하는 경우에 대해 좀 더 여러 상황을 고려해보고 사용해야겠다.

 

1. 연관관계 엔티티 동시 저장 및 주의사항

2. 동시저장에서 발생하는 이슈에 대한 대응책

 


 

이전 포스트에서는 CascadeType.ALL로 인해 얻을 수 있는 이점인 동시 저장과 동시에 그에 따라오는 단점들을 정리했다.

 

결과적으로는 ALL을 사용함으로써 동시 저장을 통해 Write 성능을 높일 수 있지만, 오히려 자신을 참조하고 있는 엔티티의 Write에서의 성능 저하로 이어질 수 있으며, save() 외에도 REMOVE 옵션으로 인한 삭제 제어를 주의해야 한다는 점을 알 수 있었다.

 

그래서 ALL 또는 몇몇 옵션만 사용하되 문제를 해결할 수 있는 방법은 없을까?

아니면 다른 방법이라도 따로 없을까? 하는 고민을 하게 되었고, 그 고민을 통해 알게된 점을 정리한다.

 

 

CascadeType.PERSIST만을 사용하는 방법. 그리고 문제점

 

CascadeType.PERSIST는 부모 엔티티를 ' 새로 저장 ' 할때 하위 엔티티도 같이 저장한다는 옵션이다.

그래서 이 옵션이 문제의 해결책이 될 것이라고 생각했다.

 

하지만 그렇지 않았다.

 

이전 포스트와 동일한 예시를 들어본다.

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "member")
public class Member {
    @Id
    private String userId;
    
    private String userPw;
    
    private String userName;
    
    private String nickname;
    
    @OneToMany(mappedBy = "member", cascadeType = CascadeType.PERSIST)
    private final List<Auth> auths = new ArrayList<>();
    
    public void addMemberAuth(Auth auth) {
        auths.add(auth);
        auth.setMember(this);
    }
}


@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "auth")
public class Auth {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String auth;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    public Auth(String auth) {
        this.auth = auth;
    }
    
    public void setMember(Member member) {
        this.member = member;
    }
}

 

동일하게 Member와 Auth가 존재하고 1 : N 관계로 매핑되어 있다.

그리고 Member에서 cascade 옵션으로 ALL이 아닌 PERSIST를 정의했다.

 

이후 아래 테스트 코드를 실행해봤다.

@SpringBootTest
public class QueryTest {
    
    @Autowired
    private MemberRepository repository;
    
    @Test
    void name() {
        Member member = Member.builder()
                            .userId("tester1")
                            .userPw("1234")
                            .userName("testerName")
                            .nickname("testerNickname")
                            .build();
        Auth auth = new Auth("ROLE_MEMBER");
        member.addMemberAuth(auth);
        
        repository.save(member);
    }
}

 

테스트 결과는 실패였다.

오류 로그로는 PropertyValueException이 발생했고, not-null property references a null or transient value: com.example.....Auth.auth 였다.

 

즉, Auth의 auth 필드는 Not Null로 선언되어 있는데 auth가 null이기 때문에 발생한 오류다.

이 로그를 보고 auth가 없을리가 없는데?? 하면서 체크해본 결과 auth는 존재하고 있다는 점이었다.

 

이 문제는 생각보다 복잡했다. 이해하는데도 시간이 좀 걸렸고..

 

우선 원인은 Auth 엔티티의 id 생성 전략이 IDENTITY라는 점이다.

이전 포스트에서 정리했듯이 두 엔티티 모두 저장이 되기 위해서는 PERSIST와 MERGE가 필요하다.

하지만 PERSIST는 ' 부모 엔티티 저장 시 자식 엔티티를 같이 저장한다 ' 라는 개념이었다.

 

그럼 MERGE가 왜 필요했던걸까? 라는 질문으로 되돌아 갈 필요가 있다.

내 프로젝트 구조에서 MERGE가 필요했던 이유는 IDENTITY 전략 때문이었다.

 

JPA에서는 IDENTITY 전략을 사용하는 엔티티에 대해 flush 이후 id 값을 반환받아 영속성 컨텍스트에서 관리하게 된다.

그래서 id값을 반환받는 처리가 필요하게 되는데 PERSIST만 사용하는 경우 flush가 마무리되지 않은 상태이기 때문에 Auth의 id값을 반환받지 못하게 되는 것이다.

그래서 Auth를 별개로 처리한 뒤 MERGE를 통해 부모 엔티티에 병합을 해줘야 할 필요성이 생기게 되고, 그로 인해 PERSIST과 MERGE를 같이 사용해야 한다는 것이다.

 

복잡하니까 순서를 좀 더 간소화 해보자면

select Member -> insert Member -> flush ( PERSIST 감지로 인해 Auth 동시 저장 시도 -> select Auth -> insert Auth -> MERGE ) -> flush 마무리.

이런 과정을 거쳐 처리된다는 것이다.

PERSIST만 정의한 상태에서는 병합이 수행될 수 없기 때문에 영속성 컨텍스트는 Auth에 대해 null로 처리하게 된다.

이유는 JPA는 영속성 컨텍스트에서 관리할 때 해당 엔티티의 ID 값을 꼭 갖도록 하는데 마치 Key값과 같기 때문이다.

그러나, Auth는 flush가 마무리되지 않았으므로 id 값을 반환받지 못하게 되고 영속성 컨텍스트는 id 값이 없는 Auth에 대해 관리하기를 포기하게 되는 것이다.

 

그래서 이런 생각이 들었다. flush가 마무리되어야 id 값을 반환받는다면 IDENTITY 전략인 단일 엔티티 저장에서는 정상적으로 처리되면서 하위 엔티티의 IDENTITY 전략은 왜 영향을 받는거지??

위 처리 순서에서도 Member의 flush는 마무리된 상태가 아니었기 때문에 이해가 잘 되지 않았다.

 

근데 순서를 잘 보면 그 속에 답이 있었다.

저 순서의 경우 MERGE가 포함된 경우이지만, PERSIST만 정의된 경우의 순서가 좀 달라지기 때문이다.

select Member -> insert Member -> flush ( PERSIST 감지로 인해 Auth 동시 저장 시도 -> select Auth ) -> flush 마무리

 

이 순서를 보면 Auth에 대해서는 select만 시도하고 insert를 처리하지 않는다.

즉, 하위 엔티티의 insert는 MERGE에 의해 수행된거지 PERSIST에 의해 수행된게 아니라는 의미다.

select Auth 까지 PERSIST가 수행한 뒤, 데이터가 없고, IDENTITY 전략이기 때문에 MERGE를 찾게 되는데 이때, MERGE가 존재한다면 이 수행과정을 이어받아 insert를 처리한 뒤 Member 와의 관계를 갱신하면서 영속성 컨텍스트에 올라가게 되는것이다.

 

하지만, PERSIST만 정의된 경우 영속성 컨텍스트는 ' 조회했더니 데이터가 없는데, IDENTITY 전략이니까 id 값 반환 받을 방법이 없네.. 근데 MERGE도 없잖아? 그럼 포기 ' 이렇게 되는것이다.

 

그래서 IDENTITY 전략이 문제라면 Member 처럼 String 타입이면 되겠네? 라는 생각이 들었다.

근데 결과부터 말하자면 안된다 이것도..

 

테스트코드에서 Auth를 생성하는 생성자에 대해 id 값을 같이 받도록 하나 만들어주고 String 타입의 id를 갖도록 했다.

그럼 Auth도 Member처럼 새로운 데이터이긴 하지만 id값을 애플리케이션에서 직접 생성하는 형태가 되는 것이다.

 

실행했을 때 오류 로그는 아래와 같았다.

JpaObjectRetrievalFailureException.

Unable to find com.example....Auth with id authId

 

authId라는 id 값을 가진 Auth 데이터는 존재하지 않는다는 오류다.

여기서 authId는 테스트 코드에서 Auth의 id로 넣어준 문자열이다.

즉, 이 Auth 객체는 데이터베이스에 없는데? 이건 처리 못해줘. 이런거다.

 

이거 보고 정신 나가는줄 알았다.

 

JPA 기본 동작 자체가 select 해서 없으면 insert를 수행해주고, 있으면 update 해주는게 맞지 않나? 내가 findById를 수행한 것도 아닌데 데이터가 없다는 소리를 왜..?

 

이것도 다 PERSIST 때문이었다.

 

JPA에서 하위 엔티티의 id 값이 이미 매핑된 상태이기 때문에 이미 존재하고 있는 데이터라고 지 맘대로 간주해서 조회하기 때문이다.

즉, id값이 매핑되었으니 ' 아! 이건 이미 저장된 데이터구나! 체크만 해봐야지 ' 라는 생각으로 조회를 하니까 ' 이거 데이터베이스에 없는데? ' 이런 소리를 하는거다.

 

여기서 포인트는 ' 하위 엔티티의 id 값이 이미 매핑된 상태 ' 다.

실제로 단일 엔티티 저장 시 id 타입이 String인건 전혀 문제가 안된다.

심지어 String 타입인 경우 영속성 컨텍스트에 해당 객체가 관리되고 있지 않다면 select는 시도하지도 않고 바로 insert를 수행한다.

 

연관관계에 있는 엔티티의 경우 JPA에서 자식 엔티티를 저장하기 전 부모 엔티티와 참조 관계를 데이터베이스와 동기화 하려고 시도하게 된다.

이때, 자식 엔티티의 id 값이 이미 존재한다면 ' 이건 데이터베이스에 저장된 데이터인데 나한테 없었구나! ' 라고 판단해서 일치 여부만 확인하는 select를 수행하게 되고, 당연히 없는 데이터니까 없다고 오류를 발생시킨다는 말이다.

 

PERSIST가 원인이라는 말이 이것 때문이다.

참조 관계를 데이터베이스와 동기화 하는 과정을 수행하는 이유가 PERSIST가 있기 때문인 것.

JPA는 단일 엔티티에 대해서는 영속성 컨텍스트에 새로 추가해 의도한대로 처리해주지만, 연관관계에서는 상태를 좀 더 엄격하게 관리하기 때문에 id 값이 설정된 경우 데이터베이스와의 검증을 필수로 수행하도록 설계 되어 있기 때문이다.

 

그럼 하위 엔티티의 IDENTIY 전략 안되고, String 타입 id도 안된다는 건데 SEQUENCE는 된다고 한다.

 

SEQUENCE의 경우 아직 테스트해보지 않아서 명확하게 정리하기는 어려운데 SEQUENCE가 정상적으로 처리되는 이유에 대해서만 정리를 한다.

 

SEQUENCE의 경우 MySQL의 auto_increment와 다르게 다음 생성될 값을 nextVal()로 가져오게 된다.

근데 이 Sequence 조회 시점이 하위 엔티티의 select 이전이기 때문에 가능하다고 한다.

즉, flush( sequence 조회 -> sequence값 auth에 할당 -> select Auth ) 이런 순서이기 때문에 정상적으로 처리된다는 것이다.

 

String 타입의 id를 사용해도 하위 엔티티 select 이전에 할당되는건데 그건 안되고 이건 왜?

포인트는 ' 어디에서 값이 할당 되었는가 ' 를 기준으로 한다고 한다.

String 타입의 id는 ' 애플리케이션 ' 에서 값이 할당된다. 테스트 코드에서 혹은 서비스 코드에서 직접 작성하게 되기 때문이다.

하지만 Sequence의 경우에는 ' 데이터베이스 ' 에서 값이 할당된다.

 

자, String 타입의 id를 할당한 경우 영속성 컨텍스트는 ' 동기화 ' 를 위한 조회를 시도하기 때문에 오류가 발생한다고 했다.

그럼 Sequence 전략으로 인해 이미 데이터베이스에서 id 값을 받았다면??

동기화를 굳이 하지 않아도 된다는 의미가 되는 것이다.

데이터베이스에서 넘겨준 값을 할당했으므로 동기화 작업이 필요없고 이후 처리를 수행할 수 있게 되는 것이다.

 

실제로 Oracle 환경 만들어두고 테스트하면 또 뭔가 새로운 문제가 튀어나올 수 있겠지만 우선은 된다고 하니

추후 테스트를 해보고 추가 작성하는 쪽으로..

 

 

MySQL에서 동시 저장을 사용하기 위해서는?

 

그럼 아직 시도해보진 않았지만 Oracle 기반에서는 SEQUENCE 전략을 사용하는 경우 동시저장을 처리할 수 있다고 하고.

그럼 MySQL에서는 어떤 방법이 있을까? 해서 알아봤다.

 

  1. Entity, Application을 철저하게 분석해서 사용할 수 있는 곳에서만 CascadeType.ALL을 사용한다.
  2. CascadeType.PERSIST, CascadeType.MERGE를 사용한다.
  3. EntityManager.persist()를 통해 처리한다.
  4. Hibernate의 Batch Insert를 사용한다.

 

1번 방법은 혼자서만 개발하는게 아니라면 좀 지양하는게 좋지 않을까..? 라는 생각이다. 아니면 정말 확실하게 패널티가 전혀 없고 REMOVE가 되더라도 상관없다고 보장되는 경우에만 사용하고.

 

2번 방법은 REMOVE의 문제점에서 자유로울수는 있지만, 다른 참조 엔티티의 save() 과정에서 불필요한 JOIN이 발생해야 하기 때문에 그 부분에 대해 감안할 수 있다면 사용할 수 있을 것이다.

 

이 두개는 이전 포스트에서 다 정리했으니 넘어가도록 하고.

 

다음은 EntityManager의 persist()를 사용하는 방버이다.

EntityManager의 persist()를 사용하더라도 EntityManager.flush()가 호출되어야만 요청이 전달되기 때문에 동일한 동시 저장을 처리할 수 있게 된다.

하지만 이 방법의 경우 단점아닌 단점이 존재한다.

편의성 측면에서 오는 단점이라고 볼 수 있는데 EntityManager를 사용하는 경우 트랜잭션 관리에 더 많은 신경을 써야 한다.

flush(), clear() 호출 시점을 잘 설계해야 한다.

또한, 테스트 코드 작성에서 Mocking에 대한 제약이 발생하게 된다.

아예 Mocking을 할 수 없는 것은 아니지만, Repository 기반으로 처리하는 것에 비해 좀 더 복잡해 질 수 있다.

이런 단점들만 잘 처리할 수 있다면 좋은 선택이긴 하나, Repository를 아예 사용하지 않는 것이 아니라면 보통 Repository 기반으로 환경을 통일해서 처리하는 것이 가장 좋은 선택이라고 한다.

 

 

다음은 Hibernate의 Batch Insert다.

이건 시도해보지도 않은 방법이긴 한데 설정은 엄청 간결하다.

spring.jpa.properties.hibernate.jdbc.batch_size=30
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

 

이렇게 properties 또는 yml에 작성해주기만 하면 된다.

수행시점은 repository.save()가 호출되는 시점인데 Batch Insert라고 해서 요청을 쌓아뒀다가 처리하는 방식은 아니고,

save() 시점에 호출되긴 하지만 결과적으로 트랜잭션 단위로 동작한다고 보면 된다.

 

이 방법은 시도해보지도 않은 이유가 있는데 이것 역시 IDENTITY 전략에 대한 단점이 존재하기 때문이다.

IDENTITY 전략은 영속성 컨텍스트에서 관리하기 위해 처리 이후 id 값을 반환받는다고 했다.

그래서 이 Batch Insert에서도 마찬가지로 IDENTITY 전략을 사용하는 엔티티라면 insert 처리마다 id 값을 개별적으로 가져와야 하기 때문에 그에 따른 지연시간이 발생할 수 있다고 한다.

 


마무리

그래서 결과적으로 MySQL을 사용하고, IDENTITY 전략을 많이 활용하고 있다면!

확실한 검증과 설계로 ALL 또는 PERSIST, MERGE를 사용하거나, 정말 성능 최적화를 위해 편의성을 조금 포기하고 EntityManager.persist()를 사용하거나, 아니면 동시 저장을 포기하고 각 Repository를 통한 save()를 처리하는 것이 현재까지 나온 결론이다!

 

결과적으로 프로젝트에서 서로 매핑되기만 하고 다른 엔티티들은 참조하지 않는 소수의 엔티티들에 대해서만 PERSIST, MERGE를 통한 동시저장을 할 수 있도록 처리했고, 그렇지 않은 엔티티들에 대해서는 동시저장을 포기하고 각자 save()를 처리하도록 수정했다.

 

벌써 몇번이고 최적화를 해보려고 프로젝트를 리팩토링해왔는데 처음으로 JMeter 테스트를 통해 새로운 문제점들을 많이 발견했다.

브라우저 또는 테스트 코드를 통한 일회성 요청이 정상적으로 처리되고 수행 시간이 만족스럽다면 꼭 JMeter 또는 자바의 CompletableFuture와 같은 비동기 병렬 처리를 통해 동시 요청을 보내 추가적인 문제점을 확인해야 겠다는 생각이 들었다.

 

일회성 요청에서 빠르더라도 요청 몇개 겹치기 시작하면 성능 저하 발생하는거 너무 쉬운 문제더라.................

 

 

1. 연관관계 엔티티 동시 저장 및 주의사항

2. 동시저장에서 발생하는 이슈에 대한 대응책

 


연관관계에 있는 엔티티를 동시 저장하기

 

JPA에서는 연관관계에 있는 엔티티를 한번의 save() 를 통해 같이 저장할 수 있는 방법이 존재한다.

그리고 그 방법을 아주 잘 활용하고 있었다.

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "member")
public class Member {
    @Id
    private String userId;
    
    private String userPw;
    
    private String userName;
    
    private String nickname;
    
    @OneToMany(mappedBy = "member", cascadeType = CascadeType.ALL)
    private final List<Auth> auths = new ArrayList<>();
    
    public void addMemberAuth(Auth auth) {
        auths.add(auth);
        auth.setMember(this);
    }
}


@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "auth")
public class Auth {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String auth;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    public Auth(String auth) {
        this.auth = auth;
    }
    
    public void setMember(Member member) {
        this.member = member;
    }
}

 

이렇게 연관관계에 있는 두 테이블이 존재한다고 할 때, MemberRepository.save(member); 를 통해 Member 엔티티와 Auth 엔티티 리스트를 같이 저장할 수 있게 된다.

여기서 포인트는 두 엔티티가 양방향 매핑 구조로 되어있다는 점과 Member 엔티티의 OneToMany 중 cascadeType이다.

 

만약 이렇게 정의하지 않는다면 MemberRepository.save(member), AuthRepository.saveAll(auths)  이렇게 두번의 요청으로 저장해야 한다.

 

그럼 여기서 단순하게 보더라도 MemberRepository를 통해 한번에 저장하는 것과 각 Repository를 통해 저장하는 것에는 차이가 있어 보인다.

처음 이 동시 저장에 대해 알게 되었을때는 'MemberRepository 한번으로 저장하도록 하더라도 어차피 2개 테이블에 저장해야 한다는건데 데이터베이스 접근은 동일한거 아닌가?' 라는 생각을 했었지만 그렇지 않았다.

 

JPA에서는 이 한번의 요청을 통해 Member에 대한 insert와 Auth에 대한 insert를 같이 전달하기 때문에 1번의 데이터베이스 접근을 통해 해결할 수 있게 된다.

 

그럼 이게 단순하게 브라우저, 테스트코드, Postman 같은 방법을 통해 테스트하게 되는 경우 보통 1번의 요청만 처리하기 때문에 큰 차이를 못느낄 수 있지만, JMeter나 자바의 CompletableFuture 를 통한 동시 요청을 발생시키는 경우에는 차이가 발생하기 시작한다.

 

처음 이 차이에 대해 느끼게 된 것은 더미데이터 생성시에 느끼게 되었는데,

당시 테스트 코드를 통해 더미데이터를 만드는 과정에서 각 Repository를 통한 요청보다 최상위 엔티티의 Repository를 통한 save() 한번으로 처리하는 것이 훨씬 빠르게 처리된다는 점을 확인할 수 있었다.

그리고 예시는 2개 테이블이지만 4개씩 되는 경우에는 차이가 더 심해지는 것을 확인할 수 있었다.

 

 

 

CascadeType.ALL이 동시 저장을 처리해 줄 수 있는 이유

양방향 매핑이 되어야 한다는 점, CascadeType.ALL을 정의해야 한다는 점이 동시 저장처리의 포인트라고 했는데 그럼 이 CascadeType.ALL은 어떻게 동시 저장을 처리할 수 있을까?

 

처음에는 이 CascadeType에 대해 데이터베이스의 cascade와 동일한거 아닐까? 라는 생각을 했었다.

하지만 CascadeType은 데이터베이스의 cascade 제약조건과는 전혀 별개로 동작하게 된다.

단지 JPA에서 작업에 대한 제어를 하기 위한 옵션이기 때문이다.

 

이 CascadeType에는 6가지의 옵션이 존재한다.

 

  1. PERSIST
    • PERSIST 의미 그대로 영속화를 위한 옵션이다. '저장' 에 대한 작업에 연관된 설정이다.
    • 부모 엔티티가 저장될 때 자식 엔티티를 같이 저장하게 된다.
    • EntityManager.persist()를 호출하지 않고 repository.save()만으로도 함께 처리될 수 있다.
    • 단, '새로운' 엔티티들을 저장하는 경우에만 동작한다.
  2. MERGE
    • 부모 엔티티가 병합될 때 자식 엔티티를 같이 병합한다.
    • 부모 엔티티가 갱신되거나 수정되었을 때 자식 엔티티를 병합해야 하는 경우 동작한다.
  3. REMOVE
    • 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 같이 삭제된다.
    • 데이터베이스의 ON DELETE CASCADE와 비슷하다.
    • 차이가 있다면 1개의 부모 엔티티에 대한 연관된 자식 엔티티 데이터 개수가 10개라면, 10번의 DELETE 쿼리를 날리기 때문에 그로 인한 성능 저하가 발생할 수 있다.
  4. REFRESH
    • 부모 엔티티가 새로고침 되는 시점에 자식 엔티티도 같이 새로고침 된다.
    • 데이터베이스 상태와 동기화 되도록 자식 엔티티를 업데이트 해준다.
  5. DETACH
    • 부모 엔티티가 영속성 컨텍스트로부터 분리될 때, 자식 엔티티도 분리된다.
    • '분리' 의 의미는 영속성 컨텍스트에서 관리되고 있는 상황에서 준영속 상태로 분리해 엔티티가 더 이상 영속성 컨텍스트에서 관리되지 않는 상태를 말한다.
    • 분리된 엔티티는 더이상 관리되지 않기 떄문에 변경사항이 데이터베이스에 반영되지 않는다.
    • 부모 엔티티를 준영속상태로 분리하는 경우 자식 엔티티도 같이 분리하므로 자식 엔티티의 변경사항 역시 반영되지 않는다는 의미가 된다.
  6. ALL
    • 위 모든 옵션을 사용한다.

 

이 옵션들 중 동시 저장에 영향을 끼치는 것은 PERSIST와 MERGE다.

PERSIST에는 또 다른 문제점이 하나 존재하는데 이건 다음 2번 포스팅에서 정리한다.

 

단일 엔티티 저장을 수행하는 경우 JPA는 SELECT 쿼리를 먼저 날려 이 데이터가 존재하는 데이터인지 아닌지를 먼저 확인한 뒤 새로운 데이터라면 INSERT를 날려 flush를 수행하게 된다.

기본적인 JPA 동작 방식인건데 양방향 매핑 구조에서 CascadeType.ALL을 정의하면 여기에 하위 엔티티 처리 과정이 포함되어야 한다.

 

예시 엔티티인 Member와 Auth를 기준으로 과정을 살펴보자면, 

  1. JPA는 Member 엔티티의 데이터가 존재하는 데이터인지 확인하기 위해 SELECT 쿼리를 날려본다.
  2. 새로운 데이터라는 것을 확인했기 때문에 INSERT를 처리한다.
  3. flush가 수행되는 시점에 PERSIST 옵션에 의해 하위 엔티티를 같이 처리하려고 시도한다.
  4. 하위 엔티티 데이터를 영속성 컨텍스트에서 관리하려고 시도했으나 이 데이터 역시 새로운 데이터이기 때문에 insert에 대한 처리를 MERGE 옵션을 통해 상위 엔티티인 Member의 처리와 병합해준다.
  5. 모든 Auth를 영속화했다면 flush를 마저 수행해 Member와 List<Auth>의 INSERT를 처리해 데이터베이스에 적용한다.

 

말을 다 줄이고 간단하게 보면 save(member) -> select Member -> insert Member -> flush ( PERSIST 감지 -> select Auth -> insert Auth -> MERGE ) -> 데이터 베이스 적용

이렇게 처리된다.

 

그럼 결과적으로 한번의 요청을 통해 INSERT INTO member ..., INSERT INTO auth .... 가 같이 데이터베이스에 전달되고 한번의 요청만으로 동시 저장을 처리하게 되며 MERGE로 인해 이때 처리되는 모든 엔티티를 영속성 컨텍스트에서 관리할 수 있게 된다.

 

 

CascadeType.ALL의 단점 및 주의사항

CascadeType.ALL은 편의성 측면에서 분명 좋은 선택지이다.

지금 예시처럼 단순 2개 테이블에 대한 양방향 매핑의 경우 생각하기에 따라 다를 수 있지만,

만약 상품 테이블 하위로 옵션, 썸네일 등등 여러 엔티티들이 연관관계를 맺고 있다면?

그리고 이 상품 데이터를 저장할 때 이 연관관계 엔티티들의 데이터가 항상 같이 처리되어야 한다면 한번의 요청으로 처리할 수 있게 된다는 점은 분명 좋은 이점이 된다.

 

하지만 그만큼 단점 및 주의사항이 존재한다.

대표적으로 REMOVE에 대한 것이 가장 많이 언급되는 것 같고, 

테스트를 통해 확인한 문제로는 save() 시 JOIN 문제가 있었다.

 

 

CascadeType.REMOVE 주의사항

 

 

REMOVE의 경우 잘 사용한다면 좋을 수 있지만, 그렇지 않다면 오히려 위험한 옵션으로 볼 수 있다.

CascadeType에 대해 검색해보면 주로 동시저장이 많이 나오고 주의사항으로 ALL 설정으로 인한 REMOVE의 위험성에 대해 많이 언급되는 것을 볼 수 있다.

 

REMOVE 옵션의 경우 '부모 엔티티를 삭제하는 경우 자식 엔티티를 같이 제거한다' 라는 특징을 갖고 있다.

그럼 어차피 ON DELETE CASCADE 제약조건을 정의해둔 엔티티라면 문제 없겠네? 라고 생각할 수 있다.

 

맞는말이긴 한데 주의사항이 있다.

우선 REMOVE의 경우 각 데이터들에 대한 DELETE 쿼리를 개별적으로 날리기 떄문에 성능적으로 좋지 않다.

Member 엔티티에 userId가 tester1 이라는 사용자가 있다고 가정하고 이 데이터에 대한 Auth 엔티티는 1, 2, 3, 4, 5라는 5개의 데이터가 존재한다고 가정해보자.

 

그럼 tester1에 대한 제거를 수행했을 때 아래와 같이 많은 개수의 쿼리가 발생하게 된다.

DELETE FROM member WHERE userId = 'tester1';

DELETE FROM auth WHERE id = 1;
DELETE FROM auth WHERE id = 2;
DELETE FROM auth WHERE id = 3;
DELETE FROM auth WHERE id = 4;
DELETE FROM auth WHERE id = 5;

 

그럼 tester1 데이터 하나를 지우려고 6번의 데이터베이스 커넥션이 발생하게 되는 것이다.

이로 인한 성능 저하는 요청이 쌓일수록 더 눈에 띄게 발생하게 될 것이다.

 

두번째 문제는 제약조건의 무시다.

Auth 테이블에 ON DELETE SET NULL로 제약조건을 설정해뒀다면, tester1 데이터가 삭제되더라도 Auth 데이터는 삭제되지 않고 외래키값을 null로 수정하게 된다.

 

하지만 여기서 저장에 대한 편의성을 찾기 위해 ALL을 정의했다면?

그리고 tester1을 제거하면? 그에 해당하는 auth 데이터 역시 모두 제거된다.

 

이 문제는 주의사항과 같은 결이다.

서버에서 auth 데이터에 대해 DELETE 요청이 들어오는데 제약조건은 무시되고 삭제될 수 있다는 것이다.

 

그렇기 때문에 REMOVE 혹은 ALL을 사용할때는 이런 문제가 존재한다는 점을 확실하게 인지하고, 현재 상황에 맞게 잘 고려해야 한다.

 

동시 저장을 꼭 사용해야 하며 이 문제를 회피하기 위해서는 ALL을 선언하지 않고 PERSIST와 MERGE만 사용하는 방법도 있다.

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "member")
public class Member {
    @Id
    private String userId;
    
    private String userPw;
    
    private String userName;
    
    private String nickname;
    
    @OneToMany(mappedBy = "member", cascadeType = {CascadeType.PERSIST, CascadeType.MERGE})
    private final List<Auth> auths = new ArrayList<>();
    
    public void addMemberAuth(Auth auth) {
        auths.add(auth);
        auth.setMember(this);
    }
}

 

이런식으로 { } 안에 여러 옵션을 작성하는게 가능하기 때문에 ALL을 사용하는데 무리가 있다면 필요한 옵션만 골라서 정의하면 된다.

 

 

save() 요청에서의 문제.

 

save() 요청에서의 문제라고 소제목을 정하긴 했는데 말로 표현하기가 뭔가 좀 애매해서 바로 정리한다.

우선 문제의 포인트는 단방향 매핑을 하고 있는 테이블에서 CascadeType.ALL를 정의한 엔티티를 참조하고 있다면 save() 요청 시 select 과정에서 불필요한 JOIN이 발생하는 문제다.

 

문제가 발생한 부분의 엔티티들은 아래와 같다.

 

@Entity
@Getter
//...
public class ProductQnA {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    @ManyToOne
    @JoinColumn(name = "productId")
    private Product product;
    
    //...
}

@Entity
@Getter
//...
public class Product {
    
    @Id
    private String id;
    
    //...
    
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
    private final List<ProductOption> options = new ArrayList<>();
    
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
    private final List<ProductThumbnail> thumbnails = new ArrayList<>();
    
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
    private final List<ProductInfoImage> infoImages = new ArrayList<>();
    
    //...
}

 

필요한 코드만 두고 나머지는 좀 생략했다.

 

지금 상황을 보면 ProductQnA라는 엔티티가 존재하는데 여기에서는 Member, Product 엔티티를 참조하고 있다.

Member의 경우 최초 예시처럼 Auth와 양방향 매핑이 되어 있는 상태고, 

Product의 경우 ProductOption, ProductThumbnail, ProductInfoImage 라는 엔티티들과 양방향 매핑 관계를 갖고 있다.

 

그리고 ProductQnA는 Member와 Product에 대해 단방향 매핑 관계다.

 

 

문제는 JMeter 테스트 과정에서 알게 됐다.

이상하게 ProductQnA의 POST 요청에서 지연시간이 길게 발생하고 있었다.

복잡한 비즈니스 로직도 없는데 왜 그런가 하고 로그를 확인했을 때는 예상치도 못했던 문제가 발생하고 있었다.

 

 

기본적인 JPA의 동작 방식을 생각해보면

참조하고 있는 엔티티인 member와 product에 대한 select 이후 productQnA에 대한 select 처리를 통해 새로운 데이터라고 판단해 insert를 수행하게 될 것이다.

 

하지만 로그는 그렇지 않았다.

로그에서는 Member를 조회하면서 auth를 join해서 쿼리를 날리고 있었다.

그리고 product를 조회하면서는 productOption, productThumbnail, productInfoImage를 join해서 쿼리를 날렸다.

즉, SELECT .... FROM member JOIN auth ...  ,  SELECT .... FROM product JOIN productOption ... JOIN productThumbnail ... JOIN productInfoImage

이렇게 조회 쿼리가 발생하게 되는 것이다.

 

ProductQnA 엔티티 코드를 보면 나는 지금 전혀 Auth, ProductOption, ProductThumbnail, ProductInfoImage가 필요 없는데..? 왜 조회하지?

 

이 문제의 원인은 CascadeType 옵션들 중 MERGE에 있다.

 

MERGE는 ' 병합 '을 해주는 옵션이라고 했다.

그럼 ProductQnA를 저장하는 과정에서 이 엔티티가 Member와 Product를 참조하고 있고 참조하고 있는 엔티티를 '병합' 해야 하기 때문에 그 자식 엔티티들까지 모두 같이 병합하는 것이다.

 

Member를 병합해야 하니 Auth를 같이 JOIN 해서 조회해야 하고, 

Product를 병합해야 하니 ProductOption, ProductThumbnail, ProductInfoImage를 JOIN해서 조회해야 한다.

즉, 이 JOIN은 JPA의 FETCH JOIN이 수행된 경우라고 볼 수 있다.

 

Member는 그래도 Auth만 같이 조회하고 Auth 특성 상 그래봤자 대부분 1 : 1 매핑이 더 많은 구조니까 그러려니 할 수 있었지만, Product는 달랐다.

Option의 제한도 없고, Thumbnail, InfoImage에 대한 제한도 없기 때문에 당장 더미데이터는 괜찮을지 몰라도 더 커지면 얼마나 문제가 생길지 알 수 없었다.

 

심지어 더미데이터는 Option 최대치가 10이고 Thumbnail은 7이었다. InfoImage도 5개.

그런데도 JMeter 테스트 과정에서 지연시간이 크게 발생하는데 만약 이걸 넘어서는 데이터들이 존재하고 데이터 크기도 커진다면 큰 문제가 되는 것이라고 생각했다.

 

그리고 가장 맘에 들지 않았던 것은 쓰지도 않을 엔티티를 조회한다는 것이 가장 맘에 들지 않았다.

 

동시 저장을 고집한다면 PERSIST, MERGE를 사용할 수 있겠지만, 이 문제의 원인이 MERGE라는 점 때문에 해결 방안으로 동시저장을 포기하고 각 Repository를 통한 저장으로 수정해 문제를 해결했다.

PERSIST만 사용한다는 선택지도 있지만, 그걸 사용하지 않은 이유는 다음 포스팅에서 정리한다.

 

 

마무리

이번 테스트를 통해 CascadeType과 JPA의 동작 방식에 대해 좀 더 고민하고 깊게 알아볼 수 있는 기회가 되었는데 이번 기회를 통해 동시 저장에 대해 고려해야 할 사항이 뭐가 있는지, JPA 사용에 대한 적합성을 좀 더 고민해볼 수 있는 좋은 기회였다.

 

개인 프로젝트가 아닌 운영 서버를 개발하는 과정이었다면 ALL 사용은 굉장히 민감한 문제가 될 수 있는 부분이라는 점을 알 수 있었고, 그로 인해 ALL을 사용하지 않고 동시저장을 처리하는 대안에 대해 고민해보게 되었다.

 

다음 포스팅은 그 고민에 대한 정리와 대안, 프로젝트 환경에서 발생한 문제점들에 대해 정리할 것이다.

QueryDSL이란?

하이버네이트 쿼리 언어(Hibernate Query Language, HQL)의 쿼리를 타입에 안전하게 생성 및 관리해주는 Framework다.

정적 타입을 이용해 SQL과 같은 쿼리를 생성할 수 있게 해준다.

JPA를 사용하면서 복잡한 쿼리, 동적 쿼리를 구현하는데 한계가 있는데 이런 문제점을 QueryDSL로 해결할 수 있다.

 

QueryDSL을 사용하지 않고 MyBatis, JPQL, Criteria등 문자열 기반으로 쿼리를 작성하는 방법에서는 컴파일시에 오류를 발견하는 것이 어려웠으나 QueryDSL은 자바 코드로 쿼리를 생성하기 때문에 컴파일시에 오류를 발생시켜 실수를 방지할 수 있다.

 

 

QueryDSL 설정

Gradle에 QueryDSL 의존성을 추가하는 것에 대해서는 좀 여러가지 방법을 볼 수 있었다.

프로젝트에서 사용한 방법만 정리.

 

개발환경

- Spring Boot 2.7.6

- Spring Data JPA

- Gradle

- MySQL

 

기본 세팅을 참고한 블로그

https://tecoble.techcourse.co.kr/post/2021-08-08-basic-querydsl/

 

Spring Boot에 QueryDSL을 사용해보자

1. QueryDSL PostRepository.java Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL…

tecoble.techcourse.co.kr

 

가장 먼저 gradle에 의존성을 추가한다.

....

dependencies {
    ....
    
    //QueryDSL
    implementation 'com.querydsl:querydsl-jpa'
    implementation 'com.querydsl:querydsl-apt'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}

def querydslSrcDir = '/src/main/generated'

sourceSets {
    main {
        java {
            srcDirs += [ querydslSrcDir ]
        }
    }
}

compileJava {
    options.compilerArgs << '-Aquerydsl.generatedAnnotationClass=javax.annotation.Generated'
}

tasks.withType(JavaCompile) {
    options.generatedSourceOutputDirectory = file(querydslSrcDir)
}

clean {
    delete file(querydslSrcDir)
}

...

 

대체적으로 implementation 으로 querydsl-jpa, querydsl-apt를 추가하는 것 까지는 동일했다.

하지만 annotationProcessor에서 querydsl-apt를 설정하는데서 조금씩 차이가 있고, dependencies를 제외한 다른 부분들 역시 블로그마다 조금씩 차이가 있었다.

 

gradle build를 마친 후 gradle -> tasks -> other -> compileJava를 통해 컴파일을 수행하면 Q 클래스가 생성된다.

QEntity는 프로젝트내의 build -> classes -> java -> main -> ... -> entity 안에 생성된다.

이것도 설정에 따라 차이가 발생한다.

설정에 따라 build -> generated -> sources -> ... -> entity 하위에 생성되기도 한다.

이렇게 생성된 클래스를 Q 클래스 혹은 Q(쿼리) 타입이라고 부르는데 QueryDSL로 쿼리를 작성할 때 이 Q 클래스를 사용해 작성하게 된다.

 

다음은 configuration 설정.

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Configuration
public class QueryDSLConfig {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entitymanager);
    }
}

 

 

config라고 해서 거한 설정이 있는것은 아니고 JPAQueryFactory를 사용하기 위해 Bean을 등록해준다.

 

//Repository
public interface BoardRepository extends JpaRepository<Board, Long>, BoardRepositoryCustom {
    @Query(value = "....")
    Board findByBoardNo(long boardNo);
    ....
}


//Custom Repository
public interfact BoardRepositoryCustom {
    List<Board> findAll();
}

//repositoryImpl
import static com.example.jpa.domain.entity.QBoard.board;
import com.querydsl.jpa.impl.JPAQueryFactory;

@Repository
public class BoardRepositoryImpl implements BoardRepositoryCustom {
    
    private final JPAQueryFactory jpaQueryFactory;
    
    @Override
    public List<Board> findAll() {
        List<Board> boardList = jpaQueryFactory.selectFrom(board).fetch();
        
        return boardList;
    }
}

 

이렇게 BoardRepository에서는 JPQL로 처리하도록 하고 Custom Repository를 생성해 QueryDSL을 사용하도록 했다.

 

QueryDSL을 사용하기 위해서는 Bean으로 등록한 JPAQueryFactory가 있어야 하고 Q 클래스로 쿼리를 작성하게 된다.

findAll에 작성된 쿼리는 SELECT * FROM board; 이다.

조회할 컬럼을 따로 지정하지 않고 조회한다면 selectFrom으로 Q 클래스만 담아주면 쿼리가 실행된다.

만약 특정 컬럼만 조회하겠다고 한다면 select로 작성하면 되고 Q클래스.컬럼명 형태로 사용한다.

그리고 fetch()로 마무리 해주면 된다.

Q 클래스는 static import로 사용하게 된다.

 

쿼리를 작성해야 하기 때문에 당연하게도 orderBy, offset, limit, join, where 등을 사용해 작성할 수 있다.

 

QueryDSL로 동적쿼리 수행

프로젝트를 JPA로 구현하게 되면서 조건에 따른 동적쿼리를 처리해야 했고 QueryDSL로 처리하는 방법이 가장 깔끔하고 괜찮아 보였기 때문에 선택하게 되었다.

//boardSevice

@Override
public Page<boardDTO> getBoardList(Criteria cri) {
    
    Page<boardDTO> dto;
    
    if(cri.getKeyword == null) {
        dto = boardRepository.getBoardList(
                PageRequest.of(cri.getPageNum() - 1
                    , cri.getBoardAmount()
                    , Sort.by("boardGroupNo").descending()
                        .and(Sort.by("boardUpperNo").ascending()))
        );
    }else if(cri.getSearchType().equals("t") {
        dto = boardRepository.getSearchTitleBoardList(
                cri.getKeyword()
                , PageRequest.of(cri.getPageNum() - 1
                    , cri.getBoardAmount()
                    , Sort.by("boardGroupNo").descending()
                        .and(Sort.by("boardUpperNo").ascending()))
        );
    }else if(cri.getSearchType().equals("c") {
        dto = boardRepository.getSearchContentBoardList(
                cri.getKeyword()
                , PageRequest.of(cri.getPageNum() - 1
                    , cri.getBoardAmount()
                    , Sort.by("boardGroupNo").descending()
                        .and(Sort.by("boardUpperNo").ascending()))
        );
    }else if(cri.getSearchType().equals("tc") {
        dto = boardRepository.getSearchTitleAndContentBoardList(
                cri.getKeyword()
                , PageRequest.of(cri.getPageNum() - 1
                    , cri.getBoardAmount()
                    , Sort.by("boardGroupNo").descending()
                        .and(Sort.by("boardUpperNo").ascending()))
        );
    }else if(cri.getSearchType().equals("u") {
        dto = boardRepository.getSearchUserIdBoardList(
                cri.getKeyword()
                , PageRequest.of(cri.getPageNum() - 1
                    , cri.getBoardAmount()
                    , Sort.by("boardGroupNo").descending()
                        .and(Sort.by("boardUpperNo").ascending()))
        );
    }else{
        ...
    }
    
    return dto;
}

 

이렇게 페이징 처리를 해야 하는 게시판 리스트이고 JPQL로 작성되어있었다.

처음 이 프로젝트를 진행했을때는 JPA로 처음 해보는거였고 동적쿼리는 생각도 못했고.... 단순하게 서비스단에서 조건문을 통해 하나하나 작성해 처리하도록 했었다.

 

그러다 이전에 리펙토링하면서 대부분 동적쿼리로 수정했는데 여기는 놓치고 지나가서 수정이 안된 상태였다.

 

다른 기능에서 수행하는 동적쿼리들은 대체로 삽입, 삭제에서 발생하는 동적쿼리였기 때문에 list를 받아 처리하는 정도의 간단한 쿼리라 JPQL로도 간단하게 처리할 수 있었다.

 

하지만 이번경우는 List에 대한 검색어 처리, Pageable로 인한 count 쿼리에서의 조건을 제어할 수 있는 동적쿼리여야 했기 때문에 @Query Annotation을 붙여 사용하던 방법에서는 마땅하게 해결책을 찾을 수 없었다.

@Query Annotation을 사용하지 않으면 문자열 생성으로 처리할 수 있는 방법을 찾았지만 그 경우는 뭔가 JDBC Template으로 처리하는 느낌이라 다른 방법을 써보고 싶었다.

 

 

그럼 코드를 정리하기 전 위 코드를 대강 정리해본다.

  1. keyword가 null인 경우 검색이 아닌 기본 리스트 조회이기 때문에 검색어와 검색 타입이 없는 쿼리가 실행되어야 한다.
  2. keyword가 존재한다면 searchType값이 존재할 것이기 때문에 검색 타입에 따른 where 문이 생성되어야 한다.
    1. searchType이 ' t '라면 title을 의미하기 때문에 where boardTitle LIKE :keyword가 수행되어야 한다.
    2. searchType이 ' c '라면 content를 의미하기 때문에 where boardContent LIKE :keyword가 수행되어야 한다.
    3. searchType이 ' tc '라면 title과 content를 의미하기 때문에 where boardTitle LIKE :keyword or boardContent LIKE :keyword가 수행되어야 한다.
    4. searchType이 ' u '라면 userId를 의미하기 때문에 where userId LIKE :keyword가 수행되어야 한다.
  3. 해당 리스트는 페이징 기능이 포함되어 있기 때문에 count 쿼리를 수행해야 하고 Page 타입으로 리턴되어야 한다.
  4. Entity가 아닌 DTO에 매핑되어 반환되어야 한다.

 

//service

@Override
public Page<BoardDTO> getBoardList(Criteria cri) {
    Pageable pageable = PageRequest.of(cri.getPageNum() - 1
                                       , cri.getBoardAmount()
                                       , Sort.by("boardGroupNo").descending()
                                           .and(Sort.by("boardUpperNo").ascending()));
    
    return boardRepository.findAll(cri, pageable);
}


//boardRepository
public interface BoardRepository extends JpaRepository<Board, Long>, BoardRepositoryCustom {
    @Query(value = "....")
    Board findByBoardNo(long boardNo);
    ....
}


//custom Repository
public interface BoardRepositoryCustom {
    Page<BoardDTO> findAll(Criteria cri, Pageable pageable);
}


//custom Repository impl
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.data.support.PageableExecutionUtils;

import static com.example.jpa.domain.entity.QBoard.board;

@Repository
@RequiredArgsConstructor
public class BoardRepositoryCustomImpl implements BoardRepositoryCustom {
    
    private final JPAQueryFactory jpaQueryFactory;
    
    @Override
    public Page<BoardDTO> findAll(Criteria cri, Pageable pageable) {
        //1. 리스트 조회
        List<BoardDTO> list = jpaQueryFactory.select(
                       Projections.fields(
                           BoardDTO.class
                           , board.boardNo
                           , board.boardTitle
                           , board.member.userId
                           , board.boardDate
                           , board.boardIndent
                       )
                   )
                   .from(board)
                   .where(
                       searchTypeEq(cri.getSearchType(), cri.getKeyword()
                   )
                   .orderBy(board.boardGroupNodesc())
                   .orderBy(board.boardUpperNo.asc())
                   .limit(cri.getBoardAmount())
                   .fetch();
        
        //2. count 쿼리
        JPAQuery<Long> count = jpaQueryFactory.select(board.countDistinct())
                   .from(board)
                   .where(
                       searhTypeEq(cri.getSearchType(), cri.getKeyword())
                   );
        
        return PageableExecutionUtils.getPage(list, pageable, count::fetchOne);
    }
    
    private BooleanExpression searchTypeEq(String searchType, String keyword) {
        if(searchType == null)
            return null;
        else if(searchType.equals("t"))
            return hierarchicalBoard.boardTitle.like(keyword);
        else if(searchType.equals("c"))
            return hierarchicalBoard.boardContent.like(keyword);
        else if(searchType.equals("tc"))
            return hierarchicalBoard.boardTitle.like(keyword).or(hierarchicalBoard.boardContent.like(keyword));
        else if(searchType.equals("u"))
            return hierarchicalBoard.member.userId.like(keyword);
        else
            return null;
    }
}

 

Repository 구조에 대해서는 위에서 설명했으니 생략.

Criteria의 경우 페이징에 필요한 페이지 번호, 페이지당 출력할 데이터 개수, 검색어 등을 담고 있는 직접 만들어준 클래스다.

 

수행하는 과정을 간단하게 보면 list 조회 후 count 쿼리, PageableExecutionUtils.getPage를 통해 결과를 반환한다.

 

일단 list는 Entity에 담는것이 아니라 DTO에 담아야 한다고 했다.

DTO에 담기 위해서는 Projections.fields()를 사용하면 된다.

이때 fields가 아닌 bean, constructor를 통한 사용도 가능하다.

bean()을 사용하는 경우 getter, setter, constructor가 모두 있어야 한다.

fields()를 사용하는 경우에는 getter, setter가 필요없이 바로 주입되며, constructor()를 사용하는 경우에는 의미 그대로 생성자를 통해 주입하게 된다.

또 다른 방법으로 DTO역시 Q 클래스를 생성하도록 해 처리하는 방법도 있다.

DTO의 생성자에 @QueryProjection Annotation을 붙여주게 되면 해당 DTO 역시 Q 클래스가 생성되기 때문에 static import로 주입해 사용할 수 있다.

 

이 내용은 아래 블로그에서 참고해 처리했다.

https://doing7.tistory.com/m/129

 

[Querydsl] 튜플이나 DTO로 결과 반환하기

프로젝션 : select 대상지정하는 일 프로젝션 대상이 두개 이상이라면 튜플이나 DTO로 조회해야한다. 🌱 튜플 사용하기 com.querydsl.core.Tuple를 사용하고 있다. 때문에 Repository 계층을 넘어서 Service나

doing7.tistory.com

 

DTO에 결과를 주입하기 위해서는 코드에서 보이는 것과 같이 담아줄 DTO.class를 첫번째 인자로 담아줘야 하고, 그 다음부터는 DTO 필드에 주입할 필드들을 작성해주면 된다.

JPA를 쓰는거니까 Q 클래스를 그냥 써주면 알아서 해주지 않을까? 라는 기대를 하며 board 만 넣어봤지만 오류가 발생하지 않을뿐 모든 필드값이 null로 나왔다..ㅎㅎ

모든 필드를 작성해야 하는 만큼 selectFrom이 아닌 select로 작성했기 때문에 from을 붙여줘야 한다.

 

where문의 경우 searchTypeEq이라는 BooleanExpression 타입의 메소드를 호출하는 것을 볼 수 있다.

QueryDSL은 null인 경우 null로 처리하는 것이 아니라 아예 쿼리에 포함하지 않는다고 한다.

그럼 searchType이 null인 기본 조회라면 null이 반환되어 where 문이 생성되지 않고 처리되며, 그게 아닌 경우 각 타입에 맞는 like 구문이 반환되어 where ~~~ LIKE :keyword가 수행되게 된다.

 

그럼 LIKE가 아닌 where boardNo = ? 같은 것은 아래와 같이 처리할 수 있다.

......
.where(
    commentBoardEq(boardNo)
    , commentImageEq(imageNo)
)
....
}


private BooleanExpression commentBoardEq(String boardNo) {
    if(boardNo == null)
        return null;
    
    return comment.board.boardNo.eq(Long.parseLong(boardNo));
}

private BooleanExpression commentImageEq(String imageNo) {
    if(imageNo == null)
        return null;
    
    return comment.image.imageNo.eq(Long.parseLong(imageNo));
}

 

댓글 리스트를 조회하면서 board에 속하는지 image에 속하는지를 구분하는 코드다.

위 처럼 처리하게 되면 조건에 따라 where boardNo = ? 같은 형태로 처리되게 된다.

이 경우와는 다르게 둘다 존재할 수 있는 상황이라면 and 조건으로 처리하게 된다.

 

 

다음은 count 쿼리다.

count 쿼리는 Pageable이 알아서 처리하게 둘 수도 있지만 대용량 데이터의 경우 count 쿼리역시 제어해서 직접 사용하는 것이 좋다.

어느 포스팅에서 봤는지 찾을 수 없어 남기진 못하지만 count 쿼리 역시 직접 작성해 제어하는 것이 최적화 하기 더 좋다는 글도 있었다.

 

count 쿼리를 보면 whre 문까지만 처리하고 fetch는 붙이지 않은것을 볼 수 있다.

이유는 PageableExecutionUtils.getPage를 통해 count 쿼리를 수행하기 때문이다.

PageableExecutionUtils.getPage()는 리스트가 첫 페이지에 담겨야 하는 양보다 적거나 마지막 페이지인 경우 count 쿼리를 수행하지 않도록 최적화 해준다.

캐싱처리만큼 완벽한 최적화는 아니지만 간단하게 사용하기에는 좋은 것 같다.

 

 

 

간단하게 다시 정리.

쿼리 작성은 JPAQueryFactory를 통해 작성한다.

Q 클래스는 import static으로 가져온다.

동적 쿼리를 위해 BooleanExpression을 사용한다. QueryDSL의 where에서는 null을 무시하고 아예 담아주지 않는다.

PageableExecutionUtils.getPage()는 list의 size()가 한 페이지에 담겨야 하는 개수보다 작거나 마지막 페이지인 경우에는 count 쿼리를 수행하지 않도록 최적화 해준다.

DTO에 담아주기 위해서는 DTO의 생성자에 @QueryProjection이라는 Annotation을 붙여주거나 select 구문 안에 Projections.fields(DTO.class, field, ....) 로 작성한다.

이때 fields말고도 bean(), constructor()로도 가능하지만 주입되기 위한 조건이 존재한다.

 

 

마지막으로 CASE WHEN을 사용하는 방법!

댓글의 경우 delete 요청이 들어오게 되었을 때 삭제하지 않고 상태값을 변경해 삭제된것 처럼 보이도록 하고 있다.

그래서 조회시 댓글 내용을 '삭제된 댓글입니다'로 보이도록 하고 있다.

이걸 서버에서 코드로 변경하는 것이 아닌 데이터베이스부터 받을때부터 그렇게 받을 수 있도록 CASE WHEN을 사용하고 있는데 QueryDSL에서는 아래와 같이 사용한다.

 

....
Projections.fields(
commentDTO.class
, comment.commentNo
, comment.member.userId
, comment.commentDate
, new CaseBuilder()
.when(comment.commentStatus.gt(0))
.then("삭제된 댓글입니다.")
.otherwise(comment.commentContent)
.as("commentContent")
, comment.commentGroupNo
.....
)

 

이렇게 new CaseBuilder를 통해 처리하면 된다.

when에 조건문이 들어가게 되는데 commentStatus의 기본값이 0이고 삭제된 값이 1이기 때문데 gt(0)가 true인 경우 then이 수행되고 아닌경우 otherwise가 수행된다.

 

 

JPA의 동적 쿼리는 JPQL, Specification으로도 처리할 수 있지만 이래저래 찾아본 바로는 아무래도 QueryDSL이 좀 더 깔끔하고 편하게 사용할 수 있는 것 같다.

컴파일 단계에서 오류를 잡아 실수를 줄일 수 있다는 점도 큰 장점인 것 같다.

개발 공부를 시작하고 프로젝트를 진행하면서 보통 VO 하나만으로 처리를 많이 해왔었다.

처음에는 MyBatis로 처리했었고 처음 공부하던 당시에는 VO나 DTO나 같은거 아닌가 정도로만 이해했었다.

둘다 데이터베이스 테이블과 동일한 구조로 만들어주고 요청한 데이터를 받거나 데이터를 담아 데이터베이스에 요청을 하는 정도만 생각했고 그렇게만 사용했기 때문이었다.

 

그러나 JPA를 공부하게 되면서 Entity라는 것에 대해 알게되면서 조회 요청에 대해 DTO로 받아야 하는 경우를 보게 되었고, 그때부터 VO, DTO, Entity는 다른건가? 라는 생각을 하게 되었다.

 

그리고 그에 대해 알아보면서 개념을 좀 정리하게 되어 이렇게 기록을 남기게 되었다.

 

일단, 보통 DTO와 VO에 대해 검색해보면 데이터를 전달하는 객체로 크~게 본 개념에서는 동일한 개념이지만 차이가 있었다.

 

 

DTO

DTO(Data Transfer Object)는 Transfer의 의미 그대로 데이터를 전송하는 객체를 말한다.

그래서 데이터를 계층간 전달하는 역할을 한다.

역할을 확실히 분리하기 위해 DTO에서는 데이터를 담고 빼는것. 즉, getter/setter만 처리하고 비즈니스 로직이 포함되어서는 안된다.

첫줄에서의 의미 그대로 '데이터를 담아 다른 계층에 전송'하는 용도 딱 그만큼만 사용하는 것이다.

 

VO

VO(Value Object)는 의미대로 값 자체를 표현하는 객체이다.

값 자체를 표현하기 때문에 불변의 객체로서 역할을 수행해야 하며, 그렇기에 getter는 갖지만 setter는 가질 수 없다.

DTO와는 다르게 데이터를 담기만 하는것이 아니라 값 자체를 표현하기 때문에 비즈니스 로직을 포함할 수 있다.

 

 

Entity

Entity는 데이터베이스와 직접적으로 매핑되는 클래스이다.

직접적으로 매핑되는 클래스이다 보니 테이블과 구조가 동일하다.

Entity는 데이터베이스와 직접적으로 매핑이 되다보니 계층간 전달 목적으로 사용해서는 안된다.

또한, 비즈니스 로직을 포함할 수 있다.

 

 

 

여기저기 알아보며 정리한 내용은 이정도 이다.

그래서 현재 사용하는 방법은 아래처럼 사용하는중.

 

Entity

  매핑되는 테이블과 동일한 구조로 생성. Entity 데이터를 insert, update 해야 하는 경우에는 Entity를 통해 데이터베이스에 요청을 보내 처리하지만 그 외에는 사용하지 않는다.

 

DTO

  데이터 조회시 Entity와 동일한 구조이더라도 왠만하면 DTO를 통해 데이터를 받도록 하고 결과를 DTO로 리턴한다.

 

VO

  막상 이렇게 분리해야 한다고 알고 나니까 아직은 사용할 프로젝트가 없어서 사용해보질 못했다...

RESTAPI를 만들어보려고 기존 프로젝트를 리펙토링하면서 api 서버에 접근하기 위해 WebClient를 사용해보게 되었다.

선택지는 HttpURLConnection, WebClient, RestTemplate 이 세가지가 있었다.

 

검색했을 때 가장 많이 볼 수 있었던것은 RestTemplate이었지만 Spring 5.0부터는 WebClient를 사용하도록 권장하고 있기 때문에 WebClient로 결정하게 되었다.

'RestTemplate이 Deprecated되었다' 라고 많이 볼 수 있었는데 아예 Deprecated 된 것은 아니었다.

Spring 깃에서 20년 2월 11일자 이슈를 보면

이런 내용을 확인할 수 있다.

'RestTemplate 향후 사용 중단 가능성에 대해 언급하는것 보다는 유지관리 모드에 있음을 설명하는것이 더 유용하고 정확하다.' 라는 내용이다.

 

즉, Deprecated 된 것이 아니라 유지보수만 하겠다는 것이다.

직접 찾아보진 못했지만 스프링에서 직접적으로 Deprecated를 언급한 적이 있다고 한다.

그 날짜가 20년 2월 12일인데

'As of 5.0, the non-blocking, 

reactive org.springframework.web.reactive.client.WebClient offers a modern alternative to the RestTemplate with efficient support for both sync and async, as well as streaming scenarios.
The RestTemplate will be deprecated in a future version and will not have major new features added going forward.
See the WebClient section of the Spring Framework reference documentation for more details and example code.'

이런 내용이었다.

아마 이 내용으로 인해 Deprecated 된다는 소문이 퍼지면서 저 이슈를 다시 작성하지 않았을까 싶다.

 

중요한건 지금도 RestTemplate은 Deprecated되지 않았고 아직 언급이 없다는 점이다.

그럼에도 WebClient로 해본 이유는 '스프링에서 권장하고 있으니 먼저 써보자 '라는 생각이었다.

물론 RestTemplate도 Deprecated가 아직은 될 예정이 없으니 써볼 예정.

 

HttpURLConnection은 결과값을 받아올 때 Stream으로 직접 하나하나 처리해야 하고 이것저것 설정해야 하는 게 많아 보여 일단은 가장 마지막에.....

 

 

WebClient란

웹으로 API를 호출하기 위해 사용되는 Http Client 모듈 중 하나이다.

 

WebClient는 Non-blocking 방식이다.

Non-blocking이라고 해서 비동기(Asynchronous)를 의미하는 것은 아니다.

크게 보자면 Non-blocking은 요청하고 딴일 하다가 응답이 오면 결과를 처리하는 방식이고

비동기 역시 요청 후 딴일 하다가 응답이 오면 처리하는 방식이다.

 

하지만 조금만 깊게 들어가면 비동기방식은 conneciton이 끊어지고 서로간에 이벤트를 통해 통신하는 방식으로 요청자와 제공자 사이에서 Message Broker라는 서비스가 중계해주게 된다.

 

참고했던 블로그 포스팅에서는 동기(Synchronous)와 비동기(Asynchronous)를 이렇게 정리해주셨다.

  • 호출'된' 함수의 수행결과 및 종료를 호출'한' 함수가(호출된 함수뿐 아니라 호출한 함수도 함께) 신경을 쓰고 있다면 Synchronous
  • 호출'된' 함수의 수행결과 및 종료를 호출'된' 함수 혼자서만 직접 신경쓰고 처리한다면 Asynchronous이다.

그럼 blocking과 Non-blocking은?

  • 호출'된' 함수가 자신이 할 일을 모두 마칠때까지 제어권을 갖고 호출'한' 함수에게 돌려주지 않는다면 block
  • 호출'된' 함수가 자신이 할 일을 마치지 않았지만 바로 제어권을 건네주어(return) 호출'한' 함수가 다른 일을 진행하도록 해주면 Non-block이다.

처음 이 포스팅을 보면서는 뭔가 말장난인가... 싶은 느낌도 있었다.

좀 정리를 다시 해보면 blocking과 Non-blocking은 제어권을 어디에서 갖고 있느냐에 따른 구분이기 때문에 요청 후 다른 처리를 하느냐 마느냐의 차이이고,

synchronous과 Asynchronous는 요청자가 처리를 계속해서 신경을 쓰고 있느냐 안쓰고 있느냐 라는 개념으로 이해하면 좀 편하다.

 

좀 더 상세하게 정리를 해주셨으니 참고.

https://happycloud-lee.tistory.com/220

 

Spring WebClient 쉽게 이해하기

1. Spring WebClient 이해 이 글을 읽기 전에 먼저 일하는 방식 변화를 이끌고 있는 애자일, 마이크로서비스, 데브옵스, 클라우드에 대해 기본적인 이해를 하실것을 권장 합니다. https://happycloud-lee.tisto

happycloud-lee.tistory.com

 

 

WebClient 사용 코드

일단 WebClient를 적용해보면서 가장 많이 보게 된 사이트는 baeldung이었다.

 

https://www.baeldung.com 

 

Baeldung

In-depth, to-the-point tutorials on Java, Spring, Spring Boot, Security, and REST.

www.baeldung.com

 

WebClient뿐만 아니라 REST, Security, OAuth 등등 여러가지가 정리되어있어 참고하기 좋았다.

이번에는 사용예제가 정리된 블로그 포스팅보다는 아무래도 여기가 더 도움이 많이 되었다.

 

프로젝트는 기본 게시판 형태로 이미지파일을 업로드할 수 있는 게시판과 일반 게시판 두가지를 갖고 있는 작은 프로젝트다.

 

환경은 아래와 같다.

  • Spring boot
  • Gradle
  • JDK 1.8
  • JWT
  • Spring Data JPA

API서버와 Client 서버를 분리해서 프로젝트를 진행했고 한대의 데스크탑에서 처리했기 때문에

API서버의 경우 localhost:9095, Client 서버의 경우 localhost:8080으로 포트만 다르게 해서 진행했다.

 

WebClient 사용 예제는 아래처럼 정리한다.

  1. WebClient 사용법(인스턴스 생성)
  2. get(), post()요청 및 Query String, PathVariable, Multipart

 

일단 WebClient를 사용하기 위해서는 Dependency를 추가해야 한다.

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-webflux 

    implementation 'org.springframework.boot::spring-boot-starter-webflux:2.7.6'

 

WebClient의 가장 기본적인 사용법

import org.springframework.web.reactive.function.client.WebClient;

....
	String response = WebClient.builder()
            .baseUrl("http://localhost:9095")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .build()
                .get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                        .queryParam("pageNum", 1)
                        .build())
                .retrieve()
                .bodyToMono(String.class)
                .block();

이 코드는 API서버에 board-list를 요청한 경우인데 WebClient. ~~ .build()까지가 WebClient를 설정하는 부분이다.

그리고 build다음으로 .get()이 요청 형태 그 뒤로 uri와 응답받는 타입 등의 요청에 대한 설정을 하고 block()으로 마무리해준다.

분리해서 아래처럼 처리할 수도 있다.

 

import org.springframework.web.reactive.function.client.WebClient;

...
    
    WebClient client = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .build();
                    
    String response = client.get()
                        .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                                .queryParam("pageNum", 1)
                                .build())
                        .retrieve()
                        .bodyToMono(String.class)
                        .block();

 

이런식으로 WebClient 인스턴스를 생성하고 그걸 가져다 사용하는 형태로 사용할 수 있다.

또한 아무런 설정도 없이 인스턴스만 생성하는 것이 가능하다.

 

WebClient client1 = WebClient.create();
WebClient client2 = WebClient.create("http://localhost:9095");

 

이렇게 아주 간단하게만 생성해두고 나머지는 이 인스턴스를 사용할 때 속성을 추가해서 상황에 맞게 사용할 수도 있다.

이번 프로젝트는 굳이 이렇게 막 여러 상황에 대한 처리를 할만한게 없었어서 이렇게 간단하게 생성하는 방법으로는 하지 않았고 서비스별로 나눠서 작성했기 때문에 WebClientConfig라는 컴포넌트를 만들어 분리해서 사용했다.

 

//WebClientConfig
import org.springframework.stereotype.Component;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;

@Component
public class WebClientConfig {
    
    public WebClient useWebClient() {
        
        WebClient webClient = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .build();
        
        return webClient;
    }
}


//service

...

@Service
@RequiredArgsConstructor
public class HierarchicalBoardWebClient {
    
    private final WebClientConfig webClientConfig;
    
    public HierarchicalBoardListDTO getHierarchicalBoardList(Criteria cri) 
                                            throws JsonProcessingException {
        
        WebClient client = webClientConfig.useWebClient();
        
        String response = client.get()
                            .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                                    .queryParam("pageNum", cri.getPageNum())
                                    .queryParam("amount", cri.getBoardAmount())
                                    .build())
                            .retrieve()
                            .bodyToMono(String.class)
                            .block();
        
        ...
    }
    
    ...
}

 

하나의 API서버에만 요청하고 받고 처리하기 때문에 기본적인 baseUrl은 9095까지 적어주어 서비스단에서 사용할때는 필요한 위치의 요청만 작성하면 되도록 했다.

이렇게 하면 모든 서비스단에서 WebClient 인스턴스를 생성할때 매번 설정하지 않아도 되니 코드 중복도 해결되고 간단하게 처리할 수 있다.

 

 

get(), post() 요청 및 Query String, PathVariable, Multipart

각 요청을 어떻게 하는지에 대해 정리한다.

API서버에 요청을 하는 방식은 get, post, patch, delete 이렇게 구분할 수 있다.

get, post, put, delete 이렇게 처음에는 배우긴 했는데 put은 리소스 전체를 업데이트할때, patch는 일부를 업데이트할때 사용하는것으로 현재 프로젝트의 경우 update처리에 대해서 전체를 업데이트하는 경우는 전혀 없기 때문에 patch만 사용했다.

근데 put이나 patch나 요청하는 방법에 대해서는 별반 차이가 없어서 큰 문제는 안될것으로 보인다.

복잡하게 요청하는 경우라면 좀 다를수도 있겠지만...

 

get 요청에서 많이 사용하는 방법이 Query String과 PathVariable 이 두가지가 있다.

아무런 매개변수가 없이 get 요청만 보내서 받는 경우도 있지만 그렇지 않은 경우도 있다.

프로젝트에서 게시판은 페이징 기능을 처리하기 때문에 쿼리스트링이 필요한 경우가 있었고,

게시판 내용을 보는 상세페이지에서는 글번호를 PathVariable로 받아 처리했기 때문에 필요했다.

 

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
...

@Service
@RequiredArgsConstrucotr
public class HierarchicalBoardWebClient {

    private final WebClientConfig webClientConfig;
    
    public HierarchicalBoardListDTO getHierarchicalBoardList(Criteria cri)
                                    throws JsonProcessingException {
        WebClient client = webClientConfig.useWebClient();
        
        // QueryString & header
        String response1 = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                        .queryParam("pageNum", cri.getPageNum())
                        .queryParam("amount", cri.getBoardAmount())
                        .build())
                .header("headerName", "headerValue")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        //아무것도 없이 그냥 get요청 하는 경우
        String response2 = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list").build())
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        
        ObjectMapper om = new ObjectMapper();
        
        om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        
        HierarchicalBoardListDTO dto;
        
        dto = om.readValue(response1, HierarchicalBoardListDTO.class);
        
        dto.setPageDTO(new PageDTO(cri, dto.getTotalPages()));
        
        return dto;
    }
    
    public HierarchicalBoardDetailDTO getHierarchicalBoardDetail(long boardNo)
                                                     throws JsonProcessingException {
        WebClient client = webClientConfig.useWebClient();
        
        //PathVariable & cookie
        String response = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-detail/{boardNo}")
                        .build(boardNo))
                .cookie("cookieName", "cookieValue")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        ObjectMapper om = new ObjectMapper();
        
        HierarchicalBoardDetailDTO dto = om.readValue(responseVal, HierarhicalBoardDetailDTO.class);
        
        return dto;
    }
}

위 메소드는 게시판 리스트 요청, 아래 메소드는 상세페이지 데이터 요청이다.

게시판 리스트 요청에서 사실 pageNum과 amout없이 넘기는 일은 없긴한데 쿼리스트링을 쓸때랑 안쓸때를 비교하기 위해 나눠두었다.

쿼리스트링을 사용하는 경우 path() 뒤로 queryParam()으로 추가를 해주면되고 하나하나 추가하기 귀찮다면

MultiValueMap 타입으로 정리해 queryParams()로 보내는 방법도 있다.

위 코드에 보이는대로 queryParam의 경우 (name, value)로 작성해 요청을 보내면 된다.

 

아래 메소드에서는 PathVariable로 받는 경우인데 요청 uri의 경우 그대로 작성을 해주고 build()에 값을 넣어 넘겨주는 형태로 처리한다.

 

더 다양하고 복잡한 속성들도 존재하지만 기본적인 요청에 대해서는 이렇게 처리하는것으로 응답을 받을 수 있다.

 

이 프로젝트에서는 JWT로 인증, 인가 처리를 하도록 했는데 그것때문에 header, cookie 옵션 역시 기록한다.

JWT의 경우 요청헤더에 담아 받거나 쿠키에 담아 받는데 클라이언트 서버에서 API 서버로 요청할때 위 처럼 보내줄 수 있다.

굳이 JWT가 아니더라도 cookie나 RequestHeader에 담아 보내야 하는 데이터가 있다면 위 처럼 보내주면 된다.

보이는 그대로 보내주면 되고 JWT라고 가정한다면

.header("Authorization", "tokenValue")  ||  .cookie("Authorization", "tokenValue")

이런 형태로 담아서 보내주면 된다.

header와 cookie 속성 역시 하나만 보낼 수 있는것이 아니기 때문에 아래에 더 추가해서 보낼 수 있고

역시 headers와 cookies로 MultiValueMap으로 처리해 보낼 수 있다.

map으로 보내게 된다면 queryParam과는 조금 다르게 처리해야 하는데 consumer를 통해 보내야 한다.

consumer는 이번에 처음 봤는데 함수적 인터페이스로 단지 매개값을 소비하는 역할만을 한다고 한다.

 

 

 

다른 요청을 정리하기 전에 남은 속성들과 코드를 정리.

 

retrieve()는 ResponseEntity를 받아 디코딩하는 경우에 사용한다.

이 위치에 retrieve() 대신 exchange()를 사용할 수 있는데 exchange는 retrieve보다 더 많은 기능을 제공하고 모든 시나리오에서 application이 직접 ResponseBody를 consume해야 한다.

요약하면 retrieve() 보다 exchange()를 사용하는게 더 복잡하다.

그렇기때문에 exchange()를 사용해서 응답코드나 응답헤더를 봐야한다거나, 아니면 직접 응답을 consume해야 한다거나 하는 특별한 이유가 없다면 retrieve()를 사용해서 처리하면 된다.

 

 

bodyToMono()에서는 응답을 받는 타입을 설정해준다.

bodyToMono()말고 bodyToFlux()도 있는데 Mono의 경우 0 ~ 1개의 결과를 처리하는 경우에 사용하고

Flux는 0 ~ N개의 결과를 처리하는 경우에 사용하면 된다.

 

프로젝트를 진행하면서는 JSON으로 다 정리된 상태로 받았기 때문에 Mono만 사용해 처리해봤고 대부분 String으로 받아와서 처리했다.

받는 타입은 크게 상관이 없는데 응답으로 넘어오는 데이터와 동일한 DTO가 존재한다면 해당 DTO.class로 받을 수도 있다.

또한 리턴 결과를 Long 타입으로 처리하는 경우도 있었어서 Long으로 받아서 처리한 경우도 있다.

 

마지막 block()은 blocking을 하겠다 라고 보면된다. 즉, 완전한 비동기 방식이 아니다.

처음에 Non-blocking과 Asynchronous의 차이를 설명한 이유이다.

block()을 설정하게 되면 결국 결과가 반환될때까지 기다렸다가 다음으로 넘어가게 된다.

근데 이렇게 사용한 이유는 프로젝트에서는 그냥 결과값을 매핑해 응답을 반환하는 형태라서 크게 문제가 되지 않기 때문이다.

하지만 만약 결과값을 다시 어떠한 알고리즘에 처리하는 과정이 추가가 된다면 비동기라고 부를수가 없다.

block()말고도 toStream()이라는 속성도 있는데 이 속성 역시 마찬가지로 대기하게 되므로 문제 해결이 되지 않는다.

이걸 완전한 비동기 방식으로 사용하려면 subscribe() 속성을 사용하면 되는데 이걸 사용하게 되면 요청 후 응답이 오지 않았더라도 비동기 처리로 다음 요청을 수행하게 되고 응답이 도착하면 그에 대한 처리를 수행하게 된다.

subscribe()의 경우 사용해보지 않아서 잘 정리해주신 블로그를 아래에..

 

https://tecoble.techcourse.co.kr/post/2021-10-20-synchronous-asynchronous/

 

동기와 비동기 with webClient

‘여기서 만나’ 프로젝트를 진행하면서 초반에는 RestTemplate을 통하여 외부 API와 데이터를 주고받았다. 이후 WebClient가 비동기 방식으로 쓰여 더 좋다고 하여 WebClient로 변경했다. 그리고 이전 글

tecoble.techcourse.co.kr

 

남은건 요청이 아닌 응답 데이터를 처리해주는 ObjectMapper이다.

응답받은 json을 dto로 파싱해주는 방법은 다양한데 가장 많이 보이기도 하고 편하게 파싱이 되는 ObjectMapper를 사용했다.

ObjectMapper의 경우 .readValue(응답데이터, 원하는DTO.class) 이렇게 작성하는것으로 간단하게 파싱이 가능하다.

그러나 만약 리턴되는 데이터가 상황에따라 굳이 DTO에는 안들어가도 되는 경우가 있을 수 있다.

이 프로젝트의 경우는 그게 페이징 데이터였다.

API서버에서 게시판 리스트를 가져올때 Page 타입으로 데이터를 꺼내 그걸 리턴해주도록 했는데 그러다보니 불필요한 페이징 데이터가 많았다. 중복되는 데이터도 많았고.

그래서 클라이언트 DTO에는 그런 데이터들을 아예 작성하지 않았다. 쓸일도 없었기 때문에.

물론 API 서버에서 미리 걸러내서 응답해준다면 더욱 좋겠지만 아쉽게도 JPA는 아직 한창 공부중이라서 방법을 찾을 수 없었고 해결하지 못해 클라이언트서버에서 해결해야 했다.

겸사겸사 상황에 따라 다른 DTO에 받는다면 이런 경우도 있겠지 하면서 사용하게 되었다.

 

그 설정은 .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 이다.

파싱하고자 하는 json 데이터에는 존재하지만 이 데이터를 정리할 dto에는 해당 필드가 존재하지 않는다면 그것을 무시하고 존재하는 필드에만 파싱을 하도록 하는 설정이다.

이 설정을 해주지 않고 그냥 처리한다면 Exception이 발생한다.

사용해보진 않았지만 비슷한 옵션으로 null 값을 무시하는 옵션도 있다.

.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false); 이다.

이 옵션들 둘다 true로 설정하면 json데이터가 모두 dto에 들어갈 수 있어야 하며 null값도 있으면 안된다.

당연히 옵션 설정 따로 안하면 default가 true이다.

 

 

그럼 다음은 post요청이다.

post 요청의 경우 DTO에 데이터를 담아 요청하는 방법과 Multipart를 담아 요청하는 두가지 방법이 있다.

다른 방식으로 요청을 보내는 케이스는 따로 찾아볼 수 없어서 이 두가지 방법만 사용해봤다.

 

일단은 기본적인 post 요청을 먼저 정리.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
...

@Service
@RequiredArgsConstrucotr
public class HierarchicalBoardWebClient {

    private final WebClientConfig webClientConfig;
    
    public long boardInsert(HttpServletRequest request, HttpServletResponse response) {
        
        WebClient client = webClientConfig.useWebClient();
        
        HierarchicalBoardDTO dto = HierarchicalBoardDTO.builder()
                    .boardTitle(request.getParameter("boardTitle"))
                    .boardContent(reqeuest.getParameter("boardContent"))
                    .build();
                    
        /*
            Mono<HierarchicalBoardDTO> dto2 = HierarchicalBoardDTO.builder()
                        .boardTitle(request.getParameter("boardTitle"))
                        .boardContent(request.getParameter("boardContent"))
                        .build();
        */
        
        return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .body(Mono.just(dto), HierarchicalBoardDTO.class)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        /*
            return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .body(dto2, HierarchicalBoardDTO.class)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        */
        
        /*
            return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .bodyValue(dto)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        */
    }
}

post 요청의 경우 이런 형태로 요청할 수 있다.

return에서 보면 주석포함 총 3가지 방식으로 리턴하고 있는데, 모두 사용할 수 있는 방법이다.

차이를 보자면 body에 어떻게 담아서 처리하는지, 아니면 bodyValue를 사용해 처리하는지의 차이다.

body를 활용하는 경우는 비동기 타입으로 인코딩해야 하는 경우이다.

dto만 먼저 보면 HierarchicalBoardDTO 타입으로 생성했다.

이걸 Mono<HIerarchialBoardDTO>로 만들어주는것이 Mono.just(dto) 부분이다.

그 뒤에는 elementClass를 넣어주어 body를 작성하게 된다.

그래서 애초에 Mono<HierarchicalBoardDTO> 로 만들어준 dto2는 Mono.just로 처리해줄 필요가 없어진다.

이걸 이해하기 위해서는 WebFlux를 이해해야 하고 Reactive Programming을 이해해야 한다.

근데 이게 좀 어려워서... 아직 정리가 안된상태...

 

bodyValue()의 경우는 비동기타입이 아닌 실제 값인 객체를 갖고 있는 경우 사용한다.

사실상 dto의 경우는 body로 인코딩을 해서 보낼 필요없이 bodyValue()로 보내주면 된다.

 

깊은 내용없이 간단하게 정리하자면 비동기타입이 아닌 실제값(객체)를 보낼것이라면 bodyValue(),

그렇지 않고 비동기타입(Mono, Flux 등)을 보낼것이라면 body()를 사용하면 된다.

그리고 이걸 이해하고 제대로 사용하기 위해서는 WebFlux를 이해해야 한다.

 

patch(), delete(), put() 요청은 post()와 거의 동일하기 때문에 따로 정리하지 않는다.

 

 

그럼 마지막으로 Multipart.

 

WebClient에서는 메모리문제를 피하기 위해 기본 in-memory buffer 값이 256KB로 설정이 되어있다.

256KB 이하의 파일만 처리를 할것이라면 굳이 손댈 필요 없겠지만 그렇지 않다면 따로 설정을 해줘야 한다.

그래서 WebClientConfig에 Bean을 추가했다.

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;

@Component
public class WebClientConfig {
    
    public WebClient useWebClient() {
        ....
    }
    
    public WebClient useImageWebClient() {
        
        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                        .codecs(clientCodecConfigurer -> clientCodecConfigurer.defaultCodecs()
                                .maxInmemorySize(20 * 1024 * 1024)).build();
        
        WebClient webClient = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .exchangeStrategies(exchangeStrategies)
                    .build();
        
        return webClient;
    }
}

이렇게 ExchangeStrategies를 통해 maxInmemorySize를 늘려주고 WebClient를 생성할때 속성을 넣어 설정해주면 문제가 해결된다.

 

요청은 아래처럼 처리한다.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.reactive.function.BodyInserters;
....

@Service
@RequiredArgsConstructor
public class ImageBoardWebClient {
    
    private final WebClientConfig webClientConfig;
    
    public long imageBoardInsert(String imageTitle
                                 , String imageContent
                                 , List<MultipartFile> files) {
        
        WebClient client = webClientConfig.useImageWebClient();
        
        //요청 데이터를 담아줄 bodyBuilder
        MultipartBodyBuilder mbBuilder = new MultipartBodyBuilder();
        
        for(int i = 0; i < files.size(); i++)
            mbBuilder.part("files", files.get(i).getResource()); //이미지 파일
        
        mbBuilder.part("imageTitle", imageTitle);     //게시글 제목
        mbBuilder.part("imageContent", imageContent); //게시글 내용
        
        return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/image-board/image-insert").build())
                    .contentType(MediaType.MULTIPART_FORM_DATA)
                    .body(BodyInserters.fromMultipartData(mbBuilder.build()))
                    .retrieve()
                    .bodyToMono(Long.class)
                    .block();
    }
}

 

기존 post 요청에서 dto에 담아 보냈던것 처럼 Multipart처리시에는 MultipartBodyBuilder에 데이터를 담아 보내주면 된다.

MultipartBodyBuilder는 <String, HttpEntity> 형태로 담아줄 수 있다.

게시글에서 기본적인 파일, 제목, 내용을 담아준 뒤, 요청 body에서는 BodyInserters.fromMultipartData()를 사용해

MultipartBodyBuilder를 빌드해 body에 담아주면 된다.

MultipartBodyBuilder를 빌드하게 되면 MultiValueMap 타입이 된다.

BodyInserters.fromMultipartData()에는 이 MultiValueMap 타입만 받을 수 있고 받은 MultipartValueMap을 MultipartData로 쓰기위해 MultipartInserter를 return한다.

이때 받는 MultiValueMap의 값은 Obejct 또는 HttpEntity이다.

 

다시 좀 정리하면 MultiPartBodyBuilder에 데이터를 담아준다.

그걸 build() 하게 되면 MultiValueMap으로 리턴된다.

BodyInserters.fromMultipartData()는 MultiValueMap을 받으며 해당 map의 값으로는 Object 또는 HttpEntity여야 한다.

받은 MultiValueMap을 MultipartData로 쓰기위해 MultipartInserters를 반환해 body에 담아준다.

 

//api server insert
@PostMapping("/image-insert")
public long imageBoardInsert(@RequestParam List<MultipartFile> files
                             , @RequestParam String imageTitle
                             , @RequestParam String imageContent) {
    
    ....
}

api 서버에서 받을때는 이렇게 받을 수 있다.

 

 

Reference

 

JPA를 사용할 때 Id 값을 Long이나 int 타입으로 설정 했을 때 자동으로 증가되도록 설정하는 경우가 있다.

뭐 Oracle에서는 sequence를 이용해 Nextval, currval로 알아낼 수 있는 방법도 있지만 MySQL에서 설정하는 auto_increment는 그런게 없다.

 

그래서 이전에 MyBatis로 프로젝트를 할 때 방금 저장한 아이디값이 필요한 경우 SELECT로 가장 큰 값을 가져오도록 서브쿼리를 사용한다거나 해서 알아내 넣어줬었다.

 

근데 JPA에서는 간단하게 이 ID값을 알아낼 수 있었다.

 

long id = boardRepository.save(Board).getId();

 

JPA에서는 save로 저장했을 때 return되는 객체가 Entity 자체라고 한다.

 

예를들어 Board라는 Entity에 id, boardTitle, boardContent, userId 이렇게 구성되어있다고 할 때

save()를 하게 되면 이 4개의 데이터가 담긴 객체가 리턴 된다는 것이다.

그래서 get을 통해 id값을 리턴받게 되면 save로 저장해준 데이터의 id값을 받을 수 있게 되는 것이다.

 

 

개발환경

Springboot 2.7.0

SpringDataJPA

Lombok

MySQL

 

 

문제사항

JPA 공부하고 기존 진행했던 간단한 게시판 프로젝트에 JPA를 적용 중 이미지 파일 처리에서 문제가 발생.

//imageBoard Entity(사진 게시판)

@Entity
@Data
@Builder
public class ImageBoard {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long imageNo;
    
    @NonNull
    private String imageTitle;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "userId")
    @ToString.Exclude
    private Member member;
    
    private Date imageDate;
    
    private String imageContent;
    
    @OneToMany(mappedBy = "imageBoard", fetch = FetchType.LAZY)
    @ToString.Exclude
    private final Set<comment> comments = new HashSet<>();
    
    @OneToMany(mappedBy = "imageBoard", fetch = FetchType.LAZY)
    @ToString.Exclude
    private final Set<ImageData> imageDataSet = new HashSet<>();
}
// ImageData Entity(이미지 파일)

@Entity
@Data
@Builder
public class ImageData {
    
    @Id
    private String imageName;
    
    @ManyToOne
    @JoinColumn(name = "imageNo")
    @ToString.Exclude
    private ImageBoard imageBoard;
    
    @NonNull
    private String oldName;
    
    private int imageStep;
}

이렇게 두가지 엔티티가 이미지 게시판을 구성하게 된다.

그럼 insert를 한다고 했을 때 ImageBoard 엔티티를 먼저 저장해주고 ImageData 엔티티를 저장해줘야 한다.

ImageBoard 테이블에 imageNo가 존재해야 ImageData에 해당 게시글의 파일 정보를 저장할 수 있으니까.

 

최근에 'Entity는 Setter를 최대한 사용하지 않는것이 좋다. 꼭 Entity에 set을 해줘야 한다면 builder를 사용하는것이 좋다.' 라는 포스팅을 봤다.

봤으면 적용해야되니 builder를 통해 처리하고자 했다.

 

//처리코드

// imageBoardInsert 메소드
....
ImageBoard imageBoard = ImageBoard.builder()
                            .member(principalService.checkPrincipal(principal)
                            .imageTitle(request.getParameter("imageTitle"))
                            .imageContent(request.getParameter("imageContent"))
                            .imageDate(Date.valueOf(LocalDate.now()))
                            .build();
imageBoardRepository.save(imageBoard);
...

//파일 처리 메소드(imageBoardInsert 메소드에서 호출)
ImageBoard imageBoard = new ImageBoard();
imageBoard.setImageNo(imageNo);

ImageData imageData = ImageData.builder()
                            .imageboard(imageBoard)
                            .imageName(saveName)
                            .oldName(originalName)
                            .imageStep(step)
                            .build();
                            
imageDataRepository.save(imageData);
....

이렇게 작성했더니 바로 오류가 발생했다.

오류는 imageTitle은 NonNull이기 때문에 null이 들어가면 안된다는것.

처음한 생각으로는 연관관계 매핑을 해놨고 imageNo만 있으면 되겠지? 해서 setImageNo를 했고

안되길래 builder로 imageNo만 넣어서 빌드를 한 다음에 넣어봤는데 그것도 안되고...

@NonNull 어노테이션만 지우면 바로 해결되는 문제이긴 했지만 만약 이걸 사용해야 하는 환경에서 이런 문제에 또 부딪히게 되면 그때가서 또 시간 써야 될것 같아서 방법을 찾아봄.

 

간단하게 설명하자면 연관관계 매핑이 되어있으면 One 에 해당하는 Entity에 Many 에 해당하는 Entity를 담아 한번에 save가 가능하다.

 

처리는 아래처럼 하면 된다.

// ImageBoard Entity

@Entity
@Data
@Builder
public class ImageBoard {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long imageNo;
    
    @NonNull
    private String imageTitle;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "userId")
    @ToString.Exclude
    private Member member;
    
    private Date imageDate;
    
    private String imageContent;
    
    @OneToMany(mappedBy = "imageBoard", fetch = FetchType.LAZY)
    @ToString.Exclude
    private final Set<Comment> comments = new HashSet<>();
    
    // cascade = CascadeType.ALL 추가
    @OneToMany(mappedBy = "imageBoard", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @ToString.Exclude
    private final Set<ImageData> imageDataSet = new HashSet<>();
    
    //코드 추가
    public void addImageData(ImageData imageData) {
        
        //imageDataSet에 객체 추가
        imageDataSet.add(imageData);
        //imageData에도 imageBoard 객체를 추가
        imageData.setImageBoard(this);
    }
}
// 처리 코드

void imageInsertProc(.......){
    ....
    
    ImageBoard imageBoard = ImageBoard.builder()
                                .member(principalService.checkPrincipal(principal))
                                .imageTitle(request.getParameter("imageTitle"))
                                .imageContent(request.getParameter("imageContent"))
                                .imageDate(Date.valueOf(LocalDate.now())
                                .build();
                                
    imageInsert(...... , imageBoard);
    
    imageBoardRepository.save(imageBoard);
    ....
}
//파일 처리 메소드 호출

void imageInsert(..... , ImageBoard imageBoard) {
    ...
    ImageData imageData = ImageData.builder()
                            .imageName(saveName)
                            .oldName(originalName)
                            .imageStep(step)
                            .build();
                            
    imageBoard.addImageData(imageData);
    ....
}

이렇게 처리했다.

imageBoard를 먼저 빌드한 뒤 파일 처리 메소드를 호출할 때 imageBoard를 같이 넘겨준다.

그럼 파일 처리과정에서 imageData를 빌드할때는 ImageBoard를 제외한 나머지 값들로 빌드해준다.

그리고 Imageboard 엔티티에서 만들어놓은 addImageData 메소드를 통해 두 엔티티에 객체들을 저장할 수 있도록 해준다.

 

그리고나서 파일처리가 끝나면 Repository를 통해 save를 해주게 되면 연관관계에 있는 두 엔티티의 데이터가 한번에 저장되게 된다.

그럼 파일 처리 도중에 ImageBoard 엔티티 데이터에 대해 굳이 신경 쓸 필요도 없고 저장도 간단하게 해줄 수 있다.

 

Reference

https://data-make.tistory.com/730

 

[JPA] 양방향 관계 Entity 저장하기

JPA 양방향 관계 Entity 저장하기 인간은 습관의 동물이다. 습관에는 대부분 좋은 습관이 많지만 그중에 나쁜 습관도 있다. 그것은 바로.. 원리를 모르고 개발하는 습관이다. 😯 . '요로케할 때 이

data-make.tistory.com

 

+ Recent posts