TT Lab
Get started
Learn Learning paths Courses

Spring Boot — Count How Many Queries Go Out

301 Queries for One List

Continue in TT Lab

Goal

You will look at the work Spring does for you. Validation, exception translation, transaction boundaries, and lazy loading all happen out of sight, and so the problems are out of sight too.

In particular, the last one — how many queries it takes to render a single list — you will actually count.

Environment

Java 21 · Maven · Spring Boot 3.4.1 · H2 (in-memory). There is no network.

mkdir -p /root/work/spring
cp -r /opt/lab/samples/spring-starter/* /root/work/spring/
cd /root/work/spring
mvn -o -B test          # -o 를 빠뜨리면 원격을 보러 가서 실패합니다

You can use only the dependencies already in the skeleton pom.xml — web, validation, data-jpa, actuator, h2, test. New dependencies cannot be downloaded.

Contract

The grader does not reference your classes; it looks only at HTTP and the query count. However you split your classes, you only need to satisfy the following.

Path Contract
GET /healthz {"status":"ok"}
POST /items {name, price} · 200/201 when valid · 400 on violation
GET /items The saved list (JSON array)
GET /items/{id} 200 if found · 404 with a message if not
POST /items/bulk An array · if even one fails, roll everything back
GET /shops The item count per shop · at most 2 queries

Keep the package as labhub.

Steps

  1. Skeleton · /healthz
  2. @Valid → 400
  3. @RestControllerAdvice → 404
  4. Constructor injection
  5. @Transactional rollback
  6. Count and reduce N+1 → 06-queries.txt
  7. @WebMvcTest slice
  8. Wrap-up → 08-notes.md

Notes

The grader copies your project and checks the copy. No grading files are left in your src.

Set up the project

Create a Spring Boot project in /root/work/spring and make GET /healthz return {"status":"ok"}. The package is labhub.

A skeleton is ready for you: mkdir -p /root/work/spring && cp -r /opt/lab/samples/spring-starter/* /root/work/spring/. There is no network, so always add -o (offline): mvn -o -B test. Dependencies that are not in the skeleton's pom cannot be downloaded.

Leave validation to the framework

Create POST /items and validate the body with @Valid. If the name is empty or the price is negative it must return 400; if valid, 200/201.

record ItemRequest(@NotBlank String name, @Min(1) int price). Without @Valid, the constraint annotations do nothing at all — most cases of "I added them but nothing is caught" come down to this. If you are writing if (name == null), you are doing work the framework should do.

Turn exceptions into status codes in one place

In GET /items/{id}, return 404 with a message for an id that does not exist. Handle it in a single @RestControllerAdvice.

If you scatter try-catch blocks across controllers, nobody knows which exception leaves with which code. Do not return a 200 with return ResponseEntity.ok(Map.of("error", ...)) — the status code is a contract, and proxies and monitoring act on it.

Switch to constructor injection

Add a @Service layer and do not put @Autowired on fields. Use constructor injection only (private final).

With a single constructor you do not need @Autowired at all. What you gain: the field can be final, tests can simply pass a replacement in, you can see how many dependencies there are, and a circular reference blows up at startup. That beats blowing up in production.

Roll everything back if the batch fails

Accept an array at POST /items/bulk and save it. If any one item is invalid, everything must roll back; if all are valid, all must be saved.

Put @Transactional on the service method. Three cautions — checked exceptions do not roll back by default (you need rollbackFor), calling this.method() inside the same class bypasses the proxy and is ignored, and it does not apply to private methods.

Count the queries and reduce them

Model shops and items as 1:N and make GET /shops return the item count for each shop. Measure the query count, write the numbers before and after the fix in 06-queries.txt, and end with at most 2 queries.

In application.properties, put spring.jpa.properties.hibernate.generate_statistics=true and count with Statistics.getPrepareStatementCount() in a test. First write it with findAll() and confirm that as many extra queries go out as there are shops, then reduce it to 1 with @Query("select distinct s from Shop s join fetch s.items"). Knowing how to count matters more than knowing how to fix.

A test that starts only the web layer

Write one test that verifies only the controller using @WebMvcTest. Supply the service with @MockitoBean instead.

@WebMvcTest does not start service beans, so the context will not start unless you supply a mock. Call it through MockMvc. If you start the full context every time, tests get slow, and when they are slow nobody runs them.

Three things that used to be invisible

Write at least three lines in 08-notes.md: why constructor injection, why N+1 happens and what fixed it, and what @Transactional rolls back (and the cases where it does not).

The text must contain the keywords 생성자 (constructor), N+1, and 롤백 (rollback). Writing down the two numbers from step 6 will make judging the same code faster next time.