Skip to content

Commit

Permalink
[�Feat] Hexagonal architecture #129 (#131)
Browse files Browse the repository at this point in the history
* setting(archUnit): 아키텍처 테스팅 의존성 추가

* test(HexagonalArchitecture): User의 HexagonalArchitecture 검증

* refactor: User 도메인 헥사고날 아키텍처로 전환

* feat: 헥사고날 애너테이션 적용

* refactor: API가 포트에 의존하도록 변경

* refactor: UseCase의 구현체들이 Port에 의존하도록 리팩토링

* test: Notice의 HexagonalArchitecture 검증

* refactor: Notice 도메인 헥사고날 아키텍처로 전환

* test: Admin의 HexagonalArchitecture 검증

* refactor(NoticeUpdateSupport): NoticeUpdateSupport 일부 로직 수정

* refactor: Admin 도메인 헥사고날 아키텍처로 전환

* refactor: 필요없는 설정파일 삭제

* test: Staff의 HexagonalArchitecture 검증

* refactor: Staff 도메인 헥사고날 아키텍처로 전환

* feat: User 도메인의 Firebase 로직을 Event로 처리하도록 구현

* feat: User-Token을 preHandler로 검증하도록 변경

* feat(MessageUserEventListener): Token Validation 리스너 제거

* feat(FirebaseService): 비즈니스 로직이 port에 의존하도록 리팩토링

* feat(FirebaseWithAdminUseCase): 어드민의 공지 전송 작업을 이벤트로 구현

* refactor: 기존의 단일 FirebaseService 를 구독과 공지 전송 2개의 서비스로 분리한다

* refactor(DependencyRuleTests): 기존 아키텍처 검증에서 event 페키지 추가 검증하도록 구현

* fix: QueryDsl이 사용하는 dto의 경로 재설정

* chore: 테스트 서식 지정자 제거와 주석 제거

* docs(README): 문서 업데이트

* refactor(ServerProperties): 공통으로 사용중인 서버의 환경변수를 Common 패키지 하부로 이동

* refactor(FirebaseExceptionHandler): exception handler의 페키지를 adapter.out으로 이동
  • Loading branch information
zbqmgldjfh authored Feb 8, 2024
1 parent c2b35e9 commit f5f2faa
Show file tree
Hide file tree
Showing 194 changed files with 3,150 additions and 1,654 deletions.
58 changes: 44 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,34 @@ Bounded Context(BC)는 동일한 문맥으로 효율적으로 업무 용어(도

---

## 2. 레거시 코드의 양방향 연관관계를 단방향으로!
## 2. 헥사고날 아키택처로의 전환

**문제 상황**

- 기존 아키택처의 한계점 ([데이터 중심의 설계가 불러온 한계](https://blogshine.tistory.com/688))
- 정확한 기준 없이 그때 그때 달라지는 페키지 구조와 네이밍 방식
- 확장에 대한 고려가 없는 기존의 설계

**문제 해결**

- 기존의 아키텍처를 헥사고날 아키텍처로 전환하면서 이를 극복

`유연성, 유지보수성` : 외부 시스템이나 인프라와의 의존성을 낮추어, 구성 요소를 쉽게 교체하거나 업데이트할 수 있게 되었습니다.
그도 그럴 것이 application의 service들은 모두 인터페이스에 해당되는 port에 의존하게 되었습니다.
더 이상 실 구현체가 아니기 때문에 중간에 다른 구현채로 변경되어도 유연하게 대응할 수 있는 장점을 갖게 되었죠! 이는 곧 유지보수성과도 직결된다 생각되더라고요!

`테스트 용이성` : 비즈니스 로직을 독립적으로 테스트할 수 있어 품질 향상과 개발 속도 향상에 도움이 됩니다!
인터페이스를 적절하게 사용하였기에 해당 로직의 독립성을 유지할 수 있던 점이 매우 장점이 돼준 것 같습니다.

`팀원과의 협업` : 책임이 분리되어 있어, 코드의 이해와 수정이 용이하며, 변화에 빠르게 대응할 수 있습니다.
즉, 흔하게 말하는 SOLID가 모두 지켜지고 있는 좋은 아키텍처 구조입니다.
또한 제2의 멤버가 들어와서 유지보수를 하거나 개편해야 해도 HexagonalArchitecture 자체의 이해도만 있다면 충분히 빠른 적응이 가능하다 생각됩니다.

**상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/689))**

---

## 3. 레거시 코드의 양방향 연관관계를 단방향으로!
JPA에 대해서는 서로 어느 정도 이해하고 있어, 적절한 fetch join을 사용하여 코딩했었기에 N+1 문제는 발생하지 않았습니다.
하지만 연관관계에 대해서 문제가 있었습니다.
가장 좋은 연관관계 설계는 단방향을 기초로 하되 필요하면 양방향 설계를 하는 것입니다.
Expand Down Expand Up @@ -177,7 +204,7 @@ https://blogshine.tistory.com/345

---

## 3. 공지 Scrap작업 multi-threading 처리로 시간 개선하기
## 4. 공지 Scrap작업 multi-threading 처리로 시간 개선하기

**문제 상황**

Expand All @@ -192,7 +219,8 @@ https://blogshine.tistory.com/345
**상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/660))**

---
## 4. Full-Text-Index도입을 통한 **검색 성능개선**

## 5. Full-Text-Index도입을 통한 **검색 성능개선**

**문제 상황**

Expand All @@ -208,7 +236,8 @@ https://blogshine.tistory.com/345
**상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/664))**

---
## 5. HeapDump를 통해 메모리 누수 원인 찾기 **검색 성능개선**

## 6. HeapDump를 통해 메모리 누수 원인 찾기 **검색 성능개선**

**문제 상황**

Expand All @@ -224,7 +253,8 @@ https://blogshine.tistory.com/345
**상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/687))**

---
# 6. Bulk Query를 통한 성능 개선

## 7. Bulk Query를 통한 성능 개선

**문제 상황**

Expand All @@ -243,7 +273,7 @@ https://blogshine.tistory.com/345

<br>

### 6-1) Insert 해결책
### 7-1) Insert 해결책

해결책은 2가지가 존재했습니다.
1. Table Id strategy를 SEQUENCE로 변경하고 Batch 작업
Expand All @@ -258,14 +288,14 @@ MySQL과 MariaDB의 Table Id 전략은 대부분이 IDENTITY 전략을 사용하

<br>

### 6-2) Delete 해결책
### 7-2) Delete 해결책
이미 프로젝트에서 queryDsl를 사용하고 있어 이를 이용하는 것이 가장 간단했기 때문에 queryDsl의 delete in 쿼리를 사용하여 해결했습니다.

**상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/686))**

---

## 7. 인증, 인가를 비즈니스 로직으로부터 분리하기
## 8. 인증, 인가를 비즈니스 로직으로부터 분리하기

**문제 상황**

Expand All @@ -283,7 +313,7 @@ MySQL과 MariaDB의 Table Id 전략은 대부분이 IDENTITY 전략을 사용하

---

## 8. 흔하디 흔한 N+1 쿼리 개선기
## 9. 흔하디 흔한 N+1 쿼리 개선기

원래 로직에서는 사용자의 Category 이름 목록을 가져오기 위해서 다음과 같이 처리가 되고 잇었습니다!

Expand Down Expand Up @@ -341,7 +371,7 @@ public List<String> getCategoryNamesFromCategories(List<Category> categories) {

쿼리가 총 1 + 2N 만큼 발생중이다.

### 8 - 1) 변경 전 쿼리
### 9 - 1) 변경 전 쿼리

```bash
Hibernate:
Expand Down Expand Up @@ -398,7 +428,7 @@ Connection: keep-alive
N+1 문제로 User한번 조회하는데 위와 같이 쿼리가 3번 나가게 됨
### 8 - 2) 변경 후
### 9 - 2) 변경 후
변경 후 한방 쿼리로 조회 끝
```java
Expand All @@ -415,7 +445,7 @@ public List<String> getUserCategoryNamesByToken(String token) {
___
## 9. Test Container를 통한 테스트의 멱등성 보장하기
## 10. Test Container를 통한 테스트의 멱등성 보장하기
테스트와, 실제 운영 DB를 둘다 MariaDB 환경으로 사용하여 문제가 발생할 일이 없다 생각했었습니다.
하지만, utf8과 같은 인코딩 방식이 로컬과 프로덕션이 달라 문제가 발생하였으며, 이또한 테스트 환경에서 걸러내지 못한 것이 문제라 생각하였습니다.
Expand All @@ -424,7 +454,7 @@ ___
---
## 10. CI / 정적분석기(SonarCloud, jacoco)를 사용한 코드 컨벤션에 대한 코드리뷰 자동화
## 11. CI / 정적분석기(SonarCloud, jacoco)를 사용한 코드 컨벤션에 대한 코드리뷰 자동화
**문제 상황**
Expand All @@ -441,7 +471,7 @@ ___
---
## 11. 서버 모니터링
## 12. 서버 모니터링
**문제 상황**
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:4.2.0'

// ArchUnit
testImplementation 'com.tngtech.archunit:archunit-junit5:1.0.1'

// Test Container
testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'
testImplementation 'org.testcontainers:testcontainers:1.17.6'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.kustacks.kuring.admin.presentation;
package com.kustacks.kuring.admin.adapter.in.web;

import com.kustacks.kuring.admin.common.dto.RealNotificationRequest;
import com.kustacks.kuring.admin.common.dto.TestNotificationRequest;
import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand;
import com.kustacks.kuring.admin.adapter.in.web.dto.RealNotificationRequest;
import com.kustacks.kuring.admin.adapter.in.web.dto.TestNotificationRequest;
import com.kustacks.kuring.admin.application.port.in.AdminCommandUseCase;
import com.kustacks.kuring.admin.domain.AdminRole;
import com.kustacks.kuring.admin.facade.AdminCommandFacade;
import com.kustacks.kuring.auth.authorization.AuthenticationPrincipal;
import com.kustacks.kuring.auth.context.Authentication;
import com.kustacks.kuring.auth.secured.Secured;
Expand All @@ -23,31 +24,32 @@
@RequestMapping(value = "/api/v2/admin", produces = MediaType.APPLICATION_JSON_VALUE)
public class AdminCommandApiV2 {

private final AdminCommandFacade adminCommandFacade;
private final AdminCommandUseCase adminCommandUseCase;

@Secured(AdminRole.ROLE_ROOT)
@PostMapping("/notices/dev")
public ResponseEntity<BaseResponse<String>> createTestNotice(
@RequestBody TestNotificationRequest request)
{
adminCommandFacade.createTestNotice(request);
@RequestBody TestNotificationRequest request
) {
adminCommandUseCase.createTestNotice(request.toCommand());
return ResponseEntity.ok().body(new BaseResponse<>(ADMIN_TEST_NOTICE_CREATE_SUCCESS, null));
}

@Secured(AdminRole.ROLE_ROOT)
@PostMapping("/notices/prod")
public ResponseEntity<BaseResponse<String>> createRealNotice(
@RequestBody RealNotificationRequest request,
@AuthenticationPrincipal Authentication authentication)
{
adminCommandFacade.createRealNoticeForAllUser(request, authentication);
@AuthenticationPrincipal Authentication authentication
) {
RealNotificationCommand command = request.toCommandWithAuthentication(authentication);
adminCommandUseCase.createRealNoticeForAllUser(command);
return ResponseEntity.ok().body(new BaseResponse<>(ADMIN_REAL_NOTICE_CREATE_SUCCESS, null));
}

@Secured(AdminRole.ROLE_ROOT)
@GetMapping("/subscribe/all")
public ResponseEntity<Void> subscribe() {
adminCommandFacade.subscribeAllUserSameTopic();
adminCommandUseCase.subscribeAllUserSameTopic();
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.kustacks.kuring.admin.presentation;
package com.kustacks.kuring.admin.adapter.in.web;

import com.kustacks.kuring.admin.common.dto.FeedbackDto;
import com.kustacks.kuring.admin.application.port.in.AdminQueryUseCase;
import com.kustacks.kuring.admin.domain.AdminRole;
import com.kustacks.kuring.auth.authorization.AuthenticationPrincipal;
import com.kustacks.kuring.auth.context.Authentication;
import com.kustacks.kuring.auth.secured.Secured;
import com.kustacks.kuring.common.dto.BaseResponse;
import com.kustacks.kuring.user.facade.UserQueryFacade;
import com.kustacks.kuring.user.application.port.in.dto.AdminFeedbacksResult;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
Expand All @@ -29,15 +29,15 @@
@RequestMapping(value = "/api/v2/admin", produces = MediaType.APPLICATION_JSON_VALUE)
public class AdminQueryApiV2 {

private final UserQueryFacade userQueryFacade;
private final AdminQueryUseCase adminQueryUseCase;

@Secured(AdminRole.ROLE_ROOT)
@GetMapping("/feedbacks")
public ResponseEntity<BaseResponse<List<FeedbackDto>>> getFeedbacks(
public ResponseEntity<BaseResponse<List<AdminFeedbacksResult>>> getFeedbacks(
@RequestParam(name = "page") @Min(0) int page,
@RequestParam(name = "size") @Min(1) @Max(30) int size)
{
List<FeedbackDto> feedbacks = userQueryFacade.lookupFeedbacks(page, size);
List<AdminFeedbacksResult> feedbacks = adminQueryUseCase.lookupFeedbacks(page, size);
return ResponseEntity.ok().body(new BaseResponse<>(FEEDBACK_SEARCH_SUCCESS, feedbacks));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.kustacks.kuring.admin.adapter.in.web.dto;

import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand;
import com.kustacks.kuring.auth.context.Authentication;

public record RealNotificationRequest(
String title,
String body,
String url,
String adminPassword
) {
public RealNotificationCommand toCommandWithAuthentication(Authentication authentication) {
return new RealNotificationCommand(title, body, url, adminPassword, authentication);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.kustacks.kuring.admin.adapter.in.web.dto;

import com.kustacks.kuring.admin.application.port.in.dto.TestNotificationCommand;

public record TestNotificationRequest(
String category,
String subject,
String articleId
) {

public TestNotificationCommand toCommand() {
return new TestNotificationCommand(category, subject, articleId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.kustacks.kuring.admin.adapter.out.event;

import com.kustacks.kuring.admin.application.port.out.AdminEventPort;
import com.kustacks.kuring.common.domain.Events;
import com.kustacks.kuring.message.adapter.in.event.dto.AdminNotificationEvent;
import com.kustacks.kuring.message.adapter.in.event.dto.AdminTestNotificationEvent;
import com.kustacks.kuring.message.application.service.exception.FirebaseMessageSendException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class AdminFirebaseMessageAdapter implements AdminEventPort {

@Override
public void sendNotificationByAdmin(String title, String body, String url) {
Events.raise(new AdminNotificationEvent(title, body, url));
}

@Override
public void sendTestNotificationByAdmin(
String articleId,
String postedDate,
String categoryName,
String subject,
String korName,
String url
) throws FirebaseMessageSendException {
Events.raise(
AdminTestNotificationEvent.builder()
.articleId(articleId)
.postedDate(postedDate)
.category(categoryName)
.subject(subject)
.categoryKorName(korName)
.baseUrl(url)
.build()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.kustacks.kuring.admin.adapter.out.persistence;

import com.kustacks.kuring.admin.application.port.out.AdminCommandPort;
import com.kustacks.kuring.admin.domain.Admin;
import com.kustacks.kuring.admin.application.port.out.AdminQueryPort;
import com.kustacks.kuring.common.annotation.PersistenceAdapter;
import lombok.RequiredArgsConstructor;

import java.util.Optional;

@PersistenceAdapter
@RequiredArgsConstructor
public class AdminPersistenceAdapter implements AdminQueryPort, AdminCommandPort {

private final AdminRepository adminRepository;

@Override
public Optional<Admin> findByLoginId(String loginId) {
return adminRepository.findByLoginId(loginId);
}

@Override
public void save(Admin admin) {
this.adminRepository.save(admin);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.kustacks.kuring.admin.domain;
package com.kustacks.kuring.admin.adapter.out.persistence;

import com.kustacks.kuring.admin.domain.Admin;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.kustacks.kuring.admin.application.port.in;

import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand;
import com.kustacks.kuring.admin.application.port.in.dto.TestNotificationCommand;

public interface AdminCommandUseCase {
void createTestNotice(TestNotificationCommand command);
void createRealNoticeForAllUser(RealNotificationCommand command);
void subscribeAllUserSameTopic();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.kustacks.kuring.admin.application.port.in;

import com.kustacks.kuring.user.application.port.in.dto.AdminFeedbacksResult;

import java.util.List;

public interface AdminQueryUseCase {
List<AdminFeedbacksResult> lookupFeedbacks(int page, int size);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.kustacks.kuring.admin.application.port.in.dto;

import com.kustacks.kuring.auth.context.Authentication;

public record RealNotificationCommand(
String title,
String body,
String url,
String adminPassword,
Authentication authentication
) {
public String getStringPrincipal() {
return String.valueOf(this.authentication.getPrincipal());
}
}
Loading

0 comments on commit f5f2faa

Please sign in to comment.