SRP
바뀌는 이유가 여러 개인가?
메일, DB, 업무 규칙, HTTP 형식이 한 클래스에 섞일 때 본다.분류: Layer 9 - 아키텍처 & 설계 패턴
설계 원칙은 코드를 예쁘게 나누는 규칙이 아니라, 요구사항 변경이 왔을 때 수정 범위와 실패 범위를 작게 유지하기 위한 판단 언어다.
이 문서는 L9의 입구 문서다. 여기서는 Clean Architecture, DDD, Twelve-Factor App을 깊게 구현하지 않는다. 대신 다음 문서를 읽기 전에 필요한 공통 어휘를 만든다.
이 다섯 가지를 잡으면 뒤의 Clean Architecture, DDD, Twelve-Factor App을 “패턴 이름 암기”가 아니라 “변화 비용을 줄이는 장치”로 읽을 수 있다.
백엔드 시스템은 처음 만들 때보다 바꿀 때 더 어려워진다. 초기에는 기능 하나가 파일 하나에 들어가도 문제가 없어 보인다. 하지만 시간이 지나면 같은 기능이 HTTP API, 배치, 메시지 컨슈머, 관리자 도구, 테스트 코드에 흩어진다.
그때 설계 품질은 추상적인 취향이 아니라 숫자로 보인다.
| 변경 상황 | 설계가 약할 때 | 설계가 버틸 때 |
|---|---|---|
| 결제 수단 1개 추가 | PaymentService의 if/else 수정, 테스트 재작성, 기존 결제 회귀 위험 | 새 Gateway 구현체 추가, 기존 흐름은 계약 테스트로 보호 |
| 주문 취소 규칙 변경 | OrderService, AdminOrderService, 배치 코드에 중복 수정 | Order.cancel() 한 곳의 규칙과 이벤트를 수정 |
| DB 교체 | 비즈니스 로직에서 TypeORM/Prisma 의존을 직접 수정 | Repository 구현체와 DI 바인딩을 교체 |
| ECS 배포 설정 누락 | 로컬은 성공, 운영은 localhost 접속 또는 502 | 시작 시 필수 환경변수 검증, 종료 신호 처리 |
설계 원칙은 이 표의 오른쪽을 보장하는 마법이 아니다. 대신 “어떤 변경이 어디까지 퍼지는가”를 계속 질문하게 만든다. 변경 하나가 3개 이상의 레이어와 2개 이상의 진입점을 동시에 건드리면, 그 코드는 이미 설계 원칙을 공부해야 하는 신호를 보내고 있다.
처음에는 controller -> service -> repository 구조만으로도 충분해 보인다.
Controller는 요청을 받고,
Service는 로직을 실행하고,
Repository는 DB를 다룬다.
작은 CRUD에서는 이 구조가 빠르고 이해하기 쉽다.
문제는 요구사항이 커지면 각 레이어 이름이 책임을 보장하지 못한다는 점이다.
OrderService 안에 결제 승인,
재고 예약,
쿠폰 복구,
메일 발송,
DB 트랜잭션,
외부 API 호출이 모두 들어가면
파일 이름은 Service지만 실제로는 여러 변경 이유를 가진다.
여기서 설계 원칙이 등장한다.
Clean Architecture는 이 중 2번을 깊게 다룬다. DDD는 3번과 도메인 경계를 깊게 다룬다. Twelve-Factor App은 5번을 깊게 다룬다. 이 문서는 그 앞에서 1번과 4번, 그리고 모든 문서에 공통으로 쓰이는 smell과 판단 기준을 정리한다.
좋은 설계는 “복잡한 구조를 많이 쓰는 것”이 아니다. 좋은 설계는 변경이 일어나는 방향을 예측하고, 그 변경이 한쪽으로만 흐르도록 통로를 만든다.
핵심 철학은 세 가지다.
첫째, 변경 이유를 분리한다. 메일 템플릿 변경, 결제 승인 규칙 변경, DB 저장 방식 변경은 서로 다른 이유로 바뀐다. 이 셋이 한 클래스에 있으면 그 클래스는 계속 흔들린다.
둘째, 정책과 세부사항을 분리한다. “주문은 결제 금액을 초과할 수 없다”는 정책이다. TypeORM으로 저장할지, Prisma로 저장할지, REST로 요청을 받을지는 세부사항이다. 정책이 세부사항을 직접 import하기 시작하면 교체 비용이 커진다.
셋째, 결정의 이유를 남긴다. 코드는 현재 상태만 보여준다. 왜 REST를 선택했는지, 왜 SQS를 도입했는지, 왜 지금은 DDD를 일부 Context에만 적용했는지는 코드만 보면 사라진다. ADR은 이 사라지는 맥락을 붙잡는다.
coupling(결합도)은 한 코드가 다른 코드의 구체적인 세부사항을 얼마나 많이 알고 있는지다. 한국어로는 “서로 얼마나 단단히 묶여 있는가”에 가깝다.
결합도가 높은 코드는 혼자 바뀌지 못한다.
예를 들어 OrderService가 TypeormOrderEntity,
DataSource,
HttpException,
SqsClient를 모두 직접 안다면
주문 규칙 하나를 테스트할 때도 DB, HTTP, 메시지큐 지식이 따라온다.
결합도가 항상 나쁜 것은 아니다. 같은 Aggregate 내부의 객체처럼 함께 바뀌어야 하는 것들은 어느 정도 결합되어도 된다. 문제는 변경 이유가 다른 것들이 강하게 결합될 때다.
cohesion(응집도)은 한 모듈 안의 코드가 같은 목적을 위해 모여 있는 정도다. 한국어로는 “한 덩어리가 얼마나 한 가지 일을 하는가”에 가깝다.
응집도가 높은 Order는 주문 상태 전이와 그 규칙을 책임진다.
응집도가 낮은 OrderService는 주문,
결제,
메일,
통계,
권한,
파일 업로드까지 모두 처리한다.
좋은 설계의 기본 방향은 단순하다.
설계 비용은 감으로만 판단하면 어렵다. 다음 숫자는 절대 기준이 아니라 점검 신호다.
| 관찰 | 의미 | 먼저 의심할 것 |
|---|---|---|
| 한 요구사항 변경에 5개 이상 파일 수정 | 책임 분리가 과하거나, 반대로 규칙이 흩어졌을 수 있음 | SRP, 응집도 |
| 같은 조건문이 2개 이상 서비스에 반복 | 도메인 규칙이 모델 밖에 흩어짐 | Anemic Domain Model |
| 테스트 하나에 mock 4개 이상 필요 | 한 유닛이 너무 많은 협력자를 안다 | 결합도, ISP |
| 새 결제/알림 수단마다 기존 서비스 수정 | 확장이 기존 코드를 계속 건드림 | OCP |
| 운영에서만 설정 누락이 드러남 | 환경 설정 검증이 늦음 | Twelve-Factor Config |
중요한 점은 숫자 자체가 아니라 추세다. 처음에는 mock 3개가 괜찮을 수 있다. 하지만 매 기능마다 mock과 수정 파일이 늘어난다면 구조가 변경 비용을 흡수하지 못하고 있다는 뜻이다.
SOLID는 객체지향 설계에서 자주 쓰는 다섯 원칙의 머리글자다. 처음에는 약어가 부담스럽지만, 실무에서는 “이 코드가 왜 불편한가”를 말하는 언어로 쓰면 된다. 여기서 smell은 버그가 이미 났다는 뜻이 아니라, 변경 비용이 커질 조짐이다.
SOLID를 적용한다는 말은 모든 파일에 인터페이스를 붙인다는 뜻이 아니다. 변경 비용이 실제로 커지는 지점에서 “무엇을 분리해야 하는가”를 찾는다는 뜻이다.
SRP(Single Responsibility Principle)는 “한 클래스는 하나의 책임만 가져야 한다”로 자주 외운다. 하지만 더 실전적인 표현은 “바뀌는 이유가 하나에 가깝게 하라”다.
다음 Controller는 HTTP 요청 처리, DB 조회, 비즈니스 규칙, 메일 발송을 모두 처리한다.
// SRP 위반: Controller가 요청 처리 이상을 맡는다.@Post("/orders")async createOrder(@Body() dto: CreateOrderDto, @Req() req) { const user = await this.userRepository.findOne(req.user.id); if (!user) throw new NotFoundException("유저 없음");
if (user.creditLimit < dto.totalAmount) { throw new BadRequestException("한도 초과"); }
const order = await this.orderRepository.save({ userId: user.id, items: dto.items, status: "pending", });
await this.mailService.sendOrderConfirmation(user.email, order.id); return order;}이 코드는 작동할 수 있다. 문제는 변경 이유가 네 개라는 점이다.
SRP의 해결 메커니즘은 책임을 무조건 잘게 찢는 것이 아니다. 변경 이유가 다른 것을 다른 위치로 옮기는 것이다.
// Controller는 입출력 변환에 집중한다.@Post("/orders")async createOrder(@Body() dto: CreateOrderDto, @Req() req) { const orderId = await this.createOrderUseCase.execute({ userId: req.user.id, items: dto.items, totalAmount: dto.totalAmount, });
return { orderId };}
// Use Case는 주문 생성 흐름을 조율한다.@Injectable()export class CreateOrderUseCase { constructor( private readonly userRepo: UserRepository, private readonly unitOfWork: OrderUnitOfWork, ) {}
async execute(cmd: CreateOrderCommand): Promise<string> { const user = await this.userRepo.findById(cmd.userId); if (!user) throw new UserNotFoundException(cmd.userId);
const order = Order.create(user, cmd.items, cmd.totalAmount); const event = new OrderCreatedEvent(order.id, user.email);
await this.unitOfWork.transaction(async (tx) => { await tx.orders.save(order); await tx.outbox.append(event); });
return order.id; }}Unit of Work는 여러 repository 쓰기를 하나의 커밋 경계로 묶는 애플리케이션 포트다. OrderUnitOfWork의 infrastructure adapter는 주문 row와 outbox row를 같은 DB 트랜잭션에 저장한다. 외부 broker 발행은 커밋된 outbox를 읽는 relayer가 담당한다. save() 다음에 Kafka/SQS publish를 직접 호출하면 두 시스템 사이에 원자적 경계가 없어 주문만 저장되거나 이벤트만 발행되는 dual-write가 다시 생긴다.
원자성은 포트 이름이 아니라 adapter 구현으로 검증한다. 예를 들어 infrastructure/persistence/typeorm-order-unit-of-work.ts는 TypeORM이 callback에 넘긴 같은 transactional EntityManager로 두 repository를 만들어야 한다.
export class TypeOrmOrderUnitOfWork implements OrderUnitOfWork { constructor(private readonly dataSource: DataSource) {}
transaction<T>(work: (tx: OrderTransaction) => Promise<T>): Promise<T> { return this.dataSource.transaction(async (manager) => { const tx: OrderTransaction = { orders: new TypeOrmOrderRepository(manager), outbox: new TypeOrmOutboxRepository(manager), };
return work(tx); }); }}검증 가능한 불변식은 “transaction callback 안의 모든 DB 접근이 같은 manager를 사용한다”다. TypeOrmOrderRepository나 TypeOrmOutboxRepository가 전역 dataSource.manager 또는 전역 repository를 사용하면 이 보장은 깨진다. 반대로 같은 transactional manager만 사용하면 callback이 예외를 던질 때 주문 변경과 outbox INSERT가 함께 rollback되고, 둘 다 성공해야 함께 commit된다. adapter 단위 통합 테스트는 outbox INSERT를 강제로 실패시킨 뒤 주문 row도 남지 않는지 확인하면 된다.
이 리팩토링 후에는 주문 한도 규칙을 NestJS 없이 테스트할 수 있다. 메일 템플릿 변경은 이벤트 핸들러만 수정한다. gRPC, SQS Consumer 같은 다른 진입점도 같은 Use Case를 호출할 수 있다.
경계도 있다. 관리자용 단순 CRUD처럼 검증과 저장만 있는 기능에 Use Case, Domain Event, Repository Port를 모두 만들면 비용이 더 크다. SRP는 “작은 파일을 많이 만들라”가 아니라 “변경 이유가 충돌하면 나누라”는 원칙이다.
실패 신호는 분명하다. 한 기능을 고칠 때 Controller 테스트, Service 테스트, 메일 테스트, DB mock이 동시에 깨진다면 책임이 한곳에 몰려 있을 가능성이 높다.
OCP(Open/Closed Principle)는 확장에는 열려 있고 수정에는 닫혀 있어야 한다는 뜻이다. 말이 추상적이지만 결제 수단 예제로 보면 쉽다.
// OCP 위반: 새 결제 수단마다 기존 메서드를 수정한다.@Injectable()export class PaymentService { async processPayment(method: string, amount: number) { if (method === "credit_card") { return this.processCreditCard(amount); }
if (method === "kakao_pay") { return this.processKakaoPay(amount); }
if (method === "naver_pay") { return this.processNaverPay(amount); } }}이 구조는 결제 수단이 두세 개일 때는 빠르다. 하지만 새 수단을 추가할 때마다 기존 조건문을 수정한다. 이미 검증된 신용카드 흐름도 회귀 테스트 범위에 다시 들어간다.
OCP의 해결 메커니즘은 “변하는 부분”을 구현체로 빼는 것이다.
export interface PaymentGateway { supports(method: string): boolean; process(amount: number): Promise<PaymentResult>;}
@Injectable()export class KakaoPayGateway implements PaymentGateway { supports(method: string) { return method === "kakao_pay"; }
async process(amount: number) { return { txId: "kp-001", success: true }; }}
@Injectable()export class PaymentService { constructor( @Inject("PAYMENT_GATEWAYS") private readonly gateways: PaymentGateway[], ) {}
async processPayment(method: string, amount: number) { const gateway = this.gateways.find((g) => g.supports(method)); if (!gateway) throw new Error(`지원하지 않는 결제 수단: ${method}`);
return gateway.process(amount); }}이제 네이버페이를 추가할 때 핵심 흐름은 덜 흔들린다.
새 NaverPayGateway와 DI 등록을 추가하고,
Gateway 계약 테스트를 통과시키면 된다.
반례도 중요하다. 결제 수단이 하나뿐이고 앞으로도 늘어날 가능성이 낮은 내부 도구라면 처음부터 전략 패턴을 만들 필요가 없다. OCP는 변동성이 확인된 축에 적용할 때 가치가 있다.
실패 신호는 if (type === "...")가 무조건 나쁘다는 것이 아니다.
같은 종류의 조건문이 여러 파일에 반복되고,
새 타입 추가 때 기존 테스트가 광범위하게 깨질 때가 진짜 신호다.
LSP(Liskov Substitution Principle)는 하위 타입이나 구현체가 상위 타입의 계약을 깨지 않아야 한다는 뜻이다. 계약은 TypeScript 타입만이 아니라 기대 동작까지 포함한다.
// LSP 위반: save()가 있다고 약속했지만 실제로는 항상 실패한다.class ReadOnlyOrderRepository extends OrderRepository { async save(order: Order): Promise<void> { throw new Error("읽기 전용 저장소에는 저장할 수 없습니다"); }}OrderRepository를 받는 코드는 save()가 가능하다고 믿는다.
그런데 구현체가 런타임에 “나는 못 한다”고 던지면
타입은 맞지만 계약은 깨진다.
해결은 상속 트릭이 아니라 계약을 더 작게 나누는 것이다.
export interface ReadableOrderRepository { findById(id: string): Promise<Order | null>; findAll(): Promise<Order[]>;}
export interface WritableOrderRepository extends ReadableOrderRepository { save(order: Order): Promise<void>; delete(id: string): Promise<void>;}
@QueryHandler(GetOrderQuery)export class GetOrderHandler { constructor(private readonly repo: ReadableOrderRepository) {}}Query Handler는 읽기만 필요하다. 따라서 읽기 계약만 받으면 된다. 읽기 전용 Repository도 이 계약은 안전하게 만족한다.
실패 신호는 “빈 구현”, “지원하지 않음” 예외, 부모 타입의 반환 의미를 미묘하게 바꾸는 구현이다. 테스트 더블을 넣었을 때만 통과하고 실제 구현체에서 의미가 달라진다면 LSP 위반을 의심한다.
ISP(Interface Segregation Principle)는 큰 인터페이스 하나보다 사용자별 작은 계약이 낫다는 원칙이다.
// ISP 위반: 모든 소비자가 모든 메서드를 알아야 한다.export interface UserRepository { findById(id: string): Promise<User | null>; findAll(): Promise<User[]>; save(user: User): Promise<void>; delete(id: string): Promise<void>; findByEmailForAnalytics(email: string): Promise<UserAnalytics>; exportToCsv(): Promise<Buffer>;}UserService는 분석 CSV를 쓰지 않는다.
AnalyticsService는 사용자 저장을 하지 않는다.
그런데 둘 다 거대한 UserRepository에 의존하면
필요 없는 변경에도 영향을 받는다.
export interface UserReader { findById(id: string): Promise<User | null>; findAll(): Promise<User[]>;}
export interface UserWriter { save(user: User): Promise<void>; delete(id: string): Promise<void>;}
export interface UserAnalyticsReader { findByEmailForAnalytics(email: string): Promise<UserAnalytics>; exportToCsv(): Promise<Buffer>;}작은 인터페이스는 테스트도 가볍게 만든다.
UserService 테스트는 UserReader와 UserWriter만 mock하면 된다.
분석용 메서드를 stub할 필요가 없다.
경계는 있다. 인터페이스를 너무 잘게 쪼개면 이름이 폭증하고 탐색 비용이 커진다. ISP의 기준은 “클라이언트가 실제로 다른가”다. 같은 소비자가 늘 함께 쓰는 메서드라면 하나의 인터페이스로 두어도 된다.
DIP(Dependency Inversion Principle)는 의존성 역전 원칙이다. “고수준 모듈은 저수준 모듈에 의존하지 않고, 둘 다 추상화에 의존해야 한다”라고 표현한다.
여기서 고수준 모듈은 비즈니스 정책에 가까운 코드다. 예를 들어 주문 생성 Use Case, 주문 취소 규칙, 정산 상태 전이 같은 코드다. 저수준 모듈은 DB, 외부 API, 메시지큐, 프레임워크 같은 세부 구현이다.
@Module({ providers: [ CreateOrderUseCase, { provide: "ORDER_REPOSITORY", useClass: TypeormOrderRepository, }, ],})export class OrdersModule {}@Injectable()export class CreateOrderUseCase { constructor( @Inject("ORDER_REPOSITORY") private readonly orderRepo: OrderRepository, ) {}}Use Case는 TypeORM 구현체를 직접 알지 않는다.
ORDER_REPOSITORY라는 추상 계약만 안다.
TypeORM에서 Prisma로 바꾸면 DI 바인딩과 구현체를 바꾸고,
Use Case의 정책은 그대로 둔다.
이 원칙은 다음 문서인 Clean Architecture의 핵심으로 이어진다. Clean Architecture의 dependency rule은 DIP를 시스템 구조 전체로 확장한 것이다.
실패 신호는 도메인 또는 application 코드에서
@Entity,
DataSource,
Request,
Response,
S3Client,
PrismaClient 같은 세부 구현이 직접 등장하는 것이다.
항상 금지는 아니지만,
비즈니스 규칙 테스트가 외부 도구 없이 실행되지 않는다면 DIP가 깨졌을 가능성이 높다.
바뀌는 이유가 여러 개인가?
메일, DB, 업무 규칙, HTTP 형식이 한 클래스에 섞일 때 본다.새 타입 추가가 기존 코드를 계속 수정하는가?
결제, 알림, 할인 정책처럼 변형이 늘어나는 축에서 본다.타입은 맞지만 기대 동작을 깨는 구현체가 있는가?
지원하지 않음 예외, 빈 구현, 의미가 다른 반환값이 보일 때 본다.쓰지 않는 메서드까지 mock하거나 의존하는가?
테스트 더블이 불필요한 메서드까지 구현해야 할 때 본다.정책 코드가 DB·HTTP·프레임워크를 직접 아는가?
도메인 테스트에 외부 연결이나 프레임워크 부팅이 필요할 때 본다.ADR(Architecture Decision Record)은 중요한 아키텍처 결정을 짧은 문서로 남기는 방식이다. 한국어로는 “아키텍처 결정 기록”이다.
ADR이 필요한 이유는 단순하다. 코드는 “무엇이 현재 존재하는지”를 보여주지만, “왜 그렇게 됐는지”는 잘 보여주지 못한다. 6개월 뒤에 누군가 “왜 REST가 아니라 SQS를 썼나요?”라고 물으면 Slack 검색이나 기억에 의존하게 된다. ADR은 그 결정을 코드 저장소 안에 남긴다.
ADR은 거대한 문서가 아니다. 대개 하나의 Markdown 파일에 다음 네 가지를 적는다.
예시는 다음처럼 작게 유지한다.
# ADR-003: 서비스 간 통신에 SQS 비동기 메시지를 사용한다
## Status
Accepted
## Context
주문 서비스와 결제 서비스 간 동기 HTTP 호출 시결제 서비스 장애가 주문 생성 흐름으로 전파된다.Circuit Breaker를 넣어도 결제 완료 이벤트 유실 문제는 남는다.
## Decision
결제 완료/실패 이벤트를 SQS에 발행하고,각 서비스가 필요한 이벤트를 구독한다.
## Consequences
- (+) 결제 서비스 장애가 주문 API로 직접 전파되지 않는다.- (+) 최소 1회 전달을 전제로 재처리 전략을 세울 수 있다.- (-) 메시지 추적과 중복 처리 idempotency가 필요하다.- (-) 로컬 테스트에 LocalStack 같은 보조 도구가 필요할 수 있다.idempotency(멱등성)는 같은 메시지나 요청이 두 번 들어와도 결과가 한 번 처리된 것처럼 유지되는 성질이다. 비동기 메시징을 선택하면 중복 전달 가능성을 받아들이는 대신, 소비자 쪽에서 idempotency key나 처리 이력으로 중복 처리를 막아야 한다.
예를 들어 같은 결제 완료 이벤트가 두 번 도착해도 주문 상태는 한 번만 PAID가 되고, 포인트 적립도 한 번만 실행되어야 한다.
ADR을 쓰는 기준은 “되돌리기 어려운가”다. PostgreSQL과 DynamoDB 선택, ECS와 EKS 선택, 동기 REST와 비동기 메시징 선택, 모놀리스와 서비스 분리 결정은 ADR 후보가 된다.
반대로 함수명, 작은 DTO 필드명, 한 화면 안에서만 쓰는 정렬 방식처럼 쉽게 바꿀 수 있는 결정은 ADR이 필요 없다. 작은 결정을 모두 ADR로 남기면 기록이 아니라 소음이 된다.
ADR의 실패 신호도 있다. 문서가 있지만 Status가 갱신되지 않거나, 이미 Superseded된 결정을 새 코드가 계속 따른다면 ADR은 기억 장치가 아니라 낡은 주석이 된다. 결정이 바뀌면 기존 ADR을 지우기보다 새 ADR에서 “ADR-003을 대체한다”라고 연결한다.
Fat Controller는 Controller가 너무 많은 일을 하는 상태다. 요청 파싱뿐 아니라 DB 조회, 비즈니스 규칙, 외부 API 호출, 메일 발송까지 Controller에 들어간다.
처음에는 빠르다. 하지만 다른 진입점이 생기는 순간 문제가 드러난다. HTTP API로 주문을 만들 때와 SQS Consumer로 주문을 만들 때 같은 규칙을 중복 구현하게 된다.
판단 기준은 코드 줄 수보다 변경 이유다. Controller가 HTTP 형식 변화 때문에만 바뀐다면 괜찮다. 하지만 주문 한도, 결제 상태, 재고 예약, 이메일 정책 때문에도 바뀐다면 책임이 섞인 것이다.
리팩토링의 핵심은 다음과 같다.
이 구조는 Clean Architecture의 상세 구현이 아니라 변경 비용을 낮추기 위한 첫 분리다. 정식 레이어와 NestJS 모듈 구조는 다음 문서에서 다룬다.
Anemic Domain Model은 도메인 모델이 데이터만 들고 있고 규칙은 Service에 흩어진 상태다. 한국어로는 “빈약한 도메인 모델”이라고 부른다.
// 빈약한 모델: 상태만 있고 규칙이 없다.export class Order { id: string; status: "pending" | "confirmed" | "cancelled" | "delivered"; totalAmount: number;}
@Injectable()export class OrderService { cancel(order: Order, reason: string) { if (order.status === "cancelled") throw new Error("이미 취소됨"); if (order.status === "delivered") throw new Error("배송 완료 취소 불가");
order.status = "cancelled"; }}이 코드의 문제는 cancel 규칙이 OrderService 밖에서도 쉽게 복제된다는 점이다.
관리자 강제 취소,
배치 취소,
고객센터 취소가 각각 상태를 직접 바꾸면
한쪽만 쿠폰 복구 이벤트를 누락할 수 있다.
규칙을 모델 안으로 모으면 실패 범위가 줄어든다.
export class Order { private status: OrderStatus;
cancel(reason: string): OrderCancelledEvent { if (this.status === OrderStatus.CANCELLED) { throw new DomainException("이미 취소된 주문입니다"); }
if (this.status === OrderStatus.DELIVERED) { throw new DomainException("배송 완료된 주문은 취소할 수 없습니다"); }
this.status = OrderStatus.CANCELLED; return new OrderCancelledEvent(this.id, reason); }}
@Injectable()export class OrderService { constructor(private readonly unitOfWork: OrderUnitOfWork) {}
async cancelOrder(orderId: string, reason: string) { await this.unitOfWork.transaction(async (tx) => { const order = await tx.orders.findById(orderId); const event = order.cancel(reason);
await tx.orders.save(order); await tx.outbox.append(event); }); }}이 예시는 DDD의 전체 설명이 아니다. 여기서 배울 점은 “상태를 바꾸는 규칙은 상태를 가진 모델 가까이에 둔다”는 것이다. Aggregate, Value Object, Domain Event의 상세 경계는 DDD 문서에서 다룬다. Domain Event는 도메인 안에서 이미 일어난 일을 과거형으로 남기는 기록이다. 위 예제에서는 Aggregate 변경과 outbox 기록까지가 하나의 로컬 트랜잭션이고, broker 전달은 그 뒤의 비동기 단계다. 여기서는 직접 호출을 줄이는 신호만 보고, 이벤트 설계와 일관성 경계는 DDD와 CQRS 문서에서 다시 다룬다.
정량 신호는 간단하다.
rg -n "OrderStatus\\.CANCELLED|status\\s*=\\s*[\"']cancelled" src를 실행했을 때
상태 변경 지점이 여러 서비스에 흩어져 있으면
도메인 규칙이 모델 밖으로 새고 있을 가능성이 높다.
Circular Dependency는 두 모듈이 서로를 직접 아는 상태다.
NestJS에서는 OrdersModule -> PaymentsModule -> OrdersModule 같은 순환 의존으로 나타난다.
// 위험 신호: Order가 Payment를 직접 알고,// Payment도 Order를 직접 안다.import { Payment } from "../payment/payment.entity";
export class Order { payment: Payment;}순환 참조의 핵심 문제는 import 에러가 아니다. 두 개념의 변경 이유가 서로 붙어 버리는 것이다. 주문 확정 규칙을 바꾸는데 결제 내부 타입까지 알아야 하면 경계가 흐려진다.
먼저 의심할 해결책은 forwardRef()가 아니다.
forwardRef()는 NestJS가 부팅하도록 도와주는 기술적 우회일 뿐,
도메인 결합을 없애지 않는다.
학습 관점의 해결 방향은 두 가지다.
Shared Kernel은 여러 Context가 의도적으로 함께 소유하는 아주 작은 공통 모델이다. 기본값은 공유가 아니라 분리이며, 어떤 타입을 공유해도 되는지는 DDD 문서에서 Bounded Context와 함께 판단한다.
export class Order { confirm(): OrderConfirmedEvent { this.status = OrderStatus.CONFIRMED; return new OrderConfirmedEvent(this.id, this.totalAmount); }}결제 모듈은 OrderConfirmedEvent를 구독해 결제 흐름을 시작할 수 있다.
주문은 결제 구현체를 몰라도 된다.
실패 신호는 반복된다.
순환 의존을 해결하기 위해 forwardRef()가 여러 곳에 늘어나고,
테스트에서 모듈을 하나 띄우려면 관련 모듈 대부분을 함께 import해야 한다면
단순 DI 문제가 아니라 경계 문제일 가능성이 높다.
이 문서의 역할은 세 문서를 대신하는 것이 아니다. 각 주제의 경계는 다음처럼 잡는다.
여기서는 DIP와 결합도 관점만 잡는다. Clean Architecture 문서에서는 다음을 깊게 다룬다.
이 문서를 읽은 뒤 Clean Architecture를 볼 때는 “의존성이 안쪽으로 향한다”를 그림으로 외우기보다 “정책 코드가 어떤 세부 구현을 몰라도 되는가”를 질문한다.
여기서는 Anemic Domain Model과 언어 혼선을 신호로만 본다. DDD 문서에서는 다음을 깊게 다룬다.
이 문서를 읽은 뒤 DDD를 볼 때는 “객체를 많이 만들자”가 아니라 “어떤 비즈니스 불변식을 한 트랜잭션에서 지켜야 하는가”를 질문한다.
여기서는 환경 drift와 종료 신호를 실패 신호로만 다룬다. Twelve-Factor App 문서에서는 다음을 깊게 다룬다.
이 문서를 읽은 뒤 Twelve-Factor를 볼 때는 “클라우드 배포 체크리스트”가 아니라 “같은 코드가 다른 환경에서 같은 방식으로 실패하고 회복되는가”를 질문한다.
설계 원칙은 비용이 있다. 인터페이스, Use Case, 이벤트, ADR, 테스트 계약은 모두 파일과 생각할 거리를 늘린다. 따라서 “무조건 적용”보다 “변경 비용이 보이는 곳에 적용”이 더 낫다.
다음 조건이면 단순 CRUD로 시작해도 된다.
title, body, visible처럼 필드 저장이 중심이다.공지사항, 내부 배너, 단순 태그 관리, 읽기 전용 관리 화면은 보통 여기에 속한다.
다음 조건이면 원칙을 적용할 신호가 강하다.
여기서 중요한 기준은 규칙의 개수가 아니라 실패 비용이다. 규칙이 하나라도 돈이나 재고를 깨뜨리면 설계 경계가 필요하다. 반대로 규칙이 많아 보여도 실패 비용이 낮고 수정 위치가 명확하면 단순 구조가 더 낫다.
1. 이 변경이 한 파일에서 끝나는가? - 예: 지금은 단순 구조 유지 - 아니오: 2번으로
2. 흩어진 변경이 같은 비즈니스 규칙 때문인가? - 예: 도메인 모델 또는 Use Case로 모을 후보 - 아니오: 3번으로
3. 외부 도구 교체 때문에 정책 코드가 흔들리는가? - 예: DIP, Port/Adapter 후보 - 아니오: 4번으로
4. 결정이 되돌리기 어렵고 팀 합의가 필요한가? - 예: ADR 작성 후보 - 아니오: 과한 구조보다 코드 주석/테스트로 충분할 수 있음이 트리는 정답지가 아니다. 하지만 원칙 적용을 “멋있어 보이는 구조”가 아니라 “변경 비용을 줄이는 선택”으로 되돌려 준다.
이 절은 긴 트러블슈팅 런북이 아니라 설계 원칙이 깨졌을 때 보이는 신호를 모은 것이다.
증상:
Error: Circular dependency detectedOrdersModule -> PaymentsModule -> OrdersModule진단:
모듈이 서로의 내부 타입을 직접 알고 있는지 본다.
forwardRef()를 추가하기 전에
두 모듈 사이에 이벤트,
인터페이스,
공유 타입 중 어떤 경계가 맞는지 먼저 판단한다.
학습 포인트: 순환 의존은 “컴파일이 안 된다”보다 “변경 이유가 서로 붙었다”는 신호다.
증상:
Order.status를 바꾸는 코드가 여러 Service에 흩어져 있다.한쪽 취소 흐름만 쿠폰 복구 이벤트를 누락한다.진단: 상태 변경 검색을 해 본다.
rg -n "OrderStatus\\.CANCELLED|status\\s*=\\s*[\"']cancelled" src학습 포인트:
검색 결과가 여러 애플리케이션 서비스에 흩어져 있으면
규칙을 Order.cancel() 같은 도메인 메서드로 모을 후보가 된다.
첫 회독에서는 Twelve-Factor App의 세부 구현을 외우지 않는다. 환경 값이 코드 밖에서 주입되고, 설정 누락이 요청 처리 중이 아니라 시작 시점에 드러나야 한다는 신호만 잡는다.
증상:
로컬에서는 정상 동작하지만 ECS에서 DATABASE_URL 누락,또는 ECONNREFUSED 127.0.0.1:5432가 발생한다.진단:
process.env.KEY || "localhost" 같은 fallback이 운영에서 숨어 있는지 본다.
필수 환경변수는 시작 시점에 명시적으로 실패해야 한다.
function getRequiredEnv(key: string): string { const value = process.env[key];
if (!value) { throw new Error(`필수 환경변수 ${key}가 설정되지 않았습니다`); }
return value;}학습 포인트: 운영 기본값은 친절함이 아니라 위험일 수 있다. 설정 누락은 요청 처리 중이 아니라 프로세스 시작 시 드러나야 한다.
이 절은 운영 런북이 아니라 후속 Twelve-Factor 문서를 읽기 위한 미리보기다. 처음에는 종료 신호를 무시하면 배포 중 처리 중인 요청이 끊길 수 있다는 점만 이해하면 된다.
증상:
ECS 롤링 배포 중 일부 요청이 502 또는 socket hang up으로 실패한다.진단:
NestJS에서 app.enableShutdownHooks()가 켜져 있는지,
DB 커넥션과 큐 consumer가 종료 신호를 받으면 정리되는지,
ECS Task Definition의 stopTimeout이 요청 처리 시간보다 충분한지 본다.
async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); await app.listen(3000);}수치 감각:
ECS는 기본적으로 컨테이너 중지 시 30초 뒤 강제 종료될 수 있다.
Fargate Linux의 stopTimeout 최대값은 120초다.
따라서 2분 이상 걸리는 작업은 종료 대기만 늘리는 것으로 해결하지 말고
큐 작업으로 분리하거나 재시도 가능하게 만들어야 한다.
학습 포인트: Graceful Shutdown은 운영 팁이 아니라 “배포 중에도 처리 중인 일을 어떻게 안전하게 끝낼 것인가”라는 설계 문제다.
증상:
왜 SQS를 쓰는지, 왜 모듈러 모놀리스로 남겼는지,왜 TypeORM을 선택했는지 아무도 확실히 기억하지 못한다.진단: 되돌리기 어려운 결정인데 PR 설명이나 Slack에만 이유가 남아 있는지 본다.
학습 포인트: 아키텍처 결정은 코드처럼 유지보수 대상이다. 결정의 이유가 사라지면 팀은 같은 논쟁을 반복한다.
NestJS가 아닌 Spring Boot, Django, Go 서버, Rails 프로젝트를 보더라도 같은 질문을 던질 수 있다.
예를 들어 Spring Boot 프로젝트에서
OrderService가 JpaRepository와 @Transactional을 직접 다룬다고 하자.
이 자체가 항상 나쁘지는 않다.
하지만 주문 규칙 테스트가 JPA 없이 실행되지 않고,
DB 교체 논의 때 주문 규칙 코드까지 흔들린다면
DIP와 Port/Adapter 분리가 필요할 수 있다.
Django에서는 View가 Model.objects.filter()를 직접 호출하면서
비즈니스 상태 변경까지 처리하는지 본다.
Go에서는 handler 패키지가 database/sql을 직접 import하고
도메인 규칙까지 처리하는지 본다.
중요한 태도는 증거 기반이다. 검색 결과가 없고 변경 비용도 보이지 않으면 구조 변경보다 기능 구현을 계속한다. 설계 원칙은 위반을 찾는 망치가 아니라 실제로 커진 변경 비용을 줄이는 렌즈다.
forwardRef()가 순환 의존의 근본 해결이 아닐 수 있는 이유를 설명할 수 있는가?stopTimeout 기본값과 상한을 확인할 때 필요한 공식 문서DataSource.transaction()과 callback의 transactional EntityManager 사용 규칙설계 원칙은 패턴 이름을 많이 아는 것이 아니라, 변경이 어디서 커지고 실패가 어디로 번지는지 관찰한 뒤 결합도는 낮추고 응집도는 높이며, 중요한 결정의 이유를 남기는 습관이다.