Skip to main content

Command Palette

Search for a command to run...

ArchUnit 을 이용해서 컨벤션을 지켜 코드 품질을 높여보자

Updated
3 min read

1. 도입 배경

기업 내 개발은 개인 단위보다는 팀 단위로 진행되며, 그에 따라 공통된 개발 규칙(컨벤션)을 정하고 이를 지속적으로 유지하는 것이 매우 중요합니다. 하지만 현실에서는 다음과 같은 문제가 자주 발생합니다.

  • 컨벤션이 문서로만 정리되어 있고, 실제로는 잘 지켜지지 않음

  • 코드 리뷰에서 반복적으로 동일한 피드백이 발생함

  • 시간이 흐를수록 일관성은 무너지고, 시스템 전체 품질이 하락함

이러한 문제를 해결하기 위해 우리는 컨벤션을 “자동으로 검증”하는 방법을 고민했고, 그 해답 중 하나로 ArchUnit을 도입하게 되었습니다.


2. ArchUnit이란?

ArchUnit은 Java (또는 Kotlin) 기반 프로젝트에서 아키텍처 규칙을 테스트 코드로 작성하고 검증할 수 있게 해주는 도구입니다.

  • 단순한 코드 컨벤션이 아닌, 구조적 설계 규칙을 강제

  • 테스트 코드처럼 작성하여 CI 파이프라인에도 통합 가능

  • 레이어 구조, 의존성 방향성, 어노테이션 적용 여부 등 다양한 규칙 정의 가능

대표적인 적용 예시

  • DTO 클래스는 반드시 @Schema 어노테이션을 가져야 한다

  • dto 패키지는 service 패키지를 참조하면 안 된다

  • entity 클래스는 특정 패키지에서만 선언되어야 한다

  • 순환 참조가 발생하지 않아야 한다


3. 규칙 정의 사례

3.1. Request/Response DTO 필드에는 반드시 @Schema 어노테이션을 적용해야 한다

목적

Swagger 문서 자동화를 위한 메타 정보 누락을 방지하고, 문서의 일관성을 유지하기 위함

규칙 설명

  • request, response 패키지에 위치한 모든 클래스 대상

  • 모든 필드는 @Schema 어노테이션을 반드시 포함해야 함

위반 예시

public class UserResponseDto {
    @Schema(description = "사용자 ID")
    private Long id;

    private String username; // ← @Schema 누락 → 테스트 실패
}

ArchUnit 구현 코드 (Kotlin)

@AnalyzeClasses(packages = ["org.example"])
class SchemaAnnotationTest {

    @Test
    fun allDtoFieldsMustHaveSchemaAnnotation() {
        val classes = ClassFileImporter().importPackages("org.example")

        val rule = classes()
            .that().resideInAnyPackage("..request..", "..response..")
            .should(object : ArchCondition<JavaClass>("all fields annotated with @Schema") {
                override fun check(clazz: JavaClass, events: ConditionEvents) {
                    if (!clazz.name.contains("Request", true) && !clazz.name.contains("Response", true)) return

                    clazz.fields.forEach { field ->
                        val result = if (field.isAnnotatedWith(Schema::class.java)) {
                            satisfied(field, "Field ${field.name} in ${clazz.name} has @Schema")
                        } else {
                            violated(field, "Field ${field.name} in ${clazz.name} is missing @Schema")
                        }
                        events.add(result)
                    }
                }
            })

        rule.check(classes)
    }
}

3.2. 감사 로그 테이블 클래스명은 ‘_log’로 끝나야 한다

목적

Audit 데이터의 테이블 네이밍을 통일하여 관리 및 추적 용이성 확보

규칙 설명

  • @AuditTable("테이블명") 어노테이션이 붙은 클래스 대상

  • 테이블명은 반드시 _log로 끝나야 함

위반 예시

@AuditTable("user_history") // ← 위반: '_log'가 아님
public class UserHistoryEntity { }

ArchUnit 구현 코드 (Kotlin)

class AuditTableNamingTest {

    @Test
    fun auditTableNameMustEndWithLog() {
        val classes = ClassFileImporter().importPackages("org.example")

        val rule = classes()
            .that().areAnnotatedWith(AuditTable::class.java)
            .should(object : ArchCondition<JavaClass>("@AuditTable name ends with '_log'") {
                override fun check(clazz: JavaClass, events: ConditionEvents) {
                    val auditTable = clazz.getAnnotationOfType(AuditTable::class.java)
                    val tableName = auditTable.value
                    val result = if (tableName.endsWith("_log")) {
                        satisfied(clazz, "Table name '$tableName' is valid")
                    } else {
                        violated(clazz, "Table name '$tableName' must end with '_log'")
                    }
                    events.add(result)
                }
            })

        rule.check(classes)
    }
}

5. 기대 효과 및 느낀점

  • 명문화된 규칙이 테스트 코드로 전환되어 유지보수가 쉬워짐

  • 코드 리뷰 단계에서 발생하던 불필요한 피드백 감소

  • 새로운 팀원이 입사하더라도 자동 테스트를 통해 규칙을 학습할 수 있음

  • 추상적이고 흐릿했던 “팀 컨벤션”을 검증 가능한 형태로 명확하게 제어 가능

6. 참고

More from this blog

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

Git merge 내부 동작 — 3-way merge, merge base, 그리고 recursive에서 ort로

git merge를 매일 쓰지만, 그 한 줄이 안에서 무슨 일을 하는지 들여다본 적은 드물어요. 이 글은 merge가 두 갈래의 변경을 어떻게 합치는지, merge base가 왜 필요한지, 그리고 Git이 기본 전략을 recursive에서 ort로 갈아치운 이유를 따라가요. Git을 쓰는 백엔드 개발자를 대상으로 해요. 브랜치 두 개를 합치는 일은 겉보기

May 15, 202612 min read

Java NIO ByteBuffer 내부 구조 — Direct vs Heap, Cleaner, 그리고 off-heap 메모리가 GC를 우회하는 방법

Netty가 빠른 이유, Kafka 클라이언트가 직렬화에 신경 쓰는 이유, MappedByteBuffer로 수 GB짜리 파일을 다루는 이유. 그 한가운데에는 ByteBuffer가 있어요. 이번 글에서는 ByteBuffer의 두 얼굴 — heap과 direct — 가 어떻게 다른지, off-heap 메모리는 어떻게 잡고 어떻게 풀리는지, JVM과 운영체제 사

May 15, 202612 min read

Java Flight Recorder 내부 구조 — Thread-Local Buffer부터 Disk Repository까지

JFR을 켜면 1% 미만 오버헤드로 JVM 내부가 그대로 기록돼요. 어떻게 이렇게 가벼울 수 있는지, 그리고 그 데이터가 어떤 경로를 거쳐 디스크에 쌓이는지 한 번 따라가 봐요. 이 글은 JFR을 "그냥 잘 쓰는 도구"에서 "내부 동작을 아는 도구"로 끌어올리고 싶은 분을 위한 글이에요. 운영 중인 서버에서 갑자기 응답 시간이 튀어요. 메트릭 그래프는 분

May 15, 202614 min read

끄적끄적 테크 블로그

162 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ