⁉️ Facade vs Service 테스트 범위에 대한 고민
포인트 조회 기능을 구현하면서 다음과 같은 통합 테스트 요구사항을 받았다.
해당 ID의 회원이 존재하지 않을 경우, null이 반환된다.
초기 코드 구조
포인트 조회 기능은 PointFacade가 담당했고, 그 내부는 다음과 같은 흐름을 갖고 있다.
public class PointFacade {
private final PointService pointService;
private final UserService userService;
public PointInfo getPointInfo(String loginId) {
User user = userService.getUserByLoginId(loginId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, loginId + " 사용자를 찾을 수 없습니다."));
Point point = pointService.getPointByUserId(user.getId())
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, loginId + " 사용자의 포인트 정보를 찾을 수 없습니다."));
return PointInfo.from(point);
}
}
즉,
- 로그인 ID로 유저 존재 여부를 확인하고,
- 그 유저에게 연결된 포인트를 조회해,
- 존재하면 PointInfo로 반환하고,
- 중간에 유저 또는 포인트가 없다면 예외를 던진다.
그런데 요구사항은 "회원이 존재하지 않을 경우 null 반환"이다.
내가 구현한 구조는 예외를 던지는 구조라 이 부분에서 고민이 되었다.
시도 1: Facade에서 null을 반환해볼까?
만약 요구사항대로 "유저가 없으면 null을 반환"하려면 getPointInfo() 내부는 이렇게 바뀌어야 한다.
public PointInfo getPointInfo(String loginId) {
Optional<User> user = userService.getUserByLoginId(loginId);
if (user.isEmpty()) {
return null;
}
...
}
그런데 이렇게 되면 Controller 단에서 "유저가 null"일 때의 처리를 해줘야 한다.
애플리케이션 계층에서 충분히 판단할 수 있는데, 굳이 null을 Controller까지 끌고 가는 구조는 더 복잡하고 설계적으로 깔끔하지 않다고 생각했다.
시도 2: 그렇다면 각각의 Service를 테스트해야 하나?
"Facade가 아니라, Service를 통합 테스트하면 어떨까?"라는 생각이 들었다.
하지만
- PointService "포인트 조회" 로직만 책임지고 있기 때문에 "회원이 존재하지 않는다"는 시나리오와는 무관했다.
- "회원 존재 여부" 검증은 UserService의 역할이므로, 그것까지 PointServiceIntergrationTest(포인트 통합 테스트)에서 다루는 건 맞지 않다고 느꼈다.
즉, 두 Service를 각각 통합 테스트한다고 해서 "회원이 없을 때 포인트 조회가 어떻게 반응해야 하는가"를 한 번에 검증할 수가 없다.
결론: Facade를 통합 테스트에서 검증하자
기존 구조를 그대로 유지하면서 요구사항을 검증하려면, 결국 Facade 레이어에서 통합 테스트를 작성하는 것이 가장 자연스럽다고 생각했다.
@SpringBootTest
class PointServiceIntegrationTest {
@Autowired
private PointFacade pointFacade;
...
@DisplayName("해당 ID의 회원이 존재하지 않을 경우, null을 반환해 Not Found 예외가 발생한다.")
@Test
void returnsEmpty_whenUserDoesNotExist() {
// arrange
String nonExistentLoginId = "nonExistent";
PointCommand.ChargeCommand command = new PointCommand.ChargeCommand(nonExistentLoginId, 1000);
// act & assert
CoreException exception = assertThrows(CoreException.class, () -> {
pointFacade.chargePoint(command);
});
assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
verify(userService, times(1)).getUserByLoginId(nonExistentLoginId);
}
}
포인트 조회 기능의 테스트 설계는 다음과 같이 정리되었다.
- 통합 테스트
- PointFacade를 통해 "유저 없음 -> CoreException"과 "유저 존재 -> 포인트 반환" 시나리오를 검증
- E2E 테스트
- HTTP 헤더 X-USER-ID부터 실제 응답까지 API 관점에서 검증
마치며
테스트 범위를 결정하는 것도 많은 고민이 필요하다는 걸 느꼈다.
이 상황에서는 포인트 조회 통합 테스트를 facade를 테스트했지만, 그렇다고 해서 "통합 테스트 == Facade 테스트"라고 생각하지는 않는다. 테스트 대상과 범위는 프로젝트 구조와 계층 책임 등등으로 달라질 수 있다고 생각한다.