입력과 출력은 문제에 주어진 형식이 아니다

vx_developer·2026년 9월 9일

코테보다가

목록 보기
8/25
post-thumbnail

알고리즘을 처음 배울 때 입력은 함수에 전달되는 값이고, 출력은 함수가 반환하는 값이라고 배운다.

function calculateDiscount(price, discountRate) {
  return price * discountRate;
}

이 함수의 입력은 상품 가격과 할인율이고, 출력은 할인 금액이다.

알고리즘의 동작을 이해하기에는 충분한 설명이다. 입력이 주어지면 정해진 절차로 처리한 뒤 결과를 반환한다는 구조를 분명하게 보여준다.

하지만 실제 서비스를 개발하기 시작하면 입력과 출력은 이렇게 정리된 상태로 주어지지 않는다.

쿠폰 서비스에서 크리스가 쿠폰을 사용한다고 생각해보자.

  • 쿠폰 ID는 URL과 요청 본문 중 어디에서 오는가?
  • 사용자의 ID를 요청값으로 받아도 되는가?
  • 주문 금액은 브라우저가 보낸 값을 믿어도 되는가?
  • 현재 시간은 사용자 기기와 서버 중 어느 쪽을 기준으로 하는가?
  • 쿠폰의 최신 상태는 어디에서 읽어야 하는가?
  • 사용할 수 없는 쿠폰이라면 false만 반환해도 되는가?
  • 쿠폰 사용에 성공했다는 것은 어떤 데이터가 변경되었다는 뜻인가?
  • 같은 요청이 두 번 들어오면 출력은 어떻게 달라져야 하는가?

코딩 문제에서는 입력 형식과 기대 출력이 문제에 적혀 있다. 실제 서비스에서는 어떤 값을 입력으로 인정하고 어떤 결과를 출력으로 표현할지 결정하는 일부터 개발자의 책임이 된다.

실제 서비스의 입력과 출력은 함수에 들어오고 나가는 값이 아니라, 신뢰할 수 없는 현실의 데이터를 검증 가능한 명령으로 바꾸고 처리 결과와 상태 변화를 다음 책임자에게 전달하는 계약이다.

함수의 매개변수보다 먼저 데이터의 출처를 확인해야 한다

쿠폰을 사용하는 함수를 다음과 같이 만들 수 있다.

async function redeemCoupon({
  couponId,
  userId,
  remainingUses,
}) {
  if (remainingUses > 0) {
    return couponRepository.redeem(couponId, userId);
  }

  return false;
}

코드만 보면 필요한 입력이 명확해 보인다.

쿠폰 ID, 사용자 ID와 남은 사용 횟수를 받아 사용 가능 여부를 판단한다. 하지만 이 값들이 모두 클라이언트의 요청 본문에서 들어온다면 문제가 생긴다.

const result = await redeemCoupon({
  couponId: request.body.couponId,
  userId: request.body.userId,
  remainingUses: request.body.remainingUses,
});

사용자는 브라우저에 표시된 값뿐 아니라 서버로 보내는 요청 자체를 변경할 수 있다.

크리스가 자신의 요청에 다른 사용자의 ID를 넣을 수도 있다. 이미 남은 횟수가 0인 쿠폰을 remainingUses: 1로 바꿔 보낼 수도 있다.

형식상 입력값이라는 이유만으로 그 값을 판단의 근거로 사용해서는 안 된다.

입력마다 신뢰할 수 있는 출처가 다르다.

필요한 정보적절한 출처이유
쿠폰 IDURL 또는 요청 본문사용자가 선택한 대상을 표현한다
요청자 ID인증된 세션 또는 접근 토큰클라이언트가 주장한 사용자 ID를 신뢰할 수 없다
쿠폰 상태데이터베이스처리 시점의 최신 상태가 필요하다
남은 사용 횟수데이터베이스다른 요청으로 변경될 수 있다
주문 금액서버가 상품 데이터로 다시 계산클라이언트의 금액은 조작될 수 있다
현재 시간서버 시계사용자 기기의 시간 설정에 의존하면 안 된다
중복 요청 식별자요청 헤더같은 작업이 반복 전달되었는지 판단하는 데 사용한다

따라서 서비스 로직에 전달할 입력은 외부 요청을 그대로 복사한 객체가 아니어야 한다.

const command = {
  couponId: parseCouponId(request.params.couponId),
  requesterId: session.user.id,
  requestId: parseRequestId(
    request.headers["idempotency-key"]
  ),
  requestedAt: clock.now(),
};

couponId와 requestId는 외부 입력이므로 형식을 검사한다. requesterId는 인증된 세션에서 가져오고, 현재 시간은 서버가 결정한다.

이렇게 구성한 command는 사용자가 보낸 원본 요청과 다르다.

원본 요청에서 필요한 값만 선택하고, 신뢰할 수 있는 출처의 값을 결합하여 서비스가 처리할 수 있는 명령으로 바꾼 것이다.

입력 형식이 올바르다고 사용할 수 있는 데이터는 아니다

쿠폰 ID가 문자열인지 확인했다고 생각해보자.

function parseCouponId(value) {
  if (typeof value !== "string" || value.length === 0) {
    throw new Error("쿠폰 ID가 올바르지 않다.");
  }

  return value;
}

이 검증은 빈 값이나 숫자가 들어오는 문제를 막는다.

하지만 문자열이라는 사실만으로 쿠폰을 사용할 수 있는 것은 아니다.

  • 실제로 존재하는 쿠폰인가?
  • 현재 사용자가 받은 쿠폰인가?
  • 활성 상태인가?
  • 만료되지 않았는가?
  • 남은 사용 횟수가 있는가?
  • 현재 주문에 적용할 수 있는가?

입력 검증에는 서로 다른 단계가 있다.

검증 단계확인하는 내용쿠폰 서비스의 예
형식 검증데이터를 해석할 수 있는가쿠폰 ID가 유효한 문자열인가
인증누가 요청했는가로그인한 사용자가 누구인가
권한 검증이 데이터에 접근할 수 있는가요청자가 쿠폰 수신자인가
비즈니스 검증서비스 규칙을 만족하는가활성 상태이고 만료되지 않았는가
상태 검증처리 순간에도 조건이 유효한가남은 사용 횟수가 여전히 있는가

형식 검증만 통과한 입력은 아직 서비스가 신뢰할 수 있는 입력이 아니다.

외부 요청을 안전한 명령으로 바꾸려면 데이터의 구조뿐 아니라 요청자의 권한과 최신 상태까지 확인해야 한다.

화면에 보이는 상태가 최종 판단의 입력은 아니다

크리스가 쿠폰 목록 화면을 열었을 때 남은 사용 횟수가 1회라고 표시되었다고 생각해보자.

프런트엔드에는 다음 데이터가 있을 수 있다.

const selectedCoupon = {
  id: "coupon_123",
  status: "ACTIVE",
  remainingUses: 1,
  expiresAt: "2026-12-31T13:00:00Z",
};

이 데이터는 화면을 표시하기에는 유용하다.

하지만 크리스가 쿠폰 사용 버튼을 누르는 순간에도 이 값이 최신이라는 보장은 없다. 다른 기기에서 같은 쿠폰을 먼저 사용했거나 운영자가 쿠폰을 취소했을 수 있다.

따라서 다음과 같이 화면 데이터를 다시 서버로 보내 최종 판단에 사용하면 안 된다.

await api.post("/coupons/redeem", {
  coupon: selectedCoupon,
});

이 요청은 클라이언트가 쿠폰의 상태와 남은 횟수까지 결정하게 만든다.

더 나은 요청은 사용자가 하려는 행동만 전달한다.

await api.post(
  `/coupons/${selectedCoupon.id}/redemptions`,
  {},
  {
    headers: {
      "Idempotency-Key": requestId,
    },
  }
);

클라이언트는 “이 쿠폰을 사용하고 싶다”라는 의도를 쿠폰 ID로 전달한다. 실제로 사용할 수 있는지는 서버가 최신 데이터를 조회하여 판단한다.

const coupon = await couponRepository.findById(
  command.couponId
);

이때 데이터베이스의 쿠폰 상태가 최종 판단을 위한 Source of Truth가 된다.

화면의 remainingUses는 사용자가 마지막으로 조회한 시점의 복사본이다. 데이터베이스의 값은 다른 요청이 반영된 최신 상태다.

입력은 단지 어디에서 얻었는지만이 아니라 언제 얻었으며 현재도 유효한지까지 확인해야 한다.

원본 데이터와 계산된 입력을 구분해야 한다

쿠폰의 만료 여부를 다음과 같이 저장할 수도 있다.

const coupon = {
  expiresAt: "2026-12-31T13:00:00Z",
  isExpired: false,
};

하지만 isExpired는 시간이 지나면 달라지는 값이다.

데이터베이스에 false로 저장한 뒤 갱신하지 않으면 만료 시각이 지나도 쿠폰이 유효한 것처럼 남을 수 있다.

만료 시각을 원본 데이터로 두고, 만료 여부는 판단 시점에 계산하는 편이 자연스럽다.

function isCouponExpired(coupon, serverNow) {
  return serverNow >= new Date(coupon.expiresAt);
}

expiresAt과 serverNow가 입력이고, isCouponExpired는 두 값으로 만든 계산 결과다.

여기에서 Source of Truth는 isExpired라는 별도 상태가 아니라 쿠폰의 만료 시각이다.

같은 원칙은 다른 값에도 적용된다.

  • remainingUses로부터 hasRemainingUses를 계산한다.
  • 쿠폰 상태와 만료 시각으로부터 isRedeemable을 계산한다.
  • 상품 가격과 수량으로부터 주문 금액을 계산한다.
  • 사용 이력으로부터 사용 횟수를 계산하거나 검증한다.

계산할 수 있는 값을 별도의 입력처럼 받으면 서로 다른 값 사이에 모순이 생길 수 있다.

입력을 설계할 때는 무엇을 원본으로 저장하고 무엇을 필요할 때 계산할지 결정해야 한다.

출력은 성공 여부 하나로 끝나지 않는다

쿠폰 사용 결과를 다음과 같이 반환할 수 있다.

return true;

쿠폰을 사용할 수 없다면 false를 반환한다.

return false;

간단한 함수에서는 충분할 수 있지만 실제 서비스에서는 정보가 부족하다.

false만으로는 쿠폰이 만료되었는지, 이미 사용되었는지, 다른 사용자의 쿠폰인지 알 수 없다. 프런트엔드는 사용자에게 적절한 메시지를 보여줄 수 없고, 운영자는 실패 원인을 분석하기 어렵다.

실패를 명확한 결과로 표현할 수 있다.

return {
  ok: false,
  reason: "COUPON_EXPIRED",
};

이 결과는 쿠폰을 적용하지 못했다는 사실과 그 이유를 함께 전달한다.

성공한 경우에도 필요한 결과를 명확하게 반환해야 한다.

return {
  ok: true,
  redemptionId: "redemption_456",
  couponId: "coupon_123",
  remainingUses: 0,
  redeemedAt: serverNow,
};

이 출력은 다음 사실을 표현한다.

  • 쿠폰 사용이 성공했다.
  • 어떤 사용 기록이 생성되었다.
  • 어떤 쿠폰이 사용되었다.
  • 처리 후 남은 횟수는 얼마인가.
  • 서버가 인정한 처리 시각은 언제인가.

좋은 출력은 내부 객체 전체를 그대로 노출하는 것이 아니다. 다음 단계가 올바르게 행동하는 데 필요한 정보만 제공하는 것이다.

실패 이유를 자세히 알려주는 것이 항상 안전한 것은 아니다

출력을 구체적으로 만들면 사용자 경험을 개선할 수 있다. 그러나 모든 정보를 모든 요청자에게 공개해도 된다는 뜻은 아니다.

로그인하지 않은 사용자가 임의의 쿠폰 ID를 조회했다고 생각해보자.

return {
  ok: false,
  reason: "COUPON_BELONGS_TO_ANOTHER_USER",
};

이 응답은 해당 ID의 쿠폰이 실제로 존재하며 다른 사용자의 것이라는 사실을 알려준다.

권한이 없는 요청에는 존재 여부를 구분하지 않는 출력이 더 안전할 수 있다.

return {
  ok: false,
  reason: "COUPON_NOT_FOUND",
};

서버 내부 로그에는 실제 실패 원인을 남기더라도 외부 응답에는 제한된 정보만 제공할 수 있다.

따라서 출력은 단순히 계산 결과를 표현하는 문제가 아니다.

  • 누가 이 출력을 받는가?
  • 어떤 정보를 알아야 다음 행동을 할 수 있는가?
  • 공개하면 안 되는 정보가 포함되어 있는가?
  • 내부 관찰용 결과와 사용자 응답을 구분해야 하는가?

입력의 신뢰도를 구분하듯 출력의 공개 범위도 구분해야 한다.

하나의 입력은 여러 결과를 만들 수 있다

크리스의 쿠폰 사용 요청이 성공하면 함수의 반환값만 생기는 것이 아니다.

다음과 같은 변화가 함께 발생할 수 있다.

  • 쿠폰의 남은 사용 횟수가 감소한다.
  • 쿠폰 사용 기록이 생성된다.
  • 주문의 할인 금액이 변경된다.
  • 사용자에게 성공 응답이 전달된다.
  • 운영 로그와 측정 지표가 기록된다.

이 흐름은 다음과 같이 볼 수 있다.

flowchart TD
    A["사용자의 쿠폰 사용 요청"]
    --> B["요청 형식과 사용자 검증"]
    B --> C["최신 쿠폰과 주문 조회"]
    C --> D["사용 가능 여부 판단"]
    D --> E["상태 변경 트랜잭션"]
    E --> F["사용 결과 응답"]
    E --> G["사용 기록과 운영 정보"]

입력은 하나의 HTTP 요청이지만 출력은 하나의 JSON 응답으로만 끝나지 않는다.

사용자에게 전달되는 응답은 외부 출력이다. 데이터베이스의 변경과 사용 기록은 서비스 상태에 남는 출력이다. 로그와 지표는 운영자가 시스템을 관찰하기 위한 출력이다.

따라서 상태를 변경하는 알고리즘에서는 반환값뿐 아니라 처리 후 시스템에 무엇이 남는가를 함께 정의해야 한다.

성공 응답보다 상태 변경의 정확성이 먼저다

쿠폰의 남은 횟수를 읽고 차감하는 코드를 생각해보자.

const coupon = await couponRepository.findById(couponId);

if (coupon.remainingUses > 0) {
  await couponRepository.updateRemainingUses(
    couponId,
    coupon.remainingUses - 1
  );

  return { ok: true };
}

하나의 요청만 들어오면 자연스럽게 동작한다.

하지만 남은 횟수가 1회일 때 두 요청이 동시에 실행되면 두 요청 모두 remainingUses를 1로 읽을 수 있다. 두 요청이 모두 성공 응답을 반환하면 하나의 쿠폰이 두 번 사용된다.

출력은 두 번 모두 { ok: true }지만 서비스 상태는 규칙을 위반한다.

판단과 변경을 하나의 조건부 작업으로 처리해야 한다.

UPDATE coupons
SET remaining_uses = remaining_uses - 1
WHERE id = $1
  AND recipient_id = $2
  AND status = 'ACTIVE'
  AND remaining_uses > 0
  AND expires_at > $3
RETURNING id, remaining_uses;

조건을 만족하는 행이 반환되면 쿠폰 사용이 성공한 것이다. 아무 행도 반환되지 않으면 처리 순간에는 사용할 수 없는 쿠폰이었다.

const updatedCoupon =
  await couponRepository.redeemIfAvailable(command);

if (!updatedCoupon) {
  return {
    ok: false,
    reason: "COUPON_NOT_REDEEMABLE",
  };
}

return {
  ok: true,
  couponId: updatedCoupon.id,
  remainingUses: updatedCoupon.remainingUses,
};

이제 출력은 실제 상태 변경 결과를 기준으로 만들어진다.

먼저 성공 응답을 결정하고 나중에 데이터를 맞추는 것이 아니다. 데이터가 서비스 규칙에 따라 변경되었는지 확인한 뒤 그 결과를 출력한다.

실제 서비스에서 출력의 정확성은 반환 객체의 모양뿐 아니라 그 출력이 시스템의 최종 상태와 일치하는지에 달려 있다.

같은 요청의 반복도 입력의 일부로 봐야 한다

네트워크가 불안정하면 크리스가 쿠폰 사용 버튼을 한 번 눌렀더라도 같은 요청이 서버에 두 번 도착할 수 있다.

첫 번째 요청은 성공했지만 응답이 전달되지 않았을 수 있다. 프런트엔드는 실패로 판단하고 같은 요청을 다시 보낸다.

두 요청의 본문이 같다는 사실만으로 같은 작업인지 판단하기는 어렵다. 사용자가 실제로 쿠폰을 두 번 사용하려는 상황과 구분해야 하기 때문이다.

따라서 요청마다 작업을 식별하는 값을 입력에 포함할 수 있다.

const command = {
  couponId,
  requesterId: session.user.id,
  requestId: request.headers["idempotency-key"],
  requestedAt: clock.now(),
};

서버는 이미 처리한 requestId인지 확인한다.

const previousResult =
  await redemptionRepository.findByRequestId(
    command.requestId
  );

if (previousResult) {
  return previousResult;
}

같은 작업이 다시 전달되었다면 쿠폰을 또 차감하지 않고 이전 처리 결과를 반환한다.

여기에서 requestId는 단순한 문자열이 아니다. 여러 HTTP 요청이 현실에서는 하나의 사용자 행동일 수 있다는 사실을 표현하는 입력이다.

입력 설계는 값의 타입을 정하는 것에서 끝나지 않는다. 시간, 재시도와 중복 요청 속에서 어떤 요청을 같은 작업으로 볼지도 결정해야 한다.

서비스 로직의 출력과 HTTP 응답을 구분하면 책임이 선명해진다

쿠폰 사용 로직이 직접 HTTP 상태 코드와 메시지를 반환하게 만들 수도 있다.

function redeemCoupon(coupon) {
  if (coupon.status !== "ACTIVE") {
    return {
      status: 400,
      message: "쿠폰을 사용할 수 없다.",
    };
  }
}

이 구조에서는 쿠폰 규칙과 HTTP 표현 방식이 하나의 함수에 섞인다.

나중에 같은 로직을 관리자 도구나 예약 작업에서 사용하려면 HTTP 응답 형식이 불필요하게 따라온다.

서비스 로직은 도메인의 결과를 반환할 수 있다.

return {
  ok: false,
  reason: "COUPON_NOT_ACTIVE",
};

API 계층은 그 결과를 HTTP 응답으로 바꾼다.

if (result.reason === "COUPON_NOT_ACTIVE") {
  return response.status(409).json({
    code: result.reason,
    message: "현재 사용할 수 없는 쿠폰이다.",
  });
}

같은 결과를 관리자 도구에서는 다른 문장으로 표시하고, 내부 작업에서는 재시도 없이 기록할 수 있다.

책임입력출력
API 계층HTTP 요청검증된 명령
서비스 로직명령과 최신 상태성공 또는 실패를 나타내는 도메인 결과
저장소조회·변경 조건실제로 읽거나 변경된 데이터
API 응답 변환도메인 결과상태 코드와 응답 본문
UIAPI 응답사용자 메시지와 화면 상태

입력과 출력의 경계를 구분하면 각 부분이 어떤 값을 믿을 수 있으며 다음 단계에 무엇을 보장해야 하는지 선명해진다.

출력은 많을수록 친절한 것이 아니다

쿠폰 사용에 성공한 뒤 데이터베이스 객체 전체를 반환할 수도 있다.

return {
  coupon,
  user,
  redemption,
  order,
};

다음 단계에서 무엇이 필요할지 몰라 모든 데이터를 보내는 방식이다.

하지만 이 출력에는 문제가 있다.

  • 사용자 이메일이나 내부 관리 정보가 노출될 수 있다.
  • 응답 크기가 불필요하게 커진다.
  • 데이터베이스 구조가 바뀌면 API 응답도 함께 바뀐다.
  • 프런트엔드가 내부 필드에 의존하기 시작한다.
  • 어떤 값이 실제 계약인지 알기 어려워진다.

사용자 화면에 쿠폰 적용 결과만 필요하다면 출력도 그 목적에 맞게 제한할 수 있다.

return {
  couponId: redemption.couponId,
  discountAmount: redemption.discountAmount,
  payableAmount: order.payableAmount,
  remainingUses: updatedCoupon.remainingUses,
};

이 출력은 화면이 다음 상태를 그리는 데 필요한 값만 전달한다.

좋은 출력은 가능한 많은 정보를 담은 객체가 아니다. 수신자가 다음 작업을 수행하는 데 필요한 정보를 명시적으로 보장하는 계약이다.

입력과 출력을 정의할 때 물어봐야 할 질문

서비스 기능을 구현하기 전에 다음 질문으로 입력과 출력의 경계를 점검할 수 있다.

  1. 사용자가 실제로 요청하는 행동은 무엇인가?
  2. 그 행동을 표현하는 데 반드시 필요한 입력은 무엇인가?
  3. 각 입력은 URL, 본문, 헤더, 세션과 데이터베이스 중 어디에서 오는가?
  4. 클라이언트가 결정해도 되는 값과 서버가 결정해야 하는 값은 무엇인가?
  5. 외부 입력에 필요한 형식 검증은 무엇인가?
  6. 입력한 사용자가 해당 데이터에 접근할 권한이 있는가?
  7. 화면에 있는 값이 처리 시점에도 최신이라고 가정하고 있지는 않은가?
  8. 최종 판단을 위한 Source of Truth는 어디에 있는가?
  9. 원본으로 저장할 값과 필요할 때 계산할 값은 무엇인가?
  10. 현재 시간은 어떤 시스템을 기준으로 하는가?
  11. 같은 요청이 반복되면 같은 작업으로 처리해야 하는가?
  12. 동시 요청이 들어와도 성공 출력과 실제 상태가 일치하는가?
  13. 성공 결과에 다음 단계가 반드시 알아야 할 정보는 무엇인가?
  14. 실패 이유를 구분해야 하는가?
  15. 외부에 공개하면 안 되는 실패 정보나 데이터가 있는가?
  16. 함수의 반환값 외에 데이터베이스, 로그와 이벤트에 남는 결과는 무엇인가?
  17. 중간 작업이 실패하면 어떤 상태와 출력을 남겨야 하는가?
  18. 내부 데이터 구조를 그대로 출력하고 있지는 않은가?
  19. 출력 크기는 실제 사용 목적에 비해 적절한가?
  20. 입력과 출력의 의미를 코드 없이도 설명할 수 있는가?

이 질문은 단순히 API 요청과 응답 형식을 정하기 위한 목록이 아니다.

현실에서 일어난 사용자 행동이 어떤 데이터로 서비스에 들어오며, 처리 이후 어떤 약속 가능한 결과로 바뀌는지 결정하기 위한 설계 질문이다.

입력과 출력은 알고리즘의 바깥이 아니라 일부다

코딩 문제에서는 입력과 출력이 먼저 정해지고 개발자는 그 사이의 처리 방법에 집중한다.

실제 서비스에서는 순서가 다르다.

개발자는 먼저 사용자의 행동과 서비스 규칙을 살펴본 뒤 다음 내용을 결정해야 한다.

  1. 어떤 값을 입력으로 받을 것인가.
  2. 각 값은 어디에서 가져올 것인가.
  3. 어느 값까지 신뢰할 수 있는가.
  4. 어떤 검증을 거쳐야 하는가.
  5. 최신 상태는 어디에서 확인할 것인가.
  6. 성공과 실패를 어떤 결과로 표현할 것인가.
  7. 처리 후 서비스에 어떤 상태가 남아야 하는가.
  8. 그 결과를 누구에게 어디까지 공개할 것인가.

입력을 잘못 정의하면 알고리즘은 잘못된 데이터를 정확하게 처리한다. 출력을 모호하게 정의하면 내부 처리가 올바르더라도 다음 단계가 적절하게 행동할 수 없다.

실제 서비스에서 입력은 외부에서 받은 값을 그대로 함수에 넣는 일이 아니라, 출처와 권한과 최신성을 검증하여 처리 가능한 명령을 만드는 일이다. 출력은 값을 반환하는 일이 아니라, 처리 결과와 상태 변화를 다음 책임자가 안전하게 사용할 수 있는 계약으로 표현하는 일이다.

입력과 출력은 알고리즘 문제의 앞뒤에 붙은 형식이 아니다.

무엇을 믿고 판단할 것인지, 어떤 상태를 만들 것인지, 그 결과를 어떻게 전달할 것인지를 결정하는 알고리즘 자체의 일부다.

profile
Vision eXperience Developer

0개의 댓글