자바 아키텍처·Spring 학습 노트 목차

아키텍처 — 왜 코드를 계층으로 나누나

기능이 몇 개일 때는 코드를 어떻게 두든 별문제가 없다. 그런데 기능이 수십 개로 늘면 어김없이 이런 일이 벌어진다 — "여기를 고쳤더니 엉뚱한 저기가 터진다." 아키텍처란 결국 이 변경이 번지는 범위를 좁히는 기술이다. 그리고 그 모든 갈래(레이어드·헥사고날·클린·DDD)는 사실 의존성을 어느 방향으로 흐르게 할 것인가라는 한 가지 질문의 변주다. 이 질문 하나를 들고, 코드로 차근차근 따라가 보자.

가장 기본 — 레이어드

먼저 요청이 들어와 DB까지 가는 길을 역할별 층으로 나누는 데서 시작한다. 패키지로 보면 보통 이렇게 생겼다.

com.shop.order
 ├─ controller   OrderController        (HTTP 요청/응답)
 ├─ service      OrderService           (업무 규칙·트랜잭션 경계)
 └─ repository   OrderRepository        (DB 접근)
다이어그램 로딩 중…

Controller는 HTTP를 받고 응답을 만드는 일(검증·직렬화)만 하고 업무 로직은 두지 않는다. Service는 업무 규칙트랜잭션 경계를 책임지며, 보통 한 유스케이스가 한 서비스 메서드가 된다. Repository는 DB 접근만 맡아 SQL·JPA를 그 안에 가둔다. 핵심은 의존성이 위에서 아래로 한 방향이라는 것이다. 그래서 컨트롤러를 뜯어고쳐도 리포지토리는 멀쩡하다.

왜 굳이 한 방향이어야 할까? 만약 의존이 양방향이면 — 서비스가 컨트롤러를 알고 있으면 — 변경이 서로 번져 격리가 깨진다. 한 방향이면 아래는 위를 아예 모르기 때문에, 위를 마음껏 바꿔도 아래가 안전하다. 결합도를 낮추는 가장 단순한 규칙인 셈이다. 작은·중간 규모 서비스라면 이 레이어드로 충분하다. 다만 자세히 보면 약점이 하나 있다 — 도메인(업무 규칙)이 아래쪽(DB·프레임워크)에 의존한다는 점이다.

@Service
class OrderService {
    private final OrderJpaRepository repo;   // ← 도메인 로직이 'JPA'라는 기술에 직접 묶인다
    void place(Order o) { /* 업무 규칙 ... */ repo.save(o); }
}

이렇게 서비스가 JPA 엔티티·리포지토리에 직접 묶이면, DB를 갈아 끼우거나 도메인만 따로 테스트하기가 어려워진다. 게다가 단순 CRUD라면 서비스가 사실상 DB 호출을 옮겨 적는 얇은 껍데기(흔히 트랜잭션 스크립트라 부른다)가 되곤 하는데, 규칙이 복잡해질수록 이 구조는 한계를 드러낸다. 이 약점을 푸는 게 다음 단계다.

의존성을 뒤집다 — 헥사고날(포트·어댑터)

그 약점의 해법은 발상의 전환, 곧 의존성 역전이다. 도메인을 가운데에 두고, 바깥의 웹·DB·메시지를 어댑터로 꽂는다. 도메인은 *인터페이스(포트)*만 알고, 그 구현(어댑터)은 바깥에서 끼운다. 포트는 둘로 나뉜다 — 인바운드 포트(driving)는 코어가 바깥에 제공하는 유스케이스 인터페이스이고, 아웃바운드 포트(driven)는 코어가 바깥에 요구하는 기능 인터페이스다.

다이어그램 로딩 중…

코드로 보면 방향이 분명해진다. 코어는 인터페이스만 정의하고, 어댑터가 그걸 구현한다.

// ── 코어(도메인) 안: 인터페이스만 안다 ──
public interface PlaceOrderUseCase {            // 인바운드 포트 (코어가 제공)
    OrderId place(PlaceOrderCommand cmd);
}
public interface LoadOrderPort {                // 아웃바운드 포트 (코어가 요구)
    Optional<Order> findById(OrderId id);
}

@Service
class PlaceOrderService implements PlaceOrderUseCase {
    private final LoadOrderPort loadOrder;      // ← 'JPA'가 아니라 '포트'에 의존
    public OrderId place(PlaceOrderCommand cmd) { /* 순수 업무 규칙 */ }
}

// ── 바깥(어댑터): 포트를 구현 ──
@Repository
class OrderPersistenceAdapter implements LoadOrderPort {   // 어댑터 → 코어 방향 의존
    private final OrderJpaRepository jpa;
    public Optional<Order> findById(OrderId id) { ... }
}

의존 화살표가 어댑터 → 코어로 향한다는 점이 핵심이다. 도메인은 JPA도 Kafka도 모른 채 인터페이스만 알기 때문에, 구현을 JPA에서 Mongo로 갈아 끼우거나 — 테스트에서 LoadOrderPort가짜 구현을 꽂아 도메인만 단독으로 검증할 수 있다. 왜 이렇게 뒤집는가 하면, 핵심은 "변하기 쉬운 세부(DB·프레임워크)가 변하기 어려운 정책(도메인)에 의존하게" 만드는 데 있다 — SOLID의 DIP다. 정책이 세부에 의존하면 세부가 바뀔 때 정책이 흔들리지만, 거꾸로 두면 흔들릴 일이 없다.

의존성은 안쪽으로만 — 클린 아키텍처

엉클 밥의 클린 아키텍처는 같은 원리를 동심원으로 다시 그린다 — 의존성은 항상 안쪽을 향한다.

다이어그램 로딩 중…

안쪽의 엔티티(가장 변하지 않는 핵심 규칙)와 유스케이스(앱 고유의 흐름)는 바깥의 프레임워크를 전혀 모르고, 바깥의 컨트롤러·DB 코드가 안쪽이 정의한 인터페이스를 구현할 뿐이다. 경계를 넘는 모든 곳은 인터페이스(DIP)로 가른다. 그래서 Spring을 통째로 들어내도 도메인은 그대로 산다. 헥사고날과 말만 다를 뿐 같은 이야기인데, 이유도 같다 — 안쪽일수록 잘 변하지 않는 핵심이고 바깥일수록 자주 바뀌는 세부이니, 의존성을 안쪽으로 고정해 두면 세부가 바뀌어도 핵심이 안 흔들린다. 한 가지 실천적 함의는, 엔티티에 @Entity 같은 ORM 애너테이션을 덕지덕지 붙이면 이미 핵심이 프레임워크에 묶인 것이라, 엄격히 가려면 도메인 모델과 영속성(JPA) 모델을 분리하기도 한다는 점이다(작은 프로젝트에선 과할 수 있어 절충한다).

도메인을 모델로 — DDD 전술 설계

규모가 더 커지면 의존성 방향만으로는 부족해진다. 모델 자체가 흐려지기 때문이다. **DDD(Domain-Driven Design)**는 도메인을 코드의 한복판에 두는 방법론으로, 먼저 전술적 빌딩 블록부터 보자. 가장 기본은 엔티티와 값 객체의 구분이다. 엔티티는 식별자(id)로 동일성을 가르고(주문 #1과 #2는 내용이 같아도 다른 주문이다), 값 객체는 속성 값으로 동등성을 판단하며 불변으로 둔다(금액 1,000원은 어느 1,000원이든 같다).

record Money(BigDecimal amount, Currency currency) {     // 값 객체 — 불변, 값으로 동등
    Money add(Money other) { return new Money(amount.add(other.amount), currency); }
}
class Order {                                            // 엔티티 — id로 동일성
    private final OrderId id;
    private final List<OrderLine> lines;
    void addLine(Product p, int qty) {                  // 불변식을 '루트'가 강제
        if (isClosed()) throw new IllegalStateException("마감된 주문");
        lines.add(new OrderLine(p, qty));
    }
}

여기서 Order는 단순한 데이터 덩어리가 아니라 함께 변해야 하는 객체들(주문+주문항목)의 일관성 경계, 곧 애그리거트이고 Order가 그 루트다. 외부에서는 루트를 통해서만 내부를 바꿔(addLine), "마감된 주문엔 항목을 못 더한다" 같은 불변식을 보호한다. 애그리거트를 어떻게 잡느냐는 DDD에서 가장 어려운 결정인데, Vaughn Vernon의 효과적 애그리거트 설계 4규칙이 길잡이가 된다 — ① 진짜 불변식만 한 경계 안에 모으고, ② 애그리거트는 작게 설계하며(거대 애그리거트는 안티패턴 — "확장도 성능도 안 되고 악몽이 된다"), ③ *다른 애그리거트는 객체 참조가 아니라 식별자(id)로 참조하고, ④ 경계를 넘는 규칙은 결과적 일관성으로 처리한다. (출처: Aggregate Design Rules — Vernon, Reference Other Aggregates by Identity.)

class Order {
    private final OrderId id;
    private CustomerId customerId;   // ✅ Customer 객체가 아니라 'id'로 참조 (규칙 ③)
}

이 규칙들이 왜 중요하냐면, 한 트랜잭션에서 하나의 애그리거트만 수정하라는 결론으로 이어지기 때문이다(규칙 ②④). 주문을 저장하면서 재고 애그리거트까지 같은 트랜잭션에 강하게 묶으면 경합과 락이 커져 확장성이 무너진다 — 그래서 "주문 생성 → 재고 차감"은 도메인 이벤트를 발행해 결과적 일관성으로 처리한다(메시징·MSA 편에서 다시 만난다). 한 엔티티에 자연스럽게 속하지 않는 로직(예: 환율 변환)은 도메인 서비스에 둔다.

도메인을 언어로 — DDD 전략 설계

전술이 코드 안의 구조라면, 전략은 모델 사이의 경계다. 먼저 유비쿼터스 언어 — 기획과 개발이 같은 용어로 말하고 그 용어가 그대로 코드 클래스명이 되게 해, 번역 과정에서 새는 손실을 없앤다. 그리고 바운디드 컨텍스트 — "회원"이라는 같은 단어가 결제팀과 배송팀에서 다른 의미라면 모델을 아예 나눠, 컨텍스트마다 독립된 모델을 둔다.

다이어그램 로딩 중…

컨텍스트들이 어떻게 관계 맺느냐컨텍스트 맵이다 — 외부 모델이 우리 도메인을 오염시키지 않게 번역하는 안티부패 계층(ACL), 두 팀이 공유하는 공유 커널, 한쪽이 다른 쪽에 맞추는 고객-공급자 등. 그리고 이 바운디드 컨텍스트가 바로 마이크로서비스 경계의 후보가 된다(MSA 편으로 이어진다). 읽기와 쓰기 모델을 분리하는 CQRS, 상태가 아니라 상태를 바꾼 이벤트의 연속을 저장하는 이벤트 소싱도 복잡한 도메인에서 함께 쓰이지만, 이들은 필요할 때 꺼내는 도구이지 기본형이 아니다.

그래서 어디까지 나눌까 — 모듈러 모놀리스

여기서 흔한 오해를 짚어야 한다. "이렇게 경계를 나눌 거면 처음부터 마이크로서비스로 가야 하나?" — 아니다. 논리적 경계배포 경계는 다른 문제다. 모듈러 모놀리스하나의 배포 단위 안에서 모듈 경계(바운디드 컨텍스트)를 명확히 한 구조로, 헥사고날·DDD를 그대로 적용하되 네트워크·분산 트랜잭션 비용 없이 응집과 경계를 얻는다. 그래서 단일 배포·간단한 디버깅·ACID 트랜잭션·낮은 인프라 비용을 누리면서도, 나중에 그 경계를 따라 서비스로 떼어내기 쉽다. 마이크로서비스가 정당화되는 건 모듈마다 다르게 확장해야 하거나, 팀이 커져 독립 배포가 절실할 때다. 업계 다수의 권고가 "모놀리스(모듈러)로 시작하라"인 이유가 여기 있다 — 분산은 공짜가 아니다. (출처: Thoughtworks — modular monolith better way, Hexagonal: the "Microservices First" Mistake.)

같은 맥락에서, 헥사고날·클린·DDD도 복잡한 도메인에서 빛난다 — 단순 CRUD에 포트·어댑터·애그리거트를 다 깔면 경계만 늘고 생산성은 줄어드는 오버엔지니어링이 된다. 도메인 복잡도에 맞춰 레이어드 → 헥사고날 → DDD점진적으로 도입하는 게 정석이다.

그래서 Spring은 어디서 돕나

지금까지 본 모든 구조에는 공통의 배선 작업이 깔려 있다 — 객체를 만들고, 인터페이스에 구현을 꽂는 일이다. 포트에 어댑터를, 서비스에 리포지토리를 누가 연결해 주는가? 바로 그 일을 Spring의 IoC/DI 컨테이너가 대신 한다(다음 편의 주제다). 트랜잭션 경계와 횡단 관심사는 AOP가, 영속성은 JPA가, 컨텍스트 사이의 통신은 메시징이 맡는다. 즉 아키텍처가 구조를 정하면, Spring이 그 구조의 배선을 자동화하는 셈이다.

정리하면, 아키텍처는 변경의 파급을 좁히는 기술이고 그 핵심은 늘 의존성 방향이다. 레이어드는 위에서 아래로 한 방향을 긋고(단순하지만 도메인이 기술에 묶인다), 헥사고날·클린은 그 방향을 뒤집어 포트·어댑터로 세부가 핵심에 의존하게 만들며, DDD는 그 위에 엔티티·값 객체·애그리거트(작게·식별자 참조·결과적 일관성)와 바운디드 컨텍스트라는 모델과 경계를 더한다. 그리고 이 경계는 모듈러 모놀리스로도 충분히 실현되니 — 분산은 정말 필요할 때만, 구조는 도메인 복잡도에 맞춰 점진적으로. 그 모든 배선을 Spring이 자동화해 준다.

Spring 핵심 — IoC·DI·AOP