
프로그래밍을 처음 배울 때 입력값은 보통 사용자가 키보드로 입력한 값이라고 배운다.
const userName = prompt(
"이름을 입력하세요."
);
console.log(userName);
사용자가 문자열을 입력하면 프로그램이 그 값을 받아 처리한다.
입력과 출력이라는 기본 개념을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 입력값을 텍스트 필드의 문자열로만 생각해서는 부족하다.
서비스 외부에서 내부로 들어오는 모든 데이터가 입력값이 될 수 있기 때문이다.
이 값들은 모두 코드가 실행되기 전에 서비스 바깥에 존재했다.
따라서 실제 서비스에서 중요한 질문은 “어떻게 입력을 받는가?”에 그치지 않는다.
실제 서비스에서 입력값은 사용자가 입력한 문자열이 아니라, 시스템의 신뢰 경계를 넘어 들어와 해석·검증·권한 확인이 필요한 모든 데이터다.
쿠폰 생성 화면에 다음과 같은 폼이 있다고 생각해 보자.
<input name="title" />
<input name="remainingUses" />
<button type="submit">
쿠폰 만들기
</button>
사용자가 입력한 제목과 사용 횟수는 명확한 입력값이다.
그러나 쿠폰 생성 요청 전체를 살펴보면 더 많은 입력이 존재한다.
POST /coupons?notify=true
Authorization: Bearer eyJ...
Content-Type: application/json
{
"title": "Coffee Date",
"recipientId": "user_123",
"remainingUses": "3"
}
서버가 받아들이는 입력은 요청 본문만이 아니다.
| 입력 위치 | 예시 | 주요 판단 |
|---|---|---|
| URL 경로 | /coupons/123 | ID 형식과 접근 권한 |
| 쿼리 문자열 | notify=true | 타입 변환과 허용 옵션 |
| HTTP 헤더 | 인증 토큰 | 인증과 위조 가능성 |
| 요청 본문 | 쿠폰 정보 | 구조와 비즈니스 규칙 |
| 파일 | 쿠폰 이미지 | 크기, 형식, 실제 내용 |
| 네트워크 정보 | IP 주소 | 프록시 환경과 신뢰 범위 |
인증된 사용자를 나타내는 값도 처음에는 요청 헤더에서 들어온 외부 데이터다.
const token =
request.headers.authorization;
토큰을 단순히 읽었다고 사용자의 신원이 확인된 것은 아니다.
서명, 만료 시각, 발급자, 대상 서비스 등을 확인한 뒤에야 인증 정보로 사용할 수 있다.
const currentUser =
await authenticate(token);
같은 데이터라도 처리 단계에 따라 의미와 신뢰 수준이 달라진다.
TypeScript에서는 요청 본문에 타입을 지정할 수 있다.
interface CreateCouponRequest {
title: string;
recipientId: string;
remainingUses: number;
}
다음과 같이 요청을 사용하고 싶을 수 있다.
const input =
request.body as CreateCouponRequest;
하지만 as는 실제 데이터를 검사하지 않는다.
클라이언트는 다음 요청을 보낼 수도 있다.
{
"title": ["Coffee Date"],
"recipientId": null,
"remainingUses": "three"
}
TypeScript 타입은 개발 중 코드가 기대하는 형태를 설명한다.
네트워크를 통해 도착한 데이터가 실제로 그 형태인지 보장하지는 않는다.
따라서 신뢰 경계에서 들어오는 값은 먼저 unknown으로 보는 편이 현실적이다.
const requestBody: unknown =
request.body;
그다음 검사와 변환을 통과한 값만 서비스 입력으로 만든다.
const input =
parseCreateCouponRequest(
requestBody
);
이 코드는 단순한 타입 변환이 아니다.
“외부에서 도착한 값을 아직 신뢰하지 않는다”는 설계 판단을 표현한다.
쿠폰 생성 요청은 한 번에 비즈니스 로직으로 들어가지 않는다.
flowchart LR
A[외부 입력]
--> B[크기와 형식 제한]
--> C[구조 해석]
--> D[값 정규화]
--> E[인증과 권한 확인]
--> F[비즈니스 규칙 적용]
--> G[데이터베이스 저장]
각 단계는 서로 다른 질문에 답한다.
if (
typeof body !== "object" ||
body === null
) {
throw new InvalidRequestError();
}
요청 본문이 객체로 처리할 수 있는 값인지 확인한다.
const title =
body.title.trim();
const remainingUses =
Number(body.remainingUses);
외부 표현을 서비스가 일관되게 사용하는 형태로 바꾼다.
const issuerId =
request.currentUser.id;
쿠폰 발행자를 요청 본문에서 믿지 않고, 검증된 인증 정보에서 가져온다.
if (
remainingUses < 1 ||
remainingUses > 10
) {
throw new CouponPolicyError(
"쿠폰은 1회에서 10회까지 사용할 수 있다."
);
}
숫자로 변환할 수 있다는 사실과 서비스에서 허용되는 숫자라는 사실을 구분한다.
입력을 안전하게 다룬다는 것은 하나의 if문을 추가하는 일이 아니다.
외부 표현이 내부에서 신뢰할 수 있는 서비스 데이터로 바뀌는 경로를 설계하는 일이다.
쿠폰 생성 요청에 issuerId가 포함되어 있다고 생각해 보자.
{
"title": "Coffee Date",
"issuerId": "admin_001",
"recipientId": "user_123",
"remainingUses": 3
}
서버가 이 값을 그대로 사용하면 사용자가 다른 사람의 ID로 쿠폰을 발행할 수 있다.
const coupon = {
title: request.body.title,
issuerId:
request.body.issuerId,
recipientId:
request.body.recipientId,
};
문자열 형식이 올바르고 실제로 존재하는 사용자 ID여도 안전하지 않다.
문제는 값의 형태가 아니라 그 값을 결정할 권한이다.
발행자 ID는 검증된 로그인 정보에서 가져와야 한다.
const coupon = {
title: input.title,
issuerId:
request.currentUser.id,
recipientId:
input.recipientId,
};
쿠폰 생성 시각도 클라이언트가 결정하게 해서는 안 된다.
const createdAt = new Date();
할인 금액 역시 서버가 상품 가격과 쿠폰 정책을 이용해 계산해야 한다.
const discountAmount =
calculateDiscount(
product.price,
coupon.policy
);
입력값을 설계할 때는 각 필드의 권한을 구분해야 한다.
| 값 | 적절한 출처 |
|---|---|
| 쿠폰 제목 | 사용자 요청 |
| 수신자 ID | 사용자 요청 후 권한·존재 확인 |
| 발행자 ID | 인증된 사용자 정보 |
| 생성 시각 | 서버 시계 |
| 초기 상태 | 비즈니스 규칙 |
| 상품 가격 | 데이터베이스 |
| 할인 금액 | 서버 계산 |
| 관리자 권한 | 인증·권한 시스템 |
외부 입력을 신뢰할 수 없다는 말은 모든 값이 거짓이라는 뜻이 아니다. 누가 그 값을 결정할 권한이 있는지 확인하기 전에는 서비스의 사실로 받아들일 수 없다는 뜻이다.
쿠폰을 적용하는 주문 요청이 다음과 같다고 생각해 보자.
{
"productId": "product_123",
"couponId": "coupon_456",
"originalPrice": 100,
"discountAmount": 80,
"finalPrice": 20
}
서버가 이 값을 그대로 저장하면 클라이언트가 결제 금액을 결정하게 된다.
await orderRepository.save({
productId: input.productId,
finalPrice: input.finalPrice,
});
화면에 표시된 값이 정상적으로 계산되었더라도 요청은 브라우저 개발자 도구나 별도의 프로그램으로 수정할 수 있다.
서버는 사용자가 선택한 대상만 입력으로 받고, 권한 있는 원본 데이터에서 결과를 다시 계산해야 한다.
const product =
await productRepository.findById(
input.productId
);
const coupon =
await couponRepository.findById(
input.couponId
);
const discountAmount =
calculateDiscount(
product.price,
coupon
);
const finalPrice =
product.price - discountAmount;
여기서 원본 데이터는 데이터베이스에 저장된 상품 가격과 쿠폰 정책이다.
discountAmount와 finalPrice는 그 원본으로부터 계산되는 값이다.
클라이언트가 계산된 값을 보내더라도 화면 확인이나 비교 목적으로만 사용할 수 있다.
서비스의 최종 판단을 맡겨서는 안 된다.
객체 전체를 데이터베이스 업데이트 함수에 전달하는 코드는 간단해 보인다.
await database.user.update({
where: {
id: currentUser.id,
},
data: request.body,
});
하지만 요청 본문에 예상하지 않은 필드가 포함될 수 있다.
{
"displayName": "Chris",
"role": "admin",
"emailVerified": true
}
사용자가 변경할 수 있어야 하는 값은 displayName뿐인데 객체 전체를 전달하면 내부 관리 필드도 수정될 수 있다.
허용된 값만 명시적으로 선택해야 한다.
const profileUpdate = {
displayName:
parseDisplayName(
request.body.displayName
),
};
await database.user.update({
where: {
id: currentUser.id,
},
data: profileUpdate,
});
이 방식은 코드가 조금 더 길다.
대신 외부 요청이 서비스 내부 모델의 모든 속성을 수정할 수 있는 통로가 되는 것을 막는다.
입력 객체와 데이터베이스 객체의 모양이 비슷하더라도 두 객체의 책임은 다르다.
이들을 하나의 타입으로 공유하면 외부 입력 범위와 내부 상태 범위가 섞일 수 있다.
데이터베이스는 서비스 내부에 있으므로 모든 값을 신뢰할 수 있다고 생각하기 쉽다.
그러나 저장된 데이터도 현재 코드가 기대하는 조건과 다를 수 있다.
const coupon =
await database.coupon.findUnique({
where: {
id: couponId,
},
});
조회에 성공했다는 사실은 쿠폰이 현재 비즈니스 규칙을 만족한다는 뜻이 아니다.
예를 들어 과거에는 remainingUses가 null일 수 있었지만 현재 코드는 숫자를 기대할 수 있다.
저장 경계에서 서비스 모델로 복원할 때 오래된 표현을 처리할 수 있다.
function toCoupon(
row: CouponRow
): Coupon {
return {
id: row.id,
title: row.title,
remainingUses:
row.remainingUses ?? 0,
};
}
이 변환이 항상 기본값으로 오류를 숨겨야 한다는 뜻은 아니다.
복구할 수 없는 데이터라면 명확한 오류로 분류하고 운영자가 발견할 수 있도록 기록해야 한다.
핵심은 데이터베이스라는 위치만으로 신뢰 수준을 결정하지 않는 것이다.
데이터를 작성한 주체, 적용된 규칙의 버전, 변경 경로까지 함께 고려해야 한다.
결제 API나 회원 API가 반환한 값은 개발자가 직접 작성한 화면 입력보다 믿을 만해 보일 수 있다.
const recipient =
await userApi.getUser(
input.recipientId
);
하지만 외부 서비스의 응답도 현재 서비스의 신뢰 경계를 넘어 들어온다.
따라서 외부 API 응답을 내부 모델로 바로 단정해서는 안 된다.
const response: unknown =
await userApi.getUser(
input.recipientId
);
const recipient =
parseRecipient(response);
parseRecipient는 외부 서비스의 표현을 현재 서비스가 사용하는 Recipient 개념으로 바꾼다.
interface Recipient {
id: string;
notificationAddress:
string | null;
}
외부 서비스가 반환한 수십 개의 필드를 그대로 전달할 필요도 없다.
현재 책임에 필요한 값만 경계를 통과시키면 외부 API의 변경이 서비스 전체로 퍼지는 범위를 줄일 수 있다.
쿠폰에 이미지를 첨부할 수 있다고 생각해 보자.
const image =
request.files.couponImage;
파일 이름이 coupon.png라고 해서 실제 PNG 이미지라는 보장은 없다.
파일 입력에는 문자열과 다른 판단이 필요하다.
원본 파일 이름을 서버 경로로 직접 사용해서는 안 된다.
const storageKey =
crypto.randomUUID();
서비스가 생성한 식별자를 저장 키로 사용하고, 원래 이름은 필요할 때 별도의 메타데이터로 보관한다.
또한 파일 검사는 업로드 후 부가적으로 실행하는 장식이 아니다.
저장 비용, 이미지 처리 비용, 악성 콘텐츠, 경로 조작 같은 문제를 막는 입력 경계의 일부다.
문자열인지 확인하는 것만으로는 충분하지 않을 수 있다.
if (
typeof input.title !== "string"
) {
throw new InvalidTitleError();
}
수백 메가바이트의 문자열도 여전히 문자열이다.
배열에 들어 있는 모든 항목이 올바르더라도 항목이 수백만 개라면 서버 자원을 과도하게 사용할 수 있다.
if (
input.title.length > 50
) {
throw new InvalidTitleError();
}
if (
input.recipientIds.length > 100
) {
throw new TooManyRecipientsError();
}
입력 제한은 화면 디자인을 위한 규칙만이 아니다.
메모리 사용량, 데이터베이스 쿼리 수, 외부 API 호출 수, 처리 시간을 통제하는 운영 규칙이기도 하다.
대량 입력이 실제 요구사항이라면 제한을 없애는 대신 처리 방식을 바꿔야 한다.
결제 서비스가 다음과 같은 웹훅을 보낸다고 생각해 보자.
{
"event": "payment.completed",
"orderId": "order_123"
}
본문만 확인하고 주문을 결제 완료로 변경하면 공격자가 같은 형태의 요청을 직접 보낼 수 있다.
await orderRepository.markPaid(
request.body.orderId
);
웹훅은 외부 시스템에서 온 입력이다.
처리 전에 발신자를 확인해야 한다.
verifyWebhookSignature({
body: request.rawBody,
signature:
request.headers[
"x-webhook-signature"
],
});
서명을 확인한 뒤에도 고려할 문제가 남는다.
같은 이벤트가 네트워크 재시도로 여러 번 도착할 수 있다.
if (
await eventRepository.exists(
event.id
)
) {
return;
}
웹훅 입력에서는 다음 내용을 함께 설계해야 한다.
입력값은 값의 형식뿐 아니라 전달 방식과 재전송 가능성까지 포함한다.
입력 오류를 설명하기 위해 받은 값을 그대로 반환할 수 있다.
throw new Error(
`잘못된 토큰: ${token}`
);
하지만 인증 토큰, 비밀번호, 결제 정보 같은 값이 오류 메시지나 로그에 남을 수 있다.
사용자에게는 수정에 필요한 정보만 제공해야 한다.
throw new AuthenticationError(
"인증 정보가 올바르지 않다."
);
운영 로그에도 전체 입력을 무조건 기록해서는 안 된다.
logger.warn(
"coupon_request_rejected",
{
userId:
request.currentUser?.id,
reason:
"INVALID_REMAINING_USES",
}
);
이 로그는 문제를 추적할 정보를 남기지만 요청 본문 전체를 복사하지 않는다.
입력 데이터를 기록할 때는 다음을 확인해야 한다.
입력을 안전하게 처리하는 책임은 값을 사용하는 순간에 끝나지 않는다.
오류, 로그, 분석 시스템으로 다시 전달되는 과정까지 이어진다.
unknown에서 시작하는가?const input =
request.body as CreateCouponRequest;
이 코드는 런타임 데이터를 검사하지 않는다.
const input =
parseCreateCouponRequest(
request.body
);
신뢰 경계에서 실제 값의 구조를 확인해야 한다.
const issuerId =
request.body.issuerId;
다른 사용자의 ID를 보낼 수 있다.
const issuerId =
request.currentUser.id;
발행자 정보는 검증된 인증 결과에서 가져온다.
await database.user.update({
data: request.body,
});
사용자가 수정하면 안 되는 내부 필드까지 입력이 될 수 있다.
await database.user.update({
data: {
displayName:
parsed.displayName,
},
});
허용된 필드를 명시적으로 선택한다.
const finalPrice =
request.body.finalPrice;
클라이언트가 값을 변경할 수 있다.
const finalPrice =
calculateFinalPrice(
product,
coupon
);
권한 있는 원본 데이터에서 서버가 다시 계산한다.
if (
file.name.endsWith(".png")
) {
save(file);
}
파일 이름은 실제 내용을 보장하지 않는다.
크기, 미디어 형식, 실제 콘텐츠, 저장 경로를 함께 검사해야 한다.
logger.error({
body: request.body,
headers: request.headers,
});
토큰과 개인 정보가 로그에 저장될 수 있다.
logger.error({
requestId,
userId,
errorCode,
});
문제 추적에 필요한 최소 정보만 기록한다.
const coupon =
row as Coupon;
저장된 데이터가 이전 규칙이나 잘못된 수정의 영향을 받았을 수 있다.
const coupon =
restoreCoupon(row);
저장 표현을 현재 서비스 모델로 복원하는 경계를 둔다.
입력값은 프로그램 밖에서 안으로 들어오는 값이다.
처음 배울 때는 주로 키보드나 입력창에서 받은 문자열로 설명한다.
const couponTitle =
prompt("쿠폰 제목");
그러나 실제 서비스에는 훨씬 다양한 입력 경계가 존재한다.
입력값을 안전하게 처리하려면 단순히 값을 읽는 것보다 그 값이 서비스의 사실이 되는 과정을 설계해야 한다.
flowchart LR
A[외부 데이터]
--> B[형식과 크기 확인]
--> C[해석과 정규화]
--> D[인증과 권한 확인]
--> E[비즈니스 규칙]
--> F[신뢰 가능한 서비스 데이터]
좋은 입력 경계는 다음 내용을 분명하게 만든다.
외부 입력이 올바른 타입이라고 해서 신뢰할 수 있는 것은 아니다.
존재하는 사용자 ID라고 해서 요청자가 그 ID를 사용할 권한이 있는 것도 아니다.
클라이언트가 계산한 금액이 수학적으로 맞다고 해서 서비스의 결제 금액으로 인정해야 하는 것도 아니다.
입력값을 설계한다는 것은 결국 다음 질문에 답하는 일이다.
이 값은 어디에서 왔으며, 어떤 과정을 통과해야 서비스가 책임질 수 있는 사실이 되는가?
입력값은 사용자가 입력한 문자열이 아니다.
시스템의 신뢰 경계를 넘어 들어오는 데이터를 그 출처와 권한에 따라 해석하고, 서비스가 안전하게 사용할 수 있는 형태로 바꾸는 출발점이다.