
프로그래밍을 처음 배울 때 클래스는 보통 “객체를 만들기 위한 틀”이라고 배운다.
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"),
};
현실의 쿠폰이 가진 정보를 프로그램의 값으로 표현한 것이다.
이 객체만으로도 쿠폰의 현재 상태를 확인할 수 있다.
하지만 현실의 쿠폰에는 데이터만 있는 것이 아니다.
이 행동들을 객체 밖의 여러 함수로 작성할 수 있다.
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;
}
}
이제 객체가 만들어졌다면 최소한 클래스가 정한 기본 조건은 충족했다고 기대할 수 있다.
이처럼 객체가 항상 지켜야 하는 조건을 불변 조건이라고 부를 수 있다.
쿠폰의 불변 조건은 다음과 같을 수 있다.
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
);
저장이 성공해야 서비스의 영구 상태에 반영된다.
따라서 다음 두 작업을 구분해야 한다.
저장이 실패하면 메모리 객체와 데이터베이스의 상태가 달라질 수 있다.
여러 사용자가 동시에 같은 쿠폰을 사용하면 더 큰 문제가 생긴다.
요청 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 문법을 사용해도 클래스가 맡는 역할은 다를 수 있다.
클래스라는 문법만 보고 같은 방식으로 설계해서는 안 된다.
각 클래스가 어떤 생명주기와 책임을 가지는지 먼저 확인해야 한다.
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,
};
}
클래스를 사용하지 않는다고 설계가 부족한 것은 아니다.
클래스는 다음과 같은 상황에서 가치가 커진다.
반대로 다음과 같은 경우에는 객체와 함수가 더 단순할 수 있다.
클래스는 기본값이 아니라 문제에 맞게 선택하는 설계 도구다.
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 요청을 모두 처리하면 책임이 지나치게 넓어진다.
메모리 객체를 변경했다고 데이터베이스까지 자동으로 변경되는 것도 아니다.
여러 요청이 같은 데이터를 수정할 때 발생하는 동시성 문제도 클래스만으로 해결되지 않는다.
그리고 모든 서비스 개념을 클래스로 표현할 필요도 없다.
단순한 데이터 전달에는 객체와 타입이 충분할 수 있다.
상태 변경 없이 계산만 수행한다면 함수가 더 명확할 수 있다.
클래스는 상태와 행동을 함께 두는 것이 서비스의 규칙을 더 잘 보호하고 설명할 때 선택해야 한다.
결국 클래스를 설계한다는 것은 다음 질문에 답하는 일이다.
어떤 상태와 행동이 하나의 책임으로 함께 움직여야 하며, 그 상태가 잘못된 경로로 변경되지 않도록 어떻게 보호할 것인가?
클래스는 객체를 만드는 틀이 아니다.
상태와 행동을 하나의 서비스 책임으로 묶고, 객체가 유효한 상태를 유지하도록 변화의 경계를 설계하는 방법이다.