객체는 관련된 값을 묶는 구조가 아니다

vx_developer·2026년 8월 28일

개발하다가

목록 보기
7/29
post-thumbnail

프로그래밍을 처음 배울 때 객체는 보통 관련된 여러 값을 하나로 묶는 구조라고 배운다.

const user = {
  name: "Chris",
  age: 30,
  isMember: true,
};

name, age, isMember처럼 서로 관련된 값을 하나의 user 객체에 담을 수 있다는 설명이다.

객체의 기본적인 문법을 이해하기에는 충분한 설명이다.

하지만 실제 서비스를 개발하기 시작하면 객체를 만든다는 것은 단순히 여러 값을 중괄호 안에 넣는 것보다 훨씬 많은 판단을 요구한다.

  • 어떤 값들이 정말 하나의 객체에 속하는가?
  • 객체는 현실의 무엇을 표현하는가?
  • 어떤 필드는 반드시 존재해야 하는가?
  • 어떤 필드는 없을 수 있는가?
  • 객체를 무엇으로 구분하는가?
  • 누가 객체의 값을 변경할 수 있는가?
  • 어떤 상태 변화가 허용되는가?
  • API에서 받은 객체를 그대로 사용해도 되는가?
  • 데이터베이스 객체와 화면 객체는 같아야 하는가?
  • 객체 안에 비즈니스 규칙도 함께 표현해야 하는가?

실제 서비스에서 객체를 사용한다는 것은 관련된 값을 묶는 것뿐 아니라, 현실에 존재하는 사용자·상품·주문·쿠폰 같은 개념을 프로그램이 이해하고 처리할 수 있는 형태로 모델링하는 일이다.


현실의 개념은 하나의 값만으로 표현되지 않는다

쿠폰 서비스에서 쿠폰 제목만 저장한다고 생각해 보자.

const couponTitle = "커피 한 잔 사주기";

제목을 화면에 표시하는 데는 충분하다.

하지만 실제로 쿠폰을 발행하고 공유하고 사용하려면 제목만으로는 부족하다.

쿠폰에는 다음과 같은 정보가 필요할 수 있다.

  • 쿠폰을 구분하는 ID
  • 쿠폰 제목
  • 쿠폰을 발행한 사용자
  • 쿠폰을 받은 사용자
  • 현재 상태
  • 사용 가능 횟수
  • 만료 시각
  • 생성 시각
  • 실제 사용 시각

이 정보를 각각의 변수로 관리할 수도 있다.

const couponId = "coupon-123";
const couponTitle = "커피 한 잔 사주기";
const senderId = "user-1";
const receiverId = "user-2";
const couponStatus = "active";
const remainingUses = 1;
const expiresAt = "2026-12-31";

문법적으로는 문제가 없다.

하지만 이 값들이 하나의 쿠폰을 설명한다는 관계가 코드에 명확하게 드러나지 않는다.

쿠폰이 여러 개 생기면 관리하기는 더 어려워진다.

const firstCouponTitle = "커피 한 잔 사주기";
const firstCouponStatus = "active";

const secondCouponTitle = "주말 설거지 대신하기";
const secondCouponStatus = "redeemed";

새로운 쿠폰이 추가될 때마다 변수가 계속 늘어난다. 제목과 상태가 서로 다른 쿠폰의 값으로 잘못 연결될 가능성도 생긴다.

객체를 사용하면 하나의 쿠폰에 속한 값을 하나의 경계 안에 모을 수 있다.

const coupon = {
  id: "coupon-123",
  title: "커피 한 잔 사주기",
  senderId: "user-1",
  receiverId: "user-2",
  status: "active",
  remainingUses: 1,
  expiresAt: new Date("2026-12-31T23:59:59Z"),
};

이제 각각의 값은 독립적인 변수가 아니라 coupon이라는 하나의 개념을 구성하는 속성이 된다.

객체는 단순히 값의 개수를 줄여주는 문법이 아니다.

현실에 존재하는 하나의 대상을 프로그램 안에서 하나의 개념으로 다룰 수 있게 만드는 구조다.


객체에 값을 넣는 순간 현실에 대한 판단이 시작된다

현실에 존재하는 쿠폰을 코드로 옮긴다고 해서 모든 정보를 객체에 넣어야 하는 것은 아니다.

예를 들어 다음 쿠폰 객체를 살펴보자.

const coupon = {
  id: "coupon-123",
  title: "커피 한 잔 사주기",
  status: "active",
  senderId: "user-1",
  receiverId: "user-2",
  remainingUses: 1,
  expiresAt: new Date("2026-12-31T23:59:59Z"),
  senderPassword: "secret-password",
  serverRegion: "ap-southeast-2",
  buttonColor: "#FFB86B",
};

모든 필드가 쿠폰과 어떤 식으로든 관련되어 보일 수 있다.

하지만 같은 객체에 있어야 한다는 뜻은 아니다.

  • title, status, remainingUses는 쿠폰의 핵심 데이터다.
  • senderId, receiverId는 쿠폰과 사용자의 관계를 표현한다.
  • senderPassword는 쿠폰이 알아야 할 정보가 아니다.
  • serverRegion은 실행 환경이나 인프라에 가까운 정보다.
  • buttonColor는 쿠폰 자체보다 특정 화면의 표시 방식일 수 있다.

하나의 객체에 너무 많은 값을 넣으면 객체가 무엇을 표현하는지 불분명해진다.

const coupon = {
  // 쿠폰 데이터
  id: "coupon-123",
  title: "커피 한 잔 사주기",

  // 사용자 데이터
  senderName: "Chris",
  senderEmail: "chris@example.com",

  // 화면 데이터
  buttonColor: "#FFB86B",
  isModalOpen: false,

  // 요청 상태
  isLoading: false,
  errorMessage: null,

  // 서버 설정
  apiBaseUrl: "https://api.example.com",
};

이 객체는 쿠폰인지, 사용자 정보인지, 화면 상태인지, 서버 설정인지 알기 어렵다.

관련이 있다는 이유만으로 모든 값을 하나의 객체에 넣으면 책임의 경계가 사라진다.

더 명확하게 분리할 수 있다.

const coupon = {
  id: "coupon-123",
  title: "커피 한 잔 사주기",
  senderId: "user-1",
  status: "active",
};

const sender = {
  id: "user-1",
  displayName: "Chris",
  email: "chris@example.com",
};

const couponPageState = {
  isModalOpen: false,
  isLoading: false,
  errorMessage: null,
};

이제 각 객체가 표현하는 대상이 분명해졌다.

객체의 경계를 정한다는 것은 다음 질문에 답하는 일이다.

어떤 값들이 함께 변경되고, 함께 저장되며, 하나의 개념으로 이해되어야 하는가?


객체의 필드 이름은 현실의 의미를 설명해야 한다

다음 객체도 쿠폰을 표현할 수 있다.

const coupon = {
  a: "커피 한 잔 사주기",
  b: 1,
  c: false,
};

코드는 동작할 수 있지만 각 값이 무엇을 의미하는지 알기 어렵다.

필드 이름에 의미를 부여하면 객체가 표현하는 현실을 이해할 수 있다.

const coupon = {
  title: "커피 한 잔 사주기",
  remainingUses: 1,
  isExpired: false,
};

이제 코드를 읽는 사람은 다음 사실을 알 수 있다.

  • 쿠폰의 제목은 "커피 한 잔 사주기"다.
  • 쿠폰은 한 번 더 사용할 수 있다.
  • 현재 만료되지 않았다.

하지만 이름을 구체적으로 작성하는 것만으로 충분하지 않을 때도 있다.

const coupon = {
  date: new Date(),
  userId: "user-1",
};

date가 생성일인지, 만료일인지, 사용일인지 알 수 없다.

userId가 발행자인지, 수신자인지, 실제 사용자인지도 알 수 없다.

역할을 포함해 이름을 작성해야 한다.

const coupon = {
  createdAt: new Date(),
  expiresAt: new Date("2026-12-31T23:59:59Z"),
  senderId: "user-1",
  receiverId: "user-2",
};

좋은 필드 이름은 단순히 값을 설명하지 않는다.

그 값이 객체 안에서 맡는 역할을 설명한다.


같은 모양의 객체가 같은 의미를 가지는 것은 아니다

다음 두 객체를 살펴보자.

const sender = {
  id: "user-1",
  name: "Chris",
};

const receiver = {
  id: "user-2",
  name: "Alex",
};

두 객체의 구조는 같다.

하지만 서비스에서 맡는 역할은 다르다.

  • sender는 쿠폰을 발행한 사용자다.
  • receiver는 쿠폰을 받은 사용자다.

객체의 모양만 보고 의미를 판단하면 이 차이를 놓칠 수 있다.

TypeScript에서는 역할을 타입 이름으로 표현할 수 있다.

interface CouponSender {
  id: string;
  displayName: string;
}

interface CouponReceiver {
  id: string;
  displayName: string;
}

필드 구조가 같더라도 이름을 통해 각각의 역할을 드러낼 수 있다.

다만 구조가 같다는 이유만으로 항상 별도의 타입을 만들어야 하는 것은 아니다.

두 값이 실제로 같은 UserSummary 형태이며 역할만 다르다면 하나의 타입을 재사용할 수도 있다.

interface UserSummary {
  id: string;
  displayName: string;
}

interface Coupon {
  sender: UserSummary;
  receiver: UserSummary;
}

여기서는 senderreceiver라는 필드 이름이 역할을 설명한다.

중요한 것은 타입을 많이 만드는 것이 아니다.

객체의 구조와 이름만 보고도 이 데이터가 현실에서 무엇을 의미하는지 이해할 수 있어야 한다.


객체의 정체성은 필드 값의 조합과 다르다

두 쿠폰의 제목이 같다고 가정해 보자.

const firstCoupon = {
  title: "커피 한 잔 사주기",
  status: "active",
};

const secondCoupon = {
  title: "커피 한 잔 사주기",
  status: "active",
};

두 객체의 값은 같아 보인다.

하지만 실제 서비스에서는 서로 다른 쿠폰일 수 있다.

크리스가 발행한 쿠폰과 다른 사용자가 발행한 쿠폰일 수도 있고, 같은 사용자가 서로 다른 시점에 두 장을 발행했을 수도 있다.

따라서 서비스 객체에는 정체성을 표현하는 ID가 필요할 수 있다.

const firstCoupon = {
  id: "coupon-101",
  title: "커피 한 잔 사주기",
  status: "active",
};

const secondCoupon = {
  id: "coupon-102",
  title: "커피 한 잔 사주기",
  status: "active",
};

제목과 상태가 같아도 ID가 다르므로 서로 다른 쿠폰이다.

function isSameCoupon(
  firstCoupon: Coupon,
  secondCoupon: Coupon
): boolean {
  return firstCoupon.id === secondCoupon.id;
}

이 함수는 쿠폰의 모든 필드가 같은지를 비교하지 않는다. 같은 정체성을 가진 쿠폰인지를 ID로 판단한다.

반대로 모든 객체가 ID를 가져야 하는 것은 아니다.

배송 주소처럼 값 자체가 중요한 객체도 있다.

interface Address {
  country: string;
  city: string;
  postalCode: string;
  addressLine: string;
}

주소의 모든 값이 같다면 같은 주소로 취급할 수 있다.

여기서 객체는 크게 두 가지 관점으로 나눠 생각할 수 있다.

  • 정체성이 중요한 객체: 사용자, 쿠폰, 주문처럼 ID로 구분한다.
  • 값이 중요한 객체: 주소, 금액, 날짜 범위처럼 속성 값으로 의미를 판단한다.

객체를 설계할 때는 먼저 물어봐야 한다.

이 객체는 고유한 대상인가, 아니면 특정한 값을 표현하는가?


객체의 타입은 가능한 상태를 제한해야 한다

쿠폰 객체를 다음처럼 만들 수 있다.

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

status가 문자열이므로 어떤 값이든 들어갈 수 있다.

const coupon: Coupon = {
  id: "coupon-123",
  title: "커피 한 잔 사주기",
  status: "banana",
};

문법적으로는 유효하지만 서비스에서는 의미가 없다.

가능한 상태를 제한할 수 있다.

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

interface Coupon {
  id: string;
  title: string;
  status: CouponStatus;
}

이제 쿠폰 상태는 서비스가 정의한 값 중 하나여야 한다.

객체의 타입은 단순히 자동 완성을 제공하기 위한 문서가 아니다.

서비스에서 어떤 데이터가 정상적인 객체로 인정될 수 있는지 표현하는 규칙이다.

하지만 타입만으로 모든 런타임 데이터를 보호할 수 있는 것은 아니다.

API 요청이나 데이터베이스, 외부 서비스에서 들어온 값은 TypeScript 타입을 무시하고 들어올 수 있다.

const coupon = await response.json();

코드에 Coupon 타입을 붙였다고 해서 실제 응답이 올바른 쿠폰이 되는 것은 아니다.

const coupon =
  (await response.json()) as Coupon;

as Coupon은 개발자가 TypeScript에 그렇게 믿으라고 말하는 것에 가깝다. 실제 데이터를 검사하지 않는다.

외부 데이터를 서비스 객체로 사용하려면 검증 과정이 필요하다.

function parseCouponStatus(
  value: unknown
): CouponStatus {
  if (
    value === "draft" ||
    value === "active" ||
    value === "redeemed" ||
    value === "expired" ||
    value === "cancelled"
  ) {
    return value;
  }

  throw new Error("올바르지 않은 쿠폰 상태다.");
}

이 함수는 외부에서 들어온 값을 서비스가 허용하는 상태로 변환하거나, 사용할 수 없는 값이라면 실패시킨다.

타입은 코드 내부의 약속이고, 검증은 외부 데이터가 그 약속을 지키는지 확인하는 과정이다.


객체의 선택적 필드는 ‘없을 수 있는 이유’를 표현해야 한다

쿠폰에는 만료일이 없을 수 있다.

interface Coupon {
  id: string;
  title: string;
  expiresAt?: Date;
}

이 타입에서 expiresAt은 존재하지 않을 수 있다.

하지만 왜 없는지는 알기 어렵다.

  • 만료일을 설정하지 않은 것인가?
  • 아직 서버에서 필드를 받지 못한 것인가?
  • 조회할 때 해당 필드를 제외한 것인가?
  • 데이터 변환 중 누락된 것인가?

만료일 없음이 서비스에서 정상적인 상태라면 이를 명시적으로 표현할 수 있다.

interface Coupon {
  id: string;
  title: string;
  expiresAt: Date | null;
}

여기서 null은 “이 쿠폰에는 만료일이 없다”는 의미로 사용할 수 있다.

const coupon: Coupon = {
  id: "coupon-123",
  title: "커피 한 잔 사주기",
  expiresAt: null,
};

필드를 선택적으로 만들 것인지 null을 허용할 것인지는 단순한 코드 스타일 문제가 아니다.

  • 필드가 아직 제공되지 않은 상태인가?
  • 해당 값이 존재하지 않는 것이 정상인가?
  • 값을 조회하지 않은 상태와 실제로 없는 상태를 구분해야 하는가?
  • API 계약에서 필드를 항상 반환할 것인가?

객체의 빈 값은 다음 글에서 더 자세히 다루겠지만, 객체를 모델링할 때부터 “없음”의 의미를 구분해야 한다.


원본 데이터와 계산할 수 있는 값을 구분해야 한다

쿠폰 객체에 만료 시각과 만료 여부를 모두 저장할 수 있다.

const coupon = {
  expiresAt: new Date("2026-12-31T23:59:59Z"),
  isExpired: false,
};

처음에는 편리해 보인다.

하지만 시간이 지나 만료 시각을 넘으면 문제가 생긴다.

expiresAt은 과거인데 isExpired는 여전히 false일 수 있다.

const coupon = {
  expiresAt: new Date("2025-12-31T23:59:59Z"),
  isExpired: false,
};

두 값이 서로 모순된다.

isExpiredexpiresAt으로 계산 가능한 값이라면 원본 데이터만 저장하는 편이 안전하다.

const coupon = {
  expiresAt: new Date("2026-12-31T23:59:59Z"),
};

필요할 때 계산한다.

function isCouponExpired(
  coupon: Coupon,
  now: Date
): boolean {
  return (
    coupon.expiresAt !== null &&
    coupon.expiresAt <= now
  );
}

이 함수는 쿠폰의 만료 시각과 현재 시각을 비교해 만료 여부를 계산한다.

물론 성능이나 조회 요구사항 때문에 계산 결과를 데이터베이스에 저장하는 경우도 있다.

그렇다면 다음을 정의해야 한다.

  • 원본 값은 무엇인가?
  • 계산 결과는 언제 갱신하는가?
  • 두 값이 다르면 무엇을 신뢰하는가?
  • 갱신에 실패했을 때 어떻게 복구하는가?

객체에 필드를 추가할 때는 편리함만 생각하면 안 된다.

이 값은 반드시 저장해야 하는 사실인가, 다른 값으로부터 계산할 수 있는 결과인가?


객체는 유효하지 않은 상태도 쉽게 만들 수 있다

다음 쿠폰 객체는 타입만 보면 문제가 없어 보인다.

const coupon = {
  status: "redeemed",
  remainingUses: 3,
  redeemedAt: null,
};

하지만 서비스 규칙을 생각하면 이상하다.

  • 이미 사용된 쿠폰인데 사용 가능 횟수가 3회 남아 있다.
  • 사용 상태인데 사용 시각이 없다.

객체 문법은 이런 모순을 막아주지 않는다.

따라서 객체를 생성할 때부터 규칙을 확인해야 한다.

interface CreateCouponInput {
  title: string;
  senderId: string;
  receiverId: string;
  expiresAt: Date | null;
}

function createCoupon(
  input: CreateCouponInput
): Coupon {
  const title = input.title.trim();

  if (title.length === 0) {
    throw new Error("쿠폰 제목은 비어 있을 수 없다.");
  }

  if (input.senderId === input.receiverId) {
    throw new Error(
      "발행자와 수신자는 같을 수 없다."
    );
  }

  return {
    id: crypto.randomUUID(),
    title,
    senderId: input.senderId,
    receiverId: input.receiverId,
    status: "active",
    remainingUses: 1,
    expiresAt: input.expiresAt,
    createdAt: new Date(),
    redeemedAt: null,
  };
}

이 함수는 단순히 입력값을 객체로 묶지 않는다.

서비스에서 유효한 쿠폰이 만들어지기 위한 조건을 확인하고, 초기 상태를 일관되게 설정한다.

객체를 아무 곳에서나 직접 만들게 하면 서로 다른 초기 상태가 생길 수 있다.

const coupon = {
  id: "",
  title: "",
  status: "redeemed",
  remainingUses: -10,
};

객체 생성 경로를 함수로 제한하면 어떤 상태의 객체가 서비스 안으로 들어올 수 있는지 관리하기 쉬워진다.


객체의 상태 변경에는 규칙이 필요하다

쿠폰 상태를 변경하는 가장 단순한 방법은 필드에 새로운 값을 넣는 것이다.

coupon.status = "redeemed";

하지만 실제 서비스에서는 상태 하나만 변경해서는 부족할 수 있다.

쿠폰을 사용할 때는 다음 작업이 함께 필요할 수 있다.

  • 현재 쿠폰이 사용 가능한지 확인한다.
  • 수신자가 요청한 것인지 확인한다.
  • 만료되지 않았는지 확인한다.
  • 남은 사용 횟수를 줄인다.
  • 모두 사용했다면 상태를 변경한다.
  • 사용 시각을 기록한다.

상태를 직접 변경하는 대신 하나의 비즈니스 행동으로 표현할 수 있다.

function redeemCoupon(
  coupon: Coupon,
  userId: string,
  now: Date
): Coupon {
  if (coupon.receiverId !== userId) {
    throw new Error(
      "이 쿠폰을 사용할 권한이 없다."
    );
  }

  if (coupon.status !== "active") {
    throw new Error(
      "사용 가능한 상태의 쿠폰이 아니다."
    );
  }

  if (
    coupon.expiresAt !== null &&
    coupon.expiresAt <= now
  ) {
    throw new Error("만료된 쿠폰이다.");
  }

  const remainingUses =
    coupon.remainingUses - 1;

  return {
    ...coupon,
    remainingUses,
    status:
      remainingUses === 0
        ? "redeemed"
        : "active",
    redeemedAt:
      remainingUses === 0
        ? now
        : coupon.redeemedAt,
  };
}

이 함수는 쿠폰 객체를 직접 수정하지 않고 새로운 객체를 반환한다.

더 중요한 점은 단순한 필드 변경을 redeemCoupon이라는 서비스 행동으로 표현했다는 것이다.

coupon.status = "redeemed";

이 코드에서는 왜 상태가 바뀌었는지 알 수 없다.

const redeemedCoupon = redeemCoupon(
  coupon,
  currentUser.id,
  now
);

이 코드에서는 사용자가 쿠폰을 사용했기 때문에 상태가 변경되었다는 의미가 드러난다.

객체의 상태는 자유롭게 변경할 수 있는 값의 집합이 아니다.

현실에서 허용되는 행동을 통해 정해진 방식으로 변경되어야 한다.


원본 객체를 직접 변경할 것인가, 새로운 객체를 만들 것인가

다음 코드는 쿠폰 제목을 직접 변경한다.

coupon.title = "디저트 사주기";

객체를 직접 변경하는 것이 항상 잘못된 것은 아니다.

함수 내부에서 새로 만든 객체를 조립하거나, 변경 범위가 명확하게 제한된 상황에서는 직접 변경이 단순할 수 있다.

하지만 같은 객체를 여러 곳에서 참조하고 있다면 예상하지 못한 영향이 발생할 수 있다.

const selectedCoupon = coupon;
const couponInList = coupon;

selectedCoupon.title = "디저트 사주기";

console.log(couponInList.title);
// 디저트 사주기

두 변수는 서로 다른 객체를 가진 것이 아니다. 같은 객체를 가리킨다.

한쪽에서 변경한 값이 다른 쪽에서도 보인다.

React 상태를 직접 변경하는 경우에는 화면 갱신을 추적하기 어려워질 수도 있다.

coupon.title = "디저트 사주기";
setCoupon(coupon);

기존 객체와 같은 참조를 전달하기 때문이다.

새 객체를 만들어 전달할 수 있다.

setCoupon((previousCoupon) => ({
  ...previousCoupon,
  title: "디저트 사주기",
}));

이제 변경 전 객체와 변경 후 객체가 구분된다.

const updatedCoupon = {
  ...coupon,
  title: "디저트 사주기",
};

console.log(updatedCoupon === coupon);
// false

새 객체를 만드는 방식은 변경 전후를 추적하기 쉽고, 변경의 영향 범위를 예측하기 쉽게 만든다.

다만 스프레드 문법은 중첩된 객체까지 모두 복사하지 않는다.

interface Coupon {
  id: string;
  title: string;
  design: {
    backgroundColor: string;
    textColor: string;
  };
}
const copiedCoupon = {
  ...coupon,
};

copiedCoupon.design.backgroundColor =
  "#000000";

copiedCoupon은 새로운 객체지만 design은 원본과 같은 객체를 참조한다.

중첩된 값도 변경해야 한다면 해당 경로를 새로 만들어야 한다.

const updatedCoupon = {
  ...coupon,
  design: {
    ...coupon.design,
    backgroundColor: "#000000",
  },
};

핵심 질문은 “객체는 항상 불변이어야 하는가?”가 아니다.

이 객체를 누가 공유하고 있으며, 변경 사실을 어떤 방식으로 추적해야 하는가?


API에서 받은 객체는 서비스 객체가 아니다

클라이언트에서 쿠폰 생성 요청을 보낸다고 가정해 보자.

{
  "title": " 커피 한 잔 사주기 ",
  "receiverId": "user-2",
  "expiresAt": "2026-12-31T23:59:59Z"
}

서버는 JSON 객체를 받는다.

하지만 이 객체를 바로 쿠폰으로 저장해서는 안 된다.

const coupon = request.body;

await couponRepository.save(coupon);

외부에서 들어온 객체에는 다음 문제가 있을 수 있다.

  • title이 비어 있을 수 있다.
  • 예상하지 못한 필드가 포함될 수 있다.
  • expiresAtDate가 아니라 문자열이다.
  • receiverId가 존재하지 않는 사용자일 수 있다.
  • 발행자 ID를 사용자가 임의로 조작했을 수 있다.
  • 쿠폰 상태를 redeemed로 직접 전달했을 수 있다.
  • 사용 횟수에 음수가 들어올 수 있다.

외부 객체를 서비스 내부 객체로 변환하는 과정이 필요하다.

interface CreateCouponRequest {
  title: unknown;
  receiverId: unknown;
  expiresAt: unknown;
}
function parseCreateCouponRequest(
  body: unknown
): CreateCouponInput {
  if (
    typeof body !== "object" ||
    body === null
  ) {
    throw new Error(
      "요청 본문은 객체여야 한다."
    );
  }

  const request =
    body as Record<string, unknown>;

  if (typeof request.title !== "string") {
    throw new Error(
      "쿠폰 제목은 문자열이어야 한다."
    );
  }

  if (
    typeof request.receiverId !== "string"
  ) {
    throw new Error(
      "수신자 ID는 문자열이어야 한다."
    );
  }

  const expiresAt =
    request.expiresAt === null
      ? null
      : new Date(String(request.expiresAt));

  if (
    expiresAt !== null &&
    Number.isNaN(expiresAt.getTime())
  ) {
    throw new Error(
      "올바르지 않은 만료 시각이다."
    );
  }

  return {
    title: request.title.trim(),
    receiverId: request.receiverId,
    expiresAt,
  };
}

이 함수는 외부 객체를 다음과 같이 처리한다.

  • 객체 형태인지 확인한다.
  • 각 필드의 타입을 검사한다.
  • 문자열의 불필요한 공백을 제거한다.
  • 날짜 문자열을 Date로 변환한다.
  • 사용할 수 없는 값은 거부한다.

데이터의 흐름은 다음과 같다.

flowchart LR
    A[사용자 입력]
    --> B[HTTP JSON 객체]
    --> C[구조 및 타입 검증]
    --> D[값 정규화]
    --> E[비즈니스 규칙 확인]
    --> F[서비스 객체 생성]
    --> G[데이터베이스 저장]

같은 쿠폰처럼 보여도 각 단계의 객체는 맡는 역할이 다르다.


한 서비스에서도 여러 종류의 쿠폰 객체가 필요하다

데이터베이스에서 조회한 쿠폰 객체를 그대로 API와 화면에서 사용할 수 있을 것 같다.

const coupon =
  await couponRepository.findById(couponId);

return coupon;

하지만 데이터베이스 객체에는 화면에 필요하지 않거나 외부에 공개하면 안 되는 정보가 포함될 수 있다.

interface CouponDatabaseRow {
  id: string;
  title: string;
  sender_id: string;
  receiver_id: string;
  status: string;
  remaining_uses: number;
  expires_at: Date | null;
  internal_memo: string | null;
  deleted_at: Date | null;
}

이 객체는 데이터베이스 구조를 표현한다.

서비스 내부에서는 도메인의 언어에 맞는 객체가 필요할 수 있다.

interface Coupon {
  id: string;
  title: string;
  senderId: string;
  receiverId: string;
  status: CouponStatus;
  remainingUses: number;
  expiresAt: Date | null;
}

API 응답에서는 날짜를 JSON으로 전달할 수 있도록 문자열로 바꿔야 한다.

interface CouponResponse {
  id: string;
  title: string;
  status: CouponStatus;
  remainingUses: number;
  expiresAt: string | null;
}

화면에서는 사용자가 이해할 수 있는 문구와 버튼 상태가 필요하다.

interface CouponCardViewModel {
  id: string;
  title: string;
  statusLabel: string;
  expiryLabel: string;
  canRedeem: boolean;
}

각 객체의 관계를 정리하면 다음과 같다.

객체주요 책임
요청 객체클라이언트가 전달한 입력을 표현한다
서비스 객체쿠폰의 핵심 데이터와 규칙을 표현한다
데이터베이스 객체저장 구조와 컬럼을 표현한다
응답 객체외부에 공개할 데이터를 정의한다
화면 객체UI가 바로 표시할 수 있는 값을 제공한다

각 계층의 객체를 변환할 수 있다.

function toCoupon(
  row: CouponDatabaseRow
): Coupon {
  return {
    id: row.id,
    title: row.title,
    senderId: row.sender_id,
    receiverId: row.receiver_id,
    status: parseCouponStatus(row.status),
    remainingUses: row.remaining_uses,
    expiresAt: row.expires_at,
  };
}
function toCouponResponse(
  coupon: Coupon
): CouponResponse {
  return {
    id: coupon.id,
    title: coupon.title,
    status: coupon.status,
    remainingUses: coupon.remainingUses,
    expiresAt:
      coupon.expiresAt?.toISOString() ??
      null,
  };
}
function toCouponCardViewModel(
  coupon: Coupon,
  now: Date
): CouponCardViewModel {
  return {
    id: coupon.id,
    title: coupon.title,
    statusLabel:
      getCouponStatusLabel(coupon.status),
    expiryLabel:
      formatExpiryLabel(
        coupon.expiresAt,
        now
      ),
    canRedeem:
      canRedeemCoupon(coupon, now),
  };
}

객체 변환은 필드 이름을 바꾸는 반복 작업처럼 보일 수 있다.

하지만 실제로는 한 영역의 데이터를 다른 영역에서 사용할 수 있는 의미로 번역하는 과정이다.

데이터베이스 구조가 변경되어도 화면 객체까지 함께 변경할 필요가 없고, 내부 필드가 API를 통해 실수로 노출되는 것도 막을 수 있다.


객체 전체를 전달하면 함수의 계약이 불분명해질 수 있다

쿠폰 제목을 만드는 함수가 있다고 가정해 보자.

function createCouponTitle(data: object) {
  // ...
}

object만으로는 함수가 어떤 필드를 필요로 하는지 알 수 없다.

전체 쿠폰 객체를 전달하도록 만들 수도 있다.

function createCouponTitle(
  coupon: Coupon
): string {
  return `${coupon.title} 쿠폰`;
}

이 함수는 title만 사용하지만 쿠폰의 모든 필드를 요구한다.

테스트하려면 불필요한 필드까지 만들어야 한다.

createCouponTitle({
  id: "coupon-1",
  title: "커피 한 잔",
  senderId: "user-1",
  receiverId: "user-2",
  status: "active",
  remainingUses: 1,
  expiresAt: null,
  createdAt: new Date(),
  redeemedAt: null,
});

함수가 실제로 필요한 값만 받도록 만들 수 있다.

function createCouponTitle(
  coupon: Pick<Coupon, "title">
): string {
  return `${coupon.title} 쿠폰`;
}

또는 값 하나만 필요하다면 문자열을 직접 받을 수도 있다.

function createCouponTitle(
  title: string
): string {
  return `${title} 쿠폰`;
}

반대로 관련된 값이 여러 개이고 함께 전달되어야 한다면 입력 객체가 더 명확할 수 있다.

interface CanRedeemCouponInput {
  coupon: Coupon;
  userId: string;
  now: Date;
}

function canRedeemCoupon(
  input: CanRedeemCouponInput
): boolean {
  return (
    input.coupon.receiverId ===
      input.userId &&
    input.coupon.status === "active" &&
    (
      input.coupon.expiresAt === null ||
      input.coupon.expiresAt > input.now
    )
  );
}

객체를 매개변수로 전달할지는 값의 개수만으로 결정하지 않는다.

  • 값들이 하나의 입력 개념을 이루는가?
  • 함수가 실제로 어떤 필드를 필요로 하는가?
  • 불필요하게 거대한 객체에 의존하고 있지는 않은가?
  • 필드 이름을 통해 호출 의도를 설명할 필요가 있는가?

객체는 편리한 전달 가방이 아니다. 함수가 외부와 맺는 계약을 표현하는 도구이기도 하다.


객체 분해 할당도 데이터의 의미를 숨길 수 있다

객체에서 필요한 값을 꺼낼 때 분해 할당을 사용할 수 있다.

const {
  title,
  status,
  expiresAt,
} = coupon;

짧고 편리하다.

하지만 여러 객체에서 같은 이름의 필드를 꺼내면 출처가 불분명해질 수 있다.

const { id, name } = sender;
const { id, title } = coupon;

같은 스코프에서 이름이 충돌한다.

별칭을 사용하면 출처와 역할을 드러낼 수 있다.

const {
  id: senderId,
  name: senderName,
} = sender;

const {
  id: couponId,
  title: couponTitle,
} = coupon;

분해 할당은 문법을 짧게 만들지만 객체가 제공하던 문맥을 제거하기도 한다.

console.log(title);

title만 보면 쿠폰 제목인지, 페이지 제목인지, 템플릿 제목인지 알기 어렵다.

console.log(coupon.title);

객체를 통해 접근하면 값의 출처가 명확하다.

따라서 모든 필드를 무조건 분해하기보다, 코드가 길어지는지보다 의미가 더 명확해지는지를 기준으로 선택해야 한다.


객체를 복사해도 신뢰할 수 있는 객체가 되는 것은 아니다

외부 입력을 새 객체에 복사하면 안전해 보일 수 있다.

const coupon = {
  ...request.body,
};

하지만 복사는 검증이 아니다.

사용자가 다음 데이터를 전달할 수도 있다.

{
  "title": "커피 한 잔 사주기",
  "receiverId": "user-2",
  "status": "redeemed",
  "senderId": "admin-user",
  "remainingUses": 999999
}

요청 객체 전체를 복사하면 사용자가 설정하면 안 되는 필드까지 서비스 객체에 들어온다.

필요한 필드만 명시적으로 선택해야 한다.

const input: CreateCouponInput = {
  title: parseTitle(request.body.title),
  receiverId:
    parseUserId(request.body.receiverId),
  expiresAt:
    parseExpiresAt(request.body.expiresAt),
};

그리고 서버가 결정해야 하는 값은 서버에서 만든다.

const coupon = createCoupon({
  ...input,
  senderId: authenticatedUser.id,
});

senderId, status, remainingUses, createdAt 같은 값은 사용자가 자유롭게 정하는 값이 아니다.

객체 스프레드는 데이터를 복사하는 문법일 뿐이다.

어떤 필드를 받아들이고 누가 값을 결정할지는 서비스 설계와 보안의 문제다.


객체가 커진다면 여러 책임이 섞였는지 확인해야 한다

처음에는 작은 쿠폰 객체로 시작할 수 있다.

interface Coupon {
  id: string;
  title: string;
  status: CouponStatus;
}

서비스가 성장하면서 계속 필드를 추가할 수 있다.

interface Coupon {
  id: string;
  title: string;
  status: CouponStatus;
  senderId: string;
  receiverId: string;
  senderName: string;
  receiverName: string;
  templateId: string;
  backgroundColor: string;
  textColor: string;
  imageUrl: string | null;
  emoji: string | null;
  isEditing: boolean;
  isSelected: boolean;
  isLoading: boolean;
  errorMessage: string | null;
  databaseVersion: number;
  apiRetryCount: number;
}

필드가 많다는 사실만으로 나쁜 객체는 아니다.

현실의 하나의 개념이 실제로 많은 정보를 가질 수도 있다.

하지만 다음과 같은 값이 섞여 있다면 책임 분리를 검토해야 한다.

  • 쿠폰의 핵심 데이터
  • 발행자와 수신자의 표시 정보
  • 쿠폰 디자인
  • 편집 화면 상태
  • API 요청 상태
  • 데이터베이스 제어 정보

역할에 따라 나눌 수 있다.

interface Coupon {
  id: string;
  title: string;
  status: CouponStatus;
  senderId: string;
  receiverId: string;
  templateId: string;
}

interface CouponDesign {
  backgroundColor: string;
  textColor: string;
  imageUrl: string | null;
  emoji: string | null;
}

interface CouponEditorState {
  isEditing: boolean;
  selectedElementId: string | null;
  isSaving: boolean;
  errorMessage: string | null;
}

분리는 파일 수를 늘리기 위한 작업이 아니다.

변경 이유가 다른 데이터를 서로 다른 객체로 관리하는 일이다.

  • 쿠폰 사용 규칙이 바뀌면 Coupon이 변경된다.
  • 디자인 기능이 바뀌면 CouponDesign이 변경된다.
  • 편집 화면 동작이 바뀌면 CouponEditorState가 변경된다.

함께 변경되지 않는 값들을 한 객체에 넣으면 작은 변경도 여러 영역에 영향을 줄 수 있다.


코드가 어떻게 달라지는가

객체에 대한 이해가 깊어지면 값의 묶음이 점차 서비스 모델로 발전한다.

첫 번째 단계: 관련 값을 각각 관리한다

const couponTitle =
  "커피 한 잔 사주기";
const couponStatus = "active";
const couponRemainingUses = 1;

값은 존재하지만 이들이 같은 쿠폰에 속한다는 관계가 약하다.

두 번째 단계: 하나의 객체로 묶는다

const coupon = {
  title: "커피 한 잔 사주기",
  status: "active",
  remainingUses: 1,
};

관련 값이 하나의 쿠폰 객체가 되었다.

하지만 어떤 값이 허용되는지, 쿠폰을 어떻게 구분하는지 명확하지 않다.

세 번째 단계: 타입과 정체성을 정의한다

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

interface Coupon {
  id: string;
  title: string;
  senderId: string;
  receiverId: string;
  status: CouponStatus;
  remainingUses: number;
  expiresAt: Date | null;
}

이제 쿠폰의 구조와 가능한 상태, 관계, 정체성이 드러난다.

네 번째 단계: 생성 규칙을 정의한다

function createCoupon(
  input: CreateCouponInput
): Coupon {
  if (input.title.trim().length === 0) {
    throw new Error(
      "쿠폰 제목은 비어 있을 수 없다."
    );
  }

  return {
    id: crypto.randomUUID(),
    title: input.title.trim(),
    senderId: input.senderId,
    receiverId: input.receiverId,
    status: "active",
    remainingUses: 1,
    expiresAt: input.expiresAt,
  };
}

아무 모양의 객체나 쿠폰으로 인정하지 않고 유효한 생성 경로를 만든다.

다섯 번째 단계: 상태 변경을 행동으로 표현한다

const redeemedCoupon = redeemCoupon(
  coupon,
  currentUser.id,
  now
);

필드를 직접 바꾸는 대신 서비스에서 허용하는 행동을 통해 객체를 변경한다.

여섯 번째 단계: 계층마다 필요한 객체로 변환한다

const coupon =
  toCoupon(databaseRow);

const response =
  toCouponResponse(coupon);

const couponCard =
  toCouponCardViewModel(coupon, now);

하나의 거대한 객체를 모든 영역에서 공유하지 않는다.

각 영역은 자신의 책임에 맞는 객체를 사용한다.

객체를 잘 설계한다는 것은 필드를 보기 좋게 정리하는 일이 아니다.

현실의 개념, 객체의 정체성, 유효한 상태, 허용되는 행동, 외부에 공개할 범위를 코드로 표현하는 일이다.


객체를 설계하기 전에 물어볼 질문

객체의 의미

  • 이 객체는 현실의 무엇을 표현하는가?
  • 객체 이름만 보고 어떤 대상인지 이해할 수 있는가?
  • 서로 관련 있다는 이유만으로 값을 한곳에 모으고 있지는 않은가?
  • 함께 변경되지 않는 값들이 섞여 있지는 않은가?
  • 이 객체의 책임을 한 문장으로 설명할 수 있는가?

정체성

  • 이 객체는 고유한 ID로 구분되는가?
  • 필드 값이 같아도 서로 다른 객체일 수 있는가?
  • ID가 없다면 무엇을 기준으로 같은 객체라고 판단하는가?
  • 배열 인덱스나 제목을 ID처럼 사용하고 있지는 않은가?
  • 데이터베이스 ID와 외부에 공개하는 ID를 구분해야 하는가?

필드

  • 각 필드는 어떤 의미를 가지는가?
  • date, value, data, userId처럼 역할이 불분명한 이름은 없는가?
  • 반드시 존재해야 하는 필드와 선택적인 필드가 구분되어 있는가?
  • null이나 undefined가 의미하는 상태가 정의되어 있는가?
  • 다른 값으로 계산할 수 있는 데이터를 중복 저장하고 있지는 않은가?

유효성

  • 어떤 상태의 객체가 정상적인가?
  • 서로 모순될 수 있는 필드 조합이 있는가?
  • 객체가 생성될 때 반드시 확인해야 하는 규칙은 무엇인가?
  • 객체를 아무 곳에서나 직접 만들 수 있게 해도 되는가?
  • 유효하지 않은 객체가 서비스 내부로 들어오는 경로가 있는가?

변경

  • 누가 이 객체를 변경할 수 있는가?
  • 어떤 상태 전이가 허용되는가?
  • 필드를 직접 변경해도 되는가?
  • 상태 변경을 비즈니스 행동으로 표현해야 하는가?
  • 같은 객체를 여러 코드가 함께 참조하고 있는가?
  • 원본을 변경할 것인가, 새로운 객체를 만들 것인가?

외부 데이터

  • 이 객체는 사용자 입력이나 API 응답에서 왔는가?
  • 외부 데이터의 구조와 타입을 실제로 검증했는가?
  • 타입 단언만으로 데이터를 신뢰하고 있지는 않은가?
  • 사용자가 설정하면 안 되는 필드를 요청에서 받고 있지는 않은가?
  • 예상하지 못한 필드를 그대로 저장하고 있지는 않은가?

계층과 공개 범위

  • 데이터베이스 객체를 API에서 그대로 반환하고 있지는 않은가?
  • 내부 필드나 민감한 정보가 노출될 가능성이 있는가?
  • 화면이 데이터베이스 구조에 직접 의존하고 있지는 않은가?
  • 요청 객체와 서비스 객체를 구분해야 하는가?
  • 화면 전용 객체가 필요한가?

객체의 크기와 책임

  • 객체가 너무 많은 이유로 변경되고 있지는 않은가?
  • UI 상태와 서비스 데이터가 섞여 있지는 않은가?
  • 사용자 정보와 쿠폰 정보가 불필요하게 중복되어 있지는 않은가?
  • 중첩 객체를 별도의 개념으로 분리해야 하는가?
  • 객체를 분리했을 때 각 변경의 영향 범위가 더 명확해지는가?

이 질문에 답하면 객체를 단순한 데이터 묶음이 아니라 서비스 모델로 설계할 수 있다.


흔히 하는 실수

모든 값을 하나의 객체에 넣는다

const data = {
  coupon,
  user,
  isModalOpen,
  error,
  apiUrl,
  accessToken,
};

한곳에서 접근하기 편하다는 이유로 서로 다른 책임의 데이터를 묶으면 객체의 의미가 사라진다.

const couponPageState = {
  selectedCouponId: null,
  isModalOpen: false,
  errorMessage: null,
};

객체가 무엇을 표현하는지 먼저 정의해야 한다.


외부 객체에 타입만 붙이고 신뢰한다

const coupon =
  request.body as Coupon;

타입 단언은 실제 입력값을 검사하지 않는다.

const input =
  parseCreateCouponRequest(request.body);

외부 데이터는 구조, 타입, 값의 범위를 검증한 뒤 서비스 객체로 변환해야 한다.


요청 객체 전체를 복사한다

const coupon = {
  ...request.body,
  id: crypto.randomUUID(),
};

사용자가 변경하면 안 되는 status, senderId, remainingUses까지 들어올 수 있다.

const coupon = createCoupon({
  title: parseTitle(request.body.title),
  receiverId:
    parseUserId(request.body.receiverId),
  senderId: authenticatedUser.id,
  expiresAt:
    parseExpiresAt(request.body.expiresAt),
});

허용할 필드를 서버가 명시적으로 선택해야 한다.


객체에 계산 가능한 값을 중복 저장한다

const coupon = {
  expiresAt,
  isExpired,
};

두 값이 서로 다른 상태가 될 수 있다.

const isExpired =
  isCouponExpired(coupon, now);

원본 데이터와 계산 결과를 구분해야 한다.


객체의 필드를 어디서나 직접 변경한다

coupon.status = "redeemed";
coupon.remainingUses = 0;

필드 사이의 규칙이 깨질 수 있다.

const redeemedCoupon =
  redeemCoupon(coupon, userId, now);

중요한 상태 변경은 하나의 비즈니스 행동으로 표현하는 편이 안전하다.


객체를 복사하면 중첩 객체도 복사된다고 생각한다

const copiedCoupon = {
  ...coupon,
};

copiedCoupon.design.textColor =
  "#FFFFFF";

바깥 객체만 새로 만들어지고 design 객체는 공유될 수 있다.

const updatedCoupon = {
  ...coupon,
  design: {
    ...coupon.design,
    textColor: "#FFFFFF",
  },
};

실제로 변경할 경로의 객체도 새로 만들어야 한다.


데이터베이스 객체를 그대로 응답한다

return databaseCoupon;

내부 메모, 삭제 시각, 관리용 필드처럼 공개하면 안 되는 정보가 포함될 수 있다.

return toCouponResponse(coupon);

API가 공개할 필드를 별도의 응답 객체로 정의해야 한다.


객체의 구조만 같으면 같은 의미라고 생각한다

function sendCoupon(user: {
  id: string;
  name: string;
}) {
  // ...
}

사용자가 발행자인지 수신자인지 역할이 불분명하다.

interface SendCouponInput {
  senderId: string;
  receiverId: string;
  couponId: string;
}

같은 타입의 값이라도 서비스에서 맡는 역할을 이름으로 표현해야 한다.


빈 객체를 기본값으로 사용한다

const coupon = {} as Coupon;

타입 검사만 피했을 뿐 실제로는 필요한 필드가 없는 객체다.

const coupon: Coupon | null = null;

아직 쿠폰이 없는 상태라면 그 상태를 명시적으로 표현해야 한다.


거대한 객체를 모든 함수에 전달한다

function formatCouponTitle(
  coupon: Coupon
): string {
  return coupon.title.trim();
}

제목만 필요한 함수가 전체 쿠폰 구조에 의존한다.

function formatCouponTitle(
  title: string
): string {
  return title.trim();
}

함수가 실제로 필요한 데이터만 받도록 설계하면 의존 범위가 줄어든다.


핵심 정리

객체는 여러 값을 하나로 묶는 구조다.

하지만 실제 서비스에서는 이 정의만으로 충분하지 않다.

서비스에서 객체는 사용자, 상품, 주문, 쿠폰처럼 현실에 존재하는 개념을 프로그램이 처리할 수 있는 형태로 표현한 모델이다.

객체를 만든다는 것은 다음을 결정하는 일이다.

  • 이 객체가 현실의 무엇을 표현하는가
  • 어떤 값이 하나의 객체에 속하는가
  • 무엇으로 객체를 구분하는가
  • 어떤 필드가 반드시 존재해야 하는가
  • 어떤 상태 조합이 유효한가
  • 누가 어떤 방식으로 값을 변경할 수 있는가
  • 외부 데이터를 어떻게 검증하고 변환할 것인가
  • 데이터베이스, API, 화면에서 각각 어떤 객체가 필요한가

같은 쿠폰을 표현하더라도 모든 계층에서 같은 객체를 사용할 필요는 없다.

const requestInput =
  parseCreateCouponRequest(request.body);

const coupon =
  createCoupon(requestInput);

const databaseRow =
  toCouponDatabaseRow(coupon);

const response =
  toCouponResponse(coupon);

const couponCard =
  toCouponCardViewModel(coupon, now);

각 객체는 서로 다른 책임을 가진다.

요청 객체는 외부 입력을 표현하고, 서비스 객체는 쿠폰의 규칙을 표현하며, 데이터베이스 객체는 저장 구조를 표현한다. 응답 객체는 공개 범위를 정의하고, 화면 객체는 사용자가 볼 정보를 제공한다.

객체의 ID는 데이터의 정체성을 유지한다. 필드 이름은 값의 역할을 설명한다. 타입은 가능한 상태를 제한한다. 생성 함수는 유효한 초기 상태를 보장하고, 비즈니스 행동은 허용되는 변경을 표현한다.

외부에서 받은 객체에 타입을 붙였다고 안전한 데이터가 되는 것은 아니다. 객체를 복사했다고 검증된 데이터가 되는 것도 아니다. 서비스가 받아들일 필드를 선택하고, 타입과 값의 범위를 검사하고, 내부에서 사용할 수 있는 형태로 변환해야 한다.

결국 객체를 설계한다는 것은 다음 질문에 답하는 일이다.

현실의 어떤 대상을 하나의 개념으로 보고, 그 대상의 정체성·상태·관계·행동을 코드 안에서 어떻게 표현할 것인가?

객체는 관련된 값을 묶는 구조가 아니다.

현실의 사용자·상품·쿠폰이 서비스 안에서 어떤 존재이며 어떤 규칙에 따라 변화하는지를 코드로 모델링하는 방법이다.

profile
Vision eXperience Developer

0개의 댓글