Table of contents
- 처음엔 다 잘 돌아간다
- 구조 없는 코드의 의존성 그래프
- 구체적 시나리오 — 주문 시스템이 망가지는 과정
- 아키텍처란 무엇인가
- 좋은 아키텍처의 세 가지 특성
- 아키텍처는 비용이 아니라 투자다
- 이 시리즈에서 다루는 아키텍처들
- 아키텍처를 바라보는 관점
- 핵심 정리
처음엔 다 잘 돌아간다
프로젝트가 시작되면 모든 게 빠르다. 파일 하나에 컨트롤러, 서비스 로직, DB 접근 코드를 몰아넣어도 동작한다. 기능이 세 개뿐이라면 그게 오히려 효율적으로 느껴진다.
문제는 여기서부터다. 기획이 추가되고, 팀원이 늘고, 배포 주기가 빨라진다. 어느 날 결제 로직을 수정했는데 포인트 적립이 깨진다. 알림 기능을 추가하려는데, 주문 코드를 이해하는 것만으로 이틀이 걸린다. 테스트를 작성하려 해도 모든 게 엮여 있어서 DB 없이는 아무것도 확인할 수 없다.
이것은 특정 팀만의 이야기가 아니다. 구조 없는 코드가 겪는 보편적인 결말이다.
구조 없는 코드의 의존성 그래프
코드에 구조가 없으면 어떤 일이 벌어지는지 그림으로 보면 명확해진다. 아래는 아키텍처 없이 작성된 코드의 전형적인 의존성 그래프다.
flowchart TB
OrderController["OrderController"] --> OrderService["OrderService"]
OrderController --> PaymentService["PaymentService"]
OrderController --> UserRepository["UserRepository"]
OrderService --> PaymentService
OrderService --> UserRepository
OrderService --> NotificationService["NotificationService"]
PaymentService --> OrderService
PaymentService --> UserRepository
PaymentService --> NotificationService
NotificationService --> UserRepository
NotificationService --> OrderService
style OrderController fill:#ff6b6b,color:#fff
style OrderService fill:#ff6b6b,color:#fff
style PaymentService fill:#ff6b6b,color:#fff
style UserRepository fill:#ff6b6b,color:#fff
style NotificationService fill:#ff6b6b,color:#fff
화살표가 모든 방향으로 뻗어 있다. PaymentService가 OrderService를 참조하고, OrderService도 PaymentService를 참조한다. 순환 의존(circular dependency)이다. 이런 구조에서는 하나를 건드리면 연쇄적으로 다른 것이 깨진다. 누가 누구를 쓰는지 추적하는 것 자체가 고된 작업이 된다.
이제 같은 기능을 구조를 잡아서 만들면 어떤 모습이 되는지 비교해 보자.
flowchart TB
subgraph presentation["Presentation"]
OC["OrderController"]
end
subgraph application["Application"]
OS["OrderService"]
PS["PaymentService"]
NS["NotificationService"]
end
subgraph domain["Domain"]
Order["Order"]
Payment["Payment"]
User["User"]
end
subgraph infrastructure["Infrastructure"]
UR["UserRepository"]
OR["OrderRepository"]
PR["PaymentRepository"]
end
OC --> OS
OS --> PS
OS --> NS
OS --> Order
PS --> Payment
NS --> User
OS --> OR
PS --> PR
NS --> UR
style presentation fill:#339af0,color:#fff
style application fill:#51cf66,color:#fff
style domain fill:#ffd43b,color:#000
style infrastructure fill:#868e96,color:#fff
화살표의 방향이 일관된다. 위에서 아래로, 바깥에서 안쪽으로. 어떤 클래스가 어떤 계층에 속하는지 한눈에 보이고, 수정의 파급 범위를 예측할 수 있다. 이것이 아키텍처가 하는 일이다.
구체적 시나리오 — 주문 시스템이 망가지는 과정
추상적인 설명보다 구체적 사례가 와닿는다. 간단한 주문 시스템을 예로 들어보자.
1단계: 빠르게 만든 첫 버전
아래 코드는 주문 생성부터 결제, 알림까지 한 클래스에서 처리한다.
@RestController
public class OrderController {
@Autowired private JdbcTemplate jdbc;
@Autowired private RestTemplate restTemplate;
@PostMapping("/orders")
public ResponseEntity<String> createOrder(@RequestBody OrderRequest req) {
// 재고 확인
int stock = jdbc.queryForObject(
"SELECT stock FROM products WHERE id = ?",
Integer.class, req.getProductId()
);
if (stock < req.getQuantity()) {
return ResponseEntity.badRequest().body("재고 부족");
}
// 재고 차감
jdbc.update(
"UPDATE products SET stock = stock - ? WHERE id = ?",
req.getQuantity(), req.getProductId()
);
// 주문 저장
jdbc.update(
"INSERT INTO orders (product_id, quantity, status) VALUES (?, ?, 'CREATED')",
req.getProductId(), req.getQuantity()
);
// 결제 API 호출
restTemplate.postForEntity(
"https://payment.example.com/pay",
new PaymentRequest(req.getProductId(), req.getAmount()),
String.class
);
// 알림 전송
restTemplate.postForEntity(
"https://notification.example.com/send",
new NotificationRequest(req.getUserId(), "주문 완료"),
String.class
);
return ResponseEntity.ok("주문 성공");
}
}
동작은 한다. 기능이 하나뿐이라면 이 구조로도 충분하다. 그런데 기획 요구사항이 추가되기 시작한다.
2단계: 요구사항이 늘어난다
- 쿠폰 할인 기능 추가
- 결제 실패 시 재고 복구
- 포인트 적립
- 주문 이력 조회 API
- 관리자용 주문 취소
각 요구사항이 들어올 때마다 createOrder 메서드에 코드가 쌓인다. 메서드 하나가 200줄을 넘기고, if-else가 중첩된다. 새 개발자가 합류하면 이 코드가 뭘 하는 건지를 파악하는 데 하루가 걸린다.
3단계: 수정이 두려워진다
포인트 적립 로직을 수정해야 하는 상황이다. 그런데 포인트 계산이 결제 금액에 의존하고, 결제 금액은 쿠폰 적용 후 금액이며, 쿠폰 적용은 주문 생성 흐름 중간에 끼어 있다. 한 줄을 바꾸려면 200줄 전체를 이해해야 하고, 다른 기능을 건드리지 않았다는 보장이 없다.
이 지점에서 리팩토링하자는 이야기가 나온다. 하지만 테스트가 없다. 테스트를 작성하려면 DB와 외부 API가 필요하다. 코드가 인프라에 직접 결합되어 있기 때문이다. 악순환이 시작된 것이다.
아키텍처란 무엇인가
소프트웨어 아키텍처라는 말은 다양한 맥락에서 쓰인다. 마이크로서비스 아키텍처, 이벤트 드리븐 아키텍처, 클라우드 네이티브 아키텍처. 용어만 놓고 보면 거창하게 느껴지지만, 본질은 단순하다.
Architecture is about the important stuff. Whatever that is. — Ralph Johnson
아키텍처는 구조적 결정의 집합이다. 코드를 어떤 단위로 나눌 것인가. 그 단위들이 어떻게 소통할 것인가. 의존성은 어느 방향으로 흐르게 할 것인가. 변경이 생겼을 때 어디까지 영향이 퍼지게 허용할 것인가. 이런 질문에 대한 답을 미리 정해두는 것이 아키텍처다.
중요한 점은, 아키텍처는 나중에 바꾸기 어려운 결정을 다룬다는 것이다. 변수 이름을 바꾸는 건 아키텍처가 아니다. 데이터베이스를 직접 참조하는 코드를 모든 서비스에 퍼뜨린 것은 아키텍처적 결정이다. 후자는 되돌리는 데 몇 주가 걸릴 수 있다.
Martin Fowler는 아키텍처를 시스템의 근본적인 조직 구조로서, 그 구성 요소들, 구성 요소들 간의 관계, 그리고 설계와 진화를 이끄는 원칙의 구현이라고 정의한 바 있다. 핵심은 관계와 원칙이다. 어떤 프레임워크를 쓰느냐가 아니라, 코드 간의 관계를 어떤 원칙으로 통제하느냐가 아키텍처를 결정짓는다.
좋은 아키텍처의 세 가지 특성
아키텍처에 정답은 없다. 하지만 좋은 아키텍처가 공통적으로 갖는 특성은 있다.
변경 용이성
소프트웨어가 존재하는 이유는 변경이다. 요구사항은 반드시 바뀌고, 그 변경을 수용할 수 있어야 소프트웨어로서 가치가 있다. 좋은 아키텍처에서는 결제 방식을 바꿀 때 결제 관련 코드만 수정하면 된다. 주문 로직이나 알림 로직은 건드리지 않아도 된다.
Robert C. Martin의 표현을 빌리면, 소프트웨어의 가치는 두 가지다. 행위 가치(현재 올바르게 동작하는 것)와 구조 가치(미래의 변경을 수용할 수 있는 것). 둘 중 구조 가치가 더 중요하다. 동작하지만 변경할 수 없는 프로그램은 요구사항이 바뀌는 순간 쓸모없어진다. 동작하지 않지만 변경하기 쉬운 프로그램은 고치면 된다.
테스트 용이성
테스트를 작성하기 어렵다면 구조에 문제가 있다는 신호다. 비즈니스 로직을 검증하기 위해 DB를 띄우고, 외부 API를 호출하고, 웹 서버를 기동해야 한다면 — 그건 로직이 인프라에 결합되어 있기 때문이다.
좋은 아키텍처에서는 비즈니스 규칙만 따로 꺼내서 단위 테스트를 돌릴 수 있다. 아래는 그 차이를 보여주는 간단한 예시다.
// 나쁜 구조 — 테스트하려면 DB가 필요하다
public class OrderService {
@Autowired private JdbcTemplate jdbc;
public boolean canOrder(Long productId, int quantity) {
int stock = jdbc.queryForObject(
"SELECT stock FROM products WHERE id = ?",
Integer.class, productId
);
return stock >= quantity;
}
}
// 좋은 구조 — 도메인 로직이 인프라에서 분리되어 있다
public class Order {
private final int stock;
private final int quantity;
public Order(int stock, int quantity) {
this.stock = stock;
this.quantity = quantity;
}
public boolean canFulfill() {
return stock >= quantity;
}
}
아래 Order 클래스는 new Order(10, 3).canFulfill()만으로 테스트가 가능하다. DB도 Spring Context도 필요 없다.
이해 용이성
코드를 작성하는 시간보다 읽는 시간이 훨씬 길다. 새 팀원이 합류했을 때 이 프로젝트의 구조가 이렇고, 이 기능은 여기를 보면 된다고 안내할 수 있어야 한다. 아키텍처는 코드의 지도 역할을 한다.
패키지 구조만 봐도 이 프로젝트가 어떤 도메인을 다루는지, 어디에 비즈니스 로직이 있고 어디에 인프라 코드가 있는지 파악할 수 있다면 좋은 아키텍처다.
com.example.shop/
├── order/
│ ├── domain/ ← 핵심 비즈니스 로직
│ ├── application/ ← 유스케이스 조합
│ ├── adapter/ ← 외부 연결 (DB, API)
│ └── port/ ← 경계 인터페이스
├── payment/
│ ├── domain/
│ ├── application/
│ └── ...
└── notification/
└── ...
디렉토리 구조가 곧 아키텍처의 선언이다. 코드를 열어보지 않아도 주문(order), 결제(payment), 알림(notification)이라는 도메인이 분리되어 있고, 각각이 동일한 내부 구조를 따르고 있음을 알 수 있다.
아키텍처는 비용이 아니라 투자다
아키텍처 설계할 시간에 기능을 하나 더 만들자. 실무에서 자주 듣는 말이다. 프로젝트 초기에는 이 논리가 설득력 있어 보인다. 기능이 적을 때는 구조 없이도 빠르게 움직일 수 있으니까.
하지만 코드베이스가 성장하면 양상이 달라진다. 구조 없는 코드에서는 기능 추가 비용이 시간이 지날수록 가파르게 증가한다. 처음에는 하루면 만들던 기능이 일주일이 걸리기 시작하고, 결국에는 “이 기능은 현재 구조에서 구현이 불가능합니다”라는 말이 나온다.
반면 아키텍처에 투자한 코드에서는 초기에 약간의 시간이 더 들지만, 기능이 늘어나도 추가 비용이 완만하게 유지된다. 이것이 투자의 속성이다. 앞에서 비용을 지불하고, 뒤에서 이자를 받는다.
물론 과설계(over-engineering)도 경계해야 한다. 기능이 세 개뿐인 프로젝트에 헥사고날 아키텍처를 적용하는 것은 낭비일 수 있다. 중요한 것은 프로젝트의 규모와 성장 가능성에 맞는 적절한 수준의 구조를 선택하는 것이다. 구조를 잡느냐 마느냐가 아니라 얼마나 잡느냐가 진짜 질문이다.
이 시리즈에서 다루는 아키텍처들
이 시리즈는 총 6편으로, 실무에서 자주 등장하는 소프트웨어 아키텍처를 하나씩 살펴본다.
| 편 | 제목 | 핵심 질문 |
|---|---|---|
| 1편 | 아키텍처가 왜 필요한가 | 구조 없는 코드는 왜 망가지는가? |
| 2편 | 레이어드 아키텍처 | 가장 익숙한 구조, 그 장점과 한계는? |
| 3편 | 헥사고날 아키텍처 | 안과 밖을 나누면 무엇이 달라지는가? |
| 4편 | 클린 아키텍처와 어니언 | 동심원 구조들의 공통점과 차이는? |
| 5편 | CQRS와 이벤트 드리븐 | 읽기와 쓰기를 분리하면 어떤 가능성이 열리는가? |
| 6편 | 모듈러 모놀리스 | 마이크로서비스 전에 해볼 수 있는 것은? |
각 편은 독립적으로 읽을 수 있지만, 순서대로 읽으면 아키텍처의 발전 맥락이 자연스럽게 이어진다. 레이어드의 한계가 헥사고날을 낳고, 헥사고날의 원리가 클린 아키텍처로 확장되며, 단일 서비스 내의 구조가 시스템 수준으로 확대될 때 CQRS와 이벤트 드리븐이 등장한다. 그리고 마이크로서비스로 가기 전에 모놀리스 안에서 할 수 있는 최선을 찾는 것이 모듈러 모놀리스다.
아키텍처를 바라보는 관점
아키텍처를 공부할 때 주의할 점이 있다. 특정 아키텍처를 정답으로 여기는 것은 위험하다. 헥사고날이 레이어드보다 항상 낫다거나, 마이크로서비스가 모놀리스보다 우월하다는 식의 사고는 실무에서 좋지 않은 결과를 낳는다.
아키텍처는 트레이드오프의 산물이다. 레이어드는 단순하고 학습 비용이 낮지만 도메인이 인프라에 종속되기 쉽다. 헥사고날은 도메인을 보호하지만 파일과 인터페이스가 많아진다. CQRS는 읽기 성능을 극대화하지만 시스템 복잡도가 올라간다.
“이 프로젝트에서 가장 중요한 품질 속성은 무엇인가?”라는 질문이 아키텍처 선택의 출발점이다. 변경 빈도가 높은가, 성능이 중요한가, 팀의 규모는 어떤가, 도메인의 복잡도는 어느 수준인가. 이런 맥락을 무시하고 요즘 트렌드만 따르면 오히려 프로젝트를 어렵게 만든다.
이 시리즈는 각 아키텍처의 장단점을 균형 있게 다루려 한다. 독자가 자신의 프로젝트에 맞는 구조를 판단할 수 있는 눈을 갖추는 것이 목표다.
핵심 정리
- 구조 없는 코드는 초기에는 빠르지만, 시간이 지나면 변경 비용이 기하급수적으로 증가한다
- 아키텍처는 코드 간의 관계와 의존 방향에 대한 구조적 결정의 집합이다
- 좋은 아키텍처의 공통 특성은 변경 용이성, 테스트 용이성, 이해 용이성이다
- 아키텍처는 초기 비용을 치르고 장기적 이익을 얻는 투자다
- 정답인 아키텍처는 없다. 프로젝트의 맥락에 맞는 적절한 수준의 구조가 최선이다
다음 편에서는 가장 익숙하고 널리 쓰이는 구조인 레이어드 아키텍처를 다룬다. Spring MVC의 Controller → Service → Repository 구조가 바로 그것이다. 이 구조의 장점을 인정하면서도, 어디에서 한계가 드러나는지를 살펴본다.




Loading comments...