멀티 모듈 클린 아키텍처 적용기

TunaHG·2024년 8월 3일
post-thumbnail

앞서 멀티 모듈 프로젝트 구성하기 글에서 구성한 멀티 모듈을 다시 확인해본다.

이전과 비교해서 common 모듈이 추가되었지만, 우선 무시하고 application, infrastructure, presentation를 기준으로 고려한다.

모듈 구성

클린 아키텍처에서 확인할 수 있는 대표적인 아키텍처 구성을 확인해본다.

여기서 앞서 의존성을 왜 지정했는지 확인할 수 있다.
이걸 기준으로 의존성을 어떻게 선언했는지 다시 코드로 살펴보면 다음과 같다.

// build.gradle.kts (presentation::api)
implementation(project(":application"))
implementation(project(":infrastructure:db"))
implementation(project(":infrastructure:http"))
implementation("org.springframework.boot:spring-boot-starter-web")

// build.gradle.kts (infrastructure::db)
implementation(project(":application"))
/** jpa **/
api("org.springframework.boot:spring-boot-starter-data-jpa")
/** query-dsl **/
api("com.querydsl:querydsl-jpa::jakarta")
kapt(group = "com.querydsl", name = "querydsl-apt", classifier = "jakarta")
kapt("org.springframework.boot:spring-boot-configuration-processor")

// build.gradle.kts (application)

api 모듈은 빌드할때 모든 연관된 프로젝트의 의존성이 필요하기 때문에 application, infrastructure를 모두 가져온다.
infrastructure 모듈은 클린 아키텍처 구성과 같이 application 모듈 의존성을 가지고, application은 domain 모듈 의존성이 필요하지만 우선 임시로 application 내에 domain을 구현할 예정이므로 의존성이 없다.

추가적으로 api 모듈은 외부 요청을 받고 응답을 줘야하므로 web 의존성을 추가했고, db 모듈은 DB와 소통할 방식으로 jpa, querydsl 의존성을 추가했다.

해당 의존성을 가지고 어떻게 프로젝트를 구현할 수 있을까?
간단한 MemberController, MemberService, MemberEntity, MemberRepository들을 활용해서 살펴본다.

api 모듈

우선 presentation::api 모듈에 MemberController부터 구현해본다.

// module: presentation::api
// package: com.example.api.member.controller
@RestController
class MemberController(
    private val memberService: MemberService
) {
    @GetMapping("/name")
    fun findByName(@RequestParam name: String) = memberService.findByName(name)
}

SpringBoot를 개발해봤다면 익숙한 Controller 형식이다. 이쪽은 별개의 모듈에만 구현했다는 것 이외에는 다른 점이 없다.

application 모듈

이제 MemberController에서 MemberService를 application 모듈에 생성해본다.

// module: application
// package: com.example.application.member.service
@Service
class MemberService(
    private val memberRepository: MemberRepository
) {
    fun findByName(name: String) = memberRepository.findByName(name)
}

MemberService 역시 익숙한 Service 형식이다.
domain과 Repository 구현부터 조금 다른 점이 생기는데, Member Class와 MemberRepository Class를 구현해본다.

// module: application
// package: com.example.application.member.domain
data class Member(
    val id: Long = 0L,
    val name: String = ""
)

// module: application
// package: com.example.application.member.repository
interface GroupRepository {
    fun findByName(name: String): Group
}

여기서 사용하는 Member는 Domain 객체로 해당 객체를 Entity로 사용하는 것이 아니라, 비즈니스 로직에 사용할 도메인 객체이므로 JPA를 통해 가져온 JpaEntity를 DomainEntity로 변환하는 과정이 필요하다.
역시 마찬가지로, DomainEntity를 DB에 저장하기 위해서는 JpaEntity로 변환하는 과정도 필요하다.

또 MemberRepository 역시 JpaRepository를 상속받지 않고 별도로 선언만 해둔다. application 모듈에는 Jpa관련된 의존성을 상속받지 않았으므로 JpaRepository를 선언할수도 없어서 상속받을수도 없다.

db 모듈

이제 대망의 infrastructure::db 모듈에 Jpa 관련된 세팅을 진행해본다.
DB 데이터를 받을 JpaEntity와 연결에 사용할 JpaRepository가 필요하다.

// module: infrastructure::db
// package: com.example.db.member.entity
@Entity(name = "member")
data class MemberJpaEntity(
    @Id
    private var id: Long,
    private var name: String
) {
    constructor(domainEntity: Member): this(
        domainEntity.id,
        domainEntity.name
    )

    fun toDomainEntity(): Member {
        return Member(id, name)
    }
}

DB에 저장할 때는 DomainEntity에서 JpaEntity로 변환해야 되므로 부 생성자(secondary constructor)를 사용해서 JpaEntity를 만들어주고, DB에서 조회해서 비즈니스 로직에서 사용할 때는 JpaEntity에서 DomainEntity로 변환해야 하므로 toDomainEntity()라는 메소드를 선언해서 DomainEntity로 변환해준다.

이제 JpaRepository를 살펴본다.

// module: infrastructure::db
// package: com.example.db.member.repository
interface MemberJpaRepository : JpaRepository<MemberJpaEntity, Long>

@Repository
class MemberRepositoryImpl(
    private val memberJpaRepository: MemberJpaRepository,
    private val queryFactory: JPAQueryFactory
) : MemberRepository, QuerydslRepositorySupport(MemberJpaEntity::class.java) {
    override fun findByName(name: String): Member {
        return queryFactory.selectFrom(memberJpaEntity)
            .where(memberJpaEntity.name.eq(name))
            .fetchFirst()
            ?.toDomainEntity()
            ?: Member()
    }
}

우선 kotlin이니까 하나의 파일에 여러 클래스가 선언될 수 있다는 점을 유의하자.

MemberJpaRepository는 SpringBoot에서 자주 사용했던 JpaRepository를 상속받는 interface다. JpaRepository를 활용하는 메소드 선언은 해당 interface에 선언하면 된다.

MemberRepositoryImpl은 application 모듈에 선언했던 MemberRepository를 구현하는 클래스다.
MemberRepositoryImpl에서는 JpaRepository와 QueryDsl 관련 객체를 주입받는다. 메소드는 비즈니스 로직에 사용하는 메소드를 MemberRepository에 선언하고, MemberRepositoryImpl에서 해당 메소드를 오버라이딩하는 방식으로 구현한다.
오버라이딩한 메소드의 내부 로직은 주입받은 JpaRepository와 QueryDsl로 구현한다.
메소드의 리턴 타입은 JpaEntity가 아니라 DomainEntity여야 한다.

결론

위와 같이 구성하면, application 모듈의 MemberService에서는 MemberRepository에 선언한 메소드만 사용할 수 있다. 선언한 메소드의 내부 로직은 db 모듈의 MemberRepositoryImpl에서 구현된다.
db 모듈은 application 모듈을 의존성으로 가지고 있기 때문에 위와 같이 구현할 수 있지만, application 모듈은 db 모듈을 의존성으로 가지고 있지 않기 때문에 MemberRepositoryImpl 혹은 JpaRepository의 메소드를 직접 가져다가 사용할 수 없다.

각 모듈의 의존성이 명확하게 설정되어 있기 때문에 application에 DB, API와 관련된 객체를 직접 가져다가 사용할수도 생성할수도 없다.
이렇게 강제적으로 선언하는게 불편할 수 있지만, 팀원끼리 컨벤션을 협의했다고 하더라도 신규 직원이 합류하거나 시간이 지나서 깜박하면서 컨벤션이 깨질 수 있기 때문에 강제적으로 선언하는것이 좋다고 생각한다.

하지만 단점도 있다.
기존에는 그냥 JpaRepository를 상속받은 interface에 메소드 선언해서 사용했다면, 여기서는 필요한 메소드를 직접 만들고 내부 구현을 jpa, querydsl로 만들어줘야 한다. 그리고 DomainEntity와 JpaEntity를 서로 변환하면서 사용해야되기 때문에 리소스가 좀 더 필요하다.

현재는 위와같이 구성했지만,
이후에는 Controller의 Request, Response 객체가 Service와 어떻게 주고받는지도 추가적으로 설정할 예정이다.
간단하게 애기하면, Request 객체의 내부 값을 모두 Service 메소드에 파라미터로 넘기면 값이 많아질때 불편하기 때문에 객체를 활용할 수도 있는데 그럴 경우 application 모듈에 value 객체를 만들고 request 객체에 value 객체로 변환하는 메소드를 추가할 수 있다.
Resposne 객체 역시 Service에서 만들어진 객체에서 만들어져야 하기 때문에 부 생성자를 이용하는 방식으로 생성되도록 구현해야 한다.

이후에도 멀티모듈 프로젝트의 개선점이나 변경이 발생하면 추가적으로 글을 작성해야겠다.

profile
나태해지지 말자

0개의 댓글