TT Lab
Get started
Learn Learning paths Courses

Spring Boot — Count How Many Queries Go Out

The More Convenient It Is, the Less You See

Continue in TT Lab

Summary

The strength and the weakness of Spring are the same thing — a lot happens automatically. So when something goes wrong, it goes wrong automatically too.

Why you should not use field injection

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

It looks convenient, but you lose four things.

  1. You cannot use final — the field can change after construction
  2. Swapping it out in tests requires reflection — with a constructor you just pass the replacement in
  3. You cannot see how many dependencies there are — with seven constructor arguments, "this class does too much" is obvious. Scattered across fields, it is not
  4. Circular references stay hidden until runtime — with constructor injection they blow up right at startup
@Service
public class ItemService {
    private final ItemRepository repo;
    ItemService(ItemRepository repo) { this.repo = repo; }   // ✅
}

With a single constructor you do not even need @Autowired. Spring uses it on its own.

It is a choice between "failing at startup" and "failing in production." The first is better.

Finish validation in the controller

public record ItemRequest(@NotBlank String name, @Min(1) int price) {}

@PostMapping("/items")
public ResponseEntity<?> create(@Valid @RequestBody ItemRequest req) { ... }

Without @Valid, the annotations do nothing at all. Most cases of "I added them but nothing is caught" come down to this.

When validation fails, Spring throws MethodArgumentNotValidException and a default 400 is returned. If you are writing if (name == null) by hand, you are doing work the framework should do.

Where exceptions become status codes

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

The point is to collect it in one place. If you scatter try-catch blocks across controllers, nobody knows which exception leaves with which code.

Also, do not put errors in a 200 like return ResponseEntity.ok(Map.of("error", ...)). The status code is part of the contract — clients, proxies, and monitoring all act on it.

@Transactional — where the boundary is

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

The transaction commits when this method finishes, and rolls back if a runtime exception escapes. Three things to know.

1. Checked exceptions do not roll back by default. If you throw IOException, the transaction commits. You need @Transactional(rollbackFor = Exception.class). If you do not know this, you end up with "an exception was thrown, yet the data is saved."

2. Self-invocation does not work. When you call another method of the same class with this.method(), the call does not go through the proxy, so @Transactional is ignored. No transaction is applied and no error appears either — this is the hardest kind to find.

3. It does not apply to private methods. A proxy cannot override them.

And then N+1

This is the main subject of the course.

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

Lazy loading (FetchType.LAZY) issues a query when the data is actually used. With 3 shops that is 4 queries; with 300 shops, 301 queries go out.

During development the data is small and you do not notice; in production, once the list grows, everything suddenly slows down. And the application log shows nothing wrong — every individual query is fast.

Here is how to fix it.

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

It fetches everything with a single join. The query count becomes 1.

Knowing how to count matters more than knowing how to fix.

spring.jpa.properties.hibernate.generate_statistics=true

And by reading Statistics.getPrepareStatementCount() in a test, you can assert the number of queries.

assertThat(queries).isLessThanOrEqualTo(2);

With this in place, even if someone later removes join fetch, the test catches it. It is one of the few ways to stop a performance regression with a test.

Split tests into layers

Annotation What it starts When
@SpringBootTest The full context Integration checks, only a few
@WebMvcTest The web layer only Controllers, validation, status codes
@DataJpaTest The JPA layer only Queries, mappings

@WebMvcTest does not start service beans, so you supply them with @MockitoBean (before Boot 3.4 the name was @MockBean). If you start the full context every time, tests get slow, and when they are slow nobody runs them.

What bites you in production

The connection pool is smaller than the thread count. The HikariCP default is 10, while Tomcat has 200 threads. If one slow query holds the whole pool, everything else waits — and the symptom is not "the DB is slow" but "the application has stopped."

open-in-view is on by default. The persistence context stays open until view rendering, so lazy loading outside the controller quietly works. It is convenient, but it holds a connection for the entire request. It is better to turn it off (spring.jpa.open-in-view=false) and have the service fill in everything it returns. Turning it off exposes the LazyInitializationException that was hidden until now, and that was the real problem all along.

Do not expose the actuator as is. /actuator/env and /actuator/heapdump contain secrets. Move them to an internal port or put authentication in front.

Why this is a problem

Mistakes in Spring are quiet because what happens automatically also happens automatically when it goes wrong. Field injection compiles and runs, and even with a circular reference it stays hidden until runtime. @Transactional does nothing when called from inside the same class, and that fact shows up nowhere — when an exception occurs, the rollback simply does not happen.

So the sections above share one thing: making it loud when you are wrong. Constructor injection makes a circular reference fail at startup; finishing validation in the controller keeps bad requests from reaching the service layer; and collecting the exception-to-status-code mapping in one place lets you learn the API's contract just by reading that place.

What it looks like in the field

What bites you in production usually has one thing in common: it does not reproduce in the development environment. N+1 stays invisible because the development DB has 3 shops, connection pool exhaustion does not happen with a single concurrent user, and test isolation problems hide when you run tests one at a time.

So these three are not caught by code review but by making them countable. Count the queries, export connection pool utilization as a metric, and run tests in random order. Making the invisible visible is the whole point of this module.