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

Separation of Concerns: 관심사를 나눠 변경의 영향 범위를 좁히는 설계 원칙

관심사 분리(Separation of Concerns, SoC)는 프로그램을 서로 다른 책임을 가진 부분으로 나누어, 한 부분의 변경이 다른 부분에 미치는 영향을 줄이는 설계 원칙이다. 이 글에서는 응집도(cohesion)와 결합도(coupling)의 관점에서 원리를 설명하고, TypeScript로 뒤섞인 코드를 계층별로 분리하는 과정을 보여준다. 계층 분

By 송민성

1. 개념

관심사 분리(Separation of Concerns, SoC)는 시스템을 서로 다른 관심사(concern) 별로 나누어, 각 부분이 하나의 관심사만 다루도록 설계하는 원칙이다. 이 용어는 에츠허르 다익스트라(Edsger W. Dijkstra)가 1974년 글에서 사용한 것으로 널리 알려져 있다.

여기서 관심사란 시스템이 해결해야 하는 "하나의 이유" 또는 "하나의 책임"이다. 예를 들어 웹 애플리케이션에는 다음과 같은 관심사가 섞여 있다.

  • 입력 검증(validation)
  • 비즈니스 규칙(business logic)
  • 데이터 영속화(persistence)
  • 화면 표현(presentation)
  • 로깅, 인증 같은 횡단 관심사(cross-cutting concern)

SoC는 특정 문법이나 프레임워크가 아니라 경계를 어디에 긋는가에 대한 원칙이다. 함수, 모듈, 클래스, 계층(layer), 서비스 등 어떤 단위에도 적용된다.

2. 왜 사용하는가

  • 변경의 국소화: 요구사항이 바뀔 때 수정해야 하는 코드가 한 곳에 모인다. 할인 정책을 바꾸는데 SQL 쿼리와 HTML 템플릿까지 열어볼 필요가 없어진다.
  • 테스트 용이성: 비즈니스 규칙이 DB나 HTTP에 의존하지 않으면 단위 테스트가 외부 자원 없이 가능하다.
  • 이해 용이성: 한 번에 하나의 관심사만 머릿속에 올리고 읽을 수 있다.
  • 교체 가능성: 저장소를 PostgreSQL에서 다른 것으로 바꾸거나, 표현 계층을 REST에서 CLI로 바꿀 때 나머지를 유지할 수 있다.
  • 병렬 작업: 경계가 명확하면 여러 사람이 충돌 없이 나눠 작업하기 쉽다.

3. 동작 원리

SoC가 효과를 내는 이유는 높은 응집도(high cohesion) 와 낮은 결합도(low coupling) 로 설명된다.

  • 응집도: 한 모듈 안의 요소들이 얼마나 같은 목적을 위해 모여 있는가. 같은 관심사의 코드는 한곳에 모은다.
  • 결합도: 모듈 사이가 얼마나 서로의 내부 사정에 의존하는가. 다른 관심사끼리는 좁고 명시적인 인터페이스(interface)로만 소통한다.

분리의 핵심 수단은 다음과 같다.

  1. 계층화(layering): 표현 → 서비스(도메인) → 저장소처럼 의존 방향을 한쪽으로 제한한다.
  2. 인터페이스 의존: 상위 계층은 구체 구현이 아니라 추상(인터페이스)에 의존한다. 이를 의존성 역전 원칙(Dependency Inversion Principle)이라 한다.
  3. 의존성 주입(Dependency Injection): 구현체를 외부에서 넣어주어 모듈이 스스로 구체 구현을 생성하지 않게 한다.
  4. 횡단 관심사의 분리: 로깅, 인증처럼 여러 곳에 걸치는 관심사는 미들웨어, 데코레이터 등으로 따로 뺀다.

경계를 판단하는 간단한 질문은 "이 코드가 바뀌어야 하는 이유가 몇 가지인가?"이다. 이유가 둘 이상이면 관심사가 섞여 있을 가능성이 높다.

4. 코드 예제

먼저 관심사가 뒤섞인 주문 처리 함수다. 검증, 가격 계산, 저장, 응답 형식이 한 함수에 있다.

typescript
// before.ts - 관심사가 뒤섞인 버전 const db: { id: number; total: number }[] = []; function handleCreateOrder(body: { price: number; qty: number; vip: boolean }) { if (body.price <= 0 || body.qty <= 0) { return { status: 400, body: "invalid input" }; } let total = body.price * body.qty; if (body.vip) total = Math.round(total * 0.9); const order = { id: db.length + 1, total }; db.push(order); return { status: 201, body: JSON.stringify(order) }; } console.log(handleCreateOrder({ price: 1000, qty: 3, vip: true }));

이 함수는 입력 규칙, 할인 정책, 저장 방식, HTTP 응답 형식 중 어느 것이 바뀌어도 수정해야 한다. 이를 계층별로 분리한다.

typescript
// after.ts - 관심사를 분리한 버전 // 1) 도메인: 순수 함수. 외부 의존성이 없다. interface OrderInput { price: number; qty: number; vip: boolean; } function calculateTotal(input: OrderInput): number { const total = input.price * input.qty; return input.vip ? Math.round(total * 0.9) : total; } // 2) 검증: 입력 규칙만 담당한다. function validate(input: OrderInput): string | null { if (input.price <= 0 || input.qty <= 0) return "invalid input"; return null; } // 3) 저장소: 인터페이스로 추상화한다. interface Order { id: number; total: number; } interface OrderRepository { save(total: number): Order; } class InMemoryOrderRepository implements OrderRepository { private orders: Order[] = []; save(total: number): Order { const order = { id: this.orders.length + 1, total }; this.orders.push(order); return order; } } // 4) 서비스: 흐름을 조합하되 HTTP를 모른다. class OrderService { constructor(private readonly repo: OrderRepository) {} create(input: OrderInput): Order { const error = validate(input); if (error) throw new Error(error); return this.repo.save(calculateTotal(input)); } } // 5) 표현 계층: 응답 형식만 담당한다. function handleCreateOrder(service: OrderService, body: OrderInput) { try { const order = service.create(body); return { status: 201, body: JSON.stringify(order) }; } catch (e) { return { status: 400, body: (e as Error).message }; } } // 조립(composition root) const service = new OrderService(new InMemoryOrderRepository()); console.log(handleCreateOrder(service, { price: 1000, qty: 3, vip: true })); console.log(handleCreateOrder(service, { price: -1, qty: 3, vip: false })); // 도메인 규칙은 DB 없이 바로 테스트할 수 있다. console.assert(calculateTotal({ price: 1000, qty: 3, vip: true }) === 2700);

calculateTotal은 순수 함수(pure function)이므로 DB나 HTTP 없이 검증된다. 저장소를 교체해도 OrderService와 calculateTotal은 수정할 필요가 없다.

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

SoC는 알고리즘이 아니라 설계 원칙이므로 고유한 시간복잡도가 없다. 다만 성능 측면에서 알아둘 점은 다음과 같다.

  • 계층 사이의 함수 호출과 객체 변환(DTO 매핑 등)은 추가 비용을 만든다. 일반적인 웹 서비스에서는 네트워크와 DB I/O에 비해 대개 작지만, 측정 없이 단정하지 말고 프로파일링으로 확인한다.
  • 프로세스나 서비스 단위로 분리하면(예: 마이크로서비스) 함수 호출이 네트워크 호출로 바뀌어 지연 시간과 장애 지점이 늘어난다. 이는 SoC의 비용이 가장 크게 드러나는 지점이다.
  • 코드 구조의 비용은 실행 속도보다 파일 수, 간접 참조(indirection) 증가에 따른 탐색 비용으로 나타나는 경우가 많다.

6. 실무 사용 사례

  • 계층형 아키텍처: Controller → Service → Repository 구조는 Spring, NestJS 등 많은 웹 프레임워크에서 쓰이는 대표적 SoC 적용이다.
  • MVC / MVVM: 데이터(Model), 화면(View), 입력 처리(Controller 또는 ViewModel)를 나눈다.
  • 프런트엔드: HTML(구조), CSS(표현), JavaScript(동작)의 분리가 고전적인 예다. React에서는 표시 컴포넌트와 데이터 로직을 커스텀 훅으로 분리하는 방식이 쓰인다.
  • 미들웨어: Express나 NestJS의 미들웨어로 인증, 로깅, 에러 처리 같은 횡단 관심사를 비즈니스 코드 밖으로 뺀다.
  • CQRS: 읽기 모델과 쓰기 모델의 관심사를 나누는 패턴이다.
  • 인프라 분리: 설정(config)을 코드에서 분리해 환경변수로 주입하는 것도 관심사 분리다.

7. 주의할 점

  • 과도한 분리(over-engineering): 변경 이유가 하나뿐인 20줄짜리 스크립트를 5개 계층으로 쪼개면 읽기만 어려워진다. 분리는 실제 변경 압력이나 테스트 필요가 있을 때 도입한다.
  • 기술 기준 분리의 함정: 컨트롤러, 서비스, 저장소로만 나누면 기능 하나를 바꿀 때 여러 폴더를 오가야 한다. 기능(도메인) 단위로 묶고 그 안에서 계층을 나누는 구성이 더 나을 때가 많다.
  • 누수되는 추상화(leaky abstraction): 서비스 계층이 ORM 엔티티나 SQL 방언에 의존하면 분리는 형식뿐이다.
  • 의존 방향 위반: 도메인이 표현 계층의 타입(요청 객체 등)을 import하기 시작하면 경계가 무너진다.
  • 빈 위임 계층: 아무 일도 하지 않고 다음 계층으로 호출만 넘기는 클래스는 비용만 늘린다.
  • SRP와의 혼동: 단일 책임 원칙(Single Responsibility Principle)은 클래스·모듈 수준에서 "변경 이유 하나"를 말하고, SoC는 그보다 넓은 시스템 수준의 원칙이다. 둘은 관련되지만 같은 개념은 아니다.

8. 핵심 정리

  • SoC는 서로 다른 책임을 가진 코드를 분리하여 변경의 영향 범위를 좁히는 원칙이다.
  • 목표는 높은 응집도와 낮은 결합도이며, 수단은 계층화, 인터페이스 의존, 의존성 주입, 횡단 관심사 분리다.
  • 경계 판단 기준은 "이 코드가 바뀌는 이유가 몇 가지인가"이다.
  • 순수한 도메인 로직을 I/O에서 떼어내면 테스트가 쉬워진다.
  • 분리에는 간접 참조와 파일 증가라는 비용이 있으므로, 실제 변경과 테스트 요구에 맞춰 적정 수준에서 멈춘다.