스터디/JPA 스터디

[JPA] Qna 미션을 통해 새롭게 배운 내용 정리

제이온 (Jayon) 2022. 1. 17.

jpa-study에서 스터디를 진행하고 있습니다.

테스트 격리

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = BEFORE_CLASS)
public class SpringContainerTest {
}

 

그동안 테스트 격리할 때는 @DirtiesContext를 통해 매번 컨텍스트를 새로 띄우는 방식으로 구현하였다. 하지만 클래스의 개수가 많아질수록 테스트의 속도가 비약적으로 느려지는 단점이 있다. 따라서 매번 데이터베이스를 clean하는 방식으로 개편하였다.

 

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SpringContainerTest {

    @Autowired
    private DatabaseCleaner databaseCleaner;

    @AfterEach
    void tearDown() {
        databaseCleaner.tableClear();
    }
}

@Service
public class DatabaseCleaner implements InitializingBean {

    @PersistenceContext
    private EntityManager entityManager;

    private List<String> tableNames;

    @Override
    public void afterPropertiesSet() {
        tableNames = entityManager.getMetamodel().getEntities().stream()
            .filter(entityType -> entityType.getJavaType().getAnnotation(Entity.class) != null)
            .map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName()))
            .collect(Collectors.toList());
    }

    @Transactional
    public void tableClear() {
        entityManager.flush();
        entityManager.clear();

        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();

        for (String tableName : tableNames) {
            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
        }
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
    }

}

 

@Sql 어노테이션을 통해 sql 파일을 만들어서 테이블을 truncate하는 방법도 있으나, 테이블의 변경이 있을 때마다 매번 sql 파일을 수정하는 것은 비효율적이라고 판단하였다. 그래서 동적으로 테이블 목록을 가져와서 각 테이블에 대해 truncate를 수행하였다. 이때 SET REFERENTIAL_INTEGRITY 는 참조 무결성에 관한 설정으로, false를 주면 무시가 된다. 만약, false를 주지 않고 바로 truncate를 취하면 순서에 따라 외래 키 참조 무결성 오류가 발생하여 테이블을 비우지 못할 수 있다.

 

테스트 Fixture 개편

public class UserFixture {

    public static final User JAVAJIGI = User.builder()
        .userId("javajigi")
        .password("password")
        .name("name")
        .email("javajigi@slipp.net")
        .build();

    public static final User SANJIGI = User.builder()
        .userId("sanjigi")
        .password("password")
        .name("name")
        .email("sanjigi@slipp.net")
        .build();
}

 

나는 그동안 위와 같이 public static 필드로 테스트 Fixtrue를 구성하고, 다음과 같이 편리하게 재활용을 하였다.

 

@DisplayName("User 테스트")
class UserTest {

    @Test
    @DisplayName("게스트 유저인지 판단한다.")
    void isGuestUser() {
        User guestUser = User.GUEST_USER;
        assertThat(guestUser.isGuestUser()).isTrue();
        assertThat(UserFixture.JAVAJIGI.isGuestUser()).isFalse();
    }

}

 

여기서 발생할 수 있는 문제점은 무엇이 있을까? 크게 2가지가 있다.

 

특정 테스트 Fixture의 필드 수 증가

지금은 UserFixture의 필드 개수가 2개지만, 상황에 따라 더 늘어나야 할 수도 있다. 가령, UserList를 만든다거나, PK 값이 존재하는 User가 필요하거나, User 개수 자체가 더 늘려야 하는 상황 등이 있다. 이때마다 새로운 필드를 추가한다면 유지 보수하기 좋은 코드가 아니다.

 

진정한 테스트 격리가 아님

    @BeforeEach
    void setUp() {
        userRepository.save(UserFixture.JAVAJIGI);
    }

 

위 코드를 수행하면 UserFixture.JAVAJIGI 의 ID는 처음에 null이었지만, save() 이후 ID 값을 얻게 된다. 이때 JAVAJIGI는 static 필드이므로 테스트 전체적으로 ID가 계속 변한다. 이것은 테스트 격리가 아니며, 예상치 못한 부작용이 발생할 수도 있다.

 

해결 과정

public class TestUser {

    public static final String USER_ID = "testUserId";
    public static final String PASSWORD = "testPassword";
    public static final String NAME = "testName";
    public static final String EMAIL = "test@test.com";

    private static Long INCREASE_ID = 0L;

    public static User create() {
        return User.builder()
            .userId(USER_ID + INCREASE_ID)
            .password(PASSWORD + INCREASE_ID)
            .name(NAME + INCREASE_ID)
            .email(EMAIL + INCREASE_ID)
            .build();
    }

    public static User createWithId() {
        INCREASE_ID++;
        return User.builder()
            .id(INCREASE_ID)
            .userId(USER_ID + INCREASE_ID)
            .password(PASSWORD + INCREASE_ID)
            .name(NAME + INCREASE_ID)
            .email(EMAIL + INCREASE_ID)
            .build();
    }
}

 

TestFixture를 static 메소드를 활용하여 매번 새로운 객체를 반환하게 만들었다. 이때 PK 값이 필요할 수도 있으므로 PK가 존재하는 User와 그렇지 않은 User로 나누었다.

 

    @Test
    @DisplayName("게스트 유저인지 판단한다.")
    void isGuestUser() {
        User guestUser = User.GUEST_USER;
        assertThat(guestUser.isGuestUser()).isTrue();
        assertThat(TestUser.createWithId().isGuestUser()).isFalse();
    }

    @BeforeEach
    void setUp() {
        User savedUser = userRepository.save(TestUser.create());
        question = TestQuestion.create(savedUser, null, false);

        question.addAnswer(TestAnswer.create(savedUser, question, false));
        question.addAnswer(TestAnswer.create(savedUser, question, false));
        question.addAnswer(TestAnswer.create(savedUser, question, false));

        contentRepository.save(question);
    }

 

이렇게 실제 테스트에서는 일반적으로 ID가 있는 테스트 Fixture를 사용하고, Repository에 save할 때는 ID가 없는 테스트 Fixture를 사용하면 된다. 이렇게 테스트 Fixture를 개편하면 위에서 언급한 2가지 문제점을 해결할 수 있다.

 

AuditingEntityListener를 사용한 생성 시간, 수정 시간 자동 생성

@Getter
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

 

생성 시간과 수정 시간은 테이블 관리를 위해 꼭 필요한 정보이다. 이것을 매번 애플리케이션 로직으로 넣어주면 불편하므로 @MappedSuperclass로 공통 정보를 분리하고, AuditingEntitylistener를 사용하여 자동으로 생성 시간과 수정 시간을 넣어주도록 구성한다.

위에서 사용한 어노테이션의 의미는 다음과 같다.

 

 

이때 스프링 부트의 엔트리 포인트에 @EnableJpaAuditing 어노테이션을 활용하여 JPA Auditing을 활성화를 반드시 해야 한다.

 

@EnableJpaAuditing 
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

 

Soft-Delete 구현

이번 미션은 데이터를 지울 때 DB에서 실제로 지우는 Hard-Delete가 아니라, 삭제 관련 flag를 true로 변환하는 Soft-Delete를 구현해야 했다. 이를 애플리케이션 단에서 매번 changeDeleted() 와 같이 삭제 관련 flag를 true로 만들어줄 수 있다. 하지만 삭제 로직이 들어갈 때마다 이를 추가해야 하고, 만약 모든 객체에 대해 Soft-Delete를 적용해야 한다면 반복된 작업이 발생한다. 또한, Repository 단에서 findAll() 을 할 때도 flag가 false인 데이터만 가져와야 하므로 findAllByDeletedFalse() 와 같이 매번 조회 메소드를 추가해야 한다.

이러한 반복된 작업을 피하기 위해서 JPA는 @Where와 @SQLDelete 어노테이션을 제공한다.

 

@Where

@Where은 해당 엔티티의 기본 쿼리에 디폴트 Where절을 적용할 수 있는 어노테이션으로, findAll() 메소드를 호출했을 때 자동으로 @Where 안에 조건이 들어간다.

 

@Where(clause = "deleted = false")
public abstract class Content extends BaseTimeEntity

 

가령 위와 같이 @Where(clause = "deleted = false") 을 설정하였다면, 무조건 delete 플래그가 false인 데이터만 가져오도록 조건을 설정한다.

 

@SQLDelete

@SQLDelete는 데이터의 삭제가 발생하면 ORM 단에서 자동으로 @SQLDelete 안의 쿼리를 호출하는 기능을 한다.

 

@Where(clause = "deleted = false")
@SQLDelete(sql = "UPDATE content SET deleted = true where id = ?")
public abstract class Content extends BaseTimeEntity

 

위와 같이 설정을 하면, contentRepository.deleteAll() 같이 실제 데이터의 삭제가 발생하면 @SQlDelete 내의 쿼리가 호출된다. 이를 통해 Soft-Delete를 쉽게 구현할 수 있다.

 

계층형 엔티티에서 주의할 점

@Where(clause = "deleted = false")
@SQLDelete(sql = "UPDATE content SET deleted = true where id = ?")
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "ContentType")
public abstract class Content extends BaseTimeEntity

 

만약 위와 같이 상위 엔티티에 Soft-Delete를 구현하였다면, 이 Soft-Delete가 하위 엔티티에도 적용이 될까?

 

Hibernate: 
    delete 
    from
        answer 
    where
        id=?
Hibernate: 
    UPDATE
        content 
    SET
        deleted = true 
    where
        id = ?
Hibernate: 
    delete 
    from
        question 
    where
        id=?
Hibernate: 
    UPDATE
        content 
    SET
        deleted = true 
    where
        id = ?

 

답은 No다. Content 테이블만 Soft-Delete가 적용되고, 나머지 하위 엔티티인 Answer, Question은 Hard-Delete가 적용된 것을 알 수 있다. 이를 해결하기 위해서는 하위 엔티티에 @OnDelete(OnDeleteAction.CASCADE) 를 설정하면 된다.

 

@OnDelete(action = OnDeleteAction.CASCADE)
@PrimaryKeyJoinColumn(foreignKey = @ForeignKey(name = "answer_fk_id"))
@Entity
public class Answer extends Content

 

삭제 History 테이블 구현

이번 미션에는 Soft-Delete와 더불어 질문 또는 답변 객체가 삭제되면 삭제 관련 History에 이력을 저장하라는 요구 사항이 있었다. 애플리케이션 단에서 삭제가 발생하는 로직에 DeleteHistoryService.add() 를 수행할 수도 있지만, 비즈니스 로직과 이력 관리는 분리 되어야 한다고 생각했다. 그래서 엔티티의 삭제 이벤트가 발생하면 한 곳에서 Delete 이력을 남기도록 @PreRemove를 사용하였다.

 

구현 과정

먼저 ApplicationContext로 직접 빈을 호출하는 유틸 클래스를 만들어야 한다. 엔티티에 Repository 주입을 불가능하므로 직접 ApplicationContext를 통해 Repository를 호출해야 한다.

 

@Component
public class BeanUtil implements ApplicationContextAware {
    private static ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        BeanUtil.applicationContext = applicationContext;
    }

    public static <T> T getBean(Class<T> clazz) {
        return applicationContext.getBean(clazz);
    }
}

 

ApplicationContextAware를 상속하면 ApplicationContext를 호출할 수 있다. static <T> T getBean(Class<T> clazz) 메소드는 static이므로 어느 곳에서든지 직접 빈을 호출할 수 있다. 어떠한 리턴 타입도 받을 수 있도록 T로 설정하였다.

 

public class ContentListener {

    @PreRemove
    public void preDelete(Object o) {
        DeleteHistoryRepository deleteHistoryRepository = BeanUtil.getBean(DeleteHistoryRepository.class);
        Content content = (Content) o;

        ContentType contentType = ContentType.valueOf(content.getClass().getSimpleName().toUpperCase(Locale.ROOT));
        DeleteHistory deleteHistory = new DeleteHistory(contentType, content.getId(), content.getWriter().getId());

        deleteHistoryRepository.save(deleteHistory);
    }

}

 

다음으로 엔티티의 삭제 이벤트를 감지하기 위해 @PreRemove 어노테이션을 붙은 메소드를 생성한다. 이 메소드 안에서 삭제 이력을 삽입하면 된다.

 

@EntityListeners(value = ContentListener.class)
@Where(clause = "deleted = false")
@SQLDelete(sql = "UPDATE content SET deleted = true where id = ?")
@Entity
public abstract class Content extends BaseTimeEntity

 

마지막으로 위 이벤트를 발생시킬 엔티티를 @EntityListeners로 설정하여 방금 만든 Listener 객체를 작성하면 된다.

 

@PreRemove의 단점

이번 미션은 단순히 질문과 답변 객체가 삭제될 때에 한해서만 이력을 관리하라고 되어 있지만, 실제로는 업데이트 조건이 추가될 수도 있고 많은 엔티티에 대해 이력을 관리할 수도 있다. 그럴 때마다 이벤트 방식으로 매번 Listener 클래스를 만드는 것은 비효율적이다. 범용 이력 테이블을 관리할 때는 spring data envers 라이브러리를 사용하는 것을 추천한다. 이 부분은 추후 다른 프로젝트에서 여건이 되면 적용할 예정이다.

 

출처

댓글

추천 글