본문 바로가기
개발 이야기

지속 가능한 소프트웨어 아키텍처 구현하기

by 신재권 2025. 3. 14.

들어가며

최근 '읽기 쉬운 코드'라는 책을 읽고 있다. 아직 다 읽진 않았지만 굉장히 재미있고, 도움이 많이 되는 책이라고 생각한다. 회사 동료들에게도 계속 공유하고 있을 정도이다.

 

이 책의 초반부에 강조하는 개념이 바로 '지속 가능한 소프트웨어'이다. 토스의 재민님 영상을 즐겨보는 영향으로, 소프트웨어 통제와 제어에 대한 관점을 많이 배웠다. 모듈 간 의존성을 관리하여 다양한 변화에 대응할 수 있는 소프트웨어를 만드는 것이 중요하다고 생각한다.


주의: 이 글은 경험이 많지 않은 주니어 개발자의 주관적인 의견을 담고 있습니다. 잘못된 개념이나 틀린 부분이 있을 수 있으니, 피드백은 언제나 환영합니다.

 

지속 가능한 소프트웨어란?

내가 생각하는 지속 가능한 소프트웨어는 다음과 같다.

"장기간에 걸쳐 유지보수, 확장, 개선이 용이하게 설계된 소프트웨어를 의미한다. 단순히 당장 작동하는 것뿐만 아니라, 시간이 지나도 가치를 유지하고 변화하는 요구사항에 쉽게 대응할 수 있는 소프트웨어를 말한다"

 

신규 프로젝트에서의 멀티 모듈 구조 설계

최근 새로운 레포지토리로 CRM 목적의 서버 프로젝트를 담당하게 됐다. 혼자서 신규 프로젝트를 시작할 때 가장 좋은 점은 처음부터 직접 설계할 수 있는 것이다. 즉, 규칙을 스스로 만들고 적용할 수 있는 자유가 존재한다.

해당 프로젝트는 멀티 모듈 구조로 설계했으며, 다음과 같은 모듈들로 구성되어 있다.

 

  • api 모듈: 클라이언트와의 통신을 담당하는 모듈, domain 모듈에 의존
  • domain 모듈: 도메인 로직을 담당하는 모듈. 프로젝트 규모가 아직 크지 않아 application 모듈도 함께 합침. 또한, entity와 domain을 거의 동일시하여 취급
  • infra 모듈: 외부 시스템과의 통신을 담당하는 모듈
  • common 모듈: 공통 모듈, 모든 모듈에 공통적으로 필요한 것들만 정의했습니다.(로깅, custom excpetion interface)
  • test-config 모듈: 실제 운영 환경에서 사용되지 않는 모듈로, test-fixture 라이브러리를 통해 테스트 컨테이너 등 테스트에 필요한 공통 설정들이 포함

 

이 구조가 완벽한 레이어 분리는 아니다. 이상적으로는 domain 모듈에서 application 모듈을 분리해야 하지만, 지금은 시기상조라고 판단하여 함께 유지하였다. 또한 도메인과 엔티티를 동일시했는데, 이는 @Entity 객체를 도메인처럼 사용한다는 의미이다. 아직까지는 도메인과 엔티티를 분리했을 때 얻을 수 있는 이점을 크게 체감하지 못했다.

 

모듈 간 규칙 유지하기

모듈의 규칙을 구성한 후에는 이를 지키는 것이 중요하다. 규칙을 지키는지 확인하는 방법은 여러 가지가 있다.

 

코드 리뷰

가장 대표적인 방법은 코드 리뷰다.

모든 코드 리뷰가 나를 거쳐 진행된다면 규칙을 유지할 수 있겠지만, 개발은 혼자 하는 것이 아니다.

모든 리뷰를 한 사람이 담당하게 되면 병목이 발생할 수밖에 없다.

 

테스트 자동화

더 효과적인 방법은 자동화다. 테스트를 통해 규칙 준수를 검증할 수 있다.

ArchUnit이라는 패키지/아키텍처 테스트 도구를 활용하여 모듈 테스트를 정의하였다.

 

처음에는 의존성 테스트를 추가하고, 점차 확장하여 어노테이션, 패키지, 네이밍 컨벤션까지 테스트하도록 하였다. (네이밍 컨벤션 테스트는 조금 과할 수 있지만, ApplicationService와 DomainService를 명확히 구분하기 위해 추가했다.)

 

ArchUnit을 활용한 아키텍처 테스트

다음은 실제 프로젝트에서 사용한 ArchUnit 테스트 코드이다.

/**
 * 지속가능한 소프트웨어를 위해 아키텍처 규칙과 네이밍 규칙을 정의합니다.
 * 이 규칙은 모듈 간의 의존성을 제한합니다.
 *
 * 모듈 간의 의존성이 높아지면, 모듈을 분리하기 어려워지고, 유지보수가 어려워집니다.
 *
 * 절대로 테스트를 비활성화 하지 마세요.
 * @author 신재권
 * */
class ModuleDependencyTest {
    private val allClasses =
        ClassFileImporter()
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
            .importPackages("com.aswemake.qmarket")

    @DisplayName("도메인 모듈은 API 모듈에 의존하면 안 된다.")
    @Test
    fun test1() {
        val rule: ArchRule =
            classes()
                .that().resideInAPackage("..domain..")
                .should().onlyDependOnClassesThat()
                .resideOutsideOfPackages("..api..")

        rule.check(allClasses)
    }

    @DisplayName("인프라 모듈은 API 모듈에 의존하면 안 된다.")
    @Test
    fun test2() {
        val rule: ArchRule =
            classes()
                .that().resideInAPackage("..infra..")
                .should().onlyDependOnClassesThat()
                .resideOutsideOfPackages("..api..")

        rule.check(allClasses)
    }

    @DisplayName("API 모듈인 인프라 모듈에 의존하면 안 된다.")
    @Test
    fun test3() {
        val rule: ArchRule =
            classes()
                .that().resideInAPackage("..api..")
                .should().onlyDependOnClassesThat()
                .resideOutsideOfPackages("..infra..")

        rule.check(allClasses)
    }

    @DisplayName("API 패키지는 @Service 또는 @Repository 어노테이션이 없어야 한다.")
    @Test
    fun test4() {
        val serviceRule: ArchRule =
            noClasses()
                .that().resideInAPackage("..api..")
                .should().beAnnotatedWith(Service::class.java)

        val repositoryRule: ArchRule =
            noClasses()
                .that().resideInAPackage("..api..")
                .should().beAnnotatedWith(Repository::class.java)

        serviceRule.check(allClasses)
        repositoryRule.check(allClasses)
    }

    @DisplayName("도메인 패키지는 @Controller, @RestController, @Repository 어노테이션이 없어야 한다.")
    @Test
    fun test5() {
        val controllerRule: ArchRule =
            noClasses()
                .that().resideInAPackage("..domain..")
                .should().beAnnotatedWith(Controller::class.java)

        val restControllerRule: ArchRule =
            noClasses()
                .that().resideInAPackage("..domain..")
                .should().beAnnotatedWith(RestController::class.java)

        val repositoryRule: ArchRule =
            noClasses()
                .that().resideInAPackage("..domain..")
                .should().beAnnotatedWith(Repository::class.java)

        controllerRule.check(allClasses)
        restControllerRule.check(allClasses)
        repositoryRule.check(allClasses)
    }

    @DisplayName("인프라 패키지는 @Controller, @RestController, @Service 어노테이션이 없어야 한다.")
    @Test
    fun test6() {
        val controllerRule: ArchRule =
            noClasses()
                .that().resideInAPackage("..infra..")
                .should().beAnnotatedWith(Controller::class.java)

        val restControllerRule: ArchRule =
            noClasses()
                .that().resideInAPackage("..infra..")
                .should().beAnnotatedWith(RestController::class.java)

        val serviceRule: ArchRule =
            noClasses()
                .that().resideInAPackage("..infra..")
                .should().beAnnotatedWith(Service::class.java)

        controllerRule.check(allClasses)
        restControllerRule.check(allClasses)
        serviceRule.check(allClasses)
    }

    @Nested
    @DisplayName("패키지 및 클래스 네이밍 컨벤션 테스트")
    inner class NamingConventionTests {
        @DisplayName("Controller 클래스는 이름이 Controller로 끝나야 한다")
        @Test
        fun test1() {
            val rule =
                classes()
                    .that().areAnnotatedWith(Controller::class.java).or().areAnnotatedWith(RestController::class.java)
                    .should().haveSimpleNameEndingWith("Controller")

            rule.check(allClasses)
        }

        @DisplayName("Service 클래스는 이름이 ApplicationService로 끝나야 한다")
        @Test
        fun test2() {
            val rule =
                classes()
                    .that().areAnnotatedWith(Service::class.java)
                    .should().haveSimpleNameEndingWith("ApplicationService")

            rule.check(allClasses)
        }

        @DisplayName("Repository 클래스는 이름이 Repository로 끝나야 한다")
        @Test
        fun test3() {
            val rule =
                classes()
                    .that().areAnnotatedWith(Repository::class.java)
                    .should().haveSimpleNameEndingWith("Repository")

            rule.check(allClasses)
        }
    }

    @DisplayName("모듈 패키지 구조 테스트")
    @Nested
    inner class ModulePackageStructureTests {
        @DisplayName("MainApplication을 제외한 모든 클래스는 각 모듈의 패키지 구조를 따라야 한다")
        @Test
        fun test() {
            val excluded =
                setOf(
                    "com.aswemake.qmarket.test_fixture_config..",
                    "com.aswemake.qmarket.common..",
                )

            val allClassesRule: ArchRule =
                classes()
                    .that().areNotAssignableTo(QMarketPushServerApplication::class.java)
                    .and().doNotHaveFullyQualifiedName("com.aswemake.qmarket.MainApplication")
                    .and().resideOutsideOfPackages(*excluded.toTypedArray())
                    .should().resideInAnyPackage(
                        "com.aswemake.qmarket.api..",
                        "com.aswemake.qmarket.domain..",
                        "com.aswemake.qmarket.infra..",
                    )

            val rootPackageRule: ArchRule =
                noClasses()
                    .that().areNotAssignableTo(MainApplication::class.java)
                    .and().doNotHaveFullyQualifiedName("com.aswemake.qmarket.MainApplication")
                    .should().resideInAPackage("com.aswemake.qmarket")

            allClassesRule.check(allClasses)
            rootPackageRule.check(allClasses)
        }
    }
}

 

마치며

이러한 방식으로 지속 가능한 소프트웨어를 유지하기 위한 규칙을 정의하고 검증할 수 있다고 생각한다.

프로젝트의 README에도 각 모듈의 역할, 설계 결정의 이유, 의존성 규칙, 네이밍 컨벤션 등을 상세히 기록해 두었다.

이렇게 명확한 구조와 규칙을 설정하고 자동화된 테스트로 검증함으로써, 팀이 커지더라도 일관된 코드 품질을 유지하고 장기적으로 관리하기 쉬운 소프트웨어를 개발할 수 있을 것이라 믿고 있다.