
프로그래밍을 처음 배울 때 반환값은 보통 함수가 계산한 결과를 호출자에게 돌려주는 값이라고 배운다.
function add(a, b) {
return a + b;
}
const result = add(3, 5);
add 함수는 두 숫자를 더한 뒤 그 결과를 return으로 돌려준다.
반환값의 기본적인 개념을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 반환값을 설계한다는 것은 단순히 계산 결과를 돌려주는 것보다 훨씬 많은 판단을 요구한다.
예를 들어 쿠폰을 사용하는 함수를 만든다고 생각해 보자.
redeemCoupon(coupon, userId, now);
이 함수가 반환하는 값에는 서비스의 중요한 결정이 담겨 있다.
반환값은 함수가 자신의 작업 결과를 호출자에게 전달하는 유일한 통로다.
동시에 호출자가 다음에 무엇을 할 수 있고 무엇을 확인해야 하는지도 결정한다.
실제 서비스에서 반환값을 설계한다는 것은 계산 결과를 돌려주는 문법을 작성하는 일이 아니라, 처리 결과와 실패 가능성을 호출자에게 정확히 전달하는 계약을 설계하는 일이다.
쿠폰을 사용하는 함수를 다시 살펴보자.
function redeemCoupon(
coupon: Coupon,
userId: string,
now: Date
): Coupon {
if (coupon.recipientId !== userId) {
throw new Error("쿠폰을 받은 사용자만 사용할 수 있다.");
}
return {
...coupon,
remainingUses: coupon.remainingUses - 1,
updatedAt: now,
};
}
이 함수는 성공했을 때 새로운 쿠폰 상태를 반환한다.
호출하는 쪽에서는 다음과 같이 사용한다.
const updatedCoupon = redeemCoupon(coupon, userId, now);
await couponRepository.save(updatedCoupon);
여기서 반환값은 단순히 "결과"가 아니다.
호출자에게 저장해도 되는 다음 상태를 알려주는 신호다.
만약 함수가 아무것도 반환하지 않는다면 호출자는 원본 쿠폰 객체를 그대로 저장하거나, 함수 내부에서 데이터베이스까지 직접 처리했다고 가정해야 한다.
function redeemCoupon(
coupon: Coupon,
userId: string,
now: Date
): void {
coupon.remainingUses -= 1;
coupon.updatedAt = now;
}
이 코드는 겉으로는 간단해 보이지만 호출자에게 중요한 정보를 숨긴다.
반환값을 설계할 때는 먼저 다음 질문을 해야 한다.
이 함수를 호출한 다음, 호출자는 무엇을 근거로 다음 단계를 진행해야 하는가?
반환값이 없다는 것도 하나의 설계 결정이다. 그 결정이 의도적인지, 아니면 단순히 놓친 것인지를 구분해야 한다.
쿠폰 사용 가능 여부를 저장하는 함수를 생각해 보자.
function saveCoupon(coupon: Coupon): boolean {
try {
couponRepository.save(coupon);
return true;
} catch {
return false;
}
}
호출자는 이렇게 사용한다.
const success = saveCoupon(coupon);
if (!success) {
// 그런데 왜 실패했는가?
}
boolean은 성공과 실패를 구분할 수 있지만 실패한 이유는 알려주지 않는다.
각 상황마다 호출자가 취해야 할 행동은 다르다.
연결 문제라면 재시도할 수 있지만, 중복된 ID라면 재시도해도 같은 결과가 반복된다.
boolean 하나로는 이런 구분을 표현할 수 없다.
type SaveCouponResult =
| { ok: true; coupon: Coupon }
| { ok: false; reason: "DUPLICATE_ID" }
| { ok: false; reason: "CONNECTION_ERROR" }
| { ok: false; reason: "VALIDATION_FAILED"; message: string };
function saveCoupon(coupon: Coupon): SaveCouponResult {
// ...
}
호출자는 실패 이유에 따라 다르게 대응할 수 있다.
const result = saveCoupon(coupon);
if (!result.ok) {
if (result.reason === "CONNECTION_ERROR") {
return retrySaveCoupon(coupon);
}
if (result.reason === "DUPLICATE_ID") {
throw new Error("이미 존재하는 쿠폰이다.");
}
}
성공 여부만 알려주는 반환값은 "무엇을 했는지"는 알려주지만 "왜 그렇게 됐는지"는 알려주지 않는다.
호출자가 결과에 따라 서로 다른 행동을 해야 한다면, 반환값도 그 차이를 표현할 수 있어야 한다.
쿠폰 사용 가능 여부를 판단하는 함수가 있다.
function redeemCoupon(coupon: Coupon, userId: string, now: Date): Coupon {
if (coupon.recipientId !== userId) {
throw new Error("쿠폰을 받은 사용자만 사용할 수 있다.");
}
if (coupon.remainingUses <= 0) {
throw new Error("사용 가능 횟수가 남아있지 않다.");
}
if (coupon.expiresAt !== null && coupon.expiresAt <= now) {
throw new Error("만료된 쿠폰이다.");
}
return {
...coupon,
remainingUses: coupon.remainingUses - 1,
};
}
이 함수는 실패를 예외로 표현한다.
호출하는 쪽은 다음과 같이 처리해야 한다.
try {
const updatedCoupon = redeemCoupon(coupon, userId, now);
await couponRepository.save(updatedCoupon);
} catch (error) {
// 어떤 에러가 발생했는지 확인해야 한다.
}
함수의 시그니처만 보면 실패 가능성이 드러나지 않는다.
function redeemCoupon(coupon: Coupon, userId: string, now: Date): Coupon;
이 타입만 보면 redeemCoupon은 항상 Coupon을 반환하는 것처럼 보인다.
호출자가 예외 처리를 빠뜨려도 컴파일 시점에는 어떤 오류도 나타나지 않는다.
실패 가능성을 반환 타입으로 드러낼 수도 있다.
type RedeemCouponResult =
| { ok: true; coupon: Coupon }
| { ok: false; reason: "NOT_RECIPIENT" | "NO_REMAINING_USES" | "EXPIRED" };
function redeemCoupon(
coupon: Coupon,
userId: string,
now: Date
): RedeemCouponResult {
if (coupon.recipientId !== userId) {
return { ok: false, reason: "NOT_RECIPIENT" };
}
if (coupon.remainingUses <= 0) {
return { ok: false, reason: "NO_REMAINING_USES" };
}
if (coupon.expiresAt !== null && coupon.expiresAt <= now) {
return { ok: false, reason: "EXPIRED" };
}
return {
ok: true,
coupon: {
...coupon,
remainingUses: coupon.remainingUses - 1,
},
};
}
이제 호출자는 try/catch 없이도 결과를 확인할 수 있다.
const result = redeemCoupon(coupon, userId, now);
if (!result.ok) {
return toErrorResponse(result.reason);
}
await couponRepository.save(result.coupon);
그렇다고 모든 실패를 값으로 표현해야 하는 것은 아니다.
두 방식은 서로 다른 상황에서 의미가 있다.
| 상황 | 적합한 표현 |
|---|---|
| 호출자가 예상하고 처리해야 하는 실패 (쿠폰 만료, 권한 없음) | 반환값으로 표현 |
| 호출자가 통제할 수 없는 예외적 상황 (DB 연결 끊김, 잘못된 프로그램 상태) | 예외로 표현 |
| 비즈니스 규칙에 따른 정상적인 흐름의 일부 | 반환값으로 표현 |
| 시스템 오류이거나 버그로 인한 상황 | 예외로 표현 |
기준은 "이 실패가 정상적인 업무 흐름의 일부인가, 아니면 예상 밖의 상황인가"다.
쿠폰이 만료된 것은 서비스에서 자연스럽게 일어나는 일이다.
데이터베이스 연결이 갑자기 끊기는 것은 함수의 정상적인 책임 범위를 벗어난 문제다.
이 둘을 같은 방식으로 처리하면 호출자는 정말 예외적인 상황과 일상적인 실패를 구분할 수 없다.
쿠폰을 ID로 조회하는 함수를 살펴보자.
function findCouponById(couponId: string): Coupon | null {
return couponRepository.findById(couponId);
}
호출자는 다음과 같이 사용한다.
const coupon = findCouponById(couponId);
if (coupon === null) {
// 쿠폰이 없다.
}
이 함수에서 null은 명확한 의미를 가진다. 해당 ID의 쿠폰이 존재하지 않는다.
하지만 다음 함수를 보자.
function getActiveCouponForUser(userId: string): Coupon | null {
// ...
}
이 함수에서 null은 여러 의미로 해석될 수 있다.
호출자가 null을 받았을 때 화면에 어떤 메시지를 보여줘야 할지 판단할 수 없다.
의미가 여러 개로 갈릴 수 있다면 null 대신 각 상황을 표현하는 타입을 사용하는 편이 낫다.
type ActiveCouponLookup =
| { status: "FOUND"; coupon: Coupon }
| { status: "NO_COUPON_ISSUED" }
| { status: "ALL_USED" }
| { status: "ALL_EXPIRED" };
function getActiveCouponForUser(
userId: string
): ActiveCouponLookup {
// ...
}
const lookup = getActiveCouponForUser(userId);
switch (lookup.status) {
case "FOUND":
return renderCoupon(lookup.coupon);
case "NO_COUPON_ISSUED":
return renderEmptyState("발급된 쿠폰이 없다.");
case "ALL_USED":
return renderEmptyState("모든 쿠폰을 사용했다.");
case "ALL_EXPIRED":
return renderEmptyState("쿠폰이 모두 만료되었다.");
}
null이 항상 나쁜 것은 아니다.
"존재하지 않는다"라는 단 하나의 의미만 가진다면 null은 여전히 좋은 선택이다.
문제는 null 하나에 여러 상황을 뭉뚱그려 담을 때 발생한다.
값이 없다는 사실만으로는 호출자가 다음 행동을 결정할 수 없을 때, 없음의 이유를 반환값 안에 구체적으로 표현해야 한다.
다음 두 함수는 모두 number를 반환한다.
function getRemainingUses(coupon: Coupon): number {
return coupon.remainingUses;
}
function getDaysUntilExpiry(coupon: Coupon): number {
if (coupon.expiresAt === null) {
return -1;
}
const diff = coupon.expiresAt.getTime() - Date.now();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
getRemainingUses가 반환하는 0은 "더 이상 사용할 수 없다"는 명확한 의미를 가진다.
getDaysUntilExpiry가 반환하는 -1은 어떤 의미인가?
코드만 보면 두 가지로 해석될 수 있다.
같은 -1이 서로 다른 상황을 가리킬 수 있다면 호출자는 반환값만으로 상황을 구분할 수 없다.
const days = getDaysUntilExpiry(coupon);
if (days < 0) {
// 만료일이 없는 것인가, 만료된 것인가?
}
number 타입 하나로 "만료일 없음"과 "이미 지남"을 동시에 표현하려는 시도가 문제다.
type ExpiryStatus =
| { type: "NO_EXPIRY" }
| { type: "EXPIRED"; daysAgo: number }
| { type: "ACTIVE"; daysLeft: number };
function getExpiryStatus(coupon: Coupon): ExpiryStatus {
if (coupon.expiresAt === null) {
return { type: "NO_EXPIRY" };
}
const diffDays = Math.floor(
(coupon.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
if (diffDays < 0) {
return { type: "EXPIRED", daysAgo: Math.abs(diffDays) };
}
return { type: "ACTIVE", daysLeft: diffDays };
}
타입만 보고 반환값이 안전하다고 판단해서는 안 된다.
number, string, boolean 같은 원시 타입은 형태만 알려줄 뿐 서비스에서 그 값이 정확히 어떤 상황을 뜻하는지는 알려주지 않는다.
특수한 값(-1, 0, "", null)으로 여러 상황을 표현하려는 함수를 발견하면, 그 상황들을 별도의 타입으로 나눌 수 있는지 확인해야 한다.
쿠폰 목록을 조회하는 함수를 생각해 보자.
function getCoupons(userId: string): Coupon[] {
return couponRepository.findAllByUserId(userId);
}
서비스가 커지면서 화면에서는 사용 가능한 쿠폰만 보여줘야 한다는 요구사항이 생겼다.
const coupons = getCoupons(userId);
const redeemableCoupons = coupons.filter((coupon) => {
return (
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(coupon.expiresAt === null || coupon.expiresAt > new Date())
);
});
이 필터링 로직이 여러 화면과 여러 API에서 반복되기 시작한다.
// 주문 화면
const usable = coupons.filter(/* 거의 같은 조건 */);
// 마이페이지
const available = coupons.filter(/* 조금 다르게 쓴 같은 조건 */);
getCoupons는 저장된 데이터를 그대로 반환할 뿐, "사용 가능한 쿠폰이 무엇인가"라는 판단은 호출자에게 떠넘긴다.
같은 판단 로직이 여러 곳에 흩어지면 조건이 하나라도 바뀔 때 모든 호출부를 찾아 수정해야 한다.
판단 자체를 반환값 구조 안에 포함시킬 수 있다.
interface CouponListResult {
redeemable: Coupon[];
expired: Coupon[];
used: Coupon[];
}
function getCouponsGroupedByStatus(
userId: string,
now: Date
): CouponListResult {
const coupons = couponRepository.findAllByUserId(userId);
return {
redeemable: coupons.filter((c) => isRedeemable(c, now)),
expired: coupons.filter((c) => isExpired(c, now)),
used: coupons.filter((c) => c.remainingUses <= 0),
};
}
이제 호출자는 판단 로직을 다시 작성하지 않고 이미 분류된 결과를 사용한다.
const { redeemable } = getCouponsGroupedByStatus(userId, now);
반환값을 설계할 때는 다음을 확인해야 한다.
호출자가 반환값을 받은 뒤 매번 반복해서 같은 판단을 하고 있지는 않은가?
원시 데이터를 그대로 돌려주는 것이 항상 나쁜 것은 아니다.
호출하는 곳마다 원하는 가공 방식이 다르다면 원본 데이터를 반환하고 가공은 각자 맡기는 편이 나을 수도 있다.
하지만 같은 판단이 여러 곳에서 반복된다면, 그 판단을 함수의 반환값 구조 안으로 옮기는 것을 고려해야 한다.
쿠폰 사용 요청의 전체 흐름을 반환값 중심으로 살펴보자.
flowchart LR
A[couponRepository.findById]
--> B[Coupon 또는 null]
--> C[canRedeemCoupon]
--> D[RedeemCouponResult]
--> E[couponRepository.save]
--> F[저장된 Coupon]
--> G[toCouponResponse]
--> H[HTTP Response]
각 단계의 반환값은 그대로 다음 함수의 입력이 된다.
async function handleRedeemCoupon(
request: Request
): Promise<CouponResponse> {
const coupon = await couponRepository.findById(
request.params.couponId
);
if (coupon === null) {
throw new NotFoundError("쿠폰을 찾을 수 없다.");
}
const result = redeemCoupon(
coupon,
request.currentUser.id,
new Date()
);
if (!result.ok) {
throw new BusinessRuleError(result.reason);
}
const savedCoupon = await couponRepository.save(result.coupon);
return toCouponResponse(savedCoupon);
}
각 반환값의 형태가 다음 단계에서 무엇을 확인해야 하는지를 정한다.
findById가 Coupon | null을 반환하기 때문에 다음 코드는 반드시 null 여부를 확인해야 한다.redeemCoupon이 RedeemCouponResult를 반환하기 때문에 다음 코드는 ok 값을 먼저 확인해야 한다.save가 저장된 Coupon을 반환하기 때문에 응답 변환 함수는 데이터베이스가 실제로 기록한 값을 기준으로 응답을 만든다.만약 중간의 어떤 함수가 실패 가능성을 반환 타입에 드러내지 않는다면, 그 확인은 코드 어딘가에서 누락되기 쉽다.
반환값은 함수 하나의 출구일 뿐 아니라 서비스 흐름에서 각 단계가 다음 단계에게 무엇을 보장하는지를 전달하는 수단이다.
여러 쿠폰을 한 번에 발급하는 함수를 생각해 보자.
async function issueCoupons(
recipientIds: string[],
template: CouponTemplate
): Promise<Coupon[]> {
const coupons: Coupon[] = [];
for (const recipientId of recipientIds) {
const coupon = await createCoupon(recipientId, template);
coupons.push(coupon);
}
return coupons;
}
100명에게 쿠폰을 발급하는 중 37번째 사용자에게서 오류가 발생하면 어떻게 되는가?
이 코드는 오류가 발생하는 순간 예외를 던지고 전체 작업이 중단된다.
호출자는 몇 명에게 성공적으로 발급되었는지, 누구에게 실패했는지 알 수 없다.
try {
await issueCoupons(recipientIds, template);
} catch (error) {
// 몇 명은 성공했을 수도 있는데, 그 정보가 사라졌다.
}
전체를 하나의 성공/실패로만 표현하면 부분적으로 성공한 결과를 설명할 수 없다.
interface IssueCouponsResult {
succeeded: Array<{ recipientId: string; coupon: Coupon }>;
failed: Array<{ recipientId: string; reason: string }>;
}
async function issueCoupons(
recipientIds: string[],
template: CouponTemplate
): Promise<IssueCouponsResult> {
const succeeded: IssueCouponsResult["succeeded"] = [];
const failed: IssueCouponsResult["failed"] = [];
for (const recipientId of recipientIds) {
try {
const coupon = await createCoupon(recipientId, template);
succeeded.push({ recipientId, coupon });
} catch (error) {
failed.push({
recipientId,
reason: error instanceof Error ? error.message : "UNKNOWN",
});
}
}
return { succeeded, failed };
}
호출자는 이제 부분 실패를 구체적으로 다룰 수 있다.
const result = await issueCoupons(recipientIds, template);
if (result.failed.length > 0) {
await notifyAdminOfFailures(result.failed);
}
await notifyRecipients(result.succeeded);
여러 개를 처리하는 함수는 "전부 성공했다"와 "무언가 실패했다"라는 두 상태만으로는 부족할 때가 많다.
몇 개가 성공했고 몇 개가 어떤 이유로 실패했는지가 호출자의 다음 행동에 영향을 준다면, 그 정보를 반환값 구조에 담아야 한다.
void), 그것은 의도적인 결정인가?null이나 undefined가 단 하나의 의미만 가지는가?-1, 0, "")으로 여러 상황을 동시에 표현하고 있지는 않은가?function saveCoupon(coupon: Coupon): boolean {
// ...
}
성공 여부는 알 수 있지만 실패한 이유는 알 수 없다.
type SaveCouponResult =
| { ok: true; coupon: Coupon }
| { ok: false; reason: string };
실패 이유를 반환값 구조에 포함한다.
function redeemCoupon(coupon: Coupon): Coupon {
if (coupon.remainingUses <= 0) {
throw new Error("사용 가능 횟수가 없다.");
}
// ...
}
함수 시그니처만 보면 항상 성공하는 것처럼 보인다.
type RedeemCouponResult =
| { ok: true; coupon: Coupon }
| { ok: false; reason: "NO_REMAINING_USES" };
예상 가능한 실패는 타입으로 드러낸다.
function getActiveCoupon(userId: string): Coupon | null {
// null이 "없음"인지 "만료됨"인지 "오류"인지 알 수 없다.
}
type ActiveCouponLookup =
| { status: "FOUND"; coupon: Coupon }
| { status: "NOT_FOUND" }
| { status: "ALL_EXPIRED" };
상황마다 다른 상태로 구분한다.
const redeemable = coupons.filter(
(c) => c.status === "active" && c.remainingUses > 0
);
이 필터 조건이 여러 파일에 흩어져 있다.
function getRedeemableCoupons(userId: string, now: Date): Coupon[] {
// 판단 로직을 함수 안에 캡슐화한다.
}
반복되는 판단은 반환값을 만드는 함수 안으로 옮긴다.
async function issueCoupons(ids: string[]): Promise<void> {
for (const id of ids) {
await createCoupon(id); // 중간에 실패하면 이후 결과를 알 수 없다.
}
}
interface IssueCouponsResult {
succeeded: string[];
failed: Array<{ id: string; reason: string }>;
}
무엇이 성공했고 무엇이 실패했는지 구조로 표현한다.
function getCouponSummary(coupon: Coupon): Coupon {
return coupon; // 화면에는 제목과 남은 횟수만 필요하다.
}
interface CouponSummary {
title: string;
remainingUses: number;
}
function getCouponSummary(coupon: Coupon): CouponSummary {
return {
title: coupon.title,
remainingUses: coupon.remainingUses,
};
}
호출자가 실제로 필요로 하는 정보만 반환한다.
반환값은 함수가 계산한 결과를 호출자에게 돌려주는 값이다.
function add(a, b) {
return a + b;
}
하지만 실제 서비스에서 반환값은 단순한 결과의 통로가 아니다.
반환값은 호출자가 다음에 무엇을 해야 할지 결정하게 만드는 계약이다.
type RedeemCouponResult =
| { ok: true; coupon: Coupon }
| { ok: false; reason: "NOT_RECIPIENT" | "NO_REMAINING_USES" | "EXPIRED" };
이 타입에는 함수가 호출자에게 전달하려는 정보가 담겨 있다.
boolean이나 null 같은 단순한 타입만으로는 이런 정보를 전부 담을 수 없다.
function saveCoupon(coupon: Coupon): boolean
이 시그니처는 성공 여부만 알려줄 뿐 실패한 이유는 알려주지 않는다.
여러 개를 처리하는 함수라면 부분적인 성공과 실패도 구조로 표현해야 한다.
interface IssueCouponsResult {
succeeded: Array<{ recipientId: string; coupon: Coupon }>;
failed: Array<{ recipientId: string; reason: string }>;
}
좋은 반환값 설계는 다음을 가능하게 한다.
결국 반환값을 정의한다는 것은 다음 질문에 답하는 일이다.
이 함수를 호출한 다음, 호출자는 어떤 정보를 근거로 성공과 실패를 구분하고 다음 행동을 결정해야 하는가?
반환값은 함수의 결과를 돌려주는 값이 아니다.
처리 결과와 실패 가능성을 호출자에게 전달하는, 함수와 호출자 사이의 또 다른 계약이다.