카테고리 없음

Spring에서 Facade패턴 적용

코딩연습중 2025. 4. 21. 21:41

인공지능 산업이 발전하면서, MSA 아키텍처 구조를 지양하는 추세이다.

이번 여름에 졸업 전에 마지막으로 제대로 된 개인 프로젝트를 진행하려고 하는데, MSA 아키텍처 구조를 적용하여 도메인 별로 모듈을 나눌 생각이다. 일단 웹으로 구현하고, 나중에 앱으로 확장할 수도 있으니 그 때에 가서 BFF를 사용할 수 있도록 프로젝트를 확장성 높게 설계할 계획이다.

 

그래서 프로젝트를 설계할 때 구조를 어떻게 설계하면 좋을지 공부하는 도중에, Controller을 얇게 유지하는 것이 좋다는 글을 봤다.

Controller에 비즈니스 로직을 완전히 빼고, Service을 중심으로 한 구조가 좋다는 건 알고 있었지만 지금까지 프로젝트를 할 때에는 구현하느라 바빠서 리팩토링을 전혀 하지 못하고 깔끔하지 못한 로직을 만들어냈다. 따라서 전에 구현했던 걸 보면서 어떻게 리팩토링을 하면 좋을지 적어보겠다!

 

Reservation Controller

//2. 예약 생성
    @PostMapping("/createReservation")
    public ResponseEntity<Object> createReservation(@RequestBody ReservationRequestDTO reservationRequestDTO) throws Exception {

        //response body에 담을 Map 객체 생성
        Map<String, Object> response = new HashMap<>();
        String email = clientService.getCurrentUserEmail();
        String name = clientService.nameByEmail(email);
        Long userId = clientService.userIdByEmail(email);
        reservationRequestDTO.setUserId(userId);

        //사용자가 요청한 예약 시간이 유효한지 확인
        if(!reservationService.isValidReservation(reservationRequestDTO)){
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("유효하지 않은 예약 시간입니다.");
        }

        //예약 정보 생성
        Designer designer = designerService.getDesignerById(reservationRequestDTO.getDesignerId());
        Client client = clientService.clientByEmail(email);
        Reservation reservation = reservationService.createReservation(reservationRequestDTO, designer, client);

        //스케줄 테이블 업데이트
        secduleService.saveDesignerTimeSecdule(reservation, designer);

        //사용자 정보 map담기
        response.put("name", name);
        response.put("email", email);
        //생성된 예약 정보 map담기
        response.put("reservation", reservation);

        return ResponseEntity.ok(response);
    }

부끄럽지만 전에 위와 같이 작성했었다. 문제점을 먼저 파악해보겠다.

  1. parameter 유효성 검사 없음.
  2. 너무 많은 비즈니스 로직 들어있음.
  3. 여러 Service에 의존.
  4. 예외 처리 제대로 안 됨.
  5. Response 형식??? 이게 맞나??

1. parameter validation 체크는 어렵지 않다. DTO에 어노테이션을 통해 Valid 조건을 걸어놓고, RequestBody로 받을 때, @Valid 어노테이션을 추가해주면 된다.

 

2,3. 가장 큰 문제이다. 하나의 Controller에서 여러 Service에 의존하고 있고, 너무 많은 비즈니스 로직이 들어있다. 위와 같은 코드는 가독성 및 확장성을 떨어트리고, 재사용성도 떨어진다. 해결 방법은 밑에서 더 자세히 다루겠다.

 

4. 위의 코드에서 사용자가 요청한 예약 시간이 유효한지 확인하는 부분이 있는데, 이는 ResponseEntity에 직접 Bad request 응답을 전달하는 게 아니라, 예외를 던진 후, exception handler에서 처리해야 한다. 예외 처리에 대해서는 다른 글에서 이미 다뤘기 때문에 자세한 건 넘어가도록 하겠다.

 

5. response 넘길 때 보통 ResponseEntity<Object> 이거 괜찮나??

 

 

Controller는 얇게 유지하자

위에서 2,3번 문제에 대해서 간단하게 설명했다.

  1. 일단 컨트롤러가 여러 서비스 호출을 조합하고 있다. 
  2. 응답 데이터를 직접 가공하고, 로직 분기를 컨트롤러에서 판단한다.

컨트롤러는 단순히 요청을 받고, 서비스에 넘기며, 응답을 전달하는 정도로 얇게 유지하는 것이 가장 좋다.

그럼 위의 코드는 어떤 식으로 개선할 수 있을까?

 

Facade 패턴 사용

자바에서 사용되는 디자인패턴에는 여러 종류가 있는데, 이 중 퍼사드 패턴은 구조 패턴의 한 종류이다. 복잡한 서브 클래스들의 공통적인 기능을 정의하는 상위 수준의 인터페이스를 제공하는 패턴이다.

  • 퍼사드 객체는 서브 클래스의 코드에 의존하는 일을 감소시키며, 간단한 인터페이스를 제공한다.
  • 서브 시스템들 간의 종속성을 줄여준다.

 

아래는 파서드 패턴을 적용해본 것이다.

 

Reservation Controller

@RestController
public class ReservationController {

    private final ReservationFacade reservationFacade;

    @GetMapping("/readReservation")
    public ResponseEntity<List<ReservationListResponseDTO>> readMyReservations() {
        List<ReservationListResponseDTO> result = reservationFacade.readMyReservations();
        return ResponseEntity.ok(result);
    }

    @PostMapping("/createReservation")
    public ResponseEntity<Map<String, Object>> createReservation(@RequestBody ReservationRequestDTO dto) throws Exception {
        Map<String, Object> result = reservationFacade.createReservation(dto);
        return ResponseEntity.ok(result);
    }

    @GetMapping("/deleteReservation")
    public ResponseEntity<Void> deleteReservation(@RequestParam Long reservationId) throws Exception {
        reservationFacade.cancelReservation(reservationId);
        return ResponseEntity.ok().build();
    }
}

 

Facade 패턴을 적용시키면 위와 같이 Controller는 요청 전달과 응답 생성만 담당하게 된다.

 

ReservationFacade(도메인 중심)

@Service
public class ReservationFacade {

    private final ReservationService reservationService;
    private final ClientService clientService;
    private final DesignerService designerService;
    private final SecduleService secduleService;

    // 예약 생성
    public Map<String, Object> createReservation(ReservationRequestDTO dto) throws Exception {
        Map<String, Object> response = new HashMap<>();
        String email = clientService.getCurrentUserEmail();
        Long userId = clientService.userIdByEmail(email);
        String name = clientService.nameByEmail(email);

        dto.setUserId(userId);

        if (!reservationService.isValidReservation(dto)) {
            throw new IllegalArgumentException("유효하지 않은 예약 시간입니다.");
        }

        Designer designer = designerService.getDesignerById(dto.getDesignerId());
        Client client = clientService.clientByEmail(email);

        Reservation reservation = reservationService.createReservation(dto, designer, client);
        secduleService.saveDesignerTimeSecdule(reservation, designer);

        response.put("name", name);
        response.put("email", email);
        response.put("reservation", reservation);

        return response;
    }

}

 

이렇게 Facade 패턴을 통해 컨트롤러의 책임을 명확히 하고, 서비스에 대한 의존성을 줄일 수 있다. 그럼 이제 Service를 보겠다.

 

 

//2. 예약 생성
    @Transactional
    public Reservation createReservation(ReservationRequestDTO reservationRequestDTO, Designer designer, Client client) throws Exception {

        ReservationSaveRequestDTO reservationSaveRequestDTO = ReservationSaveRequestDTO.builder()
                .client(client)
                .designer(designer)
                .date(reservationRequestDTO.getDate())
                .time(reservationRequestDTO.getTime())
                .isOnline(reservationRequestDTO.isOnline())
                .status(ReservationStatus.READY)
                .createdAt((reservationRequestDTO.getCreatedAt()))
                .build();

        GoogleMeetDTO googleMeetDTO = null;
        //비대면일 경우 구글 미트 링크 생성 및 dto에 정보 저장
        if(reservationRequestDTO.isOnline()){
            googleMeetDTO = googleMeetService.createMeet(client.getEmail(), reservationRequestDTO.getDate(), reservationRequestDTO.getTime());
            reservationSaveRequestDTO.setMeetLink(googleMeetDTO.getMeetLink());
        }
        //dto->entity
        Reservation reservation = new Reservation(reservationSaveRequestDTO);

        //정보 save
        reservationRepository.save(reservation);

        if(googleMeetDTO != null){
            googleMeetDTO.setReservation(reservation);
            googleMeetService.saveMeet(googleMeetDTO);
        }

        //예약 정보 생성 이벤트 push -> 이벤트 리스너에서 처리(비동기)
        ReservationEvent event = new ReservationEvent(this, reservation);
        applicationEventPublisher.publishEvent(event);

        //return entity
        return reservation;
    }

 

  1. DTO 생성로직 변경 필요. Mapper로 분리하기.
  2. 위의 코드에서 google meet 생성하는 로직은 Facade 클래스에 있어야 함.

위의 코드를 보면 예약 + 구글 미트 링크 생성이 하나의 서비스 로직에서 이루어지는데, 이는 좋지 않다. 

외부 연동이나 서비스 조합은 Facade에서 담당하도록 해야한다. 

Service는 "도메인 중심 기능"만 담당해야 유지보수가 용이하다.

 

1. 위의 코드에서 reservationRequestDTO → reservationSaveRequestDTO로 변경 후, Entity로 변환하는데, 이 로직이 모두 Service 안에 포함되어 있다. 이를 파사드로 빼서 createReservation에 saveDto만 넘겨주는 것이 가장 이상적이다. 그래야 재사용성이 높아진다. 

근데 지금 저 코드를 있는 그대로 Facade에 붙이면 가독성 떨어지는 코드가 그대로 옮겨가는 것이다.

이렇게 DTO ↔ Entity 또는 DTO → DTO 변환할 때 사용하는 것이 Mapper이다. 

(Mapper는 따로 다음 글에서 다루겠다.)

아무튼 mapper에서 save용 DTO를 생성하는 코드를 Facade에 추가하면 훨씬 보기 좋아진다.

 

2. 구글 미트 링크 생성하는 로직은 Facade에서 담당하도록 하여, 하나의 기능만 수행할 수 있도록 하며,  Service 메서드의 재사용성을 높인다.

 

위를 적용한 코드는 다음과 같다.

 

ReservationService

@Transactional
public Reservation createReservation(ReservationSaveRequestDTO dto) {
    Reservation reservation = new Reservation(dto);
    reservationRepository.save(reservation);
    return reservation;
}

 

 

ReservationFacade

@Service
@RequiredArgsConstructor
public class ReservationFacade {

    private final ReservationService reservationService;
    private final ClientService clientService;
    private final DesignerService designerService;
    private final ScheduleService scheduleService;
    private final GoogleMeetService googleMeetService;
    private final ApplicationEventPublisher eventPublisher;

    public Map<String, Object> createReservation(ReservationRequestDTO dto) throws Exception {
        Map<String, Object> response = new HashMap<>();

        String email = clientService.getCurrentUserEmail();
        Long userId = clientService.userIdByEmail(email);
        String name = clientService.nameByEmail(email);

        dto.setUserId(userId);

        // 1. 예약 가능 시간 검증
        if (!reservationService.isValidReservation(dto)) {
            throw new IllegalArgumentException("유효하지 않은 예약 시간입니다.");
        }

        Client client = clientService.clientByEmail(email);
        Designer designer = designerService.getDesignerById(dto.getDesignerId());

        // 2. 비대면인 경우 미팅 링크 생성 + 저장
        GoogleMeet meet = null;
        if (dto.isOnline()) {
            meet = googleMeetService.createAndSaveMeet(email, dto.getDate(), dto.getTime());
        }
        
        // 3. Save용 DTO 생성 (Mapper 분리)
        ReservationSaveRequestDTO saveDTO = reservationMapper.toSaveDto(requestDTO, client, designer);

        // 4. 예약 엔티티 생성 및 저장
        Reservation reservation = reservationService.createReservation(saveDTO, client, designer, meet);

        // 5. 디자이너 스케줄 저장
        scheduleService.saveDesignerTimeSchedule(reservation, designer);

        // 6. 예약 이벤트 발행
        eventPublisher.publishEvent(new ReservationEvent(this, reservation));

        // 7. 응답 조립
        response.put("name", name);
        response.put("email", email);
        response.put("reservation", reservation);
        return response;
    }
}

 

위와 같이 코드를 개선할 수 있다.

 

근데 또 로직에서의 문제점이 하나 있었다.

이는 다음 글에서 다루도록 하겠다.

 

 

참고: 

- https://velog.io/@bagt/Design-Pattern-Facade-Pattern-%ED%8D%BC%EC%82%AC%EB%93%9C-%ED%8C%A8%ED%84%B4

- https://velog.io/@jwoo5264/Facade-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%EA%B8%B0.Spring

- https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%ED%8D%BC%EC%82%AC%EB%93%9CFacade-%ED%8C%A8%ED%84%B4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EC%9E%90#%ED%8D%BC%EC%82%AC%EB%93%9C_%ED%8C%A8%ED%84%B4_%EA%B5%AC%EC%A1%B0