leo.dev
backend

쉬운 분리, 어려운 경계

notifications.service.ts가 874줄이 됐다. 설정 CRUD, 일일 배치, 휴가 실시간 알림. 책임 세 개가 한 파일에 얽혀 있었다.

300줄 넘고 책임 2개 이상이면 분리한다는 컨벤션이 있으니, 쪼개는 건 정해진 수순이다. 그런데 큰 파일을 여러 개로 자르는 일 자체는 누구나 한다. 어려운 건 어디서 자를 것인가, 그리고 자른 뒤 그 경계를 어떻게 지킬 것인가다.

경계를 찾는 신호

“책임 3개”는 사후에 붙이는 말이고, 실제로 코드를 볼 땐 다른 신호가 더 유효하다.

신호의미
constructor 주입 의존성 8개 이상책임이 여러 갈래로 분산돼 있을 가능성
private 메서드가 특정 public 메서드들에만 쓰임그 묶음이 하나의 서브도메인
테스트의 mock 범위가 지나치게 넓음책임 단위가 너무 큼

가장 잘 듣는 건 두 번째다. private 메서드가 어떤 public 메서드들에서만 호출되는지 따라가면 자연스러운 덩어리가 보인다. NotificationsService에서는 그 덩어리가 정확히 셋(설정 / 배치 / 휴가)으로 갈렸다.

modules/notifications/
├── notifications-setting.service.ts # 설정 CRUD + 매퍼 (141줄)
├── notifications-daily.service.ts # 배치 알림 (298줄)
├── notifications-leave.service.ts # 휴가 실시간 알림 (264줄)
└── notifications.service.ts # thin facade (64줄)

경계를 지키기 — facade + 캡슐화

자르고 나면 경계가 새기 쉽다. 컨트롤러와 LeaveService 같은 외부 모듈이 이미 NotificationsService를 import하고 있었다. 서브서비스를 직접 노출하면 소비자를 전부 수정해야 하고, 다음에 내부를 또 바꿀 때 같은 일을 반복한다.

그래서 얇은 facade를 남겼다. 메서드 시그니처는 그대로 두고, 로직은 서브서비스로 위임만 한다.

notifications.service.ts
@Injectable()
export class NotificationsService {
constructor(
private readonly settingService: NotificationsSettingService,
private readonly dailyService: NotificationsDailyService,
private readonly leaveService: NotificationsLeaveService,
) {}
handleDailyNotifications(): Promise<void> {
return this.dailyService.handleDailyNotifications();
}
// 나머지도 단순 위임
}

경계를 코드로 강제하는 곳은 모듈 exports다.

notifications.module.ts
providers: [
NotificationsService, // facade
NotificationsSettingService, // 내부용
NotificationsDailyService, // 내부용
NotificationsLeaveService, // 내부용
],
exports: [NotificationsService], // 외부에 노출되는 유일한 진입점

서브서비스를 exports에 넣는 순간 캡슐화가 깨진다. 소비자가 서브서비스를 직접 주입하기 시작하면 내부 구조를 바꿀 때마다 소비자 코드도 따라 바뀐다. facade 하나만 내보내면 외부 API는 그대로 둔 채 내부만 자유롭게 바꿀 수 있다.

자르다 만난 진짜 교훈 — 배치는 두 겹으로 감싼다

분리한 김에 배치 서비스를 다시 보다가, 오류 격리가 한 겹뿐인 걸 발견했다. 카테고리 단위로만 try/catch가 있고, 카테고리 안의 개별 아이템은 무방비였다. 한 문서 처리가 예외를 던지면 그 뒤 문서들의 알림이 통째로 누락된다.

notifications-daily.service.ts
for (const { name, fn } of categories) {
try {
await fn();
} catch (err: unknown) {
/* 카테고리 단위 격리 */
}
}
// fn 내부 — 아이템 단위도 감싼다
for (const doc of documents) {
try {
await this.dispatch.send(...);
} catch (err: unknown) {
this.logger.error(`알림 처리 실패: docId=${doc.id}`, ...);
}
}

두 겹을 다 감싸야 “문서 A 실패 → 문서 B~Z 누락”을 막는다. 배치 루프에서 가장 흔하게 빠뜨리는 지점이고, 874줄 덩어리 안에선 눈에 띄지도 않던 부분이다.

정리하면

분리는 목적이 아니라 수단이다. god 서비스를 자를 때 실제로 손이 가는 일은 두 가지다. private 메서드의 의존 관계를 따라 경계를 찾는 것, 그리고 facade와 모듈 exports로 그 경계가 새지 않게 막는 것. 줄 수가 줄어든 건 결과일 뿐이다.

↑↓ 이동 열기esc 닫기