
프로그래밍을 처음 배울 때 모듈은 보통 “코드를 여러 파일로 나누고 필요한 기능을 가져와 사용하는 방법”이라고 배운다.
// coupon.js
export function createCoupon() {
// ...
}
// app.js
import {
createCoupon,
} from "./coupon.js";
모듈의 기본 문법을 이해하기에는 충분한 설명이다.
하나의 파일에 모든 코드를 작성하는 대신 기능별로 파일을 나누고, export와 import를 사용해 코드를 연결할 수 있다.
하지만 실제 서비스를 개발하기 시작하면 모듈을 만든다는 것은 단순히 긴 파일을 여러 개로 분리하는 것보다 훨씬 많은 판단을 요구한다.
문법적으로 모듈은 코드를 가져오고 내보내는 단위다.
실제 서비스에서 모듈은 서비스의 책임을 나누고, 변경이 영향을 미치는 범위를 제한하며, 다른 코드에 제공할 계약을 정의하는 경계다.
실제 서비스에서 모듈을 설계한다는 것은 코드를 여러 파일로 나누는 것이 아니라, 함께 변경되는 책임을 모으고 서로 다른 변경 이유를 분리하는 일이다.
쿠폰 서비스를 하나의 파일에 작성했다고 생각해 보자.
export async function createCoupon(
request: Request
) {
const title =
String(request.body.title).trim();
if (title.length === 0) {
throw new Error(
"쿠폰 제목이 필요하다."
);
}
const coupon = {
id: crypto.randomUUID(),
title,
issuerId:
request.currentUser.id,
status: "active",
};
await database.coupon.create({
data: coupon,
});
await emailService.send({
to: request.body.recipientEmail,
subject: "새 쿠폰이 도착했다.",
});
return {
status: 201,
body: coupon,
};
}
이 함수는 쿠폰을 생성하지만 동시에 여러 책임을 처리한다.
파일이 길어지면 다음과 같이 나눌 수 있다.
create-coupon.ts
validate-coupon.ts
save-coupon.ts
send-email.ts
coupon-response.ts
파일 수는 늘어났지만 이것만으로 서비스의 책임이 명확해졌다고 할 수는 없다.
create-coupon.ts가 다른 모든 파일의 세부 구현을 직접 알고 있다면 변경의 영향은 여전히 넓게 퍼질 수 있다.
import {
validateCouponTitle,
} from "./validate-coupon";
import {
prismaCreateCoupon,
} from "./save-coupon";
import {
sendCouponEmailWithResend,
} from "./send-email";
import {
createHttp201Response,
} from "./coupon-response";
이 코드는 입력 검증 방식, 데이터베이스 기술, 이메일 서비스, HTTP 응답 형식에 모두 직접 의존한다.
파일은 분리되었지만 하나의 작업이 어떤 책임으로 구성되는지에 대한 경계는 충분히 드러나지 않는다.
모듈을 설계할 때 중요한 질문은 “몇 개의 파일로 나눌 것인가?”가 아니다.
어떤 코드들이 같은 이유로 함께 변경되는가?
쿠폰 사용 규칙을 살펴보자.
쿠폰을 사용하려면 다음 조건을 만족해야 한다고 가정한다.
active여야 한다.이 규칙을 API 코드에 직접 작성할 수 있다.
if (
coupon.recipientId !==
request.currentUser.id ||
coupon.status !== "active" ||
coupon.remainingUses <= 0 ||
(
coupon.expiresAt !== null &&
coupon.expiresAt <= new Date()
)
) {
throw new Error(
"쿠폰을 사용할 수 없다."
);
}
같은 규칙이 화면에도 필요할 수 있다.
const showRedeemButton =
coupon.recipientId ===
currentUser.id &&
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt >
new Date()
);
관리자 기능이나 알림 작업에서도 같은 판단이 반복될 수 있다.
규칙이 바뀌어 일시 정지된 쿠폰도 발행자의 승인이 있으면 사용할 수 있게 된다면 여러 파일을 함께 수정해야 한다.
이는 쿠폰 사용 규칙이라는 하나의 책임이 여러 위치에 흩어져 있다는 신호다.
규칙을 쿠폰 도메인 모듈에 모을 수 있다.
export interface RedemptionContext {
userId: string;
now: Date;
approvedByIssuer: boolean;
}
export function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
): boolean {
const isRecipient =
coupon.recipientId ===
context.userId;
const hasRemainingUses =
coupon.remainingUses > 0;
const isWithinExpiry =
coupon.expiresAt === null ||
coupon.expiresAt > context.now;
const isAllowedStatus =
coupon.status === "active" ||
(
coupon.status === "paused" &&
context.approvedByIssuer
);
return (
isRecipient &&
hasRemainingUses &&
isWithinExpiry &&
isAllowedStatus
);
}
쿠폰 사용 가능 여부를 결정하는 규칙이 한곳에 모였다.
규칙이 변경되면 이 책임을 소유한 모듈을 중심으로 수정할 수 있다.
좋은 모듈은 비슷해 보이는 코드를 모은 파일이 아니다.
같은 서비스 개념과 같은 변경 이유를 가진 코드를 함께 관리하는 단위다.
쿠폰 생성과 이메일 전송은 하나의 사용자 행동에서 연속으로 일어날 수 있다.
하지만 두 기능이 변경되는 이유는 다르다.
쿠폰 생성 규칙은 다음 이유로 변경될 수 있다.
이메일 전송 코드는 다음 이유로 변경될 수 있다.
두 기능을 하나의 모듈에 결합하면 이메일 변경이 쿠폰 생성 코드에 영향을 줄 수 있다.
export async function createCoupon(
input: CreateCouponInput
) {
const coupon =
await database.coupon.create({
data: input,
});
await resend.emails.send({
to: input.recipientEmail,
subject: "새 쿠폰",
});
return coupon;
}
이 함수는 쿠폰을 생성하는 비즈니스 행동과 특정 이메일 서비스의 사용법을 함께 알고 있다.
역할을 분리하면 각 모듈의 변경 이유가 명확해진다.
export async function createCoupon(
input: CreateCouponInput,
repository: CouponRepository
): Promise<Coupon> {
const coupon =
buildCoupon(input);
return repository.save(coupon);
}
export async function notifyRecipient(
coupon: Coupon,
notificationService:
NotificationService
): Promise<void> {
await notificationService.send({
recipientId:
coupon.recipientId,
message:
`${coupon.title} 쿠폰이 도착했다.`,
});
}
그리고 사용 사례를 조정하는 모듈에서 실행 순서를 관리한다.
export async function issueCoupon(
input: IssueCouponInput,
dependencies: IssueCouponDependencies
): Promise<Coupon> {
const coupon =
await createCoupon(
input,
dependencies.couponRepository
);
await notifyRecipient(
coupon,
dependencies.notificationService
);
return coupon;
}
쿠폰 생성과 알림 전송은 하나의 사용 사례에서 협력하지만 서로 다른 책임으로 유지된다.
모듈을 나눈다는 것은 모든 기능을 서로 단절시키는 일이 아니다.
서로 협력하되 각자 다른 변경 이유를 가질 수 있도록 경계를 만드는 일이다.
쿠폰을 생성하는 모듈에 여러 함수가 있다고 생각해 보자.
export function trimCouponTitle() {
// ...
}
export function validateCouponTitle() {
// ...
}
export function generateCouponId() {
// ...
}
export function buildCoupon() {
// ...
}
export function createCoupon() {
// ...
}
모든 함수를 export하면 다른 모듈이 내부 단계에 직접 의존할 수 있다.
import {
generateCouponId,
buildCoupon,
} from "./coupon";
나중에 ID 생성을 데이터베이스에 맡기거나 쿠폰 생성 순서를 변경하려 해도 외부 코드가 기존 내부 함수에 의존하고 있어 변경하기 어려워진다.
외부에서 필요한 기능만 공개하면 내부 구현을 자유롭게 변경할 수 있다.
function normalizeTitle(
title: string
): string {
return title.trim();
}
function buildCoupon(
input: CreateCouponInput
): Coupon {
return {
id: crypto.randomUUID(),
title:
normalizeTitle(input.title),
issuerId: input.issuerId,
recipientId:
input.recipientId,
remainingUses:
input.remainingUses,
status: "active",
};
}
export function createCoupon(
input: CreateCouponInput
): Coupon {
validateCreateCouponInput(input);
return buildCoupon(input);
}
다른 모듈은 createCoupon만 사용한다.
import {
createCoupon,
} from "./coupon";
외부 모듈이 알아야 하는 것은 쿠폰을 생성하는 방법이다.
제목을 어떤 순서로 정리하는지, ID를 어떻게 생성하는지, 내부에서 어떤 함수를 호출하는지는 알 필요가 없다.
모듈의 export 목록은 단순한 파일 문법이 아니다.
이 모듈이 다른 코드에 어떤 기능을 약속할 것인지 결정하는 공개 계약이다.
한 번 공개된 기능은 다른 코드가 의존하기 시작한다.
따라서 “다른 곳에서도 사용할 수 있을 것 같다”는 이유만으로 내부 함수를 모두 공개해서는 안 된다.
쿠폰이 데이터베이스에는 다음과 같이 저장된다고 생각해 보자.
interface CouponRow {
coupon_id: string;
coupon_title: string;
issuer_user_id: string;
recipient_user_id: string;
remaining_uses: number;
expires_at: string | null;
}
데이터베이스 구조를 서비스 전체에 그대로 전달하면 여러 모듈이 저장 방식에 의존한다.
function canRedeemCoupon(
coupon: CouponRow
) {
return (
coupon.remaining_uses > 0
);
}
화면에서도 데이터베이스 열 이름을 사용하게 될 수 있다.
<span>
{coupon.coupon_title}
</span>
데이터베이스 열 이름이나 날짜 저장 형식이 바뀌면 비즈니스 규칙과 UI까지 수정해야 한다.
저장 모듈에서 데이터베이스 구조를 서비스 모델로 변환할 수 있다.
function toCoupon(
row: CouponRow
): Coupon {
return {
id: row.coupon_id,
title: row.coupon_title,
issuerId:
row.issuer_user_id,
recipientId:
row.recipient_user_id,
remainingUses:
row.remaining_uses,
expiresAt:
row.expires_at === null
? null
: new Date(
row.expires_at
),
};
}
export async function findCouponById(
couponId: string
): Promise<Coupon | null> {
const row =
await database.coupon.findUnique({
where: {
coupon_id: couponId,
},
});
return row ? toCoupon(row) : null;
}
데이터베이스의 구조는 저장 모듈 내부에 남고, 다른 모듈은 Coupon이라는 서비스 개념을 사용한다.
const coupon =
await findCouponById(couponId);
if (coupon) {
canRedeemCoupon(
coupon,
context
);
}
모듈 경계에서는 내부 구현에 속한 데이터와 외부에 전달할 데이터를 구분해야 한다.
모듈이 자신의 저장 구조를 감추지 못하면 데이터베이스의 작은 변경도 서비스 전체의 변경으로 확산될 수 있다.
쿠폰 생성 요청 하나가 서비스 안에서 이동하는 흐름을 살펴보자.
flowchart LR
A[HTTP 모듈]
--> B[입력 검증]
--> C[쿠폰 사용 사례]
--> D[쿠폰 도메인]
--> E[저장 모듈]
각 모듈은 같은 데이터를 서로 다른 관점에서 다룬다.
HTTP 모듈은 외부 요청을 읽는다.
const requestBody: unknown =
request.body;
입력 검증 모듈은 신뢰할 수 없는 데이터를 서비스가 사용할 형태로 변환한다.
const input =
parseCreateCouponRequest(
requestBody
);
사용 사례 모듈은 인증 정보와 검증된 입력을 조합한다.
const coupon =
await issueCoupon({
...input,
issuerId:
request.currentUser.id,
});
쿠폰 도메인 모듈은 초기 상태와 비즈니스 규칙을 적용한다.
const newCoupon =
createCoupon(input);
저장 모듈은 서비스 객체를 데이터베이스 형식으로 변환해 저장한다.
await couponRepository.save(
newCoupon
);
하나의 값이 이동하지만 각 모듈의 책임은 다르다.
좋은 모듈 경계는 단순히 데이터를 전달하는 통로가 아니다.
데이터가 어느 단계까지 처리되었고 다음 모듈이 무엇을 신뢰할 수 있는지를 보여준다.
쿠폰 사용 규칙이 데이터베이스 쿼리 안에 들어 있다고 생각해 보자.
const coupon =
await prisma.coupon.findFirst({
where: {
id: couponId,
recipientId: userId,
status: "active",
remainingUses: {
gt: 0,
},
OR: [
{
expiresAt: null,
},
{
expiresAt: {
gt: new Date(),
},
},
],
},
});
조회와 판단을 한 번에 처리할 수 있어 간단해 보인다.
하지만 쿠폰 사용 규칙이 Prisma의 쿼리 형식 안에 들어갔다.
다른 데이터베이스를 사용하거나 같은 규칙을 저장 전에 검사해야 한다면 규칙을 재사용하기 어렵다.
비즈니스 판단을 별도의 모듈에 둘 수 있다.
export function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
): boolean {
return (
coupon.recipientId ===
context.userId &&
coupon.status === "active" &&
coupon.remainingUses > 0 &&
(
coupon.expiresAt === null ||
coupon.expiresAt >
context.now
)
);
}
저장 모듈은 쿠폰을 조회하는 역할에 집중한다.
const coupon =
await couponRepository.findById(
couponId
);
사용 사례 모듈은 조회된 쿠폰에 규칙을 적용한다.
if (
!canRedeemCoupon(
coupon,
context
)
) {
throw new Error(
"쿠폰을 사용할 수 없다."
);
}
이 구조에서는 데이터베이스가 바뀌어도 쿠폰 사용 규칙을 유지할 수 있다.
반대로 쿠폰 정책이 바뀌어도 모든 저장 코드를 함께 수정할 필요가 줄어든다.
기술은 비즈니스 기능을 구현하기 위해 필요하지만, 핵심 규칙이 특정 기술의 표현 방식에 묶일 필요는 없다.
프로젝트가 커지면 다음과 같은 폴더가 만들어지기 쉽다.
utils/
date.ts
validation.ts
formatter.ts
api.ts
coupon.ts
user.ts
string.ts
constants.ts
처음에는 여러 곳에서 사용할 코드를 모으기 위해 만든다.
하지만 시간이 지나면 어디에 둘지 애매한 코드가 모두 utils에 들어간다.
export function calculateCouponExpiry() {
// ...
}
export function canUserRedeemCoupon() {
// ...
}
export function createCouponMessage() {
// ...
}
이 함수들은 일반적인 도구가 아니다.
쿠폰 서비스의 정책과 의미를 담고 있다.
coupon/
create-coupon.ts
redeem-coupon.ts
coupon-policy.ts
coupon-message.ts
코드를 쿠폰 모듈로 이동하면 어떤 서비스 개념에 속하는지 분명해진다.
공통 모듈에 적합한 코드는 특정 비즈니스 개념을 몰라도 사용할 수 있어야 한다.
export function clamp(
value: number,
min: number,
max: number
): number {
return Math.min(
Math.max(value, min),
max
);
}
반면 다음 함수는 쿠폰 정책을 알고 있다.
export function limitCouponUses(
remainingUses: number
): number {
return Math.min(
remainingUses,
MAX_COUPON_USES
);
}
여러 곳에서 사용된다는 이유만으로 공통 모듈에 넣을 필요는 없다.
여러 곳에서 사용하는 쿠폰 규칙은 여전히 쿠폰 모듈의 책임이다.
재사용 횟수보다 코드가 어떤 개념과 변경 이유에 속하는지가 모듈 위치를 결정해야 한다.
쿠폰 모듈이 사용자 모듈을 가져온다고 생각해 보자.
// coupon.ts
import {
findUserById,
} from "./user";
사용자 모듈도 쿠폰 정보를 가져온다.
// user.ts
import {
findCouponsByUserId,
} from "./coupon";
두 모듈이 서로를 가져오는 순환 의존성이 생긴다.
Coupon Module
↓
User Module
↓
Coupon Module
실행 환경에 따라 초기화되지 않은 값이 생기거나 모듈을 독립적으로 테스트하기 어려워질 수 있다.
더 중요한 문제는 두 모듈의 책임이 서로 얽혀 있다는 점이다.
“사용자의 쿠폰 목록을 조회한다”는 작업은 사용자 자체의 규칙이라기보다 여러 모듈을 조정하는 사용 사례일 수 있다.
export async function getUserCoupons(
userId: string,
dependencies:
GetUserCouponsDependencies
) {
const user =
await dependencies.userRepository
.findById(userId);
if (!user) {
throw new Error(
"사용자를 찾을 수 없다."
);
}
return dependencies.couponRepository
.findByRecipientId(userId);
}
이제 사용자 모듈과 쿠폰 모듈이 서로를 직접 가져오지 않는다.
사용 사례 모듈이 필요한 기능을 조합한다.
순환 의존성을 발견했다고 무조건 파일 위치만 바꾸면 문제가 해결되는 것은 아니다.
먼저 다음을 확인해야 한다.
순환 의존성은 import문의 문제가 아니라 책임과 의존 방향을 다시 살펴보라는 신호일 수 있다.
다음과 같이 기술 종류만으로 프로젝트를 나눌 수 있다.
controllers/
services/
repositories/
models/
utils/
작은 프로젝트에서는 이해하기 쉬운 구조다.
하지만 쿠폰 기능을 변경하려면 여러 폴더를 오가야 한다.
controllers/coupon-controller.ts
services/coupon-service.ts
repositories/coupon-repository.ts
models/coupon.ts
utils/coupon-validator.ts
기능을 중심으로 가까이 배치할 수도 있다.
coupon/
coupon-controller.ts
create-coupon.ts
redeem-coupon.ts
coupon-policy.ts
coupon-repository.ts
coupon.ts
이 구조에서는 쿠폰이라는 서비스 개념과 관련된 변경 범위를 찾기 쉽다.
그렇다고 모든 프로젝트가 반드시 기능 중심 폴더 구조를 사용해야 한다는 뜻은 아니다.
규모가 작거나 기술 계층이 명확한 서비스에서는 계층별 구성이 더 단순할 수 있다.
중요한 것은 폴더 구조 자체가 아니라 다음 질문이다.
쿠폰 기능을 변경할 때 함께 살펴봐야 하는 코드가 어디에 모여 있는가?
모듈의 이름과 위치는 프로젝트가 어떤 서비스 개념으로 구성되어 있는지를 보여줘야 한다.
모듈이 너무 크면 여러 책임이 섞일 수 있다.
export class CouponService {
createCoupon() {}
redeemCoupon() {}
sendEmail() {}
uploadImage() {}
calculateAnalytics() {}
exportCsv() {}
deleteUser() {}
}
이 모듈은 쿠폰 생성, 사용, 알림, 이미지, 분석, 파일 내보내기, 사용자 삭제까지 처리한다.
이름은 CouponService지만 변경 이유가 지나치게 많다.
반대로 모듈을 너무 작게 나누면 하나의 동작을 이해하기 위해 수많은 파일을 이동해야 할 수 있다.
trim-title.ts
check-title-length.ts
create-coupon-id.ts
create-coupon-status.ts
create-coupon-date.ts
merge-coupon-fields.ts
각 함수가 파일 하나를 가져야 하는 것은 아니다.
다음 기준으로 판단할 수 있다.
모듈의 적절한 크기는 정해진 파일 수나 코드 줄 수로 결정되지 않는다.
하나의 책임을 이해하고 변경하기에 적절한 경계를 가졌는지로 판단해야 한다.
쿠폰 사용 규칙이 HTTP, 데이터베이스, 이메일 코드와 섞여 있다면 규칙 하나를 테스트하기 위해 많은 준비가 필요하다.
await request(app)
.post("/coupons/123/redeem")
.set("Authorization", token)
.expect(200);
이 테스트도 필요하지만 만료 규칙 하나를 확인하기에는 범위가 넓다.
비즈니스 규칙이 독립된 모듈에 있다면 직접 테스트할 수 있다.
it(
"만료된 쿠폰은 사용할 수 없다",
() => {
const coupon = {
...activeCoupon,
expiresAt:
new Date(
"2026-09-01T00:00:00Z"
),
};
const context = {
userId:
coupon.recipientId,
now:
new Date(
"2026-09-02T00:00:00Z"
),
approvedByIssuer: false,
};
expect(
canRedeemCoupon(
coupon,
context
)
).toBe(false);
}
);
이 테스트는 서버나 데이터베이스를 실행하지 않아도 된다.
저장 모듈은 데이터베이스 변환과 쿼리를 중심으로 테스트할 수 있다.
HTTP 모듈은 요청 검증과 응답 변환을 중심으로 테스트할 수 있다.
모듈의 책임이 명확하면 각 테스트가 무엇을 검증하는지도 명확해진다.
테스트하기 어려운 모듈은 너무 많은 외부 기술과 책임에 연결되어 있을 가능성이 있다.
모듈 내부에 선언된 값은 외부에서 직접 보이지 않더라도 여러 호출이 공유할 수 있다.
const issuedCouponIds =
new Set<string>();
export function rememberIssuedCoupon(
couponId: string
) {
issuedCouponIds.add(couponId);
}
issuedCouponIds는 외부에 공개되지 않았지만 모듈을 사용하는 모든 요청이 같은 값을 사용한다.
서버 환경에서는 여러 사용자의 데이터가 섞일 수 있다.
테스트에서도 이전 테스트의 값이 남을 수 있다.
서버 인스턴스가 여러 개라면 인스턴스마다 서로 다른 집합을 가질 수도 있다.
모듈 내부라는 이유만으로 안전한 상태가 되는 것은 아니다.
상태가 필요한 경우 그 소유자와 생명주기를 명시할 수 있다.
export function createCouponTracker() {
const issuedCouponIds =
new Set<string>();
return {
remember(couponId: string) {
issuedCouponIds.add(couponId);
},
has(couponId: string) {
return issuedCouponIds.has(
couponId
);
},
};
}
호출자는 필요한 범위에 맞게 인스턴스를 생성한다.
const requestTracker =
createCouponTracker();
서비스 전체가 공유해야 하는 영구 데이터라면 메모리 집합이 아니라 데이터베이스나 공유 저장소를 사용해야 한다.
모듈은 코드의 공개 범위를 제한하지만 상태의 올바른 생명주기를 자동으로 보장하지는 않는다.
export된 기능을 변경하면 어떤 모듈이 영향을 받는가?utils, common, shared가 책임 없는 코드의 창고가 되고 있지는 않은가?create-coupon.ts
save-coupon.ts
send-coupon.ts
파일은 나뉘었지만 각 파일이 HTTP, 비즈니스 규칙, 데이터베이스를 함께 알고 있을 수 있다.
coupon-domain/
coupon-application/
coupon-storage/
coupon-http/
파일 개수보다 각 영역의 책임과 변경 이유를 구분해야 한다.
export function normalizeTitle() {
// ...
}
export function buildCoupon() {
// ...
}
export function createCoupon() {
// ...
}
외부 코드가 내부 생성 과정에 의존할 수 있다.
function normalizeTitle() {
// ...
}
function buildCoupon() {
// ...
}
export function createCoupon() {
// ...
}
외부에 약속할 기능만 공개한다.
function redeemCoupon(
coupon: PrismaCoupon
) {
// ...
}
비즈니스 규칙이 특정 저장 기술의 모델에 의존한다.
function redeemCoupon(
coupon: Coupon
) {
// ...
}
저장 모듈에서 데이터베이스 모델을 서비스 모델로 변환한다.
// shared/utils.ts
export function canRedeemCoupon() {
// ...
}
쿠폰 정책이 책임 없는 공통 폴더에 숨겨진다.
// coupon/coupon-policy.ts
export function canRedeemCoupon() {
// ...
}
여러 곳에서 사용하더라도 쿠폰 규칙은 쿠폰 모듈이 소유한다.
// coupon.ts
import {
getUser,
} from "./user";
// user.ts
import {
getCoupons,
} from "./coupon";
책임과 의존 방향이 꼬일 수 있다.
async function getUserCoupons(
userRepository:
UserRepository,
couponRepository:
CouponRepository
) {
// 두 모듈의 기능을 조정한다.
}
별도의 사용 사례에서 두 모듈의 협력을 관리할 수 있다.
let currentUserId: string;
export function setCurrentUser(
userId: string
) {
currentUserId = userId;
}
모든 요청이 같은 값을 공유할 수 있다.
export function getUserCoupons(
userId: string
) {
// 요청별 값을 명시적으로 받는다.
}
외부에서 접근할 수 없는 상태와 여러 호출이 공유하지 않는 상태는 같은 의미가 아니다.
is-expired.ts
has-remaining-uses.ts
is-recipient.ts
is-active.ts
하나의 쿠폰 사용 규칙을 이해하기 위해 너무 많은 파일을 이동해야 할 수 있다.
export function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
) {
// 관련된 판단을 함께 표현한다.
}
독립적인 변경 이유가 없다면 하나의 책임 안에서 함께 관리할 수 있다.
모듈은 코드를 여러 파일로 나누고 가져와 사용하는 방법이다.
export function createCoupon() {
// ...
}
import {
createCoupon,
} from "./coupon";
하지만 실제 서비스에서 모듈은 단순한 파일 단위가 아니다.
모듈은 서비스의 책임과 변경 범위를 정의한다.
쿠폰 사용 규칙은 쿠폰 모듈이 소유할 수 있다.
export function canRedeemCoupon(
coupon: Coupon,
context: RedemptionContext
): boolean {
// 쿠폰 사용 규칙
}
데이터베이스 접근은 저장 모듈이 담당할 수 있다.
export interface CouponRepository {
findById(
couponId: string
): Promise<Coupon | null>;
save(
coupon: Coupon
): Promise<Coupon>;
}
여러 모듈의 실행 순서는 사용 사례가 조정할 수 있다.
export async function issueCoupon(
input: IssueCouponInput,
dependencies:
IssueCouponDependencies
) {
const coupon =
await dependencies
.couponRepository
.save(
createCoupon(input)
);
await dependencies
.notificationService
.send(coupon);
return coupon;
}
모듈의 공개 기능은 다른 코드와 맺는 계약이다.
export {
createCoupon,
canRedeemCoupon,
};
공개할 필요가 없는 구현은 모듈 내부에 남긴다.
function normalizeCouponTitle() {
// 내부 구현
}
좋은 모듈 설계는 다음을 가능하게 한다.
모듈을 설계할 때는 파일의 길이보다 변경의 이유를 살펴봐야 한다.
같은 이유로 변경되는 코드는 함께 두고, 서로 다른 이유로 변경되는 코드는 경계를 나눈다.
그리고 다른 모듈에 꼭 필요한 기능만 공개한다.
결국 모듈을 만든다는 것은 다음 질문에 답하는 일이다.
이 서비스의 책임은 어떻게 나뉘며, 하나의 변경이 어디까지 영향을 미치도록 허용할 것인가?
모듈은 파일을 나누는 방법이 아니다.
서비스의 책임을 모으고 변경 범위를 제한하며, 구성요소 사이에 명확한 계약을 만드는 방법이다.