지역 하나를 조회했는데 데이터가 없다. 이때 리포지토리가 404를 돌려준다고 생각하면, 예외를 어디서 처리해야 할지부터 꼬인다. 리포지토리는 HTTP 응답을 만드는 곳이 아니다. 우선 '찾지 못했다'는 데이터 결과를 돌려주고, 그 결과를 요청 실패로 볼지는 서비스가 결정한다.
가상의 지역 서초구에는 지표 한 건이 있고, 없는지역에는 없다고 해 보자. 두 입력이 findByRegion()을 지나 서비스 응답으로 바뀌는 지점을 따라가면 Optional.empty()와 404가 섞이지 않는다.
findByRegion()이 조회 0건에서 반환하는 값은?
Spring Data JPA 리포지토리에 다음 메서드를 선언했다고 하자. RegionMetric은 지역별 지표를 담는 엔티티이며, 이 예제에서는 지역 하나에 지표가 최대 한 건이라고 가정한다.
interface RegionMetricRepository extends JpaRepository<RegionMetric, Long> {
Optional<RegionMetric> findByRegion(String region);
}findByRegion("서초구")가 한 건을 찾으면 Optional 안에 엔티티가 들어간다. findByRegion("없는지역")가 아무것도 찾지 못하면 Optional.empty()다. null을 직접 반환하거나 404 예외를 던지는 계약이 아니다. Spring Data JPA의 쿼리 반환형 설명에서도 Optional<T>는 결과가 없으면 비어 있고, 두 건 이상이면 결과 개수 예외가 난다고 구분한다.
그림의 오른쪽 404는 리포지토리 결과가 아니라 서비스의 선택이다. 같은 '없음'이라도 목록 조회라면 빈 목록이 자연스럽고, 필수 단건 조회라면 404가 자연스러울 수 있다.
Optional.empty()는 왜 자동으로 HTTP 404가 아닐까?
Optional은 Java 값의 유무를 나타낸다. HTTP 상태 코드를 담는 상자는 아니다. 따라서 컨트롤러가 단건 지역 조회를 제공한다면, '없는지역'을 API에서 어떻게 표현할지 한 계층에서 정해야 한다. 다음 서비스는 결과가 있을 때 응답 객체로 바꾸고, 없을 때만 404 예외를 만든다.
@Service
class RegionMetricService {
private final RegionMetricRepository repository;
RegionMetricService(RegionMetricRepository repository) {
this.repository = repository;
}
RegionResponse getRegion(String region) {
return repository.findByRegion(region)
.map(RegionResponse::from)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Region not found: " + region));
}
}서초구에서는 map()이 RegionResponse를 만들고 orElseThrow()의 예외는 필요하지 않다. 없는지역에서는 map()이 실행되지 않고 orElseThrow()가 예외를 만든다. 이 서비스를 호출하는 HTTP 요청에서는 그 ResponseStatusException이 404 응답으로 이어진다. '없는지역'을 찾지 못한 것과 데이터베이스 접속이 실패한 것은 다르므로, DB 오류를 모두 404로 감싸면 진짜 장애를 숨긴다.
get()으로 바로 꺼내면 안 될까?
repository.findByRegion(region).get()은 값이 없을 때 NoSuchElementException을 낸다. 하지만 API가 왜 실패했는지, 어떤 상태 코드로 응답해야 하는지는 이 코드만으로 설명되지 않는다. 반대로 orElse(null)로 빼면 뒤에서 다시 null을 추적해야 한다. 단건 조회가 필수라면 위처럼 없을 때의 정책을 바로 옆에 적는 편이 읽기 쉽다.
정상 조회·0건·중복·DB 오류를 어떻게 구별할까?
같은 메서드라도 결과는 하나가 아니다. '조회가 안 됐다'고 모두 한 바구니에 넣지 말자.
| 상황 | 리포지토리 단계 | 서비스·API 판단 |
|---|---|---|
서초구 한 건 | 값이 든 Optional | 응답으로 변환 |
없는지역 0건 | Optional.empty() | 필수 단건 API라면 404 선택 |
| 같은 지역 두 건 이상 | 결과 개수 예외 | 데이터 중복·유일성 규칙 점검 |
| DB 연결·쿼리 실패 | 데이터 접근 오류 | 404로 바꾸지 말고 장애 원인 확인 |
특히 Optional<RegionMetric>은 '없거나 한 건'을 뜻하므로 지역당 한 건이라는 데이터 규칙과 맞아야 한다. 위 코드처럼 지역 컬럼에 유일성 제약이 없다면 중복 입력을 막는 장치가 있는지 확인해야 한다. 중복을 Optional.empty()로 오해하고 404를 내면 데이터 정합성 문제를 가려 버린다.
테스트에서도 두 경계를 따로 보자. 리포지토리 테스트는 '0건일 때 빈 Optional인가', 서비스 테스트는 '빈 Optional을 받아 어떤 예외를 선택하는가'를 확인한다. 마지막으로 HTTP 통합 테스트에서 실제 응답 상태를 확인해야, 코드상 예외 선택과 운영 API 동작을 같은 것으로 말할 수 있다.
핵심 요약
findByRegion()의 0건 결과는 Optional.empty()다. 404는 그다음, 필수 단건 요청을 처리하는 서비스가 선택한 HTTP 정책이다. map()은 값이 있을 때만 변환하고, orElseThrow()는 없을 때만 예외를 만든다. 중복 결과나 DB 장애는 0건과 다른 문제이므로 별도로 진단하자. 데이터가 없다는 사실과 요청을 어떻게 답할지는 한 줄 차이지만, 책임은 서로 다르다.

