🟩 레이어드 아키텍처와 팀의 선택
레이어드 아키텍처는 유사한 책임을 지닌 계층으로 구성하고 계층이 하위 계층에만 의존할 수 있도록 구성하는 패턴입니다.
각 계층에 맞는 개체를 포함시켜 높은 직관성으로 코드 생산성을 향상시킬 수 있었습니다.
프로젝트에서는 크게 4계층으로 나누기로 결정했습니다.
- Controller 계층 : 클라이언트의 요청, 응답을 담당
- Service 계층 : 비즈니스 로직을 담당
- Domain 계층 : 도메인 로직을 담당
- Repository 계층 : 데이터를 담당
팀원들 모두 공통적으로 가장 이해도가 높은 구조이며 관심사를 계층별로 분리하여 유지보수가 쉬워짐을 기대했습니다.
이에 더해서 팀원들 간의 DTO의 의존 방향까지 회의를 진행했습니다.
🟩 Service는 클라이언트를 모르게
Controller는 클라이언트의 요청을 받아 요청의 값을 Service 계층에 넘겨줍니다.
이 때 Request
클래스의 패키지 위치를 어디에 둘지 논의하게 됐습니다.
Controller에 request 패키지를 두는지
Service에 request 패키지를 두는지에 대한 의견을 나누게 됐습니다.
Controller, Service 패키지를 만들고 request를 Controller에 위치해봤습니다.
// 패키지 구조
controller
ㄴ TestController.java
ㄴ request
ㄴ TestCreateRequest.java
service
ㄴ TestService.java
패키지 구조에 맞게 나누어봤습니다.
Controller를 구현해봤을 때 TestCreateRequest
에 대한 import
가 Controller에 있습니다.
package example.controller;
import example.controller.request.TestCreateRequest;
import example.service.TestService;
@RequiredArgsConstruct
@RestController
public class TestController {
private final TestService testService;
@PostMapping
public ResponseEntity<Void> createTest(TestCreateReqeust request) {
testService.createTest(request);
// ...
}
}
Service에서도 마찬가지로 TestCreateRequest
에 대한 import
가 추가 된 것을 볼 수 있습니다.
package example.service;
// controller 패키지에 있는 TestCreateRequest가 import 되어 있다.
import example.controller.request.TestCreateRequest;
@Transactional
@Service
public class TestService {
private void createTest(TestCreateRequest request) {
// ...
}
}
즉 관계를 그림으로 보면 아래와 같습니다.
Controller가 Service를 필드로 가지며 의존하는 부분은 Service에서는 Controller를 모르고 있으므로 외부와의 의존이 있다고 하기 어렵습니다.
하지만 Service는 메서드의 파라미터 타입으로 controller 패키지 내부에 존재하는 TestCreateRequest를 약하게 의존하고 있는 부분에 대해서는 외부에 대한 의존을 가지고 있다고 판단했습니다.
실제로 처음에 데이터가 입력되는 부분은 클라이언트들이 맞습니다.
외부의 데이터가 서비스까지 들어오게 되는 부분은 맞지만 서비스 계층에서 필요한 정보를 클라이언트가 맞춰서 준다는 것이 아닌 Controller에서 서비스에 필요한 정보를 클라이언트로 받아서 넘겨준다는 의미를 갖게 하기로 했습니다.
다시 request 패키지의 위치를 변경시켜서 service 아래에 존재하게 했습니다.
// 요청 패키지 구조
controller
ㄴ TestController.java
service
ㄴ TestService.java
ㄴ request
ㄴ TestCreateRequest.java
이제 코드까지 수정하면 아래와 같은 그림이 완성됩니다.
Service 코드에서도 controller에 대한 import
문이 삭제되었습니다.
이제 Service는 Service의 정보만을 알고 진행하게 됩니다.
package example.service;
import example.service.request.TestCreateRequest;
@Transactional
@Service
public class TestService {
private void createTest(TestCreateRequest request) {
// ...
}
}
🟩 Controller는 클라이언트를 알도록
Service와 다르게 Controller는 외부 클라이언트와 연관이 있습니다.
클라이언트의 요청과 그에 대한 응답하는 부분을 비즈니스 로직과 엮이지 않기 위한 계층입니다.
때문에 저희 팀은 클라이언트에게 응답할 Response 객체를 controller 패키지 내부에 위치하기로 했습니다.
// 응답 패키지 구조
controller
ㄴ MemberController.java
ㄴ response
ㄴ MemberResponse.java
service
ㄴ MemberService.java
다시 한 번 그림으로 Controller, Service, Response를 표현하면 아래처럼 나뉘게 됩니다.
Service는 클라이언트가 필요한 응답을 알지 못하게 유도한 결과 입니다.
만약 외부에 대한 응답을 위해서 Service가 존재하게 될 경우 API에 맞춰서 Service 로직이 구현될 가능성을 낮췄습니다.
하지만 이러한 구조에도 고민할 부분이 많았습니다.
바로 Domain이 Controller까지 나가버리는 문제입니다.
아래 Controller 예시 코드를 보면 Service에서 Domain 객체를 받아 Response로 변환하게 됩니다.
package example.controller;
import example.controller.response.MemberResponse;
import example.service.MemberService;
@RequiredArgsConstruct
@RestController
public class MemberController {
private final MemberService memberService;
@GetMapping
public MemberResponse readMemberByMemberId(@RequestParam Long memberId) {
Member member = memberService.findByMemberId(memberId);
return new MemberResponse(
member.getName(),
member.getEmail()
);
}
}
만약 Domain 객체 내부의 도메인 로직을 호출한다면 생각하지 못한 결과를 초래할 수 있습니다.
저희는 이런 문제를 팀 컨벤션으로 해결하기로 했습니다.
Service에서 응답을 반환하거나 OSIV를 끄고 데이터 변경 위험을 최소화시키는 방법도 있었습니다.
하지만 프로젝트를 시작하는 단계에서 팀원들간의 코드 스타일이 맞춰지지 않은 상태로 계층에서 작업할 관심사를 확실하게 정해놓고 가지 않으면 이후의 유지보수 비용이 훨씬 더 들거라는 결론이 나왔습니다.
결과적으로 Response 객체를 생성할 때는 항상 정적 팩토리 메서드를 이용하고 파라미터로 도메인 객체를 받는 방식을 선택했습니다.
Response 객체 내부에서는 getter만을 사용해서 데이터를 꺼내기만 합니다.
이미 잡혀져 있는 응답의 틀에 데이터를 주입하는 방식으로 진행하여 도메인 로직을 호출할 가능성을 낮추기로 했습니다.
public record MemberResponse(String name, String email) {
public static MemberResponse from(Member member) {
return new MemberResponse(
member.getName(),
member.getEmail()
);
}
}
🟩 조금씩 헷갈려지는 코드
약 3달 정도 초반에 정한 레이어드 아키텍처 + 팀 컨벤션으로 기능 구현을 해왔습니다.
하지만 어느 순간부터 새로운 요구사항이 들어오면 어떤 클래스에 구현하면 좋을까라는 고민을 하게 됐습니다.
금방 끝낼 수 있겠다는 느낌의 간단한 요구사항 조차도 회의가 필요해졌고 사용할 수 있는 메서드를 찾지 못하고 동일한 기능의 메서드를 중복해서 구현하게 되는 문제가 생기기 시작했습니다.
구현 메서드가 있어서 현재 메서드는 필요가 없어보여요. 🥲
재사용 가능한 코드를 작성했지만 기능의 존재 여부를 모른다는 점은 무슨 말일까요?
찾고 있는 기능이 생각한 것과 다른 계층에 존재해서 그랬던걸까요?
코드를 보았을 때 같은 클래스 내부에 있었지만 많은 기능들이 생기다보니 클래스 자체의 가독성이 떨어졌습니다.
같은 계층은 맞지만 한 클래스에 존재하는 기능이 많아져서 문제라는 것을 인식할 수 있었습니다.
지금까지 메서드를 분리하거나 변수명을 열심히 만들어 기능 하나하나를 이해하기는 쉬웠지만 이제는 메서드 레벨이 아닌 클래스 레벨의 분리가 필요해졌습니다.
🟩 서비스 계층의 클래스를 하나 보자면
게시글에 관련된 비즈니스 로직을 가지고 있는 PostService가 있습니다.
PostService에는 다양한 기능들이 존재합니다.
- 조건에 맞게 조회하는 기능이 5개
- 특정 조건에 맞게 수정해야하는 기능 4개
- 삭제하거나 생성하는 기능 1개
이러한 기능들이 PostService라는 한 클래스 내부에 구현되어 있어 필요한 기능을 찾기가 어려웠습니다.
PostService 내부에 구현된 기능을 보니 조회가 가장 많고 이후에 수정, 생성, 삭제 순이었습니다.
서비스 특성상 사용자에게 응답할 조회에 대한 로직이 가장 많습니다.
그리고 프로젝트 도메인에 메인이 게시글인 만큼 업데이트하는 부분도 비중을 꽤나 차지하고 있습니다.
PostService를 먼저 Read와 Create + Update + Delete 나누기로 했습니다.
🟩 하나를 분리하면 모두 다 관심사에 맞게 분리하자
PostService를 PostReadService와 PostCUDService로 나누고 PostController도 수정해봤습니다.
각각의 서비스는 메서드의 수가 절반으로 줄어들어 클래스의 코드 라인 수가 줄었지만 Controller는 거의 그대로 입니다.
Controller도 클래스의 라인 수를 줄이는 동시에 조회와 생성, 수정, 삭제를 분리했습니다.
Controller와 Service는 조회와 생성, 수정, 삭제를 기준으로 나뉘어졌습니다.
이후에 요구사항이 들어온다면 어떤 기능이냐에 따라 접근하게 될 클래스가 분리가 됐습니다.
하지만 이렇게 클래스를 분리했을 때 패키지 내부 이전보다 더 복잡해졌습니다.
현재로서는 단순히 한 개의 클래스가 두 개로 늘어난 것 뿐이지만 이후에 조회에서 더 세부적으로 클래스를 분리하게 된다면 점차적으로 패키지 내부에 클래스가 쌓이기 시작할 것입니다.
post
ㄴ controller
ㄴ PostReadController
ㄴ PostCUDController
ㄴ service
ㄴ PostReadService
ㄴ PostCUDService
한 패키지 내부에 클래스가 모이는 것을 풀고자 이 부분도 조회와 생성, 수정, 삭제로 분리하기로 했습니다.
그리고 CUD와 같은 클래스명을 Command로 Read는 Query로 부르기로 컨벤션을 정했습니다.
이제 아래와 같이 패키지가 분리 되었습니다.
post
ㄴ query
ㄴ controller
ㄴ PostQueryController
ㄴ service
ㄴ PostQueryService
ㄴ command
ㄴ controller
ㄴ PostCommandController
ㄴ service
ㄴ PostCommandService
패키지를 분리해놓았을 때 놓친 부분이 하나 있습니다.
바로 도메인과 레포지터리를 어디에 위치해야 할지 결정해야 합니다.
먼저 도메인 로직을 생각해봤습니다.
조회를 하기 위해서 도메인 로직을 사용하기보다
생성, 수정, 삭제 등의 행위가 일어날 때 도메인 내부 로직을 호출하게 됩니다.
이는 데이터를 조작한다는 행동이기에 command 패키지에 도메인을 위치하기로 했습니다.
repository도 데이터와 연관이 깊으니 command 패키지에 같이 위치하기로 했습니다.
다시 그림을 그려보면 아래와 같습니다.
그리고 문제 발생 가능성이 있는 부분을 확인할 수 있었습니다.
바로 PostQueryService가 PostRepository를 알고 있는 점입니다.
데이터를 조회해야하는 만큼 데이터베이스에 접근해야하는 것은 당연한 일이지만 데이터를 수정, 삭제, 생성할 수 있는 기능을 언제든지 '조회를 담당하는 부분'에서 호출할 수 있다는 점입니다.
이런 식으로 잘못 사용될 여지가 있는 부분은 이전에 생겼던 문제들을 재발할 수 있습니다.
이러한 이유로 Repository도 command, query로 분리하기로 결정했습니다.
이제 최종적인 구조는 아래와 같이 만들어지게 됐습니다.
🟩 QueryService에서는 Response를 반환할 수 있게
이러한 구조를 가지게 되면서 QueryService에서 Response를 반환할 수 있게 했습니다.
기존에 팀에서 정했던 Service에서는 Response를 알지 못하게하여 외부의 정보를 차단하는 것을 QueryService에서는 Response를 알 수 있도록 팀 회의를 통해 기존 방식을 수정했습니다.
이유는 두 가지 있었습니다.
- 조회는 외부 클라이언트가 원하는 데이터를 주는 것으로 QueryService 내의 구현들은 외부에 의존적인게 맞다.
- QueryService에서 Response 객체를 넘기지 않으면 Controller에 로직이 생겨 중복되는 메서드를 활용하지 못한다.
QueryController에서 구현해야하는 로직을 QueryService에 넘겨서 다양한 요구사항에서 반복되는 로직을 재사용하며 코드 가독성이나 생산성면에서도 이점을 얻을 수 있었습니다.
@RequiredArgsConstructor
@RequestMapping("/posts")
@RestController
public class PostQueryController {
private final PostQueryService postQueryService;
// 변경 전
@GetMapping("/v1")
public ResponseEntity<PageResponse<PostResponse>> pagePostByTagNameV1(@RequestParam("tagName") String reducedTagName) {
List<Post> findPosts = postQueryService.findPostsByTagName(tagName);
List<PostTag> findPostTags = postQueryService.findPostTagsByPosts(findPosts);
// 게시글_태그 목록에서 게시글에 관련된 태그만을 불러온다.
// 해당하는 게시글에 태그를 넣는다.
// 태그가 들어간 게시글 목록들을 응답 객체로 변환한다.
// 응답 객체 목록들을 페이지 응답 객체로 다시 한 번 감싼다.
}
// 변경 후
@GetMapping("/v2")
public ResponseEntity<PageResponse<PostResponse>> pagePostByTagNameV2(@RequestParam("tagName") String reducedTagName) {
PageResponse<PostResponse> findPosts = postQueryService.pagePostsByTagName(tagName);
return ...
}
}
🟩 쉽게 리팩터링하기는 어렵다
- 팀에서 정한 컨벤션이 깨기 힘들다. -> 새로운 구조에 대한 학습 비용
- 기존 코드를 수정하기에 비용이 많이 든다. -> 새로운 기능이 계속 추가되는 중
현재 프로젝트에서 레이어드 아키텍처를 이용하고 있으며 도메인별로 패키지를 분리되어 있습니다.
팀에서 TDD를 지향하고 있기에 약 300개의 테스트와 프로덕션 코드는 리팩터링에 비용이 크다는 것을 느끼게 했습니다.
한 명, 한 명이 나눠서 구조를 개선하기에는 진행하고 있는 기능 구현을 구조에 맞게 다시 수정해야하는 점과 새로운 구조를 파악하기 위한 비용이 팀 전체를 보았을 때 크다는 점이 개선을 막아서고 있었습니다.
하지만 팀에서 가장 큰 장점 중 하나인 몹 프로그래밍으로 현재 레이어드 아키텍처의 개선 방향과 서로 간의 의도를 정확하게 파악하기로 했습니다. 👍
🟩 몹 프로그래밍으로 확실하게 리팩터링
몹 프로그래밍은 생각보다 효율적입니다.
새로운 기술에 대한 도입이나 방향성을 정해야할 때 팀원이 모여서 하나의 컴퓨터로 코드를 같이 작성하면 서로의 의도와 싱크를 맞출 수 있습니다.
특히나 아키텍처 개선과 같은 코드 전체를 수정해야하는 부분이라면 모두가 같이 진행하는 것이 이후에 의견이 엇갈리지 않고 더 빠른 속도를 가져가면서 팀 프로젝트에서의 대규모 리팩터링을 무사히 끝냈습니다.
최종 패키지를 최대한 생략해서 구조만 알 수 있도록 작성했습니다.
'👨🚀 우아한테크코스 5기' 카테고리의 다른 글
우아한테크코스 5기 LEVEL3 - 회고 (0) | 2023.08.19 |
---|---|
[20230709] 우아한테크코스 5기 LEVEL 3 - 바톤 프로젝트 2주차 (0) | 2023.07.09 |
[20230703] 우아한테크코스 5기 LEVEL3 - 바톤 프로젝트 1주차 (0) | 2023.07.03 |
[20230607] 우아한테크코스 5기 LEVEL2 - 레벨2 및 인터뷰 회고 (0) | 2023.06.23 |
[20230521] 우아한테크코스 5기 LEVEL 2 - 지하철 미션, 추가 비용 정책을 데이터베이스에서 가져오려면 (0) | 2023.05.21 |