
프로그래밍을 처음 배울 때 검증은 보통 사용자가 값을 입력했는지 확인하는 작업으로 설명한다.
if (couponTitle === "") {
alert("쿠폰 제목을 입력하세요.");
}
값이 비어 있으면 오류를 보여주고, 값이 있으면 다음 단계로 진행한다.
입력 검증의 기본 개념을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 빈칸을 확인하는 것만으로는 데이터를 안전하게 처리할 수 없다.
쿠폰 생성 기능만 보더라도 다음과 같은 질문이 생긴다.
이 질문들은 모두 검증과 관련되어 있지만 같은 종류의 검증은 아니다.
어떤 검증은 데이터 형식을 확인한다.
어떤 검증은 서비스 정책을 확인한다.
어떤 검증은 사용자의 권한을 확인한다.
어떤 검증은 현재 데이터베이스 상태를 확인하며, 어떤 검증은 동시에 실행되는 요청 사이에서도 규칙이 지켜지도록 해야 한다.
실제 서비스에서 검증은 단순히 빈칸이나 형식을 확인하는 일이 아니다. 신뢰할 수 없는 데이터를 서비스가 책임질 수 있는 상태로 바꾸고, 데이터·비즈니스·보안 규칙이 모든 변경 경로에서 유지되도록 만드는 과정이다.
다음 코드는 쿠폰 제목과 사용 횟수가 입력되었는지 확인한다.
if (
!request.body.title ||
!request.body.remainingUses
) {
throw new Error(
"필수 값을 입력해야 한다."
);
}
간단한 요청에서는 동작할 수 있다.
그러나 다음 입력들은 어떻게 처리될까?
{
"title": " ",
"remainingUses": "3"
}
{
"title": ["Coffee Date"],
"remainingUses": -10
}
{
"title": "Coffee Date",
"remainingUses": 2.5
}
모든 필드가 존재해도 서비스에서 사용할 수 없는 값일 수 있다.
쿠폰 사용 횟수에는 여러 조건이 필요하다.
const remainingUses =
Number(request.body.remainingUses);
if (
!Number.isInteger(remainingUses) ||
remainingUses < 1 ||
remainingUses > 10
) {
throw new Error(
"사용 횟수는 1부터 10까지의 정수여야 한다."
);
}
이 코드는 값이 존재하는지만 확인하지 않는다.
쿠폰 서비스가 허용하는 범위를 확인한다.
검증은 “값이 있는가?”에서 시작할 수 있지만 다음 질문으로 확장되어야 한다.
이 값이 현재 작업에서 사용해도 되는 의미 있는 값인가?
쿠폰 만료일로 다음 문자열이 들어왔다고 생각해 보자.
{
"expiresAt": "2026-12-31T00:00:00Z"
}
먼저 날짜로 해석할 수 있는지 확인해야 한다.
const expiresAt =
new Date(input.expiresAt);
if (
Number.isNaN(expiresAt.getTime())
) {
throw new Error(
"만료일 형식이 올바르지 않다."
);
}
이 검증은 데이터 형식에 답한다.
이 값을 날짜로 해석할 수 있는가?
날짜 형식이 올바르다고 해서 유효한 쿠폰 만료일이 되는 것은 아니다.
if (expiresAt <= now) {
throw new Error(
"만료일은 현재보다 미래여야 한다."
);
}
이 검증은 비즈니스 규칙에 답한다.
서비스가 이 날짜를 쿠폰 만료일로 허용하는가?
두 검증을 구분하면 오류의 의미와 책임도 명확해진다.
| 검증 | 질문 | 예시 |
|---|---|---|
| 형식 검증 | 값으로 해석할 수 있는가? | 날짜 문자열인가? |
| 범위 검증 | 허용된 범위인가? | 최대 1년 이내인가? |
| 관계 검증 | 다른 값과 모순되지 않는가? | 시작일보다 늦은가? |
| 비즈니스 검증 | 서비스 정책에 맞는가? | 회원 등급이 허용하는 기간인가? |
문법적으로 올바른 데이터와 서비스에서 허용되는 데이터는 같은 의미가 아니다.
사용자가 쿠폰 제목에 앞뒤 공백을 포함해 입력할 수 있다.
{
"title": " Coffee Date "
}
공백을 제거하기 전에 길이를 확인하면 실제 저장되는 값과 다른 기준으로 검증할 수 있다.
if (input.title.length > 20) {
throw new InvalidTitleError();
}
const title =
input.title.trim();
서비스가 저장하고 비교할 형태를 먼저 만든 뒤 그 값을 검증하는 편이 자연스러울 수 있다.
const title =
input.title.trim();
if (
title.length < 1 ||
title.length > 20
) {
throw new InvalidTitleError();
}
이제 검증과 저장이 같은 값을 기준으로 동작한다.
그러나 모든 변환을 무조건 먼저 수행해야 한다는 뜻은 아니다.
다음 변환은 주의가 필요하다.
const remainingUses =
Number(input.remainingUses);
JavaScript에서 빈 문자열은 숫자 0으로 변환된다.
Number("");
// 0
외부 데이터의 원래 상태를 구분해야 한다면 변환 전에 확인해야 한다.
if (
typeof input.remainingUses
!== "string" ||
input.remainingUses.trim() === ""
) {
throw new InvalidUsesError();
}
const remainingUses =
Number(input.remainingUses);
검증과 정규화의 순서는 기술적으로 정해진 공식이 아니다.
어떤 정보가 변환 과정에서 사라지는지에 따라 결정해야 한다.
쿠폰 기간을 다음과 같이 입력받는다고 생각해 보자.
{
"startsAt": "2026-12-10T00:00:00Z",
"expiresAt": "2026-12-01T00:00:00Z"
}
두 값은 각각 올바른 날짜다.
하지만 시작일이 만료일보다 늦기 때문에 쿠폰 기간으로는 올바르지 않다.
if (startsAt >= expiresAt) {
throw new CouponPeriodError(
"만료일은 시작일보다 늦어야 한다."
);
}
같은 문제는 할인 정책에서도 발생한다.
{
"discountType": "percentage",
"discountValue": 20,
"maximumDiscount": -5
}
각 필드의 타입만 확인하면 객체 전체의 모순을 발견하지 못할 수 있다.
검증은 여러 수준에서 필요하다.
flowchart TD
A[개별 필드 검증]
--> B[필드 사이의 관계 검증]
--> C[객체의 불변 조건]
--> D[현재 서비스 상태와의 검증]
--> E[다른 데이터와의 검증]
개별 필드 검증은 필요한 출발점이다.
그러나 실제 서비스의 규칙은 값 하나보다 여러 값의 관계에 존재하는 경우가 많다.
쿠폰 수신자 ID가 다음과 같이 들어왔다고 생각해 보자.
const recipient =
await userRepository.findById(
input.recipientId
);
if (!recipient) {
throw new RecipientNotFoundError();
}
이 코드는 수신자가 존재하는지 확인한다.
하지만 존재하는 사용자라면 누구에게나 쿠폰을 발행할 수 있는지는 별도의 문제다.
if (recipient.status !== "active") {
throw new RecipientUnavailableError();
}
if (
recipient.organizationId !==
currentUser.organizationId
) {
throw new CouponPermissionError();
}
if (
recipient.id === currentUser.id &&
!policy.allowSelfIssue
) {
throw new CouponPolicyError(
"자신에게 쿠폰을 발행할 수 없다."
);
}
하나의 ID에는 서로 다른 검증이 연결된다.
recipientId가 문자열이라는 사실은 첫 번째 질문에만 답한다.
다음 요청은 문법적으로 완벽할 수 있다.
{
"couponId": "coupon_123",
"status": "cancelled"
}
쿠폰 ID도 문자열이고 상태도 허용된 값이다.
그러나 현재 사용자가 해당 쿠폰의 발행자가 아니라면 취소할 수 없어야 한다.
if (
coupon.issuerId !==
currentUser.id
) {
throw new ForbiddenError();
}
관리자는 다른 사용자의 쿠폰을 취소할 수 있다는 규칙이 있다면 조건은 달라질 수 있다.
const canCancel =
coupon.issuerId ===
currentUser.id ||
currentUser.role === "admin";
권한 검증은 값이 올바른지를 확인하는 작업이 아니다.
누가 어떤 데이터에 어떤 행동을 할 수 있는지 확인하는 작업이다.
따라서 다음 두 요청은 동일하게 검증될 수 없다.
GET /coupons/coupon_123
DELETE /coupons/coupon_123
같은 쿠폰 ID를 사용하더라도 조회 권한과 삭제 권한은 다를 수 있다.
검증은 데이터의 모양뿐 아니라 수행하려는 행동의 의미까지 알아야 한다.
프런트엔드에서 쿠폰 제목 길이를 확인할 수 있다.
if (title.length > 50) {
setError(
"쿠폰 제목은 50자 이하여야 한다."
);
return;
}
사용자는 서버 응답을 기다리지 않고 문제를 바로 수정할 수 있다.
좋은 사용자 경험이다.
하지만 프런트엔드 검증만으로 서비스 규칙을 보호할 수는 없다.
HTTP 요청은 브라우저 개발자 도구, 스크립트, 오래된 앱 버전 또는 다른 클라이언트에서 직접 보낼 수 있다.
서버에서도 같은 규칙을 확인해야 한다.
if (input.title.length > 50) {
throw new InvalidCouponTitleError();
}
두 검증의 목적은 다르다.
| 위치 | 주요 목적 |
|---|---|
| 프런트엔드 | 빠른 피드백과 입력 안내 |
| API 경계 | 외부 데이터의 구조와 크기 확인 |
| 도메인·사용 사례 | 비즈니스 규칙과 권한 보호 |
| 데이터베이스 | 어떤 경로에서도 깨지면 안 되는 최종 제약 |
프런트엔드와 서버에서 같은 규칙이 반복될 수 있다.
일부 스키마를 공유하면 중복을 줄일 수 있지만, 공유 자체가 서버 검증을 대체하지는 않는다.
서버는 다른 모든 클라이언트가 우회할 수 없는 검증 경계여야 한다.
모든 검증을 하나의 함수에 넣을 수 있다.
async function validateCoupon(
request: Request
): Promise<boolean> {
// 본문 형식 확인
// 사용자 인증 확인
// 권한 확인
// 수신자 조회
// 쿠폰 정책 확인
// 중복 확인
// 데이터베이스 상태 확인
return true;
}
함수 이름은 단순하지만 너무 많은 책임을 가진다.
false를 반환하면 어떤 규칙이 실패했는지 알기 어렵다.
검증의 종류에 따라 책임을 나눌 수 있다.
const input =
parseCreateCouponRequest(
request.body
);
const issuer =
requireAuthenticatedUser(
request
);
const recipient =
await requireRecipient(
input.recipientId
);
assertCanIssueCoupon({
issuer,
recipient,
policy,
});
const coupon =
createCoupon({
...input,
issuerId: issuer.id,
});
각 단계는 다른 질문에 답한다.
모든 검증을 한곳에 두는 것이 일관성을 만드는 것은 아니다.
각 규칙이 올바른 책임에 위치하고 모든 변경 경로에서 사용되는 것이 중요하다.
쿠폰 사용 요청은 매우 단순할 수 있다.
{
"couponId": "coupon_123"
}
하지만 사용 가능 여부는 요청 본문만 보고 판단할 수 없다.
const canRedeem =
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt > now
) &&
coupon.recipientId ===
currentUser.id;
같은 couponId를 보내도 결과는 시간과 쿠폰 상태에 따라 달라진다.
이런 검증을 상태 기반 검증이라고 볼 수 있다.
입력 자체가 올바른지 확인하는 것만으로는 부족하다.
현재 서비스 상태에서 그 행동이 허용되는지 확인해야 한다.
남은 사용 횟수가 1인 쿠폰을 두 요청이 동시에 사용한다고 생각해 보자.
요청 A: remainingUses가 1인지 확인
요청 B: remainingUses가 1인지 확인
요청 A: 0으로 변경
요청 B: 0으로 변경
두 요청 모두 검증을 통과할 수 있다.
if (coupon.remainingUses <= 0) {
throw new CouponExhaustedError();
}
coupon.remainingUses -= 1;
await couponRepository.save(
coupon
);
검증 시점과 저장 시점이 분리되어 있기 때문이다.
데이터베이스에서 조건 확인과 변경을 하나의 작업으로 처리할 수 있다.
const result =
await database.coupon.updateMany({
where: {
id: couponId,
status: "active",
remainingUses: {
gt: 0,
},
},
data: {
remainingUses: {
decrement: 1,
},
},
});
if (result.count === 0) {
throw new CouponUnavailableError();
}
이 코드는 “남은 횟수가 0보다 큰 쿠폰만 감소시킨다”는 조건을 변경과 함께 적용한다.
더 복잡한 작업이라면 트랜잭션, 잠금 또는 버전 검사가 필요할 수 있다.
검증 로직이 올바르더라도 실행 시점의 경쟁 조건까지 자동으로 해결되는 것은 아니다.
변경 가능한 데이터에 대한 검증은 언제 확인하는지뿐 아니라 확인한 조건과 변경을 어떻게 함께 보장하는지까지 설계해야 한다.
애플리케이션에서 쿠폰 사용 횟수를 검증할 수 있다.
if (remainingUses < 0) {
throw new InvalidUsesError();
}
하지만 데이터는 다른 코드 경로, 관리 도구, 배치 작업 또는 마이그레이션에서도 변경될 수 있다.
절대로 음수가 되어서는 안 되는 값이라면 데이터베이스에도 제약을 둘 수 있다.
ALTER TABLE coupons
ADD CONSTRAINT remaining_uses_non_negative
CHECK (remaining_uses >= 0);
중복되면 안 되는 쿠폰 코드에는 고유 제약을 둘 수 있다.
CREATE UNIQUE INDEX
unique_coupon_code
ON coupons(code);
데이터베이스 제약은 친절한 사용자 오류를 만드는 데는 부족할 수 있다.
그러나 어떤 변경 경로에서도 깨져서는 안 되는 규칙을 최종적으로 보호한다.
| 규칙 | 애플리케이션 | 데이터베이스 |
|---|---|---|
| 제목 필수 | 구체적인 오류 제공 | NOT NULL 보조 |
| 남은 횟수 음수 금지 | 비즈니스 오류 제공 | CHECK로 최종 보호 |
| 쿠폰 코드 중복 금지 | 사전 확인 가능 | UNIQUE로 경쟁 상황 보호 |
| 수신자 존재 | 의미 있는 오류 제공 | 외래 키로 참조 보호 |
둘 중 하나만 선택해야 하는 것은 아니다.
같은 규칙을 서로 다른 목적의 방어선으로 표현할 수 있다.
다음 함수는 쿠폰이 유효한지 알려준다.
function isValidCoupon(
coupon: Coupon
): boolean {
// ...
}
단순한 조건에서는 충분할 수 있다.
하지만 쿠폰을 사용할 수 없는 이유가 사용자 행동을 바꿔야 한다면 Boolean만으로는 부족하다.
type RedemptionFailure =
| "NOT_RECIPIENT"
| "EXPIRED"
| "PAUSED"
| "NO_REMAINING_USES";
type RedemptionValidation =
| {
valid: true;
}
| {
valid: false;
reason:
RedemptionFailure;
};
function validateRedemption(
coupon: Coupon,
context: RedemptionContext
): RedemptionValidation {
if (
coupon.recipientId !==
context.userId
) {
return {
valid: false,
reason: "NOT_RECIPIENT",
};
}
if (
coupon.expiresAt !== null &&
coupon.expiresAt <= context.now
) {
return {
valid: false,
reason: "EXPIRED",
};
}
return {
valid: true,
};
}
호출자는 실패 이유에 따라 적절히 대응할 수 있다.
검증 결과의 표현 방식은 호출자가 무엇을 해야 하는지에 따라 결정해야 한다.
사용자에게 모든 검증 정보를 자세히 보여주는 것이 항상 좋은 것은 아니다.
로그인 기능에서 다음 메시지를 반환한다고 생각해 보자.
이 이메일의 계정은 존재하지만
비밀번호가 올바르지 않다.
이 메시지는 공격자에게 어떤 이메일이 가입되어 있는지 알려줄 수 있다.
외부에는 더 일반적인 메시지를 제공할 수 있다.
이메일 또는 비밀번호가 올바르지 않다.
반면 쿠폰 제목이 너무 길다는 오류는 구체적으로 알려주는 편이 사용자가 수정하기 쉽다.
{
"code": "COUPON_TITLE_TOO_LONG",
"field": "title",
"message": "쿠폰 제목은 50자 이하여야 한다."
}
검증 오류를 설계할 때는 다음 세 대상을 구분해야 한다.
사용자 메시지, 오류 코드, 내부 로그가 반드시 같은 정보를 포함할 필요는 없다.
if (!input.title) {
throw new Error(
"제목이 필요하다."
);
}
공백만 있는 문자열이나 지나치게 긴 문자열을 허용할 수 있다.
const title =
input.title.trim();
if (
title.length < 1 ||
title.length > 50
) {
throw new InvalidTitleError();
}
서비스가 실제로 사용하는 형태와 범위를 확인해야 한다.
if (
typeof remainingUses
=== "number"
) {
createCoupon();
}
음수, 소수, 지나치게 큰 숫자도 number다.
if (
!Number.isInteger(
remainingUses
) ||
remainingUses < 1 ||
remainingUses > 10
) {
throw new InvalidUsesError();
}
자료형과 서비스가 허용하는 범위는 별도로 확인해야 한다.
<button
disabled={!isFormValid}
>
쿠폰 만들기
</button>
버튼을 비활성화해도 서버 요청은 직접 보낼 수 있다.
서버는 모든 클라이언트 요청에 동일한 핵심 규칙을 적용해야 한다.
isValid 함수에 모든 규칙을 넣는다const valid =
await isValid(request);
어떤 책임의 어떤 규칙이 실패했는지 알기 어렵다.
입력 파싱, 권한, 정책, 상태 전이와 저장 제약을 책임에 맞게 구분해야 한다.
const coupon =
await findCoupon(couponId);
if (!coupon) {
throw new NotFoundError();
}
데이터의 존재와 현재 사용자의 권한은 다른 문제다.
if (
coupon.recipientId !==
currentUser.id
) {
throw new ForbiddenError();
}
행동과 대상에 맞는 권한 검증이 필요하다.
if (
coupon.remainingUses > 0
) {
await redeemCoupon(coupon);
}
여러 요청이 같은 상태를 동시에 확인할 수 있다.
조건 확인과 변경을 데이터베이스 트랜잭션, 조건부 업데이트 또는 버전 검사로 함께 보호해야 한다.
throw new Error(
"입력값이 잘못되었다."
);
사용자와 클라이언트가 무엇을 수정해야 하는지 알 수 없다.
수정 가능한 오류는 필드와 안정적인 오류 코드로 구분하되, 보안상 민감한 정보는 외부에 노출하지 않아야 한다.
검증은 값이 비어 있는지 확인하는 작업에서 시작할 수 있다.
if (!couponTitle) {
throw new Error(
"쿠폰 제목이 필요하다."
);
}
하지만 실제 서비스의 검증은 훨씬 넓은 규칙을 다룬다.
검증은 하나의 위치에서 한 번 실행되는 단계가 아니다.
flowchart LR
A[UI 검증]
--> B[요청 구조 검증]
--> C[인증과 권한]
--> D[비즈니스 규칙]
--> E[상태 변경 검증]
--> F[데이터베이스 제약]
각 경계는 서로 다른 책임을 가진다.
UI는 빠른 피드백을 제공한다.
API 경계는 외부 데이터의 형태와 크기를 확인한다.
사용 사례와 도메인은 권한, 정책, 상태 전이를 보호한다.
데이터베이스는 어떤 변경 경로에서도 깨지면 안 되는 조건을 최종적으로 지킨다.
검증 규칙을 설계한다는 것은 결국 다음 질문에 답하는 일이다.
이 데이터와 행동이 서비스의 약속을 깨뜨리지 않도록 어떤 규칙을, 어느 책임에서, 어느 시점까지 보장해야 하는가?
검증은 빈칸을 확인하는 일이 아니다.
외부 데이터를 서비스가 책임질 수 있는 데이터로 바꾸고, 모든 변경 경로에서 데이터·비즈니스·보안 규칙을 유지하는 방법이다.