(1) 파사드 패턴 기반 계층 구조

이원석·2025년 8월 17일
1

사이드 프로젝트

목록 보기
6/10

이번 포스팅은 해당 구조를 파사드 역할을 하는(Use Case) + 도메인 서비스로 바꾸며 겪은 변화에 대해 포스팅해보겠습니다.


단일 책임 원칙(SRP), 관심사의 분리(SoC), 레이어 간 인터페이스 분리는 유지보수성과 테스트 용이성을 높이는 핵심 설계 원칙입니다.

이전 프로젝트는 Controller → Service → Repository 형태의 계층형 아키텍처 구조였습니다.

구조가 단순해 도입은 쉬웠지만, 시간이 지날수록 Service 레이어가 비대해지며 테스트가 자주 깨졌습니다. “작은 정책 변경 하나에 테스트 수십 개가 줄줄이 실패”하는 테스트 지옥.



1) Layered Architecture

1-1. Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/chats")
class ChatController {
    private final ChatService chatService;

    @PostMapping
    public ResponseEntity<ChatResponseDto> create(@RequestBody ChatRequestDto req,
                                                  @RequestHeader("X-UUID") String uuid) {
        return ResponseEntity.ok(chatService.create(req, uuid));
    }
}

Service

public interface ChatService {
    ChatResponseDto create(ChatRequestDto req, String uuid);
}


@Service
@RequiredArgsConstructor
class ChatServiceImpl implements ChatService {
    private final ChatRepository chatRepository;
    private final SpamPolicy spamPolicy;
    private final ProfanityFilter profanityFilter;

    @Transactional
    @Override
    public ChatResponseDto create(ChatRequestDto req, String uuid) {
        spamPolicy.checkSpam(uuid);                 // 정책 1
        String sanitized = profanityFilter.filter(req.getMessage()); // 정책 2

        Chat entity = new Chat(req.getItemId(), req.getUserName(), sanitized, uuid);
        Chat saved = chatRepository.save(entity);   // 영속화
        return new ChatResponseDto(saved.getId(), saved.getMessage());
    }
}

1-2. Repository

public class JpaChatRepository implements ChatRepository { ... }


public interface SpringDataJpaChatRepository extends JpaRepository<Chat, String> { ... }



1-3. Unit Test

@ExtendWith(MockitoExtension.class)
class ChatServiceImplTest {
    @Mock ChatRepository chatRepository;
    @Mock SpamPolicy spamPolicy;
    @Mock ProfanityFilter profanityFilter;
    ChatServiceImpl sut;

    @BeforeEach void setUp() {
        sut = new ChatServiceImpl(chatRepository, spamPolicy, profanityFilter);
    }

    @Test
    void create_filters_and_saves() {
        ChatRequestDto req = new ChatRequestDto("item", "user", "헬로");
        when(profanityFilter.filter("헬로")).thenReturn("헬*로");
        when(chatRepository.save(any(Chat.class))).thenAnswer(i -> i.getArgument(0));

        ChatResponseDto res = sut.create(req, "uuid");

        verify(spamPolicy).checkSpam("uuid");
        verify(chatRepository).save(any(Chat.class));
        assertEquals("헬*로", res.getMessage());
    }
}

문제점

  • 서비스가 정책/도메인 규칙/매핑/영속화를 모두 품음 → SRP 위반.
  • 테스트는 구현 세부(욕설 필터 호출 순서·존재 등)에 강하게 결합 → 정책이 이동/리팩토링만 되어도 테스트가 깨짐.




저는 파사드 패턴을 참조하여 아래와 같은 구조로 개선해보았습니다.


2) 파사드 패턴

파사드(Facade) 패턴이란?

복잡한 하위 시스템(여러 서비스·유틸·레포지토리 등)을 하나의 단순한 인터페이스로 감싸는 구조적 디자인 패턴입니다. 클라이언트(여기선 Controller)는 내부 구성요소를 몰라도 하나의 진입점만 호출하면 됩니다. GoF 정의는 라이브러리/서브시스템 전체를 단순화하는 역할이지만, 애플리케이션 계층에서는 보통 UseCase(애플리케이션 서비스)가 곧 파사드 역할을 맡습니다.


  • 왜 UseCase 레이어에 파사드를 적용했는가?
    • 관심사의 분리(SoC):
      HTTP 요청 → 비즈니스 로직 처리를 Controller와 애플리케이션 로직으로 분리

    • 단일 책임 원칙(SRP):
      • Controller: 요청 바인딩과 응답 반환
      • ChatUseCase: 유스케이스(비즈니스 시나리오)별 처리, DTO ↔ Entity 및 흐름 라우팅
      • CreateChatService 등: 실제 전처리, 검증, 영속화 로직

    • 유연한 확장성:
      새로운 기능이 생겨도 Controller → UseCase → Service 흐름만 추가하면 됨

    • 가독성·유지보수성 향상:
      • UseCase(router)를 보면 전체 흐름이 한눈에 보임
      • 기능 단위로 분리되어 각 클래스별 코드량이 적음





1. Controller Layer

@RestController
@RequestMapping("/message")
@RequiredArgsConstructor
public class ChatController {
    private final ChatUseCase chatUseCase;

    @GetMapping
    public ApiResponse<?> getChatListByCursor(
            @RequestParam String itemId,
            @RequestParam int limit,
            @RequestParam String cursor,
            @RequestParam String search
    ) {
        return ApiResponse.success(
            chatUseCase.getChat(itemId, limit, cursor, search),
            HttpSuccessType.SUCCESS_GET_CHAT_LIST
        );
    }
    // … createChat, reportChat, increaseEntry, decreaseEntry 등
}
  • 책임
    • HTTP 요청 파라미터 바인딩
    • ChatUseCase 하나에만 의존 → Mock 대상 최소화

  • 테스트 포인트
    • "Controller가 UseCase를 올바르게 호출했는가"에 집중



2. UseCase Layer (Facade)

@Service
@RequiredArgsConstructor
public class ChatUseCase {
    private final CreateChatService createChatService;
    private final GetChatService    getChatService;
    private final ReportChatService reportChatService;
    private final IncreaseChatEntryService increaseChatEntryService;
    private final DecreaseChatEntryService decreaseChatEntryService;
    private final ChatMapper chatMapper;
    
    
     public ChatResponseDto createChat(ChatRequestDto requestDto, String UUID) {
        requestDto.setUuid(UUID);
        Chat entity = chatMapper.toEntity(requestDto);
        Chat saved = createChatService.execute(entity);
        return chatMapper.toResponseDto(saved);
    }

    public List<ChatResponseDto> getChat(String itemId, int limit, String cursor, String search) {
        return getChatService.execute(itemId, limit, cursor, search).stream()
                .map(chatMapper::toResponseDto)
                .toList();
    }
    
    // … 기타 메서드
}
  • 책임
    • 유스케이스별 서비스로 라우팅(Facade)
    • DTO ↔ Entity 매핑 및 트랜잭션 경계 설정

  • 테스트 포인트
    • 위임/매핑이 정확한가 (협력자 호출 검증, 반환 DTO 확인)
    • 내부 규칙은 검증하지 않음(도메인 테스트로 분리)



3. Application Layer — CreateChatService

@Service
@RequiredArgsConstructor
public class CreateChatService {
    private final ChatDomainService chatDomainService;
    private final BadWordFilter badWordFilter;
    private final XssSanitizerHelper xssSanitizerHelper;

    public Chat execute(Chat entity) {
        // 1) 메시지 필터링 & XSS 제거
        String filtered = badWordFilter.filter(entity.getMessage());
        filtered = xssSanitizerHelper.sanitize(filtered);
        Chat toSave = entity.withMessageInPlace(filtered);

        // 2) 도메인 호출: 검증 + 저장
        return chatDomainService.createChat(toSave);
    }
    
    // … 기타 메서드
}
  • 책임
    • 전처리 로직 (UUID 세팅, 필터링, XSS 제거) 수행
    • DomainService 호출로 규칙 검증 + 영속화 위임

  • 테스트 포인트
    • badWordFilter / xssSanitizerHelper 등의 전처리 로직 동작 검증
    • 도메인 서비스로 위임되는가 (호출 검증)



4. Domain Layer — ChatDomainService

@Service
@RequiredArgsConstructor
public class ChatDomainService {

    private final ChatRepository chatRepository;
    private final ChatPolicyHelper spamPolicy;
    private final ChatEntryHelper entryHelper;

    /**
     * 도메인 규칙 검증 + 저장
     * @param chat: 이미 매핑된 Chat 엔티티
     */
    public Chat createChat(Chat chat) {
        spamPolicy.checkSpamAndBlock(chat.getUuid());
        spamPolicy.checkRepeatedMessage(chat, chat.getUuid());
        spamPolicy.checkIsBanned(chat.getUuid());
        return chatRepository.save(chat);
    }

    public List<Chat> findChatsByCursor(String itemId, int limit, String cursor, String search) {
        return chatRepository.getChatsByCursor(itemId, limit, cursor, search);
    }

    // … 기타 메서드
  • 책임
    • 비즈니스 규칙(스팸/반복/차단) 검증
    • 영속화(save, find) 및 필요 시 도메인 이벤트 발행 (해당 프로젝트는 Controller Layer에서 발행)

  • 테스트 포인트
    • 정책 호출 위반 시 예외 처리
    • 레포지토리 호출 여부, 저장 결과 확인



3. 변경점 요약

  1. 결합도 하향:
    Controller는 UseCase 하나만, UseCase는 협력자 몇 개만 의존 → 테스트 대상 축소

  2. 변경 표면적 감소:
    정책 변경 시 도메인 테스트군만 수정하면 됨

  3. 클래스별 역활 및 의도 명확화:
    클래스 이름이 역할을 설명(UseCase, DomainService, Policy)

  4. 확장성 용이:
    유스케이스의 라우팅 경로가 늘어도 동일한 흐름/템플릿 유지



4. 트레이드오프 관점

모든 구조에는 장단점이 있기 마련입니다. 레이어드 아키텍쳐 패턴에서 파사드 패턴을 적용했을 때 어떤점들을 고려해야 할까요..

다음은 실제 프로젝트를 하며 고려했던 점입니다.

  • 굳이 UseCase 클래스가 필요한가?:

    • 다음의 경우 파사드 생략 고려:
      (a) 단순 CRUD로 규칙이 거의 없음
      (b) Controller → DomainService 단일 호출로 끝남
      (c) 별도 트랜잭션/매핑/오케스트레이션이 사실상 불필요.

    • UseCase가 유효한 경우:
      여러 정책/헬퍼의 조합, 공통 매핑/검증 표준화가 필요하거나 트랜잭션 경계 설정이 필요한 경우.

  • 과도한 서비스의 분리?:
    도메인 서비스는 실제 규칙이 모이는 곳 하나로 충분한 경우가 많습니다. 이 경우, CRUD 마다 과도하게 Domain Service를 나누게 되면 오히려 규칙이 여기저기 흩어져 응집도가 저하될 수 있습니다.




5. 마치며

이번 구조 전환이 굳이 필요한가? 라는 의문점을 가지기도 했습니다. 작은 규모 프로젝트에 이런 패턴을 적용하는것이 오버엔지니어링처럼 보일 수 있기 때문입니다.

하지만 SRP나 SoC 같은 기본 원칙을 실제로 적용해보고, 유지보수나 테스트 관점에서 어떤 장점이 있는지를 직접 느껴보고 싶었습니다.

아키텍처에 정답은 없다고들 합니다. 결국엔 팀의 상황, 프로젝트의 성격, 유지보수 주체에 따라 달라질 수밖에 없습니다. 따라서, "정답"보다는 "새로운 방향"을 잡고 시도 및 학습해보자는 생각으로 파사드 패턴을 적용해보았습니다.





참고문헌
[Spring Boot] Service Layer 분리에 대하여 (2)

0개의 댓글