
프로그래밍을 처음 배울 때 타입 변환은 보통 한 자료형을 다른 자료형으로 바꾸는 것이라고 배운다.
const amount = Number("100");
const message = String(100);
const isActive = Boolean(1);
문자열 "100"은 숫자 100이 되고, 숫자 100은 문자열 "100"이 된다.
타입 변환의 기본 개념을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 타입을 변환한다는 것은 단순히 String, Number, Boolean 함수를 호출하는 것보다 훨씬 많은 판단을 요구한다.
0은 어떻게 구분할 것인가?"false"를 Boolean으로 변환하면 어떤 값이 되는가?"10.99"는 부동소수점 숫자로 관리해도 되는가?실제 서비스에서 타입 변환은 외부 데이터의 모양만 바꾸는 작업이 아니다.
타입 변환은 외부 세계의 불확실한 데이터를 서비스가 이해하고 신뢰할 수 있는 형태로 해석하고 정규화하는 과정이다.
쿠폰 서비스를 만든다고 가정해 보자.
사용자는 쿠폰 제목, 사용 가능 횟수, 만료일을 폼에 입력한다.
<input name="title" type="text" />
<input name="remainingUses" type="number" />
<input name="expiresAt" type="date" />
화면에서 사용 가능 횟수를 숫자 입력창으로 받았더라도 브라우저에서 읽은 값은 문자열일 수 있다.
const remainingUsesInput =
formData.get("remainingUses");
console.log(remainingUsesInput);
// "3"
type="number"는 사용자가 숫자 형태를 입력하도록 도와주지만, 애플리케이션에 숫자 타입이 전달된다는 뜻은 아니다.
URL의 쿼리 파라미터도 문자열이다.
/coupons?page=2&limit=20&active=true
const page = searchParams.get("page");
// "2"
const limit = searchParams.get("limit");
// "20"
const active = searchParams.get("active");
// "true"
환경변수도 문자열로 들어온다.
process.env.COUPON_EXPIRY_DAYS;
// "30"
데이터베이스 드라이버나 외부 API도 서비스 내부에서 원하는 형태와 다른 값을 반환할 수 있다.
{
"price": "19.90",
"expiresAt": "2026-12-31T23:59:59Z",
"remainingUses": "3",
"isActive": "true"
}
외부 데이터의 타입은 서비스가 원하는 타입과 일치한다고 보장할 수 없다.
flowchart LR
A[HTML Form]
--> F[입력 경계]
B[URL Query]
--> F
C[HTTP Request]
--> F
D[Environment Variable]
--> F
E[External API]
--> F
F --> G[변환]
G --> H[검증]
H --> I[정규화]
I --> J[서비스 내부 모델]
외부 세계에는 문자열, 빈 문자열, null, 누락된 필드, 예상하지 못한 값이 섞여 있다.
서비스 내부까지 이 차이를 그대로 가져오면 모든 비즈니스 로직이 같은 변환과 검증을 반복해야 한다.
쿠폰 사용 가능 횟수를 숫자로 바꿔 보자.
const remainingUses =
Number(request.body.remainingUses);
문자열 "3"은 숫자 3으로 변환된다.
Number("3");
// 3
하지만 Number는 예상보다 많은 값을 숫자로 바꾼다.
Number("");
// 0
Number(" ");
// 0
Number(null);
// 0
Number(true);
// 1
Number("three");
// NaN
사용자가 아무 값도 입력하지 않아 빈 문자열이 들어왔는데 0으로 변환될 수 있다.
서비스는 이를 “사용 횟수가 0인 쿠폰”이라고 잘못 해석할 수 있다.
const remainingUses =
Number(request.body.remainingUses);
if (remainingUses === 0) {
throw new Error(
"사용 가능 횟수는 0보다 커야 한다."
);
}
오류는 발생하지만 실제 원인은 다르다.
사용자가 0을 입력한 것이 아니라 아무것도 입력하지 않았을 수 있다.
타입 변환은 값의 형태를 바꿀 뿐, 그 값의 의미까지 보장하지 않는다.
따라서 변환 전에 입력 상태를 확인하고, 변환 후에는 결과를 검증해야 한다.
function parseRemainingUses(
value: unknown
): number {
if (
typeof value !== "string" ||
value.trim() === ""
) {
throw new Error(
"사용 가능 횟수를 입력해야 한다."
);
}
const remainingUses = Number(value);
if (!Number.isInteger(remainingUses)) {
throw new Error(
"사용 가능 횟수는 정수여야 한다."
);
}
if (remainingUses < 1) {
throw new Error(
"사용 가능 횟수는 1 이상이어야 한다."
);
}
return remainingUses;
}
이 함수는 단순히 문자열을 숫자로 바꾸지 않는다.
변환에 성공했다는 것은 JavaScript가 값을 숫자로 만들었다는 뜻이다.
서비스가 사용할 수 있다는 것은 그 숫자가 서비스 규칙까지 만족한다는 뜻이다.
다음 함수는 매개변수를 문자열로 선언한다.
function createCoupon(
remainingUses: string
) {
const value = Number(remainingUses);
}
하지만 이 값이 HTTP 요청에서 왔다면 실제로 문자열이라고 확신하기 어렵다.
클라이언트는 숫자, 배열, 객체 또는 null을 보낼 수도 있다.
{
"remainingUses": {
"value": 3
}
}
TypeScript 타입은 컴파일 과정에서 코드의 사용을 검사하지만, 외부에서 들어오는 실제 데이터를 변경하지 않는다.
interface CreateCouponRequest {
remainingUses: string;
}
const input =
request.body as CreateCouponRequest;
타입 단언을 했다고 요청 데이터가 안전해지는 것은 아니다.
input.remainingUses.trim();
실제 값이 객체라면 런타임 오류가 발생한다.
외부 데이터는 아직 타입을 모르는 값으로 다루는 편이 더 정확하다.
function parseRemainingUses(
value: unknown
): number {
if (typeof value !== "string") {
throw new Error(
"사용 가능 횟수는 문자열로 전달해야 한다."
);
}
// 변환과 검증
}
unknown은 불편함을 만들기 위한 타입이 아니다.
값을 사용하기 전에 실제 형태를 확인하도록 만드는 경계다.
외부 데이터에 타입을 붙이는 것과 외부 데이터가 그 타입인지 확인하는 것은 서로 다른 작업이다.
세 작업은 함께 실행되는 경우가 많지만 목적은 다르다.
const remainingUses =
Number("3");
문자열 "3"을 숫자 3으로 바꾼다.
if (
!Number.isInteger(remainingUses) ||
remainingUses < 1
) {
throw new Error(
"사용 가능 횟수는 1 이상의 정수여야 한다."
);
}
숫자로 변환된 값이 쿠폰 서비스에서 사용할 수 있는지 확인한다.
사용자가 다음과 같이 쿠폰 제목을 입력할 수 있다.
" Coffee Date "
"coffee date"
"COFFEE DATE"
서비스가 대소문자를 유지하되 앞뒤 공백만 제거하기로 했다면 다음과 같이 정규화할 수 있다.
function normalizeCouponTitle(
value: string
): string {
return value.trim();
}
normalizeCouponTitle(
" Coffee Date "
);
// "Coffee Date"
변환, 검증, 정규화를 하나의 흐름으로 연결할 수 있다.
function parseCouponTitle(
value: unknown
): string {
if (typeof value !== "string") {
throw new Error(
"쿠폰 제목은 문자열이어야 한다."
);
}
const title = value.trim();
if (title.length === 0) {
throw new Error(
"쿠폰 제목을 입력해야 한다."
);
}
if (title.length > 100) {
throw new Error(
"쿠폰 제목은 100자 이하여야 한다."
);
}
return title;
}
이 함수가 반환한 문자열은 단순한 입력 문자열이 아니다.
쿠폰 제목으로 사용할 수 있도록 확인되고 정리된 값이다.
URL에서 활성화된 쿠폰만 조회한다고 가정해 보자.
/coupons?active=false
쿼리 파라미터는 문자열 "false"다.
이를 다음과 같이 변환하면 문제가 생긴다.
const active =
Boolean(searchParams.get("active"));
console.log(active);
// true
문자열 "false"는 비어 있지 않은 문자열이므로 true가 된다.
Boolean("false");
// true
Boolean("0");
// true
Boolean("no");
// true
Boolean 함수는 문자열 안에 적힌 단어의 의미를 해석하지 않는다.
값이 truthy인지 falsy인지만 판단한다.
서비스가 허용하는 표현을 직접 정의해야 한다.
function parseBoolean(
value: unknown
): boolean {
if (value === true || value === "true") {
return true;
}
if (value === false || value === "false") {
return false;
}
throw new Error(
"active는 true 또는 false여야 한다."
);
}
필드를 생략했을 때 기본값을 사용해야 한다면 그것도 명시한다.
function parseActiveFilter(
value: unknown
): boolean | undefined {
if (value === undefined) {
return undefined;
}
return parseBoolean(value);
}
여기서 각 값의 의미는 다음과 같다.
| 입력 | 서비스 내부 값 | 의미 |
|---|---|---|
"true" | true | 활성 쿠폰만 조회 |
"false" | false | 비활성 쿠폰만 조회 |
| 필드 생략 | undefined | 활성 상태로 필터링하지 않음 |
"yes" | 오류 | 지원하지 않는 표현 |
Boolean 변환은 값을 참과 거짓으로 만드는 일이 아니라 외부 표현을 서비스가 합의한 두 상태로 해석하는 일이다.
쿠폰 만료일을 다음과 같이 전달받았다고 가정해 보자.
{
"expiresAt": "2026-12-31"
}
문자열을 Date로 변환할 수 있다.
const expiresAt =
new Date("2026-12-31");
하지만 날짜 변환에는 추가 질문이 필요하다.
시드니 사용자를 위한 쿠폰이라면 "2026-12-31"만으로 정확한 만료 순간을 결정하기 어렵다.
또한 new Date는 유효하지 않은 날짜에서도 예외를 던지지 않는다.
const expiresAt =
new Date("not-a-date");
console.log(expiresAt);
// Invalid Date
따라서 결과를 확인해야 한다.
function parseExpiresAt(
value: unknown
): Date | null {
if (value === null || value === "") {
return null;
}
if (typeof value !== "string") {
throw new Error(
"만료일은 문자열 또는 null이어야 한다."
);
}
const expiresAt = new Date(value);
if (Number.isNaN(expiresAt.getTime())) {
throw new Error(
"올바르지 않은 만료일이다."
);
}
return expiresAt;
}
하지만 이 함수가 기술적으로 유효한 날짜를 만들었다고 모든 검증이 끝난 것은 아니다.
쿠폰 정책상 과거 날짜를 허용하지 않는다면 비즈니스 검증이 추가로 필요하다.
function validateFutureExpiryDate(
expiresAt: Date | null,
now: Date
): void {
if (
expiresAt !== null &&
expiresAt <= now
) {
throw new Error(
"만료일은 현재 시간 이후여야 한다."
);
}
}
날짜 문자열을 Date로 바꾸는 것은 타입 변환이다.
시간대를 결정하고, 유효성을 확인하며, 서비스 정책을 적용하는 것은 날짜를 서비스의 시간 개념으로 해석하는 과정이다.
쿠폰 구매 금액이 다음과 같이 들어왔다고 가정해 보자.
{
"price": "19.90"
}
간단하게 숫자로 변환할 수 있다.
const price = Number("19.90");
// 19.9
하지만 JavaScript의 일반적인 숫자는 부동소수점 방식으로 동작한다.
0.1 + 0.2;
// 0.30000000000000004
금액을 그대로 소수로 관리하면 계산과 반올림에서 예상하지 못한 결과가 생길 수 있다.
금액을 가장 작은 화폐 단위로 정규화할 수 있다.
호주 달러라면 달러를 센트로 변환한다.
function parsePriceInCents(
value: unknown
): number {
if (typeof value !== "string") {
throw new Error(
"가격은 문자열로 전달해야 한다."
);
}
const normalized = value.trim();
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
throw new Error(
"가격은 소수점 둘째 자리까지 입력할 수 있다."
);
}
const [dollars, cents = ""] =
normalized.split(".");
const priceInCents =
Number(dollars) * 100 +
Number(cents.padEnd(2, "0"));
if (!Number.isSafeInteger(priceInCents)) {
throw new Error(
"처리할 수 없는 가격이다."
);
}
return priceInCents;
}
parsePriceInCents("19.90");
// 1990
parsePriceInCents("19.9");
// 1990
parsePriceInCents("19");
// 1900
서비스 내부에서는 금액을 정수로 관리한다.
interface PaidCoupon {
priceInCents: number;
currency: "AUD";
}
이제 1990은 단순한 숫자가 아니다.
AUD 19.90을 센트 단위로 정규화한 값이다.
가격의 타입을 변환한다는 것은 문자열을 숫자로 바꾸는 것뿐 아니라 단위와 정밀도를 결정하는 일이다.
쿠폰 종류를 문자열로 받는다고 가정해 보자.
{
"type": "gift"
}
다음과 같이 타입을 단언할 수 있다.
type CouponType =
| "gift"
| "promise"
| "event";
const couponType =
request.body.type as CouponType;
하지만 타입 단언은 "discount" 같은 값을 막지 못한다.
{
"type": "discount"
}
외부 값을 허용된 서비스 값으로 변환해야 한다.
const COUPON_TYPES = [
"gift",
"promise",
"event",
] as const;
type CouponType =
(typeof COUPON_TYPES)[number];
function parseCouponType(
value: unknown
): CouponType {
if (
typeof value !== "string" ||
!COUPON_TYPES.includes(
value as CouponType
)
) {
throw new Error(
"지원하지 않는 쿠폰 종류다."
);
}
return value as CouponType;
}
외부 시스템이 대문자를 보내는 경우 이를 허용하기로 결정할 수도 있다.
function parseCouponType(
value: unknown
): CouponType {
if (typeof value !== "string") {
throw new Error(
"쿠폰 종류는 문자열이어야 한다."
);
}
const normalized =
value.trim().toLowerCase();
if (
!COUPON_TYPES.includes(
normalized as CouponType
)
) {
throw new Error(
"지원하지 않는 쿠폰 종류다."
);
}
return normalized as CouponType;
}
parseCouponType(" GIFT ");
// "gift"
어떤 표현까지 허용할지는 서비스의 계약이다.
무조건 소문자로 바꾸는 것이 정답은 아니다. 대소문자가 의미를 가지는 코드나 ID라면 원본을 변경하면 안 된다.
정규화는 데이터를 임의로 수정하는 작업이 아니라 어떤 차이를 같은 것으로 취급할지 결정하는 작업이다.
URL에서 쿠폰 ID를 받았다고 가정해 보자.
/coupons/00123
이 값을 숫자로 변환하면 앞의 0이 사라진다.
Number("00123");
// 123
하지만 "00123"과 "123"이 서로 다른 식별자일 수 있다.
전화번호도 마찬가지다.
Number("0412345678");
// 412345678
우편번호, 주문번호, 쿠폰 코드도 숫자처럼 보이지만 계산을 위한 값이 아닐 수 있다.
interface Coupon {
id: string;
redemptionCode: string;
}
타입을 결정할 때 값의 모양만 봐서는 안 된다.
그 값으로 어떤 행동을 하는지 봐야 한다.
0이 의미를 가지는가?숫자로 구성된 문자열이라고 모두 숫자로 변환해야 하는 것은 아니다.
타입은 값이 어떻게 생겼는지가 아니라 서비스가 그 값을 어떻게 사용하는지를 표현해야 한다.
쿠폰 만료일이 선택 사항이라고 가정해 보자.
외부에서는 여러 형태로 값이 들어올 수 있다.
"";
null;
undefined;
"2026-12-31T23:59:59Z";
서비스 내부에서도 이 값을 모두 허용하면 타입이 복잡해진다.
interface Coupon {
expiresAt:
| Date
| ""
| null
| undefined;
}
쿠폰이 만료되었는지 확인할 때마다 모든 상태를 처리해야 한다.
function isCouponExpired(
coupon: Coupon
): boolean {
if (
coupon.expiresAt === "" ||
coupon.expiresAt === null ||
coupon.expiresAt === undefined
) {
return false;
}
return coupon.expiresAt < new Date();
}
입력 경계에서 하나의 내부 표현으로 정규화하면 서비스 로직이 단순해진다.
interface Coupon {
expiresAt: Date | null;
}
function isCouponExpired(
coupon: Coupon,
now: Date
): boolean {
return (
coupon.expiresAt !== null &&
coupon.expiresAt <= now
);
}
외부의 다양한 표현은 입력 처리 계층에서 해석한다.
function parseExpiresAt(
value: unknown
): Date | null {
if (
value === undefined ||
value === null ||
value === ""
) {
return null;
}
if (typeof value !== "string") {
throw new Error(
"올바르지 않은 만료일 형식이다."
);
}
const expiresAt = new Date(value);
if (Number.isNaN(expiresAt.getTime())) {
throw new Error(
"올바르지 않은 만료일이다."
);
}
return expiresAt;
}
이 함수가 서비스 경계 역할을 한다.
flowchart LR
A[빈 문자열]
--> E[입력 변환]
B[null]
--> E
C[undefined]
--> E
D[날짜 문자열]
--> E
E --> F[Date 또는 null]
F --> G[쿠폰 비즈니스 로직]
서비스 내부의 각 함수가 외부 데이터의 모든 변형을 알 필요는 없다.
클라이언트가 보내는 쿠폰 생성 요청은 다음과 같을 수 있다.
{
"title": " Coffee Date ",
"remainingUses": "3",
"expiresAt": "2026-12-31T23:59:59Z",
"isTransferable": "false"
}
요청 데이터의 타입은 서비스가 사용하는 타입과 다르다.
interface CreateCouponRequest {
title: unknown;
remainingUses: unknown;
expiresAt: unknown;
isTransferable: unknown;
}
서비스 내부에서는 다음과 같은 객체를 사용할 수 있다.
interface CreateCouponInput {
title: string;
remainingUses: number;
expiresAt: Date | null;
isTransferable: boolean;
}
두 구조 사이를 변환하는 함수를 만든다.
function parseCreateCouponRequest(
body: Record<string, unknown>
): CreateCouponInput {
return {
title: parseCouponTitle(body.title),
remainingUses:
parseRemainingUses(
body.remainingUses
),
expiresAt:
parseExpiresAt(body.expiresAt),
isTransferable:
parseBoolean(body.isTransferable),
};
}
const input =
parseCreateCouponRequest(
request.body
);
이 지점을 통과한 후의 서비스 로직은 입력 문자열과 HTTP 형식을 알 필요가 없다.
const coupon =
createCoupon(input, currentUser);
요청 객체는 외부 시스템과의 계약을 표현한다.
서비스 객체는 비즈니스 로직이 사용할 수 있는 상태를 표현한다.
둘을 억지로 같은 타입으로 만들면 외부 데이터의 불확실성이 서비스 내부까지 퍼진다.
데이터베이스에서 쿠폰을 조회했을 때 다음과 같은 행을 받을 수 있다.
interface CouponRow {
id: string;
title: string;
remaining_uses: number;
expires_at: string | null;
is_transferable: boolean;
}
서비스에서는 이름과 타입을 다르게 사용할 수 있다.
interface Coupon {
id: string;
title: string;
remainingUses: number;
expiresAt: Date | null;
isTransferable: boolean;
}
데이터베이스 행을 서비스 객체로 변환한다.
function toCoupon(
row: CouponRow
): Coupon {
const expiresAt =
row.expires_at === null
? null
: new Date(row.expires_at);
if (
expiresAt !== null &&
Number.isNaN(expiresAt.getTime())
) {
throw new Error(
"데이터베이스에 올바르지 않은 만료일이 저장되어 있다."
);
}
return {
id: row.id,
title: row.title,
remainingUses:
row.remaining_uses,
expiresAt,
isTransferable:
row.is_transferable,
};
}
API 응답으로 보낼 때는 다시 JSON에 적합한 형태로 변환한다.
interface CouponResponse {
id: string;
title: string;
remainingUses: number;
expiresAt: string | null;
isTransferable: boolean;
}
function toCouponResponse(
coupon: Coupon
): CouponResponse {
return {
id: coupon.id,
title: coupon.title,
remainingUses:
coupon.remainingUses,
expiresAt:
coupon.expiresAt?.toISOString()
?? null,
isTransferable:
coupon.isTransferable,
};
}
하나의 쿠폰은 계층에 따라 서로 다른 형태로 표현된다.
flowchart LR
A[HTTP Request<br/>문자열과 unknown]
--> B[입력 변환 및 검증]
--> C[Service Model<br/>Date, number, boolean]
--> D[Database Mapping]
--> E[Database Row]
C --> F[Response Mapping]
--> G[JSON Response<br/>문자열과 null]
중요한 것은 모든 계층에서 같은 타입을 사용하는 것이 아니다.
각 경계에서 다음 계층이 이해할 수 있는 형태로 명시적으로 변환하는 것이다.
사용자가 쿠폰 제목을 입력했다.
const rawTitle =
" Coffee Date ";
서비스에서는 앞뒤 공백을 제거한 제목을 사용한다.
const normalizedTitle =
rawTitle.trim();
대부분의 경우 정규화된 값만 저장해도 충분하다.
const coupon = {
title: normalizedTitle,
};
하지만 원본이 필요한 서비스도 있다.
예를 들어 외부 결제 시스템의 응답이나 법적 동의 기록처럼 전달받은 원문 자체를 보존해야 할 수 있다.
const paymentRecord = {
rawResponse,
normalizedStatus,
};
이때 두 값은 서로 다른 목적을 가진다.
정규화된 값을 만든 뒤에도 모든 로직이 다시 원본을 참조하면 기준이 두 개가 된다.
const canRedeem =
rawCoupon.remainingUses !== "0";
const displayRemainingUses =
normalizedCoupon.remainingUses;
같은 판단에 서로 다른 표현을 사용하면 결과가 달라질 수 있다.
비즈니스 로직은 검증된 내부 모델을 기준으로 동작해야 한다.
const canRedeem =
coupon.remainingUses > 0;
원본을 보관해야 한다면 감사 기록이나 문제 추적 용도로 분리하고, 서비스 판단의 기준은 정규화된 값으로 통일한다.
JavaScript는 필요할 때 자동으로 타입을 변환한다.
"5" + 1;
// "51"
"5" - 1;
// 4
"2" < 10;
// true
이 동작을 암기하는 것보다 중요한 것은 실제 서비스에서 자동 변환에 의존하지 않는 것이다.
쿠폰 사용 횟수를 증가시키는 코드를 생각해 보자.
let remainingUses = "3";
remainingUses += 1;
console.log(remainingUses);
// "31"
코드는 실행되지만 서비스의 데이터는 잘못되었다.
경계에서 타입을 확정하면 내부 로직은 명확해진다.
const remainingUses =
parseRemainingUses(
request.body.remainingUses
);
const increasedRemainingUses =
remainingUses + 1;
엄격한 비교도 의도를 분명하게 만든다.
"3" == 3;
// true
"3" === 3;
// false
자동 변환이 편리한 경우도 있지만 외부 데이터를 처리하는 서비스 경계에서는 변환 규칙을 코드에 명시하는 편이 안전하다.
페이지 번호를 처리한다고 가정해 보자.
const page =
Number(searchParams.get("page")) || 1;
짧은 코드지만 여러 입력이 모두 1로 바뀐다.
Number(undefined) || 1;
// 1
Number("invalid") || 1;
// 1
Number("0") || 1;
// 1
필드가 생략된 경우에는 기본값 1이 적절할 수 있다.
하지만 "invalid"는 잘못된 요청이다. 조용히 1로 바꾸면 클라이언트의 오류를 발견하기 어려워진다.
상태를 구분한다.
function parsePage(
value: unknown
): number {
if (value === undefined) {
return 1;
}
if (
typeof value !== "string" ||
value.trim() === ""
) {
throw new Error(
"페이지 번호가 올바르지 않다."
);
}
const page = Number(value);
if (
!Number.isInteger(page) ||
page < 1
) {
throw new Error(
"페이지 번호는 1 이상의 정수여야 한다."
);
}
return page;
}
이제 입력의 의미가 명확하다.
| 입력 | 처리 |
|---|---|
| 필드 생략 | 기본값 1 사용 |
"2" | 숫자 2로 변환 |
"0" | 유효성 오류 |
"1.5" | 유효성 오류 |
"invalid" | 형식 오류 |
"" | 입력 오류 |
기본값은 값이 의도적으로 생략되었을 때 사용할 수 있다.
변환 실패까지 기본값으로 덮으면 서비스가 잘못된 입력을 정상 데이터처럼 받아들이게 된다.
쿠폰 생성 요청에서 다음 규칙을 정했다고 가정해 보자.
title은 앞뒤 공백을 제거한 1자 이상 100자 이하의 문자열이다.remainingUses는 문자열 또는 숫자로 전달할 수 있는 1 이상의 정수다.expiresAt은 ISO 8601 문자열 또는 null이다.isTransferable은 Boolean 값만 허용한다.type은 gift, promise, event 중 하나다.이 규칙은 단순한 구현 세부사항이 아니다.
클라이언트와 서버가 데이터를 주고받는 계약이다.
interface CreateCouponInput {
title: string;
remainingUses: number;
expiresAt: Date | null;
isTransferable: boolean;
type: CouponType;
}
입력 처리 함수는 그 계약을 코드로 구현한다.
function parseCreateCouponRequest(
body: Record<string, unknown>
): CreateCouponInput {
return {
title:
parseCouponTitle(body.title),
remainingUses:
parseRemainingUses(
body.remainingUses
),
expiresAt:
parseExpiresAt(
body.expiresAt
),
isTransferable:
parseBoolean(
body.isTransferable
),
type:
parseCouponType(body.type),
};
}
계약이 문서와 코드에서 일치하면 프론트엔드, 백엔드, 테스트가 같은 기준을 사용할 수 있다.
변환 규칙이 여러 컨트롤러와 컴포넌트에 흩어지면 같은 입력을 서로 다르게 해석하게 된다.
타입 변환을 다루는 방식은 서비스가 성장하면서 달라진다.
const remainingUses =
Number(request.body.remainingUses);
간단하지만 빈 문자열, NaN, 소수, 음수를 구분하지 못한다.
const remainingUses =
Number(request.body.remainingUses);
if (Number.isNaN(remainingUses)) {
throw new Error(
"숫자를 입력해야 한다."
);
}
숫자 변환 실패는 확인할 수 있지만 서비스 규칙은 아직 부족하다.
if (
!Number.isInteger(remainingUses) ||
remainingUses < 1
) {
throw new Error(
"사용 가능 횟수는 1 이상의 정수여야 한다."
);
}
숫자 타입뿐 아니라 쿠폰에서 허용되는 값인지 확인한다.
const remainingUses =
parseRemainingUses(
request.body.remainingUses
);
변환과 검증 규칙에 이름이 생기고 여러 입력 경계에서 일관되게 사용할 수 있다.
const input =
parseCreateCouponRequest(
request.body
);
const coupon =
createCoupon(input, currentUser);
서비스 로직은 HTTP 요청의 문자열과 빈 값 표현을 알 필요가 없어진다.
const input =
parseCreateCouponRequest(
request.body
);
const coupon =
createCoupon(input, currentUser);
const row =
toCouponRow(coupon);
const savedRow =
await couponRepository.save(row);
const savedCoupon =
toCoupon(savedRow);
return toCouponResponse(savedCoupon);
각 계층이 사용하는 표현과 책임이 분리된다.
0, 오류 중 무엇을 의미하는가?null, undefined를 모두 내부에 남겨둘 필요가 있는가?Date를 JSON 응답에서 문자열로 직렬화하는가?이 질문에 답하면 타입 변환을 임시 처리 코드가 아니라 서비스 경계의 설계로 다룰 수 있다.
const remainingUses =
Number(request.body.remainingUses);
빈 문자열은 0이 되고 잘못된 문자열은 NaN이 된다.
const remainingUses =
parseRemainingUses(
request.body.remainingUses
);
입력 상태, 숫자 변환 결과, 정수 여부, 허용 범위를 함께 확인해야 한다.
const isTransferable =
Boolean(request.body.isTransferable);
Boolean("false");
// true
const isTransferable =
parseBoolean(
request.body.isTransferable
);
서비스가 허용하는 참과 거짓 표현을 명시적으로 해석해야 한다.
const input =
request.body as CreateCouponInput;
타입 단언은 런타임 데이터를 검사하지 않는다.
const input =
parseCreateCouponRequest(
request.body
);
외부 값의 실제 형태를 확인한 후 내부 타입으로 변환해야 한다.
const page =
Number(request.query.page) || 1;
잘못된 문자열도 첫 페이지 요청으로 처리될 수 있다.
const page =
parsePage(request.query.page);
필드 생략과 잘못된 값을 구분해야 한다.
const couponCode =
Number("00123");
쿠폰 코드 "00123"이 123으로 변경된다.
const couponCode = "00123";
계산하지 않는 식별자는 문자열로 유지하는 편이 적절하다.
const expiresAt =
new Date(request.body.expiresAt);
유효하지 않은 날짜와 시간대 규칙을 확인하지 않는다.
const expiresAt =
parseExpiresAt(
request.body.expiresAt
);
validateFutureExpiryDate(
expiresAt,
new Date()
);
기술적으로 변환 가능한 날짜와 서비스에서 허용되는 날짜를 구분해야 한다.
const price =
Number(request.body.price);
금액 계산에서 정밀도 문제가 생길 수 있다.
const priceInCents =
parsePriceInCents(
request.body.price
);
통화와 단위를 정하고 가장 작은 화폐 단위의 정수로 정규화할 수 있다.
interface Coupon {
expiresAt:
| Date
| ""
| null
| undefined;
}
모든 비즈니스 로직이 같은 분기를 반복한다.
interface Coupon {
expiresAt: Date | null;
}
서비스 경계에서 외부 표현을 하나의 내부 형태로 정리해야 한다.
const couponCode =
input.trim().toLowerCase();
쿠폰 코드가 대소문자를 구분한다면 다른 코드로 바뀔 수 있다.
const couponCode =
parseCouponCode(input);
정규화하기 전에 어떤 차이를 같은 것으로 취급할지 서비스 규칙을 정의해야 한다.
타입 변환은 한 자료형을 다른 자료형으로 바꾸는 기능이다.
Number("3");
// 3
하지만 실제 서비스에서는 이 코드만으로 충분하지 않다.
외부 데이터는 처음부터 서비스가 기대하는 형태로 들어오지 않는다.
"", null, undefined 등으로 표현될 수 있다.따라서 서비스 경계에서는 세 가지 작업이 필요하다.
변환
→ 검증
→ 정규화
변환은 값의 표현 형식을 바꾼다.
const remainingUses =
Number(value);
검증은 서비스에서 허용할 수 있는 값인지 확인한다.
if (
!Number.isInteger(remainingUses) ||
remainingUses < 1
) {
throw new Error(
"사용 가능 횟수는 1 이상의 정수여야 한다."
);
}
정규화는 여러 외부 표현을 하나의 내부 표현으로 통일한다.
interface Coupon {
expiresAt: Date | null;
}
요청 데이터와 서비스 객체도 분리할 수 있다.
const input =
parseCreateCouponRequest(
request.body
);
const coupon =
createCoupon(input, currentUser);
입력 변환을 통과한 이후의 서비스 로직은 빈 문자열, 숫자 문자열, 잘못된 Boolean 표현을 반복해서 처리하지 않아도 된다.
날짜에는 시간대와 유효 범위가 필요하다. 가격에는 통화와 단위가 필요하다. 문자열에는 길이와 허용 문자 규칙이 필요하다. 숫자로 보이는 값이라도 식별자라면 문자열로 유지해야 할 수 있다.
결국 타입을 변환한다는 것은 다음 질문에 답하는 일이다.
외부에서 들어온 이 값은 무엇을 의미하며, 서비스 내부에서는 어떤 하나의 형태로 다뤄야 하는가?
타입 변환은 문자열을 숫자로 바꾸는 일이 아니다.
외부 세계의 불확실하고 다양한 표현을 서비스가 안전하고 일관되게 사용할 수 있는 데이터로 해석하는 과정이다.