알고리즘을 처음 배울 때 입력은 함수에 전달되는 값이고, 출력은 함수가 반환하는 값이라고 배운다.
function calculateDiscount(price, discountRate) {
return price * discountRate;
}
이 함수의 입력은 상품 가격과 할인율이고, 출력은 할인 금액이다.
알고리즘의 동작을 이해하기에는 충분한 설명이다. 입력이 주어지면 정해진 절차로 처리한 뒤 결과를 반환한다는 구조를 분명하게 보여준다.
하지만 실제 서비스를 개발하기 시작하면 입력과 출력은 이렇게 정리된 상태로 주어지지 않는다.
쿠폰 서비스에서 크리스가 쿠폰을 사용한다고 생각해보자.
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로 바꿔 보낼 수도 있다.
형식상 입력값이라는 이유만으로 그 값을 판단의 근거로 사용해서는 안 된다.
입력마다 신뢰할 수 있는 출처가 다르다.
| 필요한 정보 | 적절한 출처 | 이유 |
|---|---|---|
| 쿠폰 ID | URL 또는 요청 본문 | 사용자가 선택한 대상을 표현한다 |
| 요청자 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 상태 코드와 메시지를 반환하게 만들 수도 있다.
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 응답 변환 | 도메인 결과 | 상태 코드와 응답 본문 |
| UI | API 응답 | 사용자 메시지와 화면 상태 |
입력과 출력의 경계를 구분하면 각 부분이 어떤 값을 믿을 수 있으며 다음 단계에 무엇을 보장해야 하는지 선명해진다.
쿠폰 사용에 성공한 뒤 데이터베이스 객체 전체를 반환할 수도 있다.
return {
coupon,
user,
redemption,
order,
};
다음 단계에서 무엇이 필요할지 몰라 모든 데이터를 보내는 방식이다.
하지만 이 출력에는 문제가 있다.
사용자 화면에 쿠폰 적용 결과만 필요하다면 출력도 그 목적에 맞게 제한할 수 있다.
return {
couponId: redemption.couponId,
discountAmount: redemption.discountAmount,
payableAmount: order.payableAmount,
remainingUses: updatedCoupon.remainingUses,
};
이 출력은 화면이 다음 상태를 그리는 데 필요한 값만 전달한다.
좋은 출력은 가능한 많은 정보를 담은 객체가 아니다. 수신자가 다음 작업을 수행하는 데 필요한 정보를 명시적으로 보장하는 계약이다.
서비스 기능을 구현하기 전에 다음 질문으로 입력과 출력의 경계를 점검할 수 있다.
이 질문은 단순히 API 요청과 응답 형식을 정하기 위한 목록이 아니다.
현실에서 일어난 사용자 행동이 어떤 데이터로 서비스에 들어오며, 처리 이후 어떤 약속 가능한 결과로 바뀌는지 결정하기 위한 설계 질문이다.
코딩 문제에서는 입력과 출력이 먼저 정해지고 개발자는 그 사이의 처리 방법에 집중한다.
실제 서비스에서는 순서가 다르다.
개발자는 먼저 사용자의 행동과 서비스 규칙을 살펴본 뒤 다음 내용을 결정해야 한다.
입력을 잘못 정의하면 알고리즘은 잘못된 데이터를 정확하게 처리한다. 출력을 모호하게 정의하면 내부 처리가 올바르더라도 다음 단계가 적절하게 행동할 수 없다.
실제 서비스에서 입력은 외부에서 받은 값을 그대로 함수에 넣는 일이 아니라, 출처와 권한과 최신성을 검증하여 처리 가능한 명령을 만드는 일이다. 출력은 값을 반환하는 일이 아니라, 처리 결과와 상태 변화를 다음 책임자가 안전하게 사용할 수 있는 계약으로 표현하는 일이다.
입력과 출력은 알고리즘 문제의 앞뒤에 붙은 형식이 아니다.
무엇을 믿고 판단할 것인지, 어떤 상태를 만들 것인지, 그 결과를 어떻게 전달할 것인지를 결정하는 알고리즘 자체의 일부다.