AquilaLog
DIP헥사고날

결합이 터뜨린 연쇄 참조 오류 (헥사고날)

aquila profile image
aquila
2026.03.24 02:02수정 2026.04.02 10:50조회 8
댓글 0조회8

요약: Java에서 Kotlin으로 마이그레이션하던 중, 한 도메인의 Service나 JPA Entity를 수정하면 다른 도메인까지 연쇄적으로 컴파일 에러가 번져 테스트조차 수행할 수 없는 상황을 겪었습니다. 원인은 단순한 문법 변환 문제가 아니라, Service 간 직접 참조와 JPA Entity 공유, 인프라 중심 의존성으로 얽힌 구조적 강결합이었습니다. 이 글에서는 연쇄 오류를 어떻게 구조 문제로 분리해 해석했는지, 왜 여러 대안 중 헥사고날 아키텍처를 선택했는지, 그리고 실제 적용 후 무엇이 달라졌는지를 정리합니다.

시작하며

안녕하세요. 아퀼라 입니다. Java로 작성된 서비스를 Kotlin으로 옮기던 중, 예상보다 훨씬 큰 벽을 만났습니다.

처음에는 단순히 문법만 바꾸면 되는 작업이라고 생각했습니다. 그런데 PostService 하나를 Kotlin 스타일로 정리하는 순간, 전혀 다른 도메인의 NotificationService, CommentService, 테스트 코드까지 줄줄이 깨지기 시작했습니다. 코드 한 줄을 바꿨을 뿐인데 컴파일 에러가 여러 도메인으로 번지는 일이 반복되자, 이건 더 이상 단순한 마이그레이션 이슈가 아니라는 걸 직감했습니다.

이번 글에서는 이 문제를 단순한 리팩토링 이슈로 보지 않고, 왜 이런 연쇄 오류가 구조적으로 발생할 수밖에 없었는지를 추적한 과정을 정리해 보려고 합니다. 그리고 여러 대안을 비교한 끝에 왜 헥사고날 아키텍처를 선택했는지, 실제 전환 과정에서 어떤 문제를 만났고 어떻게 풀었는지도 함께 살펴보겠습니다.

문제가 어떻게 드러났나

마이그레이션 중 가장 먼저 체감한 문제는 변경 범위를 전혀 제어할 수 없었다는 점입니다.

예를 들어 PostService의 메서드 시그니처를 Kotlin에 맞게 조금만 정리해도, 이를 직접 호출하던 다른 도메인의 Service들이 즉시 컴파일 에러를 일으켰습니다. PostEntity에 필드를 추가하거나 타입을 수정하면, 그 엔티티를 직접 참조하는 Repository와 Service들이 줄줄이 깨졌습니다. 결국 한 도메인의 테스트를 돌리기 위해 다른 도메인의 오류부터 먼저 해결해야 했고, 마이그레이션은 파일 단위가 아니라 연관된 코드 전체를 한꺼번에 수정해야만 진행되는 상태가 되었습니다.

flowchart LR
	U["PostService 수정"] --> A["NotificationService 컴파일 에러"]
	U --> B["CommentService 컴파일 에러"]
	A --> C["테스트 전체 실행 불가"]
	B --> C
	C --> D["마이그레이션 진행 불가"]

처음에는 Kotlin 변환 과정에서 흔히 생기는 오류들이 한꺼번에 터진 것처럼 보였습니다. 하지만 에러를 하나씩 뜯어보니, 실제로는 서로 다른 성격의 문제가 같은 빌드 안에서 섞여 있었습니다. 이 구분을 하지 않으면 원인을 잘못 짚게 됩니다.

에러를 분리해서 보니 보인 것들

Service 간 직접 호출

가장 먼저 눈에 띈 것은 Service끼리 직접 의존하는 구조였습니다.

Kotlin
class NotificationService(	private val postService: PostService) {	fun notifyNewComment(postId: Long) {		val post = postService.getPost(postId)		// ...	}}

이 구조에서는 PostService의 메서드 시그니처가 바뀌는 순간 NotificationService가 바로 영향을 받습니다. 즉, 도메인 경계가 없기 때문에 한쪽의 변경이 다른 쪽의 컴파일 타임 계약까지 직접 흔들게 됩니다.

JPA Entity 공유

두 번째 문제는 JPA Entity를 여러 도메인에서 직접 사용하고 있었다는 점입니다.

Kotlin
class CommentService(	private val postRepository: PostRepository) {	fun addComment(postId: Long, content: String) {		val postEntity: PostEntity = postRepository.findById(postId)		// ...	}}

이 경우 PostEntity의 구조가 바뀌면 Post 도메인만 영향을 받는 것이 아니라, 해당 엔티티를 직접 참조하는 모든 도메인이 함께 흔들립니다. 도메인 모델과 영속성 모델의 경계가 없어서 생기는 문제였습니다.

Kotlin 변환 에러와 Mock 실패는 무엇이었나

반면 아래와 같은 에러는 근본 원인이 아니라, 마이그레이션 과정에서 함께 드러난 노이즈에 가까웠습니다.

TXT
Type mismatch: inferred type is String? but String was expected
TXT
org.mockito.exceptions.misusing.MissingMethodInvocationException

nullable 변환으로 인한 타입 불일치나, 메서드 시그니처 변경으로 인해 기존 Mock 설정이 깨지는 현상은 분명 해결해야 할 문제입니다. 다만 이것만 고친다고 해서 연쇄 참조 오류가 사라지지는 않습니다. 핵심은 구조적 강결합이 먼저이고, Kotlin 변환 오류와 테스트 깨짐은 그 위에 겹쳐 나타난 증상이었다는 점입니다.

flowchart TD
	E["컴파일 에러 다발"] --> N1["Service 간 직접 참조 ← 핵심 원인"]
	E --> N2["JPA Entity 공유 ← 핵심 원인"]
	E --> N3["Kotlin nullable 변환 ← 노이즈"]
	E --> N4["Mock 설정 무효화 ← 2차 증상"]
	N1 --> D["원인 분리가 핵심"]
	N2 --> D
	N3 --> D
	N4 --> D
Tip

강결합 구조는 원래부터 존재하고 있었습니다. 다만 Java 시절에는 변경 폭이 크지 않아 잘 드러나지 않았을 뿐입니다. Kotlin 마이그레이션처럼 넓은 변경이 들어오자, 숨어 있던 구조적 문제가 한꺼번에 수면 위로 올라온 셈입니다.

근본 원인은 무엇이었나

정리해 보면 원인은 세 가지였습니다.

  1. Service 간 직접 의존

도메인 간 호출이 인터페이스나 경계 없이 Service 주입으로 연결되어 있었습니다.

  1. JPA Entity 공유

영속성 모델이 곧 도메인 모델처럼 쓰이면서, Entity 구조 변경이 여러 계층으로 전파되었습니다.

  1. 의존성 방향 미분리

도메인이 JPA와 Spring 같은 인프라 기술에 직접 의존하고 있어, 아래 계층의 변경이 위 계층으로 쉽게 전파되었습니다.

graph TD
	subgraph "기존 구조의 문제"
		direction TB
		PS["PostService"] -->|"직접 의존"| NS["NotificationService"]
		PS -->|"직접 의존"| CS["CommentService"]
		NS -->|"직접 참조"| PE["PostEntity (JPA)"]
		CS -->|"직접 참조"| PE
		PS -->|"직접 참조"| PE
	end

	subgraph "결과"
		R1["PostEntity 수정 → 여러 Service 깨짐"]
		R2["PostService 수정 → 타 도메인 Service 깨짐"]
		R3["테스트 전체 실행 불가"]
	end

	PE -.-> R1
	PS -.-> R2
	R1 --> R3
	R2 --> R3
Waring

문제의 본질은 Kotlin이 아니라 도메인 간 경계 없는 직접 참조 구조였습니다. 언어 전환은 원인이 아니라, 기존 구조의 약점을 강하게 드러내는 계기였습니다.

어떤 해결책을 검토했나

구조적 결합을 줄이는 방법을 몇 가지 후보로 정리해 비교했습니다.

패키지 재구성

도메인별로 패키지를 나누고 접근 범위를 제한하는 방식입니다.

장점은 적용이 빠르다는 점입니다. 하지만 Service 간 직접 호출 자체를 막는 구조적 강제력은 약합니다. 결국 개발자가 계속 규칙을 기억하고 지켜야 합니다.

탈락

인터페이스 추출

직접 Service를 참조하지 않고 인터페이스를 사이에 두는 방법입니다.

겉으로 보기에는 결합이 낮아진 것처럼 보일 수 있습니다. 하지만 인터페이스가 어디에 놓이느냐에 따라 DIP가 쉽게 무너질 수 있고, 무엇보다 JPA Entity 공유 문제는 그대로 남습니다.

탈락

이벤트 기반 분리

도메인 간 직접 호출 대신 이벤트를 발행하고 구독하도록 바꾸는 방법입니다.

물리적 결합을 줄이는 데는 효과적이지만, 현재 서비스 규모에서 이벤트 순서 보장, 실패 처리, 디버깅 복잡도를 감당하기에는 비용이 컸습니다.

탈락

Gradle 멀티 모듈 분리

도메인과 인프라를 물리적으로 분리해 컴파일 타임에 의존성 침범을 막는 방법입니다.

방향성은 좋았지만, 어떤 기준으로 모듈을 자를 것인지에 대한 설계 원칙이 먼저 필요했습니다. 모듈 분리는 해답이라기보다, 해답을 강제하는 도구에 가깝다고 판단했습니다.

보류

헥사고날 아키텍처

도메인을 중심에 두고 Port와 Adapter로 외부와의 경계를 명확히 나누는 방식입니다.

이 방식은 Service 간 직접 의존을 Port로 대체할 수 있고, JPA Entity를 어댑터 내부로 밀어 넣어 도메인 모델과 분리할 수 있으며, 의존성 방향을 인프라에서 도메인 안쪽으로 역전시킬 수 있습니다.

채택

Key Point

헥사고날 아키텍처를 선택한 이유는 단순했습니다. 이번 문제의 세 가지 원인인 Service 직접 참조, Entity 공유, 인프라 중심 의존성을 한 번에 함께 해결할 수 있는 유일한 선택지였기 때문입니다.

왜 헥사고날 아키텍처가 맞았나

현재 서비스는 단순한 CRUD 수준을 넘어, 파일 저장, 메일 전송, 캐시, 외부 인프라 연동이 이미 존재하는 상태였습니다. 게시글, 댓글, 알림 같은 도메인도 서로 상호작용하고 있었고, 마이그레이션 이후에도 기능이 계속 추가될 예정이었습니다.

이런 상황에서는 도메인 규칙은 안쪽에 두고, 데이터베이스나 외부 API 같은 기술 세부사항은 바깥쪽 Adapter로 밀어내는 구조가 장기적으로 훨씬 유리합니다. 무엇보다도 마이그레이션 도중 특정 도메인만 독립적으로 수정하고 테스트할 수 있는 기반이 필요했습니다.

판단 기준현재 서비스 상황헥사고날 적합도
외부 서비스 연동파일 저장, 메일 전송, 캐시 등 외부 기술 사용✅ Adapter로 분리 가능
도메인 상호작용게시글, 댓글, 알림이 서로 연결됨✅ Port로 경계 명확화
마이그레이션 상황Java → Kotlin 전환 중✅ 점진적 전환 가능
테스트 요구 수준도메인 로직을 독립적으로 검증해야 함✅ 순수 도메인 테스트 용이

헥사고날 아키텍처를 어떻게 이해했나

헥사고날 아키텍처의 핵심은 도메인이 외부 기술을 모르도록 만드는 것입니다. 외부 세계는 Port라는 인터페이스를 통해서만 도메인과 연결되고, 실제 구현은 Adapter가 담당합니다.

graph LR
	subgraph "외부 세계 (Driving Side)"
		A1["REST Controller"]
		A2["Kafka Consumer"]
	end

	subgraph "Inbound Adapter"
		IA["Inbound<br>Adapter"]
	end

	subgraph "Application Core"
		IP["Inbound Port<br>(Interface)"]
		UC["Use Case<br>(비즈니스 로직)"]
		DM["Domain Model<br>(Entity, VO)"]
		OP["Outbound Port<br>(Interface)"]
	end

	subgraph "Outbound Adapter"
		OA["Outbound<br>Adapter"]
	end

	subgraph "외부 세계 (Driven Side)"
		DB["Database"]
		EXT["외부 API"]
	end

	A1 --> IA
	A2 --> IA
	IA --> IP
	IP --> UC
	UC --> DM
	UC --> OP
	OP --> OA
	OA --> DB
	OA --> EXT

계층형 구조에서는 Service가 Repository 같은 인프라 구현체를 직접 참조합니다. 반면 헥사고날에서는 Use Case가 의존하는 대상이 구체 구현이 아니라 Port 인터페이스입니다. 즉, 의존성은 항상 안쪽을 향하게 되고, 바깥쪽 구현은 나중에 갈아 끼울 수 있습니다.

실제 적용은 어떻게 진행했나

한 번에 전체를 갈아엎는 방식은 위험했습니다. 그래서 Strangler Fig 패턴처럼 기존 구조를 유지하면서, 기능 단위로 새 구조에 옮겨 가는 점진적 전환 전략을 택했습니다.

gantt
	title "헥사고날 전환 타임라인"
	dateFormat X
	axisFormat %s
	section "1단계: 기반 구축"
	"모듈 구조 설계" :a1, 0, 1
	section "2단계: 도메인 분리"
	"도메인 모델 추출" :a2, 1, 2
	"영속성 모델 분리" :a3, 2, 3
	section "3단계: Port/Adapter 적용"
	"Port 정의" :a4, 3, 4
	"Adapter 구현" :a5, 4, 5
	section "4단계: 검증"
	"ArchUnit 테스트 추가" :a6, 5, 6
	"도메인 단위 테스트 작성" :a7, 6, 7

1. 도메인 모델과 영속성 모델 분리

기존에는 JPA Entity가 곧 도메인 모델처럼 사용되고 있었습니다.

Kotlin
@Entityclass PostEntity(	@Id @GeneratedValue val id: Long = 0,	var title: String,	var content: String,	@ManyToOne val author: MemberEntity)

이를 순수 도메인 객체와 영속성 전용 모델로 분리했습니다.

Kotlin
class Post(	val id: PostId,	val title: String,	val content: String,	val authorId: MemberId) {	fun validateTitle() {		require(title.isNotBlank()) { "제목은 비어있을 수 없습니다" }	}}
data class PostId(val value: Long)data class MemberId(val value: Long)
Kotlin
@Entity@Table(name = "post")class PostJpaEntity(	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)	val id: Long = 0,	var title: String,	var content: String,	var authorId: Long)

2. Port를 중심으로 유스케이스 정의

이후 Use Case가 직접 Repository나 다른 Service를 참조하지 않도록 Port를 정의했습니다.

Kotlin
interface GetPostUseCase {	fun getPost(postId: PostId): Post}
interface LoadPostPort { fun loadPost(postId: PostId): Post}
TXT
@Serviceclass GetPostService(	private val loadPostPort: LoadPostPort) : GetPostUseCase {	override fun getPost(postId: PostId): Post {		return loadPostPort.loadPost(postId)	}}
Kotlin
@Repositoryclass PostPersistenceAdapter(	private val postJpaRepository: PostJpaRepository,	private val postMapper: PostMapper) : LoadPostPort {	override fun loadPost(postId: PostId): Post {		val entity = postJpaRepository.findById(postId.value)			.orElseThrow { EntityNotFoundException("게시글을 찾을 수 없음: $postId") }		return postMapper.toDomain(entity)	}}

3. 도메인 간 직접 참조 제거

예전에는 NotificationServicePostService를 직접 주입받고 있었습니다.

TXT
class NotificationService(	private val postService: PostService)

이 구조를, Notification 도메인이 필요한 정보만 요청하는 Port 기반 구조로 바꿨습니다.

Kotlin
interface LoadPostInfoPort {	fun loadPostTitle(postId: PostId): String}
@Serviceclass NotifyCommentService( private val loadPostInfoPort: LoadPostInfoPort) : NotifyCommentUseCase { override fun notify(postId: PostId, commentAuthor: String) { val postTitle = loadPostInfoPort.loadPostTitle(postId) // 알림 생성 로직... }}

이제 PostService의 내부 구현이 바뀌더라도 Port 계약만 유지되면 Notification 도메인은 영향을 받지 않습니다.

적용하면서 부딪힌 문제들

매핑 코드가 너무 많아졌다

도메인 모델과 영속성 모델을 분리하자, 모든 Entity마다 변환 로직이 필요해졌습니다.

TXT
PostMapper, CommentMapper, MemberMapper, NotificationMapper...

초기에는 보일러플레이트가 꽤 부담스러웠습니다. 그래서 Kotlin 확장 함수를 활용해 매핑 코드를 최대한 단순화했습니다.

Kotlin
fun PostJpaEntity.toDomain() = Post(	id = PostId(this.id),	title = this.title,	content = this.content,	authorId = MemberId(this.authorId))
fun Post.toEntity() = PostJpaEntity( id = this.id.value, title = this.title, content = this.content, authorId = this.authorId.value)

JPA Dirty Checking이 더 이상 기대대로 동작하지 않았다

도메인 객체와 영속성 컨텍스트가 분리되면서, 예전처럼 객체를 바꾸기만 해도 자동으로 저장되는 흐름이 사라졌습니다.

Kotlin
override fun updatePost(post: Post) {	val entity = post.toEntity()	postJpaRepository.save(entity)}

처음에는 불편하게 느껴졌지만, 오히려 저장 시점이 명확해져 디버깅은 더 쉬워졌습니다.

Warning

Dirty Checking의 암묵적 편의 대신, 어떤 변경이 언제 저장되는지 명시적으로 드러나는 구조를 택했다고 볼 수 있습니다.

순환 참조 위험이 생겼다

도메인 간 Port를 설계하다 보면, Post가 Notification을 알고 Notification이 다시 Post를 아는 식의 순환 참조가 생길 수 있습니다. 이 문제는 도메인 이벤트를 통해 단방향 흐름으로 정리했습니다.

Kotlin
class Post(...) {	fun publishComment(): PostCommentedEvent {		return PostCommentedEvent(postId = this.id, title = this.title)	}}
@Serviceclass NotificationEventHandler( private val createNotificationUseCase: CreateNotificationUseCase) { @EventListener fun onPostCommented(event: PostCommentedEvent) { createNotificationUseCase.create(event.postId, event.title) }}

점진적 전환 중 ArchUnit 규칙과 충돌했다

아직 새 구조로 옮기지 못한 코드가 남아 있는 상태에서 ArchUnit 규칙을 바로 강하게 걸면, 기존 코드가 모두 위반으로 잡히는 문제가 있었습니다. 그래서 전환 중인 코드는 임시로 표시하고, 최종적으로 어노테이션을 제거하는 전략을 사용했습니다.

Kotlin
@ArchTestval domainShouldNotDependOnAdapter: ArchRule =	noClasses()		.that().resideInAPackage("..domain..")		.and().areNotAnnotatedWith(LegacyCode::class.java)		.should().dependOnClassesThat()		.resideInAPackage("..adapter..")

이게 오버 엔지니어링은 아니었을까

겉으로 보기에는 클래스 수가 늘고, Port와 Adapter가 추가되고, 매핑 코드도 생기니 과한 설계처럼 보일 수 있습니다. 실제로 저도 처음에는 그렇게 느꼈습니다.

하지만 이번 문제는 단순히 코드를 예쁘게 나누는 수준이 아니라, 한 도메인의 변경이 다른 도메인을 무너뜨리는 구조 자체를 막아야 하는 상황이었습니다. 패키지 분리나 인터페이스 하나로는 다시 같은 문제가 반복될 가능성이 높았습니다.

헥사고날 아키텍처와 ArchUnit 검증을 함께 도입한 것은, 개발자 개인의 주의력에 맡기지 않고 아키텍처 차원에서 경계를 지키기 위한 안전장치를 마련한 것이었습니다.

Summary

정리하면 이번 선택은 보일러플레이트를 감수하더라도, 이후의 변경 비용과 연쇄 장애 가능성을 줄이기 위한 투자였습니다.

적용 결과는 어땠나

구조를 바꾼 뒤 가장 크게 달라진 점은 마이그레이션의 단위가 다시 도메인 단위로 돌아왔다는 것입니다. 이제는 특정 도메인을 수정해도 다른 도메인의 컴파일 오류를 먼저 해결할 필요가 없어졌고, 테스트도 훨씬 독립적으로 실행할 수 있게 되었습니다.

검증 항목BeforeAfter
도메인 변경 영향한쪽 수정 시 다른 도메인 연쇄 컴파일 에러Port 계약 유지 시 독립 변경 가능
마이그레이션 진행 방식연관된 파일을 함께 수정해야 함도메인 단위 점진 전환 가능
단위 테스트Mock 설정 복잡, 프레임워크 의존 큼순수 도메인 객체 중심 테스트 가능
인프라 교체도메인 코드까지 수정될 가능성 높음Adapter 교체 중심으로 대응 가능
의존성 규칙 검증코드 리뷰에 의존ArchUnit로 자동 검증
Key Point

핵심은 단순합니다. 연쇄 참조 오류를 개별적으로 봉합한 것이 아니라, 그런 오류가 다시 쉽게 발생하지 않도록 구조를 바꿨다는 점입니다.

비슷한 문제가 다시 생긴다면

같은 유형의 증상이 다시 나타난다면 아래 순서로 점검해 볼 수 있습니다.

flowchart TD
	A["1. 컴파일 에러가 다른 도메인으로 전파되는가?"] --> B{"domain에서 adapter를 import 하는가?"}
	B -- "Yes" --> P["DIP 위반 수정"]
	B -- "No" --> C{"Service가 다른 도메인의 Service를 직접 주입하는가?"}
	C -- "Yes" --> R["Port 인터페이스로 대체"]
	C -- "No" --> D{"JPA Entity를 다른 도메인에서 직접 참조하는가?"}
	D -- "Yes" --> R2["도메인 모델과 영속성 모델 분리"]
	D -- "No" --> E["Kotlin 변환 노이즈나 테스트 설정 문제 확인"]

특히 아래와 같은 신호가 보이면 우선적으로 의심해 볼 만합니다.

  • domain 패키지에서 adapter 패키지를 import하는 코드가 있을 때
  • @Entity가 붙은 클래스가 도메인 내부에 존재할 때
  • 한 도메인의 Use Case가 다른 도메인의 Use Case를 직접 주입받고 있을 때

마치며

이번 경험을 통해 가장 크게 배운 것은, 대규모 변경은 단순히 기존 코드를 옮기는 작업이 아니라 기존 구조의 약점을 드러내는 계기가 된다는 점이었습니다.

마이그레이션 과정에서 중요한 것은 에러를 많이 고치는 것이 아니라, 어떤 에러가 구조적 원인이고 어떤 에러가 동시 노이즈인지 분리해서 보는 것이었습니다. 그 구분이 되자 해결 방향도 훨씬 선명해졌습니다.

Summary
  • 마이그레이션은 숨겨진 구조적 결함을 수면 위로 끌어올립니다.
  • 연쇄 에러가 터질 때는 구조 문제와 변환 노이즈를 분리해서 봐야 합니다.
  • 결합도 문제는 임시 처방보다 구조적 해결이 필요합니다.

비슷한 상황을 겪고 있다면, 단순히 import를 고치거나 테스트 코드를 봉합하는 데서 멈추지 말고 현재 구조가 왜 변경을 견디지 못하는지부터 점검해 보시길 권합니다. 저에게는 그 출발점이 헥사고날 아키텍처였습니다.

목록으로 돌아가기

댓글

댓글 0