모듈은 파일을 나누는 방법이 아니다

vx_developer·2026년 9월 4일

개발하다가

목록 보기
14/30
post-thumbnail

프로그래밍을 처음 배울 때 모듈은 보통 “코드를 여러 파일로 나누고 필요한 기능을 가져와 사용하는 방법”이라고 배운다.

// coupon.js
export function createCoupon() {
  // ...
}
// app.js
import {
  createCoupon,
} from "./coupon.js";

모듈의 기본 문법을 이해하기에는 충분한 설명이다.

하나의 파일에 모든 코드를 작성하는 대신 기능별로 파일을 나누고, exportimport를 사용해 코드를 연결할 수 있다.

하지만 실제 서비스를 개발하기 시작하면 모듈을 만든다는 것은 단순히 긴 파일을 여러 개로 분리하는 것보다 훨씬 많은 판단을 요구한다.

  • 어떤 코드가 같은 책임에 속하는가?
  • 어떤 기능을 외부에 공개해야 하는가?
  • 모듈 내부에 감춰야 하는 구현은 무엇인가?
  • 비즈니스 규칙은 어느 모듈이 소유해야 하는가?
  • 데이터베이스가 바뀌면 어떤 코드까지 함께 변경되는가?
  • 하나의 변경이 여러 모듈로 퍼지는 이유는 무엇인가?
  • 서로를 가져오는 순환 의존성은 왜 생기는가?
  • 여러 곳에서 사용하는 코드는 모두 공통 모듈에 넣어야 하는가?
  • 파일은 나뉘었지만 책임은 여전히 섞여 있지 않은가?

문법적으로 모듈은 코드를 가져오고 내보내는 단위다.

실제 서비스에서 모듈은 서비스의 책임을 나누고, 변경이 영향을 미치는 범위를 제한하며, 다른 코드에 제공할 계약을 정의하는 경계다.

실제 서비스에서 모듈을 설계한다는 것은 코드를 여러 파일로 나누는 것이 아니라, 함께 변경되는 책임을 모으고 서로 다른 변경 이유를 분리하는 일이다.


파일이 나뉘었다고 책임까지 분리된 것은 아니다

쿠폰 서비스를 하나의 파일에 작성했다고 생각해 보자.

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,
  };
}

이 함수는 쿠폰을 생성하지만 동시에 여러 책임을 처리한다.

  • HTTP 요청에서 입력을 읽는다.
  • 사용자 입력을 검증한다.
  • 인증된 사용자를 확인한다.
  • 쿠폰의 초기 상태를 결정한다.
  • ID를 생성한다.
  • 데이터베이스에 저장한다.
  • 이메일을 전송한다.
  • HTTP 응답을 만든다.

파일이 길어지면 다음과 같이 나눌 수 있다.

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여야 한다.
  • 남은 사용 횟수가 1회 이상이어야 한다.
  • 만료일이 지나지 않아야 한다.

이 규칙을 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
);

하나의 값이 이동하지만 각 모듈의 책임은 다르다.

  • HTTP 모듈은 전송 형식을 책임진다.
  • 검증 모듈은 외부 데이터의 형태와 제약을 확인한다.
  • 사용 사례 모듈은 작업 순서를 조정한다.
  • 도메인 모듈은 쿠폰 규칙을 결정한다.
  • 저장 모듈은 데이터베이스와의 통신을 책임진다.

좋은 모듈 경계는 단순히 데이터를 전달하는 통로가 아니다.

데이터가 어느 단계까지 처리되었고 다음 모듈이 무엇을 신뢰할 수 있는지를 보여준다.


비즈니스 규칙이 기술 모듈에 의존하면 변경 방향이 뒤집힌다

쿠폰 사용 규칙이 데이터베이스 쿼리 안에 들어 있다고 생각해 보자.

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 모듈은 요청 검증과 응답 변환을 중심으로 테스트할 수 있다.

모듈의 책임이 명확하면 각 테스트가 무엇을 검증하는지도 명확해진다.

  • 도메인 테스트는 비즈니스 규칙을 확인한다.
  • 저장 모듈 테스트는 데이터 저장과 변환을 확인한다.
  • API 테스트는 외부 계약을 확인한다.
  • 통합 테스트는 여러 모듈의 협력을 확인한다.

테스트하기 어려운 모듈은 너무 많은 외부 기술과 책임에 연결되어 있을 가능성이 있다.


모듈 수준의 변경 가능한 상태는 모든 호출이 공유할 수 있다

모듈 내부에 선언된 값은 외부에서 직접 보이지 않더라도 여러 호출이 공유할 수 있다.

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();

서비스 전체가 공유해야 하는 영구 데이터라면 메모리 집합이 아니라 데이터베이스나 공유 저장소를 사용해야 한다.

모듈은 코드의 공개 범위를 제한하지만 상태의 올바른 생명주기를 자동으로 보장하지는 않는다.


모듈을 나누기 전에 물어봐야 할 질문

책임과 변경 이유

  1. 이 모듈은 어떤 서비스 책임을 담당하는가?
  2. 모듈 이름만 보고 그 책임을 예상할 수 있는가?
  3. 내부 코드들은 같은 이유로 함께 변경되는가?
  4. 서로 다른 변경 이유를 가진 코드가 섞여 있지는 않은가?
  5. 한 기능을 변경하려면 관련 없는 모듈까지 수정해야 하는가?

공개 계약

  1. 다른 모듈이 반드시 사용해야 하는 기능은 무엇인가?
  2. 외부에 공개하지 않아도 되는 내부 함수는 무엇인가?
  3. export된 기능을 변경하면 어떤 모듈이 영향을 받는가?
  4. 데이터베이스 구조나 외부 API 응답이 그대로 공개되고 있지는 않은가?
  5. 내부 구현을 바꿔도 공개 계약을 유지할 수 있는가?

의존성

  1. 이 모듈은 어떤 다른 모듈에 의존하는가?
  2. 특정 데이터베이스나 이메일 서비스에 직접 묶여 있지는 않은가?
  3. 비즈니스 규칙이 기술 모듈 안에 들어가 있지는 않은가?
  4. 서로를 가져오는 순환 의존성이 있는가?
  5. 여러 모듈을 조정하는 별도의 사용 사례가 필요한가?

데이터와 상태

  1. 모듈 내부에 변경 가능한 공유 상태가 있는가?
  2. 요청별 값이나 사용자별 값이 모듈 수준에 저장되고 있지는 않은가?
  3. 모듈이 자신의 내부 데이터 구조를 외부에 노출하는가?
  4. 모듈 경계를 지나는 데이터는 검증되거나 변환되는가?
  5. 서비스가 기억해야 하는 사실을 프로세스 메모리에만 저장하고 있지는 않은가?

재사용과 공통화

  1. 여러 곳에서 사용된다는 이유만으로 공통 모듈에 넣고 있지는 않은가?
  2. 이 코드는 어떤 서비스 개념에 속하는가?
  3. 현재 비슷해 보여도 변경 이유는 서로 다르지 않은가?
  4. 공통화로 인해 한 기능의 변경이 다른 기능에 영향을 주지는 않는가?
  5. utils, common, shared가 책임 없는 코드의 창고가 되고 있지는 않은가?

테스트와 운영

  1. 이 모듈의 핵심 규칙을 독립적으로 테스트할 수 있는가?
  2. 테스트를 위해 데이터베이스와 외부 API를 모두 실행해야 하는가?
  3. 테스트 사이에 모듈 상태가 남는가?
  4. 외부 기술을 대체해 모듈을 테스트할 수 있는가?
  5. 오류가 발생했을 때 어느 모듈의 책임인지 추적할 수 있는가?

흔히 하는 실수

긴 파일을 잘게 나누면 모듈화가 끝났다고 생각한다

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() {
  // 내부 구현
}

좋은 모듈 설계는 다음을 가능하게 한다.

  • 같은 서비스 책임을 가진 코드를 함께 찾을 수 있다.
  • 서로 다른 변경 이유를 분리할 수 있다.
  • 내부 구현을 외부 코드에서 감출 수 있다.
  • 데이터베이스와 외부 서비스의 변경 영향을 제한할 수 있다.
  • 비즈니스 규칙을 한곳에서 일관되게 관리할 수 있다.
  • 모듈 사이의 의존 방향을 이해할 수 있다.
  • 각 책임을 적절한 범위에서 테스트할 수 있다.
  • 사용자별 상태와 공유 상태의 생명주기를 구분할 수 있다.
  • 프로젝트 구조가 서비스의 개념을 드러내게 할 수 있다.

모듈을 설계할 때는 파일의 길이보다 변경의 이유를 살펴봐야 한다.

같은 이유로 변경되는 코드는 함께 두고, 서로 다른 이유로 변경되는 코드는 경계를 나눈다.

그리고 다른 모듈에 꼭 필요한 기능만 공개한다.

결국 모듈을 만든다는 것은 다음 질문에 답하는 일이다.

이 서비스의 책임은 어떻게 나뉘며, 하나의 변경이 어디까지 영향을 미치도록 허용할 것인가?

모듈은 파일을 나누는 방법이 아니다.

서비스의 책임을 모으고 변경 범위를 제한하며, 구성요소 사이에 명확한 계약을 만드는 방법이다.

profile
Vision eXperience Developer

0개의 댓글