이번 포스팅은 해당 구조를 파사드 역할을 하는(Use Case) + 도메인 서비스로 바꾸며 겪은 변화에 대해 포스팅해보겠습니다.
단일 책임 원칙(SRP), 관심사의 분리(SoC), 레이어 간 인터페이스 분리는 유지보수성과 테스트 용이성을 높이는 핵심 설계 원칙입니다.
이전 프로젝트는 Controller → Service → Repository 형태의 계층형 아키텍처 구조였습니다.

구조가 단순해 도입은 쉬웠지만, 시간이 지날수록 Service 레이어가 비대해지며 테스트가 자주 깨졌습니다. “작은 정책 변경 하나에 테스트 수십 개가 줄줄이 실패”하는 테스트 지옥.
@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));
}
}
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());
}
}
public class JpaChatRepository implements ChatRepository { ... }
public interface SpringDataJpaChatRepository extends JpaRepository<Chat, String> { ... }
@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());
}
}
저는 파사드 패턴을 참조하여 아래와 같은 구조로 개선해보았습니다.

복잡한 하위 시스템(여러 서비스·유틸·레포지토리 등)을 하나의 단순한 인터페이스로 감싸는 구조적 디자인 패턴입니다. 클라이언트(여기선 Controller)는 내부 구성요소를 몰라도 하나의 진입점만 호출하면 됩니다. GoF 정의는 라이브러리/서브시스템 전체를 단순화하는 역할이지만, 애플리케이션 계층에서는 보통 UseCase(애플리케이션 서비스)가 곧 파사드 역할을 맡습니다.
@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 등
}
@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();
}
// … 기타 메서드
}
@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);
}
// … 기타 메서드
}
@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);
}
// … 기타 메서드
결합도 하향:
Controller는 UseCase 하나만, UseCase는 협력자 몇 개만 의존 → 테스트 대상 축소
변경 표면적 감소:
정책 변경 시 도메인 테스트군만 수정하면 됨
클래스별 역활 및 의도 명확화:
클래스 이름이 역할을 설명(UseCase, DomainService, Policy)
확장성 용이:
유스케이스의 라우팅 경로가 늘어도 동일한 흐름/템플릿 유지
모든 구조에는 장단점이 있기 마련입니다. 레이어드 아키텍쳐 패턴에서 파사드 패턴을 적용했을 때 어떤점들을 고려해야 할까요..
다음은 실제 프로젝트를 하며 고려했던 점입니다.
굳이 UseCase 클래스가 필요한가?:
과도한 서비스의 분리?:
도메인 서비스는 실제 규칙이 모이는 곳 하나로 충분한 경우가 많습니다. 이 경우, CRUD 마다 과도하게 Domain Service를 나누게 되면 오히려 규칙이 여기저기 흩어져 응집도가 저하될 수 있습니다.
이번 구조 전환이 굳이 필요한가? 라는 의문점을 가지기도 했습니다. 작은 규모 프로젝트에 이런 패턴을 적용하는것이 오버엔지니어링처럼 보일 수 있기 때문입니다.
하지만 SRP나 SoC 같은 기본 원칙을 실제로 적용해보고, 유지보수나 테스트 관점에서 어떤 장점이 있는지를 직접 느껴보고 싶었습니다.
아키텍처에 정답은 없다고들 합니다. 결국엔 팀의 상황, 프로젝트의 성격, 유지보수 주체에 따라 달라질 수밖에 없습니다. 따라서, "정답"보다는 "새로운 방향"을 잡고 시도 및 학습해보자는 생각으로 파사드 패턴을 적용해보았습니다.