처음 개발을 시작하면, 하나의 파일에 모든 코드를 몰아넣고 싶은 충동이 생깁니다. DB 연결, 비즈니스 로직, 화면 출력까지 한 곳에. 당장은 편하지만, 프로젝트가 커지는 순간 "이 코드가 어디 있지?", "여기 건드렸더니 저기가 터졌다" 같은 상황이 반복됩니다.
Layered Architecture는 이런 문제를 해결하기 위해 등장한 소프트웨어 설계 철학입니다.
소프트웨어가 커지면서 개발자들은 공통적인 고통을 겪기 시작했습니다.
// ❌ 안티패턴 — 하나의 메서드가 너무 많은 일을 한다
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)가 이 개념을 체계화하는 데 큰 기여를 했습니다.
한 문장으로 요약하면:
"각 계층이 자신의 책임만 지고, 변경이 다른 계층에 영향을 최소화한다."
이를 위한 두 가지 핵심 원칙이 있습니다.
① 관심사의 분리 (Separation of Concerns)
각 계층은 자신이 맡은 역할만 수행합니다. HTTP 요청을 받는 계층은 비즈니스 로직을 모르고, 비즈니스 로직은 DB가 MySQL인지 PostgreSQL인지 알 필요가 없습니다.
② 단방향 의존성 (Unidirectional Dependency)
계층 간 의존 방향은 항상 위 → 아래로만 흐릅니다. 하위 계층은 상위 계층을 절대 참조하지 않습니다.
Presentation Layer
↓ (의존)
Business Layer
↓ (의존)
Persistence Layer
↓ (의존)
Database Layer
이 구조 덕분에 DB를 MySQL에서 MongoDB로 교체해도 비즈니스 로직은 건드릴 필요가 없어집니다.
// ✅ 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에도 많은 클래스가 필요합니다.
성능 오버헤드
계층을 거칠 때마다 객체 변환, 메서드 호출이 발생합니다. 대부분 무시할 수 있는 수준이지만, 극한의 성능이 필요한 경우 고려해야 합니다.
과도한 추상화 위험
단순한 프로젝트에 억지로 계층을 나누면 오히려 복잡도가 높아집니다. 규모에 맞는 설계가 중요합니다.
계층을 나누는 방식 자체는 하나가 아닙니다. 다양한 방법론이 존재합니다.
가장 고전적인 형태로, 보통 3~4개의 계층으로 나눕니다.
[ Presentation ] → 사용자와의 접점 (Controller, View)
[ Business ] → 비즈니스 규칙 (Service)
[ Persistence ] → 데이터 접근 (Repository, DAO)
[ Database ] → 실제 DB
계층 간 통신은 항상 인접한 계층끼리만 가능합니다. Presentation이 Persistence를 직접 호출하면 안 됩니다.
Alistair Cockburn이 제안한 방식으로, 외부 세계와 내부 도메인을 완전히 분리하는 것이 목적입니다.
[ HTTP Controller ] [ Kafka Consumer ]
↓ ↓
┌────────────────────────────┐
│ Application Core │ ← 순수한 비즈니스 로직
│ (Domain + Use Cases) │
└────────────────────────────┘
↓ ↓
[ JPA Repository ] [ External API Client ]
핵심 도메인 로직이 Spring, JPA 같은 프레임워크에 전혀 의존하지 않도록 설계하는 게 특징입니다.
Robert C. Martin(Uncle Bob)이 제안한 방식으로, 헥사고날과 유사하지만 계층을 더 세밀하게 정의합니다.
┌─────────────────────────┐
│ Frameworks & │ ← 가장 바깥 (Spring, DB 등)
│ Drivers │
│ ┌───────────────────┐ │
│ │ Interface │ │ ← Controller, Gateway
│ │ Adapters │ │
│ │ ┌─────────────┐ │ │
│ │ │ Use Cases │ │ │ ← 애플리케이션 비즈니스 규칙
│ │ │ ┌─────────┐ │ │ │
│ │ │ │Entities│ │ │ │ ← 순수 도메인 (가장 안쪽)
│ │ │ └─────────┘ │ │ │
│ │ └─────────────┘ │ │
│ └───────────────────┘ │
└─────────────────────────┘
핵심 규칙: 안쪽 원은 바깥 원을 절대 알면 안 된다. 의존성은 항상 바깥 → 안쪽으로만 향합니다.
| 방법론 | 특징 | 적합한 상황 |
|---|---|---|
| 전통적 N-Tier | 단순하고 직관적 | 중소 규모 엔터프라이즈 |
| Hexagonal | 외부 기술 교체 용이 | 기술 스택 변경이 잦은 경우 |
| Clean Architecture | 도메인 완전 독립 | 복잡한 도메인 비즈니스 |
실무에서 가장 많이 접하는 형태입니다.
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);
}
계층 간에는 각 계층에 맞는 객체를 사용하는 것이 권장됩니다.
HTTP Request → [Controller] → RequestDTO
↓
[Service] → Domain Object (Entity)
↓
[Repository] → DB Row (Entity → DB 저장)
↓ (역방향 응답)
[Service] → ResponseDTO 변환
↓
[Controller] → HTTP Response
도메인 복잡도가 높아지면 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등의 유효한 방법론을 적용시키고, 발전시켜 나가는 것이 현실적인 접근법입니다.
💡 핵심 한 줄 요약: 각 계층은 자신의 역할만, 의존성은 한 방향으로.