검증은 빈칸을 확인하는 일이 아니다

vx_developer·2026년 9월 9일

개발하다가

목록 보기
19/30
post-thumbnail

프로그래밍을 처음 배울 때 검증은 보통 사용자가 값을 입력했는지 확인하는 작업으로 설명한다.

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에는 서로 다른 검증이 연결된다.

  1. 형식이 올바른가?
  2. 실제 데이터가 존재하는가?
  3. 현재 사용할 수 있는 상태인가?
  4. 요청자가 이 대상을 선택할 권한이 있는가?
  5. 서비스 정책이 이 관계를 허용하는가?

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를 보내도 결과는 시간과 쿠폰 상태에 따라 달라진다.

  • 어제는 유효했지만 오늘은 만료되었을 수 있다.
  • 요청 직전에 다른 사용이 발생했을 수 있다.
  • 발행자가 쿠폰을 일시 정지했을 수 있다.
  • 남은 사용 횟수가 이미 0이 되었을 수 있다.

이런 검증을 상태 기반 검증이라고 볼 수 있다.

입력 자체가 올바른지 확인하는 것만으로는 부족하다.

현재 서비스 상태에서 그 행동이 허용되는지 확인해야 한다.


먼저 검증하고 나중에 저장하면 그사이에 사실이 바뀔 수 있다

남은 사용 횟수가 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로 경쟁 상황 보호
수신자 존재의미 있는 오류 제공외래 키로 참조 보호

둘 중 하나만 선택해야 하는 것은 아니다.

같은 규칙을 서로 다른 목적의 방어선으로 표현할 수 있다.


Boolean 하나로 검증 결과를 반환하면 실패의 의미가 사라진다

다음 함수는 쿠폰이 유효한지 알려준다.

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자 이하여야 한다."
}

검증 오류를 설계할 때는 다음 세 대상을 구분해야 한다.

  • 사용자는 무엇을 수정해야 하는가?
  • 클라이언트 코드는 어떤 오류인지 어떻게 구분하는가?
  • 운영자는 문제의 원인을 어떻게 추적하는가?

사용자 메시지, 오류 코드, 내부 로그가 반드시 같은 정보를 포함할 필요는 없다.


검증 규칙을 만들기 전에 물어봐야 할 질문

데이터 형식

  1. 이 값은 런타임에서 어떤 타입이어야 하는가?
  2. 형식이 올바르더라도 허용 범위를 벗어날 수 있는가?
  3. 정규화 전과 후 중 어느 값을 검증해야 하는가?
  4. 변환 과정에서 빈 값이나 잘못된 정보가 사라지지는 않는가?
  5. 문자열 길이와 배열 크기도 제한해야 하는가?

값 사이의 관계

  1. 각 필드는 올바르지만 조합이 모순될 수 있는가?
  2. 시작일과 종료일 사이에 어떤 관계가 필요한가?
  3. 쿠폰 종류에 따라 필수 필드가 달라지는가?
  4. 한 필드가 존재할 때 다른 필드도 필요해지는가?
  5. 객체가 항상 지켜야 하는 불변 조건은 무엇인가?

비즈니스와 권한

  1. 이 규칙은 데이터 형식인가, 서비스 정책인가?
  2. 현재 사용자가 이 행동을 할 권한이 있는가?
  3. 대상 데이터가 현재 이 행동을 허용하는 상태인가?
  4. 다른 데이터의 존재나 상태를 조회해야 하는가?
  5. 규칙이 변경되면 어느 모듈을 수정해야 하는가?

실행 시점과 저장

  1. 검증한 뒤 저장하기 전에 상태가 바뀔 수 있는가?
  2. 조건 확인과 변경을 원자적으로 처리해야 하는가?
  3. 데이터베이스 제약으로도 보호해야 하는 규칙인가?
  4. 같은 요청이 반복되면 검증 결과가 달라지는가?
  5. 일부 작업이 끝난 뒤 검증 실패가 발생할 수 있는가?

오류와 운영

  1. 사용자가 수정할 수 있는 정보는 무엇인가?
  2. 외부에 공개하면 안 되는 검증 정보가 있는가?
  3. 클라이언트가 오류 코드를 안정적으로 구분할 수 있는가?
  4. 운영 로그에 실패 원인이 충분히 남는가?
  5. 검증 실패가 정상적인 사용자 실수인지 공격 징후인지 구분할 수 있는가?

흔히 하는 실수

필수 필드만 확인하면 검증이 끝났다고 생각한다

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 경계는 외부 데이터의 형태와 크기를 확인한다.

사용 사례와 도메인은 권한, 정책, 상태 전이를 보호한다.

데이터베이스는 어떤 변경 경로에서도 깨지면 안 되는 조건을 최종적으로 지킨다.

검증 규칙을 설계한다는 것은 결국 다음 질문에 답하는 일이다.

이 데이터와 행동이 서비스의 약속을 깨뜨리지 않도록 어떤 규칙을, 어느 책임에서, 어느 시점까지 보장해야 하는가?

검증은 빈칸을 확인하는 일이 아니다.

외부 데이터를 서비스가 책임질 수 있는 데이터로 바꾸고, 모든 변경 경로에서 데이터·비즈니스·보안 규칙을 유지하는 방법이다.

profile
Vision eXperience Developer

0개의 댓글