이전 포스트에서는 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에서는 어떤 방법이 있을까? 해서 알아봤다.
Entity, Application을 철저하게 분석해서 사용할 수 있는 곳에서만 CascadeType.ALL을 사용한다.
CascadeType.PERSIST, CascadeType.MERGE를 사용한다.
EntityManager.persist()를 통해 처리한다.
Hibernate의 Batch Insert를 사용한다.
1번 방법은 혼자서만 개발하는게 아니라면 좀 지양하는게 좋지 않을까..? 라는 생각이다. 아니면 정말 확실하게 패널티가 전혀 없고 REMOVE가 되더라도 상관없다고 보장되는 경우에만 사용하고.
2번 방법은 REMOVE의 문제점에서 자유로울수는 있지만, 다른 참조 엔티티의 save() 과정에서 불필요한 JOIN이 발생해야 하기 때문에 그 부분에 대해 감안할 수 있다면 사용할 수 있을 것이다.
이 두개는 이전 포스트에서 다 정리했으니 넘어가도록 하고.
다음은 EntityManager의 persist()를 사용하는 방버이다.
EntityManager의 persist()를 사용하더라도 EntityManager.flush()가 호출되어야만 요청이 전달되기 때문에 동일한 동시 저장을 처리할 수 있게 된다.
하지만 이 방법의 경우 단점아닌 단점이 존재한다.
편의성 측면에서 오는 단점이라고 볼 수 있는데 EntityManager를 사용하는 경우 트랜잭션 관리에 더 많은 신경을 써야 한다.
flush(), clear() 호출 시점을 잘 설계해야 한다.
또한, 테스트 코드 작성에서 Mocking에 대한 제약이 발생하게 된다.
아예 Mocking을 할 수 없는 것은 아니지만, Repository 기반으로 처리하는 것에 비해 좀 더 복잡해 질 수 있다.
이런 단점들만 잘 처리할 수 있다면 좋은 선택이긴 하나, Repository를 아예 사용하지 않는 것이 아니라면 보통 Repository 기반으로 환경을 통일해서 처리하는 것이 가장 좋은 선택이라고 한다.
수행시점은 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와 같은 비동기 병렬 처리를 통해 동시 요청을 보내 추가적인 문제점을 확인해야 겠다는 생각이 들었다.
일회성 요청에서 빠르더라도 요청 몇개 겹치기 시작하면 성능 저하 발생하는거 너무 쉬운 문제더라.................
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의 처리와 병합해준다.
말을 다 줄이고 간단하게 보면 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을 사용하지 않고 동시저장을 처리하는 대안에 대해 고민해보게 되었다.
다음 포스팅은 그 고민에 대한 정리와 대안, 프로젝트 환경에서 발생한 문제점들에 대해 정리할 것이다.
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는 요청자가 처리를 계속해서 신경을 쓰고 있느냐 안쓰고 있느냐 라는 개념으로 이해하면 좀 편하다.