[Hexagonal Architecture] 3. Adapter 구현하기

2023. 3. 31. 03:42Dev/Clean Architecture

반응형
  1. Web Adapter 구현하기
  2. Persistence Adapter 구현하기
    1. Output Port
    2. ORM Entity
    3. Mapper 클래스
  3. 참고

1. Web Adapter 구현하기

웹 어댑터는 incoming adapter 중 하나로, 외부로부터 http를 통해 request를 받아 애플리케이션 코어를 호출하는 주도하는 어댑터입니다.

우리가 controller 클래스로 정의하는 부분이 이에 해당되며, http와 관련된 기능을 담당합니다.

01-4

웹 어댑터는 application - port - in에 위치한 input port를 호출합니다.

input port는 외부 어댑터와 애플리케이션 코어가 통신하는 명세로 인터페이스로 되어있어 DIP(의존성 역전 원칙)이 작용됩니다.

이전시간에 설명했던 UseCase 인터페이스, 읽기전용 UseCase인 Query 인터페이스가 여기에 해당됩니다.


웹 어댑터는 아래와 같은 단계로 작업을 수행합니다.

  1. http request를 Java 객체로 매핑
  2. 권한 검사
  3. 입력 유효성 검증
  4. 입력 값을 Use Case용 입력모델로 매핑
  5. 유스케이스 호출
  6. 유스케이스 결과를 http로 매핑
  7. http response 반환

http와 관련된 기능은 철저하게 웹 어댑터에서 처리하고,
애플리케이션 코어나 다른 어댑터는 http와 관련된 모든 기능을 관여하지 않습니다.

애플리케이션 코어에서 http에 대한 기능을 관여하지 않는 성질에 의해 다른 어댑터로 쉽게 교체할 수 있습니다.


웹 어댑터도 유스케이스와 같이 작게 쪼개어 가독성을 높이고 코드의 재활용을 높입니다.
(또한 협업에도 효율적입니다.)

공지사항 조회용 웹 어댑터

@Validated
@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class GetNoticeController(
    private val getNoticeQuery: GetNoticeQuery,
) {

    @Operation(summary = "공지사항 목록")
    @GetMapping("")
    fun getNotices(
        @RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
        @RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
    ) = getNoticeQuery.getNoticeSummaries(GetNoticesCommand(page = page, size = size))

    @Operation(summary = "공지사항 상세조회")
    @GetMapping("/{notice_id}")
    suspend fun getNotice(@PathVariable("notice_id") noticeId: String,
    ) = getNoticeQuery.getNoticeDetail(noticeId)
        ?: throw NotFoundException("조회되는 공지사항이 없습니다.")

}

공지사항 등록용 웹 어댑터

@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class RegisterNoticeController(
    private val registerNoticeUseCase: RegisterNoticeUseCase,
) {

    @Operation(summary = "공지사항 등록")
    @PostMapping("")
    suspend fun getNotices(
        @Valid @RequestBody command: RegisterNoticeCommand,
    ) = registerNoticeUseCase.registerNotice(command)

}

2. Persistence Adapter 구현하기

기존의 계층형 아키텍처의 문제점은 데이터베이스(영속성 계층)에 의존적인 구조라는 점이었습니다.

헥사고날 아키텍처는 데이터베이스가 아닌 도메인 주도 설계입니다.
도메인을 기준으로 설계하기 위해서는 DIP(의존성 역전)이 적용되어야 합니다.

01-5

서비스에서 영속성 작업을 수행하기 위해 output port 를 호출합니다.

persistence adapter는 output port 인터페이스를 구현하고
output port는 명세만 작성되어있기 때문에 영속성 계층에 대한 의존성이 없습니다.

web adapter와 마찬가지로 다른 어댑터로 쉽게 교체할 수 있고, 영속성 어댑터의 변경은 애플리케이션 코어에 영향을 주지 않습니다.


영속성 어댑터는 아래와 같은 단계로 작업을 수행합니다.

  1. 입력값을 받고
  2. 입력값을 데이터베이스용 형식으로 매핑한 후 데이터 베이스로 보낸 다음
    • JPA를 사용하는 경우 JPA 엔티티 도메인 형식으로 매핑
    • MongoDB를 사용하는 경우 MongoDB 도큐먼트 형식으로 매핑
  3. 데이터베이스 출력값을 애플리케이션 형식으로 매핑하여
  4. 출력값을 반환합니다.

2.1. Output Port

Output port에 JpaRepository 인터페이스가 있을 경우, 영속성 어댑터가 output port에 의존성을 갖게되는 문제점이 있습니다.

output port에서도 input port와 마찬가지로, 어뎁터에서 작동될 기능에 대한 명세가 기록된 인터페이스만 정의하고
JpaRepository 인터페이스는 adapter - out - persistence에 위치시키는 것이 구조적으로 좋습니다

ISP(인터페이스 분리 원칙)


interface LoadNoticePort {
    fun loadNotices(pageable: Pageable): Flow<Notice>
    suspend fun loadNotice(id: String): Notice?
}
interface SaveNoticePort {
    suspend fun saveNotice(notice: Notice)
}

2.2. ORM Entity

만일, 연결할 퍼시스턴스 어댑터가 MongoDB 기반이라면,
MongoDB document 정의가 필수적으로 필요합니다.

이때, 도메인 레이어에서 영속성 레이어로의 결합을 낮추기 위해, 도메인 객체로 변환하는 매퍼를 별도로 정의하기를 권장합니다.

스크린샷 2023-03-31 오전 3 14 44

2.2.1. ORM Entity

@Document(value = "notice")
internal class NoticeDocument(var title: String, var content: String) {
    @Id
    var id: String? = null

    @CreatedDate
    var createdAt: LocalDateTime = LocalDateTime.now()

    @LastModifiedDate
    var updatedAt: LocalDateTime? = null

}

2.2.2. ORM Repository

JpaRepository, MongoRepository, CoroutineCrudRepository 등 이용하고자하는 Spring Data 프로젝트를 활용하여 DB 레코드를 조회합니다.

@Repository
internal interface NoticeRepository: CoroutineCrudRepository<NoticeDocument, String> {
    fun findAllBy(pageable: Pageable): Flow<NoticeDocument>
}

2.3. Mapper 클래스

영속성 어댑터에서 조회한 ORM Entity를 도메인으로 변환하고,
웹어댑터에서 요청하는 도메인 정보를 영속성 어댑터에서 활용하기 위해 ORM Entity로 변환하는 과정이 필요합니다.

도메인 → ORM Entity, ORM Entity → 도메인으로 변환하는 Mapper 클래스를 만들어줍니다.

internal object NoticeMapper {

    fun mapToNotice(doc: NoticeDocument) =
        Notice(summary = Notice.Summary(id = doc.id, title = doc.title,
            createdAt = DateTimeUtils.toString(doc.createdAt)), content = doc.content)

    fun mapToDocument(notice: Notice) =
        NoticeDocument(title = notice.summary.title, content = notice.content)

}

3. 참고

3.1. application domain

참고로, application core에 위치한 도메인은 아래와 같이 정의했습니다.

간단하게, 간략정보를 담고 있는 Summary와, detail 메서드로 상세정보를 조회할 수 있도록 했습니다.

data class Notice(
    val summary: Summary,
    val content: String,
) {
    data class Summary(
        val id: String? = null,
        val title: String,
        val createdAt: String? = null,
    )

    data class Detail(
        val id: String? = null,
        val title: String,
        val content: String,
        val createdAt: String? = null,
    )

    fun detail(): Detail {
        return Detail(id = summary.id, title = summary.title, content = content, createdAt = summary.createdAt)
    }
}

3.2. Transaction 위치

영속성 어댑터에서 ORM 엔티티에 접근하는 과정이 있으나, 트랜잭션은 하나의 기능을 모두 마쳤을 때 commit 또는 rollback이 되어야하는 기능입니다.

따라서, 영속성 어댑터가 아닌 서비스(= UseCase 구현체)에서 트랙젝션을 설정하는 것이 좋습니다.

@Transactional(readOnly = true)
@UseCase
internal class GetNoticeService(
    private val loadNoticePort: LoadNoticePort,
) : GetNoticeQuery {

    override fun getNoticeSummaries(command: GetNoticesCommand) =
        loadNoticePort.loadNotices(PageRequest.of(command.page, command.size, Sort.by(
            Sort.Order.desc("id")))).map { it.summary }

    override suspend fun getNoticeDetail(id: String): Notice.Detail? =
        loadNoticePort.loadNotice(id)?.detail()
}
@Transactional(readOnly = true)
@UseCase
internal class RegisterNoticeService(
    private val saveNoticePort: SaveNoticePort,
): RegisterNoticeUseCase {

    @Transactional
    override suspend fun registerNotice(command: RegisterNoticeCommand) {
        saveNoticePort.saveNotice(Notice(Notice.Summary(title = command.title), content = command.content))
    }

}

트랜젝션은 서비스 내의 메서드에 직접 설정하거나, AOP를 이용하여 service 메서드 종료 후 동작되도록 정의할 수 있습니다


※ 해당 코드는 GitHub에서 demo-hexagonal 프로젝트에서 확인할 수 있습니다.

728x90
반응형