C
Spring Boot/JPA/Lesson 09

Spring Data JPA 실습 — Repository · N+1 · DTO 변환

1시간·theory
이 챕터
2/2

Spring Data JPA 실습 — Repository · N+1 · DTO 변환

🎯 이 lesson 을 읽고 나면

이 lesson 을 다 읽고 나면 아래 3가지를 자신 있게 할 수 있습니다.

  • ✅ JpaRepository 만 상속해서 0줄 코드로 CRUD 구현
  • ✅ N+1 문제 → @EntityGraph 또는 JOIN FETCH 로 해결
  • ✅ Lazy vs Eager + 실무에서 무조건 Lazy 인 이유

학습 목표를 체크리스트로 두고 다 답할 수 있게 되면 lesson 을 닫으세요.

JpaRepository — 메서드를 *이름만 짓고 끝*

가장 큰 마법

java
public interface UserRepository extends JpaRepository<User, Long> {
}

인터페이스 한 줄 만 선언하면 — Spring 이 런타임에 구현체를 자동 생성 합니다. 이미 사용 가능한 메서드:

  • save(entity) — INSERT 또는 UPDATE
  • findById(id) — Optional 반환
  • findAll() — 전체 조회
  • count() — 개수
  • deleteById(id) — 삭제
  • existsById(id) — 존재 여부

메서드 이름으로 쿼리 만들기

java
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String keyword);
    long countByCreatedAtAfter(LocalDateTime date);
    boolean existsByEmail(String email);
    List<User> findByAgeGreaterThanOrderByNameAsc(int age);
}

메서드 이름 규칙 만 지키면 Spring 이 자동으로 SQL 생성. 가독성·속도 모두 압승.

키워드: findBy, countBy, existsBy, deleteBy + 필드명 + (Containing, GreaterThan, In, Between, OrderBy...Asc/Desc ...)

직접 JPQL 쓰기 — @Query

복잡한 쿼리는 JPQL (Java Persistence Query Language) 로 작성:

java
public interface UserRepository extends JpaRepository<User, Long> {

    @Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
    Optional<User> findActiveByEmail(@Param("email") String email);

    @Query("SELECT u FROM User u JOIN u.orders o WHERE o.amount > :min")
    List<User> findBigSpenders(@Param("min") int min);

    @Modifying
    @Query("UPDATE User u SET u.lastLogin = :now WHERE u.id = :id")
    int updateLastLogin(@Param("id") Long id, @Param("now") LocalDateTime now);
}

JPQL 은 SQL 과 비슷해 보이지만 "엔티티 이름" 으로 작성 — 테이블이 아닙니다.

페이징 — Pageable

java
Page<User> page = userRepository.findAll(
    PageRequest.of(0, 20, Sort.by("createdAt").descending())
);

List<User> content = page.getContent();   // 현재 페이지
long total = page.getTotalElements();      // 전체 개수
int totalPages = page.getTotalPages();

무한 스크롤·페이지 네비게이션 모두 이걸로 끝.

N+1 문제 — 실무에서 가장 자주 만나는 함정

문제 상황

java
@Entity
class User {
    @Id Long id;
    @OneToMany(mappedBy = "user")
    List<Order> orders;
}

@Entity
class Order { @Id Long id; @ManyToOne User user; int amount; }
java
List<User> users = userRepository.findAll();   // (1) SELECT * FROM users  ← 1번
for (User u : users) {
    System.out.println(u.getOrders().size());   // (N) SELECT * FROM orders WHERE user_id = ?  ← 사용자 수만큼
}

사용자 100명 → 쿼리 101번 발생. 데이터 늘수록 지수적으로 느려짐. 이게 N+1 문제.

해결 1 — @EntityGraph (가장 간단)

java
public interface UserRepository extends JpaRepository<User, Long> {

    @EntityGraph(attributePaths = {"orders"})
    List<User> findAll();
}

한 번의 JOIN 쿼리로 끝. SQL 로는:

sql
SELECT u.*, o.* FROM users u
LEFT JOIN orders o ON o.user_id = u.id

해결 2 — JOIN FETCH (JPQL 직접)

java
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();

DISTINCT 가 중요 — 안 쓰면 중복 행 이 늘어납니다.

해결 3 — Batch Size (@BatchSize)

완전 해결은 아니지만 N+1 → N/배치사이즈 +1 로 줄임. 다중 연관 관계 + 페이징 조합에서 유용.

java
@Entity
class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 100)        // 100개 단위로 IN 쿼리
    List<Order> orders;
}

Lazy vs Eager — 언제 뭘 쓰나

java
@ManyToOne(fetch = FetchType.LAZY)   // 기본값. *필요할 때만* 로딩
@OneToMany(fetch = FetchType.LAZY)   // 기본값.
@ManyToOne(fetch = FetchType.EAGER)  // 즉시 로딩 — *쓰지 마세요*

실무 규칙: 무조건 LAZY. EAGER 는 예측 불가능한 쿼리 폭발 의 원인. 필요할 땐 그때그때 @EntityGraph 또는 JOIN FETCH.

N+1 디버깅 — show-sql 켜놓기

yaml
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

개발 중 콘솔에 실제 발생 SQL 이 보입니다. 같은 쿼리가 수십 번 반복 되면 N+1 의심 신호.

Entity → DTO 변환 — *왜 분리해야 하나*

Entity 를 직접 응답하면 안 되는 이유

java
// ❌ Entity 를 그대로 응답
@GetMapping("/users/{id}")
public User get(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

4가지 문제:

1. 순환 참조: UserOrder 양방향 매핑이면 무한 JSON 직렬화 → StackOverflow.
2. 민감 정보 노출: password·internalNote 같은 필드가 자동 노출.
3. API 스키마와 DB 스키마 결합: DB 컬럼명 바꾸면 API 도 같이 깨짐.
4. 불필요한 쿼리: Lazy 필드를 Jackson 이 직렬화하려다 추가 쿼리 발생.

DTO 패턴

java
@Getter @Builder
public class UserResponse {
    private Long id;
    private String email;
    private String name;
    private int orderCount;

    public static UserResponse from(User u) {
        return UserResponse.builder()
            .id(u.getId())
            .email(u.getEmail())
            .name(u.getName())
            .orderCount(u.getOrders().size())
            .build();
    }
}

@GetMapping("/{id}")
public UserResponse get(@PathVariable Long id) {
    User u = userRepository.findById(id).orElseThrow();
    return UserResponse.from(u);
}

Static factory 메서드 (from) 가 변환 책임 — Entity 와 DTO 가 서로 모르는 관계 유지.

입력용 DTO 도 분리

java
@Getter @Setter
public class UserCreateRequest {
    @Email
    @NotBlank
    private String email;

    @Size(min = 2, max = 50)
    private String name;
}

@PostMapping
public UserResponse create(@Valid @RequestBody UserCreateRequest req) {
    User u = User.builder().email(req.getEmail()).name(req.getName()).build();
    return UserResponse.from(userRepository.save(u));
}

@ValidBean Validation (@Email·@NotBlank·@Size) 을 자동 실행. 검증 실패 시 400 Bad Request 자동 반환.

MapStruct — 변환 코드 자동 생성

DTO 가 많아지면 from() 메서드도 많아집니다. MapStruct 라이브러리가 컴파일 타임에 변환 코드 생성 — 보일러플레이트 제거.

java
@Mapper(componentModel = "spring")
public interface UserMapper {
    UserResponse toResponse(User u);
    User toEntity(UserCreateRequest req);
}

중·대규모 프로젝트의 표준 입니다. 처음엔 수동으로 짜다가 익숙해지면 도입하세요.

🤖 AI 에게 이렇게 요청해보세요

이 lesson 의 개념을 알면 AI 에게 구체적으로 지시할 수 있습니다. 막연한 "고쳐줘" 가 아니라 어휘를 가진 요청 — 그게 토큰 절약의 출발점입니다.

  • "이 findAll + 반복문이 N+1 인데 @EntityGraph 로 해결해줘"
  • "이 Entity 를 UserResponse DTO 로 변환하는 static factory 메서드 추가해줘"
  • "JpaRepository 에 findByEmailAndActive 메서드 시그니처 추가해줘"

왜 이게 토큰을 줄이나

개념을 모를 땐 AI 답변을 받고도 "그게 뭐예요?" 를 다시 물어야 합니다. 그 "다시 물음" 이 토큰을 잡아먹습니다. 개념 한 번 익혀두면 대화가 한 번에 끝납니다.

Spring Data JPA 실습 — Repository · N+1 · DTO 변환 - Spring Boot