클래스는 객체를 만드는 틀이 아니다

vx_developer·2026년 9월 6일

개발하다가

목록 보기
15/30
post-thumbnail

프로그래밍을 처음 배울 때 클래스는 보통 “객체를 만들기 위한 틀”이라고 배운다.

class Coupon {
  title: string;
  remainingUses: number;

  constructor(
    title: string,
    remainingUses: number
  ) {
    this.title = title;
    this.remainingUses = remainingUses;
  }
}
const coupon = new Coupon(
  "Coffee Date",
  3
);

클래스와 인스턴스의 관계를 이해하기에는 충분한 설명이다.

Coupon이라는 틀을 만들고, new를 사용해 실제 쿠폰 객체를 생성한다. 생성된 객체는 클래스에 정의된 속성과 메서드를 가진다.

하지만 실제 서비스를 개발하기 시작하면 클래스를 정의한다는 것은 객체를 쉽게 여러 개 만드는 것보다 더 많은 판단을 요구한다.

  • 이 데이터는 클래스가 직접 관리해야 하는 상태인가?
  • 상태를 변경할 수 있는 행동은 무엇인가?
  • 잘못된 상태의 객체가 만들어지는 것을 어떻게 막을 것인가?
  • 외부 코드가 객체의 속성을 직접 변경해도 되는가?
  • 데이터베이스에서 가져온 데이터는 어떻게 객체로 복원할 것인가?
  • 현재 시간이나 저장소 같은 외부 의존성을 클래스가 직접 알아야 하는가?
  • 상속과 조합 중 어떤 방법이 변경에 더 적합한가?
  • 단순한 데이터에도 반드시 클래스를 사용해야 하는가?
  • 여러 요청이 같은 클래스 인스턴스를 공유해도 되는가?

클래스 문법은 속성과 메서드를 가진 객체를 만드는 기능을 제공한다.

그러나 실제 서비스에서 중요한 것은 객체를 만드는 문법이 아니라 어떤 상태와 행동이 하나의 책임으로 함께 움직여야 하는지를 결정하는 일이다.

실제 서비스에서 클래스는 객체를 만드는 틀이 아니라, 서로 밀접한 상태와 행동을 하나의 책임 안에서 관리하고 잘못된 상태 변화를 제한하는 설계 방식이다.


현실의 쿠폰은 데이터만 가진 기록이 아니다

쿠폰 서비스에서 하나의 쿠폰을 다음과 같은 객체로 표현할 수 있다.

const coupon = {
  id: "coupon_123",
  title: "Coffee Date",
  recipientId: "user_chris",
  remainingUses: 3,
  status: "active",
  expiresAt:
    new Date("2026-12-31"),
};

현실의 쿠폰이 가진 정보를 프로그램의 값으로 표현한 것이다.

이 객체만으로도 쿠폰의 현재 상태를 확인할 수 있다.

하지만 현실의 쿠폰에는 데이터만 있는 것이 아니다.

  • 쿠폰을 사용할 수 있는지 판단한다.
  • 쿠폰을 사용하면 남은 횟수가 감소한다.
  • 남은 횟수가 0이 되면 사용 완료 상태가 된다.
  • 만료일이 지나면 사용할 수 없다.
  • 발행자가 쿠폰을 일시 정지할 수 있다.
  • 특정 상태에서만 다시 활성화할 수 있다.

이 행동들을 객체 밖의 여러 함수로 작성할 수 있다.

function canRedeemCoupon(
  coupon: CouponData,
  userId: string,
  now: Date
): boolean {
  // ...
}
function decreaseRemainingUses(
  coupon: CouponData
): CouponData {
  // ...
}
function completeCoupon(
  coupon: CouponData
): CouponData {
  // ...
}

함수 방식 자체에는 문제가 없다.

상태를 명시적으로 전달하고 새로운 결과를 반환하는 함수는 이해하고 테스트하기 쉽다.

그러나 쿠폰 상태를 다루는 규칙이 많아지면 중요한 질문이 생긴다.

쿠폰의 상태를 변경하는 모든 코드가 같은 규칙을 지키고 있는가?

클래스는 이 질문에 답하기 위한 하나의 설계 방법이 될 수 있다.


속성을 공개하면 규칙을 거치지 않고 상태를 바꿀 수 있다

다음 클래스는 쿠폰의 데이터를 모두 공개한다.

class Coupon {
  constructor(
    public remainingUses: number,
    public status:
      | "active"
      | "paused"
      | "completed"
  ) {}
}

외부 코드는 속성을 자유롭게 변경할 수 있다.

const coupon =
  new Coupon(3, "active");

coupon.remainingUses = -10;
coupon.status = "completed";

문법적으로는 가능하지만 서비스의 규칙에는 맞지 않을 수 있다.

남은 횟수가 음수가 되어서는 안 되고, 사용 횟수가 남아 있는데 아무 이유 없이 완료 상태로 바뀌어서도 안 된다.

클래스가 있어도 외부 코드가 모든 상태를 직접 수정할 수 있다면 클래스는 데이터 객체를 감싸는 역할만 한다.

상태를 감추고 허용된 행동을 통해서만 변경하게 만들 수 있다.

type CouponStatus =
  | "active"
  | "paused"
  | "completed";

class Coupon {
  #remainingUses: number;
  #status: CouponStatus;

  constructor(
    remainingUses: number,
    status: CouponStatus
  ) {
    this.#remainingUses =
      remainingUses;
    this.#status = status;
  }

  get remainingUses() {
    return this.#remainingUses;
  }

  get status() {
    return this.#status;
  }

  redeem() {
    if (this.#status !== "active") {
      throw new Error(
        "활성 상태의 쿠폰만 사용할 수 있다."
      );
    }

    if (this.#remainingUses <= 0) {
      throw new Error(
        "남은 사용 횟수가 없다."
      );
    }

    this.#remainingUses -= 1;

    if (this.#remainingUses === 0) {
      this.#status = "completed";
    }
  }
}

이제 외부 코드는 남은 횟수를 직접 음수로 만들 수 없다.

coupon.redeem();

redeem 메서드를 호출하면 클래스가 쿠폰 사용 규칙을 확인하고 상태를 변경한다.

클래스의 의미는 데이터를 감추는 데서 끝나지 않는다.

어떤 상태 변화가 허용되는지 행동을 통해 제한하는 데 의미가 있다.


생성자는 객체가 처음부터 유효한 상태인지 확인해야 한다

상태 변경만 제한해도 충분하지 않다.

처음부터 잘못된 상태의 객체를 만들 수 있다면 이후의 메서드가 올바르게 동작한다고 보장하기 어렵다.

const coupon =
  new Coupon(
    -10,
    "active"
  );

남은 사용 횟수가 음수인 쿠폰이 생성되었다.

생성 과정에서 기본 규칙을 확인할 수 있다.

class Coupon {
  #remainingUses: number;
  #status: CouponStatus;

  constructor(
    remainingUses: number,
    status: CouponStatus
  ) {
    if (
      !Number.isInteger(
        remainingUses
      ) ||
      remainingUses < 0
    ) {
      throw new Error(
        "남은 사용 횟수는 0 이상의 정수여야 한다."
      );
    }

    if (
      remainingUses === 0 &&
      status !== "completed"
    ) {
      throw new Error(
        "사용 횟수가 없으면 완료 상태여야 한다."
      );
    }

    this.#remainingUses =
      remainingUses;
    this.#status = status;
  }
}

이제 객체가 만들어졌다면 최소한 클래스가 정한 기본 조건은 충족했다고 기대할 수 있다.

이처럼 객체가 항상 지켜야 하는 조건을 불변 조건이라고 부를 수 있다.

쿠폰의 불변 조건은 다음과 같을 수 있다.

  • 남은 사용 횟수는 음수가 아니다.
  • 남은 사용 횟수는 정수다.
  • 남은 횟수가 0이면 상태는 completed다.
  • 완료된 쿠폰은 다시 사용할 수 없다.
  • 필수 식별자는 빈 문자열일 수 없다.

클래스는 생성과 변경 경로를 통제함으로써 이러한 조건을 한곳에서 보호할 수 있다.


생성과 복원은 서로 다른 행동이다

새 쿠폰을 만드는 상황을 생각해 보자.

새 쿠폰의 ID, 초기 상태, 생성 시간은 서비스가 결정해야 한다.

class Coupon {
  private constructor(
    readonly id: string,
    readonly title: string,
    readonly issuerId: string,
    readonly recipientId: string,
    private remainingUsesValue:
      number,
    private statusValue:
      CouponStatus,
    readonly createdAt: Date
  ) {}

  static create(
    input: CreateCouponInput,
    now: Date
  ): Coupon {
    return new Coupon(
      crypto.randomUUID(),
      input.title.trim(),
      input.issuerId,
      input.recipientId,
      input.remainingUses,
      "active",
      now
    );
  }
}

호출자는 새 쿠폰의 초기 상태를 임의로 정할 수 없다.

const coupon =
  Coupon.create(
    {
      title: "Coffee Date",
      issuerId: "user_chris",
      recipientId: "user_123",
      remainingUses: 3,
    },
    new Date()
  );

하지만 데이터베이스에서 기존 쿠폰을 읽어올 때는 이미 ID와 상태, 생성 시간이 존재한다.

기존 데이터를 create로 다시 만들면 새로운 ID와 시간이 생성되어 원본의 정체성을 잃을 수 있다.

복원 동작을 별도로 정의할 수 있다.

interface CouponSnapshot {
  id: string;
  title: string;
  issuerId: string;
  recipientId: string;
  remainingUses: number;
  status: CouponStatus;
  createdAt: Date;
}
class Coupon {
  // ...

  static restore(
    snapshot: CouponSnapshot
  ): Coupon {
    return new Coupon(
      snapshot.id,
      snapshot.title,
      snapshot.issuerId,
      snapshot.recipientId,
      snapshot.remainingUses,
      snapshot.status,
      snapshot.createdAt
    );
  }
}

이제 두 동작의 의미가 구분된다.

Coupon.create(input, now);
// 새로운 쿠폰을 만든다.
Coupon.restore(snapshot);
// 저장되어 있던 쿠폰을 복원한다.

생성자는 단순히 속성에 값을 넣는 장소가 아니다.

객체가 어떤 경로로 태어나며 각 경로에서 누가 어떤 값을 결정하는지 표현하는 경계다.


메서드 이름은 상태 변경의 이유를 설명해야 한다

다음 클래스는 범용적인 수정 메서드를 제공한다.

class Coupon {
  update(data: Partial<CouponData>) {
    Object.assign(this, data);
  }
}

호출자는 어떤 속성이든 바꿀 수 있다.

coupon.update({
  remainingUses: 100,
  status: "active",
});

이 코드만 보고는 왜 값이 바뀌었는지 알기 어렵다.

관리자가 보정한 것인지, 쿠폰을 사용한 것인지, 취소를 복구한 것인지 의미가 드러나지 않는다.

서비스의 행동에 이름을 붙일 수 있다.

class Coupon {
  redeem(
    userId: string,
    now: Date
  ) {
    // 사용 규칙 확인 후 횟수 감소
  }

  pause(
    issuerId: string
  ) {
    // 발행자 확인 후 일시 정지
  }

  resume(
    issuerId: string,
    now: Date
  ) {
    // 재활성화 규칙 확인
  }

  extendExpiry(
    issuerId: string,
    newExpiry: Date
  ) {
    // 만료일 연장 규칙 확인
  }
}

이제 호출 코드가 서비스에서 일어난 행동을 설명한다.

coupon.redeem(
  currentUser.id,
  now
);
coupon.pause(
  currentUser.id
);

setStatus("paused")보다 pause(issuerId)가 더 많은 의미를 전달한다.

클래스의 메서드는 속성을 변경하는 기술적 명령이 아니라 서비스에서 허용하는 행동을 표현해야 한다.


클래스가 모든 검증을 책임지는 것은 아니다

쿠폰 생성 API가 다음 요청을 받는다고 생각해 보자.

{
  "title": " Coffee Date ",
  "recipientId": "user_123",
  "remainingUses": "3"
}

HTTP 요청은 신뢰할 수 없는 외부 데이터다.

문자열이어야 할 값이 배열일 수 있고, 숫자처럼 보이는 값이 실제로는 문자열일 수 있다.

이 데이터를 바로 클래스에 전달해서는 안 된다.

const coupon =
  Coupon.create(
    request.body,
    new Date()
  );

TypeScript 타입은 실행 중에 들어오는 데이터를 검증하지 않는다.

외부 입력의 형태와 형식을 먼저 확인해야 한다.

const input =
  parseCreateCouponRequest(
    request.body
  );

그다음 인증 정보와 검증된 값을 조합한다.

const coupon =
  Coupon.create(
    {
      ...input,
      issuerId:
        request.currentUser.id,
    },
    new Date()
  );

검증에는 서로 다른 책임이 있다.

검증 종류예시적절한 위치
입력 형태제목이 문자열인가?요청 검증
형식 변환"3"을 숫자로 바꿀 수 있는가?요청 변환
인증현재 사용자가 누구인가?인증 계층
권한이 사용자가 쿠폰을 발행할 수 있는가?사용 사례 또는 도메인
불변 조건사용 횟수가 음수가 아닌가?클래스
외부 사실수신자가 실제로 존재하는가?사용 사례와 저장소

클래스는 자신이 소유한 상태가 유효하도록 보호해야 한다.

그러나 HTTP 형식, 인증 토큰 해석, 데이터베이스 조회까지 모두 클래스 안에 넣을 필요는 없다.


클래스가 데이터베이스를 직접 조회하면 책임이 넓어진다

쿠폰을 사용하는 메서드 안에서 데이터베이스를 직접 조회할 수 있다.

class Coupon {
  async redeem(
    userId: string
  ) {
    const user =
      await database.user.findUnique({
        where: {
          id: userId,
        },
      });

    const policy =
      await database.couponPolicy
        .findFirst();

    // 규칙 확인과 상태 변경
  }
}

이 클래스는 이제 쿠폰 상태뿐 아니라 데이터베이스 연결, 사용자 저장 구조, 정책 저장 방식까지 알아야 한다.

테스트에서도 실제 데이터베이스를 준비하거나 복잡한 모킹이 필요해진다.

필요한 외부 사실을 사용 사례에서 조회한 뒤 클래스에 전달할 수 있다.

const coupon =
  await couponRepository.findById(
    couponId
  );

const policy =
  await policyRepository.getCurrent();

coupon.redeem({
  userId:
    request.currentUser.id,
  now,
  policy,
});

클래스는 전달받은 정보로 쿠폰 규칙을 판단한다.

class Coupon {
  redeem(
    context: RedemptionContext
  ) {
    // 쿠폰 상태와 전달된 정책으로 판단
  }
}

이 구조에서 역할은 다음과 같이 나뉜다.

  • 사용 사례는 필요한 데이터를 조회하고 작업 순서를 조정한다.
  • 저장소는 데이터베이스와 통신한다.
  • 클래스는 자신의 상태와 상태 변경 규칙을 관리한다.

클래스가 현실의 모든 정보를 스스로 가져오게 하면 편리해 보일 수 있다.

하지만 그만큼 클래스가 더 많은 기술과 책임에 의존한다.


현재 시간도 클래스 밖에서 들어오는 입력이다

쿠폰 만료 여부를 판단할 때 클래스 내부에서 현재 시간을 직접 가져올 수 있다.

class Coupon {
  isExpired(): boolean {
    return (
      this.expiresAt !== null &&
      this.expiresAt <= new Date()
    );
  }
}

호출은 간단하지만 동일한 객체도 실행 시점에 따라 결과가 달라진다.

테스트에서 특정 시간을 재현하기도 어렵다.

판단 기준 시간을 전달하면 숨겨진 입력이 드러난다.

class Coupon {
  isExpired(
    now: Date
  ): boolean {
    return (
      this.expiresAt !== null &&
      this.expiresAt <= now
    );
  }
}
const isExpired =
  coupon.isExpired(
    new Date()
  );

테스트에서는 경계 시간을 정확히 전달할 수 있다.

const expiresAt =
  new Date(
    "2026-12-31T00:00:00Z"
  );

const isExpired =
  coupon.isExpired(expiresAt);

현재 시간뿐 아니라 무작위 ID, 환경 설정, 현재 사용자, 환율과 같은 값도 객체의 결과에 영향을 주는 외부 입력이다.

클래스 안에서 직접 가져올지, 매개변수나 명시적인 의존성으로 전달할지는 예측 가능성과 테스트 가능성을 기준으로 판단해야 한다.


상태를 직접 바꿀 것인가, 새로운 객체를 반환할 것인가

클래스의 메서드는 자신의 상태를 직접 변경할 수 있다.

class Coupon {
  redeem() {
    this.remainingUses -= 1;
  }
}

이 방식은 객체가 시간에 따라 변하는 현실의 대상을 자연스럽게 표현한다.

하지만 호출 전후에 같은 인스턴스의 값이 달라진다는 사실을 놓치면 예상하지 못한 문제가 생길 수 있다.

const sameCoupon = coupon;

coupon.redeem();

console.log(
  sameCoupon.remainingUses
);
// 함께 변경된다.

상태를 직접 변경하지 않고 새로운 객체를 반환하는 방식도 가능하다.

class Coupon {
  redeem(): Coupon {
    if (this.remainingUses <= 0) {
      throw new Error(
        "남은 사용 횟수가 없다."
      );
    }

    return new Coupon(
      this.id,
      this.title,
      this.remainingUses - 1
    );
  }
}
const redeemedCoupon =
  coupon.redeem();

기존 coupon과 다음 상태인 redeemedCoupon이 구분된다.

어느 방식이 항상 더 낫다고 할 수는 없다.

직접 변경하는 방식은 다음 상황에서 자연스러울 수 있다.

  • 하나의 인스턴스가 명확한 소유자에게 관리된다.
  • 상태 변화 자체가 객체의 핵심 행동이다.
  • 변경 시점을 추적하기 쉽다.
  • 프레임워크나 저장 방식이 변경 객체를 자연스럽게 처리한다.

새 객체를 반환하는 방식은 다음 상황에서 유용할 수 있다.

  • 이전 상태를 유지해야 한다.
  • 여러 코드가 같은 객체 참조를 공유한다.
  • 변경을 명시적으로 표현하고 싶다.
  • 상태 비교와 테스트가 중요하다.

중요한 것은 두 방식을 무심코 섞지 않는 것이다.

호출자가 메서드의 결과와 변경 방식을 예측할 수 있어야 한다.


클래스의 상태 변경이 데이터베이스 저장을 의미하지는 않는다

메모리 안의 쿠폰 객체를 변경했다고 생각해 보자.

coupon.redeem({
  userId,
  now,
  policy,
});

이 시점에는 클래스 인스턴스의 상태만 바뀌었다.

데이터베이스의 쿠폰은 아직 이전 상태일 수 있다.

await couponRepository.save(
  coupon
);

저장이 성공해야 서비스의 영구 상태에 반영된다.

따라서 다음 두 작업을 구분해야 한다.

  1. 다음 상태가 무엇인지 계산한다.
  2. 계산된 상태를 영구 저장소에 반영한다.

저장이 실패하면 메모리 객체와 데이터베이스의 상태가 달라질 수 있다.

여러 사용자가 동시에 같은 쿠폰을 사용하면 더 큰 문제가 생긴다.

요청 A가 remainingUses = 1 조회
요청 B가 remainingUses = 1 조회
요청 A가 0으로 변경 후 저장
요청 B도 0으로 변경 후 저장

두 요청이 성공했지만 사용 횟수는 한 번만 감소한 것처럼 저장될 수 있다.

클래스는 한 객체의 상태 전이 규칙을 표현할 수 있다.

하지만 데이터베이스 트랜잭션, 잠금, 버전 검사와 같은 동시성 문제까지 자동으로 해결하지는 않는다.

await couponRepository.save(
  coupon,
  {
    expectedVersion:
      originalVersion,
  }
);

객체 수준의 일관성과 시스템 수준의 일관성은 구분해야 한다.


상속은 공통 속성을 재사용하기 위한 기본 선택이 아니다

여러 종류의 쿠폰이 있다고 생각해 보자.

class Coupon {
  // ...
}

class PercentageCoupon
  extends Coupon {
  // ...
}

class FixedAmountCoupon
  extends Coupon {
  // ...
}

class BirthdayCoupon
  extends Coupon {
  // ...
}

종류별로 클래스를 상속하면 구조가 자연스러워 보일 수 있다.

하지만 서비스 정책은 한 방향의 계층으로만 나뉘지 않을 수 있다.

  • 정액 할인과 정률 할인이 있다.
  • 한 번만 쓰거나 여러 번 쓸 수 있다.
  • 만료일이 있거나 없다.
  • 특정 상품에만 적용되거나 전체 주문에 적용된다.
  • 양도할 수 있거나 양도할 수 없다.

상속으로 모든 조합을 표현하면 클래스 수가 빠르게 늘어날 수 있다.

ReusableBirthdayPercentageCoupon
TransferableSingleUseFixedCoupon
ProductLimitedReusableCoupon

변하는 규칙을 별도의 구성요소로 조합할 수 있다.

interface DiscountPolicy {
  calculate(
    price: number
  ): number;
}
class PercentageDiscount
  implements DiscountPolicy {
  constructor(
    private readonly rate: number
  ) {}

  calculate(
    price: number
  ): number {
    return price * this.rate;
  }
}
class Coupon {
  constructor(
    private readonly discountPolicy:
      DiscountPolicy
  ) {}

  calculateDiscount(
    price: number
  ): number {
    return this.discountPolicy
      .calculate(price);
  }
}

쿠폰은 할인 정책을 상속받지 않고 필요한 정책과 협력한다.

이 방식은 조합이라고 볼 수 있다.

상속이 항상 잘못된 것은 아니다.

하지만 단순히 코드가 비슷하다는 이유만으로 부모와 자식 관계를 만들면 서로 독립적으로 바뀌어야 하는 규칙들이 강하게 결합될 수 있다.

다음 질문으로 판단해야 한다.

  • 하위 타입을 상위 타입과 동일하게 다룰 수 있는가?
  • 부모 클래스의 변경이 모든 자식 클래스에 적절한가?
  • 실제로 안정적인 종류 관계인가?
  • 행동을 조합하는 편이 더 자연스럽지 않은가?

서비스 클래스는 상태를 가져야 하는지부터 확인해야 한다

NestJS와 같은 프레임워크에서는 서비스를 클래스로 작성하는 경우가 많다.

@Injectable()
export class CouponService {
  constructor(
    private readonly repository:
      CouponRepository
  ) {}

  async findById(
    couponId: string
  ) {
    return this.repository
      .findById(couponId);
  }
}

이 클래스의 주요 목적은 쿠폰 한 개의 상태를 표현하는 것이 아니다.

저장소 같은 의존성을 전달받고 여러 작업을 제공하는 서비스 객체다.

이런 클래스는 보통 여러 요청에서 공유될 수 있다.

따라서 요청마다 달라지는 값을 인스턴스 속성에 저장하면 안 된다.

@Injectable()
export class CouponService {
  private currentUserId:
    string | null = null;

  setCurrentUser(
    userId: string
  ) {
    this.currentUserId = userId;
  }
}

동시에 여러 요청을 처리하면 사용자 정보가 섞일 수 있다.

요청별 값은 메서드 매개변수로 전달하는 편이 안전하다.

async getMyCoupons(
  userId: string
) {
  return this.repository
    .findByRecipientId(userId);
}

같은 class 문법을 사용해도 클래스가 맡는 역할은 다를 수 있다.

  • 도메인 클래스는 한 쿠폰의 상태와 행동을 표현한다.
  • 서비스 클래스는 여러 작업과 의존성을 구성한다.
  • 컨트롤러 클래스는 HTTP 요청과 응답을 연결한다.
  • 저장소 클래스는 데이터베이스 접근을 구현한다.

클래스라는 문법만 보고 같은 방식으로 설계해서는 안 된다.

각 클래스가 어떤 생명주기와 책임을 가지는지 먼저 확인해야 한다.


단순한 데이터에는 클래스가 필요하지 않을 수 있다

API 응답을 표현하는 데이터가 있다고 생각해 보자.

interface CouponResponse {
  id: string;
  title: string;
  remainingUses: number;
}

이 데이터는 클라이언트에 전달할 값을 표현한다.

자신의 상태를 변경하거나 불변 조건을 보호하는 행동이 필요하지 않다.

굳이 클래스로 만들 필요는 없을 수 있다.

class CouponResponse {
  constructor(
    public id: string,
    public title: string,
    public remainingUses: number
  ) {}
}

클래스를 사용해도 동작하지만 추가되는 의미가 거의 없다.

일반 객체와 함수만으로도 쿠폰 규칙을 명확하게 표현할 수 있다.

interface Coupon {
  id: string;
  remainingUses: number;
}
function redeemCoupon(
  coupon: Coupon
): Coupon {
  return {
    ...coupon,
    remainingUses:
      coupon.remainingUses - 1,
  };
}

클래스를 사용하지 않는다고 설계가 부족한 것은 아니다.

클래스는 다음과 같은 상황에서 가치가 커진다.

  • 상태와 행동이 밀접하게 함께 움직인다.
  • 객체가 항상 지켜야 하는 조건이 있다.
  • 상태 변경 경로를 제한해야 한다.
  • 여러 행동이 같은 내부 상태를 사용한다.
  • 서비스 개념의 생명주기와 정체성이 중요하다.

반대로 다음과 같은 경우에는 객체와 함수가 더 단순할 수 있다.

  • 데이터를 전달하기만 한다.
  • 한 번 계산하고 버리는 값이다.
  • 행동 없이 구조만 필요하다.
  • 상태 변경이 없다.
  • 직렬화와 역직렬화가 주된 목적이다.

클래스는 기본값이 아니라 문제에 맞게 선택하는 설계 도구다.


클래스를 사용하기 전에 물어봐야 할 질문

책임과 모델링

  1. 이 클래스는 현실의 어떤 서비스 개념을 표현하는가?
  2. 클래스 이름만 읽어도 책임을 예상할 수 있는가?
  3. 상태와 행동이 실제로 함께 변경되는가?
  4. 서로 다른 변경 이유를 가진 기능이 한 클래스에 섞여 있지는 않은가?
  5. 단순한 객체와 함수로 표현하는 편이 더 명확하지 않은가?

상태와 불변 조건

  1. 객체가 항상 지켜야 하는 조건은 무엇인가?
  2. 잘못된 상태의 객체를 생성할 수 있는가?
  3. 외부 코드가 속성을 직접 변경할 수 있는가?
  4. 모든 상태 변경이 의미 있는 메서드를 거치는가?
  5. 메서드 실행 후에도 불변 조건이 유지되는가?

생성과 복원

  1. 새 객체를 만들 때 누가 ID와 초기 상태를 결정하는가?
  2. 데이터베이스에서 기존 객체를 복원하는 경로가 필요한가?
  3. 생성과 복원이 같은 생성자를 사용해도 의미가 명확한가?
  4. 외부 입력이 검증 없이 생성자에 들어오지는 않는가?
  5. 객체를 JSON으로 변환하고 다시 복원할 방법이 있는가?

의존성과 외부 세계

  1. 클래스가 데이터베이스나 HTTP 요청을 직접 알고 있는가?
  2. 현재 시간이나 인증 사용자가 숨겨진 의존성으로 들어가 있는가?
  3. 외부 데이터를 사용 사례에서 조회한 뒤 전달할 수 있는가?
  4. 클래스를 테스트하기 위해 실제 외부 서비스가 필요한가?
  5. 클래스가 자신의 책임보다 많은 정보를 알고 있지는 않은가?

변경 방식

  1. 메서드가 현재 객체를 직접 변경하는가?
  2. 새로운 객체를 반환하는가?
  3. 호출자가 변경 방식을 쉽게 예측할 수 있는가?
  4. 같은 인스턴스를 여러 코드가 공유하고 있지는 않은가?
  5. 이전 상태를 보존해야 하는가?

상속과 조합

  1. 실제로 안정적인 부모와 자식 관계가 존재하는가?
  2. 코드 중복만을 줄이기 위해 상속하고 있지는 않은가?
  3. 부모 클래스 변경이 모든 하위 클래스에 적절한가?
  4. 변경 가능한 정책을 별도의 객체로 조합할 수 있는가?
  5. 상속 계층이 서비스 규칙의 조합을 제한하지 않는가?

저장과 동시성

  1. 메모리 객체의 변경이 언제 데이터베이스에 저장되는가?
  2. 저장 실패 시 객체 상태를 어떻게 처리하는가?
  3. 여러 요청이 같은 데이터를 동시에 변경할 수 있는가?
  4. 버전 검사나 트랜잭션이 필요한가?
  5. 클래스가 시스템 수준의 일관성까지 보장한다고 오해하고 있지는 않은가?

흔히 하는 실수

클래스의 모든 속성을 공개한다

class Coupon {
  constructor(
    public remainingUses: number,
    public status: CouponStatus
  ) {}
}

외부 코드가 비즈니스 규칙을 우회해 상태를 변경할 수 있다.

class Coupon {
  #remainingUses: number;

  redeem() {
    // 규칙을 확인한 뒤 변경한다.
  }
}

변경이 필요한 상태는 의미 있는 행동을 통해 관리한다.


범용적인 업데이트 메서드를 만든다

coupon.update({
  status: "completed",
});

왜 상태가 변경되었는지 알기 어렵고 허용되지 않은 변경도 가능하다.

coupon.redeem(context);
coupon.pause(issuerId);

서비스에서 실제로 일어난 행동을 메서드 이름으로 표현한다.


생성자에서 외부 입력을 그대로 신뢰한다

const coupon =
  new Coupon(
    request.body
  );

HTTP 요청은 클래스가 기대하는 형태와 다를 수 있다.

const input =
  parseCreateCouponRequest(
    request.body
  );

const coupon =
  Coupon.create(input, now);

외부 입력을 변환하고 검증한 뒤 클래스의 계약으로 전달한다.


클래스 안에서 모든 외부 데이터를 조회한다

class Coupon {
  async redeem() {
    const user =
      await database.user
        .findFirst();

    const policy =
      await fetchPolicy();
  }
}

클래스가 데이터베이스와 외부 API까지 책임지게 된다.

const policy =
  await policyRepository.getCurrent();

coupon.redeem({
  userId,
  now,
  policy,
});

외부 사실은 사용 사례에서 준비하고 클래스는 자신의 규칙을 적용한다.


상속으로 모든 차이를 표현한다

class TransferableReusableCoupon
  extends ReusableCoupon {
  // ...
}

정책 조합이 늘어날수록 상속 구조가 복잡해질 수 있다.

new Coupon({
  usagePolicy,
  transferPolicy,
  discountPolicy,
});

독립적으로 변하는 행동은 조합할 수 있는지 검토한다.


프레임워크 서비스에 요청 상태를 저장한다

@Injectable()
class CouponService {
  currentUserId: string;
}

여러 요청이 같은 인스턴스를 공유하면 사용자 데이터가 섞일 수 있다.

getMyCoupons(
  userId: string
) {
  // ...
}

요청별 데이터는 매개변수나 요청 전용 컨텍스트로 전달한다.


모든 데이터 구조를 클래스로 만든다

class CouponResponse {
  // 데이터만 존재한다.
}

행동이나 불변 조건이 없다면 클래스가 추가하는 의미가 적을 수 있다.

interface CouponResponse {
  id: string;
  title: string;
}

단순한 데이터는 객체나 타입으로 충분할 수 있다.


핵심 정리

클래스는 속성과 메서드를 정의하고 객체를 만드는 문법을 제공한다.

class Coupon {
  constructor(
    readonly title: string
  ) {}
}
const coupon =
  new Coupon("Coffee Date");

하지만 실제 서비스에서 클래스의 핵심은 객체를 여러 개 만드는 데 있지 않다.

클래스는 하나의 서비스 개념에 속한 상태와 행동을 함께 관리하는 방법이다.

class Coupon {
  #remainingUses: number;
  #status: CouponStatus;

  redeem(
    context: RedemptionContext
  ) {
    // 사용 가능 여부를 확인한다.
    // 남은 횟수를 감소시킨다.
    // 필요한 경우 완료 상태로 전환한다.
  }
}

좋은 클래스는 다음 내용을 분명하게 만든다.

  • 어떤 상태를 소유하는가?
  • 어떤 행동을 제공하는가?
  • 어떤 상태 변화가 허용되는가?
  • 객체가 항상 지켜야 하는 조건은 무엇인가?
  • 외부 코드가 알 필요 없는 구현은 무엇인가?
  • 어떤 정보는 클래스 밖에서 전달되어야 하는가?
  • 객체가 언제 생성되고 어떻게 복원되는가?
  • 변경된 상태가 언제 저장되는가?

클래스가 있다고 해서 데이터가 자동으로 안전해지는 것은 아니다.

속성을 모두 공개하면 외부 코드가 규칙을 우회할 수 있다.

클래스 안에서 데이터베이스와 HTTP 요청을 모두 처리하면 책임이 지나치게 넓어진다.

메모리 객체를 변경했다고 데이터베이스까지 자동으로 변경되는 것도 아니다.

여러 요청이 같은 데이터를 수정할 때 발생하는 동시성 문제도 클래스만으로 해결되지 않는다.

그리고 모든 서비스 개념을 클래스로 표현할 필요도 없다.

단순한 데이터 전달에는 객체와 타입이 충분할 수 있다.

상태 변경 없이 계산만 수행한다면 함수가 더 명확할 수 있다.

클래스는 상태와 행동을 함께 두는 것이 서비스의 규칙을 더 잘 보호하고 설명할 때 선택해야 한다.

결국 클래스를 설계한다는 것은 다음 질문에 답하는 일이다.

어떤 상태와 행동이 하나의 책임으로 함께 움직여야 하며, 그 상태가 잘못된 경로로 변경되지 않도록 어떻게 보호할 것인가?

클래스는 객체를 만드는 틀이 아니다.

상태와 행동을 하나의 서비스 책임으로 묶고, 객체가 유효한 상태를 유지하도록 변화의 경계를 설계하는 방법이다.

profile
Vision eXperience Developer

0개의 댓글