
프로그래밍을 처음 배울 때 추상화는 보통 “복잡한 내부 구현을 감추고 필요한 기능만 보여주는 것”이라고 배운다.
function sendCoupon() {
// 쿠폰 생성
// 데이터베이스 저장
// 알림 전송
}
호출자는 내부에서 어떤 코드가 실행되는지 모두 알지 않아도 sendCoupon()을 호출할 수 있다.
추상화의 기본적인 개념을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 복잡한 코드를 함수나 클래스 안에 감췄다는 이유만으로 좋은 추상화가 되지는 않는다.
문법적으로 추상화는 세부 구현을 숨기고 간단한 인터페이스를 제공하는 방법이다.
실제 서비스에서는 구현보다 역할과 목적을 중심으로 코드를 바라보게 만들고, 사용하는 코드가 현재 책임에 필요한 정보에만 의존하도록 경계를 정하는 일이다.
실제 서비스에서 추상화란 복잡한 코드를 보이지 않게 만드는 것이 아니라, 세부 구현 중 무엇을 감추고 어떤 의미와 규칙을 반드시 드러낼지 결정하는 일이다.
쿠폰 발행 과정을 다음 함수에 넣었다고 생각해 보자.
async function process(
data: any
) {
const result =
await prisma.coupon.create({
data,
});
await resend.emails.send({
to: data.email,
subject: "New coupon",
});
return result;
}
호출자는 데이터베이스 저장과 이메일 전송의 세부 코드를 볼 필요가 없다.
하지만 process라는 이름만으로는 이 함수의 역할을 알기 어렵다.
코드는 감춰졌지만 의미는 드러나지 않았다.
서비스의 행동을 중심으로 이름과 계약을 표현할 수 있다.
async function issueCoupon(
input: IssueCouponInput
): Promise<Coupon> {
// ...
}
이 선언은 함수가 쿠폰을 발행한다는 사실을 보여준다.
IssueCouponInput은 발행에 필요한 정보를 표현하고, Coupon은 성공했을 때 반환되는 서비스 결과를 표현한다.
좋은 추상화는 코드의 줄 수를 줄이는 것보다 호출자가 올바른 수준의 질문을 하게 만든다.
await issueCoupon(input);
호출자는 “Prisma에 어떤 데이터를 전달해야 하는가?”보다 “쿠폰 발행에 어떤 정보가 필요한가?”를 생각하게 된다.
쿠폰 발행 함수가 저장소를 사용한다고 생각해 보자.
async function issueCoupon(
input: IssueCouponInput,
repository: CouponRepository
) {
const coupon =
createCoupon(input);
return repository.save(coupon);
}
사용 사례는 쿠폰이 PostgreSQL에 저장되는지, 다른 데이터베이스에 저장되는지 알지 않는다.
하지만 데이터베이스의 복잡성이 사라진 것은 아니다.
구체적인 구현 안에 남아 있다.
class PrismaCouponRepository
implements CouponRepository {
async save(
coupon: Coupon
): Promise<Coupon> {
const row =
await prisma.coupon.create({
data: toCouponRow(coupon),
});
return toCoupon(row);
}
}
추상화는 복잡성을 제거하는 마법이 아니다.
복잡성을 책임에 맞는 위치로 이동시키는 방법이다.
각 코드는 자신의 책임에 필요한 복잡성만 이해한다.
flowchart LR
A[HTTP 요청]
--> B[쿠폰 발행]
--> C[저장 역할]
--> D[Prisma 구현]
--> E[Database]
앞 단계는 뒤 단계의 모든 세부 구현을 알 필요가 없다.
그러나 세부 구현은 실제 책임을 가진 영역에 여전히 존재한다.
쿠폰 저장소 계약을 다음과 같이 작성할 수 있다.
interface CouponRepository {
save(
coupon: Coupon
): Promise<Coupon>;
}
이 계약은 데이터베이스 테이블 이름, SQL, Prisma 모델을 감춘다.
그러나 모든 내용을 감추지는 않는다.
Coupon이다.Coupon을 반환한다.추상화는 모든 정보를 감추는 것이 아니다.
호출자가 책임을 수행하기 위해 알아야 할 정보는 드러내야 한다.
다음 함수는 지나치게 많은 의미를 감춘다.
async function execute(
value: unknown
): Promise<unknown> {
// ...
}
호출자는 입력과 출력, 행동, 실패 가능성을 거의 알 수 없다.
반대로 구현 세부 사항을 지나치게 노출할 수도 있다.
interface CouponRepository {
insertIntoCouponsTable(
data:
Prisma.CouponCreateInput
): Promise<PrismaCoupon>;
}
이 계약은 쿠폰 저장이라는 역할보다 특정 데이터베이스 구현 방식을 보여준다.
서비스의 목적을 중심으로 표현하는 편이 낫다.
interface CouponRepository {
save(
coupon: Coupon
): Promise<Coupon>;
}
좋은 추상화는 필요한 정보까지 감추지 않고, 현재 책임과 관계없는 구현만 감춘다.
쿠폰 서비스에서 이메일과 푸시 알림을 모두 전송한다고 생각해 보자.
두 기능을 하나의 범용 함수로 묶을 수 있다.
async function send(
type: string,
target: string,
content: string
) {
if (type === "email") {
// 이메일 전송
}
if (type === "push") {
// 푸시 전송
}
}
코드는 재사용되지만 호출자는 문자열 규칙을 알아야 한다.
await send(
"email",
"chris@example.com",
"쿠폰이 도착했다."
);
이메일과 푸시는 모두 메시지를 전달하지만 필요한 정보와 실패 방식이 다를 수 있다.
interface NotificationService {
notifyCouponIssued(
notification:
CouponIssuedNotification
): Promise<void>;
}
사용 사례는 쿠폰 발행 알림이라는 목적에 의존한다.
await notificationService
.notifyCouponIssued({
recipientId:
coupon.recipientId,
couponId: coupon.id,
});
구현은 이메일, 푸시 또는 두 방식을 함께 사용할 수 있다.
class EmailCouponNotifier
implements NotificationService {
async notifyCouponIssued(
notification:
CouponIssuedNotification
) {
// 이메일 주소 조회
// 템플릿 생성
// 이메일 전송
}
}
추상화는 비슷한 코드를 무조건 합치는 작업이 아니다.
겉으로 비슷해도 목적과 변경 이유가 다르면 분리해야 한다.
반대로 구현 방식이 달라도 서비스에서 같은 역할을 수행한다면 하나의 계약으로 표현할 수 있다.
여러 종류의 쿠폰을 하나의 함수로 처리하기 위해 옵션을 계속 추가한다고 생각해 보자.
function calculateDiscount(
price: number,
type: "fixed" | "percentage",
value: number,
hasMaximum: boolean,
maximumAmount?: number,
appliesToMember?: boolean,
isBirthday?: boolean
) {
// ...
}
호출자는 각 매개변수 조합이 무엇을 의미하는지 알아야 한다.
calculateDiscount(
100,
"percentage",
0.2,
true,
15,
false,
true
);
코드는 하나의 함수로 모였지만 사용 방법은 더 복잡해졌다.
할인이라는 역할을 별도의 계약으로 표현할 수 있다.
interface DiscountPolicy {
calculate(
price: number
): number;
}
class PercentageDiscount
implements DiscountPolicy {
constructor(
private readonly rate: number,
private readonly maximum:
number | null
) {}
calculate(
price: number
): number {
const discount =
price * this.rate;
return this.maximum === null
? discount
: Math.min(
discount,
this.maximum
);
}
}
class FixedDiscount
implements DiscountPolicy {
constructor(
private readonly amount: number
) {}
calculate(
price: number
): number {
return Math.min(
price,
this.amount
);
}
}
호출자는 필요한 정책을 선택한다.
const discountPolicy =
new PercentageDiscount(
0.2,
15
);
const discount =
discountPolicy.calculate(100);
추상화는 매개변수를 많이 받아 모든 경우를 처리하는 범용 함수를 만드는 것과 다르다.
변하는 행동을 의미 있는 역할로 분리하면 각 구현의 규칙을 독립적으로 이해할 수 있다.
다음 함수는 기술적 동작을 이름으로 사용한다.
async function insertCouponRow(
data: CouponRow
) {
// ...
}
저장소 구현 내부에서는 적절한 이름일 수 있다.
하지만 비즈니스 코드에서 이 함수를 직접 사용하면 호출자는 데이터베이스 행을 삽입하는 관점으로 쿠폰 발행을 바라보게 된다.
await insertCouponRow(
toCouponRow(coupon)
);
사용 사례에서는 서비스 목적을 드러내는 표현이 더 자연스럽다.
await couponRepository.save(
coupon
);
같은 실행 결과를 만들더라도 추상화 수준이 다르다.
| 위치 | 적절한 표현 |
|---|---|
| HTTP 계층 | 쿠폰 발행 요청을 처리한다 |
| 사용 사례 | 쿠폰을 발행한다 |
| 도메인 | 유효한 쿠폰을 생성한다 |
| 저장소 | 쿠폰을 저장한다 |
| 데이터베이스 구현 | 쿠폰 행을 삽입한다 |
모든 영역이 같은 수준의 언어를 사용할 필요는 없다.
각 영역은 자신의 책임에 맞는 수준으로 대상을 표현해야 한다.
Prisma에서 조회한 데이터를 그대로 반환한다고 생각해 보자.
async function findCoupon(
couponId: string
): Promise<PrismaCoupon> {
return prisma.coupon.findUniqueOrThrow({
where: {
id: couponId,
},
});
}
이 함수를 사용하는 모든 코드는 Prisma의 모델에 의존한다.
날짜 저장 방식이나 열 이름이 바뀌면 비즈니스 코드와 UI까지 영향을 받을 수 있다.
저장소에서 서비스 모델로 변환할 수 있다.
async function findCoupon(
couponId: string
): Promise<Coupon | null> {
const row =
await prisma.coupon.findUnique({
where: {
id: couponId,
},
});
return row
? toCoupon(row)
: null;
}
다른 영역은 Coupon이라는 서비스 개념을 사용한다.
const coupon =
await repository.findCoupon(
couponId
);
if (
coupon &&
coupon.canRedeem(userId, now)
) {
// ...
}
추상화 경계에서는 데이터가 어느 세계의 개념인지 확인해야 한다.
같은 쿠폰을 표현하더라도 각 형태의 책임은 다르다.
쿠폰 알림을 처음에는 이메일로만 보낸다고 생각해 보자.
await resend.emails.send({
to: recipient.email,
subject: "쿠폰이 도착했다.",
});
나중에는 푸시 알림이나 문자 메시지가 추가될 수 있다.
비즈니스 사용 사례가 특정 이메일 API에 직접 의존하면 전달 방식이 바뀔 때 사용 사례도 수정해야 한다.
서비스에서 안정적으로 유지되는 의미를 찾아볼 수 있다.
쿠폰이 발행되면 수신자에게 알린다.
이 의미를 계약으로 표현한다.
interface CouponNotifier {
notifyIssued(
coupon: Coupon
): Promise<void>;
}
현재는 이메일 구현을 사용할 수 있다.
class EmailCouponNotifier
implements CouponNotifier {
async notifyIssued(
coupon: Coupon
) {
// 이메일 전송
}
}
나중에는 여러 전달 방식을 조합할 수 있다.
class MultiChannelCouponNotifier
implements CouponNotifier {
constructor(
private readonly notifiers:
CouponNotifier[]
) {}
async notifyIssued(
coupon: Coupon
) {
await Promise.all(
this.notifiers.map(
(notifier) =>
notifier.notifyIssued(
coupon
)
)
);
}
}
여기서 안정적인 것은 이메일이라는 기술이 아니라 “쿠폰 발행 사실을 수신자에게 알린다”는 서비스 목적이다.
좋은 추상화는 자주 바뀌는 구현을 변하지 않는 척 감추는 것이 아니다.
상대적으로 안정적인 역할과 변화하기 쉬운 구현을 구분한다.
다음 함수는 외부 알림 서비스를 호출한다.
async function notifyRecipient(
coupon: Coupon
): Promise<void> {
await externalEmailApi.send(
coupon
);
}
함수 이름은 간단하지만 호출자는 여러 실패를 고려해야 한다.
추상화는 호출 방법을 단순하게 만들 수 있지만 현실의 실패 가능성을 없애지는 않는다.
실패가 비즈니스적으로 중요하다면 계약에서 구분할 수 있다.
type NotificationResult =
| {
success: true;
}
| {
success: false;
retryable: boolean;
reason: string;
};
async function notifyRecipient(
coupon: Coupon
): Promise<NotificationResult> {
// ...
}
또는 예외를 사용한다면 어떤 오류가 재시도 가능한지 상위 계층이 판단할 수 있도록 분류해야 한다.
좋은 추상화는 내부 라이브러리의 모든 오류를 그대로 노출하지 않는다.
그렇다고 호출자가 대응해야 할 실패 의미까지 지워서도 안 된다.
catch로 모든 실패를 바꾸면 중요한 차이가 사라진다다음 코드는 모든 오류를 같은 메시지로 바꾼다.
try {
return await issueCoupon(input);
} catch {
throw new Error(
"쿠폰 처리에 실패했다."
);
}
호출자는 실패했다는 사실만 알 수 있다.
하지만 실제 원인은 서로 다를 수 있다.
기술적 세부 오류를 그대로 사용자에게 보여줄 필요는 없다.
그러나 서비스 내부에서는 대응에 필요한 의미를 보존해야 한다.
throw new RecipientNotFoundError(
input.recipientId
);
throw new CouponPolicyError(
"MAXIMUM_USES_EXCEEDED"
);
throw new PersistenceUnavailableError({
cause: error,
});
API 계층은 내부 오류를 적절한 응답으로 변환할 수 있다.
if (
error instanceof
RecipientNotFoundError
) {
return {
status: 404,
message:
"수신자를 찾을 수 없다.",
};
}
추상화는 불필요한 기술 정보는 감추되, 복구와 판단에 필요한 차이는 유지해야 한다.
쿠폰 제목과 사용자 이름을 정리하는 코드가 비슷하다고 생각해 보자.
const couponTitle =
input.title.trim();
const userName =
input.name.trim();
코드가 중복되었으므로 공통 함수로 만들 수 있다.
function normalizeText(
value: string
) {
return value.trim();
}
현재는 잘 동작한다.
하지만 이후 규칙은 다르게 바뀔 수 있다.
처음에 코드 모양이 같았다는 이유로 하나의 규칙으로 묶으면 서로 다른 변경 이유가 결합된다.
서비스 의미를 드러내는 함수로 분리할 수 있다.
function normalizeCouponTitle(
title: string
) {
return title.trim();
}
function normalizeUserName(
name: string
) {
return name.trim();
}
내부 구현이 현재 같더라도 서비스 규칙은 서로 다를 수 있다.
좋은 추상화는 코드의 모양만 보고 공통화하지 않는다.
함께 변경되어야 하는 의미가 실제로 같은지 확인한다.
현재 쿠폰 서비스에 할인 방식이 하나뿐이라고 생각해 보자.
function calculateDiscount(
price: number,
rate: number
) {
return price * rate;
}
미래에 여러 할인 방식이 생길 수 있다는 이유로 처음부터 복잡한 구조를 만들 수 있다.
interface DiscountStrategyFactory {
create(
type: DiscountType,
configuration:
DiscountConfiguration
): DiscountStrategy;
}
아직 다른 구현도 없고 어떤 방향으로 변경될지도 모른다면 이 추상화는 실제 요구사항보다 추측을 표현한다.
추측으로 만든 경계가 미래의 실제 변경 방향과 다르면 오히려 수정할 코드가 늘어난다.
처음에는 구체적이고 명확한 코드로 시작할 수 있다.
function calculatePercentageDiscount(
price: number,
rate: number
) {
return price * rate;
}
새로운 요구가 나타났을 때 공통점과 차이를 확인한다.
실제 변화가 나타난 뒤 안정적인 역할을 발견하면 추상화할 수 있다.
추상화를 늦춘다는 것은 설계를 포기한다는 뜻이 아니다.
아직 알 수 없는 변화를 억지로 예측하지 않고, 현재의 의미를 분명하게 표현하는 선택이다.
추상화는 변경 범위를 줄이고 테스트를 쉽게 만들 수 있다.
하지만 다음과 같은 비용도 만든다.
다음과 같이 한 줄의 실행을 이해하기 위해 여러 파일을 열어야 할 수도 있다.
await couponIssuer.issue(input);
CouponIssuer
→ CouponFactory
→ CouponPolicy
→ CouponRepository
→ CouponMapper
→ DatabaseAdapter
이 구조가 필요한 복잡성을 잘 분리한다면 가치가 있다.
하지만 단순한 쿠폰 한 개를 저장하는 기능에 아무런 변경 요구 없이 계층만 늘어났다면 이해 비용이 더 클 수 있다.
추상화는 많을수록 좋은 것이 아니다.
감춘 복잡성보다 새롭게 만들어낸 간접성이 더 크지 않은지 확인해야 한다.
async function process(
data: any
) {
// ...
}
구현은 감췄지만 함수의 목적과 계약도 함께 사라졌다.
async function issueCoupon(
input: IssueCouponInput
): Promise<Coupon> {
// ...
}
서비스 행동과 필요한 입력, 결과를 드러낸다.
interface CouponRepository {
save(
input:
Prisma.CouponCreateInput
): Promise<PrismaCoupon>;
}
추상화가 있어도 호출자는 Prisma를 알아야 한다.
interface CouponRepository {
save(
coupon: Coupon
): Promise<Coupon>;
}
서비스가 사용하는 개념으로 계약을 표현한다.
calculateDiscount(
price,
true,
false,
0.2,
15
);
호출부에서 각 값의 의미와 조합 규칙을 이해하기 어렵다.
const policy =
new PercentageDiscount(
0.2,
15
);
policy.calculate(price);
변하는 행동을 의미 있는 역할로 표현한다.
function normalizeText(
value: string
) {
return value.trim();
}
서로 다른 서비스 규칙이 하나의 범용 함수에 결합될 수 있다.
normalizeCouponTitle(title);
normalizeUserName(name);
현재 구현보다 변경 이유와 서비스 의미를 기준으로 판단한다.
catch {
throw new Error(
"처리에 실패했다."
);
}
수신자 없음, 정책 위반, 데이터베이스 장애의 차이가 사라진다.
throw new RecipientNotFoundError();
throw new CouponPolicyError();
throw new PersistenceUnavailableError();
호출자가 대응해야 하는 실패 의미는 유지한다.
Factory
→ Strategy
→ Adapter
→ Provider
→ Manager
실제 변경 요구가 없다면 간접성만 늘어날 수 있다.
현재 서비스 행동을 구체적으로 표현하고, 실제로 변화가 생겼을 때 안정적인 역할을 추출한다.
추상화는 복잡한 내부 구현을 감추고 필요한 기능만 보여주는 방법이다.
await issueCoupon(input);
호출자는 쿠폰을 저장하는 SQL이나 이메일 API의 세부 사용법을 모두 알 필요가 없다.
하지만 실제 서비스에서 추상화는 단순히 코드를 보이지 않게 만드는 작업이 아니다.
좋은 추상화는 다음을 구분한다.
비즈니스 코드는 서비스의 목적을 표현할 수 있다.
await couponIssuer.issue(input);
저장소는 쿠폰을 저장하는 역할을 표현한다.
interface CouponRepository {
save(
coupon: Coupon
): Promise<Coupon>;
}
구체적인 데이터베이스 코드는 구현 내부에 둔다.
class PrismaCouponRepository
implements CouponRepository {
// ...
}
추상화는 복잡성을 제거하지 않는다.
각 복잡성을 책임에 맞는 위치로 이동시켜 다른 코드가 불필요한 세부 사항에 의존하지 않게 한다.
그렇다고 모든 코드를 인터페이스와 클래스로 감쌀 필요는 없다.
변경 가능성이 낮고 이해하기 쉬운 내부 함수는 구체적으로 두는 편이 더 명확할 수 있다.
추상화에는 항상 간접성과 이해 비용이 생긴다.
따라서 추상화를 추가하기 전에는 그것이 감추는 복잡성과 새로 만드는 복잡성을 함께 비교해야 한다.
결국 추상화를 설계한다는 것은 다음 질문에 답하는 일이다.
이 코드를 사용하는 사람이 자신의 책임을 수행하기 위해 반드시 알아야 하는 의미는 무엇이며, 몰라도 되는 구현 세부 사항은 무엇인가?
추상화는 복잡한 코드를 감추는 것이 아니다.
구현보다 역할과 목적을 중심으로 코드를 이해하게 만들고, 변화와 책임이 만나는 경계를 설계하는 방법이다.