추상 부모 클래스인 AuditingEntityListener에 BooleanExpression 적용 및 검증

Woody·2024년 9월 1일

TIL

목록 보기
10/19

배경

팀원들이 DB에 저장된 수치를 슬랙으로 편하게 확인할 수 있도록, 서버 팀원과 함께 해당 기능을 구현했다. 나는 API 설계를 담당했고, 다음과 같은 요구 사항을 만족하는 API를 설계해야 했다.

요구사항

1. 시작 시간과 종료 시간 모두 주어진 경우: 해당 기간 동안의 수치를 확인할 수 있다.

2. 시작 일자만 주어진 경우: 시작 일자부터 지금까지의 수치를 확인할 수 있다.

3. 종료 일자만 주어진 경우: 오픈 날짜부터 종료 일자까지의 수치를 확인할 수 있다.

4. 시작 일자와 종료 일자를 모두 주어지지 않은 경우: 오픈 날짜부터 현재까지의 총 수치를 확인할 수 있다.

5. 날짜 형식: yyyy-MM-dd 형식이다.

설계

  1. 쿼리 파라미터(from, to)로 시작 일자와 종료 일자를 선택적으로 받을 수 있도록 한다.
  2. 현재 AuditingEntityListener를 이용해서 created_at칼럼과 updated_at칼럼이 자동으로 저장되고 있기 때문에, 입력 받은 fromto 값을 created_at칼럼과 비교해서 데이터를 필터링한다.
  • Case 1 (from = null, to = null): 오픈 날짜부터 현재까지의 수치를 반환.

  • Case 2 (from = 2024-08-15, to = null): created_at이 from 이후인 데이터를 반환. (예: WHERE created_at > '2024-08-15')

  • Case 3 (from = null, to = 2024-08-15): created_at이 to 이전인 데이터를 반환. (예: WHERE created_at < '2024-08-15')

  • Case 4 (from = 2024-08-15, to = 2024-08-17): created_at이 from과 to 사이에 있는 데이터를 반환. (예: WHERE created_at BETWEEN '2024-08-15' AND '2024-08-17')

수치를 불러오는 쿼리마다 위 케이스를 적용하는 것은 비효율적이라고 생각했고, Querydsl을 이용해서 해당 로직을 공통으로 사용할 수 있도록 만들기로 결정했다.

구현

들어가기 전에 현재 모든 엔티티는 추상 클래스인 아래 AuditingTimeEntity를 상속을 받고 있다.

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditingTimeEntity {
    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

// 상속받아 사용 중이다.
public class Meeting extends AuditingTimeEntity
public class User extends AuditingTimeEntity 

이제 구현으로 넘어가면, 해당 로직은 두 조건을 만족해야 한다.

1. from과 to 값에 따라 동적으로 조건절을 적용해야 한다.

BooleanExpression , BooleanBuilder 이 두 기술은 Querydsl에서 동적으로 조건절을 적용할 수 있는 기술이다.

BooleanBuilderBuilder 객체를 생성해야 하고, 조건절을 builder로 감싸 가독성을 저해한다고 판단하여 BooleanExpression을 선택했다.

BooleanBuilderBooleanExpression

2. Meeting, User 등 서로 다른 테이블에 모두 적용할 수 있어야 한다.

서로 다른 테이블에 적용하기 위해서, 모든 테이블은 AuditingTimeEntity를 상속하고 있기 때문에 처음에는 auditingTimeEntity를 이용해서 로직을 만들었다.

하지만 아래와 같은 에러 코드를 띄우며 실패했다.

org.hibernate.query.SemanticException: Could not interpret path expression 'auditingTimeEntity.createdAt'

원인은 auditingTimeEntity는 부모 추상 클래스이기 때문에 매핑된 테이블이 없기 때문에 Hibernate에서 경로를 해석할 수 없었다. 따라서 createdAt을 사용하기 위해서는 meeting.createdAt, user.createdAt과 같이 사용해야 한다.

따라서 auditingTimeEntity를 사용하지 않고, 서로 다른 엔티티 객체에 어떻게 동일한 조건절을 반영할 수 있을까 고민했다.

Querydsl에서 사용하는 Q객체를 자세히 관찰한 결과, Q객체의 변수로 Path 프로퍼티가 사용되는 것을 확인할 수 있었고, createdAt칼럼은 DateTimePath<LocalDateTime>으로 정의되고 있었다.

따라서 createdAt 칼럼을 매개변수로 받아서 적용하기로 결정했고, 수정된 로직은 아래와 같다.

private BooleanExpression generateDateFilter(
        final DateTimePath<LocalDateTime> createdAt,
        final LocalDateTime from,
        final LocalDateTime to
) {
    if (from != null && to != null) {
        return createdAt.between(from, to);
    }
    if (from != null) {
        return createdAt.after(from);
    }
    if (to != null) {
        return createdAt.before(to);
    }
    return null; // null을 반환할 시, 자동으로 조건절에서 제거된다.
}

// 아래와 같이 사용한다.
.where(generateDateFilter(meeting.createdAt, from,to))
.where(generateDateFilter(user.createdAt, from,to))

조건절 검증

BooleanExpression을 처음 사용해보기 때문에 테스트 코드를 통해 검증하고 싶었다.

하지만 위에서 말했듯이 auditingTimeEntity 는 부모 추상 클래스이기 때문에 매핑된 테이블이 없으므로 별도의 객체를 생성할 수 없다. 그로 인해 EntityManager.persist를 통해서 테스트에 사용할 데이터를 생성할 수 없었다.

그래서 INSERT문을 통해서 데이터를 생성하기로 결정했고, 다음과 같이 테스트 데이터를 생성했다.

@Transactional
@SpringBootTest
class MetricsRepositoryGenerateDateFilterTest {
		private static final String INSERT_QUERY_TEMPLATE = "INSERT INTO meeting (title, password, duration, place_type, additional_info, created_at) VALUES ('title', '1234', 'HALF','ONLINE', '', ?)";
		
		@Autowired
		private EntityManager em;
		
		@DisplayName(
		        """
		            1. 2022520일 데이터
		            2. 202311일 데이터
		            3. 2023615일 데이터
		            4. 20231231일 데이터
		        """
		)
		@BeforeEach
		public void setUp() {
		    em.createNativeQuery(INSERT_QUERY_TEMPLATE)
		            .setParameter(1, "2022-05-20T10:00:00")
		            .executeUpdate();
		
		    em.createNativeQuery(INSERT_QUERY_TEMPLATE)
		            .setParameter(1, "2023-01-01T12:00:00")
		            .executeUpdate();
		
		    em.createNativeQuery(INSERT_QUERY_TEMPLATE)
		            .setParameter(1, "2023-06-15T15:30:00")
		            .executeUpdate();
		
		    em.createNativeQuery(INSERT_QUERY_TEMPLATE)
		            .setParameter(1, "2023-12-31T23:59:59")
		            .executeUpdate();
		}
}
  • EntityManager.createNativeQuery를 통해서 데이터를 생성한다.

  • 해당 테스트의 주요 관심사는 created_at이기 때문에 나머지 데이터는 템플릿화하고, BeforeEach에서 created_at 칼럼을 설정한다.

  • 데이터베이스에 INSERT 쿼리를 실행하기 위해 DB 커넥션이 필요하므로, @Transactional을 선언한다.

마무리

이러한 과정으로 요구사항을 만족하는 API를 구현할 수 있었다.

BooleanExpression을 사용해 본 점과 createNativeQuery를 사용해서 쿼리문을 통해 테스트 데이터를 생성하는 방식에 대해 학습할 수 있었다.

0개의 댓글