이번에 스프링 애플리케이션을 구현하면서 레이어드 아키텍처를 적용했다.
계층간 데이터 전달 방법에 대해 고민하게 되었는데,
내가 어떤 고민을 했고 어떻게 진행하고 있는지 정리해보고자 한다.
레이어드 아키텍처(Layered Architecture)
우선, 레이어드 아키텍처에 대해 알아보자.
소프트웨어 시스템을 관심사별로 여러개의 계층(레이어)으로 분리
한 아키텍처를 말한다.
크게 Presentation Layer
, Service Layer
, Data Access Layer
세 가지로 구분된다.
크게 세개의 계층으로 나누는것은 유사하나 표현이 조금씩 다르게 사용되기도 한다.
각 레이어에 대해 자세히 살펴보면 다음과 같다.
- Presentation Layer: 사용자 인터페이스(UI)와 상호작용하는 레이어이다. Controller가 해당 계층에 속한다. Controller는 직접적인 비즈니스 로직을 수행하는 것이 아니라 사용자의 요청을 받아오고, 사용자에게 결과를 응답하는 역할을 한다.
- Service Layer: 애플리케이션의 핵심 비즈니스 규칙과 로직을 처리한다. 예를 들어 회원 가입을 처리한다고 하자. 그러면 전달받은 데이터를 사용해 현재 중복되는 아이디가 있는지, 가입 가능한 상태인지 확인한 후 회원 가입을 처리할 수 있다.(DB에서 데이터를 조회하거나 추가하는걸 직접 한다는 뜻은 아니다. 비즈니스 로직 전체 흐름을 관리할 뿐 Data Acess Layer를 통해 데이터와 관련된 내용을 처리한다.) 비즈니스 전체 플로우를 담당한다.
- Data Access Layer: 데이터베이스나 다른 저장 매체와 상호작용하는 레이어이다. 실제로 데이터의 CRUD 작업을 처리한다.
이처럼 각 계층은 역할과 책임이 구분된다. 진행중인 과제에서 아직은 Service 레이어의 역할이 없어 개념이 완전히 와닿지는 않지만, 추후 추가 기능이 생기면 역할 구분이 확실해 질 것이라고 생각한다.
계층간 데이터 전달
그렇다면 레이어간 데이터 전달은 어떤 형태로 해야할까?
이 때 고려할 수 있는 것들이 도메인과 DTO이다.
- 도메인이란, 하나의 비즈니스 업무 영역과 관련된 개념이다. 실제 비즈니스 로직이나 규칙, 도메인 정보 등을 포함한다.
이번 글에서는 도메인과 엔티티 명칭을 혼용해서 사용했다.
(이 부분은 아직 개념이 명확하지 않아 추가로 학습할 예정이다.) - DTO는 이전에도 한번 다룬적이 있는데, 계층간의 데이터 전달을 위해 사용하는 객체이다.
예를들어, 쇼핑몰 웹 애플리케이션을 만들고 회원 가입 기능을 구현한다고 생각해보자.
아래와 같이 Member라는 클래스를 정의하였다. (@Entity 관련 애너테이션 및 기타 로직은 생략하였다.)
public class Member {
private Long id;
private String loginId;
private String password;
private String memberName;
private List<Cart> cartList;
private List<Orders> orderList;
private MemberType memberType;
private MemberRole memberRole;
...
}
회원 가입을 하기 위해 사용자로부터 이름, 아이디, 비밀번호 및 기타 정보를 받아와야 할 것이다.
반대로 회원정보를 조회할 때에는 이름, 아이디, 주문정보, 장바구니 등의 정보만 전달하고 비밀번호와 같은 민감 정보는 전달하지 않는다.
이처럼 상황에 따라 필요한 데이터가 달라질 수 있다.
DTO를 사용해 아래와 같이 필요한 정보만 담아 데이터 전달에 사용
할 수 있다.
(Java 14부터 Record 클래스를 지원하고 있으니 필요하면 알아보면 도움이 될 것 같다.)
public class MemberCreateRequest {
@NotBlank(message = "아이디를 입력해주세요.")
@Pattern(regexp = "^[a-zA-Z0-9]{4,12}$",
message = "아이디는 4~12 자리면이면서 한글, 영어, 숫자만 포함되어야 합니다.")
private String id;
@NotBlank(message = "비밀번호를 입력해주세요.")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_\\-+=])[a-zA-Z\\d!@#$%^&*()_\\-+=]{8,16}$",
message = "비밀번호는 8~16 자리이면서 1개 이상의 알파벳, 숫자, 특수문자가 포함되어야합니다.")
private String password;
@NotBlank(message = "이름을 입력해주세요.")
private String memberName;
}
}
또한 이번 글에서 다루지는 않았지만 spring-boot-starter-validation
을 사용하면
위와 같이 DTO 필드들에 대한 유효성 검증(@NotBlank, @Pattern 등)을 해줄수도 있다.
(올바르지 않은 값을 전달하면 예외 발생)
사용자로부터 필요한 데이터만 받아왔다면,
사용자로부터 전달받은 데이터(DTO)를 Controller -> Service -> Repository까지 쭉 사용하면 되는걸까?
아니면 어디선가 형태를 바꾸어서 전달해야할까?
이에 대해 여러가지 의견이 있다. 내가 고민해본것, 리뷰어와 얘기한 내용, 크루들과 토론했던 내용을 정리해보려고 한다.
미리 말하자면, 네가지 방식 중 어느것이 맞고 틀린것이 아니다. 각각의 장단점이 있으므로 상황에 맞게 사용해야한다.
▶︎ 학습을 하면서 정리해본 내용입니다. 잘못된 내용이 있거나 추가하고싶은 내용이 있다면 댓글 부탁드립니다.
1. Controller에서 받아온 DTO를 Repository 까지 사용
소규모 프로젝트에서는 Service/Repository에서 구현하고 있는 기능 혹은 도메인의 데이터가 많지 않을 수 있다.
DTO에는 필요한 정보만 담겨져 있기 때문에 해당 객체를 통해 필요한 데이터를 바로 사용할 수 있다.
데이터를 주고받는 절차가 간단해진다.
그러나 이 방식에는 문제점이 있다. Controller와 Service, Repository가 강하게 연관되어있다.
만약 API가 변경된다면 이에 따라 Controller, Service, Repository까지 관련된 코드를 모두 수정해야할 수도 있다.
2. Service 레이어에서 도메인으로 변환한 뒤 Repository로 전달
Service에서 DTO를 도메인으로 변경하고, Repository는 도메인을 받고, 도메인을 응답한다.
이렇게 되면 Repository는 DTO <-> Entity 변환 작업은 하지 않고 DB 관련 작업만 수행하게된다.
Service가 전체적인 비즈니스 플로우를 관리하고,
Repository는 온전히 데이터베이스와 소통하는 역할을 할 수 있게되었다.
다만, 문제가 될 수 있는 부분은 아직 Service가 Controller에 종속적이라는 점이다.
Controller가 받아오는 데이터에 따라 Service가 받는 데이터 형태가 결정된다.
Service가 원하는 방식으로 정보를 받지 못할 수 있다.
3. 레이어간 데이터를 전달할 때 별도의 DTO 사용
Controller가 받아온 DTO를 바로 Service 레이어 혹은 Repository로 전달하게 되면
하위 레이어가 받는 데이터가 상위 레이어에 종속적이게 된다.
레이어간 의존을 약화하기 위해 다음 레이어로 데이터를 전달할 때 계층마다 데이터 전송을 위한 별도의 DTO를 적용한다.
위 그림에서 색상이 다르면 변경된 것을 의미한다 (ex. 빨간색 DTO -> 주황색 DTO는 변경된 DTO임)
이처럼 Controller가 받아온 데이터를 Service가 필요로하는 내용만 담은 DTO로 변경해서 전달할 수 있다.
그러나 DTO <-> DTO 변환 작업이 추가된다.
이 또한 오버엔지니어링이 될 수 있으므로 애플리케이션 기능과 구조를 고려하여 적용해야한다.
4. Controller에서 Mapper 사용
Controller에서 Mapper 클래스를 사용해 DTO <-> Entity 변환 작업을 해준다.
DTO <-> Entity 변환 작업은 Mapper 클래스에 위임하고, Controller는 클라이언트 요청과 응답에 집중한다.
엔티티와 DTO의 의존관계가 줄어든다.( 둘 중 하나가 수정되면 mapper 클래스를 수정하면 된다.)
Service, Repository는 Entity 형태로 데이터를 주고받는다. 따라서 DTO 형태에 영향을 받지 않고 재사용성이 높아진다.
대신 이렇게 생성된 Entity는 불완전한 상태일 수 있어서 불완전한 Entity를 주고 받는게 적합한가 하는 의문이 든다.
(ex. DB 저장 전 Entity에는 id 값이 없다.)
엔티티를 생성하는 것 자체가 핵심 비즈니스 로직일 수 있다.
그런 경우 Entity 생성 로직이 Service 레이어가 아닌 Controller 레이어에서 이루어진다는 점이 애매할 수 있다.
나는 이번 프로젝트에서는 2번 방식을 적용했다. 적용한 이유는 다음과 같다.
1. 애플리케이션의 규모가 크지 않다.
3번 혹은 4번 방식을 적용하는 것은 오버엔지니어링이라고 생각했다.
2. 컨트롤러-서비스가 1:1 관계를 가지고 있다.
만약 하나의 서비스가 여러 컨트롤러에서 호출된다면, 서비스가 Entity를 받아야 컨트롤러별 API 형태와 관계 없이 동작할 것이다.
그러나 현재는 하나의 서비스는 하나의 컨트롤러에서만 사용되고 있고,
컨트롤러의 기능과 서비스의 기능 또한 1:1로 매칭되고 있어 원하는 형태의 데이터를 바로 받아오고 있다.
따라서 추가적인 DTO 변환 작업 없이 서비스까지 전달하기로 했다.
3. Repository는 데이터베이스와 상호작용하는 역할에 집중하고싶다.
그래서 DTO 변환은 미리 완료해서 전달하기로 했다.
만약 프로젝트 기능 혹은 규모가 커지거나 구조가 변경된다면 다른 방식을 고려할 수도 있다.
이 내용에 대해 리뷰어에게 질문했을 때 받은 답변을 공유하며 글을 마무리 하고자 한다.
dto 를 다루는 방식은 크루마다 다양한 것처럼 프로젝트마다, 그리고 팀마다 다 달라요.
그래서 개발할 프로젝트가 어떤 특성을 가지고 있는지 파악해보고, 유지보수를 할 인원들이 정하는게 제일 좋아요.
결론적으로, 여기서도 '완벽한 정답'은 없었다.
애플리케이션 기능, 구현 방식, 프로젝트 구조 혹은 팀/회사의 컨벤션에 따라 적절한 방식을 사용하면 된다.
"어떤 방법이 맞는걸까?"를 고민하기 보다 "현재 애플리케이션에 어떤 방식이 적절할까?"를 고민해보자.
참고자료
Entity To DTO: Conversion for a Spring REST API
우아한기술블로그 - 2. Controller와 Service 레이어의 강한 결합
Spring Entities should convert to Dto in service?
How to use DTOs in the Controller, Service and Repository pattern
'우아한테크코스 > 레벨 2 - Spring' 카테고리의 다른 글
[Spring, JUnit5] 테스트 격리: DB 초기화 (InitializingBean, BeforeEachCallback) (0) | 2024.05.25 |
---|---|
[JPA] @Embedded, @Embeddable 개념과 사용법 (0) | 2024.05.16 |
[Spring] HandlerMethodArgumentResolver 사용하기 (0) | 2024.05.09 |
[Spring] DTO 검증하고 결과 알려주기 with @Valid, @ExceptionHandler (0) | 2024.05.03 |
[Spring] @Controller와 @RestController 차이, 그리고 ResponseEntity (1) | 2024.04.17 |