image.png

저희 패키지 구조를 아키텍처로 표현한 형태입니다.


❓ 해당 구조를 선택한 이유

현재 구조는 클린 아키텍처를 모방했으며 완벽한 클린 아키텍처를 적용하지 않았습니다.

완벽한 클린아키텍처는 여러모로 복잡하며 팀과 협의과 완벽해야 된다고 생각합니다. 기본적인 3tier layered architecture를 사용하나 어느정도 클린 아키텍처의 사상을 가지고 진행하려고 합니다!

image.png

front → back으로 전달 될 때, tcp layer를 살펴보면 다른 계층은 스프링 프레임워크가 모두 구현을 해주기 때문에 우리는 (in port)presentation layer → application layer → (out port)presentation layer만 집중합니다.

여기서 application에 위치한 것만 domain 패키지에 포함되어야 하지만, 저희는 3tier architecture를 혼합해 inport 또한 domain의 일부로 보려고 합니다!

그리고 out port에 대한 presentation 계층은 infra 패키지에 집중합니다.

이렇게 설계 했을 때 장점이 무엇일까요?

현재는 먼 미래를 설계하기에 너무 과도하다 판단했습니다. 그렇다고 스파게티 코드를 작성하기에 실제 api 연동 작업에서 버그가 발생하면 수정할 부분이 너무 많아집니다. 또, 스파게티 코드는 버그를 추적하기도 힘들구요. 그래서 clean architecture를 일부 차용하면서 버그는 추적하기 쉬운 형태로 구성했습니다.


❓ 해당 구조의 이론을 어떻게 적용 할 수 있나요?

예시로 프로필을 수정하는 시나리오를 작성하겠습니다.

image.png

두단계 프로세스에 거쳐 이미지를 업로드하는 이벤트 스토밍을 작성

time flow를 기반으로 첫 번째 흐름은 이미지를 먼저 업로드 하는 단계,

두 번째 흐름은 업로드 된 이미지를 사용 확정하는 단계입니다.

지금 작성한 이벤트 스토밍을 보면 문제점이 있습니다.

User 에서 Image를 직접 참조하면서 User domain이 Image domain까지 추적하는 형태입니다.

이런 스파게티 형태가 Profile image 뿐만 아니라 challenge, product에도 적용이 된다면 각 도메인에서 왜 이미지 확정이 실패했는지 추적해야하는 복잡한 비용이 발생합니다.

이를 개선해보겠습니다.

image.png

현재 구조는 두 경계를 명확히 구분해, 프로필 수정은 이미지 사용 확정을 요청하는 것으로 끝마칩니다.

그리고 File 혹은 Image 도메인에서 이미지 사용 확정을 처리하는 형태로 개선합니다.

이렇게 개선된 환경은 어떤 도메인에서 이미지 사용 확정에 실패하든 Image Domain에서만 해당 로그를 추적하면 됩니다. 다수의 책임 → 단일의 책임으로 역할이 분명해졌습니다.

코드 예시

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {

		private final UserService userSErvice;
		
		@PutMapping
		public ApiTemplate<?> update(@Auth Long userId, @Valid UpdateUserDto dto) {
				Long result = userService.update(userId, dto);
				return ApiTemplate.ok(Enum.message, result);
		}
}

Controller는 inPort로 application에서 필요로 하는 데이터를 gatekeeper로 검증하고 정확한 데이터로 표현해 전달하고 응답합니다.

응답하는데 왜 InPort일까? 고민하면 좋은 포인트 일 것 같습니다!

@Service
@Transactional
@RequiredArgsConstructor
public class UserService {

		private final UserJpaRepository userJpaRepository;
		private final ProfileImageHelper profileImageHelper;
		
		public Long update(Long userId, UpdateUserDto dto) {
				UserEntity userEntity = userJpaRepository.findById(userId);
				if (!userEntity.isDefaultProfileImage()) {
						profileImageHelper.markUnuse(userEntity.getProfileImageUrl());
				}
				
				userEntity.update(dto.name(), dto.profileImageUrl());
				profileImageHelper.markUse(userEntity.getProfileImageUrl());
				userJpaRepository.save(userEntity);
				return userEntity.getId();
		}

}
public interface ProfileImageHelper {

		void markUse(String imageUrl);
		void markUnUse(String imageUrl);
}

Service는 application Layer로 Event Storming의 User Context에서 추가 정책을 조금 더 보완해서 코드로 표현해봤습니다. 먼저, EventStorming에서 확인했듯이 경계를 구분하면서 Profile Image 확정을 하는 책임은 User Domain의 책임이 아니게 되었습니다.

그럼 Profile Image Helper는 Application Layer? 를 고민해봐야합니다. Bounded Context가 구분되면서 외부 서비스로 봐야합니다. 그래서 Interface로 전달해야 되는 표현 구조만 잡아주면 됩니다!

(실제 AWS와 같은 외부에서 사용하는 서비스가 아니므로 EventStorming에서는 핑크 스티커로 표현하지 않았습니다)

결과적으로 User 도메인은 비즈니스 정책에 맞는 로직만 작성하고 ProfileImageHelper라는 Interface로 DIP를 적용해 필요한 기능을 호출만합니다. 그럼 User Domain을 맡은 책임자는 File Domain을 맡은 책임자에게 Profile Image 사용 확정 기능을 구현해주세요! 라고 협업할 수 있습니다.

버그 추적에서도 User Domain은 프로필 수정 api 요청 중 문제가 발생하더라도 user domain의 비즈니스 로직만 확인하면 됩니다. 그 외에는 File Domain의 책임이 될 수 있습니다. 의존성이 단방향으로 많이 개선될 수 있는 구조이므로 해당 구조를 채택하게 되었습니다!

마찬가지로 S3Helper, jwt 과 같은 외부에서 지원하는 기능을 구현해야 된다면 Infra Package에서 개발을 하고 interface로 dip를 적용해 구현하면 됩니다!


CQRS 패턴은 어떻게 적용할 수 있나요?

image.png

첫 번째 이미지로 첨부되었던 해당 이미지를 보면 Presentation은 Infra를 직접 의존할 수 있습니다.

어떻게 가능할까요?

Presentation은 Inport로 가장 컴포넌트입니다. 그리고 Infra 또한 Outport로 저수준 컴포넌트입니다.

저수준 컴포넌트 → 저수준 컴포넌트는 문제가 없는 사항입니다.

또, application의 business logic이 없는데 추가로 한 계층을 거치는 것은 비용 낭비이기 때문입니다.

맨 처음 언급한 Presentation Layer를 살펴보면 out port에서는 db 또는 외부 서비스에 access 하기 위한 표현만 해주면 된다고 작성했습니다. Infra에서 사용되는 기술은 DIP를 적용해 interface로 구현하므로 필요한 정보는 모두 표현이 되어있습니다! 즉 In port → out port로 데이터만 전달하면 되는 것이죠.

예시로 Jwt Token 발급이나 Page Query를 조회하는 코드를 작성해보겠습니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

		private final AuthService authService;
		private final JwtProvider jwtProvider;
		
		@PutMapping
		public ApiTemplate<?> signIn(@RequestBody @Valid SignInDto dto) {
				Long userId = authService.signIn(dto);
				AuthResponse result = jwtProvider.issueToken(userId);
				return ApiTemplate.ok(Enum.message, result);
		}
}

물론 Jwt 생성하는 로직을 비즈니스 로직으로 처리하겠다면 Service에서 처리할 수 있습니다.

회원가입만 집중하겠는가? vs 회원가입 후 토큰 발급까지 집중하겠는가? 라는 관점에 따라 위치를 적절히 선택할 수 있을 것 같습니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserQueryController {

		private final UserQuery userQuery;
		
		@PutMapping
		public ApiTemplate<?> signIn(@Auth Long userId, @ModelAttribute GetMyPageFilterDto dto) {
				UserMyPageResponse result = userQuery.getMyPage(dto);
				return ApiTemplate.ok(Enum.message, result);
		}
}
@Repository
@RequiredArgsConstructor
public class UserQueryImpl implements UserQuey {
	
		private final JPAQueryFactory jpaQueryFactory;
		private final UserJpaRepository userJpaReposity;
		
		@Override
		public UserMyPageResponse getMyPage(GetMyPageFilterDto dto) {
				// 동적 페이지의 경우 queryDsl
				// 단순한 조회의 경우 userJpaRepository의 JPQL or native Query활용 가능 
		}
}