User 도메인 리팩토링 리뷰를 끝내고, Notice 도메인의 리팩토링을 진행해야한다.
1. 컨트롤러 분리
각 UseCase에 따라 컨트롤러를 분리하여 Apdater.in.web 패키지에 분류하였다.
2. Dto 이동, 네이밍 변경
Notice 도메인에서는 dto 패키지의 NoticeDetailInfo 클래스를 DTO로 사용한다.
해당 클래스를 GetNoticeDetailResponse 으로 변경하였다.
또한, port.in 패키지 안으로 이동시켰다.
3. GetNoticeDetailResponse의 생성자, 필드 속성 개선
해당 클래스는 NoArgsContructor 롬복 어노테이션으로 기본 생성자를 가지도록 설계되어있다. 그리고, 클래스 내부에 어노테이션 없이 코드로, 모든 필드를 받는 생성자가 선언되어 있다.
RequestBody에 쓰이는 DTO라면, 기본 생성자가 필요하다. JSON 데이터에서 자바 객체로 변경하는 역직렬화 과정에서 DTO에 대해서 Reflection을 사용해 프록시 객체를 만들고, Reflection은 메소드의 파라미터를 알 수 없기게 기본 생성자를 사용해서 프록시 객체를 생성하기 때문이다.
하지만, 이 DTO는 ResponseBody에 쓰이는 DTO이고, 이 클래스의 객체를 만드는 서비스 등의 코드에서 모든 파라미터 값을 사용하는 생성자를 사용하므로 기본 생성자가 필요 없다. 따라서 NoArgsContructor 어노테이션은 삭제하였다.
모든 필드를 받는 생성자는 롬복의 AllArgsContructor 롬복 어노테이션을 사용하면 되므로, 어노테이션을 추가하고 코드로 작성된 생성자는 삭제하였다.
또한, Notice 테이블은 contents 필드를 제외하고 모두 nullable = false이다.
따라서 해당 필드들은 final 처리하였다.
4. Inbound Port 생성, 서비스 분리
기존의 NoticeService에 대한 인터페이스가 될 Inbound Port를 선언하였다. 이때, 각 UseCase에 맞게, GetNoticeListQuery와 GetNoticeDetailQuery로 인터페이스를 선언하였다. 두 UseCase가 모두 조회하는 경우이므로, Query라는 네이밍을 사용해 인터페이스를 선언하였다.
새롭게 추가한 각 Port 인터페이스를 구현하는 서비스를 만들었다. 각각 GetNoticeListService, GetNoticeDetailService 클래스이다.
5. Outbound Port 생성, Persistence Adapter 생성, NoticeRepository 이동
기존에 서비스에서 바로 Repository로 요청을 보냈다면, 데이터베이스 중심적으로 코드를 설계한 것이다.
따라서, 데이터베이스라는 외부 리소스에 대한 요청을 보내는 Repository를 Adapter.out로 이동시킨다.
그리고, 이러한 Repository 메소드를 사용하여 요청을 핸들링하는 Outbound Port 인터페이스와 이 Port를 구현하는 Persistence Adapter 클래스를 추가한다. LoadNoticePort 인터페이스와 NoticePersistenceAdapter이다.
그런데 여기서 한가지 의문점이 들었다. 왜 Persistence Adapter는 한 개일까? Notice 도메인에서는 현재 단순한 조회만 수행하므로, 한개의 Persistence Adapter만 가져가도 되지만 다양한 CRUD가 구현된다면 Persistence Adapter도 Port로 분리되어야하는 거 아닐까? Repository에 의존하는 코드를 최소화하기 위한 것일까?
6. Notice Repository의 불필요한 메소드 제거
Notice Repository는 findNoticeDto 라는 메소드를 가지고 있는데, 이 메소드는 조회와 동시에 GetNoticeDetailResponse로 매핑하여 반환한다.
이러한 구조는, 책임이 잘 분리되지 않았다고 판단했다. 물론, 바로 매핑하면 추가적인 코드가 필요없지만, 만약 Notice 엔티티에서 가져오는 필드를 변경한다거나, 추가적인 매핑이 필요하면 Repository 코드가 수정되어야한다.
올바른 접근은 GetNoticeDetailResponse의 변경과 GetNoticeDetailResponse를 매핑하는 mapper혹은 서비스 코드의 변경이 맞다고 생각했다.
따라서, NoticeRepository에서 상속받아 가지고 있는 findAll을 사용하고, findNoticeDto 메소드는 제거했다.
7. 각 서비스에서 Response 형태에 맞게 매핑하도록 수정
Notice Repository에서 별도의 매핑 없이 Notice 데이터를 반환하므로, 서비스에서 이를 받아 매핑하도록 했다.
NoticePersistenceAdapter에서 매핑할까 생각했지만, Notice의 영속성에 대한 어댑터이므로 적절하지 않다고 판단했다. GetNoticeDetailResponse는 비즈니스 로직에 따라 반환하는 객체이므로, 비즈니스 로직을 처리하는 서비스에서 Port로부터 Notice 데이터를 받아 매핑하도록 구현했다.
8. 컨트롤러에 UseCase(Query)를 적용해 기존에 NoticeService 제거
각 컨트롤러에 위와 같이 헥사고날 아키텍처로 개선한 Service 로직과 플로우를 반영하기 위해, 적합한 각각의 Query를 연결했다. 이제 Service는 필요 없으므로 제거한다.
9. 단위 테스트 구성 - 컨트롤러 테스트 추가
컨트롤러 테스트를 우선적으로 추가했다.
GetNoticeDetailController에 대한 테스트 GetNoticeDetailControllerTest 로 확인해보자.
package com.yapp.artie.domain.notice.adapter.in.web;
...
@WebMvcTest(controllers = GetNoticeDetailController.class)
class GetNoticeDetailControllerTest extends BaseControllerIntegrationTest {
@MockBean
private GetNoticeDetailQuery getNoticeDetailQuery;
@Test
void getNoticeDetail() throws Exception {
givenUserByReference(defaultUser().build());
Long noticeId = 1L;
mvc.perform(get("/notice/{noticeId}", noticeId).accept(MediaType.APPLICATION_JSON)
.header("Authorization", "sample"))
.andExpect(status().isOk());
then(getNoticeDetailQuery).should().loadNoticeDetail(noticeId);
}
}
BaseControllerIntegrationTest를 extends하도록 선언했다. 이로 통해, API 요청시 필요한 인증과 관련된 모킹 처리를 별도로 해줄 필요가 없다.
컨트롤러가 의존하는 Query(UseCase 인터페이스)를 선언해, MockBean 처리했다.
givenUserByReference(defaultUser().build());
위 코드로 defaultUser 데이터로 인증 처리를 모킹하도록 처리하였다.
mvc.perform(get("/notice/{noticeId}", noticeId).accept(MediaType.APPLICATION_JSON)
.header("Authorization", "sample"))
.andExpect(status().isOk());
위 코드로, noticeId 값으로 path parameter를 가지도록 설정하고,
accept메소드를 통해 Content-Type이 application/json으로 요청하도록 설정하고,
이 API는 인증을 거치므로, 임의의 값으로 Authorization 헤더에 값을 넣도록 설정했다.
그리고, 이 응답값이 200 OK를 반환한다는 것을 확인하도록 했다.
then(getNoticeDetailQuery).should().loadNoticeDetail(noticeId);
getNoticeDetailQuery는 noticeId 값을 파라미터로 loadNoticeDetail 메소드를 호출함을 확인하도록 했다.
이로써, 컨트롤러에 대한 단위 테스트를 적용했다.
10. 단위 테스트 구성 - SQL 파일 추가, Persistence Adapter 테스트 추가
그 다음은 Adapter.out.persistence 패키지의 NoticePersistenceAdapter의 테스트를 추가했다.
package com.yapp.artie.domain.notice.adapter.out.persistence;
...
@DataJpaTest
@Import({NoticePersistenceAdapter.class})
@AutoConfigureTestDatabase(replace = Replace.NONE)
class NoticePersistenceAdapterTest {
@Autowired
NoticePersistenceAdapter noticePersistenceAdapter;
@Autowired
NoticeRepository noticeRepository;
@Test
@Sql("NoticePersistenceAdapterTest.sql")
void loadNoticeList_모든_공지_조회() {
List<Notice> notices = noticePersistenceAdapter.loadNoticeList();
assertThat(notices.size()).isEqualTo(6);
assertThat(notices.get(0).getId()).isEqualTo(1L);
}
@Test
void loadNoticeList_공지가_없을_경우_빈_리스트를_반환한다() {
List<Notice> notices = noticePersistenceAdapter.loadNoticeList();
assertThat(notices.size()).isEqualTo(0);
}
@Test
@Sql("NoticePersistenceAdapterTest.sql")
void loadNoticeDetail_공지_ID를_이용해서_공지_세부_내용을_조회한다() {
Notice notice = noticePersistenceAdapter.loadNoticeDetail(1L);
assertThat(notice.getId()).isEqualTo(1L);
}
@Test
void loadNoticeDetail_공지_ID를_찾지_못한_경우_예외를_발생한다() {
assertThatThrownBy(() -> {
noticePersistenceAdapter.loadNoticeDetail(1L);
}).isInstanceOf(
NoticeNotFoundException.class);
}
}
이 NoticePersistenceAdapter는 별도로 Mapper를 가지지 않기때문에 Import시 해당 어댑터만 선언하면 된다.
INSERT INTO `notice` (id, contents, created_at, title)
VALUES (1, '아르티가 업데이트 되었어요~ 1', '2023-04-01 10:58:50', '공지사항 제목1');
INSERT INTO `notice` (id, contents, created_at, title)
VALUES (2, '아르티가 업데이트 되었어요~ 2', '2023-04-02 12:58:50', '공지사항 제목2');
INSERT INTO `notice` (id, contents, created_at, title)
VALUES (3, '아르티가 업데이트 되었어요~ 3', '2023-04-03 15:58:50', '공지사항 제목3');
INSERT INTO `notice` (id, contents, created_at, title)
VALUES (4, '아르티가 업데이트 되었어요~ 4', '2023-04-04 19:58:50', '공지사항 제목4');
INSERT INTO `notice` (id, contents, created_at, title)
VALUES (5, '아르티가 업데이트 되었어요~ 5', '2023-04-05 08:58:50', '공지사항 제목5');
INSERT INTO `notice` (id, contents, created_at, title)
VALUES (6, '아르티가 업데이트 되었어요~ 6', '2023-04-20 20:58:50', '공지사항 제목6');
이때, 실제 데이터를 넣어서 테스트하기 위해 SQL 파일을 resources 폴더 안에 넣어두었다. 그리고 @Sql 어노테이션으로 적용하였다. 참고로, @DataJpaTest가 테스트 실행시마다 롤백하므로, 별도로 Sql이 반영된 것을 롤백할 필요가 없다.
11. 단위 테스트 구성 - Service 테스트 추가, 기존 Service 테스트 제거
그 다음은 Service Test를 추가하자.
package com.yapp.artie.domain.notice.application.service;
...
class GetNoticeDetailServiceTest {
private final LoadNoticePort loadNoticePort = Mockito.mock(LoadNoticePort.class);
private final GetNoticeDetailService getNoticeDetailService = new GetNoticeDetailService(
loadNoticePort);
@Test
void loadNoticeDetail_공지_ID를_이용해서_공지_세부_내용을_조회한다() {
Notice notice = Notice.create("공지 제목 1", "공지 내용 1");
givenNotice(notice);
GetNoticeDetailResponse noticeDetailResponse = getNoticeDetailService.loadNoticeDetail(
notice.getId());
assertThat(noticeDetailResponse.getId()).isEqualTo(notice.getId());
}
@Test
void loadNoticeDetail_공지_ID를_찾지_못한_경우_예외를_발생한다() {
givenNoticeByIdWillFail();
assertThatThrownBy(() -> getNoticeDetailService.loadNoticeDetail(1L)).isInstanceOf(
NoticeNotFoundException.class);
}
private void givenNotice(Notice notice) {
given(loadNoticePort.loadNoticeDetail(any()))
.willReturn(notice);
}
private void givenNoticeByIdWillFail() {
given(loadNoticePort.loadNoticeDetail(any()))
.willThrow(NoticeNotFoundException.class);
}
}
해당하는 서비스와 Port불러오고, 서비스에 불러온 Port를 넣어줘서 의존성을 주입한다.
그리고 given~ private 메소드를 추가해, Port의 행위를 모킹한다.
givenNotice와 givenNoticeByIdWillFail이 그 메소드이다. givenNotice는 파라미터로 Notice를 받아, Port에서 loadNoticeDetail메소드를 수행하는 것을 모킹한다. givenNoticeByIdWillFail은 어떤 값을 Port의 loadNoticeDetail로 넘겨주더라도, 예외를 발생하게 모킹한다.
그리고, 도메인에 대한 테스트를 추가해야하는데, Notice 도메인은 생성자를 제외하고는 메소드가 없으므로 테스트를 하지 않는다.
12. 접근 제어자 개선
컨트롤러, 어댑터 등에 사용되는 접근 제어자를 default로 변경하여 개선함
'팀 프로젝트 > ARTIE ( 아르티 ) : 2022.12 ~' 카테고리의 다른 글
외래키 제거 고민하기 (1) | 2023.05.05 |
---|