aster®

[타임시트] 테스트와 검증 — 로직 단위 테스트부터 운영 측정까지 (08)

마무리 · 2026-09

남색 윤곽선으로 그린 둥근 칸이 가로 일곱, 세로 다섯 줄로 늘어선 격자가 있다. 절반쯤은 초록으로 채워져 있고 세 칸은 빨간 윤곽선이다. 오른쪽에 납작한 판 세 장이 쌓여 있고 맨 위 판만 초록이다.

타임시트의 검증은 다섯 층으로 나뉜다. 계산 로직은 단위 테스트로, API 는 실행 중인 서버에 요청을 보내는 e2e 로 확인했다. 운영 환경은 같은 e2e 를 운영 주소에 대고 돌렸다. 화면은 브라우저에서 크기를 쟀고 속도는 스크립트와 브라우저 기록으로 쟀다. 테스트 프레임워크는 따로 설치하지 않았다. Node.js 에 들어 있는 node:test 하나로 웹과 서버 테스트를 모두 돌린다.

이 글은 층마다 무엇을 어떻게 돌렸는지, 돌리다 막힌 곳은 어디였는지, 그리고 끝내 확인하지 못한 것이 무엇인지를 정리한다.

층도구무엇을 보나마지막 결과
웹 로직 단위 테스트node --test급여·지오펜스·달력·오늘 상태·시간표 계산54/54
서버 e2e (로컬)tsx --test로그인·매장·초대·요청·로고·시간표 API17/17 (+ 세션 상한 S17·S17b 2/2)
운영 e2e같은 e2e 파일, 운영 주소운영 환경(Vercel·Neon)에서의 동작시나리오별 기록
화면운영 가이드 + 390·320px iframe모바일 폭 넘침·높이·줄바꿈측정값 기록
성능perf-boot.mts, Resource Timing응답 시간, 데이터 도착 시점, CLSdocs/verify/performance.md

표 1. 타임시트의 검증 다섯 층

1. 웹 로직 단위 테스트 — node --test

1-1. 계산은 lib/ 로 빼고 화면과 따로 잰다

급여, 50m 지오펜스 알림, 달력 칸, 오늘 근무 상태, 근무 시간표 계산은 모두 web/src/lib/*.ts 에 순수 함수로 있다. 순수 함수는 같은 입력에 늘 같은 값을 돌려주고 화면이나 네트워크를 건드리지 않는 함수다. 그래서 브라우저 없이 Node 에서 바로 돌릴 수 있다.

"test": "node --test src/lib/*.test.mjs"

테스트 파일은 계산 파일 옆에 *.test.mjs 로 둔다. 테스트 이름 앞에는 PRD 의 수용 기준 번호(P-1, G-4, TD-6 …)를 붙여서 어느 요구를 확인하는지 바로 보이게 했다.

파일테스트 수확인하는 것 (PRD)
pay.test.mjs16기본급·주휴·연장 가산·5인 미만·휴가·대타 (02·03·09)
calendar.test.mjs14달력 칸·주 시작·기록 붙는 날짜 (10)
today.test.mjs9오늘 근무 상태·퇴근 뒤 버튼 (12)
geo.test.mjs5거리 계산, 첫 알림·60초 재알림 (04)
inviteLink.test.mjs4초대 가입 링크·문자 주소 (06)
schedule.test.mjs4요일·시각에서 주 시간 계산 (13)
format.test.mjs2시계 HH:MM:SS 형식
합계54

표 2. 웹 단위 테스트 파일별 개수

이 글을 쓰면서 web/ 에서 node --test src/lib/*.test.mjs 를 다시 돌렸다. tests 54, pass 54, fail 0 이었다.

급여 테스트 하나는 이렇게 생겼다. 2026년 최저임금 10,320원으로 주 20시간·5일을 다 채우면 기본급과 주휴수당이 얼마인지 원 단위까지 맞춰 본다.

// docs/prd/02-pay.md P-1~P-4, docs/prd/03-overtime.md O-1~O-3
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeWeek, computeWeeks, computeMonth, startOfWeek } from "./pay.ts";

const H = 3_600_000;
const monday = new Date(2026, 8, 21); // 2026-09-21 (월)
const at = (dayOffset, hour) => new Date(2026, 8, 21 + dayOffset, hour).getTime();
const shift = (dayOffset, from, hours) => ({ id: `${dayOffset}-${from}`, start: at(dayOffset, from), end: at(dayOffset, from) + hours * H });
const base = { hourlyWage: 10320, weeklyHours: 20, workDaysPerWeek: 5, fivePlus: true };

test("P-1 주 20시간·5일 개근 → 기본급 206,400 + 주휴 41,280", () => {
  const w = computeWeek(monday, [0, 1, 2, 3, 4].map((d) => shift(d, 10, 4)), base);
  assert.equal(w.basePay, 206400);
  assert.equal(w.holidayPay, 41280);
  assert.equal(w.overtimePay, 0);
  assert.equal(w.total, 247680);
});

테스트 파일이 .mjs 인데 ./pay.ts 를 바로 import 한다. 이 PC 의 Node 는 24.19.0 이고 이 버전의 Node 는 .ts 파일의 타입 표기를 걷어내고 바로 실행한다. 그래서 빌드 단계 없이 TypeScript 소스를 테스트할 수 있다.

1-2. 확장자 없는 import — registerHooks

오늘 근무 대시보드를 만들 때 이 방식에서 막혔다.

Cannot find module .../src/lib/pay

today.ts 는 앱 코드 규칙대로 import … from "./pay" 처럼 확장자 없이 가져온다. Next.js 번들러는 .ts 를 붙여 찾아 주지만 Node 는 확장자를 추측하지 않는다. 테스트 파일이 ./today.ts 를 불러오는 것은 되는데, 그 안의 "./pay" 에서 멈춘 것이다.

앱 코드를 테스트에 맞춰 고치지 않고 테스트 쪽에서 Node 의 모듈 찾기에 훅을 걸었다. registerHooks 는 Node 가 import 경로를 해석하는 과정에 끼어드는 함수다. 먼저 원래대로 찾아보고 실패하면서 확장자가 없는 상대 경로면 .ts 를 붙여 다시 찾는다. 같은 문제를 먼저 겪은 calendar.test.mjs 의 방법을 그대로 가져왔다.

import { registerHooks } from "node:module";
import { test } from "node:test";
import assert from "node:assert/strict";

// today.ts 는 "./pay" 를 확장자 없이 import 한다 — calendar.test.mjs 와 같은 방법으로 .ts 를 붙여 찾는다
registerHooks({
  resolve(specifier, context, next) {
    try {
      return next(specifier, context);
    } catch (e) {
      if (specifier.startsWith(".") && !/\.[cm]?[jt]s$/.test(specifier)) return next(`${specifier}.ts`, context);
      throw e;
    }
  },
});
const { todayShifts, todayState, toMinute } = await import("./today.ts");

마지막 줄이 정적 import 가 아니라 await import() 인 이유가 있다. 정적 import 는 파일의 다른 코드보다 먼저 해석되므로, 훅을 등록하기 전에 ./pay 를 찾다가 실패한다. 동적 import 로 바꿔야 훅이 걸린 뒤에 불러온다.

1-3. 기능이 늘 때마다 같이 늘었다

첫 요청에서 15개로 시작한 테스트가 하루 동안 54개가 됐다. 새 계산이 생길 때마다 그 계산의 수용 기준을 테스트로 먼저 옮겼다.

시점개수더해진 것
요청 1 (급여·알림)15P·O·G 시나리오
요청 5 (휴가·대타)21L-1~L-4, S-1
요청 8 (근무 달력)35달력 14
요청 13 (초대 문자)39초대 링크 4
요청 20 (시계 한 줄)41clockText 2
요청 19 (오늘 근무)49오늘 상태 8
요청 22 (다시 출근 없음)50TD-6
요청 24 (근무 시간표)54시간표 4

표 3. 웹 단위 테스트가 늘어난 순서 (히스토리 [검증] 줄)

2. 서버 e2e — 실행 중인 API 에 요청을 보낸다

2-1. 테스트가 브라우저 흉내를 낸다

서버 테스트는 함수를 직접 부르지 않는다. 실제로 떠 있는 API 에 HTTP 요청을 보내고 응답을 확인한다. 이렇게 처음부터 끝까지 실제 경로로 확인하는 테스트를 e2e(end-to-end) 테스트라고 한다. 파일은 server/test/e2e.test.ts 하나이고 tsx --test 로 돈다. tsx 는 TypeScript 파일을 바로 실행하는 도구다.

"test:e2e": "tsx --test test/*.test.ts"

API 가 먼저 떠 있어야 한다. 저장소 문서(README.md, CLAUDE.md, docs/verify/auth.md)에는 모두 "pnpm dev 로 서버를 띄운 뒤 pnpm test:e2e" 로 적혀 있다.

로그인이 쿠키로 되기 때문에, 테스트 안에 쿠키를 저장하고 CSRF 토큰을 싣는 작은 클라이언트를 뒀다. 브라우저가 하는 일을 흉내 내는 것이다.

/** 브라우저 역할: 쿠키 저장소 + CSRF 토큰. 테스트마다 IP 를 달리해 로그인 제한이 섞이지 않게 한다. */
class Client {
  jar = new Map<string, string>();
  csrf: string | null = null;
  ip = `10.0.0.${ipSeq++}`;

  /** GET 이 아니면 기본으로 CSRF 헤더를 싣는다 (브라우저 FE 와 같다). csrf: false 로 뺄 수 있다. */
  async req(method: string, path: string, o: { body?: unknown; csrf?: boolean; auth?: boolean; headers?: Record<string, string> } = {}) {
    const wantCsrf = o.csrf ?? method !== "GET";
    const csrf = wantCsrf ? await this.ensureCsrf() : null; // 쿠키 헤더를 만들기 전에 csrf 쿠키를 받아 둔다
    const headers: Record<string, string> = { Origin: ORIGIN, "X-Forwarded-For": this.ip };
    if (this.jar.size) headers.Cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; ");
    // … 요청을 보내고 Set-Cookie 를 jar 에 반영 …
  }
}

테스트 계정은 매번 ${randomUUID()}@test.dev 로 새로 만든다. 식별할 수 있는 도메인을 써야 운영에서 돌린 뒤 테스트 데이터만 골라 지울 수 있다(3절).

2-2. 처음 돌렸을 때 10개 중 8개가 403

처음 e2e 를 돌렸을 때 10개 중 8개가 403 으로 실패했다. 서버의 CSRF 검사가 거부한 것이다.

서버는 옳았다. 테스트 클라이언트가 요청 헤더의 Cookie 를 먼저 만들고 그다음에 CSRF 토큰을 받아 왔다. CSRF 토큰을 받는 요청이 csrf_token 쿠키를 새로 주는데, 이미 만들어 둔 Cookie 헤더에는 그 쿠키가 없었다. 서버는 헤더의 토큰과 쿠키의 토큰이 맞는지 보므로 403 을 돌려줬다.

토큰을 먼저 받고 쿠키 헤더를 나중에 만들도록 순서를 바꿨다. 위 코드의 "쿠키 헤더를 만들기 전에 csrf 쿠키를 받아 둔다" 주석이 그 자리다. 10/10 이 됐고 시나리오를 하나 더해 11/11 이 됐다.

2-3. 시나리오

묶음시나리오무엇
인증S1·S2, S3, S4, S5·S6, X-1, X-2, S7·S8·S9, S23, S10~S13, S14, S18, S19가입, 쿠키 속성, 틀린 비밀번호 응답 시간 차, 변조 토큰, CSRF, 리프레시 회전과 재사용 탐지, 로그아웃, CORS, 로그인 횟수 제한
매장·초대I-1~I-8초대 코드 등록·재사용·만료·취소, 권한, 출퇴근, 대시보드
요청C-1~C-7, L·S·M기록 수정 요청과 승인, 휴가·대타
로고LG-1~LG-7형식 검사, SVG 안전, 크기, 권한
시간표WS-1~WS-6시간표 저장·계산, 날짜별 변경

표 4. 서버 e2e 시나리오 (test() 17개)

test() 호출은 파일에 19개 있다. 그중 S17·S17b 둘은 세션 상한을 5초로 줄여 띄운 서버가 따로 필요해서 S17=1 환경변수가 있을 때만 돈다. 30일을 기다릴 수 없으니 서버 설정을 바꿔 5초 뒤 만료되는지, 3초마다 쓰면 기한이 밀리는지를 본다.

# 서버를 SESSION_MAX_AGE_SEC=5 PORT=4201 로 띄운 뒤
S17=1 API=http://localhost:4201 pnpm test:e2e

로컬 결과는 11/11 → 15/15 → 17/17 로 늘었고 S17·S17b 는 2/2 였다.

3. 운영 e2e — 같은 파일을 운영에 대고

같은 e2e 파일을 운영에 대고 돌렸다. API·WEB_ORIGINS 를 웹 운영 주소로, DATABASE_URL 을 운영 DB 로 준다. 요청은 웹 주소로 들어가 rewrites 를 거쳐 API 에 닿는다. 로컬에서는 보이지 않던 것이 세 가지 나왔다.

무엇로컬운영원인
alg:none 토큰(S5·S6)401403Vercel 방화벽이 앱에 닿기 전에 막는다(x-vercel-mitigated: deny)
로그인 횟수 제한테스트마다 IP 가 달라 섞이지 않음뒤쪽 I-1 이 429Vercel 이 X-Forwarded-For 를 덮어써 모든 요청이 실제 IP 하나로 잡힘
틀린 비밀번호 응답 시간(S4)36.8 / 36.3ms316.0 / 271.3ms네트워크 편차 포함

표 5. 운영 e2e 에서 로컬과 다르게 나온 것

첫 번째는 앱이 아니라 플랫폼이 먼저 거부한 것이다. 거부된 것은 같으니 테스트가 플랫폼의 403 도 거부로 인정하도록 고쳤다.

두 번째는 테스트 설계가 운영과 맞지 않았다. 테스트 클라이언트는 요청마다 다른 X-Forwarded-For 값을 넣어 IP 를 나누려 했지만 Vercel 은 이 헤더를 실제 IP 로 덮어쓴다. DB 의 refresh_tokens.ip 를 조회하니 실제 IP 하나만 남아 있었다. 그래서 S4·S19 의 실패 로그인이 같은 IP 의 1분 5회 제한을 채웠고 뒤이어 돈 I-1 의 로그인이 429 가 됐다. 성공한 로그인은 횟수에 들어가지 않는 것도 확인했다. I-1 은 1분 쉬고 따로 돌려 통과했다. docs/verify/auth.md 는 이것을 "trust proxy 를 켜도 로그인 제한 키를 위조할 수 없다" 로 적었다.

따로 돌리려다 하나 더 알게 됐다. --test-name-pattern 은 파일 인자 앞에 둬야 한다. 뒤에 두면 필터가 적용되지 않는다.

# ✅ 플래그를 파일 앞에
pnpm exec tsx --test --test-name-pattern="I-1" test/e2e.test.ts
# ❌ 파일 뒤에 두면 필터되지 않는다
pnpm exec tsx --test test/e2e.test.ts --test-name-pattern="I-1"

3-1. 운영 테스트 데이터 정리

운영 e2e 는 운영 DB 에 계정과 매장을 만든다. 돌린 뒤에는 @test.dev 사용자와 그 사용자만 속한 행만 지웠다. 테스트 계정이 아닌 사용자와 매장은 건드리지 않았다.

언제지운 것남긴 것
첫 운영 e2e사용자 24, 매장 2(운영 사용자 0 이 됨)
쿠키 세션 전환 뒤사용자 16, 매장 6테스트 계정이 아닌 사용자 2·매장 1
유급 선택 제거 뒤사용자 4, 매장 2실제 사용자 2·매장 1
가이드·로고 배포 뒤사용자 2, 매장 1실제 사용자 2·매장 1
근무 시간표 배포 뒤사용자 3, 그들만의 매장 2사용자 3·매장 1

표 6. 운영 e2e 뒤 지운 테스트 데이터 (히스토리 기록)

4. 로그인 화면을 못 보는 대신 — 가이드 예시를 iframe 으로 잰다

화면 검증에는 두 가지 제약이 있었다.

첫째, 브라우저 자동화로는 계정을 만들거나 비밀번호를 넣지 않는다. 그래서 로그인해야 보이는 멤버·사장 화면을 자동화로 열 수 없다.

둘째, 자동화 환경에서 브라우저 창 크기 조절이 적용되지 않았다. 모바일 폭(390px)을 보려고 창을 줄여도 그대로였다.

두 가지를 이렇게 돌아갔다. 로그인 없이 열리는 운영 컴포넌트 가이드(/guide, 06편)에는 앱과 같은 컴포넌트·같은 CSS 로 그린 예시가 있다. 운영 페이지 안에 width: 390px(또는 320px) iframe 을 만들고 그 안에 가이드 페이지를 띄워서 요소의 크기를 쟀다. iframe 안의 페이지는 iframe 폭을 화면 폭으로 알고 그린다.

단계무엇
1운영 페이지를 연다
2그 안에 390px·320px iframe 을 만든다
3iframe 안에 가이드 페이지(재현할 컴포넌트 예시)를 띄운다
4getBoundingClientRect()·scrollWidth 로 높이·넘침을 잰다

표 7. iframe 으로 모바일 폭을 재는 순서

이 방법으로 잰 것들이다.

무엇잰 값
가이드 17쪽 문서 넘침처음 /guide/foundations 437px(390px 틀) → 수정 뒤 넘치는 쪽 0
입력칸 .field (요청 화면 대신 Field 예시)수정 전 select 43 / input 46 → 수정 뒤 전부 44, 두 칸 겹침 0
멤버 시계옛 표기 2줄 → 23:59:59·00:00:00 1줄
오늘 근무 가이드 3쪽가로 넘침 0(scrollWidth 375/305·309), 시계 10개 높이 48, 금액 6개 한 줄
스켈레톤clock-card 232/232, today-dashboard 426/426, 320px 에서 522/466
조건 수정 모달여는 버튼 32px, 모달 폭 326, 취소·저장 34px, 가로 넘침 0

표 8. 390·320px iframe 으로 잰 값 (히스토리 [검증] 줄)

멤버 시계는 가이드 예시가 아니라, 운영 CSS·글꼴을 쓰는 iframe 안에 멤버 화면 구조(px-5 + 카드 p-5)를 만들어 줄 수를 쟀다.

이 방법에는 한계가 있다. 측정한 것은 가이드 예시이지 로그인한 실제 화면이 아니다. 데스크톱 Chrome 의 iframe 이라 iOS Safari 의 날짜 칸 동작은 재현되지 않았다. 사용자가 휴대폰에서 본 날짜 칸 겹침은 이 방법으로 재현되지 않았고 재현되지 않았다는 사실을 그대로 적었다. 측정에 쓴 스크립트는 저장소에 남아 있지 않다.

5. 성능 측정 — 스크립트와 Resource Timing

"데이터 로드부터 ui 표시까지" 를 재 달라는 요청에 두 층으로 쟀다(docs/verify/performance.md).

5-1. API — perf-boot.mts

server/scripts/perf-boot.mts 는 멤버 앱이 켜질 때 부르는 순서를 그대로 흉내 낸다. /users/me 로 로그인을 확인하고 이어서 기록·휴가·요청·동료 4개를 병렬로 부른다. 웹 주소 경유와 API 직접 호출을 각 10회 돌린다.

async function boot(c: C) {
  const t0 = performance.now();
  const a = await c.req("GET", paths.me);
  const [s, ab, r, co] = await Promise.all([c.req("GET", paths.shifts), c.req("GET", paths.absences), c.req("GET", paths.requests), c.req("GET", paths.colleagues)]);
  return { total: performance.now() - t0, me: a.ms, shifts: s.ms, absences: ab.ms, requests: r.ms, colleagues: co.ms, bytes: s.bytes, vid: a.vid, status: [a, s, ab, r, co].map((x) => x.status).join(",") };
}

측정 전에 운영 DB 에 @test.dev 사장·멤버를 만들고 멤버에게 두 달치 평일 근무 기록 40건을 넣는다. 응답 헤더 x-vercel-id 도 같이 기록해서 요청이 어느 리전에서 처리됐는지 본다(07편의 icn1::iad1). 스크립트 주석에는 끝나면 계정·매장을 지운다고 적혀 있지만 스크립트 자체에 지우는 코드는 없다. 히스토리에는 측정이 끝난 뒤 지웠다고 적혀 있다.

cd server && DATABASE_URL=<운영> N=10 pnpm exec tsx scripts/perf-boot.mts

결과는 멤버 부팅 합계 p50 563ms(웹 경유)·p90 1088ms, DB 를 거치지 않는 /health p50 214ms 였다.

5-2. 화면 — Resource Timing 과 layout-shift

화면 쪽은 브라우저에 이미 로그인돼 있던 사장 세션으로 쟀다. 비밀번호를 넣지 않았고 읽기만 했다. 사장 화면 5개를 iframe 으로 각 5회 열고 브라우저가 남기는 기록을 읽었다.

화면HTML TTFB/users/me 끝마지막 API (p50)CLS
/admin 대시보드143348320
/admin/calendar 달력143209140
/admin/requests 요청143127390
/admin/members 멤버133258270
/admin/store 매장143396420

표 9. 사장 화면별 데이터 도착 시점 (ms, 문서 요청 시작 기준, 5회 중앙값)

화면이 데이터를 그리는 순간을 직접 재지는 못했다. 대신 "마지막 API 응답이 끝난 시점" 을 썼다. React 는 그 직후 데이터를 그린다.

측정 도중 두 번 막혔다.

  1. 도구 제한 45초. 9회 측정을 스크립트 호출 한 번으로 기다리다가 브라우저 자동화 도구의 45초 제한을 넘었다. 측정은 페이지 안에서 돌려 두고 결과만 나중에 여러 번 읽어 오는 방식으로 바꿨다.
  2. 보이지 않는 탭. 처음에는 requestAnimationFrame 으로 스켈레톤이 사라지는 순간을 재려 했다. 그런데 측정 탭이 화면 뒤에 가려진(hidden) 상태라 브라우저가 requestAnimationFrame 을 돌리지 않았다. 같은 이유로 FCP·LCP(첫 글자·가장 큰 요소가 그려진 시점)도 기록이 남지 않았다. 그래서 Resource Timing 의 마지막 API 응답 시점으로 대신했다.

6. 검증하지 못한 것

항목상태이유
로그인한 멤버·사장 실화면미검증자동화로 계정 생성·비밀번호 입력을 하지 않는다. 가이드 예시와 iframe 으로 대신
멤버 화면 성능 실측미검증브라우저에 멤버 세션이 없어 API 부팅 순서(perf-boot.mts)로 대신
브라우저 시나리오 S20~S22미검증새로고침 뒤 로그인 유지, 탭 두 개 동시 만료, 로그아웃 동기화. 사람 확인 필요
iOS Safari 날짜 칸미검증실기기가 없다. 데스크톱 Chrome iframe 에서는 재현 안 됨
실기기 전반미검증모든 모바일 측정이 데스크톱 브라우저 iframe 이다
FCP·LCP측정 불가측정 탭이 hidden 이라 paint 기록이 없다
느린 모바일 회선미측정개발 PC 유선 회선에서만 쟀다
라이트 모드 화면미검증버튼 색 등은 다크 모드에서 쟀다
독립 QA미실시QA 역할 에이전트를 띄우지 않았다. 다시 돌린 검사는 자기 검토라 QA 로 치지 않는다
세션 상한 S17 운영로컬만운영 설정을 바꿔야 해서

표 10. 검증 한계

단위 테스트와 e2e 는 숫자로 통과를 확인할 수 있었다. 화면과 기기 쪽은 운영 가이드와 iframe 으로 좁혀 갈 수 있는 데까지만 쟀고 나머지는 위 표대로 사람이 휴대폰과 로그인한 화면으로 확인해야 하는 항목으로 남아 있다.

이전 편: [타임시트] Vercel 배포 — 웹과 API 를 따로 올리고 리전을 재다 (07)

처음으로: [타임시트] 스택과 구조 안내 (00)