Skip to content
ioob.dev
Go back

소프트웨어 아키텍처 1편 — 아키텍처가 왜 필요한가

· 10분 읽기
Software Architecture 시리즈 (1/6)
  1. 소프트웨어 아키텍처 1편 — 아키텍처가 왜 필요한가
  2. 소프트웨어 아키텍처 2편 — 레이어드 아키텍처
  3. 소프트웨어 아키텍처 3편 — 헥사고날 아키텍처
  4. 소프트웨어 아키텍처 4편 — 클린 아키텍처와 어니언: 동심원 구조들의 공통점과 차이
  5. 소프트웨어 아키텍처 5편 — CQRS와 이벤트 드리븐: 읽기/쓰기 분리에서 이벤트 기반까지
  6. 소프트웨어 아키텍처 6편 — 모듈러 모놀리스: 마이크로서비스 전에 해볼 수 있는 것
Table of contents

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

화살표가 모든 방향으로 뻗어 있다. PaymentServiceOrderService를 참조하고, OrderServicePaymentService를 참조한다. 순환 의존(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단계: 요구사항이 늘어난다

각 요구사항이 들어올 때마다 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 구조가 바로 그것이다. 이 구조의 장점을 인정하면서도, 어디에서 한계가 드러나는지를 살펴본다.

2편: 레이어드 아키텍처 — 가장 익숙한 구조와 그 한계


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Claude Code 자주 쓰는 커맨드 모음
Next Post
소프트웨어 아키텍처 2편 — 레이어드 아키텍처