Spring Boot를 활용하며 항상 Layered Architecture를 활용하며 서버를 개발해왔다. 개발을 하면서 항상 어떤 일을 Controller에 주고, 어떤 일을 Service에게 주어야 할지, 어떻게 로직을 각 Layer에 맞게 적절하게 분배할 지, 도메인이 처리하도록 책임을 위임할 지, Service가 모두 처리하도록 할 지 등 여러 가지 고민을 해왔다. 이에 Controller, Service, Repository가 하는 일을 명확히 할 겸, Layered Architecture에 대해 공부해보았다. 이번 시간에는 Layered Architecture에 대한 개념과 Spring Boot에서 사용하는 사례를 살펴보고 내가 했던 고민과 해결방법을 공유하려 한다.
Layered Architecture는 소프트웨어 시스템을 여러 개의 논리적인 계층으로 분리하는 방법이다. 계층을 분리하는 이유는 무엇일까? 이미 알고 있겠지만 관심사의 분리라고 생각한다. 각 계층은 특정한 역할과 책임을 가지며, 상위 계층은 하위 계층을 사용하여 기능을 수행한다. 이러한 계층 구조는 시스템의 복잡성을 줄이고 유지보수성을 향상시킨다. 어떻게 복잡성을 줄이고 유지보수성을 향상시키는 지는 장단점에서 살펴보자.
관심사의 분리는 한편으로는 비즈니스 로직의 크기에 따라 고민이 생기기도 한다. 예를 들어, 복잡하지 않은 api 같은 경우에는 controller는 단순히 service의 로직을 메소드로 wrapping하는 용도가 되고, 단순 layer에서 i/o 작업만 일어나기 때문에 이걸 제대로 활용하고 있는게 맞나? 하는 생각이 들기 때문이다. 자세한 내용은 추후 고민파트에서 살펴보자.
일반적으로 Layered Architecture는 다음과 같은 계층으로 구성된다. 계층의 개수에 따라 n-계층이라고 부르기도 한다. 다음은 4-계층 Layered Architecture이다.
이러한 계층 구조를 통해 시스템은 상위 계층이 하위 계층에게 의존하며, 상위 계층은 하위 계층을 모르는 것이 원칙입니다. 이로써 각 계층은 독립적으로 개발하고 테스트할 수 있으며, 변경이 필요한 경우에도 다른 계층에 영향을 미치지 않을 가능성이 높습니다.
보통 우리는 3-계층을 사용한다. Controller Layer, Service Layer, Repository Layer이다.
이전의 4-계층을 Spring Boot에서 흔히 사용하는 패턴을 보면 이런 식인 것 같다.
Controller Layer에서 사용자의 요청을 받아들이고 Service Layer를 호출하여 비즈니스 로직을 처리 후, 프론트에게 보내줄 response dto를 적절히 만들어서 보낸다.
@PostMapping
public ResponseEntity<ApiResponse<RecipeResponse>> createRecipe(@CurrentUser User user, @RequestBody RecipeCreateRequest recipeCreateRequest){
recipeService.deleteCancelledFiles(recipeCreateRequest);
RecipeResponse response = recipeService.createRecipe(user, recipeCreateRequest);
ApiResponse apiResponse = ApiResponse.builder()
.message("레시피 생성 성공")
.status(HttpStatus.CREATED.value())
.data(response)
.build();
return ResponseEntity.ok()
.body(apiResponse);
}
Service Layer는 비즈니스 로직을 처리하는 역할을 한다. Repository Layer를 호출하여 데이터베이스와 상호작용한다. 하지만 비즈니스 로직이 커질 경우 다른 Service를 호출하기도 한다. 또한, 도메인 객체를 직접 생성하고 도메인 로직을 호출하기 때문에 Service Layer는 4-계층에서 Domain Layer의 일부라고 볼 수 있다.
@Transactional
public RecipeResponse createRecipe(User user, RecipeCreateRequest request) {
Util.validateDuplication(request.getIngredients(), request.getOptionalIngredients());
List<Ingredient> requiredIngredients = ingredientSimpleService.getIngredientsByIds(request.getIngredients());
List<Ingredient> optionalIngredients = ingredientSimpleService.getIngredientsByIds(request.getOptionalIngredients());
Recipe recipe = recipeRepository.save(createRecipeEntity(user, request));
saveNecessaryIngredientsOfRecipe(recipe, requiredIngredients);
saveOptionalIngredientsOfRecipe(recipe, optionalIngredients);
stepService.saveStepsForRecipe(recipe, request.getSteps());
return RecipeResponse.builder()
.recipeId(recipe.getId())
.build();
}
Repository Layer는 데이터베이스나 외부 시스템과의 상호작용을 담당한다. Repository는 도메인 객체를 영속화하며, 쿼리를 통해 데이터 조작을 수행한다. 이 때문에 주로 infrastructure layer에 해당한다고 볼 수 있지만, 도메인 객체를 조회하고 db에 저장하는 기능을 제공하므로, domain layer에도 일부 속한다고 볼 수 있다.
엄연히 보면 우리가 만든 repository interface 자체는 domain layer에 속하고, spring boot에서 이를 구현한 클래스가 infrasturcutre layer에 해당하여 repository는 domain layer와 infrastructure layer의 매개체라고 볼 수 있다. 하지만 개발자 입장에서 봤을 때 repository layer에서 db 상호작용을 주로 맡으니 infrastruture layer에 해당한다고 봐도 무리가 없을 것 같다.
public interface RecipeRepository extends JpaRepository<Recipe, Long> {
@EntityGraph(
attributePaths = {"author", "steps"}, type = EntityGraphType.FETCH
)
Optional<Recipe> findAllElementsById(Long id);
boolean existsById(Long recipeId);
@Query(value = "select distinct r from Recipe r join fetch r.author ",
countQuery = "select count(r) from Recipe r")
Page<Recipe> findRecipes(Pageable pageable);
}
데이터 입장에서 보면 Validation은 많이 하면 할수록 좋다. 하지만 모든 layer에서 Validation하게 된다면, 이미 validation을 통과한 데이터임에도 불구하고, 하위 계층에서 같은 로직을 반복하게 된다. 이는 성능 저하로 이어질 수 있다. 또한, 중복 코드가 발생하여 code smell이 난다.
내가 주로 활용하는 방법은 Controller에서는 null인지 아닌지 등 간단한 validation을 bean validation을 통해 진행하고, Service에서 repository를 활용한 validation과 비즈니스 로직과 관련된 추가적인 validation을 진행하는 것이다. 예를 들어, userId를 매개변수로 받아 이 userId가 db에 존재하는 id인지 아닌지를 service에서 검사하게 한다. 그 외에 비즈니스 로직과 관련된 validation은 모두 service가 담당하도록 했다.
그렇게 비즈니스 로직을 service layer에 숨기고 개발하다보면, controller는 여러 서비스를 조합해서 흐름을 나타내는 곳인데, 이를 망각하고 하나의 api를 위한, 하나의 api에 의한 service 메소드를 우후죽순 만들어낼 때가 있다. 즉, 너무 많은 내용을 wrapping하는 경우다. 이러면 해당 service 메소드의 재사용성이 떨어질 수도 있고, 책임을 벗어난 다른 일까지 담당하는 것일 수도 있다. Layered Architecture의 장점으로 재사용성이 있는데, 재사용성이 높으려면 적절하게 숨기고 적절하게 드러내야 하는 것 같다.
@Transactional(readOnly = true)
public User getUserById(Long userId) {
return userRepository.findByIdAndIsQuit(userId, false).orElseThrow(() -> new NotFoundException(USER_NOT_FOUND));
}
굉장히 기본적인 UserService의 메소드다. userId를 검증하는 것은 다른 domain에서도 많이 사용된다. 그렇기 때문에 ‘userRepository.findByIdAndIsQuit(userId, false).orElseThrow(() -> new NotFoundException(USER_NOT_FOUND));’ 이 코드가 많은 도메인에서 중복이 될 것이다. 나는 getUserById라는 메소드를 이미 만들놨다면 해당 메소드를 재사용하는 것이 맞지 않을까 생각하게 되었다. 또한, 이렇게 내용이 간단한 메소드야 말로 재사용에 최적화된 메소드가 아닌가하는 생각이 들었다. (메소드 크기가 커질수록 해당 로직에 특화되었다는 것이니 재사용되기가 어렵지 않겠는가)
그럼 이 메소드를 다른 도메인에서 사용하고 싶다면 UserService를 의존성 주입해야 한다. 이 메소드를 사용하고 싶어 UserService를 DI 받게 될 때 발생하는 문제는 두 가지다.
자, 그렇다면 ‘userRepository.findByIdAndIsQuit(userId, false).orElseThrow(() -> new NotFoundException(USER_NOT_FOUND));’를 쓰지 않고 getUserById를 재사용할 수 있는 방법은 없을까?
재사용이 많이 되는 메소드들만 따로 모아두는 것은 어떨까? 필자는 해당 도메인 서비스에서 다른 도메인 서비스로 재사용이 많이 되는 메소드들을 모아 [도메인 이름]SimpleService로 분리했다.
getUserById를 UserSimpleService에 정의하고, 다른 Service에서는 UserSimpleService를 DI 받는 것이다. getUserById 뿐만 아니라 checkExistByName 등 다른 도메인에서 자주 사용되는 메소드들이면 여기에 정의하는 것이다.
물론 프로젝트 초기 단계부터 SimpleService를 만드는 것은 비추이다. 어찌보면 관리 포인트가 하나 더 생기는 것이니 Service에 만들어도 문제되지 않으면 Service에 만들고, 이후에 개발을 진행하며 Service 안에서 메소드 별로 사용되는 빈도가 현저히 차이나면 다른 도메인에서 재사용이 많이 되는 메소드를 이처럼 따로 분리하는 것도 좋은 방법이라고 생각된다.
사실 이름이 SimpleService인게 직관적이지는 않아서 맘에 들지는 않지만 적절한 게 생각나지 않아 이렇게 지었다. 처음에는 UtilService였는데 Util과 Service용도는 다르기 때문에 올바르지 않다고 생각되어 SimpleService라고 지었다. (SpringBoot 내부에서도 Simple은 많이 쓰니까..)
보통 별다른 로직 없이 i/o 작업만 하는 친구들이 다른 도메인에서 많이 사용되기 때문에 모아두고 보면 Sinkhole Anti Pattern에 해당되는 것 아닐까 고민할 수 있다. 하지만 단순 I/O 작업이라서 Sinkhole Anti Pattern인 것은 아니다. Sinkhole Anti Pattern은 요청의 20% 이상이 단순 통과 처리일 때부터 의심해도 된다. 모든 어플리케이션은 단순 통과처리 api가 있을 수 밖에 없기 때문이다.
사실 Layered Architecture를 가지고 개발을 하면 계층을 활용하여 어떻게 관심사를 적절하게 분리하고, 재사용을 극대화할 수 있는지에 대해서 끊임없이 고민하게 되는 것 같다. 그럴 때마다 나는 다음 두 가지 원칙을 최우선으로 생각한다.
https://junhyunny.github.io/architecture/pattern/layered-architecture/
https://github.com/ajou-swef/cookcode-backend
해당 깃헙은 최근 캡스톤 디자인 졸업 작품으로 진행 중인 프로젝트다.