LabHub

Spring Boot — 쿼리가 몇 개 나가는지 세어 본다 · 보이지 않는 것들 · 이론

편한 만큼 안 보인다

LabHub 에서 이어서 보기

한 줄 요약

Spring 의 장점과 단점은 같은 것이다 — 많은 일이 자동으로 일어난다. 그래서 잘못돼도 자동으로 잘못된다.

필드 주입을 쓰지 않는 이유

@Servicepublic class ItemService {    @Autowired private ItemRepository repo;   // ❌}

편해 보이는데 네 가지를 잃는다.

1. final 을 못 쓴다 — 생성 후에 바뀔 수 있는 필드가 된다
2. 테스트에서 갈아 끼우려면 리플렉션이 필요하다 — 생성자면 그냥 넘기면 된다
3. 의존성이 몇 개인지 안 보인다 — 생성자 인자가 일곱 개면 "이 클래스가 너무 많은 일을 한다" 가 눈에 보인다. 필드로 흩어 두면 안 보인다
4. 순환 참조가 런타임까지 숨는다 — 생성자 주입이면 기동 시점에 바로 터진다

@Servicepublic class ItemService {    private final ItemRepository repo;    ItemService(ItemRepository repo) { this.repo = repo; }   // ✅}

생성자가 하나면 @Autowired 도 필요 없다. Spring 이 알아서 쓴다.

> "기동할 때 터지는 것" 과 "운영 중에 터지는 것" 중에 고르는 문제다. 앞이 낫다.

검증은 컨트롤러에서 끝낸다

public record ItemRequest(@NotBlank String name, @Min(1) int price) {}@PostMapping("/items")public ResponseEntity<?> create(@Valid @RequestBody ItemRequest req) { ... }

@Valid 가 없으면 애너테이션은 아무 일도 하지 않는다. 붙여 두고 안 걸리는 경우의 대부분이 이것이다.

검증에 걸리면 Spring 이 MethodArgumentNotValidException 을 던지고 기본 400 이 나간다. 직접 if (name == null) 을 쓰고 있다면 프레임워크가 할 일을 뺏은 것이다.

예외를 상태 코드로 바꾸는 자리

@RestControllerAdviceclass ApiErrors {    @ExceptionHandler(NotFoundException.class)    ResponseEntity<Map<String, String>> notFound(NotFoundException e) {        return ResponseEntity.status(404).body(Map.of("message", e.getMessage()));    }}

한곳에 모으는 것이 요점이다. 컨트롤러마다 try-catch 를 흩어 두면 어느 예외가 어떤 코드로 나가는지 아무도 모르게 된다.

그리고 return ResponseEntity.ok(Map.of("error", ...)) 처럼 200 에 오류를 담지 않는다. 상태 코드는 계약의 일부다 — 클라이언트·프록시·모니터링이 전부 그걸 보고 동작한다.

@Transactional — 경계가 어디인가

@Transactionalpublic void bulkCreate(List<ItemRequest> reqs) { ... }

이 메서드가 끝날 때 커밋되고, 런타임 예외가 나가면 롤백된다. 세 가지를 알아야 한다.

1. 체크 예외는 기본적으로 롤백하지 않는다. IOException 을 던지면 커밋된다. @Transactional(rollbackFor = Exception.class) 를 줘야 한다. 모르면 "예외가 났는데 데이터는 저장돼 있는" 상태가 된다.

2. 자기 호출(self-invocation)은 안 먹는다. 같은 클래스의 다른 메서드를 this.method() 로 부르면 프록시를 거치지 않아 @Transactional 이 무시된다. 트랜잭션이 안 걸리는데 오류도 안 난다 — 가장 찾기 어려운 부류다.

3. private 메서드에는 안 붙는다. 프록시가 오버라이드할 수 없기 때문이다.

그리고 N+1

여기가 이 코스의 본론이다.

List<Shop> shops = shopRepo.findAll();          // 쿼리 1개for (Shop s : shops) {    s.getItems().size();                        // 가게마다 1개씩 더}

지연 로딩(FetchType.LAZY)은 실제로 쓸 때 쿼리를 날린다. 가게가 3개면 4개, 300개면 301개가 나간다.

개발할 때는 데이터가 적어 안 보이고, 운영에서 목록이 커지면 갑자기 느려진다. 그리고 애플리케이션 로그에는 아무 이상이 없다 — 쿼리 하나하나는 다 빠르다.

고치는 방법.

@Query("select distinct s from Shop s join fetch s.items")List<Shop> findAllWithItems();

한 번에 조인해서 가져온다. 쿼리가 1개가 된다.

세는 방법을 아는 것이 고치는 방법보다 중요하다.

spring.jpa.properties.hibernate.generate_statistics=true

그리고 테스트에서 Statistics.getPrepareStatementCount() 를 읽으면 쿼리 수를 단언할 수 있다.

assertThat(queries).isLessThanOrEqualTo(2);

이게 들어가 있으면 나중에 누가 join fetch 를 지워도 테스트가 잡는다. 성능 회귀를 테스트로 막는 몇 안 되는 방법이다.

테스트를 층으로 나눈다

| 애너테이션 | 띄우는 것 | 언제 |
|---|---|---|
| @SpringBootTest | 전체 컨텍스트 | 통합 확인, 소수만 |
| @WebMvcTest | 웹 층만 | 컨트롤러·검증·상태 코드 |
| @DataJpaTest | JPA 층만 | 쿼리·매핑 |

@WebMvcTest 는 서비스 빈을 띄우지 않으므로 @MockitoBean 으로 넣어 준다(부트 3.4 이전 이름은 @MockBean). 전체 컨텍스트를 매번 띄우면 테스트가 느려지고, 느려지면 아무도 안 돌린다.

운영에서 실제로 물리는 것들

커넥션 풀이 스레드 수보다 작다. HikariCP 기본이 10인데 톰캣 스레드는 200이다. 느린 쿼리 하나가 풀을 다 잡으면 나머지가 전부 대기한다 — 그때 증상은 "DB 가 느리다" 가 아니라 "애플리케이션이 멈췄다" 다.

open-in-view 가 기본 켜져 있다. 뷰 렌더링까지 영속성 컨텍스트가 열려 있어서, 컨트롤러 밖에서 지연 로딩이 일어나도 조용히 동작한다. 편하지만 커넥션을 요청 내내 붙잡는다. 끄고(spring.jpa.open-in-view=false) 필요한 것을 서비스에서 다 채워 내보내는 편이 낫다. 끄면 지금까지 숨어 있던 LazyInitializationException 이 드러나는데, 그게 원래 있던 문제다.

액추에이터를 그대로 열지 않는다. /actuator/env/actuator/heapdump 에는 비밀이 들어 있다. 내부 포트로 옮기거나 인증을 건다.

왜 이게 문제인가

Spring 에서 잘못이 조용한 이유는 자동으로 되는 일이 잘못될 때도 자동이기 때문이다. 필드 주입은 컴파일도 되고 실행도 되며, 순환 참조가 있어도 런타임까지 숨는다. @Transactional 은 같은 클래스 안에서 부르면 아무 일도 하지 않는데, 그 사실이 아무 데도 나타나지 않는다 — 예외가 나도 롤백이 안 될 뿐이다.

그래서 위 절들의 공통점은 하나다. 틀렸을 때 소리가 나게 만드는 것. 생성자 주입은 순환 참조를 기동 시점에 터뜨리고, 검증을 컨트롤러에서 끝내면 잘못된 요청이 서비스 계층까지 내려가지 않으며, 예외를 상태 코드로 바꾸는 자리를 한 곳에 모으면 그 자리를 보는 것만으로 API 의 계약을 알 수 있다.

현장에서 만나는 모습

운영에서 물리는 것들은 대개 개발 환경에서 재현되지 않는다는 공통점이 있다. N+1 은 개발 DB 에 가게가 3 개라 안 보이고, 커넥션 풀 고갈은 동시 사용자가 한 명일 때 일어나지 않으며, 테스트 격리 문제는 테스트를 하나씩 돌릴 때는 숨는다.

그래서 이 세 가지는 코드 리뷰로 잡는 것이 아니라 셀 수 있게 만들어 잡는다. 쿼리 수를 세고, 커넥션 풀 사용률을 지표로 내보내고, 테스트를 무작위 순서로 돌린다. 보이지 않던 것을 보이게 만드는 것이 이 모듈의 전부다.