[개발] 프로젝트 아키텍처 및 유즈케이스 계층 설계

김종준·2023년 8월 16일
1

Hiit

목록 보기
8/12
post-thumbnail

프로젝트 아키텍처 및 유즈케이스 계층 설계


관련 코드 바로가기


프로젝트를 몇 번 진행하다 보니 비즈니스 로직에 관한 고민과 함께 가장 많이 한 고민이 프로젝트 아키텍처에 관한 것이었습니다.

그렇기에 이번에는 본격적인 개발에 들어가기 전에 아키텍처에 대한 고민을 먼저 하였습니다.


멀티 모듈 vs 단일 모듈

우선 가장 먼저 한 고민은 "어떤 모듈 구조를 가져갈 것인가?"입니다.

최근에 프로젝트를 멀티 모듈을 통해 진행하며 모듈 단위로 구분되는 의존성에 대한 장점을 많이 느꼈습니다.


예를 들자면

module Country
  class A, B, C
    
module City
  class D, E, F

module Person
  class G, H, I

위와 같이 모듈과 클래스가 구분되어 있고

Country gradle : implementation project(':City')

City gradle : implementation project(':Person')

위와 같이 gradle을 통해 연관관계를 설정한다면

Country는 Person에 대해 알 수 없습니다.


이처럼 자신이 필요한 것 외에는 알지 못하는 것의 장점은 명확한 연관관계를 구분해 준다는 것입니다.

또 알 수 없으니 사용할 수 없고 그렇기에 이는 협업 시에 휴먼에러도 줄여준다는 장점이 있다 느꼈습니다.


하지만 단점은 단일 모듈보다 설정이 어렵다는 것입니다.

이는 공부의 목적으로 하는 프로젝트라면 어렵다는 것은 문제가 되지 않습니다.

배우고 써보며 알아가면 되니까요.

하지만 이번 프로젝트의 경우는 배우는 것보다는 이제껏 공부한 것을 잘 정리해 "프로젝트에 필요하다 판단되는 것들을 적용"하며 프로젝트를 전반적으로 설계하는 과정을 경험하려고 하는 프로젝트입니다.

그렇기에 이번 프로젝트는 아직 완전히 파악하지 못한 멀티 모듈보다는 잘 알지는 못하더라도 레퍼런스가 많고 안정적인 단일 모듈을 선택하였습니다.


계층

모듈 다음으로 고민한 것은 계층입니다.

우선 가장 크게는 계층을 추후 모듈로 분리할 수 있는 단위로 나누었습니다.

그렇게 나눈 계층은 아래와 같습니다.

  • batch
  • common
  • config
  • domain
  • infra
  • repository
  • web

그리고 각 계층 바로 아래 계층마다 Config 파일을 두어 해당 계층에서 필요한 설정을 하도록 하였습니다.


  • dto : 계층 간 이동을 위해 필요한 객체의 모임
  • exception : 계층에서 발생할 수 있는 예외 모임
  • support: 도메인과 관련된 도움 객체 모임
  • util: 도메인과 관련없는 도움 객체 모임
  • support : 검증이 필요하지 않은 도움 객체의 모임 ex) converter
  • util : 검증이 필요한 도움 객체의 모임 ex) validater

또 위와 같이 각 계층에서 필요한 객체들은 계층 내부에서 패키지로 구분하도록 하였습니다.

하지만 이 구분에서 전 계층 간 공통으로 사용할 수 있는 것이 있다면 common 패키지에 선언하여 모든 계층에서 사용할 수 있도록 하였습니다.

스크린샷 2023-08-15 오후 11 10 25

이렇게 이전 프로젝트들을 참고하고 고민하며 큰 틀에서 계층에 관한 설계를 해보았습니다.


이후에는 큰 틀에서 설계한 계층 간의 소통을 어떻게 할 것인지에 관해 고민하였습니다.

이상과 실리 사이에서 적절한 선택을 하여야 했기에 이 부분이 계층 설계에 있어 가장 많이 고민한 것 같습니다.


계층간 소통

제가 고민한 계층의 소통 방법은 아래와 같습니다.

  1. 계층 간 소통에는 각 계층에서 선언한 request와 response 객체(dto 객체)를 통해 소통한다.
    ex) A 계층에서 B 계층과 소통하려면 B 계층에서 선언한 request, response 객체를 사용하여 소통한다.

  2. 계층 내부 소통에는 도메인 객체를 사용하거나 기본타입 객체를 사용한다.

    • 오버로딩이 필요한 경우가 생기면 dto 객체를 만들어 사용할 수 있다.
  3. web 계층의 controller와 domain 계층의 usecase 간의 소통에서는 web에서 선언한 request를 통해 소통하고 domain에서 선언한 response를 통해 응답받는다.

    • controller와 usecase 간의 소통에는 예외를 두는 이유는 web 계층에서 사용자로부터 받은 요청을 usecase에서 선언한 request를 통해 받는 변환 과정에서 데이터의 변화가 거이 일어나지 않기 때문이다.

    • controller의 response를 usecase의 response에 의존하는 이유도 위와 동일하다.

    • 여러 usecase를 사용하여 사용자의 요청을 처리해야 하는 경우는 facade 패턴을 사용하여 usecase를 묶어 처리한다.


dto는 계층 간 소통에만 사용하도록 하였고 web 계층의 controller와 domain 계층의 usecase 간의 소통에서 실리적인 선택을 하였습니다.


Usecase

위의 실리적 선택으로 "controller와 usecase가 너무 강하게 결합되는 것이 아닌가?"하는 의문을 가질 수 있습니다.

저 또한 동의하고 usecase와 controller와 강하게 결합된다 생각합니다.


이는 실리를 선택하였기에 usecase와 controller 간의 강한 결합은 피할 수 없는 문제라 판단하였습니다.

대신 이번 프로젝트에서는 그 결합의 크기를 줄이기 위해 usecase를 controller의 메서드에 따라 선언하는 방법을 선택하였습니다.

예를 들면 controller에 사용자의 요청을 처리하는 3개의 메서드가 있다면 3개의 uscase 클래스가 그 책임을 나누어 가지는 것입니다.

이렇게 하나의 usecase가 controller 전체가 아닌 메서드를 처리하는 방법을 통해 controller 단위의 결합을 controller의 각 메서드 단위의 결합으로 바꿀 수 있었습니다.

그리고 이러한 선택은 결합의 크기를 줄일 뿐 아니라 usecase가 너무 많은 책임으로 비대해지는 것을 막아주는 효과도 있을 것이라 기대하고 있습니다.


예제 코드

@RestController
@RequestMapping("/api/v1/foo")
@RequiredArgsConstructor
public class FooController {

	private final SaveFooUseCase saveFooUseCase;

	@PostMapping()
	public ApiResponse<ApiResponse.SuccessBody<FooUseCaseResponse>> save(SaveFooRequest request) {
		FooUseCaseResponse response = saveFooUseCase.execute(request);
		return ApiResponseGenerator.success(response, HttpStatus.OK);
	}
}

이제 usecase 자체에 대해 조금 더 알아봅시다.

기본적인 usecase의 틀은 간단합니다.

비지니스 로직 수행을 위한 도메인 객체를 가지고 비즈니스 로직 수행을 도와주는 아래 3가지의 클래스를 가집니다.

  • support / converter
  • util
  • service

하나씩 조금 더 알아봅시다.

converter는 from이라는 메서드를 통해 도메인으로 객체를 변환시켜 주고 to라는 메서드를 통해 응답이나 다른 계층의 요청에 필요한 객체로 변환시켜 주는 역활을 가집니다.

@Component
public class FooConverter implements AbstractDtoConverter<SaveFooRequest, Foo> {

	@Override
	public Foo from(SaveFooRequest source) {
		return Foo.builder().name(source.getName()).build();
	}

	public FooUseCaseResponse toDomainResponse(Foo source) {
		return FooUseCaseResponse.builder().name(source.getName()).build();
	}
}

이는 별도의 검증이 필요한(테스트가 필요한) 로직을 가지고 있지 않아 support에 속합니다.

그리고 검증이 필요한 로직을 가지고 있는 경우 util에 속하여 도메인 객체의 비즈니스 로직 수행을 돕습니다.


service는 다른 계층 혹은 다른 도메인과 연동이 필요한 경우 정의하여 사용합니다.

@Component
public class FooService {
  
  // 디른 계층 혹은 도메인에 관한 의존성

	public String doSomeThing(Foo foo) {
    // ex) foo와 연관된 bar을 찾는다.
    ...
	}
}

"usecase에 사용될 클래스가 많을 것인데 설계가 부족한 것이 아닌가?" 하는 생각을 할 수 있습니다.

설계란 프로젝트의 기준을 만드는 것인데 기준이 너무 세세하다면 제약이 될 수 있다고 생각하고 방향성을 나타낼 수 있는 정도가 적당하다고 생각합니다.


이번 프로젝트에서 usecase의 기준을 정리하면 아래와 같습니다.

  • model : 비즈니스 로직을 수행한다.
  • service : 외부 계층, 다른 도메인과 연동하는 일을 수행한다.
  • request : 계층으로 요청을 위해 사용한다.
  • response : 계층에서 응답을 위해 사용한다.
  • support: 도메인과 관련된 도움을 주는 일을 수행한다.
  • util: 도메인과 관련 없는 도움을 주는 일을 수행한다.
  • support : 검증이 필요하지 않은 일을 수행한다.
  • util : 검증이 필요한 일을 수행한다.

완벽하지는 않겠지만 계층 간의 의존성을 줄이고 싶다는 이번 프로젝트의 방향성을 나타내기에는 충분하다고 생각합니다.


마치며

최근에 학생 수준에서 프로젝트를 하는 것에 어떤 의미가 있을지 고민을 한 적이 있습니다.

아마 대부분의 사람이 "동료와의 협업 경험"이라는 대답을 할 것으로 생각합니다.

협업 경험도 중요하지만 저는 여기에 "일의 의존성 파악"이라는 키워드를 추가 하고 싶습니다.


누군가에게는 좋은 협업 경험을 제공한 동료가 다른 누군가에게는 나쁜 협업 경험을 제공할 수 있는 것처럼

동료와의 협업 경험이란 단지 그 당시 상황이 결정할 뿐이지 사람이 제어할 수 없는 것이라 생각합니다.


그렇다면 프로젝트가 혼자 공부하는 것과 무엇이 다를까요?

저는 내가 하는 일이, 정해진 약속이, 변경해야 하는 약속이 다른 사람들에게 어떠한 방식으로든 영향을 미친다는 점이 다르다고 생각합니다.

이러한 점에서 프로젝트는 혼자 공부하는 것과 다르게 "일의 의존성"에 대해 고민하게 됩니다.

처음에는 내가 담당하는 일이, 추후 더 나아가서는 프로젝트를 어떻게 구성하고 일을 나누어야 효과적일지 생각하고 고민하며 일의 의존성에 대해 인지하고 고민하는 것이 학생 수준에서 프로젝트를 하며 얻어갈 수 있는 것 중 하나가 아닐까 하고 생각합니다.

감사합니다.

2개의 댓글

comment-user-thumbnail
2023년 8월 16일

이런 유용한 정보를 나눠주셔서 감사합니다.

1개의 답글