Spring

Hexagonal Architecture 정말 필요할까?

curiousKidd 2025. 12. 4. 16:11
반응형

새로운 프로젝트의 아키텍처를 분석하면서 Hexagonal Architecture(육각형 아키텍처)를 처음 제대로 마주했다.

사실 이름은 몇 번 들어봤지만, 실제 코드로 구현된 것을 보니 솔직히 첫 느낌은 "이거 너무 복잡한 거 아냐?"였다.

인터페이스가 너무 많고, 모듈도 쪼개져 있고, AutoConfiguration까지... 패키지 분리만으로도 충분히 레이어를 나눌 수 있는데 왜 이렇게까지 해야 하나 싶었다. 하지만 깊이 파고들수록 "아, 이래서 이렇게 만들었구나" 싶은 순간들이 있었다.

이 글은 Hexagonal Architecture를 처음 접하는 분들, 그리고 나처럼 "이게 정말 필요한가?" 의문을 가진 분들을 위한 실무 개발자의 시선에서 쓴 기록이다.


Hexagonal Architecture가 뭔데?

Hexagonal Architecture는 Ports and Adapters Pattern이라고도 불린다.

2005년 Alistair Cockburn이 제안한 아키텍처 패턴으로, 핵심 아이디어는 간단하다.

"비즈니스 로직을 외부 세계(UI, DB, 외부 API)로부터 완전히 격리시킨다"

기본 구조

        외부 세계
           ↓
      ┌─────────┐
  UI  │  PORT   │  DB
 ───→ │ ADAPTER │ ←───
      └────┬────┘
           ↓
      ┌─────────┐
      │ DOMAIN  │  ← 비즈니스 로직
      └─────────┘

3개 레이어로 구성된다

  1. Domain (중심부): 순수한 비즈니스 로직
  2. Ports (경계): 인터페이스 (Input Port + Output Port)
  3. Adapters (외부): 구체적인 기술 구현

실제 코드로 보는 구조

내가 분석한 프로젝트의 실제 구조다.

Domain Layer (순수 비즈니스 로직)

// model/Employee.kt
data class Employee(
    val employeeId: Long,
    val name: String,
    val companyId: Long
) {
    fun updateName(newName: String): Employee {
        require(newName.isNotBlank()) { "이름은 필수입니다" }
        return copy(name = newName)
    }
}

완전히 순수한 Kotlin 코드다. Spring도, JDBC도, 어떤 프레임워크도 모른다.

Port Layer (인터페이스)

// Input Port (애플리케이션을 호출)
interface EmployeeLookUpService {
    fun get(companyId: CompanyIdentity, employeeId: EmployeeIdentity): Employee
}

// Output Port (애플리케이션이 호출)
interface EmployeeRepository {
    fun findByEmployeeIdentity(...): Employee?
}

비즈니스 로직이 필요로 하는 "계약"만 정의한다.

Adapter Layer (실제 구현)

// Input Adapter (REST API)
@RestController
class EmployeeApiController(
    private val service: EmployeeLookUpService  // Port에 의존
) {
    @GetMapping("/employees/{id}")
    fun getEmployee(@PathVariable id: Long): EmployeeResponse {
        return service.get(...).toResponse()
    }
}

// Output Adapter (JDBC 구현)
class EmployeeRepositoryImpl(
    private val jdbcRepository: EmployeeJdbcRepository
) : EmployeeRepository {
    override fun findByEmployeeIdentity(...): Employee? {
        return jdbcRepository.findById(...).toModel()
    }
}

내가 가졌던 의문 "패키지 분리랑 뭐가 다른데?"

솔직히 처음엔 이게 의문이었다.

일반적인 MVC 패키지 구조

my-app/
└── src/main/kotlin/
    ├── controller/
    │   └── EmployeeController.kt
    ├── service/
    │   └── EmployeeService.kt
    └── repository/
        └── EmployeeRepository.kt

레이어가 분리되어 있다. 깔끔해 보인다. 그런데 문제는...

단일 모듈의 치명적 약점

// controller/EmployeeController.kt
package com.example.controller

import com.example.service.EmployeeService
import com.example.repository.EmployeeRepository  // ❌ 가능!

@RestController
class EmployeeController(
    private val service: EmployeeService,
    private val repository: EmployeeRepository  // ❌ 계층 위반!
) {
    @GetMapping("/employees/{id}")
    fun get(@PathVariable id: Long) {
        // Controller가 Repository를 직접 호출
        return repository.findById(id)  // ❌ 심각한 설계 위반
    }
}

문제의 핵심 이 코드는 컴파일이 된다.

패키지는 분리되어 있지만, Java/Kotlin 컴파일러는 이런 계층 위반을 막을 수 없다. 오직 코드 리뷰에서만 발견 가능하고, 실수로 import만 하면 끝이다.


멀티 모듈의 핵심 빌드 타임 강제

Hexagonal Architecture를 멀티 모듈로 구현하면 컴파일러가 설계를 강제한다.

모듈 의존성 설정

// api/build.gradle.kts
dependencies {
    implementation(project(":service"))
    // implementation(project(":repository-jdbc"))  // ❌ 의존성 없음!
}

강제되는 올바른 설계

// api/EmployeeController.kt
import com.example.service.EmployeeService  // ✅ 가능
import com.example.repository.EmployeeRepository  // ❌ 컴파일 에러!
// "Unresolved reference: repository"

@RestController
class EmployeeController(
    private val service: EmployeeService,  // ✅ 정상
    // private val repository: EmployeeRepository  // ❌ 컴파일 불가능!
) {
    // ...
}

핵심 차이

  • 단일 모듈: "계층 위반하지 마세요" (권고사항)
  • 멀티 모듈: "계층 위반 불가능" (시스템 강제)

순환 참조도 빌드 시점에 차단

단일 모듈에서 발생 가능한 순환 참조

// service/EmployeeService.kt
class EmployeeService(
    private val validator: EmployeeValidator  // Controller 레이어의 클래스
) {
    fun create(employee: Employee) {
        validator.validate(employee)  // ❌ 역방향 의존!
    }
}

// controller/EmployeeValidator.kt
class EmployeeValidator(
    private val service: EmployeeService  // ❌ 순환 참조!
) {
    fun validate(employee: Employee) {
        val existing = service.findByEmail(employee.email)
    }
}

결과: 컴파일 성공, 런타임에 StackOverflowError 또는 이상한 동작

멀티 모듈에서는?

// api/build.gradle.kts
dependencies {
    implementation(project(":service"))  // api → service
}

// service/build.gradle.kts
dependencies {
    // implementation(project(":api"))  // ❌ 추가하면 Gradle 에러!
}

Gradle 에러

FAILURE: Build failed with an exception.

Circular dependency between the following tasks:
:api:compileKotlin
\--- :service:compileKotlin
     \--- :api:compileKotlin (*)

빌드 자체가 실패한다. 순환 참조를 설계 단계에서 원천 차단한다.


실무에서 느낀 진짜 장점

1. 기술 스택 변경이 쉽다

프로젝트를 진행하다 보면 "JDBC에서 JPA로 바꿔야 할까?" 같은 고민이 생긴다.

기존 레이어드 아키텍처

// Service가 JDBC에 강결합
class EmployeeService(
    @Autowired private val jdbcTemplate: JdbcTemplate
) {
    fun getEmployee(id: Long): Employee {
        return jdbcTemplate.queryForObject(...)  // SQL 직접 실행
    }
}

JDBC를 JPA로 바꾸려면? Service 코드 전부 수정해야 한다.

Hexagonal Architecture

// Service는 인터페이스에만 의존
class EmployeeLookUpServiceImpl(
    private val repository: EmployeeRepository  // 인터페이스!
) {
    fun get(...): Employee {
        return repository.findByEmployeeIdentity(...)
    }
}

JDBC → JPA 전환

  1. EmployeeJpaRepositoryAdapter 새로 작성
  2. AutoConfiguration 교체
  3. Service 코드는 한 줄도 수정 안 함!

2. 테스트가 정말 쉽다

@Test
fun `구성원 조회 테스트`() {
    // Given: Mock Repository (실제 DB 불필요!)
    val mockRepository = mockk<EmployeeRepository>()
    every { mockRepository.findByEmployeeIdentity(...) } returns testEmployee
    
    val service = EmployeeLookUpServiceImpl(mockRepository)
    
    // When
    val result = service.get(...)
    
    // Then
    assertThat(result).isEqualTo(testEmployee)
}

DB 없이도 비즈니스 로직 테스트 가능. Testcontainers 없이도 빠른 단위 테스트.

3. 팀 협업이 명확하다

팀 A: api 모듈 (REST API 개발)
팀 B: service 모듈 (비즈니스 로직)
팀 C: repository-jdbc 모듈 (DB 연동)

각 팀은 인터페이스만 합의하고 병렬 개발 가능.
팀 A가 실수로 팀 C 코드 직접 호출? → 빌드 에러로 즉시 발견

그럼 항상 이렇게 해야 하나?

아니다. 이건 모든 프로젝트에 적용할 필요 없다.

멀티 모듈이 과도한 경우

❌ 단순 CRUD 애플리케이션
❌ 팀 규모 1-2명
❌ 빠른 프로토타이핑 필요
❌ 단기 프로젝트 (3개월 이하)

이런 경우엔 단일 모듈 + 패키지 분리로 충분하다. 오히려 멀티 모듈은 관리 포인트만 늘어나는 부담이 된다.

멀티 모듈이 가치 있는 경우

✅ 복잡한 비즈니스 로직
✅ 여러 외부 시스템 연동
✅ 팀 규모 5명 이상 또는 여러 팀 협업
✅ 장기 운영 계획 (5년 이상)
✅ 신입 개발자 합류 빈번
✅ 기술 스택 교체 가능성

실무 판단 기준

when {
    팀규모 <= 3 && 프로젝트복잡도 == "낮음" -> "단일 모듈 + 패키지 분리"
    팀규모 >= 5 || 장기운영 || 복잡한비즈니스로직 -> "멀티 모듈"
    else -> "프로젝트 특성에 맞게 선택"
}

트레이드오프: 장단점 정리

장점

  • ✅ 빌드 타임에 의존성 위반 차단 (사람 → 시스템)
  • ✅ 순환 참조 자동 감지 (런타임 → 빌드 타임)
  • ✅ 기술 스택 교체 용이 (Service 코드 불변)
  • ✅ 높은 테스트 용이성 (Mock 활용)
  • ✅ 팀 협업 명확 (모듈 경계 = 팀 경계)

단점

  • ⚠️ 초기 설정 복잡도 (AutoConfiguration, 멀티 모듈 구조)
  • ⚠️ 보일러플레이트 코드 증가 (인터페이스 다수)
  • ⚠️ 학습 곡선 존재 (팀원 교육 필요)
  • ⚠️ 작은 프로젝트엔 과도할 수 있음

실제 프로젝트 적용 팁

1. 점진적 도입

1단계: 핵심 도메인만 Port-Adapter 분리
2단계: 복잡도 높은 모듈 멀티 모듈화
3단계: 전체 시스템으로 확장

처음부터 전부 다 바꾸려 하지 말자.

2. 팀 컨벤션 명확히

// Bean 네이밍 규칙
@Bean
fun employeeService(...): EmployeeService =  // 인터페이스명 사용
    EmployeeServiceImpl(...)

// 모듈 의존성 원칙
// ✅ 허용: api → service → model
// ❌ 금지: api → repository (컴파일 에러)

3. AutoConfiguration 템플릿 만들기

// 공통 템플릿
@AutoConfiguration
class {ModuleName}AutoConfiguration {
    @Bean
    fun {serviceName}(...): {ServiceInterface} =
        {ServiceImpl}(...)
}

반복 작업을 줄이자.


결론: "이론적 우아함" vs "실용적 가치"

6년차 개발자로서 느낀 점은, 아키텍처는 문제를 해결하기 위한 도구라는 것이다.

Hexagonal Architecture는 분명히 우아하고, 이론적으로 완벽하다. 하지만 중요한 건:

  • 팀 규모는?
  • 프로젝트 복잡도는?
  • 얼마나 오래 운영할 건가?
  • 기술 변경 가능성은?

이런 질문에 답하면서 우리 프로젝트에 맞는 아키텍처를 선택하는 게 더 중요하다.

멀티 모듈의 핵심 가치는 "사람이 지켜야 하는 원칙"을 "시스템이 강제하는 구조"로 바꾸는 것이다. 이게 당신 팀에 필요하다면 도입하고, 아니라면 과감히 단순한 구조를 선택하자.

완벽한 아키텍처는 없다. 우리 상황에 맞는 아키텍처가 있을 뿐이다.


참고 자료


2025년 12월, 6년차 개발자의 아키텍처 고민 기록

반응형