티스토리 뷰
Facade 패턴 적용 이유
Service가 Controller의 DTO에 의존
개인 프로젝트를 진행하고 있던 중 고민인 부분이 생겼다. 바로 Controller - Service - Repository 계층에서 요청 또는 응답 DTO가 어디까지 진입 가능할지, 어디서부터 만들어져 나갈지에 대한 것이다. (참고로 여기서 말하는 DTO는 계층 간 DTO가 아닌 사용자 요청으로부터 오는 DTO 혹은 사용자에게 응답으로 전달되는 DTO를 말한다. 즉 Controller의 인자 혹은 반환값으로 전달되는 DTO) 기존에는 해당 블로그를 참고하여 DTO가 Service Layer까지는 들어오고 해당 계층에서 나갈 수 있도록, Entity ↔ DTO 간 변환은 별도의 Mapper 클래스가 수행하도록 구현하였다.
따라서 새로운 유저를 등록하는 로직(회원가입)의 UserController
, UserService
, UserMapper
코드는 대강 다음과 같았다.
@RequestMapping("/api/users")
@RequiredArgsConstructor
@RestController
public class UserController {
private final UserService userService;
@PostMapping
public UserJoinResponse joinUser(@RequestBody @Valid UserJoinRequest request) {
return userService.joinUser(request);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {
private final UserMapper userMapper;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public UserJoinResponse joinUser(UserJoinRequest request) {
checkForUsername(request.getUsername()); //username 중복 여부 확인
String encodedPassword = passwordEncoder.encode(password); //비밀번호 인코딩
User user = userMapper.toEntity(request, encodedPassword);
User savedUser = userRepository.save(user);
userMapper.toResponse(savedUser);
}
public void checkForUsername(String username) {
if (userRepository.existsByUsername(username)) {
throw new BusinessException(ALREADY_EXISTS_USERNAME);
}
}
}
@Component
public class UserMapper {
public User toEntity(UserJoinRequest request, String encodedPassword) {
return User.builder()
.username(request.getUsername())
.password(encodedPassword)
.birthDate(request.getBirthDate())
.build();
}
public UserJoinResponse toResponse(User user) {
return UserJoinResponse.builder()
.userId(user.getId())
.username(user.getUsername())
.birthDate(user.getBirthDate())
.build();
}
}
기존 코드에서 마음에 들지 않던 부분이 바로 핵심 비즈니스 로직을 처리하는 Service가 Controller의 DTO 즉 사용자의 요청, 사용자를 위한 응답에 강하게 결합되어 있다는 것이었다. 이 경우 대부분의 Service 메소드 상단에서 사용자의 요청, 위 코드에서는 UserJoinRequest
에 대해 본격적인 비즈니스 로직을 처리하기 전 요청을 검증하는 로직이 필요하게 되었다. (단순한 값 검증이 아닌 DB Connection이 필요한 검증 ex. 요청한 계정명이 이미 존재하는지 검증) 물론 요청을 검증하는 부분도 핵심 비즈니스 로직 중 하나로 볼 순 있다.
이외에도 정말 단순한 변경 예를 들어 만약 요청 DTO의 필드명이 username
→ userName
으로 변경되면 UseService
의 코드도 같이 변경해줘야 한다. 또한 Service가 별도의 DTO나 엔티티가 아닌 사용자 요청 DTO를 인자로 받고, 사용자 응답 DTO를 반환하기 때문에 해당 DTO 변경에 따라 관련 Service의 테스트 코드도 변경해줘야 했다. 즉 Controller의 DTO가 변할 때마다 계속해서 Service 쪽 코드를 확인해야 하는 상황이 발생하는 것이다. (Facade 패턴을 적용해도 100% 해결되는 문제는 아니다...)
@DisplayName("유저 회원가입 서비스 테스트 : 성공")
@Test
void joinUser() {
//given
String username = "testUsername";
String password = "testPassword";
UserJoinRequest request = new UserJoinRequest(username, password); //Controller의 요청 DTO
given(userRepository.existsByUsername(anyString())).willReturn(false);
//when
UserJoinResponse resepon = userService.joinUser(request); //Controller의 응답 DTO
//then
then(userRepository).should(times(1)).existsByUsername(anyString());
then(userRepository).should(times(1)).save(userCaptor.capture());
assertThat(resepon.getUsername()).isEqualTo(username);
assertThat(userCaptor.getValue().getPassword()).startsWith("{bcrypt}");
}
가장 큰 문제는 Service가 특정 사용자 요청 및 응답 DTO에 강하게 결합되어 있기 때문에 재활용성이 떨어지게 된다는 것이다. UserService
내 메소드는 User
도메인 뿐만 아니라 Post
, Comment
도메인 등 다른 도메인에서도 호출될 수 있는데, 이때 DTO를 반환하는 메소드는 다른 도메인에서 호출될 수 없다. 또한 API 마다 응답 DTO가 다르기 때문에 이로 인해 비슷한 로직을 처리하되 단순히 처리 결과를 서로 다른 DTO로 변환하여 반환하는 메소드를 여러 개 만들게 된다. 즉 불필요한 코드 중복이 발생하며 응답 DTO 개수가 증가함에 따라 Service도 비대해질 수 있다는 것이다.
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {
private final UserMapper userMapper;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserDetailsResponse getUserDetails(String userId) {
User user = getUser(userId);
return userMapper.toResponse(user);
}
public UserShortDetailsResponse getUserShortDetails(String userId) {
User user = getUser(userId);
return userMapper.toShortResponse(user);
}
public User getUser(String userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(NOT_FOUND_USER));
}
}
일단 여기까지 정리하자면 현재 프로젝트에 Facade 패턴을 적용하기로 마음먹은 첫번째 이유는 Service 계층이 사용자 요청 및 응답 DTO에 강하게 결합되어 있기 때문이다. 다음 두번째 이유는 특정 비즈니스 로직을 처리하기 위해 여러 도메인 서비스가 필요한 상황에 대한 것이다. 예를 들어 "사용자 등급이 골드 이상인 경우에만 게시글을 작성할 수 있다"라는 로직을 구현하기 위해서는 PostService
, UserService
가 필요할 것이다. 이러한 상황에 대해 기존 코드는 다음과 같이 PostService
가 UserService
를 의존하도록 구현하였다.
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {
private final PostMapper postMapper;
private final PostRepository postRepository;
private final UserService userService;
@Transactional
public PostCreateResponse createPost(String userId, PostCreateRequest request) {
User user = userService.getValidUser(userId, Rating.GOLD); //등급이 검증된 유저 엔티티 반환
Post post = postMapper.toEntity(request, user);
Post savedPost = postRepository.save(post); //게시글 저장
return postMapper.toResponse(savedPost);
}
}
사실 여러 도메인이 포함된 비즈니스 로직을 처리하는 방식에는 여러 가지가 있다. 위 코드처럼 중심 도메인 Service(PostService
)가 다른 도메인 Service(UserService
)를 의존하도록 하는 방법이 있으며, 이 외에도 하나의 Controller(PostController
)가 여러 Service(PostService
, UserService
)를 의존하는 방법, 하나의 Service(PostService
)가 여러 Repository(PostRepository
, UserRepository
)를 의존하는 방법이 있다. 왜 이 2가지 방법이 아닌 Service 간에 의존관계를 두는 방법을 선택했는지 이유를 정리해보고, 다시 기존에 적용했던 Service 간에 의존관계를 두는 방법이 어떤 부분에서 문제가 있길래 Facade 패턴을 적용하려고 하는지 마지막으로 살펴보겠다.
여러 도메인이 포함된 비즈니스 로직 처리 문제
하나의 Controller 내 여러 Service 의존
@RequestMapping("/posts")
@RequiredArgsConstructor
@RestController
public class PostController {
private final PostService postService;
private final UserService userService;
//참고로 @CurrentUserId는 현재 로그인한 유저의 id를 주입해주는 커스텀 어노테이션
@PostMapping
public PostCreateResponse createPost(@CurrentUserId String userId, PostCreateRequest request) {
User user = userService.getValidUser(userId, Rating.GOLD);
return postService.createPost(user, request);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {
private final UserRepository userRepository;
public User getValidUser(String userId, Rating rating) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(NOT_FOUND_USER));
if (!user.getRating().validate(rating)) {
throw new BusinessException(FORBIDDEN_USER_RATING);
}
return user;
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {
private final PostMapper postMapper;
private final PostRepository postRepository;
@Transactional
public PostCreateResponse createPost(User user, PostCreateRequest request) {
Post post = postMapper.toEntity(request, user);
Post savedPost = postRepository.save(post);
return postMapper.toResponse(savedPost);
}
}
이 경우 PostService
의 createPost
메소드의 크기가 줄어들었으므로 좋은거 아닌가 싶을 수 있지만 오산이다. 먼저 첫번째 문제는 Controller 내에서 처리 순서에 맞게 적절한 Service를 호출해줘야 하므로 Controller에도 비즈니스 로직이 일부 포함되게 된다는 것이다. 위 코드에서 PostController
는 게시글 작성 로직이 "현재 유저가 골드 이상이라는 것을 검증 → 게시글 저장"으로 구성된다는 것을 알고 있다. 첫번째 문제는 비즈니스 로직이 크게 복잡하지 않다면 감안하고 넘어갈 수 있는 부분이지만 두번째 문제는 심각한 오류도 발생시킬 수 있다.
일반적으로 트랜잭션 즉 @Transactional
어노테이션은 Service 단위로 붙는다. 그리고 이 상태에서 OSIV 설정을 끄게 되면 Service 메소드를 기준으로 DB Connection이 열리고 닫힌다. 즉 OSIV 설정을 끈 상태로 위 코드를 실행하면 userService.getValidUser
메소드에서 1번, postService.createPost
메소드에서 1번, 총 2번 DB Connection이 열리고 닫힌다. 따라서 실시간 요청을 처리하는 서비스의 경우 성능에 악영향을 끼칠 수 있다.
또한 만약 userService.getValidUser
메소드가 리소스를 변경하거나 생성하는 기능을 한다면 postService.createPost
에서 예외가 발생해도 userService.getValidUser
에서의 데이터 변경이 롤백되지 않아 데이터 불일치 문제가 발생하게 된다.(트랜잭션이 각각 묶이므로) 즉 "나는 계좌이체로 돈을 보냈는데, 친구는 돈을 받지 못한 상황" 같이 데이터 무결성이 깨지게 된다.
위와 같은 치명적인 이유로 해당 방법은 사용하지 않는 것을 권장한다.
하나의 Service 내 여러 Repository 의존
@RequestMapping("/posts")
@RequiredArgsConstructor
@RestController
public class PostController {
private final PostService postService;
@PostMapping
public PostCreateResponse createPost(@CurrentUserId String userId, PostCreateRequest request) {
return postService.createPost(userId, request);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {
private final PostMapper postMapper;
private final PostRepository postRepository;
private final UserRepository userRepository;
@Transactional
public PostCreateResponse createPost(String userId, PostCreateRequest request) {
User user = getValidUser(userId, Rating.GOLD);
Post post = postMapper.toEntity(request, user);
Post savedPost = postRepository.save(post);
return postMapper.toResponse(savedPost);
}
public User getValidUser(String userId, Rating rating) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(NOT_FOUND_USER));
if (!user.getRating().validate(rating)) {
throw new BusinessException(FORBIDDEN_USER_RATING);
}
return user;
}
}
이 경우에는 그래도 하나의 비즈니스 로직을 하나의 트랜잭션으로 묶을 수 있어 데이터 무결성이 깨지는 등의 문제는 발생하지 않는다. 그러나 위 코드의 문제점은 재사용성이 떨어져 여러 중복 코드가 발생할 수 있다는 것이다. getValidUser
와 같이 특정 유저의 등급을 확인하는 로직은 Post
뿐만 아니라 Comment
, Order
등의 도메인에서도 사용될 수 있다. 그러나 CommentService
, OrderService
는 UserRepository
만 의존할 수 있으므로 내부에 같은 구현 코드를 각각 작성해야 한다. 또한 Post
도메인에서 User
도메인의 비즈니스 로직까지 모두 처리하고 있는 것이 SRP 관점에서 그리 좋아보이진 않는다.
Service 간 의존
@RequestMapping("/posts")
@RequiredArgsConstructor
@RestController
public class PostController {
private final PostService postService;
@PostMapping
public PostCreateResponse createPost(@CurrentUserId String userId, PostCreateRequest request) {
return postService.createPost(userId, request);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {
private final PostMapper postMapper;
private final PostRepository postRepository;
private final UserService userService;
@Transactional
public PostCreateResponse createPost(String userId, PostCreateRequest request) {
User user = userService.getValidUser(userId, Rating.GOLD);
Post post = postMapper.toEntity(request, user);
Post savedPost = postRepository.save(post);
return postMapper.toResponse(savedPost);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {
private final UserRepository userRepository;
public User getValidUser(String userId, Rating rating) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(NOT_FOUND_USER));
if (!user.getRating().validate(rating)) {
throw new BusinessException(FORBIDDEN_USER_RATING);
}
return user;
}
}
위의 2가지 방법은 데이터 무결성 깨짐, 많은 코드 중복 발생이라는 치명적인 이유로, 현재 도메인 Service 내에서 다른 도메인의 Service를 의존하는 방법을 사용해왔다. 그러나 여전히 몇몇 문제는 존재한다.
먼저 순환 참조 문제가 발생할 수 있다는 것이다. 만약 위 코드에서 PostService
가 UserService
를 의존하면서 동시에 UserService
가 PostService
또한 의존하게 되면 순환 참조로 인해 다음과 같은 예외가 발생하면서 어플리케이션 실행이 중단된다.
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
물론 순환 참조를 허용하도록 설정할 수 있다고 스프링이 친절하게 알려주지만, 순환 참조의 경우 변경에 따른 영향의 범위가 커져 권장되지 않는다.
이외에도 무엇보다 Service 간 의존관계를 가지도록 구현하게 되면 한 도메인의 Service 내 메소드가 2가지 종류로 구분되게 된다. 즉 같은 도메인의 Controller에서 사용되는 메소드와 다른 도메인의 Service에서도 사용될 수 있는 메소드로 구분되는 것이다. 이는 SRP 원칙에 위배될 수도 있으며 다른 도메인에서는 호출될 수 없는 Service 메소드(해당 도메인 API의 요청, 응답 DTO에 결합되어 있기 때문)가 public으로 열려있다는 부분에서 여럿이서 개발 시 혼동을 줄 수 있겠다고 생각했다.
자 이러한 이유 "Service 내에서 사용자 요청 및 응답 DTO에 강하게 의존" + "여러 도메인이 포함된 비즈니스 로직 처리 문제"로 Controller - Service 계층 사이에 FacadeService를 추가하여 리팩토링하기로 마음 먹었다. 이렇게 구성된 계층 구조는 "한 도메인의 Controller - 한 도메인의 FacadeService - 여러 도메인의 Service" 식으로 구성된다. 리팩토링을 정확히 어떻게 수행했는지, 이 과정에서 느낀 해당 구조의 장단점을 정리해보고자 한다.
(Facade 패턴에 대한 개념은 다음을 참고)
Facade 패턴 적용
적용 방법
먼저 기존 프로젝트는 도메인 별로 패키지가 구분되어 있는 상태였다. 예를 들어 post
, comment
, user
패키지로 분리한 후 각 도메인 패키지 아래에 controller
, service
, repository
패키지로 다시 구분하였다. 이 상태에서 각 도메인 별 FacadeService 즉 PostFacadeService
, CommentFacadeService
, UserFacadeService
는 service
패키지 아래에 위치시켰다. 그리고 한 도메인의 FacadeService 내부에서는 동일한 도메인의 Mapper, 그리고 여러 도메인의 Service를 의존하도록 하였다.
결과적으로 한 도메인의 Controller는 동일한 도메인의 FacadeService만을 의존하게 되며 한 도메인의 Service는 동일한 도메인의 Repository만을 의존하게 된다. 즉 아래 그림과 같은 구조가 만들어지게 된다. 또한 사용자 요청 DTO가 Service에는 넘어가지 않도록 FacadeService에서 의존하고 있는 Mapper가 엔티티 또는 별도의 DTO로 변환하여 Service에게 넘겨주도록 하였으며, 반대로 Service 계층에서 엔티티 또는 별도의 DTO를 반환하면 Mapper가 사용자 응답 DTO로 변환하도록 하였다.
리팩토링 전후의 전체 프로젝트 계층 구조는 다음과 같다.
기존 구조
적용 후 구조
이렇게 전체 프로젝트 구조를 설정한 후 하나의 비즈니스 로직을 처리하기 위해 어떻게 FacadeService와 Service를 구성할지 고민하였다. 일단 FacadeService의 역할은 하나의 핵심 비즈니스 로직을 처리하기 위해 여러 Service 등을 조합하는 것으로 정의를 하였다. 예를 들어 "주문 생성" 비즈니스 로직이 있을 때, 이 로직은 "주문 검증 → 결제 수행 → 주문 저장" 이라는 세부 로직으로 나누어질 수 있다. 즉 나누어진 각 로직의 상세 구현은 각자 도메인의 Service에게 위임하며 이 Service를 적절한 순서로 호출하면서 조합하는 역할은 FacadeService에게 있는 것이다.
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class OrderFacadeService {
private final OrderMapper orderMapper;
private final OrderService orderService;
private final PaymentService paymentService;
@Transactional
public OrderCreateResponse placeOrder(OrderCreateRequest request) {
Order order = orderMapper.toEntity(request);
//주문 검증
orderService.validate(order);
//결제 수행
paymentService.pay(order.getPayment());
//주문 저장
Order savedOrder = orderService.save(order);
return orderMapper.toResponse(savedOrder);
}
}
Facade 패턴 적용 결과
예시 코드1
처음에 보여주었던 유저 회원가입 로직 또한 세부 로직으로 구분될 수 있다. 즉 "유저의 계정명 중복 확인 → 비밀번호 인코딩 → 유저 저장" 이라는 3가지 로직으로 구성된다. Facade 패턴을 적용한 결과는 다음과 같다.
@RequiredArgsConstructor
@RequestMapping("/users")
@RestController
public class UserController {
private final UserFacadeService userFacadeService;
@PostMapping
public UserJoinResponse joinUser(@RequestBody @Valid UserJoinRequest request) {
return userFacadeService.joinUser(request);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserFacadeService {
private final UserMapper userMapper;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@Transactional
public UserJoinResponse joinUser(UserJoinRequest request) {
//유저의 계정명 중복 확인
userService.checkForUsername(request.getUsername());
//비밀번호 인코딩
String encodedPassword = passwordEncoder.encode(request.getPassword());
User user = userMapper.toEntity(request, encodedPassword);
//유저 저장
User savedUser = userService.saveUser(user);
return userMapper.toResponse(savedUser);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {
private final UserRepository userRepository;
private static final String ENCODED_PASSWORD_PREFIX = "{bcrypt}";
@Transactional
public void saveUser(User user) {
//유저 저장 전 비밀번호 인코딩 여부 확인
if (!user.getPassword().startsWith(ENCODED_PASSWORD_PREFIX)) {
throw new BusinessException(PASSWORD_ENCODING_ERROR);
}
userRepository.save(user);
}
public void checkForUsername(String username) {
if (userRepository.existsByUsername(username)) {
throw new BusinessException(ALREADY_EXISTS_USERNAME);
}
}
}
"유저의 계정명 중복 확인", "비밀번호 인코딩", "유저 저장" 3가지 세부 로직의 상세 구현 그러니깐 계정명 중복을 어떤 식으로 확인할지, 비밀번호를 어떻게 인코딩할지, 유저 정보는 어디에 저장할지 등은 FacadeService의 관심 사항이 아니다.
예시 코드2
이제는 하나의 비즈니스 로직을 처리하기 위해 여러 도메인이 관여하는 경우의 예제를 보자. 앞서 언급했던 "등급이 골드 이상인 유저만 게시글을 작성할 수 있는 로직" 또한 여러 세부 로직으로 구성된다. "현재 유저의 등급이 골드 이상인지 검증 → 게시글 저장"으로 구분될 수 있는데 너무 간단하므로 게시글 내용에 비방어, 욕설 등이 포함되어 있는지 확인하는 기능도 추가해보자. 결과적으로 해당 로직은 "현재 유저의 등급이 골드 이상인지 검증 → 게시글 내용 검증 → 게시글 저장"으로 나눠지게 된다. Facade 패턴을 적용한 결과는 다음과 같다.
@RequestMapping("/posts")
@RequiredArgsConstructor
@RestController
public class PostController {
private final PostFacadeService postFacadeService;
@PostMapping
public PostCreateResponse createPost(@CurrentUserId String userId, PostCreateRequest request) {
return postFacadeService.createPost(userId, request);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostFacadeService {
private final PostMapper postMapper;
private final PostService postService;
private final UserService userService;
@Transactional
public PostCreateResponse createPost(String userId, PostCreateRequest request) {
//현재 유저의 등급이 골드 이상인지 검증
User user = userService.getValidUser(userId, Rating.GOLD);
Post post = postMapper.toEntity(request, user);
//게시글 내용 검증
postService.validateContent(post);
//게시글 저장
Post savedPost = postService.save(post);
return postMapper.toResponse(savedPost);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {
private final UserRepository userRepository;
public User getValidUser(String userId, Rating rating) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(NOT_FOUND_USER));
if (!user.getRating().validate(rating)) {
throw new BusinessException(FORBIDDEN_USER_RATING);
}
return user;
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class PostService {
private final PostRepository postRepository;
public void validateContent(Post post) {
if (!validate(post.getContent()) {
throw new BusinessException(INVALID_POST_CONTENT)
}
}
@Transactional
public post save(Post post) {
return postRepository.save(post);
}
private void validate(String content) {
//...
}
}
현재는 " 게시글 내용 검증", "게시글 저장" 로직을 따로 분리하였지만 게시글 저장 시 내용 검증을 강제하고 싶다면 이를 하나의 로직으로 보고 "현재 유저의 등급이 골드 이상인지 검증 → 게시글 저장" 2개의 세부 로직으로 구성해도 될 것이다.
장점
Facade 패턴을 적용한 후 내가 느낀 장점은 다음과 같다.
- Service에는 Controller 계층의 DTO(사용자 요청 및 응답 DTO)가 넘어오지 않음
이에 따라 다른 도메인에서도 해당 Service 메소드를 호출할 수 있어 Service의 재사용성이 증가한다. 또한 기존 코드에서는 이 사용자 요청 DTO가 Repository까지 넘어오는 경우가 존재했는데, 이 또한 Service로 넘겨주기 전 별도의 DTO로 변환해줌으로써 문제를 해결하였다.
사실 Facade 패턴을 적용했기 때문에 얻을 수 있는 장점은 아니고 FacadeService 없이도 사용자 요청 DTO를 Controller에서 직접 Service를 위한 DTO로 변환해주어도 된다. 그러나 이러한 변환 처리를 별도의 FacadeService 내에서 하도록 계층을 분리했다는 점에서는 의미가 있다고 생각한다.
- 계층 간, 도메인 간 의존관계가 깔끔해짐
각 객체의 역할이 더 분명해지면서 계층 간 의존관계도 깔끔해졌다. 즉 Controller는 웹 요청 및 응답과 관련된 부분을 처리, Service는 세부적인 비즈니스 로직을 처리, FacadeService는 이러한 Service를 조합하여 하나의 핵심 비즈니스 로직을 처리, Mapper는 데이터 간 변환을 처리하는 역할만을 담당하게 되었다. 따라서 변경 사항 발생 시 어떤 클래스를 수정해야 할지 명확히 알 수 있으며 변경에 따른 영향 범위도 줄어들었다. 또한 FacadeService가 다른 도메인의 FacadeService는 의존할 수 없고 Service 또는 Mapper만 의존할 수 있기 때문에 Service 간의 의존관계로 인해 발생할 수 있었던 순환 참조 문제도 자동으로 해결되었다.
- 효율적인 캐싱이 가능해짐
예를 들어 지난 일주일 간(2023.12.14 ~ 2023.12.20)의 지출 내역에 대한 통계값을 캐싱한 경우 해당 날짜 범위의 지출 내역이 추가되거나 수정 및 삭제되면 캐싱 데이터를 삭제해줘야 한다. 추가의 경우 요청 DTO 자체에 지출일 데이터가 있어 @CacheEvict
조건을 간단하게 지정해줄 수 있지만, 수정이나 삭제의 경우 기존 데이터가 해당 날짜 범위에 포함되는지 여부를 요청값만으로는 알 수 없어 DB 조회 결과를 억지로 반환해줘야 했다. 이는 스프링의 캐시가 AOP로 동작하기 때문이다. 따라서 기존의 지출 내역 삭제 Service 메소드는 다음과 같이 DB에서 조회된 지출 엔티티의 지출일 데이터를 반환해줘야 했다. (실제로 반환값을 사용하지 않음에도 불구하고)
기존 코드
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ExpenditureService {
private final ExpenditureRepository expenditureRepository;
@CacheEvict(cacheNames = CACHE_NAME, key = "#userId", condition = "#root.target.containsAWeekAgo(#result)") //SpEL 표현식
@Transactional
public LocalDateTime deleteExpenditure(String userId, Long expenditureId) {
Expenditure expenditure = getValidExpenditure(userId, expenditureId);
expenditure.softDelete();
return expenditure.getExpenditureDate(); //캐싱을 위해 지출일 데이터 반환
}
public boolean containsAWeekAgo(LocalDateTime expenditureDate) {
LocalDate now = LocalDate.now();
LocalDateTime startDateTime = now.minusDays(7).atStartOfDay();
LocalDateTime endDateTime = now.atStartOfDay();
return (expenditureDate.isEqual(startDateTime) || expenditureDate.isAfter(startDateTime)) &&
expenditureDate.isBefore(endDateTime);
}
public Expenditure getValidExpenditure(String userId, Long expenditureId) {
Expenditure expenditure = getExpenditure(expenditureId);
if (!Objects.equals(expenditure.getUser().getId(), userId)) {
throw new BusinessException(FORBIDDEN_EXPENDITURE);
}
return expenditure;
}
}
리팩토링 후 코드
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ExpenditureFacadeService {
private final ExpenditureMapper expenditureMapper;
private final ExpenditureService expenditureService;
@Transactional
public void deleteExpenditure(String userId, Long expenditureId) {
Expenditure expenditure = expenditureService.getValidExpenditure(userId, expenditureId);
expenditureService.deleteExpenditure(expenditure);
}
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ExpenditureService {
private final ExpenditureRepository expenditureRepository;
//캐싱 조건을 Expenditure 엔티티 인자값으로 판단할 수 있으므로 반환 타입 void 가능
@CacheEvict(cacheNames = CACHE_NAME, key = "#userId", condition = "#root.target.containsAWeekAgo(#expenditure.expenditureDate)") //SpEL 표현식
@Transactional(propagation = Propagation.MANDATORY)
public void deleteExpenditure(Expenditure expenditure) {
expenditure.softDelete(); //Expenditure 엔티티의 deleted 필드를 true로 설정
}
public boolean containsAWeekAgo(LocalDateTime expenditureDate) {
LocalDate now = LocalDate.now();
LocalDateTime startDateTime = now.minusDays(7).atStartOfDay();
LocalDateTime endDateTime = now.atStartOfDay();
return (expenditureDate.isEqual(startDateTime) || expenditureDate.isAfter(startDateTime)) &&
expenditureDate.isBefore(endDateTime);
}
public Expenditure getValidExpenditure(String userId, Long expenditureId) {
Expenditure expenditure = getExpenditure(expenditureId);
if (!Objects.equals(expenditure.getUser().getId(), userId)) {
throw new BusinessException(FORBIDDEN_EXPENDITURE);
}
return expenditure;
}
}
리팩토링 후 FacadeService에서 해당 지출 엔티티를 조회한 후 Service 메소드의 인자로 조회된 엔티티를 넘겨주기 때문에 반환 타입을 void로 바꾼 후 @CacheEvict
조건도 깔끔하게 지정할 수 있게 되었다.
그러나 ExpenditureFacadeService
의 deleteExpenditure
메소드에서 트랜잭션을 하나로 묶어주지 않은 상태로 OSIV를 끄게 되면 expenditureService.deleteExpenditure
메소드 내에서 발생하는 Soft Delete 즉 엔티티 값 변경에 따른 Dirty Checking이 제대로 수행되지 않아 update 쿼리가 생성되지 않는다. 이는 Expenditure
엔티티를 조회하는 expenditureService.getValidExpenditure
메소드에서의 영속성 컨텍스트가 expenditureService.deleteExpenditure
수행 시까지 유지되지 않기 때문이다. (다시 말해 expenditureService.deleteExpenditure
메소드의 인자로 전달된 Expenditure
엔티티가 영속화(persist
)된 상태가 아니기 때문)
따라서 Dirty Checking이 제대로 동작하지 않는 상황을 막기 위해 expenditureService.deleteExpenditure
메소드에는 @Transactional(propagation = Propagation.MANDATORY)
트랜잭션 전파 옵션을 설정해주었다. (MANDATORY
옵션: 트랜잭션이 존재할 경우 해당 트랜잭션을 이용하며, 존재하지 않을 경우에는 예외 발생) 이렇듯 (뒤에서 이야기 하겠지만) FacadeService 계층 추가 시 트랜잭션 중첩이 발생하면서 트랜잭션 관리에 대한 책임이 생긴다.
단점
사실 리팩토링을 하던 중 내 뇌가 스파게티가 되어 버릴 지경이라 지금까지 해온 것을 reset하고 리팩토링하지 않기로 결심했었다. 그냥 관둘 순 없고 몇가지 합리적인 이유들을 생각해보았는데 아래는 이 이유들 그리고 패턴을 적용하고 난 후 느끼는 몇가지 단점들을 정리한 것이다.
- 핵심 비즈니스 처리 로직을 FacadeService, Service로 분리함으로써 전체 로직 파악이 어려움
FacadeService를 구현하기 위해서는 일단 핵심 비즈니스 처리 로직을 세부 로직으로 나누는 과정이 필요하다. 그러나 항상 로직이 깔끔하게 분리되는 것은 아니다. 애초에 로직이 너무 작은 경우에는 FacadeService가 한 도메인의 Service 메소드만을 단순 호출하여 FacadeService가 불필요하게 끼어있는 계층이 되어버릴 수 있으며, 로직이 너무 꼬여서 복잡한 경우에는 세부 로직으로 나누기 쉽지 않을 뿐더러 어찌저찌 나누었다 하더라도 전체 로직을 파악하기가 어려워질 수 있다.
- 트랜잭션 관리의 어려움 + 중복되는 DTO 증가
어떻게 보면 Business 계층을 FacadeService와 Service 2개의 계층으로 나누었기 때문에 FacadeService, Service 계층 모두 @Transactional
어노테이션을 신경써서 붙여줘야 한다. 즉 중첩되는 트랜잭션을 더 신경써서 관리해줘야 한다. 만약 FacadeService 메소드에서는 @Transactional(readOnly = true)
로 읽기 전용 트랜잭션을 열어주었지만 Service에서는 쓰기 작업을 한다면 다음과 같은 오류가 발생할 수 있다.
그러나 이 문제는 기존 Service 간 의존관계를 맺는 경우에도 발생할 수 있는 문제이긴 한다. 다음으로는 단순히 FacadeService ↔ Service 계층 간 데이터 전달을 위해 별도의 DTO를 정의함으로써 중복되는 DTO가 증가한다는 단점이 있다. 그러나 현재는 DTO가 중복되지만 미래에는 어떤 변경 사항이 발생할지 모를 일이므로 자연히 해결될 수 있는 문제이다.
- 기존 코드(테스트 코드 포함)에 대한 변경이 매우 많이 발생
개인적으로 가장 큰 단점이라고 생각한다. 이미 서비스가 안정적으로 운영되며 규모가 큰 프로젝트의 경우 리팩토링이 부담될 수 있다. 특히 계층 간 의존관계가 강하게 결합되어 있는 경우 매우매우 많은 코드를 수정해야 할 수 있다. 특히 테스트 코드까지 많이 작성한 상태라면 이 코드 또한 같이 변경해줘야 한다.
정리
애초에 현재 프로젝트 규모가 크지 않아 전체 구조를 리팩토링하는 것이 크게 부담이진 않았는데, 통계 로직이나 복잡한 조회 로직 등을 최대한 코드 중복이 발생하지 않도록, 가독성 있게 작성하는 것이 약간 어려웠다. 답이 없는 문제이다 보니 이렇게도 바꿔보고 저렇게도 바꿔보면서 코드는 점점 알아볼 수 없는 지경이 되었는데, 마지막에는 이게 일단 지금 상황에서는 최선이다를 되뇌며 리팩토링을 멈추었다.
만약 빠르게 개발해야 하는 상황이었다면, 큰 규모의 서비스였다면 굳이 Facade 패턴을 적용하진 않았을 것 같다. 그러나 항상 개발을 하면서 비즈니스 로직에 Controller의 DTO가 침범하는 것이 내심 마음에 들지 않았었는데, 이번 기회에 직접 리팩토링해봄으로써 Facade 패턴의 장단점을 직접 느껴볼 수 있어서 좋은 경험이었다고 생각한다. 또한 기존 테스트 코드들 때문에 리팩토링이 힘들어지기도 했지만 또 덕분에, 놓칠 수 있던 부분들도 신경 쓸 수 있었다는 점에서 테스트 코드의 중요성을 다시 한번 깨닫게 되었다.
끝.
참고
https://tecoble.techcourse.co.kr/post/2021-04-25-dto-layer-scope/
https://gose-kose.tistory.com/29
https://velog.io/@lsj8367/Facade-Pattern-%EC%A0%81%EC%9A%A9%EA%B8%B0
'Spring > Spring' 카테고리의 다른 글
[Spring] Link 헤더를 이용한 REST Pagination 구현 (0) | 2024.01.14 |
---|---|
[Spring] AOP로 공통 응답 형식 처리하기 (0) | 2024.01.14 |
[Spring Boot] 날짜 데이터를 요청 및 응답으로 주고 받는 여러 가지 방법 (0) | 2023.11.23 |
[Querydsl] Spring Boot 3.x with Gradle 설정 (1) | 2023.10.20 |
[개념] FrontController 패턴 (with 스프링) (0) | 2023.09.05 |
- Total
- Today
- Yesterday
- Linux
- Transaction
- 템플릿 콜백 패턴
- spring
- mockito
- github
- junit5
- servlet filter
- Git
- Assertions
- 단위 테스트
- vscode
- 서블릿 컨테이너
- JPA
- Spring Security
- 디자인 패턴
- FrontController
- Front Controller
- Java
- Gitflow
- spring aop
- SSE
- 전략 패턴
- facade 패턴
- spring boot
- QueryDSL
- ParameterizedTest
- rest api
- 모두의 리눅스
- C++
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |