[코드가 환경을 모르는 구조 4/7] 타임머신 — 시간 축을 교체한다

HR SaaS에서 시간은 비즈니스 로직이다

HR SaaS에서 "오늘이 며칠이냐"가 바뀌면 급여 명세서 금액이 바뀌고, 연차 잔여 일수가 바뀌고, 퇴직금 추정치가 바뀝니다. 다른 도메인에서 시간은 대체로 로그가 언제 찍혔는지를 기록하는 타임스탬프지만, HR에서 시간은 결과를 만들어 내는 변수입니다.

급여의 월말 정산일은 회사마다 다릅니다. 연차는 입사일을 기준으로 발생하되 근로기준법과 사내 규정이 얽힌 규칙을 따릅니다. 퇴직금은 퇴사일 기준 평균임금을 근거로 삼고, 평균임금의 산정 기간이 몇 달이냐는 다시 시간의 함수입니다. 연말정산은 고정된 시점에 한 번, 4대보험 기준일은 매달 특정 날짜에, 근로시간 산정 기간은 주와 월에 걸쳐 움직입니다. 시간을 잘못 이해한 HR 코드는 과소·과다 지급된 급여, 혹은 법적 분쟁으로 이어집니다.

"이 직원이 내년 3월 15일에 퇴사하면 퇴직금이 얼마인가?"를 QA 담당자가 검증하려면 어떻게 해야 할까요. 그 날이 올 때까지 기다릴 수는 없습니다. 테스트 코드에서는 임의 날짜를 넣을 수 있지만, 운영과 유사한 환경에서 돌아가는 서비스를 대상으로 "다음 달 급여를 계산하면 어떻게 되는지"를 시뮬레이션해야 하는 요구는 늘 존재합니다. 시간이 비즈니스 로직의 입력이라면, 그 입력은 운영 환경에서도 바꿔 끼울 수 있어야 합니다.

flex에는 2020년부터 "타임머신"이라는 디버깅 기능이 있습니다. 이 기능의 본질은 한 문장으로 요약됩니다. 시간 축을 교체 가능한 Port로 만든다.


시간을 직접 호출하지 않는다는 규율

타임머신이 작동하려면 전제가 필요합니다. 모든 비즈니스 로직이 시스템 시계를 직접 호출하지 않고 Clock 인터페이스를 통해 현재 시간을 얻어야 한다는 것. 사소해 보이는 이 규율이 무너지면 나머지 설계도 함께 무너집니다.

자바의 java.time.Clock이 있어 구현은 어렵지 않습니다. 서비스 클래스는 Clock을 생성자로 주입받고, LocalDateTime.now() 대신 LocalDateTime.now(clock)을 호출합니다.

Kotlin
class PayrollService(private val clock: Clock) {
    fun settleMonth(): Result {
        val now = clock.instant()
        // ... 비즈니스 로직은 이 now를 기준으로만 판단
    }
}

코드의 다른 어디에서도 Instant.now(), LocalDateTime.now(), System.currentTimeMillis()를 부르지 않습니다. 이 금지가 타임머신의 전제 조건입니다.

프로덕션에서는 Clock의 기본 구현이 시스템 시계를 그대로 반환합니다. 이것이 Port에 꽂히는 기본 Adapter입니다. 타임머신 모드에서는 요청 헤더에 담긴 시점을 현재 시간으로 반환하는 다른 Adapter가 꽂힙니다. 서비스 클래스는 clock.instant()가 돌려주는 값만 사용할 뿐, 어느 Adapter가 꽂혔는지는 알지 못합니다.

호출부를 한 줄도 수정하지 않고 Port만 갈아 끼울 수 있다는 것, 그것이 헥사고날 아키텍처가 이 장면에서 내어 주는 보상입니다.


헤더 하나로 시계를 바꿔 끼운다

보상의 실체는 HTTP 요청 헤더에서 드러납니다. 디버그 모드의 요청에는 FlexTeam-Debug-DateTime 같은 헤더가 붙고, 값은 원하는 시점의 ISO-8601 문자열입니다.

Servlet 필터가 요청 헤더를 읽어, 해당 요청 범위에서 쓸 시간 컨텍스트를 ThreadLocal에 주입합니다. 요청을 처리하는 동안 Clock Adapter는 이 ThreadLocal을 조회해 "헤더가 있다면 헤더의 시점을, 없다면 시스템 시계를" 반환합니다. 요청 처리가 끝나면 필터가 ThreadLocal을 정리합니다.

Kotlin
// 의도를 드러내는 분기 로직 (요지)
override fun instant(): Instant =
    FlexRequestedTimeContextHolder.get()?.requestedTime
        ?: Instant.now()

이 세 줄에 타임머신의 전부가 들어 있습니다. "요청 범위에 지정된 시간이 있으면 그걸, 없으면 실제 시계를." 이 분기는 Clock Adapter 안에만 있고, 비즈니스 코드에는 보이지 않습니다.

이 구현은 Clock 인터페이스의 계약을 어기지 않습니다. Clock은 여전히 "현재라고 부를 만한 시점을 반환한다"는 계약을 지키고, "현재"의 해석만 요청 단위로 달라질 뿐입니다.

덕분에 특정 요청만 골라서 시간을 이동시킬 수 있습니다. 같은 서비스에서 동시에 처리되는 다른 요청은 여전히 실제 시스템 시계를 바라보고, 타임머신을 탄 요청만 따로 미래나 과거를 걷습니다. 타임머신은 요청 스코프의 도구이고, 나머지 트래픽을 오염시키지 않습니다.


비동기 경계를 넘어 시간을 전파한다

요청 스코프 안에 머무르는 동안에는 ThreadLocal만으로 충분합니다. 그러나 현실 서비스에는 비동기 경계가 많습니다. Kotlin 코루틴으로 전환하거나, ExecutorService로 작업을 위임하거나, Kafka 이벤트로 처리를 넘기면 ThreadLocal은 자연스럽게 따라가지 않습니다.

flex는 이 경계마다 시간 컨텍스트를 함께 실어 보내는 장치를 둡니다. 코루틴 컨텍스트에 FlexRequestedTimeContext를 포함시키고, 비동기 작업에는 컨텍스트를 캡처하는 래퍼를 씌우고, Kafka 메시지 헤더에는 시점을 직렬화해 실어 보냅니다. 소비자 쪽에서는 같은 컨텍스트를 다시 꺼내 ThreadLocal에 복원한 뒤 처리합니다. 이렇게 해야 한 사용자 요청에서 시작한 시뮬레이션이 비동기 후처리 단계까지 일관된 시간을 유지합니다.

이 전파가 없다면, 사용자가 디버그 헤더로 "내년 3월"을 지정해 급여를 계산시켰을 때 메인 스레드의 계산은 내년 3월로, 후속 Kafka 소비자는 오늘 날짜로 처리하는 기묘한 상황이 벌어집니다. 시간의 일관성은 HTTP 요청 범위를 넘어 비동기 경계를 가로지르는 "업무 단위"까지 함께 움직여야 비로소 완성됩니다.


환경이 이 기능을 켠다

시간 컨텍스트가 경계를 넘나드는 만큼, 언제 어디서 이 기능이 켜지느냐는 곧 안전의 문제입니다. 프로덕션에서 외부 헤더가 그대로 시간을 바꿔 버린다면, 사용자의 현재 시각을 임의로 조작할 수 있는 거대한 사고가 됩니다.

그래서 이 기능은 환경별로 활성 여부를 결정합니다. dev와 qa에서는 켜 두고, 특정 역할의 디버그 토큰을 가진 사용자만 쓸 수 있으며, prod에서는 기본적으로 비활성입니다. 이 스위치 자체도 값 주입으로 해결합니다. 애플리케이션 코드에 "prod에서는 꺼야 한다"는 분기를 직접 넣는 대신, "Clock Adapter가 헤더를 신뢰할지 말지"를 values가 결정합니다.

2화에서 본 Helm 3단 오버라이드가 여기서도 반복됩니다. 기본값은 보수적으로 "헤더를 신뢰하지 않는다"이고, dev values가 이를 뒤집습니다. 배포 파이프라인의 원칙과 애플리케이션의 Port/Adapter 구조가 서로 맞물리면서, "환경별 기능 on/off"라는 단순한 스위치 역시 본편 5화가 말한 원리 위에서 돌아갑니다.

감사 관점의 장치도 있습니다. 타임머신이 적용된 요청은 응답 헤더에 표시되거나 로그에 별도로 남아, "이 응답은 실제 현재 시각이 아니라 시뮬레이션이다"라는 사실이 흔적으로 남습니다. 타임머신은 디버깅 도구이지 운영 도구가 아니며, 그 사실은 결과물에 꼬리표처럼 따라붙어야 합니다.


규율이 만드는 교체 가능성

타임머신의 구현 자체는 놀라울 정도로 단순합니다. 필터 하나, Clock Adapter 하나, ThreadLocal 하나, 그리고 비동기 전파 유틸리티 몇 개. 코드만 보면 "이렇게 작은 코드로 이게 가능한가" 싶을 정도입니다.

가능한 이유는 코드의 분량이 아니라 규율의 깊이에 있습니다. 모든 비즈니스 코드가 Clock을 주입받는다는 규율. LocalDateTime.now()를 직접 부르는 코드가 리뷰에서 반드시 지적된다는 관행. 비동기 경계마다 컨텍스트를 전파하는 표준 유틸리티. 이 규율이 유지되는 한, 시간이라는 축은 언제든 교체 가능한 상태로 남습니다. 그 교체 가능성이 2020년부터 QA의 일상적인 검증 도구이자 버그 재현의 신뢰할 만한 장치로 기능해 왔습니다.

물론 규율이 언제나 쉽지만은 않습니다. 우리 코드는 Clock을 주입받지만, 우리가 부르는 서드파티 라이브러리가 내부에서 시스템 시계를 직접 호출하는 경우까지 막을 방법은 없습니다. 어떤 JWT 라이브러리는 만료 검증을 위해 System.currentTimeMillis()를 직접 읽고, 어떤 스케줄링 유틸은 cron 계산에, 일부 ORM의 자동 타임스탬프도 마찬가지입니다. 타임머신으로 이동한 시점이 이 라이브러리들에는 닿지 않아, 같은 요청 안에서 어떤 값은 시뮬레이션 시점, 어떤 값은 실제 시점으로 섞이는 미묘한 틈이 생깁니다. 대응은 대체로 세 가지입니다. 라이브러리가 Clock 주입을 받아 준다면 연결하고, 받지 않으면 우리 쪽 어댑터에서 한 번 더 감싸고, 그마저도 불가능하면 "이 부분은 타임머신의 관할 밖"이라고 문서에 박제해 두는 것.

또 하나 자주 걸리는 경우는 Kafka 재처리입니다. 과거에 생산된 메시지의 헤더에는 당시의 시간 컨텍스트가 박혀 있어, 몇 달 뒤 토픽을 되감아 재처리하면 소비자 쪽 Clock이 그 시점의 과거로 돌아가 버립니다. 이벤트 소싱 관점에서는 오히려 원하는 동작일 수도 있지만, 현재 시점으로 다시 계산하고 싶다면 재처리 경로에서 헤더의 시간을 의도적으로 벗겨 내는 단계를 거쳐야 합니다. 규율은 깊어도 세계의 모든 틈을 막지는 못하며, 그 틈이 어디에 있는지를 아는 것 역시 규율의 일부입니다.

이 편을 한 문장으로 요약하면 이렇습니다. "현재 시간"은 Port이고, 그 Port에 어떤 Adapter를 꽂는지는 요청 헤더와 환경 설정이 결정한다. 규율은 단순한데, 그 규율이 만드는 교체 가능성은 깊습니다. 시간이 교체 가능하다면, 공간도 교체 가능합니다. 다음 편에서는 이 사고방식이 공간 축으로 옮겨졌을 때 어떤 도구가 만들어지는지 보겠습니다.

다음 화에서는,
“Rewrite Host. 공간 축을 교체한다. 헤더 하나로 dev 환경 속 서비스 하나만 로컬로 바꿔 끼우기”에 대해서 이야기 합니다.

🚀플렉스팀 채용페이지 바로가기☕flex Private Talk 신청하기
글이 마음에 드셨나요?
공유하기
페이스북링크드인트위터
flex가 궁금하다면? 지금 무료로 체험해 보세요
flex가 궁금하다면? 지금 무료체험하기
  • CAIO's Note
    2026. 4. 30
    조직 생산성 높이는 방법, 알잘딱깔센은 시스템이 만든다
    당신의 구성원이 헛발질하는 건 일머리가 없어서가 아니라, 뇌 용량을 잡무에 다 써버렸기 때문이다.
  • 아티클
    2020. 5. 25
    근태관리, 유연근무제, 그리고 코로나 시대
    코로나, 뉴노멀, 유연근무제, 그리고 근태관리