ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • MVC에서 헥사고날 아키텍처로의 여정 - 1편: 구조의 재배열
    백엔드 : 서버공부/Spring 2025. 10. 28. 17:50
    728x90

    들어가며

    아직 공부 중인 입장에서, 기존에 익숙했던 Spring MVC 방식의 구조를 헥사고날 아키텍처(Ports & Adapters) 스타일로 바꿔보면서 머릿속에서 어떻게 ‘자리 바꾸기’가 일어나는지 정리해본다. 이 글은 "이게 정답이다"라기보다는, 내가 직접 겪은 헷갈림과 깨달음을 기록해두는 용도에 가깝다.

     

    학습용 프로젝트는 아래 링크에서 확인할 수 있다.

    https://github.com/albbayaa/ilil_Alba_project

     

     

    글을 좀 재밌게 쓰고싶었는데.. 아키텍처가 너무 헷갈려서 오늘 하루종일 시달렸더니... 

    기록이 노잼이 되었다.. 

     

    3-4편 걸쳐서 "MVC에서 헥사고날 아키텍처로의 여정" 글을 작성할건데 이건 학습 기록용이다.

    언젠가 마스터하면 제대로 정리글을 쓸 수 있을 듯!

    기록 기록 기록

     

    무엇이 나를 괴롭혔냐면! 

    이런 부분에서 많이 막혔다.

    • in / out은 누가 기준인가
    • 왜 Controller가 adapter인가
    • 왜 UseCase가 port를 주입받는가
    • 왜 Repository 구현체가 adapter/out으로 밀려났는가
    • 도메인 엔티티는 그냥 JPA 엔티티 아니었나

    아래는 그 질문들에 대한 내 현재 이해 상태다. 나중에 이해가 더 깊어지면 2편, 3편에서 수정할 수도 있다.

     

    1. 기존 MVC 구조의 모습

    전통적인 Spring MVC는 보통 이런 흐름이다.

    Controller
    → Service
    → Repository
    → Database

    각 역할은 다음과 같다.

    • Controller: HTTP 요청을 받고 Service를 호출한다.
    • Service: 비즈니스 로직을 처리한다.
    • Repository: DB에 접근한다 (예: Spring Data JPA).
    • Database: 실제 데이터 저장소.

    이 구조의 문제라고 느낀 점은 다음과 같다.

    1. Controller가 구체적인 Service 클래스에 강하게 의존한다.
    2. Service가 구체적인 Repository 구현체에 강하게 의존한다.
    3. 비즈니스 로직(Service)이 데이터 접근 방식(JPA, DB 스키마 등)에 끌려다닌다.
    4. DB를 바꾸거나, 외부 API를 붙이거나, 캐시 레이어를 추가하려고 해도 Service부터 건드려야 한다.

    즉, 핵심 로직이 인프라 기술에 너무 가까이 붙어 있다. 이게 유지보수 부담으로 돌아온다.

     

    2. 헥사고날 아키텍처로의 재배열

     

     

    헥사고날 아키텍처(Ports & Adapters)로 리팩토링하면서 구조를 이렇게 재배치했다는 게 현재 내 해석이다.

    1. adapter/in/web
      HTTP 요청이 들어오는 입구. 예: MemberWebController
    2. application/member/usecase
      핵심 비즈니스 규칙을 수행하는 계층. 예: MemberApplicationUseCase
    3. application/member/port
      애플리케이션 안팎을 구분하는 인터페이스 집합
      • 외부에서 들어오는 요청을 받기 위한 Inbound Port
      • 외부 리소스(DB 등)를 호출하기 위한 Outbound Port
    4. adapter/out/persistence
      실제 인프라(DB, JPA 등)와 애플리케이션을 연결하는 구현체. 예: MemberJpaAdapter

    흐름으로 보면 아래와 같다.

    (1) HTTP 요청
    → (2) Inbound Adapter (Controller)
    → (3) Inbound Port 호출
    → (4) UseCase 실행
    → (5) Outbound Port 호출
    → (6) Outbound Adapter(JPA 등)
    → (7) DB

     

    중요한 지점 하나. 기준은 UseCase다.

    • UseCase 바깥에서 UseCase 안으로 들어오는 방향이 inbound (incomming)
    • UseCase에서 바깥(인프라)으로 나가는 방향이 outbound (outcomming)

     

    즉 in/out은 HTTP냐 DB냐로 나누는 게 아니라 UseCase를 중심에 두고 안쪽으로 들어오는가(outside → core), 아니면 바깥으로 나가는가(core → outside)로 나뉜다. 이걸 이해하고 나니까 port, adapter 이름이 덜 낯설어졌다.

     

    여기서 중요한건 이 들어오고 나간다가 물리적 데이터의 흐름이아니라 의존성이라는 것이다 예를 들어서 

     

    컨트롤러는 유즈케이스의 반환(result)과 입력(dto)을 따라야 하고,
    그건 결국 유즈케이스가 정의한 계약이니까 in port다.

     

    (이 개념 잡는게 어려웠다. 왜냐면 데이터의 흐름이 기준이라면 Result는 out port 인것이 자연스럽기 때문이다. )

     

    3. 계층별 역할 변화

    이제 각각이 MVC에서 헥사고날로 오면서 어떤 식으로 바뀌는지 정리해본다. 이 부분은 내가 실제로 가장 헷갈렸고, 한 번 정리해놓고 나서부터 구조가 보이기 시작했다.

    3-1. 도메인 계층 (Domain)

    위치: domain/member/entity/

    MVC 시절에는 엔티티가 그냥 DB 매핑용 데이터 홀더였다. 필드랑 getter/setter만 있고, 진짜 로직은 전부 Service에 몰려 있었다.

    헥사고날 관점에서는 엔티티가 단순 DTO처럼 굴지 않는다. 도메인 스스로가 자신의 상태를 바꾸는 규칙을 가진다. 즉 도메인 모델이 더 "주도권"을 가진다.

    public class Member extends BaseTime {
        // ...
    
        public void updateIsCertification(IsCertification isCertification) {
            this.isCertification = isCertification;  // 도메인 규칙 반영
        }
    
        public void updateEmailVerification(EmailVerification emailVerification) {
            this.emailVerification = emailVerification;
        }
    }
    

    이게 중요한 이유는, 비즈니스 규칙이 특정 서비스 클래스에 흩어지지 않고 도메인 객체 가까이에 모인다는 점이다. 도메인 객체가 "나를 이렇게 바꿀 수 있다 / 없다"를 직접 말한다.

    내가 이해한 결론: Domain은 더 이상 수동적 데이터 구조가 아니라, 상태와 행동을 함께 가진다.

     

    3-2. 포트 (Port)

    위치: application/member/port/

    포트는 내가 처음 들었을 때 제일 이해 안 된 단어였다. 지금은 이렇게 받아들이고 있다.

    포트 = "이 방향으로만 나랑 얘기해라"라고 정의해놓은 인터페이스

    그리고 이 포트는 두 종류로 나뉜다.

    1. Inbound Port
      외부(웹, 스케줄러, 메시지 등)가 애플리케이션의 유스케이스를 호출할 때 사용하는 인터페이스
      즉 애플리케이션으로 들어오는 입구 규격
    2. Outbound Port
      애플리케이션(유스케이스)이 외부 인프라(DB, 이메일 서버, 외부 API 등)에 요청을 보낼 때 사용하는 인터페이스
      즉 애플리케이션에서 나가는 출구 규격

    예시로 보면 더 깔끔하다.

    Inbound Port (명령/조회 등 유스케이스를 외부에서 호출하기 위한 계약)

    public interface MemberCommandPort {
        Member createMember(Member member);
        Member updateMember(Long id, Member member);
        void deleteMember(Long id);
    }
    
    public interface MemberQueryPort {
        Optional<Member> getMemberById(Long id);
        Optional<Member> getMemberByEmail(String email);
        List<Member> getAllMembers();
    }
    

    컨트롤러(adapter/in/web)는 위 인터페이스만 알고 있다. 특정 구현(어느 클래스가 실제로 이걸 구현하냐)은 몰라도 된다.

    Outbound Port (유스케이스가 필요로 하는, 인프라 관련 작업 계약)

    public interface MemberRepositoryPort {
        Member save(Member member);
        Optional<Member> findById(Long id);
        Optional<Member> findByEmail(String email);
        List<Member> findAll();
        void delete(Member member);
    }
    

    유스케이스는 더 이상 JPA Repository를 직접 들고 있지 않는다. 대신 MemberRepositoryPort라는 추상화된 "요구사항"에만 의존한다. 실제 DB가 MySQL이든 MongoDB든, 심지어 외부 회원관리 API든 상관없게 된다.

     

     

    3-3. 유스케이스 (UseCase)

    위치: application/member/usecase/

    UseCase는 "이 시스템이 제공하는 하나의 시나리오"라고 보면 된다. MVC의 Service와 비슷한 위치지만, 차이가 있다.

    • UseCase는 Inbound Port를 구현한다.
    • UseCase는 Outbound Port에 의존한다.

    즉 유스케이스는 한쪽으로는 "내가 이런 기능을 제공하겠습니다"라고 약속하고 (Inbound Port 구현), 다른 쪽으로는 "이런 외부 능력(저장 등)이 필요합니다"라고 선언한다 (Outbound Port 의존).

    @RequiredArgsConstructor
    public class MemberApplicationUseCase implements MemberCommandPort, MemberQueryPort {
    
        // 오직 Outbound Port에만 의존
        private final MemberRepositoryPort memberRepositoryPort;
    
        @Override
        public Member createMember(Member member) {
            return memberRepositoryPort.save(member);  // 추상화된 포트 사용
        }
    
        @Override
        public Optional<Member> getMemberById(Long id) {
            return memberRepositoryPort.findById(id);
        }
    }
    

    중요 포인트:

    • 여기에는 JPA, DB 커넥션, HTTP 요청 정보 같은 구체 기술 얘기가 전혀 없다.
    • 오로지 비즈니스 흐름만 남겨두려고 한다.

    내 결론: UseCase는 "규칙과 흐름"만 책임진다. 기술은 모른다.

     

    3-4. 어댑터 (Adapter)

    • Inbound Adapter: adapter/in/web/
    • Outbound Adapter: adapter/out/persistence/

    어댑터는 말 그대로 외부 세계와 우리 애플리케이션 사이의 번역기 역할이다.

    이 부분이 개인적으로 약간 충격이었다. 기존 MVC에서 Controller는 그냥 애플리케이션 내부 첫 진입점으로 생각했는데, 헥사고날 관점에서는 Controller조차 "외부에 붙어 있는 기술 (HTTP)"을 다루는 쪽이라서 Adapter로 분류한다. 즉 Controller는 "inbound adapter"다.

    Inbound Adapter (웹 컨트롤러)

    @RestController
    @RequestMapping("/api/members")
    public class MemberWebController {
    
        // Port 인터페이스에만 의존 (구체 UseCase 클래스를 직접 몰라도 된다)
        private final MemberQueryPort memberQueryPort;
        private final MemberCommandPort memberCommandPort;
    
        @GetMapping("/{id}")
        public BaseResponse<?> getMember(@PathVariable Long id) {
            return new BaseResponse<>(
                memberQueryPort.getMemberById(id)
                    .orElseThrow()
            );
        }
    }
    

    여기서 Controller는 더 이상 "MemberApplicationUseCase" 같은 구체 클래스를 직접 들고 있지 않다. 그냥 Port(인터페이스)에만 의존한다. HTTP 요청을 받아서 Port를 호출해주고, 응답을 HTTP 응답 포맷으로 감싸주는 역할만 한다. 이게 inbound adapter다.

    Outbound Adapter (DB 접근 구현)

    @Component
    @RequiredArgsConstructor
    public class MemberJpaAdapter implements MemberRepositoryPort {
    
        // 실제 Spring Data JPA
        private final MemberJpaRepository memberJpaRepository;
    
        @Override
        public Member save(Member member) {
            return memberJpaRepository.save(member);  // 진짜 DB 작업
        }
    
        @Override
        public Optional<Member> findById(Long id) {
            return memberJpaRepository.findById(id);
        }
    
        // 나머지 메서드도 동일한 방식으로 구현
    }
    

    이 어댑터는 Outbound Port(MemberRepositoryPort)의 실제 구현체다. 즉 DB, JPA, 쿼리 최적화 등 "진짜 인프라 기술"은 전부 여기로 몰아넣는다. 덕분에 UseCase는 JPA를 신경 쓸 필요가 없고, 나중에 DB를 갈아끼우더라도 UseCase는 그대로 둔 채 어댑터만 바꾸면 된다.

    내 결론: Adapter는 바깥 세상과 붙어 있는 부분이다. HTTP도 바깥, DB도 바깥이다. 애플리케이션 코어는 거기에 직접 손대지 않는다.

     

    4. 의존성 흐름 비교

     

    MVC 패턴 (강하게 결합된 구조)

    Controller
    → Service
    → Repository
    → Database

    여기서 각 화살표는 "구체 클래스를 직접 알고 있음"을 의미한다.

     

     

    헥사고날 패턴 (포트 중심 구조)

    Inbound Adapter(Controller)
    → Inbound Port(인터페이스)
    → UseCase(비즈니스 규칙)
    → Outbound Port(인터페이스)
    → Outbound Adapter(JPA 등 구체 구현)
    → Database

    화살표 방향은 여전히 요청의 흐름이지만, 의존성은 반대로 정리되어 있다. 핵심은 UseCase가 더 이상 바깥 구현체를 모른다는 점이다.

     

    UseCase는 오직 Port만 알고, Adapter가 그 Port를 구현한다.

     

    정리해서 말하면

    • 컨트롤러는 Inbound Port를 통해 UseCase를 부른다.
    • UseCase는 Outbound Port를 통해 어댑터를 부른다.
    • 어댑터만이 구체 기술(JPA, DB 등)을 안다.

     

    5. "어디서 어디로" 간 코드들

    실제 코드가 어느 폴더로 이동했는지를 표로 정리하면 다음과 같다.

     

    이 표는 내가 머릿속에서 MVC → 헥사고날 매핑시킬 때 제일 도움이 됐다.

     

    요소 MVC에서의 위치 헥사고날에서의 위치 변화 요약

    HTTP 요청 처리 Controller adapter/in/web/Controller 여전히 컨트롤러지만 이제 구체 UseCase가 아니라 Port에 의존
    비즈니스 로직 Service application/.../usecase/...UseCase 역할은 비슷하지만 의존 방향이 역전됨
    데이터 접근 Repository adapter/out/persistence/...Adapter DB 연동 구현은 바깥으로 밀려남
    서비스-레포 연결부 Service → Repository (구체 의존) UseCase → Outbound Port (인터페이스 의존) 강한 결합이 인터페이스 기반 약한 결합으로 바뀜
    도메인 엔티티 단순 JPA 엔티티 도메인 규칙을 가진 Rich Domain Model 엔티티가 수동 객체에서 규칙 보유 객체로 격상

     

    6. 코드 비교로 마무리

     

    MVC 스타일 (기존)

    // Controller가 구체 Service에 직접 의존
    @RestController
    class MemberController {
        private MemberService memberService; // 구체 클래스
    
        public void getMember(Long id) {
            memberService.getMember(id); // 구현체 호출
        }
    }
    
    // Service가 구체 Repository에 직접 의존
    class MemberService {
        private MemberRepository memberRepository; // 구체 클래스
    
        public Member getMember(Long id) {
            return memberRepository.findById(id);
        }
    }
    

     

    헥사고날 스타일 (변환 후)

    // Controller는 Port 인터페이스에만 의존
    @RestController
    class MemberWebController {
        private final MemberQueryPort memberQueryPort; // 인터페이스
    
        public void getMember(Long id) {
            memberQueryPort.getMemberById(id); // 추상화 호출
        }
    }
    
    // UseCase는 Port 인터페이스에만 의존
    class MemberApplicationUseCase implements MemberCommandPort, MemberQueryPort {
        private final MemberRepositoryPort memberRepositoryPort; // 인터페이스
    
        public Member createMember(Member member) {
            return memberRepositoryPort.save(member); // 추상화 호출
        }
    }
    
    // 실제 구현은 어댑터에서 담당
    @Component
    class MemberJpaAdapter implements MemberRepositoryPort {
        private final MemberJpaRepository memberJpaRepository; // Spring Data JPA
    
        public Member save(Member member) {
            return memberJpaRepository.save(member);
        }
    }
    

     

    이 흐름에서 내가 얻은 결론은 단순하다.

    1. UseCase가 중심이다.
      in/out이라는 개념은 UseCase를 기준으로 정의된다.
    2. Adapter는 외부 세계와 맞닿은 변환 레이어다.
      inbound adapter는 HTTP 등을 다루고, outbound adapter는 DB 등을 다룬다.
    3. Port는 서로를 직접 보지 않게 하는 계약이다.
      UseCase는 외부 기술을 몰라도 된다. Controller는 UseCase의 구체 구현을 몰라도 된다.

     

    여기까지는 내가 현재 이해하고 있는 상태이고,

    다음 편에서는 이 구조가 실제로 의존성 역전을 어떻게 보장하는지와 테스트 관점에서 어떤 이득이 있는지를 정리해볼 생각이다.

Designed by Tistory.