Layered Architecture에 대해서

StrayCat·2026년 2월 23일

CS지식

목록 보기
5/32

🏗️ Layered Architecture — 왜 우리는 코드를 계층으로 나누는가?


들어가며

처음 개발을 시작하면, 하나의 파일에 모든 코드를 몰아넣고 싶은 충동이 생깁니다. DB 연결, 비즈니스 로직, 화면 출력까지 한 곳에. 당장은 편하지만, 프로젝트가 커지는 순간 "이 코드가 어디 있지?", "여기 건드렸더니 저기가 터졌다" 같은 상황이 반복됩니다.
Layered Architecture는 이런 문제를 해결하기 위해 등장한 소프트웨어 설계 철학입니다.


1. Layered Architecture의 탄생 배경

소프트웨어가 커지면서 개발자들은 공통적인 고통을 겪기 시작했습니다.

관심사 혼재 ( Mixing Concerns )

// ❌ 안티패턴 — 하나의 메서드가 너무 많은 일을 한다
public void processOrder(HttpServletRequest request) {
    // 1. HTTP 요청 파싱
    String userId = request.getParameter("userId");
    
    // 2. DB에서 직접 조회
    Connection conn = DriverManager.getConnection("jdbc:mysql://...");
    PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
    ps.setString(1, userId);
    
    // 3. 비즈니스 로직
    if (userPoint < 1000) throw new Exception("포인트 부족");
    
    // 4. 응답 출력까지
    response.getWriter().write("주문 완료");
}
  • 이런 코드는...
  • 테스트도 불가능하고
  • 한 명이 수정하면 전체가 영향을 받고
  • 재사용도 불가능합니다

이런 현실적인 문제들을 해결하기 위해, 1990년대~2000년대 초반 엔터프라이즈 애플리케이션이 급격히 복잡해지면서 계층을 나누어 각 계층이 하나의 역할만 담당하도록 하는 아이디어가 정립되었습니다.

📌 Martin Fowler의 저서 Patterns of Enterprise Application Architecture (2002)가 이 개념을 체계화하는 데 큰 기여를 했습니다.


2. Layered Architecture의 궁극적인 목적

한 문장으로 요약하면:

"각 계층이 자신의 책임만 지고, 변경이 다른 계층에 영향을 최소화한다."

이를 위한 두 가지 핵심 원칙이 있습니다.

① 관심사의 분리 (Separation of Concerns)
각 계층은 자신이 맡은 역할만 수행합니다. HTTP 요청을 받는 계층은 비즈니스 로직을 모르고, 비즈니스 로직은 DB가 MySQL인지 PostgreSQL인지 알 필요가 없습니다.

② 단방향 의존성 (Unidirectional Dependency)
계층 간 의존 방향은 항상 위 → 아래로만 흐릅니다. 하위 계층은 상위 계층을 절대 참조하지 않습니다.

Presentation Layer
       ↓  (의존)
Business Layer
       ↓  (의존)
Persistence Layer
       ↓  (의존)
Database Layer

이 구조 덕분에 DB를 MySQL에서 MongoDB로 교체해도 비즈니스 로직은 건드릴 필요가 없어집니다.


3. Layered Architecture가 개발자에게 미치는 영향

✅ 긍정적인 영향

  • 역할 분담이 명확해진다
  • 팀 협업 시 "이건 Service 담당, 저건 Controller 담당" 식으로 충돌 없이 병렬 개발이 가능합니다.
  • 테스트가 용이해진다
  • 각 계층을 독립적으로 테스트할 수 있습니다. Service 로직을 테스트할 때 실제 DB 연결 없이 Mock으로 대체할 수 있습니다.
// ✅ Service만 독립적으로 단위 테스트 가능
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository; // DB 없이 Mock 사용

    @InjectMocks
    private OrderService orderService;

    @Test
    void 주문_생성_성공() {
        // given
        given(orderRepository.save(any())).willReturn(new Order());
        
        // when & then
        assertDoesNotThrow(() -> orderService.createOrder(new OrderRequest()));
    }
}
  • 유지보수성이 향상됩니다.
    특정 계층만 수정하면 되므로, 변경의 파급 효과를 예측하고 제어할 수 있습니다.

⚠️ 부정적인 영향 (트레이드오프)

  • 세상에 완벽한 것은 아쉽게도 아직은 없는 것 같습니다. 우리 모두는 무언가를 얻기 위해선, 무언가를 희생해야 합니다. 이처럼 얻는 것이 있으면 잃는 것 또한 생기는 상충 개념을 트레이드오프 (Trade-Off)라고 부릅니다.
  • 트레이드오프는 필연적이고, 우리는 그때마다 상황에 맞게 '무엇을 우선순위에 둘지' 선택해야 합니다.
  • 이러한 가치판단과 의사결정의 능력이 단순 기술자가 아닌 개발자로서 갖추어야 할 궁극적인 덕목이라고 생각합니다.
  • 과도한 보일러플레이트
    계층마다 DTO를 변환해야 하는 경우, 단순한 CRUD에도 많은 클래스가 필요합니다.

  • 성능 오버헤드
    계층을 거칠 때마다 객체 변환, 메서드 호출이 발생합니다. 대부분 무시할 수 있는 수준이지만, 극한의 성능이 필요한 경우 고려해야 합니다.

  • 과도한 추상화 위험
    단순한 프로젝트에 억지로 계층을 나누면 오히려 복잡도가 높아집니다. 규모에 맞는 설계가 중요합니다.


4. Layered Architecture의 여러 방법론

계층을 나누는 방식 자체는 하나가 아닙니다. 다양한 방법론이 존재합니다.

4-1. 전통적인 Layered Architecture (N-Tier)

가장 고전적인 형태로, 보통 3~4개의 계층으로 나눕니다.

[ Presentation ]  → 사용자와의 접점 (Controller, View)
[ Business ]      → 비즈니스 규칙 (Service)
[ Persistence ]   → 데이터 접근 (Repository, DAO)
[ Database ]      → 실제 DB

계층 간 통신은 항상 인접한 계층끼리만 가능합니다. Presentation이 Persistence를 직접 호출하면 안 됩니다.


4-2. Hexagonal Architecture (헥사고날 / Ports & Adapters)

Alistair Cockburn이 제안한 방식으로, 외부 세계와 내부 도메인을 완전히 분리하는 것이 목적입니다.

         [ HTTP Controller ]  [ Kafka Consumer ]
                  ↓                  ↓
         ┌────────────────────────────┐
         │     Application Core     │  ← 순수한 비즈니스 로직
         │   (Domain + Use Cases)   │
         └────────────────────────────┘
                  ↓                  ↓
         [ JPA Repository ]  [ External API Client ]
  • Port: 코어가 외부와 소통하기 위해 정의한 인터페이스
  • Adapter: 실제 외부 기술을 Port에 맞게 구현한 클래스

핵심 도메인 로직이 Spring, JPA 같은 프레임워크에 전혀 의존하지 않도록 설계하는 게 특징입니다.


4-3. Clean Architecture

Robert C. Martin(Uncle Bob)이 제안한 방식으로, 헥사고날과 유사하지만 계층을 더 세밀하게 정의합니다.

          ┌─────────────────────────┐
          │      Frameworks &     │  ← 가장 바깥 (Spring, DB 등)
          │       Drivers         │
          │  ┌───────────────────┐  │
          │  │   Interface      │  │  ← Controller, Gateway
          │  │   Adapters       │  │
          │  │  ┌─────────────┐  │  │
          │  │  │  Use Cases │  │  │  ← 애플리케이션 비즈니스 규칙
          │  │  │ ┌─────────┐ │  │  │
          │  │  │ │Entities│ │  │  │  ← 순수 도메인 (가장 안쪽)
          │  │  │ └─────────┘ │  │  │
          │  │  └─────────────┘  │  │
          │  └───────────────────┘  │
          └─────────────────────────┘

핵심 규칙: 안쪽 원은 바깥 원을 절대 알면 안 된다. 의존성은 항상 바깥 → 안쪽으로만 향합니다.


4-4. 비교 정리

방법론특징적합한 상황
전통적 N-Tier단순하고 직관적중소 규모 엔터프라이즈
Hexagonal외부 기술 교체 용이기술 스택 변경이 잦은 경우
Clean Architecture도메인 완전 독립복잡한 도메인 비즈니스

5. Layered Architecture의 다양한 계층 형태

5-1. Spring Boot의 일반적인 3계층 구조

실무에서 가장 많이 접하는 형태입니다.

Controller (Presentation)
    ↓
Service (Business)
    ↓
Repository (Persistence)
// ① Controller — HTTP 요청/응답만 담당
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        // Service에게 위임하고 응답만 처리
        OrderResponse response = orderService.createOrder(request);
        return ResponseEntity.ok(response);
    }
}

// ② Service — 비즈니스 로직 담당
@Service
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderResponse createOrder(OrderRequest request) {
        // 비즈니스 규칙 적용
        Order order = Order.from(request);
        Order saved = orderRepository.save(order);
        return OrderResponse.from(saved);
    }
}

// ③ Repository — DB 접근만 담당
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByUserId(Long userId);
}

5-2. DTO 변환을 포함한 계층 흐름

계층 간에는 각 계층에 맞는 객체를 사용하는 것이 권장됩니다.

HTTP Request → [Controller] → RequestDTO
                                  ↓
              [Service]   → Domain Object (Entity)
                                  ↓
              [Repository] → DB Row (Entity → DB 저장)
                                  ↓ (역방향 응답)
              [Service]   → ResponseDTO 변환
                                  ↓
              [Controller] → HTTP Response
  • Entity를 Controller까지 그대로 노출하면, DB 구조가 API 스펙에 그대로 드러나게 됩니다. 이는 캡슐화 위반이자 보안 위험이 될 수 있습니다.

5-3. 도메인 계층을 추가한 4계층 구조

도메인 복잡도가 높아지면 Service와 별개로 Domain Layer를 분리합니다.

Controller (Presentation)
    ↓
Service (Application) — 유스케이스 조율
    ↓
Domain (Domain) — 순수 비즈니스 규칙 (Entity, Domain Service)
    ↓
Repository (Infrastructure) — 기술적 구현
// Domain Layer — 순수 자바 객체, 프레임워크 의존성 없음
public class Order {
    private OrderStatus status;
    private List<OrderItem> items;

    // 비즈니스 규칙이 도메인 내부에 존재
    public void cancel() {
        if (this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException("배송 완료된 주문은 취소 불가");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

마치며

Layered Architecture특정 기술이 아니라 생각하는 방식입니다. 어떤 프레임워크를 쓰든, 어떤 언어를 사용하든 "이 코드가 어느 계층에 속하는가?"를 묻는 습관이 생기면 코드의 품질이 달라집니다.
처음엔 Controller, Service, Repository 3계층부터 시작해서, 프로젝트가 복잡해질수록 Hexagonal이나 Clean Architecture등의 유효한 방법론을 적용시키고, 발전시켜 나가는 것이 현실적인 접근법입니다.

💡 핵심 한 줄 요약: 각 계층은 자신의 역할만, 의존성은 한 방향으로.

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글