1. 연관관계 엔티티 동시 저장 및 주의사항
연관관계에 있는 엔티티를 동시 저장하기
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가지의 옵션이 존재한다.
- PERSIST
- PERSIST 의미 그대로 영속화를 위한 옵션이다. '저장' 에 대한 작업에 연관된 설정이다.
- 부모 엔티티가 저장될 때 자식 엔티티를 같이 저장하게 된다.
- EntityManager.persist()를 호출하지 않고 repository.save()만으로도 함께 처리될 수 있다.
- 단, '새로운' 엔티티들을 저장하는 경우에만 동작한다.
- MERGE
- 부모 엔티티가 병합될 때 자식 엔티티를 같이 병합한다.
- 부모 엔티티가 갱신되거나 수정되었을 때 자식 엔티티를 병합해야 하는 경우 동작한다.
- REMOVE
- 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 같이 삭제된다.
- 데이터베이스의 ON DELETE CASCADE와 비슷하다.
- 차이가 있다면 1개의 부모 엔티티에 대한 연관된 자식 엔티티 데이터 개수가 10개라면, 10번의 DELETE 쿼리를 날리기 때문에 그로 인한 성능 저하가 발생할 수 있다.
- REFRESH
- 부모 엔티티가 새로고침 되는 시점에 자식 엔티티도 같이 새로고침 된다.
- 데이터베이스 상태와 동기화 되도록 자식 엔티티를 업데이트 해준다.
- DETACH
- 부모 엔티티가 영속성 컨텍스트로부터 분리될 때, 자식 엔티티도 분리된다.
- '분리' 의 의미는 영속성 컨텍스트에서 관리되고 있는 상황에서 준영속 상태로 분리해 엔티티가 더 이상 영속성 컨텍스트에서 관리되지 않는 상태를 말한다.
- 분리된 엔티티는 더이상 관리되지 않기 떄문에 변경사항이 데이터베이스에 반영되지 않는다.
- 부모 엔티티를 준영속상태로 분리하는 경우 자식 엔티티도 같이 분리하므로 자식 엔티티의 변경사항 역시 반영되지 않는다는 의미가 된다.
- ALL
- 위 모든 옵션을 사용한다.
이 옵션들 중 동시 저장에 영향을 끼치는 것은 PERSIST와 MERGE다.
PERSIST에는 또 다른 문제점이 하나 존재하는데 이건 다음 2번 포스팅에서 정리한다.
단일 엔티티 저장을 수행하는 경우 JPA는 SELECT 쿼리를 먼저 날려 이 데이터가 존재하는 데이터인지 아닌지를 먼저 확인한 뒤 새로운 데이터라면 INSERT를 날려 flush를 수행하게 된다.
기본적인 JPA 동작 방식인건데 양방향 매핑 구조에서 CascadeType.ALL을 정의하면 여기에 하위 엔티티 처리 과정이 포함되어야 한다.
예시 엔티티인 Member와 Auth를 기준으로 과정을 살펴보자면,
- JPA는 Member 엔티티의 데이터가 존재하는 데이터인지 확인하기 위해 SELECT 쿼리를 날려본다.
- 새로운 데이터라는 것을 확인했기 때문에 INSERT를 처리한다.
- flush가 수행되는 시점에 PERSIST 옵션에 의해 하위 엔티티를 같이 처리하려고 시도한다.
- 하위 엔티티 데이터를 영속성 컨텍스트에서 관리하려고 시도했으나 이 데이터 역시 새로운 데이터이기 때문에 insert에 대한 처리를 MERGE 옵션을 통해 상위 엔티티인 Member의 처리와 병합해준다.
- 모든 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을 사용하지 않고 동시저장을 처리하는 대안에 대해 고민해보게 되었다.
다음 포스팅은 그 고민에 대한 정리와 대안, 프로젝트 환경에서 발생한 문제점들에 대해 정리할 것이다.
'Spring' 카테고리의 다른 글
JPA의 역 N + 1 문제 (0) | 2025.03.29 |
---|---|
[ JPA Cascade ] 2. 동시저장에서 발생하는 이슈에 대한 대응책 (1) | 2025.03.28 |
Spring Boot에서 QueryDSL 사용하기 (1) | 2024.03.08 |
DTO, VO, Entity의 분리 (0) | 2023.09.27 |
프로젝트에 WebClient 사용해보기 (0) | 2023.06.08 |