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를 남겼다. 메서드 시그니처는 그대로 두고, 로직은 서브서비스로 위임만 한다.
@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다.
providers: [ NotificationsService, // facade NotificationsSettingService, // 내부용 NotificationsDailyService, // 내부용 NotificationsLeaveService, // 내부용],exports: [NotificationsService], // 외부에 노출되는 유일한 진입점서브서비스를 exports에 넣는 순간 캡슐화가 깨진다. 소비자가 서브서비스를 직접 주입하기 시작하면 내부 구조를 바꿀 때마다 소비자 코드도 따라 바뀐다. facade 하나만 내보내면 외부 API는 그대로 둔 채 내부만 자유롭게 바꿀 수 있다.
자르다 만난 진짜 교훈 — 배치는 두 겹으로 감싼다
분리한 김에 배치 서비스를 다시 보다가, 오류 격리가 한 겹뿐인 걸 발견했다. 카테고리 단위로만 try/catch가 있고, 카테고리 안의 개별 아이템은 무방비였다. 한 문서 처리가 예외를 던지면 그 뒤 문서들의 알림이 통째로 누락된다.
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로 그 경계가 새지 않게 막는 것. 줄 수가 줄어든 건 결과일 뿐이다.