퍼사드 패턴을 도입하게 된 배경
디자이너, 프론트엔드, 백엔드 총 5명으로 구성된 우리 팀은 올해 초부터 쉴 틈 없이 달려왔다. 매주 팀 회의, 파트별 회의, 프론트/백엔드 회의를 각각 1회씩 진행하며, 기획부터 설계, 개발까지 모두 경험했다. 배포 전에는 통합 테스트를 거쳐 최종적으로 이슈를 해결하고 도메인 등록과 배포를 통해 프로젝트를 성공적으로 마무리하였다. 프로젝트가 종료된 후 일부 팀원은 각자의 삶으로 돌아갔다. 어쩌면 당연한 일이지만, 시작이 있으면 끝이 있다는 게 시원섭섭하다. 알면서도 어쩔 수 없는 감정인 듯하다.
배포된 서비스를 사용해보고 프로젝트 코드를 살펴보니 일정에 맞추느라 신경 쓰지 못했던 부분, 아쉬웠던 부분, 개선해 보면 좋을 것 같은 부분들이 보였다. 그중에서도 눈에 띈 부분은 바로 서비스 계층이었다. 우리 팀은 하나의 서비스 계층에서 여러 서비스 계층과 데이터 액세스 계층을 의존하고 있었다. 예를 들면, 하나의 userService가 FoodService, CarService와 같은 여러 서비스 계층을 호출하는 구조였다. 물론, 2개를 의존하는 것은 크게 문제가 되지 않는다. 하지만 그 이상이 문제였다.
아래 그림을 보면 instanceService는 하나의 Service와 두 개의 Repository를 호출한다. 이 정도는 괜찮다. 하지만 instanceDetailService는 너무 복잡하여 설명하기조차 어렵다. 사실 이 부분은 프로젝트 초반부터 팀원과 함께 고민했던 부분이었다. 다행히 N+1 문제나 순환 참조 문제는 발생하지 않았고, 배포까지 무사히 진행되었다. 그렇다면 코드가 잘 작성된 것 아닌가? 겉보기에 괜찮아 보일 수도 있지만, 다른 도메인의 서비스들은 단일 책임 원칙을 지키지 않았고, 중복 호출되는 케이스도 있었다. 사실 설계할 때부터 이러한 부분들을 고려했어야 했지만, 우리는 작고 소중한 취업 준비생이었으니 어쩔 수 없었을 것이다. ㅎㅎㅎㅎ 결국 나와 백엔드 팀원은 2차 프로젝트 개발, 즉 코드 리팩토링과 성능 개선을 진행하기로 했다.
퍼사드 패턴이란 무엇인가
퍼사드 패턴은 구조 패턴의 한 종류로서, 복잡한 서브 클래스들의 공통 기능을 정의하는 상위 수준의 인터페이스를 제공하는 패턴이다. 퍼사드 객체는 서브 클래스의 코드에 대한 의존성을 줄여주고, 복잡한 소프트웨어를 간단히 사용할 수 있도록 간단한 인터페이스를 제공한다. 이로 인해 얻는 이점은 서브 시스템들 간의 종속성을 줄여주며, 클라이언트가 여러 서브 클래스를 호출할 필요 없이 편리하게 사용할 수 있다는 것이다.
기존 코드(리펙토링 전)
이전에는 컨트롤러에서 챌린지 진행 상황에 따라 다른 챌린지들을 제공하는 API, 챌린지 검색 API 등을 위해 instanceHomeService와 instanceSearchService를 호출하고 있었다. 물론 하나의 클래스에 모든 코드를 작성할 수도 있지만, 제공하는 기능에 따라 클래스를 분리하는 것이 더 적절하다고 생각했다. 이러한 방식이 서비스를 제공하는 데 문제가 되느냐고 묻는다면, 그렇지 않다.
하지만, 재사용성과 확장성을 고려했을 때, 규모가 커지고 기능이 추가되면 호출해야 하는 서비스 클래스의 수는 증가한다. 하나의 컨트롤러에서 제어해야 하는 서비스가 많아지고, 코드도 복잡해질 것이다.
그럼 또 다른 의견이 나올 수 있다. “컨트롤러를 나누면 되지 않나요?” 그렇게 묻는다면, 반박할 말이 없을 것이다.
InstanceHomeController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/challenges")
public class InstanceHomeController {
private final InstanceHomeService instanceHomeService;
private final InstanceSearchService instanceSearchService;
@PostMapping("/search")
public ResponseEntity<PagingResponse<InstanceSearchResponse>> searchInstances(
@RequestBody InstanceSearchRequest instanceSearchRequest, Pageable pageable) {
Page<InstanceSearchResponse> searchResults
= instanceSearchService.searchInstances(instanceSearchRequest.keyword(),
instanceSearchRequest.progress(), pageable);
return ResponseEntity.ok().body(
new PagingResponse<>(SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage(), searchResults)
);
}
@GetMapping("/recommend")
public ResponseEntity<SlicingResponse<HomeInstanceResponse>> getRecommendInstances(
Pageable pageable,
@AuthenticationPrincipal UserPrincipal userPrincipal) {
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
Sort.by(Direction.DESC, "participantCount"));
Slice<HomeInstanceResponse> recommendations = instanceHomeService.getRecommendations(
userPrincipal.getUser(), pageRequest);
return ResponseEntity.ok().body(
new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations)
);
}
@GetMapping("/popular")
public ResponseEntity<SlicingResponse<HomeInstanceResponse>> getPopularInstances(Pageable pageable) {
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
Sort.by(Direction.DESC, "participantCount"));
Slice<HomeInstanceResponse> recommendations = instanceHomeService.getInstancesByCondition(pageRequest);
return ResponseEntity.ok().body(
new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations)
);
}
@GetMapping("/latest")
public ResponseEntity<SlicingResponse<HomeInstanceResponse>> getLatestInstances(Pageable pageable) {
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
Sort.by(Direction.DESC, "startedDate"));
Slice<HomeInstanceResponse> recommendations = instanceHomeService.getInstancesByCondition(pageRequest);
return ResponseEntity.ok().body(
new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations)
);
}
}
InstanceHomeService
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class InstanceHomeService {
private final FilesService filesService;
private final InstanceRepository instanceRepository;
public Slice<HomeInstanceResponse> getRecommendations(User user, Pageable pageable) {
List<Instance> instances = new ArrayList<>();
List<String> userTags = Arrays.stream(user.getTags().split(",")).toList();
for (String userTag : userTags) {
instances.addAll(instanceRepository.findRecommendations(userTag, PREACTIVITY));
}
List<HomeInstanceResponse> recommendations = instances.stream()
.distinct()
.map(this::mapToHomeInstanceResponse)
.toList();
int start = (int) pageable.getOffset();
int end = Math.min((start + pageable.getPageSize()), recommendations.size());
return new PageImpl<>(recommendations.subList(start, end), pageable, recommendations.size());
}
public Slice<HomeInstanceResponse> getInstancesByCondition(Pageable pageable) {
Slice<Instance> instances = instanceRepository.findPagesByProgress(PREACTIVITY, pageable);
return instances.map(this::mapToHomeInstanceResponse);
}
private HomeInstanceResponse mapToHomeInstanceResponse(Instance instance) {
FileResponse fileResponse = filesService.convertToFileResponse(instance.getFiles());
return HomeInstanceResponse.createByEntity(instance, fileResponse);
}
}
InstanceSearchService
리펙토링 하기 전의 코드라서 클린하지 않다. 이번 기회에 퍼사드 패턴뿐만 아니라, 클린 코드를 지향하고 이펙티브 자바를 공부하면서 배운 내용들을 적용해보려 한다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class InstanceSearchService {
private final FilesService filesService;
private final SearchRepository searchRepository;
private final StringToEnum stringToEnum;
private final String[] progressData = {"PREACTIVITY", "ACTIVITY", "DONE"};
public Page<InstanceSearchResponse> searchInstances(String keyword, String progress, Pageable pageable) {
Page<Instance> search;
Progress convertProgress;
boolean flag = false;
for (String progressCond : progressData) {
if (progressCond.equals(progress)) {
flag = true;
}
}
if (flag) {
convertProgress = stringToEnum.convert(progress);
search = searchRepository.search(convertProgress, keyword, pageable);
} else {
search = searchRepository.search(null, keyword, pageable);
}
return search.map(this::convertToSearchResponse);
}
private InstanceSearchResponse convertToSearchResponse(Instance instance) {
FileResponse fileResponse = filesService.convertToFileResponse(instance.getFiles());
return InstanceSearchResponse.builder()
.topicId(instance.getTopic().getId())
.instanceId(instance.getId())
.keyword(instance.getTitle())
.pointPerPerson(instance.getPointPerPerson())
.participantCount(instance.getParticipantCount())
.fileResponse(fileResponse)
.build();
}
}
개선 코드(퍼사드 패턴 적용 후)
InstanceHomeController
퍼사드 패턴을 적용한 후, 컨트롤러에서 달라진 점이 보이는가? 이전에는 두 개의 서비스를 호출했지만, 이제는 하나의 퍼사드만 호출하고 있다. 퍼사드를 통해 필요한 기능을 제공함으로써, 클라이언트는 서비스 시스템들의 구체적인 구현을 알 필요가 없어졌다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/challenges")
public class InstanceHomeController {
private final InstanceHomeFacade instanceHomeFacade;
@PostMapping("/searchV1")
public ResponseEntity<PagingResponse<InstanceSearchResponse>> searchInstancesV1(
@RequestBody InstanceSearchRequest instanceSearchRequest, Pageable pageable) {
Page<InstanceSearchResponse> searchResults
= instanceHomeFacade.searchInstancesByKeywordAndProgressV1(instanceSearchRequest, pageable);
return ResponseEntity.ok().body(
new PagingResponse<>(SuccessCode.SUCCESS.getStatus(), SuccessCode.SUCCESS.getMessage(), searchResults)
);
}
@GetMapping("/recommend")
public ResponseEntity<SlicingResponse<HomeInstanceResponse>> getRecommendInstances(
Pageable pageable,
@AuthenticationPrincipal UserPrincipal userPrincipal) {
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
Sort.by(Direction.DESC, "participantCount"));
Slice<HomeInstanceResponse> recommendations = instanceHomeFacade.recommendInstances(
userPrincipal.getUser(), pageRequest);
return ResponseEntity.ok().body(
new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations)
);
}
@GetMapping("/popular")
public ResponseEntity<SlicingResponse<HomeInstanceResponse>> getPopularInstances(Pageable pageable) {
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
Sort.by(Direction.DESC, "participantCount"));
Slice<HomeInstanceResponse> recommendations = instanceHomeFacade.findInstancesByCondition(
pageRequest);
return ResponseEntity.ok().body(
new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations)
);
}
@GetMapping("/latest")
public ResponseEntity<SlicingResponse<HomeInstanceResponse>> getLatestInstances(Pageable pageable) {
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
Sort.by(Direction.DESC, "startedDate"));
Slice<HomeInstanceResponse> recommendations = instanceHomeFacade.findInstancesByCondition(
pageRequest);
return ResponseEntity.ok().body(
new SlicingResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), recommendations)
);
}
}
InstanceFacade
이전에는 규모가 확장되고 기능이 추가될 때마다 새로 생성한 서비스 클래스를 호출해야 했다. 하지만 퍼사드 패턴을 도입한 후에는, 확장이 필요한 상황에서도 퍼사드 인터페이스에 필요한 메서드를 정의하고 구현하기만 하면 된다. 이로 인해 호출해야 하는 서비스는 하나의 퍼사드로 일원화되었다.
public interface InstanceHomeFacade {
Page<InstanceSearchResponse> searchInstancesByKeywordAndProgressV1(InstanceSearchRequest instanceSearchRequest,
Pageable pageable);
Slice<HomeInstanceResponse> recommendInstances(User user, Pageable pageable);
Slice<HomeInstanceResponse> findInstancesByCondition(Pageable pageable);
}
InstanceFacadeService
@RequiredArgsConstructor
@Component
@Slf4j
@Transactional
public class InstanceHomeFacadeService implements InstanceHomeFacade {
private final InstanceRecommendationService instanceRecommendationService;
private final InstanceSearchService instanceSearchService;
private final FilesService filesService;
@Override
public Page<InstanceSearchResponse> searchInstancesByKeywordAndProgressV1(
InstanceSearchRequest instanceSearchRequest,
Pageable pageable) {
Page<Instance> searchedInstances = instanceSearchService.searchInstancesV1(instanceSearchRequest.keyword(),
instanceSearchRequest.progress(), pageable);
return searchedInstances.map(this::convertToSearchResponse);
}
@Override
public Slice<HomeInstanceResponse> recommendInstances(User user, Pageable pageable) {
List<Instance> instanceList = instanceRecommendationService.getRecommendations(user);
List<HomeInstanceResponse> recommendations = convertToHomeInstanceResponseList(instanceList);
return createPageFromList(recommendations, pageable);
}
@Override
public Slice<HomeInstanceResponse> findInstancesByCondition(Pageable pageable) {
Slice<Instance> instancesByCondition = instanceRecommendationService.getInstancesByCondition(pageable);
return instancesByCondition.map(this::mapToHomeInstanceResponse);
}
private InstanceSearchResponse convertToSearchResponse(Instance instance) {
FileResponse fileResponse = filesService.convertToFileResponse(instance.getFiles());
return InstanceSearchResponse.builder()
.topicId(instance.getTopic().getId())
.instanceId(instance.getId())
.keyword(instance.getTitle())
.pointPerPerson(instance.getPointPerPerson())
.participantCount(instance.getParticipantCount())
.fileResponse(fileResponse)
.build();
}
private List<HomeInstanceResponse> convertToHomeInstanceResponseList(List<Instance> instances) {
return instances.stream()
.map(this::mapToHomeInstanceResponse)
.collect(Collectors.toList());
}
private HomeInstanceResponse mapToHomeInstanceResponse(Instance instance) {
FileResponse fileResponse = filesService.convertToFileResponse(instance.getFiles());
return HomeInstanceResponse.createByEntity(instance, fileResponse);
}
private Slice<HomeInstanceResponse> createPageFromList(List<HomeInstanceResponse> list, Pageable pageable) {
int start = (int) pageable.getOffset();
int end = Math.min((start + pageable.getPageSize()), list.size());
return new PageImpl<>(list.subList(start, end), pageable, list.size());
}
}
InstanceRecommendationService
서비스 계층에서는 다른 서비스를 호출하지 않고, 순환 참조 문제를 피할 수 있으며 단일 책임 원칙을 지키게 된다. (물론, 필요에 따라 다른 서비스를 호출해야 할 수도 있다. 하지만, 이전처럼 코드가 복잡하지 않고 단일 책임 원칙을 지키고 있으므로 순환 참조 문제는 발생하지 않을 것이라고 생각한다. )
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class InstanceRecommendationService {
private final InstanceRepository instanceRepository;
public List<Instance> getRecommendations(User user) {
String[] userTags = user.getTags().split(",");
List<Instance> instances = new ArrayList<>();
for (String userTag : userTags) {
instances.addAll(instanceRepository.findRecommendations(userTag, PREACTIVITY));
}
return instances.stream().distinct().collect(Collectors.toList());
}
public Slice<Instance> getInstancesByCondition(Pageable pageable) {
return instanceRepository.findPagesByProgress(PREACTIVITY, pageable);
}
}
InstanceSearchService
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class InstanceSearchService {
private final SearchRepository searchRepository;
private final StringToEnum stringToEnum;
public Page<Instance> searchInstancesV1(String keyword, String progress, Pageable pageable) {
boolean isValidProgress = false;
List<String> progressData = Arrays.stream(Progress.values()).map(Objects::toString).toList();
for (String progressCond : progressData) {
if (progressCond.equals(progress)) {
isValidProgress = true;
break;
}
}
if (isValidProgress) {
Progress convertProgress = stringToEnum.convert(progress);
return searchRepository.searchV1(convertProgress, keyword, pageable);
}
return searchRepository.searchV1(null, keyword, pageable);
}
}
퍼사드 패턴 사용 후기
장점
- 가독성이 좋아졌다. 각 서비스들은 맡은 역할만 수행하고, 클라이언트에게 제공할 추가적인 로직은 퍼사드에서 처리하게 된다.
- 하위 시스템 간의 의존 관계가 많을 경우 이를 감소시키고 의존성을 한 곳으로 모으게 되었다.
- 클라이언트가 시스템 코드를 모르더라도 Facade 클래스만 이해하고 사용이 가능해졌다.
단점
- 퍼사드 클래스 자체가 서브 시스템에 대한 의존성을 가지게 되었다.
- 추가적인 코드와 패키지 구조로 인해 관리해야 하는 파일이 많아졌다.