Software Architecture/Architectural Styles

Architectural Styles - 04. 클린 아키텍처

클린 아키텍처 — 의존성 규칙을 동심원으로 그리기

2012년, Robert C. Martin은 자신의 블로그에 'Clean Architecture'라는 글을 올렸다. 거기 그린 그림은 동심원이었다 — 가운데 Entities, 그 바깥으로 Use Cases, Interface Adapters, Frameworks & Drivers가 겹겹이 둘러싼 모양. 이 그림은 5년 뒤 같은 이름의 책(, 2017)이 되고, 동시대에 Jeffrey Palermo가 제안한 'Onion Architecture'(2008)와 거의 같은 구조로 통합됐다. 동심원 그림은 인터넷에 널리 퍼졌지만, 정작 핵심은 그림이 아니다. 동심원은 발상을 시각화한 부수물이고, 본질은 '의존성 규칙(dependency rule)'이라는 한 문장에 있다.

비유로 감 잡기 — 양파의 겹과 핵

양파를 반으로 잘라보자. 겉껍질부터 안쪽 핵까지 겹이 있다. 겉껍질은 양파가 자라는 환경(흙, 물, 공기)과 직접 닿는다. 안쪽 핵은 양파의 '본질'이다 — 이 핵에서 새 잎이 돋는다. 겉껍질이 벗겨지거나 상해도 핵만 살면 양파는 다시 자란다. 반대로 핵이 상하면 양파는 죽는다.

클린 아키텍처의 동심원이 이 양파와 닮았다. 가장 바깥(Frameworks & Drivers)은 외부 환경(Spring, JPA, React, AWS)과 직접 닿는 '껍질'이다. 가장 안쪽(Entities)은 시스템의 '핵' 비즈니스 규칙이다. 겉껍질인 Spring이 버전업되거나 JPA가 다른 ORM으로 바뀌어도, 안쪽 핵(Entities)은 영향을 받지 않아야 한다. 반대로 핵(Entities)을 바꾸면 그것을 둘러싼 모든 겹이 흔들린다.

이 비유가 가리키는 핵심 통찰 — 안쪽은 바깥을 모른다. 양파의 핵이 겉껍질이 갈색인지 흰색인지 모르듯, 도메인(Entities)은 자기를 둘러싼 프레임워크가 Spring인지 Django인지 모른다. 이 '모름'이 클린 아키텍처의 전부다.

비유의 한계 — 양파 비유는 '방사형 구조'를 강조하지만, 클린 아키텍처의 동심원은 논리적 분리지 물리적 분리가 아니다. 같은 프로세스 안에 있다. 또한 양파의 각 겹은 생물학적 구조지만, 클린 아키텍처의 각 원은 '추상화 수준'이다. 비유는 '물리적 분리'에서 무너진다.

의존성 규칙 — 클린 아키텍처의 한 문장

Martin이 (2017)에서 정의한 의존성 규칙은 단순하다.

소스 코드 의존성은 항상 안쪽으로만 향해야 한다. 바깥 원의 코드가 안쪽 원의 코드를 import할 수는 있어도, 안쪽 원의 코드가 바깥 원의 코드를 import하면 안 된다.

이 한 문장이 전부다. 동심원은 이 규칙을 시각화한 그림일 뿐이다. 동심원을 그려놓고 의존성 규칙을 어기면, 그것은 '구조만 흉내 낸' 클린 아키텍처가 아니다.

안쪽으로 갈수록 '정책(policy)'이 된다. 바깥쪽으로 갈수록 '메커니즘(mechanism)'이나 '세부(detail)'가 된다. 정책은 무엇을 해야 하는지, 메커니즘은 어떻게 할 건지. 정책(안쪽)이 메커니즘(바깥)을 모르게 하는 게 핵심이다.

동심원의 네 계층

Martin이 정의한 동심원은 바깥에서부터 안쪽으로 네 겹이다.

  • Frameworks & Drivers (가장 바깥) — Spring, React, DB, 웹 프레임워크. 가장 바깥 세계와 닿는 메커니즘.
  • Interface Adapters — 컨트롤러, 프레젠터, 게이트웨이, 리포지토리 구현. 바깥과 안 사이의 변환기.
  • Use Cases — 애플리케이션 특유의 비즈니스 규칙. 시스템이 '무엇을 하는가'의 유스케이스 단위.
  • Entities (가장 안쪽) — 엔터프라이즈 비즈니스 규칙. 도메인 객체. 시스템이 바뀌어도 살아남을 수 있는 핵심 규칙.
flowchart TD
    subgraph L4["4. Frameworks & Drivers (바깥)"]
        F1[Spring Web]
        F2[JPA/Hibernate]
        F3[Kafka Client]
        F4[AWS SDK]
    end
    subgraph L3["3. Interface Adapters"]
        A1[Controller / Presenter]
        A2[Repository Impl]
        A3[Gateway Impl]
    end
    subgraph L2["2. Use Cases"]
        U1[PlaceOrderUseCase]
        U2[CancelOrderUseCase]
    end
    subgraph L1["1. Entities (안쪽)"]
        E1[Order 애그리거트]
        E2[Customer 애그리거트]
    end
    F1 --> A1
    F2 --> A2
    F3 --> A3
    A1 --> U1
    A2 --> U1
    A3 --> U1
    U1 --> E1
    U2 --> E1

화살표가 항상 안쪽을 향한다. Frameworks가 Interface Adapters를 import할 수는 있지만, Entities가 Frameworks를 import하면 규칙 위반이다.

헥사고날과 클린 — 같은 본질, 다른 강조

03편에서 본 헥사고날(포트와 어댑터)과 클린 아키텍처는 본질이 같다. 둘 다 의존성 역전(DIP)을 아키텍처 수준으로 밀어 올려 도메인이 외부를 모르게 만든다. Cockburn이 '육각형의 포트'로 표현한 것을, Martin이 '동심원의 계층'으로 재표현했다.

차이는 강조점에 있다. 헥사고날은 '포트와 어댑터'라는 메커니즘(입출력 교체 가능성)을 강조한다. 클린 아키텍처는 '추상화 수준의 위계'(정책 vs 세부)를 강조한다. 헥사고날이 '어댑터 교환'을 목적으로 한다면, 클린은 '안쪽 계층의 보호'를 목적으로 한다. 결과적으로 같은 구조가 나오지만, 설계 의도가 다르다.

구분 헥사고날 (Cockburn) 클린 (Martin)
출발 입출력 교체 가능성 의존성 규칙을 통한 보호
핵심 어휘 포트(인터페이스) / 어댑터(구현) 엔티티 / 유스케이스 / 어댑터 / 프레임워크
강조 교체(REST ↔ gRPC) 추상화 위계(정책 vs 세부)
시각적 은유 육각형 동심원
본질 의존성 역전 의존성 역전

한 프로젝트에서 두 이름을 섞어 쓰는 경우도 흔하다 — 헥사고날의 포트를 클린의 Use Case로, 어댑터를 Interface Adapters로 부르는 식. 두 패턴이 의존성 역전이라는 같은 원칙의 다른 표현이라는 증거다.

온니언 아키텍처 — Palermo의 변형

Jeffrey Palermo가 2008년에 제안한 온니언(Onion) 아키텍처는 클린과 거의 같다. 동심원 구조, 의존성 역전, 도메인 중심. 차이는 미세하다 — 온니언은 '도메인 서비스'를 명시적으로 분리하고, 인프라스트럭처를 가장 바깥에 둔다. 하지만 의존성 규칙의 본질은 같다. 실무에서 '온니언'이라고 부르든 '클린'이라고 부르든, 구조는 같다. 중요한 건 의존성이 안쪽으로만 흐른다는 점이다.

클린 아키텍처 위반 감지 — 코드에서 어떻게 알아내나

신호 의존성 위반 위치
Entity 클래스에 @Entity, @Table JPA(4계층) → Entities(1계층) 침투
Use Case 인터페이스가 JpaRepository를 extend Frameworks(4) → Use Cases(2) 침투
Entity가 RestTemplate, WebClient를 import Infra(4) → Entities(1) 침투
Use Case 구현체가 @Controller, @RequestMapping Web(4) → Use Cases(2) 침투
단위 테스트가 @SpringBootTest 없이 안 돌아감 도메인·유스케이스가 프레임워크에 묶임
Use Case가 HttpServletRequest, @RequestBody를 앎 웹 요청 모델이 안쪽까지 침투

마지막 두 행이 가장 강력한 신호다. "OrderPlaceService를 순수 JUnit으로 테스트할 수 있는가"가 클린 아키텍처 적용 여부의 가장 확실한 지표다.

설계 사례 — 의존성 규칙을 따르는 네 계층

주문 시스템의 '주문 취소' 유스케이스를 네 계층으로 구현해 본다.

1계층 — Entities (가장 안쪽)

// entity/Order.java — 어떤 프레임워크도 모름
public class Order {
    private final OrderId id;
    private OrderStatus status;
    private Money total;
    private CustomerId customerId;

    public void cancel() {
        if (status != OrderStatus.PENDING && status != OrderStatus.CONFIRMED) {
            throw new IllegalStateException("취소 불가 상태: " + status);
        }
        this.status = OrderStatus.CANCELLED;
    }

    public boolean isCancellable() {
        return status == OrderStatus.PENDING || status == OrderStatus.CONFIRMED;
    }
}

public record OrderId(Long value) {}
public record Money(BigDecimal value) {}
public record CustomerId(Long value) {}
public enum OrderStatus { PENDING, CONFIRMED, CANCELLED, SHIPPED }

순수 Java 객체. 프레임워크 애노테이션이 없다. 비즈니스 규칙(cancel, isCancellable)이 객체 안에 있다. 이 계층은 Spring이 바뀌어도, DB가 바뀌어도, 웹 프레임워크가 바뀌어도 흔들리지 않는다.

2계층 — Use Cases

// usecase/CancelOrderUseCase.java — 인바운드 포트 (인터페이스)
public interface CancelOrderUseCase {
    void cancel(CancelOrderCommand cmd);
}

public record CancelOrderCommand(OrderId orderId, CustomerId customerId, String reason) {}

// usecase/CancelOrderService.java — Use Case 구현 (여전히 프레임워크 모름)
public class CancelOrderService implements CancelOrderUseCase {
    private final OrderRepository orderRepo;        // 아웃바운드 포트 (interface)
    private final EventPublisher eventPublisher;    // 아웃바운드 포트 (interface)

    @Override
    public void cancel(CancelOrderCommand cmd) {
        Order order = orderRepo.findById(cmd.orderId())
            .orElseThrow(() -> new OrderNotFoundException(cmd.orderId()));
        if (!order.customerId().equals(cmd.customerId())) {
            throw new UnauthorizedException();
        }
        order.cancel();
        orderRepo.save(order);
        eventPublisher.publish(new OrderCancelledEvent(order.id(), cmd.reason()));
    }
}

// usecase/OrderRepository.java — 도메인이 정의하는 포트
public interface OrderRepository {
    Optional<Order> findById(OrderId id);
    void save(Order order);
}

// usecase/EventPublisher.java — 도메인이 정의하는 포트
public interface EventPublisher {
    void publish(OrderCancelledEvent event);
}

Use Case 계층도 프레임워크를 모른다. OrderRepository는 인터페이스로, JPA 구현체는 다음 계층에 있다. EventPublisher도 마찬가지. 이 계층은 "주문 취소"라는 비즈니스 정책을 담고, 그 정책을 어디에 저장하든 어떻게 발행하든 관심 없다.

3계층 — Interface Adapters

// adapter/web/OrderController.java — 웹 어댑터
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final CancelOrderUseCase cancelOrder;  // Use Case 인터페이스만

    @PostMapping("/{id}/cancel")
    public ResponseEntity<?> cancel(@PathVariable Long id, @RequestBody CancelRequest req) {
        cancelOrder.cancel(new CancelOrderCommand(
            new OrderId(id),
            new CustomerId(req.customerId()),
            req.reason()
        ));
        return ResponseEntity.noContent().build();
    }
}

// adapter/persistence/JpaOrderRepository.java — 영속성 어댑터
@Repository
public class JpaOrderRepository implements OrderRepository {  // Use Case 포트 구현
    private final OrderJpaRepository jpa;

    @Override
    public Optional<Order> findById(OrderId id) {
        return jpa.findById(id.value()).map(this::toDomain);
    }
    @Override
    public void save(Order order) {
        JpaOrderEntity entity = toEntity(order);
        jpa.save(entity);
    }
    // 도메인 객체 ↔ 영속성 객체 매핑
}

이 계층은 외부(웹, DB)와 도메인 사이의 변환을 담당한다. HTTP 요청을 Use Case 명령으로, 도메인 객체를 영속성 객체로 바꾼다. 프레임워크(Spring, JPA)는 이 계층에서만 등장한다.

4계층 — Frameworks & Drivers

// adapter/persistence/OrderJpaRepository.java — Spring Data JPA 인터페이스
public interface OrderJpaRepository extends JpaRepository<JpaOrderEntity, Long> {
    // Spring Data JPA가 제공하는 메서드만 사용
}

// adapter/persistence/JpaOrderEntity.java — 영속성 객체 (도메인의 Order와 다름)
@Entity
@Table(name = "orders")
public class JpaOrderEntity {
    @Id @GeneratedValue
    private Long id;
    private String status;
    private BigDecimal total;
    private Long customerId;
    // DB 스키마에 맞춘 객체. 도메인 Order와 매핑은 어댑터가 담당.
}

// adapter/messaging/KafkaEventPublisher.java
@Component
public class KafkaEventPublisher implements EventPublisher {
    private final KafkaTemplate<String, Object> kafka;

    @Override
    public void publish(OrderCancelledEvent event) {
        kafka.send("order-events", event.orderId().value().toString(), event);
    }
}

이 계층은 실제 기술(Spring Data JPA, Kafka)을 다룬다. 도메인 객체와 영속성 객체를 분리한 것(Order vs JpaOrderEntity)이 중요하다 — 이 둘이 같으면 JPA가 도메인까지 침투한 것이다.

의존성 규칙 검증 — ArchUnit으로 강제

@AnalyzeClasses(packages = "com.shop")
class CleanArchitectureTest {
    @ArchTest
    static final ArchRule entities는_프레임워크를_모른다 =
        noClasses().that().resideInAPackage("..entity..")
            .should().dependOnClassesThat()
                .resideInAnyPackage("..springframework..", "..javax.persistence..", "jakarta.persistence..");

    @ArchTest
    static final ArchRule usecases는_웹을_모른다 =
        noClasses().that().resideInAPackage("..usecase..")
            .should().dependOnClassesThat()
                .resideInAnyPackage("..spring.web..", "..springframework.web..");

    @ArchTest
    static final ArchRule 의존성은_안쪽으로만_향한다 =
        layeredArchitecture().consideringOnlyDependenciesInLayers()
            .layer("Entities").definedBy("..entity..")
            .layer("UseCases").definedBy("..usecase..")
            .layer("Adapters").definedBy("..adapter..")
            .whereLayer("Entities").mayOnlyBeAccessedByLayers("UseCases", "Adapters")
            .whereLayer("UseCases").mayOnlyBeAccessedByLayers("Adapters");
}

이 테스트가 빌드를 통과해야 클린 아키텍처가 강제된다. 누군가 Entity에 @Entity를 달면 빌드가 깨진다.

클린 아키텍처의 비용과 한계

헥사고날의 비용이 그대로 적용된다 — 보일러플레이트 증가, 단순 CRUD엔 오버엔지니어링, 초기 진입 장벽. 거기에 더해 클린 특유의 비용 하나.

  • 추상화 위계의 주관성 — 어느 규칙이 Entities(전사적)이고 어느 규칙이 Use Cases(애플리케이션 특수)인지 경계가 모호할 때가 많다. Martin은 "시스템이 바뀌어도 살아남을 수 있는 규칙이 Entity"라고 하지만, 현실에선 이 판단이 어렵다.

반대로 클린이 특히 가치를 발휘하는 경우:

  • 도메인 규칙이 풍부하고, 그 규칙이 시스템의 '자산'인 경우 (금융, 핀테크, B2B SaaS)
  • 프레임워크 교체 가능성을 열어두고 싶은 경우 (Spring → 다른 프레임워크)
  • 도메인을 빠르게 단위 테스트하며 비즈니스 규칙을 검증하고 싶은 경우
  • 여러 진입점(REST, gRPC, 배치, CLI)이 같은 유스케이스를 호출해야 하는 경우

의존성 규칙이 전부다

클린 아키텍처를 도입하겠다며 폴더를 동심원으로 나누는 건 쉽다. 하지만 그것만으로는 아무 일도 일어나지 않는다. 진짜는 — 의존성이 안쪽으로만 흐르게 강제하느냐다. 그 강제가 ArchUnit이든 코드 리뷰든 Java 모듈 시스템이든 상관없다. 중요한 건 한 줄 import로 안쪽 계층이 바깥 계층을 끌어오지 못하게 막는 것이다. 강제 없는 동심원은 그림일 뿐, 클린 아키텍처가 아니다.


참고

  • Martin — (Prentice Hall, 2017), Ch.11 (DIP)·Ch.20 (규칙)·Ch.22 (헥사고날·클린·온니언 비교) — 접근 2026-07-20
  • Palermo — "The Onion Architecture" (jeffreypalermo.com, 2008~) 시리즈 — 접근 2026-07-20
  • Martin — "The Clean Architecture" (blog.cleancoder.com, 2012) — 접근 2026-07-20
  • Cockburn — "Hexagonal Architecture" (alistair.cockburn.us, 2005) — 접근 2026-07-20
  • Vernon — (Addison-Wesley, 2013), 아키텍처 장