
프로그래밍을 처음 배울 때 매개변수는 보통 함수에 값을 전달하기 위한 변수라고 배운다.
function greet(name) {
return `Hello, ${name}`;
}
greet("Chris");
name은 함수가 값을 전달받기 위해 선언한 매개변수이고, "Chris"는 함수를 호출할 때 전달한 인자다.
매개변수의 기본적인 문법을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 매개변수를 정의한다는 것은 단순히 함수 안으로 값을 전달하는 것보다 훨씬 많은 판단을 요구한다.
예를 들어 쿠폰을 사용하는 함수를 만든다고 생각해 보자.
redeemCoupon(
coupon,
userId,
now
);
이 함수의 매개변수에는 서비스의 중요한 결정이 담겨 있다.
userId는 사용자가 입력한 값인가, 인증된 사용자 정보인가?매개변수는 함수가 외부에서 받아들이는 정보의 범위를 결정한다.
동시에 함수가 무엇을 알고, 무엇을 책임지며, 무엇에 의존하는지도 드러낸다.
실제 서비스에서 매개변수를 정의한다는 것은 값을 전달하는 문법을 작성하는 일이 아니라, 함수가 외부 세계와 맺는 계약을 설계하는 일이다.
쿠폰의 사용 가능 여부를 확인하는 함수를 만들어 보자.
function canRedeemCoupon(
coupon,
userId,
now
) {
return (
coupon.recipientId === userId &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt > now
)
);
}
이 함수는 세 가지 정보를 요구한다.
함수의 매개변수를 보면 함수가 어떤 정보로 결정을 내리는지 알 수 있다.
Coupon + User ID + Current Time
→ canRedeemCoupon
→ Boolean
만약 userId가 없다면 현재 사용자가 쿠폰의 수신자인지 확인할 수 없다.
now가 없다면 쿠폰의 만료 여부를 판단할 수 없다.
따라서 매개변수는 단순히 함수 안에서 사용할 변수를 나열한 것이 아니다.
함수가 자신의 책임을 수행하기 위해 반드시 알아야 하는 정보를 선언한 것이다.
매개변수를 설계할 때는 먼저 다음 질문을 해야 한다.
이 함수가 하나의 결정을 내리거나 행동을 수행하려면 정확히 어떤 정보가 필요한가?
쿠폰을 생성하는 함수가 다음과 같이 정의되어 있다고 가정해 보자.
function createCoupon(
title: string,
issuerId: string,
recipientId: string
) {
// ...
}
함수를 호출할 때 발행자 ID와 수신자 ID의 순서를 바꿨다.
createCoupon(
"Coffee Date",
"user_recipient",
"user_issuer"
);
세 값 모두 문자열이기 때문에 TypeScript는 이 실수를 발견하지 못한다.
문법적으로는 올바르지만 서비스의 의미는 잘못되었다.
이 문제는 매개변수의 타입만으로 계약을 충분히 표현하지 못할 때 발생한다.
입력 객체를 사용하면 각 값의 의미를 호출부에 드러낼 수 있다.
interface CreateCouponInput {
title: string;
issuerId: string;
recipientId: string;
}
function createCoupon(
input: CreateCouponInput
) {
// ...
}
호출 코드도 더 명확해진다.
createCoupon({
title: "Coffee Date",
issuerId: "user_issuer",
recipientId: "user_recipient",
});
이제 문자열의 위치를 기억할 필요가 없다.
각 값이 어떤 역할을 하는지 이름으로 확인할 수 있다.
더 강한 구분이 필요하다면 서비스의 ID를 서로 다른 타입으로 표현할 수도 있다.
type UserId =
string & { readonly __brand: "UserId" };
type CouponId =
string & { readonly __brand: "CouponId" };
function redeemCoupon(
couponId: CouponId,
userId: UserId
) {
// ...
}
이 방식은 단순한 문자열 두 개가 아니라 서로 다른 서비스 개념을 함수의 계약에 표현한다.
모든 문자열에 별도의 타입을 만들 필요는 없다.
하지만 같은 타입의 값이 여러 개 전달되고, 순서가 바뀌면 중요한 문제가 발생한다면 타입과 구조로 의미를 구분할 가치가 있다.
다음 함수는 숫자를 매개변수로 받는다.
function createCoupon(
remainingUses: number
) {
// ...
}
타입만 보면 모든 숫자를 전달할 수 있다.
createCoupon(3);
createCoupon(0);
createCoupon(-10);
createCoupon(1.5);
createCoupon(Number.NaN);
그러나 쿠폰의 사용 가능 횟수라면 모든 숫자가 유효하지는 않다.
서비스가 요구하는 계약은 다음과 같을 수 있다.
function createCoupon(
remainingUses: number
) {
if (
!Number.isInteger(remainingUses)
) {
throw new Error(
"사용 가능 횟수는 정수여야 한다."
);
}
if (
remainingUses < 1 ||
remainingUses > 100
) {
throw new Error(
"사용 가능 횟수는 1에서 100 사이여야 한다."
);
}
// ...
}
number라는 타입은 데이터의 형태를 설명하지만 서비스의 모든 규칙을 설명하지는 못한다.
매개변수의 실제 계약에는 여러 요소가 포함된다.
| 계약 요소 | 질문 |
|---|---|
| 타입 | 문자열인가, 숫자인가, 객체인가? |
| 의미 | 이 값은 서비스에서 무엇을 나타내는가? |
| 제약 | 허용되는 범위와 형식은 무엇인가? |
| 출처 | 사용자 입력인가, 인증 정보인가, 데이터베이스 값인가? |
| 신뢰 수준 | 이미 검증되었는가? |
| 선택 여부 | 반드시 필요한가, 없어도 되는가? |
| 변경 여부 | 함수가 이 값을 직접 변경하는가? |
| 생명주기 | 이 호출에서만 필요한가, 이후에도 유지되는가? |
함수의 계약을 타입 하나만으로 생각하면 중요한 서비스 규칙이 빠질 수 있다.
쿠폰 생성 API가 다음과 같은 요청을 받는다고 생각해 보자.
{
"title": " Coffee Date ",
"recipientId": "user_123",
"remainingUses": "3",
"expiresAt": "2026-12-31"
}
HTTP 요청의 값은 아직 서비스가 신뢰할 수 있는 데이터가 아니다.
remainingUses는 숫자가 아니라 문자열일 수 있다.
title에는 불필요한 공백이 포함될 수 있다.
expiresAt은 유효하지 않은 날짜일 수 있다.
요청에 예상하지 못한 속성이 포함될 수도 있다.
async function handleCreateCoupon(
request: Request
) {
return createCoupon(
request.body
);
}
이 코드에서는 외부 데이터가 검증 없이 비즈니스 함수로 전달된다.
createCoupon이 어떤 형태의 입력을 받는지 명확하지 않고, 함수 내부에서도 어떤 값을 신뢰할 수 있는지 판단하기 어렵다.
외부 입력을 먼저 변환하고 검증한다.
interface CreateCouponInput {
title: string;
recipientId: string;
remainingUses: number;
expiresAt: Date | null;
}
function parseCreateCouponRequest(
body: unknown
): CreateCouponInput {
if (
typeof body !== "object" ||
body === null
) {
throw new Error(
"요청 본문이 올바르지 않다."
);
}
const input =
body as Record<string, unknown>;
if (
typeof input.title !== "string"
) {
throw new Error(
"쿠폰 제목은 문자열이어야 한다."
);
}
const title = input.title.trim();
if (title.length === 0) {
throw new Error(
"쿠폰 제목을 입력해야 한다."
);
}
const remainingUses =
Number(input.remainingUses);
if (
!Number.isInteger(remainingUses) ||
remainingUses < 1
) {
throw new Error(
"사용 가능 횟수는 1 이상의 정수여야 한다."
);
}
const expiresAt =
input.expiresAt === null
? null
: new Date(
String(input.expiresAt)
);
if (
expiresAt !== null &&
Number.isNaN(expiresAt.getTime())
) {
throw new Error(
"만료일 형식이 올바르지 않다."
);
}
if (
typeof input.recipientId !==
"string"
) {
throw new Error(
"수신자 ID가 필요하다."
);
}
return {
title,
recipientId:
input.recipientId,
remainingUses,
expiresAt,
};
}
그다음 검증된 값만 서비스 함수에 전달한다.
async function handleCreateCoupon(
request: Request
) {
const input =
parseCreateCouponRequest(
request.body
);
const coupon = createCoupon(
input
);
return coupon;
}
데이터 흐름은 다음과 같다.
flowchart LR
A[HTTP 요청]
--> B[unknown 외부 데이터]
--> C[변환과 검증]
--> D[CreateCouponInput]
--> E[비즈니스 함수]
이 구조에서는 두 종류의 계약이 구분된다.
TypeScript에서 요청 본문을 CreateCouponInput으로 단언했다고 데이터가 실제로 안전해지는 것은 아니다.
const input =
request.body as CreateCouponInput;
타입 단언은 런타임 데이터를 검증하지 않는다.
외부 입력은 실제 실행 중에도 확인한 뒤 함수의 내부 계약으로 전달해야 한다.
쿠폰 생성 요청에 issuerId가 포함되어 있다고 가정해 보자.
{
"title": "Coffee Date",
"issuerId": "user_admin",
"recipientId": "user_123"
}
사용자가 요청 본문에 원하는 issuerId를 넣을 수 있다면 다른 사용자인 것처럼 쿠폰을 발행할 수 있다.
다음 함수 호출은 필요한 값을 모두 전달하고 있지만 안전하지 않다.
createCoupon({
title: request.body.title,
issuerId:
request.body.issuerId,
recipientId:
request.body.recipientId,
});
issuerId의 올바른 출처는 사용자 입력이 아니라 서버가 확인한 인증 정보다.
const input =
parseCreateCouponRequest(
request.body
);
const coupon = createCoupon({
...input,
issuerId:
request.currentUser.id,
});
같은 문자열이라도 출처에 따라 신뢰 수준이 다르다.
| 값 | 권장 출처 |
|---|---|
| 쿠폰 제목 | 사용자 입력 후 검증 |
| 수신자 ID | 사용자 입력 후 존재 여부와 권한 확인 |
| 발행자 ID | 인증된 사용자 정보 |
| 생성 시간 | 서버가 관리하는 시간 |
| 초기 상태 | 비즈니스 규칙 |
| 쿠폰 ID | 서버 또는 데이터베이스 |
| 관리자 권한 | 서버에서 확인한 권한 정보 |
사용자가 결정할 수 있는 값과 서버가 결정해야 하는 값을 구분해야 한다.
매개변수에 어떤 값을 전달할지만 생각해서는 부족하다.
그 값이 어디에서 왔는지도 확인해야 한다.
다음 함수는 새 쿠폰의 모든 속성을 외부에서 받는다.
interface CreateCouponInput {
id: string;
title: string;
issuerId: string;
recipientId: string;
remainingUses: number;
status: "active" | "used";
createdAt: Date;
}
function createCoupon(
input: CreateCouponInput
): Coupon {
return {
...input,
};
}
호출자는 생성 시점부터 쿠폰 상태를 "used"로 만들거나 임의의 생성 시간을 전달할 수 있다.
createCoupon({
id: "custom_id",
title: "Coffee Date",
issuerId: "user_chris",
recipientId: "user_123",
remainingUses: 3,
status: "used",
createdAt:
new Date("2020-01-01"),
});
하지만 새 쿠폰의 초기 상태가 항상 "active"여야 한다면 호출자가 상태를 결정할 이유가 없다.
interface CreateCouponInput {
title: string;
issuerId: string;
recipientId: string;
remainingUses: number;
expiresAt: Date | null;
}
function createCoupon(
input: CreateCouponInput,
now: Date
): Coupon {
return {
id: crypto.randomUUID(),
title: input.title,
issuerId: input.issuerId,
recipientId:
input.recipientId,
remainingUses:
input.remainingUses,
expiresAt: input.expiresAt,
status: "active",
createdAt: now,
updatedAt: now,
};
}
이제 각 값의 결정권이 구분된다.
매개변수를 줄이는 것은 편의를 위한 작업만이 아니다.
호출자가 변경해서는 안 되는 값을 함수의 계약에서 제거함으로써 비즈니스 규칙을 보호하는 방법이기도 하다.
쿠폰 제목을 표시하는 함수를 생각해 보자.
function getCouponLabel(
coupon: Coupon
): string {
return `${coupon.title} · ${coupon.remainingUses}회`;
}
현재 이 함수는 title과 remainingUses만 사용한다.
하지만 매개변수로 Coupon 전체를 받기 때문에 함수가 쿠폰의 모든 속성에 의존하는 것처럼 보인다.
interface Coupon {
id: string;
title: string;
description: string;
issuerId: string;
recipientId: string;
remainingUses: number;
status: CouponStatus;
expiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
필요한 정보만 받으면 의존 범위가 분명해진다.
interface CouponLabelInput {
title: string;
remainingUses: number;
}
function getCouponLabel(
input: CouponLabelInput
): string {
return (
`${input.title} · ` +
`${input.remainingUses}회`
);
}
함수는 이제 쿠폰 저장 구조 전체가 아니라 표시할 두 값에만 의존한다.
반대로 쿠폰 사용 규칙처럼 쿠폰이라는 하나의 개념 전체를 다루는 함수라면 Coupon을 받는 것이 자연스럽다.
function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
): boolean {
// ...
}
항상 객체 전체를 피해야 한다는 뜻은 아니다.
다음 질문으로 판단해야 한다.
함수에 전달하는 정보가 많을수록 함수가 접근할 수 있는 범위도 넓어진다.
필요한 정보만 전달하는 것은 의존성과 접근 범위를 제한하는 방법이다.
쿠폰의 만료일을 변경하는 함수를 만들어 보자.
function updateExpiry(
coupon: Coupon,
expiresAt?: Date
) {
// ...
}
expiresAt이 전달되지 않았다면 무엇을 의미할까?
선택적 매개변수 하나에 여러 의미가 섞여 있다.
업데이트 요청에서는 특히 다음 세 상태를 구분해야 할 수 있다.
undefined → 변경하지 않음
null → 기존 만료일 제거
Date → 새로운 만료일 설정
이 계약을 타입으로 표현할 수 있다.
interface UpdateCouponInput {
expiresAt?:
| Date
| null;
}
function updateCoupon(
coupon: Coupon,
input: UpdateCouponInput
): Coupon {
if (
input.expiresAt === undefined
) {
return coupon;
}
return {
...coupon,
expiresAt: input.expiresAt,
};
}
호출 방법에 따라 의도가 달라진다.
updateCoupon(coupon, {});
// 만료일을 변경하지 않는다.
updateCoupon(coupon, {
expiresAt: null,
});
// 기존 만료일을 제거한다.
updateCoupon(coupon, {
expiresAt:
new Date("2026-12-31"),
});
// 새로운 만료일을 설정한다.
선택적 매개변수는 단순히 전달해도 되고 전달하지 않아도 되는 값이 아니다.
값이 없을 때 어떤 행동을 해야 하는지까지 함수의 계약에 포함해야 한다.
다음 함수는 쿠폰의 기본 사용 횟수를 1회로 정한다.
function createCoupon(
title: string,
remainingUses = 1
) {
// ...
}
호출은 간단해진다.
createCoupon("Coffee Date");
하지만 1은 단순한 편의 값이 아니라 서비스 정책일 수 있다.
정책이 변경되어 쿠폰 템플릿마다 기본 사용 횟수가 달라진다면 함수의 기본 매개변수만으로는 부족하다.
function getDefaultRemainingUses(
template: CouponTemplate
): number {
return template.defaultUses;
}
const remainingUses =
input.remainingUses ??
getDefaultRemainingUses(
template
);
const coupon = createCoupon({
...input,
remainingUses,
});
기본 매개변수는 다음 조건에서 유용하다.
반면 운영 정책이나 사용자별 설정, 데이터베이스 값에 따라 달라지는 기본값이라면 별도의 정책 계산으로 드러내는 편이 낫다.
기본값을 선언하는 순간 함수는 값이 없을 때의 결정을 대신 내린다.
따라서 기본 매개변수도 서비스 규칙의 일부다.
다음 함수는 쿠폰을 생성한 뒤 알림 전송 여부를 Boolean으로 받는다.
async function createCoupon(
input: CreateCouponInput,
sendNotification: boolean
) {
const coupon =
await couponRepository.save(
input
);
if (sendNotification) {
await notificationService.send(
coupon
);
}
return coupon;
}
호출 코드만 보면 true가 무엇을 의미하는지 바로 알기 어렵다.
await createCoupon(
input,
true
);
이름을 가진 객체를 사용하면 의미가 조금 더 분명해진다.
await createCoupon(input, {
notifyRecipient: true,
});
그러나 더 중요한 질문이 있다.
알림 전송 여부에 따라 함수가 서로 다른 행동을 수행한다면, 실제로 하나의 책임을 가진 함수인가?
쿠폰 생성과 알림 전송을 분리할 수 있다.
const coupon =
await createCoupon(input);
await notifyCouponRecipient(
coupon
);
또는 사용 사례를 조정하는 함수에서 순서를 관리할 수 있다.
async function issueCoupon(
input: IssueCouponInput
): Promise<Coupon> {
const coupon =
await createCoupon(input);
await notifyCouponRecipient(
coupon
);
return coupon;
}
Boolean 매개변수가 항상 잘못된 것은 아니다.
하지만 Boolean 하나에 따라 함수의 주요 흐름이 크게 달라진다면 함수에 여러 행동이 결합되어 있는지 확인해야 한다.
특히 다음과 같은 호출은 의미를 이해하기 어렵다.
processCoupon(
coupon,
true,
false,
true
);
이럴 때는 이름을 가진 옵션 객체를 사용하거나 서로 다른 행동을 별도의 함수로 표현하는 편이 낫다.
쿠폰 만료 여부를 확인하는 함수가 함수 내부에서 현재 시간을 조회한다고 생각해 보자.
function isCouponExpired(
coupon: Coupon
): boolean {
return (
coupon.expiresAt !== null &&
coupon.expiresAt <=
new Date()
);
}
겉으로 보면 이 함수는 coupon 하나만 받는다.
하지만 실제 결과에는 현재 시간도 영향을 준다.
동일한 쿠폰을 전달해도 호출 시점에 따라 결과가 달라진다.
현재 시간을 매개변수로 드러낼 수 있다.
function isCouponExpired(
coupon: Coupon,
now: Date
): boolean {
return (
coupon.expiresAt !== null &&
coupon.expiresAt <= now
);
}
호출자는 판단 기준이 되는 시간을 명시한다.
const isExpired =
isCouponExpired(
coupon,
new Date()
);
테스트에서도 원하는 시간을 정확히 전달할 수 있다.
it(
"만료일과 현재 시간이 같으면 만료된 것으로 판단한다",
() => {
const expiresAt =
new Date(
"2026-09-02T00:00:00Z"
);
const coupon = {
...baseCoupon,
expiresAt,
};
expect(
isCouponExpired(
coupon,
expiresAt
)
).toBe(true);
}
);
현재 시간뿐 아니라 다음 값도 숨겨진 입력이 될 수 있다.
결과에 영향을 주는 정보가 매개변수나 명시적인 의존성으로 드러나면 함수의 동작을 예측하기 쉬워진다.
쿠폰을 사용하는 함수가 다음과 같다고 생각해 보자.
function redeemCoupon(
couponId: string,
couponTitle: string,
issuerId: string,
recipientId: string,
currentUserId: string,
status: CouponStatus,
remainingUses: number,
expiresAt: Date | null,
approvedByIssuer: boolean,
now: Date
) {
// ...
}
매개변수가 많다고 무조건 잘못된 함수는 아니다.
실제 행동에 필요한 정보가 많을 수도 있다.
하지만 매개변수 목록이 길어졌다면 다음 가능성을 확인해야 한다.
쿠폰 정보와 실행 상황을 각각 하나의 개념으로 묶을 수 있다.
interface RedemptionContext {
userId: string;
approvedByIssuer: boolean;
now: Date;
}
function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
): boolean {
const isRecipient =
coupon.recipientId ===
context.userId;
const hasRemainingUses =
coupon.remainingUses > 0;
const isWithinExpiry =
coupon.expiresAt === null ||
coupon.expiresAt >
context.now;
const isAllowedStatus =
coupon.status === "active" ||
(
coupon.status === "paused" &&
context.approvedByIssuer
);
return (
isRecipient &&
hasRemainingUses &&
isWithinExpiry &&
isAllowedStatus
);
}
이 구조는 단순히 매개변수 개수를 줄인 것이 아니다.
coupon은 판단 대상이고, context는 이번 판단이 이루어지는 상황이라는 의미를 만든다.
다만 서로 관련 없는 값을 하나의 객체로 무조건 묶어서는 안 된다.
interface Everything {
coupon: Coupon;
currentUser: User;
request: Request;
response: Response;
database: Database;
logger: Logger;
config: Config;
}
이 객체를 모든 함수에 전달하면 각 함수가 무엇에 의존하는지 다시 숨겨진다.
매개변수 객체는 값의 의미와 변경 이유가 같을 때 사용해야 한다.
쿠폰 사용 가능 여부를 확인하기 위해 사용자 전체 객체를 전달한다고 가정해 보자.
function canRedeemCoupon(
coupon: Coupon,
user: User,
now: Date
) {
return (
coupon.recipientId === user.id &&
coupon.remainingUses > 0
);
}
User 객체에는 함수가 필요하지 않은 정보가 포함될 수 있다.
interface User {
id: string;
email: string;
passwordHash: string;
address: string;
dateOfBirth: Date;
}
함수는 사용자 ID만 필요하다.
function canRedeemCoupon(
coupon: Coupon,
userId: string,
now: Date
) {
// ...
}
필요하지 않은 정보를 전달하지 않으면 다음 효과가 있다.
함수 계약은 무엇을 받는지만 정의하지 않는다.
무엇을 받지 않고, 무엇을 알지 못하게 할지도 결정한다.
다음 함수는 userId를 전달받아 쿠폰을 사용한다.
async function redeemCoupon(
couponId: string,
userId: string
) {
const coupon =
await couponRepository.findById(
couponId
);
// ...
}
userId가 매개변수에 있다고 해서 인증된 사용자라는 보장은 없다.
호출자가 요청 본문의 값을 그대로 전달할 수 있기 때문이다.
await redeemCoupon(
request.params.couponId,
request.body.userId
);
사용자는 다른 사람의 ID를 요청에 넣을 수 있다.
신뢰할 수 있는 인증 정보에서 사용자 ID를 가져와야 한다.
await redeemCoupon(
request.params.couponId,
request.currentUser.id
);
함수 안에서도 비즈니스 권한을 확인한다.
if (
coupon.recipientId !== userId
) {
throw new Error(
"쿠폰을 받은 사용자만 사용할 수 있다."
);
}
여기에는 서로 다른 두 판단이 존재한다.
매개변수의 이름이나 타입만으로 값의 신뢰성을 보장할 수는 없다.
값이 어떤 경로로 함수에 전달되는지까지 확인해야 한다.
쿠폰 사용 요청의 전체 흐름을 살펴보자.
flowchart LR
A[HTTP 요청]
--> B[인증된 사용자 확인]
--> C[요청 값 검증]
--> D[쿠폰 조회]
--> E[사용 가능 여부 판단]
--> F[다음 상태 계산]
--> G[데이터베이스 저장]
--> H[응답 반환]
코드에서는 각 단계의 출력이 다음 함수의 입력이 된다.
async function handleRedeemCoupon(
request: Request
): Promise<CouponResponse> {
const couponId =
parseCouponId(
request.params.couponId
);
const userId =
request.currentUser.id;
const coupon =
await couponRepository.findById(
couponId
);
if (!coupon) {
throw new Error(
"쿠폰을 찾을 수 없다."
);
}
const now = new Date();
const redeemedCoupon =
applyCouponRedemption(
coupon,
{
userId,
now,
}
);
const savedCoupon =
await couponRepository.save(
redeemedCoupon
);
return toCouponResponse(
savedCoupon
);
}
각 함수의 매개변수는 이전 단계에서 어떤 조건이 충족되어야 하는지를 보여준다.
parseCouponId는 신뢰할 수 없는 경로 매개변수를 받는다.findById는 검증된 쿠폰 ID를 받는다.applyCouponRedemption은 존재가 확인된 쿠폰과 실행 상황을 받는다.save는 규칙 적용이 끝난 다음 상태를 받는다.toCouponResponse는 저장된 내부 객체를 받는다.매개변수는 함수 하나의 입구일 뿐 아니라 서비스 흐름에서 단계와 단계 사이의 경계를 만든다.
undefined와 null을 구분해야 하는가?transferCoupon(
"user_123",
"user_456",
"coupon_789"
);
각 문자열의 의미를 호출부에서 알기 어렵다.
transferCoupon({
couponId: "coupon_789",
senderId: "user_123",
recipientId: "user_456",
});
값의 역할을 이름으로 드러낸다.
const input =
request.body as CreateCouponInput;
createCoupon(input);
타입 단언은 실제 데이터를 검사하지 않는다.
const input =
parseCreateCouponRequest(
request.body
);
createCoupon(input);
외부 입력은 런타임에서 변환하고 검증한다.
createCoupon({
...request.body,
issuerId:
request.body.issuerId,
status:
request.body.status,
});
사용자가 발행자와 초기 상태를 조작할 수 있다.
createCoupon({
...validatedInput,
issuerId:
request.currentUser.id,
});
인증 정보와 초기 상태 규칙은 서버에서 결정한다.
function canRedeemCoupon(
context: ApplicationContext
) {
// ...
}
함수가 실제로 어떤 정보에 의존하는지 알기 어렵다.
function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
) {
// ...
}
판단 대상과 필요한 실행 상황만 전달한다.
function updateExpiry(
expiresAt?: Date
) {
// undefined가 무엇을 의미하는가?
}
type ExpiryUpdate =
| {
type: "KEEP";
}
| {
type: "REMOVE";
}
| {
type: "SET";
value: Date;
};
필요하다면 상태별 의미를 명시적인 타입으로 표현한다.
processCoupon(
coupon,
true,
false
);
각 값의 의미와 함수의 실제 행동을 알기 어렵다.
issueCoupon(coupon);
notifyCouponRecipient(coupon);
서로 다른 행동이라면 별도의 책임으로 표현할 수 있다.
function canRedeemCoupon(
coupon: Coupon
) {
return (
coupon.recipientId ===
currentUser.id &&
coupon.expiresAt >
new Date()
);
}
현재 사용자와 시간이 숨겨져 있다.
function canRedeemCoupon(
coupon: Coupon,
userId: string,
now: Date
) {
// ...
}
결과에 영향을 주는 값을 입력으로 드러낸다.
let currentCoupon: Coupon;
let currentUser: User;
function redeemCoupon() {
// 전역 상태 사용
}
호출은 짧지만 함수가 어떤 데이터에 의존하는지 알 수 없다.
function redeemCoupon(
coupon: Coupon,
userId: string,
now: Date
) {
// ...
}
매개변수 수가 적은 것보다 의존성이 명확한 것이 중요하다.
매개변수는 함수가 값을 전달받기 위해 선언하는 변수다.
function greet(name) {
return `Hello, ${name}`;
}
하지만 실제 서비스에서 매개변수는 단순한 값의 통로가 아니다.
매개변수는 함수가 외부 세계에서 무엇을 받아들일지 결정한다.
function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
): boolean {
// ...
}
이 선언에는 함수의 책임이 담겨 있다.
매개변수의 타입만으로 계약이 완성되는 것은 아니다.
remainingUses: number
실제 계약에는 값의 의미와 제약도 포함된다.
1 이상 100 이하의 유한한 정수
값의 출처도 중요하다.
const issuerId =
request.currentUser.id;
인증 정보에서 얻어야 할 값을 사용자 입력에서 받아서는 안 된다.
함수가 결정해야 하는 값은 매개변수에서 제거할 수 있다.
function createCoupon(
input: CreateCouponInput,
now: Date
): Coupon {
return {
...input,
id: crypto.randomUUID(),
status: "active",
createdAt: now,
};
}
호출자는 필요한 정보만 제공하고, 함수는 자신의 책임에 속한 결정을 수행한다.
좋은 매개변수 설계는 다음을 가능하게 한다.
결국 매개변수를 정의한다는 것은 다음 질문에 답하는 일이다.
이 함수가 자신의 책임을 수행하려면 외부로부터 정확히 어떤 정보를 받아야 하며, 그 정보는 어떤 의미와 제약, 출처를 가져야 하는가?
매개변수는 함수에 값을 전달하는 문법이 아니다.
함수가 무엇을 알고 무엇을 책임질 것인지 정하고, 외부 세계와 맺는 계약의 경계를 정의하는 방법이다.