← all posts
DEV 2026.05.02 · 17 min read Intermediate

아키텍처는 폴더 구조가 아니다 — 변경 비용을 통제하는 결정들

소프트웨어 아키텍처의 본질부터 의존성이 변경을 전파하는 메커니즘, SOLID 원칙이 Hexagonal Architecture로 이어지는 논리까지, 설계 결정의 이유를 추적한다.


“우리는 MVC 패턴을 씁니다”라는 팀과, “도메인 레이어가 어떤 프레임워크도 몰라야 하는 이유를 아는 팀”의 코드는 6개월 후 완전히 다른 모습이다. 아키텍처를 폴더 구조나 기술 스택 선택으로 이해하면, 코드베이스가 성장할수록 왜 변경 비용이 기하급수적으로 증가하는지 영원히 파악할 수 없다. 아키텍처란 무엇이고, 그 결정은 코드에 어떻게 나타나는가?

아키텍처 = 변경 비용을 통제하는 결정

Martin Fowler는 아키텍처를 “변경하기 어려운 결정들의 집합”이라 정의했다. Uncle Bob은 여기서 한 발 더 나아간다. “좋은 아키텍처는 결정을 최대한 미룰 수 있게 한다.” DB를 MySQL로 할지 PostgreSQL로 할지, 프레임워크를 Spring으로 할지 Quarkus로 할지 — 이런 결정을 나중으로 미룰 수 있다면, 더 많은 정보를 가지고 더 나은 결정을 내릴 수 있다.

이것이 가능하려면 조건이 하나 있다. 도메인이 DB와 프레임워크를 몰라야 한다.

아키텍처 결정 없이 개발하면 코드는 예측 가능한 경로로 무너진다. 처음에는 OrderController → OrderService → OrderRepository의 단순한 구조다. 3개월 후 OrderService에 결제 SDK, 이메일 발송, 재고 차감이 더해진다. 6개월 후에는 의존성이 사방으로 퍼져 어디를 바꾸면 어디가 깨질지 예측할 수 없다. Uncle Bob이 “Big Ball of Mud”라 부른 이 상태는 아키텍처 원칙의 부재가 만들어낸 필연적 결과다.

의존성 = 변경이 전파되는 경로

아키텍처의 본질을 이해하면 의존성이 다르게 보인다. A → B 관계가 있을 때 B가 변경되면 A도 영향을 받는다. 의존성 화살표의 방향이 곧 변경 전파의 방향이다.

이것이 전통적인 레이어드 아키텍처의 핵심 문제다. Controller → Service → Repository → DB라는 구조에서 의존성은 DB 방향으로 흐른다. 도메인(Service)이 인프라(JPA)를 알고 있으므로, DB 스키마가 바뀌면 비즈니스 로직까지 영향을 받는다. 더 중요한 것(비즈니스 규칙)이 덜 중요한 것(JPA)에 종속된다.

변경 전파의 범위는 Fan-out과 Fan-in으로 측정할 수 있다. Fan-out이 높은 클래스는 변경 시 많은 의존성을 확인해야 하고, Fan-in이 높은 클래스는 변경 시 많은 의존자에게 파급된다. OrderService가 결제 SDK, 이메일, 재고, Kafka를 직접 의존할 때(Fan-out 높음), 이 클래스 하나를 건드리면 모든 의존성이 흔들린다.

Ripple Effect

구체 클래스에 직접 의존하면, 구현 변경이 의존성 체인을 따라 전파된다. JpaOrderRepository의 메서드 시그니처가 바뀌면 OrderService가 영향을 받고, OrderController까지 수정이 필요해진다. 변경 1개가 파급 4개 이상으로 이어진다.

인터페이스를 도입하면 이 전파를 차단할 수 있다. OrderServiceOrderRepository 인터페이스에만 의존하면, JpaOrderRepository의 내부 구현이 바뀌어도 인터페이스 시그니처가 유지되는 한 OrderService는 변경할 필요가 없다.

SOLID가 아키텍처 패턴의 이유다

SOLID 원칙을 클래스 레벨의 OOP 지침으로만 이해하면, Hexagonal Architecture의 Port가 왜 인터페이스인지, 레이어를 왜 나누는지 설명할 수 없다. 각 원칙은 구체적인 아키텍처 결정의 이유다.

SRP는 레이어 분리의 이유다. 비즈니스 팀이 비즈니스 규칙을 바꾸면 도메인 레이어가 변경되고, DBA가 DB 스키마를 바꾸면 인프라 레이어가 변경된다. 변경 이유가 다른 것들은 다른 레이어에 있어야 한다.

OCP는 Adapter 패턴의 이유다. PaymentPort 인터페이스를 한 번 정의하면, 새 결제 수단 추가는 새 Adapter 클래스 파일 하나를 추가하는 것으로 끝난다. 기존 PlaceOrderService 코드는 단 한 줄도 수정하지 않는다. 확장에는 열려있고, 기존 코드 수정에는 닫혀있다.

DIP는 Hexagonal Architecture의 핵심이다. OrderService(고수준)가 JpaOrderRepository(저수준)에 직접 의존하는 것이 자연스러운 방향이다. DIP는 이것을 역전시킨다. JpaOrderRepositoryOrderRepository 인터페이스를 구현하게 하고, OrderService는 인터페이스에만 의존한다. 인프라가 도메인 계약을 구현하는 방향으로 역전된다.

// DIP 적용 후 — 도메인이 인프라를 전혀 모름
@Service
public class PlaceOrderService implements PlaceOrderUseCase {
    private final OrderRepository orderRepository;   // 인터페이스
    private final PaymentPort paymentPort;           // 인터페이스
    private final OrderEventPublisher eventPublisher; // 인터페이스

    // JPA, Kafka, SMTP — 어디에도 없다
}

// 인프라가 도메인 계약을 구현
@Repository
public class JpaOrderRepository implements OrderRepository { /* ... */ }

@Component
public class KafkaOrderEventPublisher implements OrderEventPublisher { /* ... */ }

레이어드 아키텍처가 만드는 구조적 문제

레이어드 아키텍처 자체가 잘못된 것이 아니다. 문제는 구현 방식이다. @Entity 클래스에 비즈니스 로직이 혼재하면 JPA 없이 도메인을 테스트할 수 없다. 기본 생성자 강제, LAZY 로딩, 양방향 관계 — JPA의 제약이 비즈니스 규칙을 오염시킨다.

Service 레이어가 비대해지는 것도 구조적 결과다. “Presentation은 API만, Repository는 DB만” 규칙을 지키면, 그 사이의 모든 것이 Service로 집중된다. 비즈니스 로직, 외부 API 호출, 트랜잭션 조율, DTO 변환, 캐싱, 이벤트 발행 — 전부 Service 레이어에 쌓인다.

결과는 테스트 불가능한 코드다. “최소 주문 금액 미달 시 주문 실패”라는 단순한 비즈니스 규칙을 테스트하려면 JPA, 결제 API, 이메일 서버, Kafka가 전부 필요하다. @SpringBootTest가 강제되고, 테스트 실행에 30초가 걸린다.

도메인 객체(Order)에 비즈니스 규칙을 두고, Service는 흐름 조율만 담당하면 테스트가 달라진다.

// 도메인 객체에 비즈니스 규칙
public class Order {
    public void place(Money minOrderAmount) {
        if (this.calculateTotal().isLessThan(minOrderAmount)) {
            throw new MinOrderAmountViolationException(minOrderAmount);
        }
        this.status = OrderStatus.PLACED;
    }
}

// Spring 없이, DB 없이, 10ms 안에 실행
@Test
void 최소주문금액_미달_시_주문_실패() {
    Order order = Order.create(items);
    assertThatThrownBy(() -> order.place(Money.of(10_000)))
        .isInstanceOf(MinOrderAmountViolationException.class);
}

언제 Hexagonal이고 언제 레이어드인가

아키텍처 선택에 정답은 없다. 도메인 복잡도, 팀 역량, 외부 시스템 교체 가능성을 종합해 판단한다.

단순 CRUD 서비스, 2주 내 폐기할 프로토타입, 팀 전체가 동의하지 않은 상태 — 이런 경우 Hexagonal은 과잉이다. 반대로 복잡한 비즈니스 규칙이 있고, 외부 시스템(결제, 알림, 배송) 교체 가능성이 있고, 빠른 단위 테스트가 필요하다면 Hexagonal이 합리적 선택이다.

트레이드오프

Hexagonal의 비용은 명확하다. 파일 수 증가, Entity↔Domain 매핑 코드, 팀 학습 비용. 그 대가로 얻는 것도 명확하다. 외부 시스템 교체 시 변경 비용 O(1), Spring 없이 수십 ms 단위 테스트, 새 팀원이 Port만 보면 구현 위치를 아는 명확한 구조.

레이어드에서 Hexagonal로 전환이 필요하다면 한 번에 바꾸려 하지 마라. 단계별로 진행하면 된다. Repository 인터페이스를 도메인으로 올리는 것이 첫 단계다(1-2일). 외부 의존성을 Port로 추상화한다(2-3일/Port). Entity와 Domain Model을 분리한다(1주). UseCase 인터페이스를 도입한다(1-2일). 각 단계마다 테스트가 통과하는지 확인하며 진행한다.

정리

  • 아키텍처는 폴더 구조가 아니다. 의존성 방향을 결정하는 것이 아키텍처다.
  • 의존성은 변경이 전파되는 경로다. 구체 클래스 의존을 인터페이스 의존으로 바꾸면 Ripple Effect가 인터페이스 경계에서 차단된다.
  • SRP는 레이어 분리의 이유, OCP는 Adapter 패턴의 이유, DIP는 Hexagonal Architecture의 핵심이다. SOLID를 아키텍처 수준에서 읽으면 어떤 패턴도 “왜 이렇게 설계됐는가”를 스스로 유도할 수 있다.
  • 레이어드 아키텍처의 문제는 의존성이 DB 방향으로 흐른다는 것이다. Hexagonal은 이 방향을 역전시킨다.
  • 도메인이 인프라를 모를 때, 인프라 변경은 Adapter 파일 교체로 끝나고 도메인 테스트에 인프라가 불필요해진다.

다음 글에서는 Hexagonal Architecture의 구체적인 구조 — Driving Port, Driven Port, Adapter