팀원들이 DB에 저장된 수치를 슬랙으로 편하게 확인할 수 있도록, 서버 팀원과 함께 해당 기능을 구현했다. 나는 API 설계를 담당했고, 다음과 같은 요구 사항을 만족하는 API를 설계해야 했다.
요구사항
1. 시작 시간과 종료 시간 모두 주어진 경우: 해당 기간 동안의 수치를 확인할 수 있다.
2. 시작 일자만 주어진 경우: 시작 일자부터 지금까지의 수치를 확인할 수 있다.
3. 종료 일자만 주어진 경우: 오픈 날짜부터 종료 일자까지의 수치를 확인할 수 있다.
4. 시작 일자와 종료 일자를 모두 주어지지 않은 경우: 오픈 날짜부터 현재까지의 총 수치를 확인할 수 있다.
5. 날짜 형식: yyyy-MM-dd 형식이다.
from, to)로 시작 일자와 종료 일자를 선택적으로 받을 수 있도록 한다.AuditingEntityListener를 이용해서 created_at칼럼과 updated_at칼럼이 자동으로 저장되고 있기 때문에, 입력 받은 from과 to 값을 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
이제 구현으로 넘어가면, 해당 로직은 두 조건을 만족해야 한다.
BooleanExpression , BooleanBuilder 이 두 기술은 Querydsl에서 동적으로 조건절을 적용할 수 있는 기술이다.
BooleanBuilder는 Builder 객체를 생성해야 하고, 조건절을 builder로 감싸 가독성을 저해한다고 판단하여 BooleanExpression을 선택했다.
| BooleanBuilder | BooleanExpression |
|---|---|
![]() | ![]() |
서로 다른 테이블에 적용하기 위해서, 모든 테이블은 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. 2022년 5월 20일 데이터
2. 2023년 1월 1일 데이터
3. 2023년 6월 15일 데이터
4. 2023년 12월 31일 데이터
"""
)
@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를 사용해서 쿼리문을 통해 테스트 데이터를 생성하는 방식에 대해 학습할 수 있었다.