Pingu
영차영차! Backend

헥사고날 아키텍처로 비즈니스 로직 보호하기: s-class 프로젝트 실전 사례

2026년 1월 26일
6개 태그
헥사고날 아키텍처
hexagonal architecture
포트 앤 어댑터
클린 아키텍처
Kotlin
Spring Boot

헥사고날 아키텍처로 비즈니스 로직 보호하기: s-class 프로젝트 실전 사례

서론

마이크로서비스 아키텍처에서 각 서비스는 독립적으로 개발되고 배포됩니다. 하지만 시간이 지나면서 데이터베이스 스키마 변경, 외부 API 변경, 프레임워크 업그레이드 등 다양한 외부 요인에 의해 비즈니스 로직이 영향을 받기 쉽습니다.

s-class 프로젝트에서는 **헥사고날 아키텍처(Hexagonal Architecture)**를 적용하여 비즈니스 로직을 외부 의존성으로부터 보호하고, 테스트 용이성과 유지보수성을 높였습니다.

기존 레이어드 아키텍처의 한계

프로젝트 초기에는 전통적인 레이어드 아키텍처(Layered Architecture)를 사용했습니다. Controller → Service → Repository 구조로 간단하고 직관적이었죠.

// 기존 레이어드 아키텍처
@RestController
class UserController {
    private val userService: UserService
}

@Service
class UserService {
    private val userRepository: UserRepository
    // 비즈니스 로직이 여기 섞여있음
}

@Repository
interface UserRepository : JpaRepository<UserEntity, String>

하지만 프로젝트가 커지면서 문제가 생기기 시작했습니다:

  1. 비즈니스 로직이 Service 계층에 흩어짐: Service 클래스가 비대해지고, 어디에 어떤 로직이 있는지 찾기 어려워졌습니다.
  2. 데이터베이스에 강하게 결합: JPA Entity를 도메인 모델로 사용하다 보니, 데이터베이스 스키마 변경이 비즈니스 로직에 직접 영향을 미쳤습니다.
  3. 테스트 어려움: Service를 테스트하려면 항상 Repository를 Mock해야 했고, 실제 비즈니스 로직만 단위 테스트하기 어려웠습니다.
  4. 외부 의존성 변경 시 파급 효과: 외부 API 클라이언트를 변경하면 Service 코드 전체를 수정해야 했습니다.
고민하는 개발자

고민하는 개발자

데이터베이스 스키마 변경, 외부 API 변경, 프레임워크 업그레이드... 매번 비즈니스 로직까지 수정해야 하는 상황. "이게 맞나?"

DDD(Domain-Driven Design)를 제대로 적용하려면 비즈니스 로직을 중심에 두고, 외부 의존성과 완전히 분리해야 했습니다. 그래서 헥사고날 아키텍처로 전환하기로 결정했습니다.

헥사고날 아키텍처란?

헥사고날 아키텍처는 Alistair Cockburn이 제안한 아키텍처 패턴으로, 포트 앤 어댑터(Ports and Adapters) 패턴이라고도 불립니다. 핵심 아이디어는 다음과 같습니다:

  • 비즈니스 로직을 중심에 배치: 도메인 계층은 외부 의존성이 없는 순수한 비즈니스 로직만 포함
  • 외부 세계와의 통신을 어댑터로 분리: 데이터베이스, HTTP, 메시지 큐 등은 모두 어댑터로 처리
  • 포트를 통한 인터페이스 정의: 도메인이 필요로 하는 기능과 제공하는 기능을 포트로 정의
Hexagonal Architecture Diagram

Hexagonal Architecture Diagram

헥사고날 아키텍처의 전체 구조. 도메인을 중심으로 어댑터들이 연결되어 있습니다. (출처: herbertograca.com)

헥사고날 아키텍처는 Alistair Cockburn이 제안한 아키텍처 패턴으로, 포트 앤 어댑터(Ports and Adapters) 패턴이라고도 불립니다. 핵심 아이디어는 다음과 같습니다:

  • 비즈니스 로직을 중심에 배치: 도메인 계층은 외부 의존성이 없는 순수한 비즈니스 로직만 포함
  • 외부 세계와의 통신을 어댑터로 분리: 데이터베이스, HTTP, 메시지 큐 등은 모두 어댑터로 처리
  • 포트를 통한 인터페이스 정의: 도메인이 필요로 하는 기능과 제공하는 기능을 포트로 정의
다이어그램 로딩 중...

계층 구조

s-class 프로젝트의 모든 서비스(account-service, lms-service, payment-service, supporters-service)는 다음과 같은 계층 구조를 따릅니다:

src/main/kotlin/com/sclass/{service}/
├── domain/              # 도메인 계층 (비즈니스 로직의 핵심)
│   ├── model/          # 도메인 모델 (Entity, Value Object)
│   └── port/           # 포트 인터페이스
│       ├── inbound/    # 인바운드 포트 (UseCase 인터페이스)
│       └── outbound/   # 아웃바운드 포트 (Repository, External Service 인터페이스)
│
├── application/         # 애플리케이션 계층 (유스케이스 구현)
│   └── usecase/       # 유스케이스 구현체
│
└── adapter/            # 어댑터 계층 (외부 세계와의 통신)
    ├── inbound/       # 인바운드 어댑터 (Web Controller)
    └── outbound/      # 아웃바운드 어댑터 (Persistence, External API Client)

1. Domain Layer (도메인 계층)

도메인 계층은 비즈니스 로직의 핵심입니다. 외부 의존성이 전혀 없어야 하며, 순수한 Kotlin 코드로만 구성됩니다.

Domain Model

도메인 모델은 비즈니스 규칙을 캡슐화합니다:

// account-service: User 도메인 모델
data class User(
    val id: UserId,
    val email: Email,
    val authProvider: AuthProvider,
    val credential: Credential,
    val profile: UserProfile,
    val services: List<UserService>,
    val createdAt: Instant,
) {
    fun hasAccessTo(service: Service): Boolean {
        return services.any { it.service == service && it.isActive }
    }
    
    fun getRoleIn(service: Service): Role? {
        return services.find { it.service == service && it.isActive }?.role
    }
}

Port (포트)

포트는 도메인이 필요로 하는 기능과 제공하는 기능을 인터페이스로 정의합니다:

Inbound Port (인바운드 포트): 애플리케이션이 제공하는 기능

// account-service: OAuth 인증 유스케이스
interface OAuthAuthenticateUseCase {
    fun authenticateWithCode(
        code: String,
        provider: AuthProvider,
        service: Service,
    ): OAuthAuthenticateResult
}

Outbound Port (아웃바운드 포트): 애플리케이션이 필요로 하는 기능

// account-service: 사용자 저장소 인터페이스
interface UserRepository {
    fun findById(id: String): User?
    fun findByEmail(email: String): User?
    fun findByOAuthId(oauthId: String, provider: AuthProvider): User?
    fun save(user: User): User
}

2. Application Layer (애플리케이션 계층)

애플리케이션 계층은 유스케이스를 구현합니다. 도메인 계층의 포트를 사용하여 비즈니스 로직을 조합합니다:

// account-service: OAuth 인증 유스케이스 구현
@Service
class OAuthAuthenticateUseCaseImpl(
    private val oAuthServiceFactory: OAuthServiceFactory,
    private val userRepository: UserRepository,  // Outbound Port
    private val authServiceClient: AuthServiceClient,
    private val roleRepository: RoleRepository,
) : OAuthAuthenticateUseCase {  // Inbound Port 구현
    
    @Transactional
    override fun authenticateWithCode(
        code: String,
        provider: AuthProvider,
        service: Service,
    ): OAuthAuthenticateResult {
        // 1. OAuth 제공자로부터 사용자 정보 가져오기
        val oAuthService = oAuthServiceFactory.getService(provider)
        val tokenResponse = oAuthService.getToken(code)
        val userInfo = oAuthService.getUserInfo(tokenResponse.accessToken)
        
        // 2. 사용자 조회 또는 생성
        var user = userRepository.findByOAuthId(userInfo.id, provider)
            ?: createNewUser(userInfo, provider, tokenResponse)
        
        // 3. 기본 역할 할당
        if (user.getRoleIn(service) == null) {
            user = assignDefaultRoleToUser(user, service)
        }
        
        // 4. 인증 토큰 생성
        val token = authServiceClient.generateUserToken(...)
        
        return OAuthAuthenticateResult(
            user = user,
            authToken = AuthToken(...),
            hasAccessService = user.hasAccessTo(service),
        )
    }
}

3. Adapter Layer (어댑터 계층)

어댑터 계층은 외부 세계와의 통신을 담당합니다.

Inbound Adapter (인바운드 어댑터)

외부에서 들어오는 요청을 처리합니다:

// account-service: OAuth 컨트롤러
@RestController
@RequestMapping("/api/v1/account/oauth")
class OAuthController(
    private val oAuthAuthenticateUseCase: OAuthAuthenticateUseCase,  // Inbound Port
) {
    @PostMapping("/exchange")
    fun exchangeToken(
        @RequestBody request: OAuthExchangeRequest,
    ): ResponseEntity<AuthResponse> {
        val stateData = OAuthState.decode(request.state)
        
        val result = oAuthAuthenticateUseCase.authenticateWithCode(
            code = request.code,
            provider = AuthProvider.valueOf(request.provider.uppercase()),
            service = stateData.service,
        )
        
        return ResponseEntity.ok(
            AuthResponse(
                token = result.authToken.tokenString,
                expiresAt = result.authToken.expiresAt.toString(),
            )
        )
    }
}

Outbound Adapter (아웃바운드 어댑터)

외부 시스템과의 통신을 처리합니다:

// account-service: JPA 사용자 저장소 어댑터
@Repository
class JpaUserRepository(
    private val userJpaRepository: SpringDataUserRepository,
    private val credentialHandlerFactory: CredentialHandlerFactory,
    private val userMapper: UserMapper,
) : UserRepository {  // Outbound Port 구현
    
    override fun findById(id: String): User? {
        val userEntity = userJpaRepository.findById(id).orElse(null) ?: return null
        val credentialEntity = credentialHandlerFactory
            .getHandler<CredentialEntity>(userEntity.authProvider)
            .findCredentialEntityByUserId(id) ?: return null
        
        return userMapper.toDomain(userEntity, credentialEntity, ...)
    }
    
    override fun save(user: User): User {
        val credentialEntity = credentialHandlerFactory
            .getHandler<CredentialEntity>(user.authProvider)
            .toEntity(user)
        
        val savedEntity = credentialHandlerFactory
            .getHandler<CredentialEntity>(user.authProvider)
            .saveEntity(credentialEntity)
        
        return findById(savedEntity.userId)!!
    }
}

실제 적용 사례

Case 1: LMS Service - 학생 관리

LMS 서비스의 학생 관리 기능을 예로 들어보겠습니다:

// 1. Domain: Inbound Port
interface StudentManagementUseCase {
    fun getStudents(
        organizationId: OrganizationId,
        page: Int,
        limit: Int,
        ...
    ): Pair<List<Student>, Pagination>
    
    fun createStudent(...): Student
    fun updateStudent(...): Student?
}

// 2. Application: UseCase 구현
@Service
class StudentManagementUseCaseImpl(
    private val studentRepository: StudentRepository,  // Outbound Port
    private val userRepository: UserRepository,
    private val courseRepository: CourseRepository,
) : StudentManagementUseCase {
    
    override fun createStudent(...): Student {
        // 비즈니스 로직: 선생님 존재 확인, 학생 생성, 코스 생성 등
        val teacher = teacherId?.let { 
            userRepository.findById(it, organizationId)
                ?: throw IllegalArgumentException("Teacher not found")
        }
        
        val student = Student(...)
        val savedStudent = studentRepository.save(student)
        
        if (courseName != null) {
            val course = Course(...)
            courseRepository.save(course)
        }
        
        return savedStudent
    }
}

// 3. Adapter: Inbound (Controller)
@RestController
@RequestMapping("/api/v1/lms/students")
class StudentController(
    private val studentManagementUseCase: StudentManagementUseCase,
) {
    @PostMapping
    fun createStudent(
        @Valid @RequestBody request: CreateStudentRequest,
        @OrganizationId organizationId: OrganizationId,
    ): ResponseEntity<ApiResponse<StudentResponse>> {
        val student = studentManagementUseCase.createStudent(...)
        return ResponseEntity.ok(ApiResponse.success(StudentResponse.from(student)))
    }
}

// 4. Adapter: Outbound (Repository)
@Repository
class JpaStudentRepository(
    private val studentJpaRepository: SpringDataStudentRepository,
    private val studentMapper: StudentMapper,
) : StudentRepository {
    override fun save(student: Student): Student {
        val entity = studentMapper.toEntity(student)
        val saved = studentJpaRepository.save(entity)
        return studentMapper.toDomain(saved)
    }
}

Case 2: Payment Service - 결제 처리

Payment 서비스의 결제 처리 기능:

// 1. Domain: Inbound Port
interface ProductPaymentUseCase {
    fun preparePayment(...): ProductPaymentInfo
    fun approvePayment(...): ProductPayment
    fun cancelPayment(paymentId: String): ProductPayment
}

// 2. Application: UseCase 구현
@Service
class ProductPaymentService(
    private val paymentRepository: PaymentRepository,  // Outbound Port
    private val nicePayService: NicePayService,
    private val productServiceClient: ProductServiceClient,
) : ProductPaymentUseCase {
    
    override fun approvePayment(...): ProductPayment {
        // 비즈니스 로직: PG 승인, Order 생성, 완료 처리
        val payment = paymentRepository.findByOrderId(orderId)
            ?: throw IllegalArgumentException("Payment not found")
        
        // PG 승인
        val pgResponse = nicePayService.approvePayment(...)
        payment.markPgApproved(pgResponse.tid)
        
        // Order 생성
        productServiceClient.createOrder(...)
        payment.markAsCompleted()
        
        return paymentRepository.save(payment)
    }
}

// 3. Adapter: Inbound (Controller)
@RestController
@RequestMapping("/product")
class ProductPaymentController(
    private val productPaymentUseCase: ProductPaymentUseCase,
) {
    @PostMapping("/approve")
    fun approvePayment(...): ResponseEntity<ProductPaymentResponse> {
        val payment = productPaymentUseCase.approvePayment(...)
        return ResponseEntity.ok(ProductPaymentResponse.from(payment))
    }
}

의존성 방향

헥사고날 아키텍처의 핵심은 의존성 방향입니다:

Adapter (Inbound)  →  Application  →  Domain
Adapter (Outbound) →  Application  →  Domain
  • 도메인 계층: 어떤 계층에도 의존하지 않음 (순수 Kotlin)
  • 애플리케이션 계층: 도메인 계층에만 의존
  • 어댑터 계층: 애플리케이션 계층과 도메인 계층에 의존

이렇게 하면:

  • 데이터베이스를 PostgreSQL에서 MongoDB로 변경해도 도메인 로직은 변경되지 않음
  • REST API를 GraphQL로 변경해도 비즈니스 로직은 변경되지 않음
  • 외부 API 클라이언트를 변경해도 도메인 로직은 변경되지 않음

장점과 단점

장점

  1. 테스트 용이성: 도메인 로직을 외부 의존성 없이 테스트 가능
  2. 유연성: 데이터베이스, 외부 서비스를 쉽게 교체 가능
  3. 비즈니스 로직 보호: 도메인 계층이 외부 변경에 영향받지 않음
  4. 명확한 책임 분리: 각 계층의 역할이 명확함
  5. 유지보수성: 변경 영향 범위가 명확함

단점

  1. 복잡도 증가: 작은 프로젝트에서는 오버엔지니어링일 수 있음
  2. 보일러플레이트 코드: 인터페이스와 구현체를 분리해야 함
  3. 학습 곡선: 팀원들이 아키텍처 패턴을 이해해야 함

Next Actions

  • 도메인 이벤트 추가: 도메인 모델에서 이벤트를 발행하여 느슨한 결합 구현
  • CQRS 패턴 적용: 읽기와 쓰기를 분리하여 성능 최적화
  • 도메인 서비스 추출: 복잡한 비즈니스 로직을 도메인 서비스로 분리
  • 어댑터 테스트 강화: Mock 객체를 사용한 포트 테스트 작성
  • 의존성 주입 최적화: 순환 의존성 방지 및 의존성 그래프 단순화
  • 도메인 모델 검증 강화: 값 객체에서 비즈니스 규칙 검증 로직 추가

댓글

?