소프트웨어 개발을 하다 보면 한 부분을 변경했더니 다른 곳이 모두 깨지는 경험을 누구나 한 번쯤 해봤을 거예요.
이는 ==높은 결합도(Coupling)==의 전형적인 문제죠. 유지보수가 어려워지고, 기능 변경에 따른 위험이 커져요.
그렇다면 우리는 어떻게 커플링을 줄이고 구조적인 설계를 적용할 수 있을까요?
커플링이란?#
커플링(Coupling)이란 모듈이나 클래스가 서로 얼마나 강하게 의존하는지를 나타내는 결합도를 말해요
- 높은 결합도: 상호 의존성이 크고, 하나를 바꾸면 다른 부분에 연쇄적인 영향
- 낮은 결합도: 역할과 책임이 잘 분리되어 있어 변경이 영향을 최소화
결국 우리가 추구해야 하는 방향은 "높은 응집도, 낮은 결합도"를 가진 코드를 작성하는 거예요.
응집도는 클래스나 모듈 내부의 구성 요소들이 하나의 목적에만 집중하는 정도를 의미하고 있어요.
커플링의 문제점
- 유지보수 난이도 상승
- 변경 범위 확대
- 테스트와 배포에서 불안정성 증가
이 문제를 해결하기 위해 아키텍처를 도입하는 이유가 생겨요.
아키텍처는 단순히 코드 구조를 예쁘게 만드는 것이 아니라, 변경에 강한 시스템을 만들기 위한 약속이니까요.
커플링 확인 방법#
- 의존성 그래프 분석: 클래스나 모듈 간 연결성을 시각화
- 코드 리뷰 & 정적 분석 도구 활용
- 디미터 법칙(Law of Demeter) 점검: 객체 내부 구조를 외부에 노출하지 않고 캡슐화 유지
SOLID 원칙과 DI#
아키텍처의 근간에는 SOLID 원칙이 있어요.
그 중 **SRP(단일 책임 원칙)**과 IoC(제어 역전), **DI(의존성 주입)**은 결합도를 낮추는 핵심 요소예요.
DI에는 세 가지 방법이 있어요:
- 생성자 주입(DI via Constructor) – 가장 안전하고 선호되는 방식
- Setter 메서드 주입 – 런타임 시 오류 가능성 존재
- 필드 주입(Class field) – 테스트와 유지보수에 어려움
생성자 DI 예시
class SomeService {
final SomeDependency dependency;
SomeService(this.dependency);
}
Setter DI
class SomeService {
SomeDependency? dependency;
set setDependency(SomeDependency value) => dependency = value;
}
ServiceLocator (get_it 패키지 예시)
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
void setup() {
getIt.registerSingleton<SomeService>(SomeService(SomeDependency()));
}
// 사용
final service = getIt<SomeService>();
생성자 DI를 우선 사용하고, 필요에 따라 Setter나 ServiceLocator를 사용할 수 있어요.
외부 객체를 직접 생성하지 않고, 외부에서 받아 쓰는 습관만으로도 커플링이 크게 줄어들어요.
역할과 책임의 분리#
아키텍처 적용의 핵심은 **역할(Role)**과 **책임(Responsibility)**을 구분하는 거예요.
- 역할: 무엇을 해야 하는가 (추상화)
- 책임: 어떻게 할 것인가 (구체화)
역할을 나누지 않고 모든 책임을 한 클래스에 담아버리면, 변경 영향이 커지고 재사용성도 떨어져요.
좋은 협력과 나쁜 협력#
코드상에선 협력 방식에서도 결합도 차이가 생겨요.
- 좋은 협력: Tell, Undirectional, Weak
- 상태를 가져오지 말고 명령하라 (Tell)
- 단방향 의존성 유지
- 변경에 영향을 최소화하는 약한 의존성
- 나쁜 협력: Ask, Bidirectional, Strong
- 상태를 직접 접근(Ask) → 캡슐화 깨짐
- 양방향 의존 → 스파게티 코드 유발
- 강한 의존성 → 변경 시 연쇄 오류 발생
Flutter에서 아키텍처 적용#
Flutter 개발에서는 관심사 분리를 통해 주요 영역을 Presentation, Business Logic, Data Layer로 구분하기도 해요.
- Presentation: 화면(UI)과 사용자 입력 처리
- Business Logic: 계산과 도메인 규칙 적용
- Data Layer: API, DB, 캐싱 등의 데이터 저장·조회
예를 들어 Controller 클래스에 입력 처리와 데이터 로딩을 모두 넣으면 거대한 클래스가 되어 커플링이 심해지죠.
이를 쪼개서 레이어 역할별로 분리하는 것이 Feature-First 아키텍처의 핵심이다.
Feature-First 아키텍처 예시#
각 기능마다 아래와 같이 계층 분리를 적용해요.
lib/
├── features/
│ └── product/
│ ├── domain/
│ │ ├── entities/
│ │ └── usecases/
│ ├── data/
│ │ ├── models/
│ │ └── repositories/
│ ├── presentation/
│ │ ├── screens/
│ │ └── widgets/
이렇게 분리하면 각 기능별로 독립적인 개발·테스트가 가능해요.
데이터 전달 패턴#
Flutter에서 페이지 간 데이터 전달 시 두 가지 방식이 있어요.
- extra로 객체 전달 – 화면 전환 시 바로 데이터를 넘김
- 장점: 즉시 사용 가능
- 단점: 없거나 빈 값일 때 예외 처리 필요
- 식별값 전달 후 데이터 조회 – 다음 페이지에서 ID로 다시 가져옴
- 장점: 흐름이 명확하고 무결성 확보
- 단점: 추가 API 호출 필요
아키텍처 평가: C4 모델#
C4 모델은 시스템을 네 가지 수준으로 나눠 시각적으로 분석한다.
- Context – 시스템과 외부 환경의 상호작용
- Container – 실행 가능한 단위로 분리
- Component – 컨테이너 내부 구성 요소
- Code – 실제 클래스와 함수 수준
이를 통해 의존성, 책임 범위를 시각적으로 분석하면서 아키텍처 품질을 정량적으로 평가할 수 있어요.
Anemic Domain Model 탈출#
비즈니스 로직이 없는 객체는 빈약한 도메인 모델이에요. 이를 방지하려면:
- Domain 계층: Entity, Value Object, Domain Event 등 개념과 규칙 정의
- Application 계층: 유스케이스 실행, 도메인과 외부를 연결
- Persistence 계층: DB 저장과 조회
- Infrastructure 계층: 외부 SDK, 메시징, 오류보고
이렇게 수평적(역할별)·수직적(기능별) 분리를 병행하면 클린 아키텍처를 유지할 수 있어요.
Wisdom is knowing what to do next; Skill is knowing how ot do it, and Virtue is doing it.
— David Jordan