JAVA/JPA 학습기록

[JPA] 쓰기 지연으로 인한 쿼리 실행 시점과 예외처리 및 기본키 생성 전략

kth990303 2022. 9. 10. 19:06
반응형

최근에 흥미로운 글을 봤다. JPA 쓰기지연 기능 때문에 커스터마이징한 예외가 발생하지 않고 DataIntegrityViolationException 예외가 발생한다는 글이었다.

 

service에서 repository의 delete 메서드를 실행하고, 만약 외래키 설정으로 인한 DataIntegrityViolationException이 발생하면 catch로 잡아서 커스텀 예외를 던지려 하는 상황이라 가정하자. 해당 서비스 메서드를 테스트할 때, 예외 상황으로 커스텀 예외가 발생할 줄 알았는데 catch로 잡지 못하고 DataIntegrityViolationException가 발생했다는 글이었다.

https://velog.io/@giantim/5

 

@Transactional 과 JPA 사용 시 주의점

@Transactional 과 영속성 컨텍스트의 쿼리 실행 시점에 대해서 알아봅니다 :)

velog.io

SimpleJpaRepository의 Transactional propagation 옵션이 `REQUIRED_NEW`가 아닌 `REQUIRED`이기 때문에 부모 트랜잭션(service layer의 트랜잭션)에 참여하게 된다. 결국, 쓰기 지연 덕분에 service method가 끝나고 트랜잭션이 커밋되므로 쓰기 지연 저장소에 있던 쿼리들이 실제 DB에 반영하다가 DataIntegrityViolationException이 발생하게 되는 것.

 

위 블로그 글 덕분에 JPA 작동 원리에 대해서 다시 한 번 공부하게 됐다.


그래서 나도 테스트를 좀 해보려고 아래와 같은 상황을 작성해보았다.

delete, find, save 등 어떠한 쿼리든간에 JPA의 쓰기 지연 저장소에 저장됐다가 트랜잭션이 커밋할 때 실제 DB에 쿼리를 보내는 것은 동일하기 때문에 save로 한 번 테스트해보았다.

 

(find의 경우는 Spring Data JPA의 경우 JPQL을 쓰기 때문에 바로 flush를 해버린다. 따라서 save일 때를 테스트해보았다. delete의 경우는 위 블로그 내용에 해당되며, 해당 글은 실제 테스트 결과, 그리고 분석해본 결과 틀리지 않았다고 생각됐기 때문이다.)

Team 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
@Getter
@Table(name = "team")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team extends BaseEntity {
 
    public static final int MAX_TEAMNAME_LENGTH = 20;
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;
 
    @Column(name = "team_name"length = 20, nullable = false, unique = true)
    private String name;
 
        // ...
}
cs

기본 키 생성 전략은 IDENTITY 이며, name 옵션에는 unique가 걸려있다.

AUTO로 할 경우 식별자 값이 생성되는 방법을 알 수 없어 IDENTITY로 설정했다.

 

TeamRepository

1
2
3
4
5
6
7
8
@Override
public Long save(Team team) {
    try {
        return teamJpaDao.save(team).getId();
    } catch (final DataIntegrityViolationException e) {
        throw new DuplicateTeamNameException(team.getName());
    }
}
cs

team을 등록하는 repository 메서드.

단, team의 name에 unique 옵션이 걸려있어서 같은 팀이름으로 팀을 등록할 경우 DataIntegrityViolationException이 발생한다.

 

TeamService

1
2
3
4
5
6
7
8
9
10
11
12
@Transactional
public Long save(final TeamRequestDto teamRequestDto, final Long memberId) {
    final Team team = new Team(
            teamRequestDto.getName(),
            teamRequestDto.getDescription(),
            teamRequestDto.getEmoji(),
            teamRequestDto.getColor(),
            teamRequestDto.isSecret()
    );
    final Long createdTeamId = teamRepository.save(team);
    return createdTeamId;
}
cs

repository의 save 메서드를 호출하고 메서드는 끝이 난다.

save 메서드 이후에 별도의 find 메서드가 없어 flush가 일어나지 않는다.

따라서 메서드 종료 전까지 save 쿼리는 쓰기 지연 저장소에 저장될 것이다.

 

(무언가 잘못된 부분이 있다고 벌써 눈치챘을 수도 있다. 하지만 일단 넘어가자. 이후에 얘기할 것임.)

 

테스트 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
@DisplayName("모임을 같은 이름으로 저장하면 예외를 발생시킨다.")
void findByIdWithName() {
    // given
    final TeamRequestDto teamRequestDto = new TeamRequestDto(
            "woowacourse",
            "테스트 모임입니다.",
            "testEmoji",
            "#123456",
            "나는야모임장",
            false
    );
    teamService.save(teamRequestDto, member.getId());
 
    assertThatThrownBy(() -> teamService.save(teamRequestDto, member.getId()))
            .isInstanceOf(DuplicateTeamNameException.class);
}
 
cs

같은 이름의 팀을 등록할 때 커스텀 예외 (DuplicateTeamNameException)가 발생하는지 테스트할 것이다.

트랜잭션이 커밋될 때 실제 DB에 쿼리가 나갈 것이고, 해당 쿼리는 데이터 정합성에 맞지 않으므로 repository에서 캐치하지 못하고 DataIntegrityViolation을 발생시킬 것이라 예측했다.

 

결과

DataIntegrityViolation 이 아닌 커스텀 예외가 발생했다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
service save 메서드 실행 시작
repository save 메서드 호출 직전
2022-09-10 14:58:13.983 [DEBUG] [org.hibernate.engine.jdbc.spi.SqlStatementLogger] [Test worker]- 
    insert 
    into
        team
        (team_id, created_at, last_modified_at, color, description, emoji, team_name, secret) 
    values
        (default, ?, ?, ?, ?, ?, ?, ?)
repository save 메서드 호출 직후
transaction 닫히기 직전
service save 메서드 실행 시작
repository save 메서드 호출 직전
2022-09-10 14:58:14.088 [DEBUG] [org.hibernate.engine.jdbc.spi.SqlStatementLogger] [Test worker]- 
    insert 
    into
        team
        (team_id, created_at, last_modified_at, color, description, emoji, team_name, secret) 
    values
        (default, ?, ?, ?, ?, ?, ?, ?)
2022-09-10 14:58:14.098 [WARN ] [org.hibernate.engine.jdbc.spi.SqlExceptionHelper] [Test worker]- SQL Error: 23505, SQLState: 23505
2022-09-10 14:58:14.098 [ERROR] [org.hibernate.engine.jdbc.spi.SqlExceptionHelper] [Test worker]- Unique index or primary key violation: "PUBLIC.UK_SOB22SIQDNN2RFSXK6F00PGWB_INDEX_2 ON PUBLIC.TEAM(TEAM_NAME) VALUES 4"; SQL statement:
insert into team (team_id, created_at, last_modified_at, color, description, emoji, team_name, secret) values (default, ?, ?, ?, ?, ?, ?, ?) [23505-200]
repository DataIntegrityViolationException 발생
cs

하지만 결과를 보면 insert 쿼리가 먼저 나가고 트랜잭션이 종료됐다.

그리고 이후에 같은 팀이름을 가진 팀을 넣으려할 때 DataIntegrityViolationException이 발생했지만 repository에서 catch로 잡아서 커스텀한 예외가 발생한 것을 확인할 수 있다.

 

즉, 쓰기 지연이 발생하지 않은 것이다.

 

그 이유는 뭘까?

 

정답은 바로 Team 도메인의 기본 키 생성 전략에 있었다.

우리는 teamService에서 repository의 save 메서드를 호출하고 트랜잭션을 커밋한 후에 실제 DB로 쿼리가 나갈 줄 알았다. 하지만,  generationType.IDENTITY는 em.persist일 때 쓰기 지연을 하지 않기 때문에 repository save 메서드를 호출하자마자 바로 실제 DB에 쿼리가 나간 것을 확인할 수 있다. IDENTITY 전략은 DB column 수에 따라 식별자 id를 저장해주기 때문이다. 따라서 쓰기 지연이 아닌 실제 DB에 바로 쿼리를 날리는 것. 이 점을 놓치지 않고 잘 이해하고 있었다면 위에서 teamService 부분을 설명할 때 뭔가 이상함을 눈치챘을 것이다.


사실 서비스 테스트 코드를 작성할 때 의도한 커스텀 예외가 아닌 DataIntegrityViolationException이 발생한 트러블은 우리 프로젝트에선 아직 겪어보지 못했다. 우리 프로젝트에선 삭제 로직이 거의 없는데다가, 외래키 설정도 일부에만 존재하기 때문이다. 그 외에 데이터 정합성 이슈가 발생할만한 부분에선 서비스, 또는 도메인에서 예외검증을 충분히 해줬거나, DTO의 @Valid로 검증해줬기 때문에 그럴지도 모르겠다.

 

하지만 위 블로그 글 덕분에 JPA 쓰기 지연 및 Transaction의 전파 개념, 기본키 생성 전략에 대해 다시 한 번 복습할 수 있는 계기가 되었다. 나중에 알고보니 우아한테크코스 2기 선배님 블로그였다. 덕분에 많이 배우고 갑니다! :+1

 

 

(여담으로, 저 테스트 코드에는 @BeforeEach가 있었다. 그에 따라 insert 쿼리가 위 코드보다 실제로 더 많이 나갔는데, 왜 더 많이 나갔는지 당시에 이유를 몰랐어서 무려 2시간동안 삽질을 했다;;; 추석연휴에 개발했더니 피로가 쌓였나 ㅎㅎ...)

반응형