
프로그래밍을 처음 배울 때 함수는 보통 여러 코드를 하나로 묶어 필요할 때 다시 실행하는 문법이라고 배운다.
function greet() {
console.log("Hello");
}
greet();
조금 더 배우면 입력값을 받아 결과를 반환하는 방법도 배운다.
function add(a, b) {
return a + b;
}
const result = add(10, 20);
함수의 기본적인 문법과 재사용 개념을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 함수를 만든다는 것은 단순히 여러 줄의 코드를 묶는 것보다 훨씬 많은 판단을 요구한다.
실제 서비스에서 함수는 단순히 코드를 재사용하기 위한 문법이 아니다.
함수는 서비스에서 일어나는 하나의 행동이나 판단에 이름을 붙이고, 그 책임의 범위를 정의하는 방법이다.
쿠폰 서비스를 개발한다고 가정해 보자.
사용자는 쿠폰을 만들고, 다른 사람에게 보내고, 받은 쿠폰을 사용한다.
현실 세계에서는 다음과 같은 행동이 일어난다.
프로그램은 이러한 행동을 함수로 표현할 수 있다.
createCoupon();
sendCoupon();
redeemCoupon();
isCouponExpired();
decreaseRemainingUses();
recordCouponRedemption();
함수 이름만 읽어도 쿠폰 서비스에서 어떤 일이 일어나는지 알 수 있다.
이때 함수는 단순히 코드 몇 줄을 감싸는 상자가 아니다.
현실 세계의 행동을 프로그램이 이해하고 실행할 수 있는 단위로 바꾼 것이다.
function isCouponExpired(
expiresAt: Date | null,
now: Date
): boolean {
return (
expiresAt !== null &&
expiresAt <= now
);
}
이 함수는 날짜를 비교하는 코드를 재사용하기 위해서만 존재하지 않는다.
쿠폰 서비스가 생각하는 만료의 의미를 코드로 정의한다.
isCouponExpired라는 이름 아래에 쿠폰의 만료 규칙이 모였다.
함수에 이름을 붙이는 순간, 단순한 비교 연산은 서비스의 규칙이 된다.
다음 코드는 쿠폰을 생성한다.
async function processCoupon(
request: Request
) {
const title =
request.body.title.trim();
const remainingUses =
Number(
request.body.remainingUses
);
const coupon = {
id: crypto.randomUUID(),
title,
remainingUses,
createdAt: new Date(),
};
await database.coupons.insert(
coupon
);
await emailService.send({
to: request.body.recipientEmail,
subject: "새로운 쿠폰이 도착했다.",
});
console.log(
"Coupon created:",
coupon.id
);
return coupon;
}
하나의 함수로 묶여 있고 정상적으로 동작할 수도 있다.
하지만 이 함수가 정확히 어떤 책임을 맡고 있는지는 불분명하다.
이 함수 안에서는 여러 종류의 일이 동시에 일어난다.
함수의 이름은 processCoupon이다.
process라는 단어만으로는 함수가 무엇을 처리하고 어디까지 책임지는지 알기 어렵다.
요구사항이 변경되면 문제는 더 분명해진다.
코드를 함수 안에 넣었다고 책임이 자동으로 정리되는 것은 아니다.
함수의 경계는 코드의 줄 수가 아니라 함께 변경되어야 하는 책임을 기준으로 결정해야 한다.
다음 함수가 있다고 가정해 보자.
function check(
coupon: Coupon
): boolean {
return (
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt > new Date()
)
);
}
코드는 쿠폰을 사용할 수 있는지 확인한다.
하지만 check라는 이름만으로는 무엇을 확인하는지 알 수 없다.
호출하는 코드도 의도를 충분히 설명하지 못한다.
if (check(coupon)) {
// 쿠폰 사용
}
이름을 서비스의 질문으로 바꾸면 의미가 분명해진다.
function canRedeemCoupon(
coupon: Coupon,
now: Date
): boolean {
return (
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt > now
)
);
}
if (
canRedeemCoupon(coupon, now)
) {
// 쿠폰 사용
}
이제 호출하는 코드만 읽어도 무엇을 판단하는지 알 수 있다.
canRedeemCoupon은 다음 질문에 답한다.
이 쿠폰을 현재 사용할 수 있는가?
함수 내부에서는 상태 비교, 숫자 비교, 날짜 비교가 일어난다.
하지만 함수의 이름은 그 구현 방법을 설명하지 않는다.
checkStatusAndUsesAndExpiryDate라고 이름 붙이지 않는다.
그 코드가 서비스에서 어떤 의미를 가지는지를 설명한다.
좋은 함수 이름은 다음 두 수준을 구분한다.
isCouponExpired(coupon, now);
hasRemainingUses(coupon);
isCouponActive(coupon);
위 함수들은 각각 하나의 세부 조건을 판단한다.
canRedeemCoupon(coupon, now);
이 함수는 여러 조건을 조합해 하나의 비즈니스 질문에 답한다.
구현 세부사항이 아니라 서비스의 언어로 함수 이름을 정하면 코드가 요구사항을 설명하기 시작한다.
함수는 하나의 책임만 가져야 한다는 말을 자주 듣는다.
이 말을 문자 그대로 받아들이면 모든 연산을 작은 함수로 나누게 될 수 있다.
function subtractOne(
value: number
): number {
return value - 1;
}
function createUsedDate(
now: Date
): Date {
return now;
}
function changeStatusToUsed():
"used" {
return "used";
}
함수가 작아졌지만 쿠폰 사용이라는 전체 행동을 이해하기는 오히려 어려워졌다.
함수 하나가 코드 한 줄만 가져야 하는 것은 아니다.
함수의 책임은 서비스 관점에서 하나의 이유로 변경되는 작업의 범위로 생각할 수 있다.
예를 들어 쿠폰을 사용하는 행동은 여러 단계로 구성된다.
function redeemCoupon(
coupon: Coupon,
redeemedBy: string,
now: Date
): CouponRedemptionResult {
if (
coupon.recipientId !==
redeemedBy
) {
throw new Error(
"쿠폰을 받은 사용자만 사용할 수 있다."
);
}
if (
!canRedeemCoupon(coupon, now)
) {
throw new Error(
"사용할 수 없는 쿠폰이다."
);
}
const remainingUses =
coupon.remainingUses - 1;
return {
coupon: {
...coupon,
remainingUses,
status:
remainingUses === 0
? "used"
: coupon.status,
updatedAt: now,
},
redemption: {
couponId: coupon.id,
redeemedBy,
redeemedAt: now,
},
};
}
이 함수에는 조건 확인, 횟수 감소, 상태 변경, 사용 기록 생성이 포함되어 있다.
코드 줄 수는 적지 않지만 모두 하나의 행동을 완성하기 위해 필요하다.
쿠폰을 사용한다.
이 행동의 규칙이 함께 변경된다면 하나의 책임으로 묶는 것이 자연스럽다.
예를 들어 “쿠폰을 마지막으로 사용하면 상태를 used로 바꾼다”라는 정책이 변경되면 redeemCoupon의 동작도 함께 변경된다.
함수의 크기를 판단할 때는 단순히 줄 수를 세기보다 다음을 물어봐야 한다.
하나의 책임은 반드시 하나의 연산을 의미하지 않는다.
하나의 명확한 목적을 의미한다.
다음 코드에는 아직 중복이 없다.
if (
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt > now
)
) {
// 쿠폰 사용
}
중복이 없으므로 함수로 분리할 필요가 없다고 생각할 수 있다.
하지만 이 조건은 단순한 기술적 비교가 아니다.
쿠폰을 사용할 수 있는지를 결정하는 비즈니스 규칙이다.
function canRedeemCoupon(
coupon: Coupon,
now: Date
): boolean {
return (
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt > now
)
);
}
if (
canRedeemCoupon(coupon, now)
) {
// 쿠폰 사용
}
함수로 분리하면서 다음 변화가 생겼다.
함수를 추출하는 이유는 중복 제거만이 아니다.
복잡한 코드에 이름을 붙여 그 코드가 무엇을 의미하는지 드러내기 위해서도 함수를 만든다.
반대로 코드가 중복되었다고 항상 함수로 묶어야 하는 것도 아니다.
우연히 코드 모양만 같은 두 작업은 나중에 서로 다른 이유로 변경될 수 있다.
function normalizeValue(
value: string
): string {
return value.trim().toLowerCase();
}
이 함수를 쿠폰 제목과 쿠폰 코드에 함께 사용한다고 가정해 보자.
const title =
normalizeValue(input.title);
const code =
normalizeValue(input.code);
현재는 같은 변환을 적용하지만 두 값의 규칙은 다를 수 있다.
코드 모양이 같다는 이유만으로 묶으면 서로 다른 비즈니스 규칙이 하나의 함수에 결합된다.
각 의미에 맞는 함수를 만드는 편이 낫다.
function normalizeCouponTitle(
value: string
): string {
return value.trim();
}
function normalizeCouponCode(
value: string
): string {
return value
.trim()
.toUpperCase();
}
함수의 재사용 여부는 코드가 같은지가 아니라 의미와 변경 이유가 같은지를 기준으로 판단해야 한다.
함수는 외부에서 값을 받아 내부에서 처리하고 결과를 반환한다.
function calculateRemainingUses(
currentUses: number
): number {
return currentUses - 1;
}
간단한 구조지만 입력과 출력은 함수의 책임 범위를 만든다.
함수가 무엇을 하지 않는지도 명확하다.
반대로 다음 함수는 너무 많은 외부 정보에 의존한다.
let currentCoupon: Coupon;
let currentUser: User;
let currentTime: Date;
function redeem() {
if (
currentCoupon.recipientId !==
currentUser.id
) {
throw new Error(
"사용 권한이 없다."
);
}
if (
currentCoupon.expiresAt !== null &&
currentCoupon.expiresAt <=
currentTime
) {
throw new Error(
"만료된 쿠폰이다."
);
}
currentCoupon.remainingUses -= 1;
}
이 함수는 호출 코드에서 보이지 않는 값들에 의존한다.
redeem()만 읽어서는 다음 사실을 알기 어렵다.
필요한 값을 명시적으로 전달하면 경계가 분명해진다.
function redeemCoupon(
coupon: Coupon,
currentUser: User,
now: Date
): Coupon {
if (
coupon.recipientId !==
currentUser.id
) {
throw new Error(
"사용 권한이 없다."
);
}
if (
coupon.expiresAt !== null &&
coupon.expiresAt <= now
) {
throw new Error(
"만료된 쿠폰이다."
);
}
return {
...coupon,
remainingUses:
coupon.remainingUses - 1,
};
}
함수 선언만 읽어도 필요한 정보와 반환 결과를 확인할 수 있다.
Coupon + User + Date
→ redeemCoupon
→ Coupon
함수의 입력과 출력은 단순한 문법 요소가 아니다.
그 함수가 외부 세계와 어떤 데이터를 주고받는지를 정의하는 계약이다.
다음 함수는 현재 시간을 직접 가져온다.
function isCouponExpired(
coupon: Coupon
): boolean {
return (
coupon.expiresAt !== null &&
coupon.expiresAt <= new Date()
);
}
실제 서비스에서는 사용할 수 있는 코드다.
하지만 테스트할 때 결과가 실행 시점에 따라 달라진다.
const coupon = {
expiresAt:
new Date(
"2026-12-31T23:59:59Z"
),
};
2026년에는 만료되지 않았지만 2027년에 테스트하면 만료된 것으로 판단된다.
현재 시간을 입력으로 받으면 판단 기준이 드러난다.
function isCouponExpired(
coupon: Coupon,
now: Date
): boolean {
return (
coupon.expiresAt !== null &&
coupon.expiresAt <= now
);
}
const result =
isCouponExpired(
coupon,
new Date(
"2026-12-01T00:00:00Z"
)
);
같은 입력에는 항상 같은 결과가 나온다.
현재 시간은 원래 함수가 사용하던 숨겨진 입력이었다.
이를 매개변수로 드러내면서 함수는 더 예측 가능하고 테스트하기 쉬워졌다.
숨겨진 입력은 시간만 있는 것이 아니다.
모든 외부 의존성을 반드시 매개변수로 전달해야 하는 것은 아니다.
다만 함수의 결과에 영향을 주는 정보가 무엇인지 의식해야 한다.
함수의 선언에 나타나지 않는 입력이 많아질수록 호출자는 결과를 예측하기 어려워진다.
쿠폰을 사용할 수 있는지 확인하는 함수는 값을 계산한다.
function canRedeemCoupon(
coupon: Coupon,
userId: string,
now: Date
): boolean {
return (
coupon.recipientId === userId &&
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt > now
)
);
}
이 함수는 전달받은 값을 변경하지 않는다.
데이터베이스에도 접근하지 않는다.
같은 입력을 전달하면 같은 결과를 반환한다.
이러한 함수는 결과를 이해하고 테스트하기 쉽다.
const canRedeem =
canRedeemCoupon(
coupon,
"user_123",
now
);
반면 다음 함수는 서비스의 외부 상태를 변경한다.
async function saveCoupon(
coupon: Coupon
): Promise<void> {
await couponRepository.save(
coupon
);
}
이 함수는 데이터베이스에 값을 저장한다.
실행 결과가 함수의 반환값만으로 드러나지 않는다.
이처럼 함수 밖의 상태를 읽거나 변경하는 동작을 흔히 부수 효과라고 부른다.
대표적인 부수 효과는 다음과 같다.
부수 효과가 나쁘다는 뜻은 아니다.
서비스는 데이터를 저장하고 이메일을 보내야 하므로 부수 효과가 반드시 필요하다.
중요한 것은 계산과 부수 효과를 무분별하게 섞지 않는 것이다.
async function redeemCoupon(
couponId: string,
userId: string
) {
const coupon =
await couponRepository.findById(
couponId
);
if (!coupon) {
throw new Error(
"쿠폰을 찾을 수 없다."
);
}
const now = new Date();
const redeemed =
applyCouponRedemption(
coupon,
userId,
now
);
await couponRepository.save(
redeemed.coupon
);
await redemptionRepository.save(
redeemed.redemption
);
return redeemed.coupon;
}
외부 상태를 다루는 함수가 전체 흐름을 조정한다.
실제 비즈니스 규칙은 별도의 함수에서 계산한다.
function applyCouponRedemption(
coupon: Coupon,
userId: string,
now: Date
): CouponRedemptionResult {
if (
coupon.recipientId !== userId
) {
throw new Error(
"쿠폰을 받은 사용자만 사용할 수 있다."
);
}
if (
!canRedeemCoupon(coupon, now)
) {
throw new Error(
"사용할 수 없는 쿠폰이다."
);
}
const remainingUses =
coupon.remainingUses - 1;
return {
coupon: {
...coupon,
remainingUses,
status:
remainingUses === 0
? "used"
: coupon.status,
updatedAt: now,
},
redemption: {
couponId: coupon.id,
redeemedBy: userId,
redeemedAt: now,
},
};
}
이 구조에서는 역할이 구분된다.
redeemCoupon은 조회, 저장 등 전체 실행 흐름을 관리한다.applyCouponRedemption은 쿠폰 사용 규칙과 상태 변화를 계산한다.함수 하나에 모든 작업을 넣는 대신 계산과 외부 변경의 경계를 드러낸 것이다.
다음 함수는 전달받은 쿠폰을 직접 변경한다.
function redeemCoupon(
coupon: Coupon
): Coupon {
coupon.remainingUses -= 1;
if (
coupon.remainingUses === 0
) {
coupon.status = "used";
}
return coupon;
}
호출 전과 호출 후의 변수가 같은 객체를 가리킨다.
const originalCoupon = coupon;
const redeemedCoupon =
redeemCoupon(coupon);
console.log(
originalCoupon.remainingUses
);
originalCoupon도 이미 변경되어 있다.
함수 이름만 보고 전달한 객체가 직접 변경될 것이라고 예상하지 못할 수 있다.
새로운 객체를 반환하면 변화가 명시적이다.
function redeemCoupon(
coupon: Coupon
): Coupon {
const remainingUses =
coupon.remainingUses - 1;
return {
...coupon,
remainingUses,
status:
remainingUses === 0
? "used"
: coupon.status,
};
}
const redeemedCoupon =
redeemCoupon(coupon);
이제 원본 쿠폰과 사용 후 쿠폰을 구분할 수 있다.
console.log(
coupon.remainingUses
);
// 3
console.log(
redeemedCoupon.remainingUses
);
// 2
항상 객체를 복사해야 한다는 뜻은 아니다.
성능이나 사용 중인 프레임워크, 데이터 모델에 따라 직접 변경이 적절한 경우도 있다.
중요한 것은 함수가 입력을 변경하는지 호출자가 예측할 수 있어야 한다는 점이다.
함수의 변화 범위가 명확할수록 다른 코드에 미치는 영향을 이해하기 쉽다.
쿠폰 사용 가능 여부를 화면과 서버에서 각각 확인한다고 가정해 보자.
프론트엔드에서는 버튼을 비활성화한다.
const disabled =
coupon.status !== "active" ||
coupon.remainingUses <= 0 ||
(
coupon.expiresAt !== null &&
new Date(coupon.expiresAt) <=
new Date()
);
백엔드에서도 같은 조건을 확인한다.
if (
coupon.status !== "active" ||
coupon.remainingUses <= 0 ||
(
coupon.expiresAt !== null &&
coupon.expiresAt <= new Date()
)
) {
throw new Error(
"사용할 수 없는 쿠폰이다."
);
}
처음에는 문제없이 동작한다.
하지만 정책이 변경되었다고 가정해 보자.
쿠폰이 일시 정지 상태여도 발행자가 직접 사용을 승인하면 사용할 수 있다.
같은 조건식이 여러 곳에 흩어져 있다면 모든 위치를 찾아 수정해야 한다.
한곳이라도 놓치면 화면에서는 사용할 수 있다고 표시되지만 서버에서는 거절하는 상황이 생길 수 있다.
규칙에 이름을 붙이고 한곳에서 관리한다.
interface RedemptionContext {
userId: string;
approvedByIssuer: boolean;
now: Date;
}
function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
): boolean {
const hasPermission =
coupon.recipientId ===
context.userId;
const hasAvailableUse =
coupon.remainingUses > 0;
const isWithinExpiry =
coupon.expiresAt === null ||
coupon.expiresAt >
context.now;
const isAllowedStatus =
coupon.status === "active" ||
(
coupon.status === "paused" &&
context.approvedByIssuer
);
return (
hasPermission &&
hasAvailableUse &&
isWithinExpiry &&
isAllowedStatus
);
}
이 함수는 쿠폰 사용 가능 여부를 결정하는 정책을 표현한다.
다만 프론트엔드의 검사는 사용자 경험을 위한 안내일 뿐이다.
최종 권한과 비즈니스 규칙은 신뢰할 수 있는 서버에서도 반드시 확인해야 한다.
if (
!canRedeemCoupon(
coupon,
context
)
) {
throw new Error(
"사용할 수 없는 쿠폰이다."
);
}
함수를 만든 목적은 단순히 조건문의 중복을 줄이는 데 있지 않다.
서비스의 중요한 판단 기준을 한곳에서 명확하게 관리하는 데 있다.
함수 이름이 행동을 설명하지 않으면 호출자는 내부 구현을 확인해야 한다.
const result =
couponService.handle(
couponId
);
handle이 무엇을 하는지 알기 어렵다.
의도를 이름에 드러낸다.
getCouponById(couponId);
validateCoupon(coupon);
redeemCoupon(couponId, userId);
cancelCoupon(couponId, issuerId);
transferCoupon(
couponId,
recipientId
);
조회와 변경도 구분할 수 있다.
isCouponExpired(coupon, now);
canRedeemCoupon(
coupon,
userId,
now
);
이 함수들은 질문에 답한다.
일반적으로 값을 반환하지만 서비스 상태를 변경하지 않을 것이라고 예상할 수 있다.
redeemCoupon(
couponId,
userId
);
cancelCoupon(
couponId,
issuerId
);
이 함수들은 명령을 표현한다.
서비스의 상태를 변경할 가능성이 있다는 사실을 이름에서 예상할 수 있다.
다음과 같은 이름은 조심해서 사용해야 한다.
processCoupon();
handleCoupon();
manageCoupon();
updateData();
doTask();
execute();
너무 넓은 이름은 함수가 여러 책임을 가지게 되는 것을 숨길 수 있다.
함수의 이름을 구체적으로 정하기 어렵다면 함수가 정확히 어떤 일을 해야 하는지 아직 결정하지 못한 것일 수 있다.
Boolean을 반환하는 함수는 true와 false가 무엇을 의미하는지 이름에서 알 수 있어야 한다.
function checkCoupon(
coupon: Coupon
): boolean {
// ...
}
true가 무엇을 의미하는지 불분명하다.
질문 형태로 이름을 정한다.
isCouponActive(coupon);
isCouponExpired(coupon, now);
hasRemainingUses(coupon);
canRedeemCoupon(
coupon,
userId,
now
);
호출 코드도 자연스럽게 읽힌다.
if (
isCouponExpired(coupon, now)
) {
throw new Error(
"만료된 쿠폰이다."
);
}
부정 표현이 겹치면 의미를 이해하기 어려워질 수 있다.
if (
!isCouponNotExpired(
coupon,
now
)
) {
// ...
}
긍정적인 질문이나 서비스에서 실제로 사용하는 개념으로 바꾸는 편이 낫다.
if (
isCouponExpired(coupon, now)
) {
// ...
}
또는 다음과 같이 전체 행동 가능 여부를 질문할 수 있다.
if (
!canRedeemCoupon(
coupon,
userId,
now
)
) {
// ...
}
함수 이름은 단순한 스타일 문제가 아니다.
반환값이 서비스에서 어떤 의미를 가지는지를 설명하는 인터페이스다.
쿠폰을 사용하는 함수가 Boolean만 반환한다고 가정해 보자.
function redeemCoupon(
coupon: Coupon
): boolean {
if (
coupon.remainingUses <= 0
) {
return false;
}
coupon.remainingUses -= 1;
return true;
}
false가 반환되었지만 이유를 알 수 없다.
실패 이유가 호출자에게 필요하다면 더 구체적인 결과를 반환할 수 있다.
type RedemptionFailureReason =
| "NOT_RECIPIENT"
| "EXPIRED"
| "NO_REMAINING_USES"
| "INACTIVE";
type RedemptionResult =
| {
success: true;
coupon: Coupon;
}
| {
success: false;
reason:
RedemptionFailureReason;
};
function redeemCoupon(
coupon: Coupon,
userId: string,
now: Date
): RedemptionResult {
if (
coupon.recipientId !== userId
) {
return {
success: false,
reason: "NOT_RECIPIENT",
};
}
if (
coupon.expiresAt !== null &&
coupon.expiresAt <= now
) {
return {
success: false,
reason: "EXPIRED",
};
}
if (
coupon.remainingUses <= 0
) {
return {
success: false,
reason:
"NO_REMAINING_USES",
};
}
if (
coupon.status !== "active"
) {
return {
success: false,
reason: "INACTIVE",
};
}
const remainingUses =
coupon.remainingUses - 1;
return {
success: true,
coupon: {
...coupon,
remainingUses,
status:
remainingUses === 0
? "used"
: coupon.status,
},
};
}
호출자는 결과를 보고 사용자에게 적절한 안내를 제공할 수 있다.
const result =
redeemCoupon(
coupon,
currentUser.id,
now
);
if (!result.success) {
showRedemptionError(
result.reason
);
return;
}
showRedeemedCoupon(
result.coupon
);
모든 함수가 이런 결과 객체를 반환해야 하는 것은 아니다.
예외를 던지는 방식이 적절한 경우도 있다.
중요한 것은 호출자가 다음 행동을 결정하는 데 필요한 정보를 함수가 제공해야 한다는 점이다.
함수의 반환값은 단순한 계산 결과가 아니라 함수가 외부에 전달하는 처리 결과다.
다음 함수는 쿠폰을 생성한다.
function createCoupon(
title: string,
description: string,
issuerId: string,
recipientId: string,
remainingUses: number,
expiresAt: Date | null,
imageUrl: string | null,
templateId: string,
isTransferable: boolean,
now: Date
): Coupon {
// ...
}
매개변수가 많다고 무조건 잘못된 함수는 아니다.
하지만 호출할 때 값의 순서를 잘못 전달하기 쉽고 함수의 책임도 이해하기 어렵다.
createCoupon(
title,
description,
recipientId,
issuerId,
remainingUses,
expiresAt,
imageUrl,
templateId,
false,
now
);
issuerId와 recipientId는 모두 문자열이므로 순서가 바뀌어도 TypeScript가 발견하지 못할 수 있다.
관련된 값을 하나의 입력 객체로 표현할 수 있다.
interface CreateCouponInput {
title: string;
description: string;
issuerId: string;
recipientId: string;
remainingUses: number;
expiresAt: Date | null;
imageUrl: string | null;
templateId: string;
isTransferable: boolean;
}
function createCoupon(
input: CreateCouponInput,
now: Date
): Coupon {
return {
id: crypto.randomUUID(),
title: input.title,
description:
input.description,
issuerId: input.issuerId,
recipientId:
input.recipientId,
remainingUses:
input.remainingUses,
expiresAt: input.expiresAt,
imageUrl: input.imageUrl,
templateId:
input.templateId,
isTransferable:
input.isTransferable,
status: "active",
createdAt: now,
updatedAt: now,
};
}
호출 코드에서 각 값의 의미가 드러난다.
const coupon =
createCoupon(
{
title: "Coffee Date",
description:
"함께 커피 마시기",
issuerId: "user_chris",
recipientId: "user_123",
remainingUses: 1,
expiresAt: null,
imageUrl: null,
templateId:
"template_coffee",
isTransferable: false,
},
now
);
다만 입력 객체로 묶었다고 함수의 책임이 자동으로 작아지는 것은 아니다.
함수가 서로 관계없는 값을 지나치게 많이 요구한다면 두 가지 가능성을 확인해야 한다.
매개변수의 수는 함수 책임의 크기를 점검하는 신호가 될 수 있다.
함수를 작성한 다음에는 어디에 둘지도 결정해야 한다.
모든 함수를 하나의 utils.ts 파일에 넣으면 처음에는 편리하다.
// utils.ts
export function trimText() {}
export function createCoupon() {}
export function canRedeemCoupon() {}
export function formatDate() {}
export function sendEmail() {}
export function calculatePrice() {}
하지만 서비스가 성장하면 utils.ts는 서로 관련 없는 함수가 모이는 공간이 된다.
함수의 위치는 책임이 속한 영역에 따라 결정하는 편이 낫다.
coupon/
├── createCoupon.ts
├── redeemCoupon.ts
├── canRedeemCoupon.ts
├── couponTypes.ts
└── couponRepository.ts
또는 규모에 따라 하나의 도메인 모듈 안에서 관리할 수 있다.
// coupon/couponRules.ts
export function isCouponExpired(
coupon: Coupon,
now: Date
): boolean {
// ...
}
export function canRedeemCoupon(
coupon: Coupon,
userId: string,
now: Date
): boolean {
// ...
}
표현 형식을 위한 함수는 UI 또는 응답 계층에 둘 수 있다.
// coupon/couponPresenter.ts
export function formatCouponExpiry(
expiresAt: Date | null
): string {
// ...
}
데이터베이스 접근은 Repository에 둘 수 있다.
// coupon/couponRepository.ts
export async function findCouponById(
couponId: string
): Promise<Coupon | null> {
// ...
}
함수가 어디에 있어야 하는지는 재사용 횟수만으로 결정하지 않는다.
다음 질문을 함께 고려해야 한다.
함수의 위치는 파일 정리의 문제가 아니라 서비스 책임의 소속을 표현하는 설계다.
쿠폰 생성 요청이 들어오면 여러 함수가 순서대로 호출된다.
flowchart LR
A[HTTP 요청]
--> B[입력 변환과 검증]
--> C[쿠폰 생성]
--> D[데이터베이스 저장]
--> E[알림 전송]
--> F[응답 변환]
코드에서는 다음과 같이 표현할 수 있다.
async function handleCreateCoupon(
request: Request
): Promise<CouponResponse> {
const input =
parseCreateCouponRequest(
request.body
);
const coupon =
createCoupon(
{
...input,
issuerId:
request.currentUser.id,
},
new Date()
);
const savedCoupon =
await couponRepository.save(
coupon
);
await notificationService
.notifyCouponRecipient(
savedCoupon
);
return toCouponResponse(
savedCoupon
);
}
각 함수는 서로 다른 책임을 가진다.
parseCreateCouponRequest는 외부 입력을 서비스가 사용할 수 있는 형태로 바꾼다.createCoupon은 새 쿠폰의 초기 상태와 생성 규칙을 정의한다.couponRepository.save는 쿠폰을 저장한다.notifyCouponRecipient는 수신자에게 알림을 보낸다.toCouponResponse는 내부 객체를 API 응답 형태로 변환한다.handleCreateCoupon은 이 함수들을 호출해 하나의 사용 사례를 완성한다.
이 함수의 역할은 모든 세부 규칙을 직접 구현하는 것이 아니라 작업의 순서와 관계를 조정하는 것이다.
좋은 함수 분리는 코드를 최대한 잘게 나누는 것이 아니다.
서로 다른 책임을 구분하면서도 전체 서비스 흐름을 읽을 수 있게 만드는 것이다.
다음 코드는 쿠폰 제목을 정리하고 확인한다.
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;
}
이를 모든 연산 단위로 분리할 수도 있다.
function assertString(
value: unknown
): asserts value is string {
// ...
}
function trimTitle(
value: string
): string {
// ...
}
function assertNotEmpty(
value: string
): void {
// ...
}
function assertMaximumLength(
value: string,
maximum: number
): void {
// ...
}
재사용 가능한 검증 체계를 만드는 상황이라면 도움이 될 수 있다.
하지만 작은 서비스에서 한 번만 사용한다면 함수 호출을 따라가느라 쿠폰 제목 규칙을 한눈에 보기 어려워질 수 있다.
함수를 분리할 때는 다음 효과가 있는지 확인해야 한다.
단순히 코드 줄 수를 줄이거나 모든 동사를 함수로 만드는 것이 목적은 아니다.
함수 분리는 이해와 변경을 돕기 위한 설계 판단이다.
함수가 하나의 비즈니스 행동을 정의하면 테스트도 그 행동을 기준으로 작성할 수 있다.
describe(
"canRedeemCoupon",
() => {
it(
"활성 상태이고 사용 횟수가 남은 쿠폰은 사용할 수 있다",
() => {
const coupon: Coupon = {
id: "coupon_123",
recipientId: "user_123",
status: "active",
remainingUses: 1,
expiresAt: null,
};
const result =
canRedeemCoupon(
coupon,
"user_123",
new Date(
"2026-09-01T00:00:00Z"
)
);
expect(result).toBe(true);
}
);
it(
"만료된 쿠폰은 사용할 수 없다",
() => {
const coupon: Coupon = {
id: "coupon_123",
recipientId: "user_123",
status: "active",
remainingUses: 1,
expiresAt:
new Date(
"2026-08-31T00:00:00Z"
),
};
const result =
canRedeemCoupon(
coupon,
"user_123",
new Date(
"2026-09-01T00:00:00Z"
)
);
expect(result).toBe(false);
}
);
}
);
테스트 이름은 함수가 보장해야 하는 서비스 규칙을 설명한다.
내부 구현이 변경되어도 이 약속이 유지된다면 테스트는 계속 통과한다.
function canRedeemCoupon(
coupon: Coupon,
userId: string,
now: Date
): boolean {
return [
coupon.recipientId === userId,
coupon.status === "active",
coupon.remainingUses > 0,
coupon.expiresAt === null ||
coupon.expiresAt > now,
].every(Boolean);
}
구현 방식은 달라졌지만 함수가 외부에 약속한 행동은 같다.
함수 단위 테스트의 목적은 함수 안의 모든 줄이 실행되는지 확인하는 데만 있지 않다.
그 함수가 이름으로 표현한 규칙이 계속 지켜지는지 확인하는 데 있다.
처음에는 버튼 이벤트 안에 모든 코드를 작성할 수 있다.
async function onRedeemClick() {
if (
coupon.remainingUses <= 0
) {
alert(
"남은 사용 횟수가 없다."
);
return;
}
const response =
await fetch(
`/api/coupons/${coupon.id}/redeem`,
{
method: "POST",
}
);
if (!response.ok) {
alert(
"쿠폰 사용에 실패했다."
);
return;
}
const redeemedCoupon =
await response.json();
setCoupon(redeemedCoupon);
}
작은 화면에서는 충분할 수 있다.
서비스가 성장하면 서로 다른 책임이 보이기 시작한다.
async function onRedeemClick() {
// 검증, API 요청, 오류 처리,
// 화면 상태 변경
}
구현은 빠르지만 화면 책임과 서비스 행동이 섞여 있다.
async function requestCouponRedemption(
couponId: string
): Promise<CouponResponse> {
const response =
await fetch(
`/api/coupons/${couponId}/redeem`,
{
method: "POST",
}
);
if (!response.ok) {
throw new Error(
"쿠폰 사용에 실패했다."
);
}
return response.json();
}
화면은 HTTP 요청의 세부사항을 알 필요가 줄어든다.
async function redeemCoupon(
couponId: string,
userId: string,
now: Date
): Promise<Coupon> {
const coupon =
await couponRepository.findById(
couponId
);
if (!coupon) {
throw new Error(
"쿠폰을 찾을 수 없다."
);
}
const result =
applyCouponRedemption(
coupon,
userId,
now
);
return couponRepository.save(
result.coupon
);
}
쿠폰 사용이라는 행동이 UI나 HTTP 요청과 분리된다.
function applyCouponRedemption(
coupon: Coupon,
userId: string,
now: Date
): CouponRedemptionResult {
// 쿠폰 사용 규칙과
// 다음 상태 계산
}
async function redeemCoupon(
couponId: string,
userId: string,
now: Date
): Promise<Coupon> {
// 조회, 계산 함수 호출, 저장
}
이제 규칙은 데이터베이스 없이 테스트할 수 있고, 전체 흐름은 별도의 함수에서 관리된다.
서비스가 성장한다는 것은 함수의 수를 무조건 늘리는 것이 아니다.
변경되는 책임을 발견하고 그 책임에 적절한 이름과 경계를 부여하는 과정이다.
process, handle, manage처럼 범위가 불분명한 이름을 사용하고 있지는 않은가?utils에 넣기보다 책임이 속한 기능 가까이에 둘 수 있는가?이 질문에 답하면 함수를 단순한 코드 정리 도구가 아니라 서비스의 책임을 설계하는 단위로 사용할 수 있다.
function stepOne() {}
function stepTwo() {}
function stepThree() {}
각 함수가 서비스에서 어떤 의미를 가지는지 알기 어렵다.
parseCreateCouponRequest();
createCoupon();
saveCoupon();
notifyCouponRecipient();
줄 수가 아니라 행동과 책임을 기준으로 함수를 나누는 편이 낫다.
function compareDateAndStatus() {
// ...
}
무엇을 위한 비교인지 알기 어렵다.
function canRedeemCoupon() {
// ...
}
구현 방법보다 서비스에서 답하려는 질문을 이름으로 표현한다.
function processValue(
value: unknown,
options: {
trim?: boolean;
lowercase?: boolean;
convertNumber?: boolean;
allowNull?: boolean;
}
) {
// ...
}
하나의 함수가 여러 종류의 값과 규칙을 처리하면서 의미가 불분명해진다.
parseCouponTitle(value);
parseRemainingUses(value);
parseExpiresAt(value);
서비스에서 서로 다른 의미를 가진 값은 각 규칙에 맞는 함수로 표현할 수 있다.
function canRedeemCoupon() {
return (
currentCoupon.expiresAt >
new Date()
);
}
어떤 쿠폰과 시간을 사용하는지 호출부에서 알 수 없다.
function canRedeemCoupon(
coupon: Coupon,
now: Date
) {
return (
coupon.expiresAt === null ||
coupon.expiresAt > now
);
}
결과에 영향을 주는 정보를 입력으로 드러낸다.
function isCouponValid(
coupon: Coupon
): boolean {
if (
coupon.expiresAt !== null &&
coupon.expiresAt <= new Date()
) {
coupon.status = "expired";
return false;
}
return true;
}
isCouponValid라는 질문형 이름에서 객체 변경을 예상하기 어렵다.
function isCouponExpired(
coupon: Coupon,
now: Date
): boolean {
return (
coupon.expiresAt !== null &&
coupon.expiresAt <= now
);
}
상태 변경이 필요하다면 별도의 행동으로 표현한다.
function expireCoupon(
coupon: Coupon,
now: Date
): Coupon {
return {
...coupon,
status: "expired",
updatedAt: now,
};
}
조회와 변경의 책임을 구분해야 한다.
async function createCoupon() {
// 입력 검증
// 쿠폰 생성
// 데이터베이스 저장
// 이메일 발송
// 분석 이벤트 기록
// 응답 변환
}
하나의 외부 작업이 실패했을 때 전체 결과를 판단하기 어렵다.
const input =
parseCreateCouponRequest(body);
const coupon =
createCoupon(input, now);
const savedCoupon =
await saveCoupon(coupon);
await notifyCouponRecipient(
savedCoupon
);
return toCouponResponse(
savedCoupon
);
각 작업의 책임과 실패 지점을 구분할 수 있다.
function normalizeText(
value: string
): string {
return value
.trim()
.toLowerCase();
}
쿠폰 제목, 이메일, 쿠폰 코드에 모두 적용하면 서로 다른 규칙이 결합된다.
normalizeCouponTitle(value);
normalizeEmail(value);
normalizeCouponCode(value);
현재 구현이 같더라도 의미와 변경 이유가 다르면 별도의 함수로 유지할 수 있다.
const success =
redeemCoupon(coupon);
실패했을 때 무엇을 해야 하는지 알기 어렵다.
const result =
redeemCoupon(
coupon,
userId,
now
);
if (!result.success) {
showRedemptionError(
result.reason
);
}
호출자가 필요한 결정을 내릴 수 있도록 결과를 표현해야 한다.
getTitle();
trimTitle();
checkTitleLength();
createTitle();
함수 호출을 따라가야 해서 하나의 간단한 규칙을 이해하기 어려워질 수 있다.
함수를 나눌 때는 각 함수가 독립적인 의미, 책임 또는 변경 이유를 가지는지 확인해야 한다.
함수는 여러 줄의 코드를 하나로 묶고 다시 실행할 수 있게 하는 문법이다.
function add(a, b) {
return a + b;
}
하지만 실제 서비스에서 함수의 역할은 코드 재사용에 그치지 않는다.
현실의 행동과 서비스의 규칙에 이름을 붙인다.
createCoupon();
canRedeemCoupon();
redeemCoupon();
cancelCoupon();
transferCoupon();
함수 이름은 내부에서 사용하는 연산보다 서비스에서 어떤 의미를 가지는지를 설명해야 한다.
function canRedeemCoupon(
coupon: Coupon,
userId: string,
now: Date
): boolean {
// 쿠폰 사용 가능 규칙
}
함수의 입력과 출력은 책임의 경계를 만든다.
Coupon + User ID + Current Time
→ canRedeemCoupon
→ Boolean
함수 안의 모든 코드는 하나의 목적을 위해 함께 존재해야 한다.
그 목적은 코드 한 줄이 아니라 하나의 비즈니스 행동일 수 있다.
function applyCouponRedemption(
coupon: Coupon,
userId: string,
now: Date
): CouponRedemptionResult {
// 권한 확인
// 만료 확인
// 남은 횟수 확인
// 다음 상태 계산
// 사용 기록 생성
}
계산 규칙과 외부 상태 변경을 구분하면 함수는 더 예측하고 테스트하기 쉬워진다.
const result =
applyCouponRedemption(
coupon,
userId,
now
);
await couponRepository.save(
result.coupon
);
함수를 나누는 목적은 코드 조각을 최대한 많이 만드는 것이 아니다.
결국 함수를 만든다는 것은 다음 질문에 답하는 일이다.
이 서비스에서 하나의 독립된 행동이나 판단은 무엇이며, 그 행동은 어떤 정보를 받아 어디까지 책임져야 하는가?
함수는 코드를 묶어두는 상자가 아니다.
현실의 행동과 서비스의 규칙에 이름을 붙이고, 책임과 변경의 경계를 정의하는 방법이다.