본문 바로가기
TYLER SONGBlog
← Blog index
CS학습5 min read

소프트웨어 설계: 변경 비용을 낮추는 모듈 경계와 의존성 방향

소프트웨어 설계는 요구사항을 구현 가능한 구조로 바꾸면서 변경 비용을 통제하는 활동이다. 이 글은 응집도(cohesion)와 결합도(coupling), 의존성 역전 원칙(DIP)을 중심으로 설계의 원리를 설명하고, TypeScript 코드로 의존성 방향을 뒤집는 과정을 보여준다. 실무에서 마주치는 과설계와 잘못된 추상화의 함정도 다룬다.

By 송민성

1. 개념

소프트웨어 설계(software design)는 시스템을 모듈(module)로 나누고, 모듈 사이의 책임과 의존 관계를 정하는 활동이다. 코드를 어떻게 쓸지가 아니라 코드를 어디에 두고 무엇이 무엇을 알게 할지를 결정한다.

설계 품질을 판단하는 두 가지 핵심 척도가 있다.

  • 응집도(cohesion): 한 모듈 안의 요소들이 얼마나 같은 목적을 향해 모여 있는가. 높을수록 좋다.
  • 결합도(coupling): 모듈이 다른 모듈의 내부 사정을 얼마나 알고 있는가. 낮을수록 좋다.

좋은 설계는 "높은 응집도, 낮은 결합도"로 요약된다. 이 둘이 중요한 이유는 하나의 변경이 영향을 미치는 범위를 결정하기 때문이다.

2. 왜 사용하는가

소프트웨어 비용의 상당 부분은 최초 작성이 아니라 이후의 변경과 유지보수에서 발생한다. 설계의 목적은 다음과 같다.

  • 요구사항이 바뀔 때 수정해야 할 파일과 모듈의 수를 줄인다.
  • 외부 의존(DB, 결제 API, 메시지 큐)을 교체하거나 테스트에서 대체할 수 있게 한다.
  • 팀원이 시스템의 일부만 이해해도 작업할 수 있게 한다.

설계가 없으면 비즈니스 로직이 HTTP 핸들러나 SQL 문자열과 뒤섞이고, 작은 변경이 예상치 못한 곳을 깨뜨린다.

3. 동작 원리

3.1 의존성의 방향

모듈 A가 모듈 B를 import하면 A는 B에 의존(dependency)한다. B가 바뀌면 A도 영향을 받을 수 있다. 기본적인 계층 구조에서는 비즈니스 로직이 데이터베이스 구현에 직접 의존한다.

text
주문 서비스 → MySQL 저장소 구현

이 구조에서는 저장소를 바꾸면 비즈니스 로직도 수정해야 한다.

3.2 의존성 역전 원칙(DIP, Dependency Inversion Principle)

DIP는 두 가지를 말한다.

  1. 상위 수준 모듈은 하위 수준 모듈에 의존하지 않고, 둘 다 추상화에 의존해야 한다.
  2. 추상화는 세부 사항에 의존하지 않고, 세부 사항이 추상화에 의존해야 한다.

비즈니스 로직이 필요로 하는 인터페이스를 비즈니스 쪽에서 정의하고, 저장소 구현이 그 인터페이스를 따르게 하면 의존 방향이 뒤집힌다.

text
주문 서비스 → OrderRepository(인터페이스) ← 인메모리/DB 구현

3.3 SOLID 중 설계 판단에 자주 쓰이는 원칙

  • 단일 책임 원칙(SRP): 모듈이 변경되어야 하는 이유는 하나여야 한다.
  • 개방-폐쇄 원칙(OCP): 확장에는 열려 있고 수정에는 닫혀 있어야 한다.
  • 의존성 역전 원칙(DIP): 위에서 설명한 대로다.

4. 코드 예제

주문 생성 로직이 저장소 구현을 모르게 만드는 예제다. npx tsx order.ts로 실행할 수 있다.

typescript
// 도메인이 소유하는 추상화(포트) interface Order { id: string; amount: number; } interface OrderRepository { save(order: Order): Promise<void>; findById(id: string): Promise<Order | undefined>; } // 상위 수준 모듈: 구현체를 모른다 class OrderService { constructor(private readonly repo: OrderRepository) {} async placeOrder(id: string, amount: number): Promise<Order> { if (amount <= 0) { throw new Error("주문 금액은 0보다 커야 합니다."); } const order: Order = { id, amount }; await this.repo.save(order); return order; } } // 하위 수준 모듈: 인터페이스를 구현한다 class InMemoryOrderRepository implements OrderRepository { private readonly store = new Map<string, Order>(); async save(order: Order): Promise<void> { this.store.set(order.id, order); } async findById(id: string): Promise<Order | undefined> { return this.store.get(id); } } // 조립 지점(composition root)에서만 구현체를 선택한다 async function main() { const repo = new InMemoryOrderRepository(); const service = new OrderService(repo); await service.placeOrder("order-1", 15000); console.log(await repo.findById("order-1")); try { await service.placeOrder("order-2", -1); } catch (e) { console.log((e as Error).message); } } main();

실제 운영에서는 PostgresOrderRepository를 같은 인터페이스로 구현해 main에서 바꿔 끼우면 된다. OrderService는 수정하지 않는다. 테스트에서도 DB 없이 InMemoryOrderRepository로 비즈니스 규칙을 검증할 수 있다.

5. 시간 복잡도 또는 성능 특성

설계 원칙은 알고리즘 복잡도를 바꾸지 않는다. 다만 다음 비용은 알아둘 필요가 있다.

  • 인터페이스를 통한 호출은 대부분의 환경에서 런타임 오버헤드가 미미하다. 웹 서비스에서는 네트워크와 I/O 지연이 훨씬 지배적이다.
  • 추상화 계층이 늘면 코드를 읽을 때 따라가야 할 파일과 간접 참조가 늘어난다. 이는 성능이 아니라 이해 비용의 문제다.
  • 성능이 중요한 경로(hot path)는 측정(profiling)한 뒤에 필요한 부분만 최적화한다.

6. 실무 사용 사례

  • 계층형 아키텍처(layered architecture): 컨트롤러, 서비스, 저장소로 책임을 나눈다.
  • 헥사고날 아키텍처(hexagonal architecture)와 클린 아키텍처: 도메인을 중심에 두고 DB, 웹, 외부 API를 바깥 어댑터로 둔다. 위 예제가 이 구조의 축소판이다.
  • 외부 서비스 격리: 결제나 알림 같은 외부 API를 인터페이스 뒤에 두어 공급자 교체와 테스트 대역(test double) 사용을 쉽게 한다.
  • 모듈 경계 설정: 모놀리스 안에서도 모듈 간 공개 API만 호출하도록 제한하면 나중에 서비스 분리가 쉬워진다.

7. 주의할 점

  • 과설계(over-engineering): 구현체가 하나뿐이고 바뀔 가능성도 낮은데 모든 곳에 인터페이스를 만들면 복잡성만 늘어난다. 변경 가능성이 실제로 보이는 경계에 추상화를 둔다.
  • 잘못된 추상화: 중복처럼 보인다는 이유로 성급하게 합친 추상화는 이후 예외 케이스가 늘면서 오히려 변경을 어렵게 한다. 같은 코드가 세 번쯤 반복되어 공통 패턴이 드러난 뒤에 추출하는 편이 안전하다.
  • 누수되는 추상화(leaky abstraction): 인터페이스가 특정 DB의 개념(예: SQL 조각, 트랜잭션 객체)을 드러내면 교체 가능성이 사라진다.
  • 순환 의존(circular dependency): 모듈 A와 B가 서로 import하면 독립적인 테스트와 분리가 불가능해진다. 의존 방향을 한쪽으로 정리해야 한다.
  • 설계는 한 번에 끝나지 않는다: 요구사항이 드러나면서 경계를 조정하는 리팩터링(refactoring)이 전제되어야 한다.

8. 핵심 정리

  • 설계의 목표는 변경 비용을 낮추는 것이며, 척도는 높은 응집도와 낮은 결합도다.
  • 의존성 역전(DIP)은 비즈니스 로직이 인터페이스를 소유하고 구현이 그것을 따르게 해 의존 방향을 뒤집는다.
  • 구현체 선택은 조립 지점 한 곳에서 하면 테스트와 교체가 쉬워진다.
  • 추상화는 공짜가 아니다. 실제 변경 가능성이 있는 경계에만 도입하고, 중복을 성급히 합치지 않는다.
  • 설계는 한 번에 완성되는 것이 아니라 리팩터링으로 계속 다듬는 과정이다.