2023. 3. 31. 03:42ㆍDev/Clean Architecture
1. Web Adapter 구현하기
웹 어댑터는 incoming adapter 중 하나로, 외부로부터 http를 통해 request를 받아 애플리케이션 코어를 호출하는 주도하는 어댑터입니다.
우리가 controller 클래스로 정의하는 부분이 이에 해당되며, http와 관련된 기능을 담당합니다.
웹 어댑터는 application - port - in
에 위치한 input port를 호출합니다.
input port는 외부 어댑터와 애플리케이션 코어가 통신하는 명세로 인터페이스로 되어있어 DIP(의존성 역전 원칙)이 작용됩니다.
이전시간에 설명했던 UseCase 인터페이스, 읽기전용 UseCase인 Query 인터페이스가 여기에 해당됩니다.
웹 어댑터는 아래와 같은 단계로 작업을 수행합니다.
- http request를 Java 객체로 매핑
- 권한 검사
- 입력 유효성 검증
- 입력 값을 Use Case용 입력모델로 매핑
- 유스케이스 호출
- 유스케이스 결과를 http로 매핑
- 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(의존성 역전)이 적용되어야 합니다.
서비스에서 영속성 작업을 수행하기 위해 output port 를 호출합니다.
persistence adapter는 output port 인터페이스를 구현하고
output port는 명세만 작성되어있기 때문에 영속성 계층에 대한 의존성이 없습니다.
web adapter와 마찬가지로 다른 어댑터로 쉽게 교체할 수 있고, 영속성 어댑터의 변경은 애플리케이션 코어에 영향을 주지 않습니다.
영속성 어댑터는 아래와 같은 단계로 작업을 수행합니다.
- 입력값을 받고
- 입력값을 데이터베이스용 형식으로 매핑한 후 데이터 베이스로 보낸 다음
- JPA를 사용하는 경우 JPA 엔티티 도메인 형식으로 매핑
- MongoDB를 사용하는 경우 MongoDB 도큐먼트 형식으로 매핑
- 데이터베이스 출력값을 애플리케이션 형식으로 매핑하여
- 출력값을 반환합니다.
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 정의가 필수적으로 필요합니다.
이때, 도메인 레이어에서 영속성 레이어로의 결합을 낮추기 위해, 도메인 객체로 변환하는 매퍼를 별도로 정의하기를 권장합니다.
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 프로젝트에서 확인할 수 있습니다.
'Dev > Clean Architecture' 카테고리의 다른 글
[Hexagonal Architecture] 2. Use Case 구현하기 (0) | 2022.11.18 |
---|---|
[Hexagonal Architecture] 1. 헥사고날 아키텍처란? (0) | 2022.11.18 |