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에서는 어떤 방법이 있을까? 해서 알아봤다.
- 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 기반으로 환경을 통일해서 처리하는 것이 가장 좋은 선택이라고 한다.
다음은 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와 같은 비동기 병렬 처리를 통해 동시 요청을 보내 추가적인 문제점을 확인해야 겠다는 생각이 들었다.
일회성 요청에서 빠르더라도 요청 몇개 겹치기 시작하면 성능 저하 발생하는거 너무 쉬운 문제더라.................
'Spring' 카테고리의 다른 글
JPA의 역 N + 1 문제 (0) | 2025.03.29 |
---|---|
[ JPA Cascade ] 1. 연관관계 엔티티 동시 저장 및 주의사항 (1) | 2025.03.28 |
Spring Boot에서 QueryDSL 사용하기 (1) | 2024.03.08 |
DTO, VO, Entity의 분리 (0) | 2023.09.27 |
프로젝트에 WebClient 사용해보기 (0) | 2023.06.08 |