[Spring Boot] 레이어드(Layerd) 아키텍쳐 : 계층별 주요 책임 및 고려할 점
레이어드 아키텍쳐 (3-tier Layered Architecture)
우리는 흔히 Spring Framework를 이용하여 Controller, Service, Repository 등의 계층별 모듈로 구분하여 프로그램을 작성하였다. 해당 구조를 3-tier Layered 아키텍쳐라고 한다.
해당 구조는 각 계층이 서로 독립적으로 동작하고 각 계층의 역할을 명확하게 구분하여 유지보수성과 확장성을 높이는 것을 목표로 한다.
이번 포스팅에서는 프로그램을 작성할 때, 계층별로 집중해야 하는 포인트에 대해서 알아보려고 한다.
Presentation Layer
사용자와 애플리케이션 간의 인터페이스 역할을 담당하는 계층이다. 사용자의 요청을 수신하고, 이를 비즈니스 로직으로 전달하며, 결과를 사용자에게 반환하는 책임이 있다.
UI 요소나 API 엔드포인트를 통해 사용자와 직접 상호작용하며, 데이터 유효성 검사 및 포맷팅을 처리한다.
주요 책임과 포인트
엔드포인트 정의: Presentation 계층에서는 외부 세계로부터 접근할 수 있는 API의 엔드포인트를 정의해야 하며, 이는 일관성 있는 명명 규칙, 행위를 추상화할 수 있는 명확하고 직관적인 URL 구조, RESTful 원칙 사용 등을 고려하여 작성할 수 있다.
응답 형식 통일과 예외 처리: 요청의 결과나 발생한 오류를 알아보고 사용하기 편한 형태로 반환하여 사용자에게 알린다. 응답 형식을 통일하여 클라이언트에서 일관된 방식으로 데이터를 처리할 수 있도록 돕는다.
데이터의 유효성 검증 : 사용자의 입력 데이터에 대한 최소한의 유효성 검증을 수행하여, 비즈니스 로직으로 부적절한 데이터가 전달되지 않도록 방지한다.
요청 및 응답 전용 DTO 사용 : 요청(Request)과 응답(Response)은 DTO(Data Transfer Object)를 통해 데이터의 교환이 이루어져야 한다.
응답 형식 통일과 예외 처리
통일된 응답 객체의 예시
Service Layer로부터 받은 데이터들과, 응답 코드, 메시지들을 하나의 통일된 구조의 Object 형태로 응답을 통일하여 클라이언트가 일관된 방식으로 데이터를 처리할 수 있도록 한다.
@RestController
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping("/api/v1/products/selling")
public ApiResponse<List<ProductResponse>> getSellingProducts(){
return ApiResponse.ok(productService.getSellingProducts());
}
@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request){
return ApiResponse.ok(productService.createProduct(request));
}
}
@Getter
public class ApiResponse<T> {
private ApiResponse(HttpStatus status, String message, T data) {
this.code = status.value();
this.status = status;
this.message = message;
this.data = data;
}
private int code;
private HttpStatus status;
private String message;
private T data;
public static <T> ApiResponse<T> of(HttpStatus httpStatus, String message, T data){
return new ApiResponse<>(httpStatus, message, data);
}
public static <T> ApiResponse<T> of(HttpStatus httpStatus, T data){
return new ApiResponse<>(httpStatus, httpStatus.name(), data);
}
public static <T> ApiResponse<T> ok(T data){
return new ApiResponse<>(HttpStatus.OK, HttpStatus.OK.name(), data);
}
}
예외 처리의 예시
Spring에서 제공되는 RestControllerAdvice는 글로벌하게 예외를 처리하고 JSON 또는 XML과 같은 RESTful 응답을 반환하기 위해 사용된다. 예외에 대한 응답 또한 일관된 형태로 직접 Handler를 통해서 통일시킬 수 있다.,
@RestControllerAdvice
public class ApiControllerAdvice {
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Object> bindException(BindException e){
return ApiResponse.of(HttpStatus.BAD_REQUEST, e.getBindingResult().getAllErrors().get(0).getDefaultMessage(), null);
}
}
<함께 보기> 사용자 정의 예외 처리
https://sjh9708.tistory.com/168
데이터의 유효성 검증
요청 데이터가 적절한 필드와 포멧이고, 사용할 수 있는 지 필터링하는 것은 Presentation Layer의 주요 역할이다.
Spring에서는 다양한 종류의 Validator를 제공한다.
@Getter
@NoArgsConstructor
public class ProductCreateRequest {
@NotNull(message = "상품 타입은 필수입니다.")
private ProductType type;
@NotNull(message = "상품 판매상태는 필수입니다.")
private ProductSellingStatus sellingStatus;
@NotBlank(message = "상품 이름은 필수입니다.")
private String name;
@Positive(message = "상품 가격은 양수여야 합니다.")
private int price;
// ...
}
요청 및 응답 전용 DTO 사용
요청(Request)과 응답(Response)은 DTO(Data Transfer Object)를 통해 데이터의 교환이 이루어져야 한다.
특히 Entity를 DTO의 형태로 가공하지 않고 그대로 응답 데이터로 반환하는 것은 금지다.
불필요한 데이터와 민감 정보 포함
Entity는 데이터베이스와 직접 매핑된다. 따라서 불필요한 데이터까지 포함될 수 있다. 이는 클라이언트에서 API 사용의 직관성을 해치고 특히 테이블의 구조가 복잡하거나 연관 관계라면 더욱 그러하다. 자연스럽게 네트워크의 속도에도 좋지 않을 것이라는 것은 알 수 있다.
더 나아가 내부적으로 민감한 데이터(개인정보, 비밀키 등..)을 포함해서는 안되기 때문에 반드시 DTO로 가공하여야 한다.
N+1 문제
또한 Entity를 그대로 반환하게 된다면 DB의 N+1 문제에 쉽게 노출되게 된다.
- DTO를 사용하여 필요한 데이터만 선택적으로 가져오고, 지연 로딩된 필드나 연관된 엔티티를 미리 적절하게 페치(fetch)하면, N+1 문제를 회피하고 성능을 최적화할 수 있다.
- 필요한 데이터만 선택하기 때문에 지연 로딩으로 인한 예기치 않은 쿼리 실행을 방지할 수 있다.
요청 및 응답용 DTO와 Business Layer DTO의 분리
이 말은 [Client <-> Presentation DTO]와 [Presentation <-> Business DTO]의 분리를 권장한다는 의미이다.
- 요청 및 응답용 DTO : 클라이언트와의 데이터 교환을 담당하며, Presentation Layer와 밀접한 관련
- Presentation과 Business Layer 사이의 DTO : 비즈니스 로직과 Presentation Layer 간의 데이터 교환을 담당
글을 쓰고 있는 나도 그렇고 많은 사람들이 Entity를 그대로 노출시키지 않고 DTO로 변환하여 사용하는 것 까지는 잘 지키지만, Layer 별로 DTO를 분리하여 사용하는 단계를 밟지 않는 경우도 많다. 왜냐하면 크게 문제될 것이 없기 때문이다. 하지만 장기적으로 보았을 때 Layer 간의 의존도를 감소시킬 수 있으며 명확한 관심사를 가질 수 있게 한다.
주요 장점
- 이상적인 Layered 구조 설계에서 하위 Layer은 상위 Layer의 역할을 모른다고 전재되며, 느슨한 결합으로 작성되는 것이 좋다.
- Presentation Layer와 Business Layer 간의 데이터 교환을 전담하는 DTO를 사용하면, 비즈니스 로직의 세부 사항이 Presentation Layer에 노출되지 않도록 할 수 있다.
- 비즈니스 로직이 변경될 때, Presentation Layer에서 사용하는 DTO를 변경하지 않고 Business Layer 내에서 필요한 DTO를 변경할 수 있다. 따라서 DTO의 분리는 두 계층간의 결합도를 낮추고 유지보수에 유리하다.
- 데이터가 Business Layer로 전달되기 전에 데이터의 유효성 검사를 통해 미리 필터링 할 수 있으며 각 계층의 요구 사항에 맞게 데이터를 변환할 수 있다.
- 데이터의 유효성 검사에 대해서 역할을 분리할 수 있다는 것 또한 장점이 된다. 예를 들면 아래와 같다.
- Request DTO에서는 필드 형식, 유효한 포멧, Nullable 등 사용할 수 있는 데이터의 형식인지를 관점을 맞출 수 있다.
- Business Layer로 전달되는 DTO에서는 비즈니스 로직 관점에서 데이터를 필터링할 수 있다. 예를 들어 예약을 할 때 특정 요일에는 예약하지 못하도록 필터링 할 수 있다.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
// // Entity를 그대로 반환 (Worst)
// @PostMapping
// public ApiResponse<User> createUser(@RequestBody User user) {
// User createdUser = userService.createUser(user);
// return ApiResponse.ok(createdUser);
// }
// // Request와 Response를 DTO로 교환
// @PostMapping
// public ApiResponse<UserDto> createUser(@RequestBody @Valid CreateUserRequestDto createUserRequestDto) {
// UserDto userDto = userService.createUser(createUserRequestDto);
// return ApiResponse.ok(userDto);
// }
// Layer 별 DTO 분리
@PostMapping
public ApiResponse<UserResponseDto> createUser(@RequestBody @Valid CreateUserRequestDto createUserRequestDto) {
UserDto user = userService.createUser(createUserRequestDto);
UserResponseDto userDto = UserResponseDto.of(user);
return ApiResponse.ok(userDto);
}
}
Business Layer
Business Layer는 애플리케이션의 핵심 비즈니스 로직을 담고 있는 계층으로, Presentation Layer와 Persistence Layer 간의 중간 다리 역할을 한다.
주요 책임과 포인트
비즈니스 로직 구현 : 비즈니스 로직을 구현하고 일관성 있는 결과를 보장 및 예외 처리가 이루어져야 한다. 또한 해당 비즈니스 로직은 상위 및 하위 Layer의 세부 사항에 종속되어서는 안 된다.
트랜잭션 관리: 일련의 작업에 대한 트랜잭션 경계를 설정하고 관리하여 데이터의 일관성과 원자성을 유지해야 한다. 비즈니스 로직에 따라 트랜잭션 전략을 세워야 한다.
데이터 가공 및 검증 : Persistence Layer에서 받는 Entity를 그대로 노출해서는 안 되고 적절한 DTO로 변환하여 반환해야 한다. Presentation Layer에서 받는 파라미터 및 DTO에 대한 비즈니스적 관점에서의 검증이 이루어져야 한다.
트랜잭션 관리
@Transactional은 Spring에서 트랜잭션 경계를 설정하는 데 사용되며, 메서드 또는 클래스의 실행이 트랜잭션 내에서 수행되도록 보장한다.
readOnly = true로 설정된 트랜잭션은 데이터베이스에 쓰기 작업(INSERT, UPDATE, DELETE)을 방지하여 캐시 사용을 최적화하고, 락을 최소화하여 트랜잭션의 오버헤드를 줄일 수 있다. 서비스 환경에서 읽기 작업에 대한 요청이 70% 정도 이상을 차지하는 경우가 많다고 한다. 따라서 Read와 Write를 구분하여 적절한 트랜잭션을 적용하는 것은 성능 개선에 효과적이다.
@RequiredArgsConstructor
@Transactional(readOnly = true) // 기본 트랜잭션은 ReadOnly로 설정
@Service
public class OrderService {
//Write 작업에 대한 트랜잭션 설정
@Transactional
public OrderResponse createOrder(OrderCreateServiceRequest request, LocalDateTime registeredDateTime) {
// ...
// 저장
Order savedOrder = this.orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
private List<Product> findProductsBy(List<String> productNumbers) {
// 상품번호로 (중복 제거된) 상품들 조회
List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
Map<String, Product> productMap = products.stream()
.collect(Collectors.toMap(product -> product.getProductNumber(), p -> p));
// 중복되는 상품을 허용하는 리스트 만들기
List<Product> resultProducts = productNumbers.stream()
.map(productMap::get)
.collect(Collectors.toList());
return duplicateProducts;
}
// ...
}
CQRS (Command Query Responsibility Segregation)
CQRS는 명령(Command)와 조회(Query) 작업의 책임을 분리하여, 데이터 변경 작업과 조회 작업을 각각의 전용 모델로 구현하는 패턴이다. 이를 통해 읽기와 쓰기 작업을 독립적으로 최적화할 수 있으며, 특히 복잡한 도메인에서 유용하다.
@RequiredArgsConstructor
@Transactional(readOnly = true) // 기본 트랜잭션은 ReadOnly로 설정
@Service
public class OrderCommandService {
// ...
@Transactional
public OrderResponse createOrder(OrderCreateServiceRequest request, LocalDateTime registeredDateTime) {
// ...
// 저장
Order savedOrder = this.orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class OrderQueryService {
// ...
private List<Product> findProductsBy(List<String> productNumbers) {
// 상품번호로 (중복 제거된) 상품들 조회
List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
Map<String, Product> productMap = products.stream()
.collect(Collectors.toMap(product -> product.getProductNumber(), p -> p));
// 중복되는 상품을 허용하는 리스트 만들기
List<Product> resultProducts = productNumbers.stream()
.map(productMap::get)
.collect(Collectors.toList());
return duplicateProducts;
}
// ...
}
데이터베이스 또한 Command가 허용되는 Master DB와, Read 전용 Slave DB로 나누어서 사용할 수 있다.
키워드
분산 데이터베이스 동기화 : Master DB와 Slave DB간의 데이터의 동기화가 이루어져야 한다. Amazone Aurora의 Replica 기능 혹은 Kafka Source Connecting 등을 사용하여 분산 데이터베이스 환경을 구축할 수 있다.
다이나믹 데이터 소스 라우팅 : Spring 단에서 @Transactional의 readOnly 속성을 기반으로 자동으로 데이터베이스가 선택되도록 할 수 있다.
Persistence Layer
데이터베이스와 같은 영구 저장소에 데이터를 저장, 검색, 수정, 삭제하는 기능을 제공하는 계층이다.
애플리케이션의 다른 부분으로부터 데이터 저장소의 세부 구현을 숨기고, 데이터 접근을 추상화하여 제공할 수 있어야 한다.
주요 책임과 포인트
데이터 접근 집중과 비즈니스 로직과 분리 : 데이터베이스에 대한 CRUD 작업에 집중해야 한다. SQL 쿼리나 ORM, Mapper 등을 사용해 데이터를 접근하고 제어한다. Persistence Layer는 비즈니스 로직을 포함하지 않아야 한다.
다양한 데이터 접근 기술 고려 : 각 전략은 시스템의 요구사항, 성능, 유지보수성, 확장성 등을 기준으로 선택해야 한다. 가장 많이 사용되는 기술들은 JPA, MyBatis 등이 있다.
데이터 접근 집중과 비즈니스 로직과 분리
Worst Case
public interface UserRepository extends JpaRepository<User, Long> {
// 나쁜 예: 비즈니스 로직이 포함된 메서드
@Transactional
default User createUserWithBusinessLogic(String name, String email) {
if (findByEmail(email).isPresent()) {
throw new IllegalArgumentException("Email already exists");
}
User user = new User();
user.setName(name);
user.setEmail(email);
return save(user);
}
Optional<User> findByEmail(String email);
}
개선
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
// Service Layer
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User createUser(String name, String email) {
User user = new User();
user.setName(name);
user.setEmail(email);
return userRepository.save(user);
}
public Optional<User> findUserByEmail(String email) {
return userRepository.findByEmail(email);
}
}
다양한 데이터 접근 기술 고려
JPA (Java Persistence API)
- ORM 기반의 데이터 접근, RDBMS <-> OOP의 상이한 구조 간의 매핑을 자동으로 처리해준다. 이는 객체 지향적인 데이터 접근 로직을 작성할 수 있게 해 준다.
- Spring Data JPA, QueryDSL 등의 다양한 파생 기술들은 간단한 CRUD 작업을 자동으로 생성하거나 동적 쿼리 및 타입 체크 등 데이터 접근에 유용한 기능들을 제공한다. 이를 기반으로 개발자는 비즈니스 로직에 집중할 수 있다.
- JPA는 특정 데이터베이스에 종속되지 않기 때문에 이식성이 높고 유연하다.
- 유의 키워드: 지연 로딩, N+1 문제, 변경 감지, 영속성, 캐시
MyBatis
- 직접 SQL을 작성하여 매핑하는 데이터 접근 방식을 제공한다. 객체지향으로 작성하기 어려운 복잡한 쿼리나 데이터베이스에 특화된 기능을 사용할 때 유리하다.
- 복잡한 쿼리나 데이터베이스 성능을 극대화해야 하는 경우 선택을 고려할 수 있다.
'Backend > Spring' 카테고리의 다른 글
[Spring Boot] 트랜잭션 범위와 전파 속성(Propagation) (4) | 2024.08.26 |
---|---|
[Spring Boot] 테스트 코드 : 계층별 테스트 코드 작성 전략 (Controller, Service, Repository) (0) | 2024.08.05 |
[Spring Boot] 좋은 테스트 코드 : 필요성 및 실천 방법론 개요 (1) | 2024.07.22 |
[Spring Boot] 테스트 : Test Double : 모듈 의존성 격리(Mocking) (0) | 2024.05.16 |
[Spring Boot] 테스트 코드 : 시작하기 (JUnit) (0) | 2024.03.05 |