UI는 좁게, API는 넓게: 타임존 설정의 두 단계 검증 전략
조직별 타임존 설정에서 드롭다운은 6개만 보여주지만 API는 IANA 전체를 받습니다. 이 '좁은 UI, 넓은 API' 패턴이 왜 단순 화이트리스트보다 나은지, 그리고 Intl.DateTimeFormat으로 어떻게 구현하는지.
1. 문제 상황
1.1 "타임존 드롭다운에 400개를 다 보여줘요?"
주간 리포트 기능을 위해 조직별 타임존 설정이 필요해졌습니다. 가장 먼저 부딪힌 결정은: 어떤 타임존을 선택할 수 있게 할 것인가.
IANA tz database에는 400개 이상의 zone이 있습니다. 모두 드롭다운에 넣으면:
- 한국 사용자가 "Asia/Seoul"을 찾으려면 스크롤 지옥
America/Los_AngelesvsAmerica/Vancouver같은 헷갈리는 옵션 다수- "실수로 아무거나 선택"해서 주차 경계가 이상해지는 리스크
반대로 6개만 허용하면:
- UX는 단순
- 하지만 해외 지사 확장 시마다 allowlist 업데이트 필요
- 테스트용으로 아무 IANA zone을 쓸 수도 없음
두 선택지 모두 극단적입니다. "UI는 좁게, API는 넓게" 패턴이 나온 이유입니다.
1.2 원하는 동작
일반 사용자:
[설정] → [타임존] 드롭다운 → 6개 중 하나 선택 → 저장
관리자/개발자 (API 직접 호출):
PATCH /api/organizations/xxx { timezone: "Europe/Helsinki" }
→ IANA validation 통과 → 저장
악의적 입력:
PATCH /api/organizations/xxx { timezone: "Not/A_Zone" }
→ 400 ValidationError
UI의 간결함과 API의 확장성, 그리고 방어벽의 견고함을 동시에 확보하는 게 목표였습니다.
2. 원인 분석
2.1 왜 API도 좁게 막지 않나
첫 번째 유혹은 "API도 같은 allowlist로 막자"입니다. 코드가 단순해지고, UI와 서버가 한 소스를 공유합니다.
// ❌ 둘 다 좁게
const ALLOWED_TZ = ["Asia/Seoul", "Asia/Tokyo", ...] as const;
// API
if (!ALLOWED_TZ.includes(body.timezone)) {
throw new ValidationError("Invalid timezone");
}
하지만 이 접근은 실제로는 다음과 같은 불편함을 만듭니다:
- 해외 지사 확장 시 배포 필수 — 타임존 하나 추가하려고 새 버전을 빌드해서 배포해야 함.
- 테스트에서 mock 어려움 — E2E 테스트가 fixed allowlist를 준수해야 함.
- 내부 도구 제약 — 관리자 스크립트가 "아무 IANA zone"을 쓸 수 없음.
2.2 "API는 넓게"의 의미
API는 기술적으로 유효한 모든 IANA 타임존을 받아들입니다. 기준은 Intl.DateTimeFormat이 생성자에서 에러 없이 통과시키는 문자열입니다.
Asia/Seoul→ ✅America/New_York→ ✅Europe/Helsinki→ ✅ (UI에는 없지만 API는 허용)UTC→ ✅Not/A_Zone→ ❌ (RangeError: Invalid time zone specified)""→ ❌
런타임의 ICU 라이브러리가 아는 모든 zone을 허용하되, 구문적으로 틀린 입력은 막습니다.
2.3 두 층의 목적
| 층 | 목적 | 방법 |
|---|---|---|
| UI | UX 간결함, 실수 방지 | curated allowlist (6개) |
| API | 기술적 확장성, 미래 대비 | IANA 전체 허용 + Intl.DateTimeFormat 검증 |
두 층은 서로 다른 우선순위를 가집니다. UI는 "사람이 실수 없이 고르기", API는 "프로그램이 유효한 값을 전달할 수 있기". 합의를 강요하지 않고 각자의 역할을 수행하게 하는 게 핵심입니다.
3. 해결 방법
3.1 UI allowlist
// src/lib/timezones.ts
export const SUPPORTED_TIMEZONES = [
{ value: "Asia/Seoul", label: "한국 표준시 (KST, UTC+9)" },
{ value: "Asia/Tokyo", label: "일본 표준시 (JST, UTC+9)" },
{ value: "America/Los_Angeles", label: "로스앤젤레스 (PST/PDT, UTC-8/-7)" },
{ value: "America/New_York", label: "뉴욕 (EST/EDT, UTC-5/-4)" },
{ value: "Europe/London", label: "런던 (GMT/BST, UTC+0/+1)" },
{ value: "Europe/Berlin", label: "베를린 (CET/CEST, UTC+1/+2)" },
] as const;
export type SupportedTimezone = (typeof SUPPORTED_TIMEZONES)[number]["value"];
export const DEFAULT_TIMEZONE: SupportedTimezone = "Asia/Seoul";
6개 선정 기준:
- 주 사용자 지역: 한국, 일본 — 프로젝트 본사/지사
- 주요 해외 지역: LA, NYC, 런던, 베를린 — 확장 시나리오
- DST 포함 혼합: LA/NYC (있음), 서울/도쿄 (없음), 런던/베를린 (있음) — 테스트 커버리지를 위해서도
각 zone에 한국어 병기 라벨을 둬서 UX를 부드럽게 했습니다.
3.2 드롭다운 컴포넌트
import { SUPPORTED_TIMEZONES } from "@/lib/timezones";
export function TimezoneSelect({ value, onChange }: {
value: string;
onChange: (v: string) => void;
}) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{SUPPORTED_TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
);
}
드롭다운은 6개 옵션만 표시합니다. 사용자가 다른 값을 선택할 방법이 없습니다.
3.3 API 검증
// src/app/api/organizations/[id]/route.ts
function isValidTimezone(tz: unknown): tz is string {
if (typeof tz !== "string" || tz.length === 0) return false;
try {
// Intl.DateTimeFormat이 받는 IANA zone인지 확인
new Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
return false;
}
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
await requireAuth();
const body = await parseBody(request);
if (body.timezone !== undefined) {
if (!isValidTimezone(body.timezone)) {
throw new ValidationError("Invalid timezone");
}
}
const updated = await prisma.organization.update({
where: { id },
data: { timezone: body.timezone },
});
return NextResponse.json(updated);
} catch (error) {
return handleApiError(error, "PATCH /api/organizations/[id]");
}
}
Intl.DateTimeFormat 생성자가 잘못된 zone에 대해 RangeError를 던진다는 동작을 이용한 유효성 검증입니다. 성공하면 (에러 없으면) 유효, 실패하면 무효.
3.4 기본값과 마이그레이션
model Organization {
// ... 기존 필드
timezone String @default("Asia/Seoul")
}
기본값 Asia/Seoul로 기존 row가 자동 backfill 됩니다. Prisma migration(또는 db push) 실행 시 기존 조직에 한국 표준시가 할당되고, 사용자가 원하면 설정 페이지에서 변경할 수 있습니다.
기본값이 UI allowlist의 첫 번째 항목과 일치하는 게 중요합니다. UI는 "현재 값을 default로 스크롤"하는 경향이 있어서, 기본값이 없는 zone이면 드롭다운이 어색해 보입니다.
4. 핵심 개념 정리
4.1 Validation vs Normalization
이 패턴의 UI 층은 "선택 강제"이고, API 층은 "검증"입니다. 두 가지는 다릅니다:
| 방식 | 설명 | 예시 |
|---|---|---|
| Normalization | 입력을 정리/변환 | " Asia/Seoul " → "Asia/Seoul" (trim) |
| Validation | 입력이 유효한지 판정 | "Asia/Seoul" → OK, "Not/A_Zone" → 400 |
| Enforcement (UI) | 유효하지 않은 입력 자체를 불가능하게 | <select>에서 6개만 |
이번 설계는 UI에서 enforcement(선택 강제) + API에서 validation(검증) 조합입니다. UI는 normalization을 해도 좋습니다 (예: 대소문자 정규화).
4.2 "Postel's Law"의 균형
인터넷 설계 원칙 중 하나가 **Postel's Law (Robustness Principle)**입니다:
"Be conservative in what you send, be liberal in what you accept."
보내는 건 엄격하게, 받는 건 관대하게.
API 층에서 이 원칙을 따릅니다 — IANA 전체를 수용. 하지만 UI 층에서는 반대로 좁게 강제합니다. 이게 Postel's Law의 반대인가요?
아니요. UI는 "사용자가 보내는 값"을 구성하는 도구이고, API는 "수신자 입장"입니다. 사용자가 보낼 값은 UI에서 좁게 제한(conservative in what you send)되어 있지만, API는 받아줄 때 넓게 수용(liberal in what you accept)합니다. 두 층이 완벽하게 Postel's Law를 구현합니다.
4.3 "확장 포인트"로서의 API 관용성
좁은 API는 확장이 어려운 시스템을 만듭니다. 이번 프로젝트의 현실적 고려사항:
- E2E 테스트에서
UTC를 테스트 전용으로 사용 - 관리자 CLI 스크립트가 API를 통해
Europe/Helsinki를 설정 - 미래의 지사 확장 시 UI 업데이트 없이도 API 레벨 지원
이런 "예외적 접근"이 가능하려면 API는 allowlist에 묶이면 안 됩니다. 단, UI를 통한 접근은 여전히 좁게 유지되므로 일반 사용자에게는 영향이 없습니다.
5. 베스트 프랙티스
5.1 체크리스트
- [ ] UI allowlist는 프로덕트 결정 (사용자 지역/언어에 맞게)
- [ ] API 검증은 기술적 유효성 (런타임이 지원하는 전체 범위)
- [ ] 기본값은 UI allowlist의 첫 항목과 일치
- [ ] Prisma
@default(...)또는 migration으로 기존 데이터 backfill - [ ] 두 층의 차이를 주석으로 문서화
- [ ] E2E에서 UI는 좁게 검증, API 단위 테스트는 넓게 검증
5.2 주석으로 의도 명시
/**
* 주간 리포트의 주차 경계 계산에 사용되는 IANA 타임존 목록.
*
* UI 드롭다운은 이 allowlist로 제한되지만, API는 `Intl.DateTimeFormat` 검증만
* 통과하면 IANA 문자열을 모두 허용한다 (향후 확장 여지).
*/
export const SUPPORTED_TIMEZONES = [...];
이 주석 한 블록이 미래의 PR에서 "왜 API도 이 allowlist로 막지 않죠?"라는 질문에 대한 답이 됩니다.
5.3 새 zone 추가 절차
UI에 새 zone을 추가하는 건 작은 작업이지만, 체크할 게 있습니다:
SUPPORTED_TIMEZONES에 추가- 한국어 라벨이 자연스러운지 확인
- DST가 있는 zone인지 체크하고 알맞은 라벨 (
PST/PDT같은 형식) - 기존 테스트가 여전히 통과하는지
- 관리자에게 알림 (기본값 변경 고려)
API 검증 쪽은 건드릴 필요가 없습니다. 새 zone을 추가해도 Intl.DateTimeFormat은 이미 지원하고 있기 때문입니다.
5.4 테스트 분리
describe("Timezone validation (API)", () => {
it.each([
"Asia/Seoul", "America/New_York", "UTC", "Europe/Helsinki", // allowlist 밖
])("유효한 IANA zone 수용: %s", async (tz) => {
expect(isValidTimezone(tz)).toBe(true);
});
it.each([
"", "Not/A_Zone", "KST", "Asia / Seoul", // 공백 포함
])("유효하지 않은 값 거부: %s", async (tz) => {
expect(isValidTimezone(tz)).toBe(false);
});
});
describe("Timezone dropdown (UI)", () => {
it("6개 옵션만 표시", () => {
const { container } = render(<TimezoneSelect value="Asia/Seoul" onChange={() => {}} />);
expect(container.querySelectorAll("option")).toHaveLength(6);
});
});
UI 테스트는 allowlist 범위, API 테스트는 넓은 범위. 두 축이 명확히 분리됩니다.
6. FAQ
Q. Intl.DateTimeFormat이 zone을 검증하는 건 비공식 동작 아닌가요?
A. MDN과 ECMA-402 스펙에 명시되어 있는 정식 동작입니다. 잘못된 IANA zone이 주어지면 RangeError를 throw합니다. 향후 스펙이 바뀌는 시나리오는 매우 드뭅니다.
Q. new Date().toLocaleString("en-US", { timeZone: "..." })로도 검증 가능하지 않나요?
A. 가능합니다. 같은 메커니즘입니다 — 내부적으로 Intl.DateTimeFormat을 씁니다. 하지만 new Intl.DateTimeFormat("en-US", { timeZone }) 쪽이 의도가 명확하고, formatter 객체를 재사용할 수도 있습니다.
Q. UTC는 IANA zone이 아닌데 어떻게 통과하나요?
A. Intl.DateTimeFormat은 "UTC"를 특수 alias로 허용합니다. "Etc/UTC"도 같이 허용됩니다. 스펙에서 명시된 추가 허용 값이라고 보면 됩니다.
Q. 사용자가 API로 Europe/Helsinki를 설정하면 UI 드롭다운에 뭐가 표시되나요?
A. 설정된 값이 allowlist에 없으면 드롭다운은 그 값을 보여줄 방법이 없어서 첫 항목(Asia/Seoul)으로 보이거나 빈 값이 됩니다. 이 엣지 케이스를 위해 UI는 "현재 값이 allowlist에 없으면 임시로 한 항목을 추가해서 표시"하는 로직을 넣을 수 있습니다:
const options = SUPPORTED_TIMEZONES.some((t) => t.value === value)
? SUPPORTED_TIMEZONES
: [...SUPPORTED_TIMEZONES, { value, label: value }];
Q. UI 드롭다운에 6개 모두 같은 UTC+9인데 같이 두는 건 과한가요?
A. 일본과 한국은 지역 구분 상 다르게 표시해야 합니다. 시차는 같지만 DST가 바뀔 가능성(과거 논의 이력), 공휴일, 내부 협업 등에서 구분이 의미 있을 수 있어요. "같아 보이므로 생략"은 커뮤니케이션 오류의 씨앗입니다.
Q. 이 패턴을 다른 필드에도 적용할 수 있나요?
A. 네. 같은 패턴이 유용한 곳:
- 언어 코드: UI 드롭다운은 6개, API는 BCP 47 전체
- 통화 코드: UI는 주요 통화, API는 ISO 4217 전체
- 국가 코드: UI는 주요 국가, API는 ISO 3166 전체
- 파일 확장자: UI는 Upload 버튼에
accept속성으로 좁게, API는 MIME type 검증으로 넓게
"사용자가 자주 고르는 값"과 "시스템이 원칙적으로 받을 수 있는 값"을 분리하는 원칙이 일반화됩니다.
7. 참고 자료
- IANA Time Zone Database
- MDN - Intl.DateTimeFormat
- Wikipedia - Postel's Law
- ECMA-402 - Internationalization API
- 관련 글: Docker는 UTC, 사용자는 KST: Intl.DateTimeFormat만으로 만든 tz-aware ISO Week
9. 다음 단계
타임존을 조직별로 설정할 수 있게 되면, 다음 고민은 사용자별 타임존입니다. 한 조직 안에서 서울/싱가포르 원격 멤버가 섞여 있다면 주간 리포트의 경계를 개인별로 다르게 볼 수도 있어야 할까? 이 결정은 프로덕트 복잡도를 크게 늘리므로 현재 버전에서는 보류했습니다. 요구사항이 구체화되면 User.timezone nullable 필드를 도입해 "설정된 경우 개인 tz, 없으면 조직 기본값" 패턴으로 확장할 수 있습니다.