
Kotlin 스크립트로
package선언과 실제 폴더 경로 불일치를 먼저 확인한 기록입니다.
이번 글은 구조 리팩터링 자체보다, 리팩터링 전에 현재 상태를 어떻게 숫자로 확인했는지에 초점을 둡니다.
프로젝트 구조를 정리하다 보면 생각보다 자주 만나는 문제가 있습니다.
파일 위치와 package 선언이 서로 맞지 않는 경우입니다.
예를 들면 파일은 아래 위치에 있는데,
src/main/kotlin/com/sleekydz86/idolglow/member/adapter/web/MemberController.kt
파일 안의 패키지는 이렇게 되어 있는 식입니다.
package com.sleekydz86.idolglow.member.ui
Kotlin/JVM에서는 패키지 선언과 실제 폴더 경로가 반드시 1:1로 맞아야만 컴파일되는 것은 아닙니다.
그래서 당장 서버가 뜨거나 테스트가 도는 데는 문제가 없어 보일 수 있습니다.
하지만 코드 구조를 정리하려고 하면 이야기가 달라집니다.
IDE에서 파일을 찾을 때 헷갈리고, 신규 파일을 어디에 둬야 할지 애매해집니다.
리팩터링할 때도 패키지 기준으로 봐야 하는지, 폴더 기준으로 봐야 하는지 판단이 흐려집니다.
특히 모듈별 레이어 규칙이 섞여 있는 프로젝트에서는 이런 불일치가 계속 누적됩니다.
port/in, port/out 구조를 사용함domain/repository만 사용함adapter/web 폴더에 있지만 패키지는 ui를 사용함global/config와 global/infrastructure/config처럼 비슷한 책임의 클래스가 중복됨이 상태에서 바로 리팩터링을 시작하면 수정 범위가 계속 커질 수 있습니다.
그래서 먼저 전체 현황을 숫자로 확인하기로 했습니다.
문제는 기능 버그처럼 바로 터지는 형태는 아니었습니다.
서버가 무조건 죽거나 API가 실패하는 문제도 아니었습니다.
대신 구조 정리 과정에서 계속 이런 식의 불일치가 보였습니다.
파일 위치: src/main/kotlin/com/sleekydz86/idolglow/event/adapter/web
패키지명: com.sleekydz86.idolglow.event.ui
또는 이런 형태도 있었습니다.
파일 위치: src/main/kotlin/com/sleekydz86/idolglow/mypage/domain/repository
패키지명: com.sleekydz86.idolglow.mypage.repository
파일을 열어보기 전까지는 이 클래스가 어느 계층에 속하는지 확신하기 어려웠습니다.
이 문제는 단순히 보기 불편한 수준에서 끝나지 않습니다.
신규 파일을 추가할 때 기존 폴더 구조를 따라야 할지, 패키지 선언을 따라야 할지 애매해집니다.
코드 리뷰에서도 기능보다 구조 이야기가 반복됩니다.
리팩터링을 진행할 때도 어떤 기준을 표준으로 삼을지 정하기 어렵습니다.
그래서 먼저 src/main/kotlin 아래의 Kotlin 파일을 대상으로 패키지명과 실제 폴더 경로가 다른 파일을 찾아보기로 했습니다.
처음에는 단순히 몇 개 파일만 잘못 옮겨진 줄 알았습니다.
하지만 프로젝트를 훑어보니 특정 파일 하나의 문제가 아니라, 여러 시점에 여러 기준으로 패키지를 붙인 흔적에 가까웠습니다.
의심한 원인은 크게 세 가지였습니다.
IDE에서 파일을 옮기면 패키지 선언도 같이 바뀌는 경우가 있습니다.
하지만 항상 그런 것은 아닙니다.
특히 대량 이동, 수동 이동, 브랜치 병합 과정에서는 파일 위치만 바뀌고 package 선언은 그대로 남을 수 있습니다.
예전에는 웹 계층을 ui라고 부르다가, 나중에는 adapter/web으로 바꿨을 수 있습니다.
이 경우 새 파일은 새 기준을 따르고, 기존 파일은 옛 기준에 남아 있게 됩니다.
결과적으로 같은 책임의 클래스인데도 어떤 파일은 ui, 어떤 파일은 adapter/web 아래에 위치하게 됩니다.
어떤 모듈은 헥사고날 아키텍처에 가깝게 port/in, port/out을 두고,
어떤 모듈은 전통적인 service, repository 구조를 그대로 사용하고 있었습니다.
이 자체가 무조건 나쁜 것은 아닙니다.
문제는 기준이 명시되어 있지 않으면, 다음 사람이 어떤 구조를 따라야 할지 알 수 없다는 점입니다.
처음부터 거창한 정적 분석 도구를 만들 생각은 없었습니다.
목표는 아키텍처 전체를 검증하는 것이 아니라, 패키지 선언과 폴더 경로가 얼마나 어긋나 있는지 빠르게 확인하는 것이었습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| IDE에서 직접 확인 | 바로 확인 가능 | 전체 규모 파악이 어렵고 누락 가능성이 큼 | 제외 |
find, grep 조합 | 빠르게 검색 가능 | 패키지와 경로 비교 후처리가 필요함 | 보류 |
| Detekt 커스텀 룰 작성 | CI에 붙이기 좋음 | 현재 단계에서는 구현 비용이 큼 | 나중에 검토 |
| Gradle Task 작성 | 프로젝트 표준 검사로 넣기 좋음 | 초기 실험용으로는 조금 무거움 | 나중에 검토 |
| Kotlin 스크립트 작성 | 프로젝트 언어와 같고 빠르게 작성 가능 | 정교한 예외 처리는 직접 넣어야 함 | 선택 |
이 문제에서는 Kotlin 스크립트가 가장 현실적인 선택이었습니다.
프로젝트가 Kotlin 기반이기 때문에 별도 언어를 들고 오지 않아도 되고,
파일 탐색, 정규식, 경로 비교 정도는 짧은 코드로 충분히 처리할 수 있었습니다.
최종적으로는 analyze.kt를 만들어 src/main/kotlin 아래의 .kt 파일을 전부 순회하도록 했습니다.
비교 기준은 단순합니다.
package를 경로로 변환한 값예를 들어 패키지가 아래와 같다면,
package com.sleekydz86.idolglow.member.ui
기대 경로는 아래처럼 계산됩니다.
com/sleekydz86/idolglow/member/ui
그리고 실제 파일의 부모 경로가 이 값과 다르면 불일치로 판단합니다.
처음에는 파일 단위 목록을 전부 출력할까 했습니다.
하지만 불일치 파일이 많으면 파일명만 나열해도 판단이 어렵습니다.
그래서 이번에는 상위 패턴을 먼저 볼 수 있도록 그룹화했습니다.
실행 결과 헤더에는 전체 불일치 수와 상위 패턴을 표시했습니다.
Total: 212
Top 5 mismatch patterns:
1. <상위 패턴 1>
2. <상위 패턴 2>
3. <상위 패턴 3>
4. <상위 패턴 4>
5. <상위 패턴 5>
실제 프로젝트에서는 총 212개의 패키지/폴더 불일치가 확인되었습니다.
이 숫자는 성능 지표가 아니라 현재 구조 상태를 파악하기 위한 기준값입니다.
중요한 것은 212라는 숫자 자체보다, 어떤 패턴이 반복되는지였습니다.
예를 들어 adapter/web과 ui가 반복적으로 어긋나 있다면 단순 파일 이동 실수가 아니라 명명 기준이 섞인 문제일 가능성이 큽니다.
반대로 특정 파일 하나만 어긋나 있다면 개별 이동 실수로 볼 수 있습니다.
전체 스크립트의 핵심은 아래 코드입니다.
import java.nio.file.*
import kotlin.io.path.*
val sourceRoot = Paths.get("src/main/kotlin")
val packagePattern = Regex("""^package\s+([\w.`]+)""", RegexOption.MULTILINE)
val mismatches = sourceRoot.toFile().walkTopDown()
.filter { it.isFile && it.name.endsWith(".kt") }
.mapNotNull { file ->
val path = file.toPath()
val content = path.readText()
val packageName = packagePattern.find(content)?.groupValues?.get(1)?.replace("`", "") ?: return@mapNotNull null
val relativePath = sourceRoot.relativize(path).parent?.toString()?.replace('\\', '/') ?: return@mapNotNull null
val expectedPath = packageName.replace('.', '/')
if (relativePath != expectedPath) {
Triple(relativePath, packageName, path.name)
} else null
}.toList()
println("Total: ${mismatches.size}")
mismatches.groupBy { (rel, pkg, _) ->
val relSuffix = rel.removePrefix("com/sleekydz86/idolglow/")
val pkgSuffix = pkg.removePrefix("com.sleekydz86.idolglow.")
"folder=$relSuffix | package=$pkgSuffix"
}.entries.sortedByDescending { it.value.size }.take(25).forEach { (k, v) ->
println("${v.size}\t$k")
}
val packagePattern = Regex("""^package\s+([\w.`]+)""", RegexOption.MULTILINE)
package 선언은 보통 파일 상단에 있지만, 단순히 첫 줄만 보면 놓칠 수 있습니다.
그래서 MULTILINE 옵션을 사용해 파일 전체에서 패키지 선언을 찾도록 했습니다.
백틱으로 감싼 패키지명도 있을 수 있어서 아래처럼 제거했습니다.
.replace("`", "")
val relativePath = sourceRoot.relativize(path)
.parent
?.toString()
?.replace('\\', '/')
?: return@mapNotNull null
Windows 환경에서는 경로 구분자가 \로 나올 수 있습니다.
반면 Linux나 macOS에서는 /를 사용합니다.
비교 전에 /로 통일하지 않으면 로컬과 CI 환경에서 결과가 다르게 나올 수 있습니다.
이런 부분은 작지만 실제로 자주 발목을 잡습니다.
val expectedPath = packageName.replace('.', '/')
패키지명은 . 기준이고, 폴더 경로는 / 기준입니다.
그래서 패키지명을 경로 형태로 변환한 뒤 실제 상대 경로와 비교했습니다.
mismatches.groupBy { (rel, pkg, _) ->
val relSuffix = rel.removePrefix("com/sleekydz86/idolglow/")
val pkgSuffix = pkg.removePrefix("com.sleekydz86.idolglow.")
"folder=$relSuffix | package=$pkgSuffix"
}
프로젝트 루트 패키지인 com.sleekydz86.idolglow는 출력에서 크게 의미가 없었습니다.
그래서 접두어를 제거하고, 실제로 비교해야 하는 모듈 내부 경로만 보도록 했습니다.
그리고 많이 발생한 패턴부터 확인하기 위해 정렬했습니다.
.entries
.sortedByDescending { it.value.size }
.take(25)
처음 구조를 정리할 때는 전체 파일 목록보다 상위 패턴을 먼저 보는 편이 낫습니다.
대부분의 구조 문제는 자주 반복되는 몇 개 패턴에서 먼저 드러납니다.
이 작업은 성능 개선 작업이 아닙니다.
그래서 응답 시간이 몇 초 줄었다는 식의 수치는 사용하지 않았습니다.
검증 기준은 다음처럼 잡았습니다.
src/main/kotlin 아래의 Kotlin 파일을 전부 탐색하는가package 선언이 있는 파일만 비교하는가현재 analyze.kt에는 헤더로 Total: 212와 상위 5개 패턴이 반영되어 있습니다.
실행 시에도 같은 형식으로 출력됩니다.
다만 이 수치는 현재 프로젝트 상태를 기준으로 한 값입니다.
파일 이동이나 패키지 수정이 진행되면 자연스럽게 달라집니다.
따라서 이 숫자를 품질 점수처럼 해석하면 안 됩니다.
리팩터링 전 기준선으로 보는 것이 적절합니다.
패키지 정렬 상태를 테스트로 고정하기 위해 PackagePathAlignmentBaselineTest도 별도로 실행해봤습니다.
하지만 해당 테스트는 kaptGenerateStubsKotlin 단계에서 internal compiler error로 실패했습니다.
처음에는 테스트 코드 자체의 문제를 의심했습니다.
하지만 실패 지점을 보면 테스트 로직보다는 컴파일 단계에서 먼저 깨지고 있었습니다.
현재 프로젝트는 Phase 2 마이그레이션 중간 상태이고,
global/config와 global/infrastructure/config에 config 클래스가 각각 36개씩 중복되어 있습니다.
이 중복 상태 때문에 kapt stub 생성 단계에서 컴파일이 깨진 것으로 보입니다.
즉, 지금 당장 PackagePathAlignmentBaselineTest가 실패했다고 해서 패키지 경로 검사 아이디어 자체가 잘못됐다고 보기는 어렵습니다.
테스트가 실행되기 전에 컴파일 단계에서 먼저 막힌 상황에 가깝습니다.
Phase 2 마이그레이션을 이어가면서 config 클래스 중복을 정리하면,
해당 테스트도 다시 통과할 가능성이 큽니다.
이 스크립트는 패키지명과 폴더 경로의 문자열 일치 여부만 확인합니다.
즉, 아키텍처가 올바른지는 판단하지 않습니다.
예를 들어 아래 구조가 있다고 해보겠습니다.
folder=member/service
package=member.service
이 둘이 일치하면 스크립트는 정상으로 봅니다.
하지만 이 구조가 도메인 규칙상 좋은 구조인지는 별개의 문제입니다.
서비스가 다른 모듈의 DTO를 직접 참조하는지,
adapter가 domain을 거꾸로 침범하는지,
application 계층이 infrastructure 구현체를 직접 바라보는지 같은 문제는 잡지 못합니다.
또 다른 한계는 루트 패키지가 코드에 고정되어 있다는 점입니다.
removePrefix("com/sleekydz86/idolglow/")
removePrefix("com.sleekydz86.idolglow.")
현재 프로젝트에서는 충분했지만, 다른 프로젝트에 재사용하려면 루트 패키지를 설정값으로 빼는 편이 낫습니다.
그리고 현재 스크립트는 결과를 출력만 합니다.
CI에서 실패 처리까지 하지는 않습니다.
운영 가능한 품질 게이트로 만들려면 다음 작업이 추가로 필요합니다.
지금 단계에서는 일부러 거기까지 가지 않았습니다.
먼저 현재 프로젝트가 얼마나 어긋나 있는지 확인하는 것이 목적이었기 때문입니다.
이번 작업의 핵심은 패키지 구조를 한 번에 깔끔하게 고치는 것이 아니었습니다.
그 전에 현재 상태를 눈으로 확인할 수 있게 만드는 것이었습니다.
구조 개선을 할 때 가장 위험한 방식은 감으로 시작하는 것입니다.
“대충 이쪽이 이상한 것 같다”는 느낌만으로 리팩터링을 시작하면 수정 범위가 계속 커집니다.
이번에는 analyze.kt로 패키지명과 폴더 경로의 불일치를 먼저 뽑았습니다.
그 결과 현재 기준으로 212개의 불일치가 확인됐고, 상위 패턴을 기준으로 어디부터 정리해야 할지 판단할 수 있었습니다.
PackagePathAlignmentBaselineTest는 아직 통과하지 못했습니다.
다만 원인은 테스트 로직보다 Phase 2 중간 상태에서 발생한 config 클래스 중복과 kapt 컴파일 실패에 가까워 보입니다.
결국 이런 작업은 거창한 기술보다 순서가 더 중요합니다.
먼저 현황을 뽑고,
반복되는 패턴을 찾고,
기준을 정한 뒤,
마지막으로 리팩터링합니다.
작은 Kotlin 스크립트지만, 구조 정리의 출발점으로는 충분했습니다.