Continuous Refactoring Help

현재 시간에 의존하는 SQL 쿼리 리팩터링 - 첫 번째

예제 코드: 현재 시간에 의존하는 SQL 쿼리 리팩터링

배경

SQL 쿼리가 현재 시간에 의존하는 경우 무엇이 문제이고 어떻게 리팩터링 할 수 있는지 현실적이고 쉬운 예제를 통해 알아보자.

coupon-image.jpg

위 이미지는 평범한 이커머스 서비스의 할인 쿠폰 발급 화면이다. 사용자가 프로모션 페이지에 들어오면, 쿠폰을 다운로드 받을 수 있는 버튼이 있다. 쿠폰 발급은 시간에 의존한다. 예컨대 블랙프라이데이 할인 쿠폰이라면 블랙프라이데이 당일에만 발급받을 수 있다던가 하는 식이다. 지정된 프로모션 기간이 아니면 쿠폰 다운로드 버튼은 비활성화 되어있어야 한다. 이를 위해 레거시 백엔드 프로젝트를 개발했던 사람들은 쿼리에 로직을 넣었다. 내가 담당하던 레거시 프로젝트에 있는 쿼리를 단순화하면 아래와 같은 형태였다.

select exists( select id from coupon where id = #{couponId} and now() between start_dt and end_dt );

내가 프로모션 이벤트 서비스의 개발 담당자가 되기 전까지, 위와 비슷한 레거시 쿼리로 수년 동안 서비스를 운영해왔다. 하지만 이 쿼리만으로는 요구사항을 충족시킬 수 없었다.

새로운 요구사항들

기능 측면

프로모션 기획자가 프로모션 당일 이전에 이벤트 페이지와 쿠폰 다운로드 기능을 미리 테스트할 수 있어야 한다. 이를테면 블랙프라이데이 일주일 전에 마치 블랙프라이데이 당일에 이벤트 페이지를 보고 있는 것처럼 시뮬레이션 할 수 있어야 한다.

성능 측면

쿼리로 인한 DB 부하를 줄여야 한다. 당시 내가 다니던 회사는 MSA(Micro Service Architecture)로 전환 중이었지만, 프로모션 관련 기능은 아직 거대한 오라클 데이터베이스에 의존했다. 오라클은 매우 고성능이었지만, 선착순 쿠폰 발급같은 bursty traffic은 점점 버티지 못하게 되어 SPOF(Single Point of Failure)가 된 지 오래였다.

코드 품질 측면

쿼리에 로직이 섞여있어 중요한 도메인 로직을 쉽게 테스트할 수 없다. 테스트가 있다고해도 그 테스트는 DB에 의존적이다. 코드를 통해 비즈니스 규칙이 표현되지 않는다. 이커머스의 쿠폰 로직은 발급 가능 시간 이외에도 여러가지 정책이 추가되어 훨씬 복잡해 질 수 있다. 예를들면 다운로드 가능 횟수, 사용 기간, 회원 등급, 쿠폰 할인 정책 등이 있다. 코드 품질이 낮다면 그런 정책이 쿼리에, 또는 프레젠테이션 로직에 섞여있을 가능성이 높다. 그렇게 파편화된 코드에 기능을 추가하는 것은 매우 비용이 크다.

리팩터링 하기 전에 - 리팩터링의 전략

  1. Rest API 수준에서 테스트 할 수 있어야 한다. 이를 위해 코드를 변경하기 전에 인수 테스트를 추가한다.

  2. 쿼리에 있는 now() 함수를 파라미터로 치환하고, 이를 웹 어댑터(컨트롤러)까지 전파한다.

1. 실패하는 인수 테스트 추가

git commit: 1. add a failing acceptance test.

보통 레거시 코드에는 테스트가 없다(그러니까 레거시지). 다음과 같이 RestAssured를 사용해 간단한 인수 테스트를 추가한다.

@IntegrationTest @SpringBootTest(webEnvironment = RANDOM_PORT) class ReadCouponAcceptanceTest { @LocalServerPort private int port; @BeforeEach void setUp() { RestAssured.port = port; } @Test void couponApiShouldReturnExpectedResponse() { RestAssured. given(). when() .get("/coupon/1?testDateTime=2024-12-25T12:00:00.000Z"). then() .statusCode(200) .body("couponId", equalTo(1)) .body("isDownloadable", equalTo(true)); } }
# 테스트 쿠폰 데이터 추가 - 2024년 크리스마스에만 받을 수 있는 쿠폰. insert into coupon (name, start_dt, end_dt) values ('Test Coupon', '2024-12-25 00:00:00', '2024-12-25 23:59:59');

기존 GET 요청에 testDateTime 파라미터를 추가했다. 이 파라미터로 쿼리에 있는 now() 함수를 대체할 것이다. 물론 아직 구현을 수정하지 않았고, 지금은 크리스마스 당일도 아니니 테스트는 실패할 것이다.

2. 쿼리에 있는 now() 함수를 파라미터로 치환

git commit: 2. update sql query & introduce parameter(now).

쿼리에 있는 now() 함수를 파라미터로 치환한다. 이때 ORM이나 SqlMapper가 만들어내는 쿼리가 내가 기대하는 것과 같은지 반드시 확인해야 한다. 특히 datetime 구현체에 따라 달라지니 조심해야 한다. 스프링 프레임워크 프로젝트에서는 p6spy를 사용해서 쿼리 로그를 볼 수 있다.

DAO & Query 변경

refactoring-time-dependent-sql-query-0.png

서비스 변경

refactoring-time-dependent-sql-query-1.png

쿼리 로그 확인

쿼리 로그를 통해 Instant가 퀴리에 어떤 형태로 들어가는 지 확인 할 수 있다(zone offset 이 붙는다).

2024-05-19T14:08:32.072+09:00 INFO 39783 --- [refactoring-examples] [o-auto-1-exec-1] p6spy : 1 ms | select exists(select id from coupon where id = 1 and '2024-05-19T14:08:32.054+0900' between start_dt and end_dt)

3. Extract Parameter

git commit: 3. introduce parameter again.

계속해서 저수준 DAO의 변경을 서비스, 웹어댑터(컨트롤러)로 전파한다. IntelliJ IDEA 같은 IDE를 사용하면 안전하고 쉽게 할 수 있다. (Extract Parameter)

서비스 변경

refactoring-time-dependent-sql-query-3.png

컨트롤러 변경

refactoring-time-dependent-sql-query-4.png

4. Controller 로직 변경

git commit: 4. introduce request parameter: testDateTime.

컨트롤러 로직을 변경한다. testDateTime 파라미터가 있으면 그 값을 사용하고, 없으면 Instant.now()를 사용한다.

refactoring-time-dependent-sql-query-5.png

5. 인수 테스트 성공하는지 확인

여기까지 진행하면 인수 테스트가 성공하는 것을 볼 수 있다.

정리

이번엔 통해 SQL 쿼리가 현재 시간에 의존하는 경우 어떻게 리팩터링 할 수 있는지 알아보았다. 이제 요구 사항의 기능 측면은 충족하게 되었다. 프로모션 기획자나 QA가 현재 시간을 조작하여 실제 프로모션 시간인 것처럼 테스트 할 수 있다. 다만 아직 성능, 코드 품질 요구사항은 전혀 충족되지 않았다. 아직 쿼리에 로직이 섞여있기 때문에 쿼리의 결과를 캐싱할 수도 없고, 중요한 도메인 로직을 쉽게 테스트할 수도 없다. 이 부분은 다음 글로 이어진다.

Last modified: 19 May 2024