반환값은 함수의 결과를 돌려주는 값이 아니다

vx_developer·2026년 9월 2일

개발하다가

목록 보기
12/30
post-thumbnail

프로그래밍을 처음 배울 때 반환값은 보통 함수가 계산한 결과를 호출자에게 돌려주는 값이라고 배운다.

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인가?
  • 입력값이 유효하지 않았는가?
  • 네트워크 타임아웃이 발생했는가?

각 상황마다 호출자가 취해야 할 행동은 다르다.

연결 문제라면 재시도할 수 있지만, 중복된 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 연결 끊김, 잘못된 프로그램 상태)예외로 표현
비즈니스 규칙에 따른 정상적인 흐름의 일부반환값으로 표현
시스템 오류이거나 버그로 인한 상황예외로 표현

기준은 "이 실패가 정상적인 업무 흐름의 일부인가, 아니면 예상 밖의 상황인가"다.
쿠폰이 만료된 것은 서비스에서 자연스럽게 일어나는 일이다.
데이터베이스 연결이 갑자기 끊기는 것은 함수의 정상적인 책임 범위를 벗어난 문제다.
이 둘을 같은 방식으로 처리하면 호출자는 정말 예외적인 상황과 일상적인 실패를 구분할 수 없다.


null은 무엇을 의미하는가

쿠폰을 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);
}

각 반환값의 형태가 다음 단계에서 무엇을 확인해야 하는지를 정한다.

  • findByIdCoupon | null을 반환하기 때문에 다음 코드는 반드시 null 여부를 확인해야 한다.
  • redeemCouponRedeemCouponResult를 반환하기 때문에 다음 코드는 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);

여러 개를 처리하는 함수는 "전부 성공했다"와 "무언가 실패했다"라는 두 상태만으로는 부족할 때가 많다.
몇 개가 성공했고 몇 개가 어떤 이유로 실패했는지가 호출자의 다음 행동에 영향을 준다면, 그 정보를 반환값 구조에 담아야 한다.


반환값을 설계하기 전에 물어봐야 할 질문

호출자의 다음 행동

  1. 호출자는 이 반환값만 보고 다음에 무엇을 해야 할지 판단할 수 있는가?
  2. 반환값이 없다면(void), 그것은 의도적인 결정인가?
  3. 호출자가 이 함수를 호출한 뒤 반드시 확인해야 하는 값이 있는가?
  4. 같은 판단 로직이 여러 호출부에서 반복되고 있지는 않은가?

성공과 실패

  1. 이 함수는 실패할 수 있는가?
  2. 실패했다는 사실뿐 아니라 왜 실패했는지도 호출자에게 필요한가?
  3. 이 실패는 정상적인 업무 흐름의 일부인가, 예상 밖의 상황인가?
  4. 예외와 반환값 중 어느 쪽으로 표현하는 것이 호출자에게 더 명확한가?
  5. 여러 개를 처리하는 함수라면 부분 실패를 표현해야 하는가?

없음과 특수값

  1. null이나 undefined가 단 하나의 의미만 가지는가?
  2. 특수한 값(-1, 0, "")으로 여러 상황을 동시에 표현하고 있지는 않은가?
  3. "아직 없음"과 "찾을 수 없음"과 "오류로 알 수 없음"을 구분해야 하는가?

타입과 의미

  1. 반환 타입이 형태만 설명하고 있지는 않은가?
  2. 같은 타입이지만 서로 다른 의미를 가진 값을 반환하고 있지는 않은가?
  3. 반환값의 각 상태를 명시적인 타입으로 표현할 수 있는가?
  4. 호출자가 반환값의 타입만 보고 잘못 사용할 가능성이 있는가?

데이터의 범위

  1. 반환값에 호출자가 필요로 하지 않는 정보까지 포함되어 있지는 않은가?
  2. 민감한 정보가 불필요하게 반환값에 담겨 있지는 않은가?
  3. 원본 데이터를 그대로 반환하는 것과 가공된 결과를 반환하는 것 중 무엇이 적절한가?

흔히 하는 실수

boolean으로 실패 이유를 감춘다

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" };

예상 가능한 실패는 타입으로 드러낸다.


null 하나에 여러 의미를 담는다

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 }>;
}

좋은 반환값 설계는 다음을 가능하게 한다.

  • 호출자가 예외 상황을 명시적으로 확인하게 한다.
  • 실패의 원인을 구분해 서로 다른 대응을 할 수 있게 한다.
  • 없음과 실패와 오류를 서로 다른 상황으로 표현한다.
  • 반복되는 판단 로직을 함수 안으로 옮긴다.
  • 부분 실패가 있는 작업의 결과를 정확히 전달한다.
  • 다음 단계의 함수가 무엇을 입력받을지 명확하게 만든다.
  • 필요하지 않은 정보가 불필요하게 이동하는 것을 막는다.

결국 반환값을 정의한다는 것은 다음 질문에 답하는 일이다.

이 함수를 호출한 다음, 호출자는 어떤 정보를 근거로 성공과 실패를 구분하고 다음 행동을 결정해야 하는가?

반환값은 함수의 결과를 돌려주는 값이 아니다.
처리 결과와 실패 가능성을 호출자에게 전달하는, 함수와 호출자 사이의 또 다른 계약이다.

profile
Vision eXperience Developer

0개의 댓글