자료형은 데이터의 종류만 구분하지 않는다

vx_developer·2026년 8월 25일

개발하다가

목록 보기
2/30
post-thumbnail

프로그래밍을 처음 배울 때 자료형은 보통 데이터의 종류를 구분하는 개념으로 배운다.

const name = "JungWoo"; // string
const age = 30; // number
const isMember = true; // boolean

문자열은 문자 데이터를 저장하고, 숫자형은 숫자를 저장하며, 불리언은 참과 거짓을 표현한다.
입문 단계에서는 자료형의 차이를 이해하는 데 유용한 설명이다.
하지만 실제 서비스를 개발할 때 자료형은 단순히 값을 분류하는 역할만 하지 않는다.
자료형은 어떤 데이터가 들어올 수 있는지, 그 데이터로 무엇을 할 수 있는지, 어떤 상태를 허용할 것인지, 잘못된 데이터가 서비스 안으로 들어오는 것을 어떻게 막을 것인지 결정한다.
즉, 실제 서비스에서 자료형을 설계한다는 것은 데이터의 종류를 선택하는 것이 아니라 서비스가 허용할 수 있는 데이터의 범위와 규칙을 정의하는 일이다.

문자열이라고 모두 같은 데이터는 아니다

다음 값들은 JavaScript에서 모두 문자열이다.

const userName = "JungWoo";
const email = "jungwoo@example.com";
const password = "mypassword123";
const couponStatus = "active";
const expiryDate = "2026-12-31";

프로그래밍 언어의 관점에서는 모두 string이다.
하지만 서비스의 관점에서는 전혀 다른 의미와 규칙을 가진다.

  • 사용자 이름은 일정 길이 안에서 자유롭게 입력할 수 있다.
  • 이메일은 이메일 주소의 형식을 따라야 한다.
  • 비밀번호는 외부에 노출하거나 그대로 저장하면 안 된다.
  • 쿠폰 상태는 서비스가 허용한 몇 가지 값 중 하나여야 한다.
  • 만료일은 날짜로 변환하고 비교할 수 있어야 한다.

따라서 다음과 같이 모든 값을 단순한 문자열로만 정의하면 서비스의 규칙을 충분히 표현할 수 없다.

interface Coupon {
  title: string;
  status: string;
  expiresAt: string;
}

status가 단순한 문자열이면 다음과 같은 값도 타입상으로는 허용된다.

coupon.status = "hello";
coupon.status = "사용 가능함";
coupon.status = "";
coupon.status = "actvie";

actvie처럼 철자가 잘못된 값도 들어갈 수 있다.
데이터베이스에는 active, Active, ACTIVATED처럼 서로 다른 표현이 섞일 수도 있다.
실제 서비스에서는 쿠폰 상태를 아무 문자열이나 받을 수 있는 값이 아니라, 정해진 선택지 중 하나로 제한해야 한다.

type CouponStatus =
  | "draft"
  | "active"
  | "redeemed"
  | "expired"
  | "cancelled";

interface Coupon {
  title: string;
  status: CouponStatus;
  expiresAt: Date;
}

이제 쿠폰 상태에는 서비스가 정의한 값만 들어갈 수 있다.
자료형이 단순한 데이터 분류를 넘어 서비스 규칙을 표현하기 시작한 것이다.

숫자라고 모두 계산할 수 있는 것은 아니다

다음 값들은 모두 숫자로 표현할 수 있다.

const productPrice = 100;
const quantity = 2;
const userId = 1024;
const phoneNumber = 61412345678;
const postcode = 2134;

하지만 이 값들이 모두 같은 방식으로 사용되는 것은 아니다.
상품 가격과 수량은 계산할 수 있다.

const totalPrice = productPrice * quantity;

하지만 사용자 ID를 두 배로 계산하는 것은 서비스상 아무 의미가 없다.

const doubledUserId = userId * 2;

전화번호와 우편번호도 숫자로 보이지만 실제로는 계산하기 위한 값이 아니다.
국가번호나 앞자리의 0이 중요할 수 있고, 우편번호는 특정 길이를 유지해야 할 수도 있다.

const australianPostcode = "0214";

이를 숫자로 저장하면 앞의 0이 사라진다.

const australianPostcode = 0214;
// 실제로 필요한 "0214" 형태를 유지하기 어렵다.

따라서 숫자로만 이루어져 있다는 이유로 반드시 number를 선택해서는 안 된다.
자료형을 결정할 때 중요한 질문은 “이 값이 숫자로 보이는가?”가 아니다.

이 값을 이용해 산술 계산을 해야 하는가?

계산을 위한 값이라면 숫자형이 적절할 가능성이 높다.
식별하거나 표시하기 위한 값이라면 문자열이 더 적절할 수 있다.

금액을 단순한 소수로 다루면 문제가 생길 수 있다

쇼핑몰이나 결제 서비스에서 가격은 보통 숫자로 보인다.

const price = 19.99;

그러나 컴퓨터가 소수를 표현하는 방식 때문에 예상과 다른 결과가 나올 수 있다.

console.log(0.1 + 0.2);
// 0.30000000000000004

단순한 화면에서는 크게 문제가 없어 보일 수 있지만, 결제와 정산에서는 아주 작은 오차도 허용하기 어렵다.
그래서 실제 서비스에서는 금액을 가장 작은 화폐 단위로 저장하는 경우가 많다. 호주 달러라면 센트 단위로 관리할 수 있다.

const productPriceInCents = 1999;
const quantity = 2;

const totalPriceInCents =
  productPriceInCents * quantity;

화면에 표시할 때 달러로 변환한다.

const formattedPrice = new Intl.NumberFormat("en-AU", {
  style: "currency",
  currency: "AUD",
}).format(totalPriceInCents / 100);

자료형을 선택한다는 것은 단지 number를 사용할지 결정하는 일이 아니다.
단위와 정밀도, 계산 방법까지 함께 결정하는 일이다.
price보다 priceInCents라는 변수명이 더 좋은 이유도 여기에 있다.
변수명 자체가 값의 단위를 설명하기 때문이다.

불리언은 두 상태만 표현할 수 있다

불리언은 참과 거짓을 표현한다.

const isCouponUsed = false;

간단해 보이지만 실제 쿠폰에는 더 많은 상태가 존재할 수 있다.

  • 아직 작성 중인 쿠폰
  • 발행되어 사용할 수 있는 쿠폰
  • 이미 사용된 쿠폰
  • 유효기간이 지난 쿠폰
  • 발행자가 취소한 쿠폰

이를 여러 개의 불리언으로 표현하면 문제가 생길 수 있다.

interface Coupon {
  isDraft: boolean;
  isActive: boolean;
  isUsed: boolean;
  isExpired: boolean;
  isCancelled: boolean;
}

다음과 같이 서로 충돌하는 상태가 만들어질 수 있다.

const coupon = {
  isDraft: false,
  isActive: true,
  isUsed: true,
  isExpired: true,
  isCancelled: true,
};

쿠폰이 동시에 사용 가능하고, 사용되었고, 만료되고, 취소된 상태가 되어버렸다.
서로 배타적인 상태라면 하나의 상태값으로 표현하는 편이 명확하다.

type CouponStatus =
  | "draft"
  | "active"
  | "redeemed"
  | "expired"
  | "cancelled";

불리언은 정말로 두 가지 상태만 존재할 때 적합하다.

const hasAcceptedTerms = true;
const isEmailVerified = false;
const isPreviewOpen = true;

두 가지보다 많은 상태가 존재한다면 불리언보다 상태 타입이나 열거형을 고려해야 한다.

날짜는 문자열처럼 보여도 문자열과 다르다

API나 데이터베이스에서 날짜는 다음과 같은 문자열로 전달되는 경우가 많다.

const expiresAt = "2026-12-31T23:59:59Z";

하지만 문자열인 상태에서는 날짜 계산을 안정적으로 수행하기 어렵다.

const isExpired = expiresAt < new Date();

문자열과 Date 객체를 직접 비교하는 이 코드는 의도한 방식으로 동작한다고 보장할 수 없다.
날짜를 실제 날짜 타입으로 변환해야 한다.

const expiryDate = new Date(expiresAt);
const currentDate = new Date();

const isExpired = expiryDate < currentDate;

실제 서비스에서는 시간대도 고려해야 한다.
호주와 한국에서 동시에 사용하는 쿠폰 서비스라면 “12월 31일까지”라는 문장이 어느 지역의 시간을 기준으로 하는지 정해야 한다.
시드니의 자정과 서울의 자정은 같은 순간이 아니다.
따라서 날짜를 다룰 때는 다음과 같은 질문이 필요하다.

  • 날짜만 필요한가, 정확한 시각까지 필요한가?
  • 어느 시간대를 기준으로 하는가?
  • 데이터베이스에는 어떤 형식으로 저장하는가?
  • 사용자 화면에는 어떤 지역 형식으로 표시하는가?
  • 만료 시각을 포함하는가, 포함하지 않는가?

자료형을 선택한다는 것은 string과 Date 중 하나를 고르는 데서 끝나지 않는다.
서비스가 시간을 해석하는 기준까지 정의해야 한다.

값이 없다는 것도 하나의 상태다

다음 사용자 정보에서 프로필 이미지가 없을 수 있다.

interface User {
  name: string;
  profileImageUrl: string | null;
}

null은 프로필 이미지가 없다는 사실을 명시적으로 표현한다.
하지만 다음과 같이 속성 자체가 선택 사항일 수도 있다.

interface UpdateUserRequest {
  name?: string;
  profileImageUrl?: string | null;
}

이때 각각의 상태는 다른 의미를 가질 수 있다.

  • profileImageUrl 속성이 없음: 이미지 정보를 변경하지 않는다.
  • profileImageUrl: null: 기존 이미지를 삭제한다.
  • profileImageUrl: "https://...": 새 이미지로 변경한다.

단순히 “값이 없음”으로 보이는 상태도 실제 서비스에서는 서로 다른 행동을 의미할 수 있다.

updateUser({});

이 요청은 기존 프로필 이미지를 유지할 수 있다.

updateUser({
  profileImageUrl: null,
});

이 요청은 기존 프로필 이미지를 제거한다는 의미가 될 수 있다.
null, undefined, 빈 문자열을 아무 구분 없이 사용하면 업데이트 로직에서 예상하지 못한 문제가 발생한다.
자료형은 값이 존재할 때의 모습만 정의하는 것이 아니다.
값이 없을 수 있는지, 없다면 어떤 의미인지를 함께 정의해야 한다.

배열의 타입은 내부 데이터까지 설명해야 한다

다음 코드는 배열이라는 사실만 알려준다.

const coupons: Array<any> = [];

any를 사용하면 배열 안에 무엇이든 넣을 수 있다.

coupons.push("coupon");
coupons.push(100);
coupons.push(null);
coupons.push({ title: "Dinner Date" });

실제 서비스에서는 배열 안에 들어가는 데이터의 형태를 명확하게 정의해야 한다.

interface CouponSummary {
  id: string;
  title: string;
  status: CouponStatus;
  expiresAt: Date;
}

const coupons: CouponSummary[] = [];

이제 쿠폰 목록에는 정해진 형태의 데이터만 들어갈 수 있다.
목록에 전체 쿠폰 데이터가 필요한지도 생각해야 한다.
쿠폰 목록 화면에서 메시지, 편집 정보, 사용 기록까지 모두 가져올 필요가 없다면 목록에 필요한 형태를 별도로 정의할 수 있다.

interface CouponDetail {
  id: string;
  title: string;
  message: string;
  status: CouponStatus;
  expiresAt: Date;
  design: CouponDesign;
  redemptionHistory: Redemption[];
}

interface CouponSummary {
  id: string;
  title: string;
  status: CouponStatus;
  expiresAt: Date;
}

둘 다 쿠폰이지만 사용하는 화면과 목적에 따라 필요한 데이터의 형태가 다르다.
자료형을 설계하는 것은 데이터베이스의 모든 값을 그대로 복사하는 일이 아니다. 각 기능이 실제로 필요로 하는 데이터의 모양을 정의하는 일이다.

TypeScript 타입만으로 외부 데이터가 안전해지지는 않는다

다음 코드를 보자.

const coupon =
  request.body as CreateCouponRequest;

이 코드는 요청 데이터가 실제로 올바른 형태인지 확인하지 않는다. TypeScript 컴파일러에게 해당 값을 CreateCouponRequest로 취급하라고 알려줄 뿐이다.
사용자는 여전히 잘못된 데이터를 보낼 수 있다.

{
  "title": 123,
  "amount": "무료",
  "status": "anything",
  "expiresAt": "언젠가"
}

API 요청, URL 매개변수, 환경변수와 외부 API 응답은 런타임에 들어온다.
TypeScript 타입은 이러한 데이터를 자동으로 검증하지 않는다.
실제 서비스에서는 런타임 검증이 필요하다.

const CreateCouponSchema = z.object({
  title: z.string().trim().min(1).max(100),
  amountInCents: z.number().int().positive(),
  expiresAt: z.coerce.date(),
});

const validatedData =
  CreateCouponSchema.parse(request.body);

여기서 중요한 흐름은 다음과 같다.
알 수 없는 외부 데이터 → 형식과 규칙 검증 → 신뢰할 수 있는 서비스 데이터
TypeScript 타입은 개발 중 실수를 줄여준다.
런타임 검증은 실제 사용자가 보내는 잘못된 데이터가 서비스 안으로 들어오는 것을 막아준다.
둘은 서로 대체하는 것이 아니라 함께 사용해야 하는 도구다.

데이터베이스 타입과 애플리케이션 타입은 같지 않을 수 있다

데이터베이스에서 날짜는 특정 날짜 타입으로 저장될 수 있지만 API를 통해 전달되면 문자열이 된다.

{
  "expiresAt": "2026-12-31T23:59:59.000Z"
}

서버 내부에서는 Date 객체였더라도 JSON에는 Date 타입이 존재하지 않기 때문이다.

const coupon = await database.coupon.findUnique({
  where: { id: couponId },
});

console.log(coupon.expiresAt instanceof Date);
// 서버에서는 true일 수 있다.

클라이언트가 응답을 받으면 다시 문자열이 된다.

const response = await fetch("/api/coupons/123");
const coupon = await response.json();

console.log(coupon.expiresAt instanceof Date);
// false

따라서 데이터가 시스템의 경계를 통과할 때 타입이 어떻게 변하는지 이해해야 한다.
데이터베이스 모델, 서버의 도메인 모델, API 응답 타입과 화면에서 사용하는 타입이 반드시 완전히 같을 필요는 없다.
각 계층의 목적에 맞는 형태를 가질 수 있다.

좋은 타입은 잘못된 상태를 만들기 어렵게 한다

다음 결제 결과 타입을 살펴보자.

interface PaymentResult {
  success: boolean;
  transactionId?: string;
  errorMessage?: string;
}

이 타입은 다음과 같은 모순된 상태를 허용한다.

const result = {
  success: true,
  errorMessage: "결제에 실패했습니다.",
};

성공했는데 실패 메시지가 존재한다.
결과를 성공과 실패로 분리하면 모순된 데이터를 줄일 수 있다.

type PaymentResult =
  | {
      success: true;
      transactionId: string;
    }
  | {
      success: false;
      errorCode: string;
      errorMessage: string;
    };

성공한 경우에는 거래 ID가 반드시 존재하고, 실패한 경우에는 오류 정보가 반드시 존재한다.

function handlePaymentResult(result: PaymentResult) {
  if (result.success) {
    console.log(result.transactionId);
    return;
  }

  console.error(result.errorMessage);
}

좋은 타입 설계는 개발자가 문서를 기억해서 올바른 코드를 작성하기를 기대하지 않는다.
잘못된 상태 자체를 표현하기 어렵게 만든다.

자료형을 선택하기 전에 질문해야 할 것

실제 서비스에서 데이터의 타입을 정할 때는 다음을 확인해야 한다.

  • 이 값은 계산을 위한 값인가, 식별을 위한 값인가?
  • 허용할 수 있는 값이 정해져 있는가?
  • 값이 없을 수 있는가?
  • 값이 없다면 어떤 의미인가?
  • 두 가지 상태만 존재하는가, 더 많은 상태가 있는가?
  • 단위는 무엇인가?
  • 정밀도가 중요한가?
  • 외부에서 들어오는 값인가?
  • 런타임 검증이 필요한가?
  • 시스템의 경계를 통과하면서 형태가 달라지는가?
  • 이 타입이 서로 모순된 상태를 허용하고 있지는 않은가?

이런 질문들에 답하면 단순히 string, number, boolean을 고르는 수준을 넘어 서비스가 실제로 허용하는 데이터의 모습을 설계할 수 있다.

자료형은 서비스가 데이터와 맺는 약속이다

처음에는 자료형을 문자열, 숫자형, 불리언처럼 값을 분류하는 문법으로 배운다. 그러나 실제 서비스에서 타입은 훨씬 더 중요한 역할을 한다.
타입은 쿠폰이 가질 수 있는 상태를 제한하고, 금액의 단위와 정밀도를 표현하며, 값이 없을 때의 의미를 구분한다.
함수가 어떤 데이터를 받을 수 있는지 알려주고, API가 어떤 결과를 반환하는지 설명하며, 서로 모순되는 상태가 만들어지는 것을 방지한다.
결국 자료형을 설계한다는 것은 다음 질문에 답하는 일이다.

우리 서비스는 어떤 데이터를 정상적인 데이터로 인정할 것인가?

이 질문에 제대로 답하지 않으면 프로그램은 실행될 수 있어도 서비스의 규칙은 쉽게 무너진다.
반대로 타입을 잘 설계하면 많은 오류를 코드가 실행되기 전에 발견할 수 있고, 개발자는 데이터의 실제 의미에 집중할 수 있다.
자료형은 단순히 데이터의 종류를 구분하는 문법이 아니다.
서비스가 데이터를 이해하고 신뢰하기 위해 맺는 하나의 약속이다.

profile
Vision eXperience Developer

0개의 댓글