Data 실전 가이드

Data의 핵심 개념과 실무 활용

Python중급
10시간
9개 항목
학습 진행률0 / 9 (0%)

학습 항목

1. Java
Spring|Data|JPA|완벽|가이드
퀴즈튜토리얼
2. JavaScript
중급
Data Structure|테스트|전략|완벽|가이드
퀴즈튜토리얼
3. Python
Big|Data|핵심|개념|완벽|정리
퀴즈튜토리얼
4. Python
고급
Data Science|실전|프로젝트|가이드
퀴즈튜토리얼
5. Python
초급
Data Structure|최신|기능|완벽|가이드
퀴즈튜토리얼
6. Python
초급
Data|Analysis|테스트|전략|완벽|가이드
퀴즈튜토리얼
7. Python
중급
Data|Science|기초부터|심화까지
퀴즈튜토리얼
8. Python
중급
Data|Science|최신|기능|소개
퀴즈튜토리얼
9. TypeScript
중급
Data|Structure|실전|프로젝트|가이드
퀴즈튜토리얼
1 / 9

이미지 로딩 중...

Spring Data JPA 완벽 가이드 - 슬라이드 1/13

Spring Data JPA 완벽 가이드

초보 개발자를 위한 Spring Data JPA 완벽 가이드입니다. JPA의 기본 개념부터 실무에서 자주 사용하는 핵심 기능들을 친절하게 설명합니다. 엔티티 설계, 연관관계 매핑, 쿼리 메서드, 페이징 처리까지 실전 예제와 함께 배워보세요.


목차

  1. Entity와_기본_매핑
  2. Repository_인터페이스
  3. 쿼리_메서드
  4. @Query_어노테이션
  5. 연관관계_매핑_OneToMany
  6. Fetch_Join과_N+1_문제
  7. 페이징과_정렬
  8. Auditing_생성일시와_수정일시_자동_관리

1. Entity와_기본_매핑

시작하며

여러분이 회원 관리 시스템을 개발하는데, 데이터베이스에 저장된 회원 정보를 자바 객체로 다루고 싶다면 어떻게 해야 할까요? JDBC를 사용했다면 ResultSet에서 일일이 데이터를 꺼내 객체에 담는 번거로운 작업을 해야 했을 겁니다.

이런 반복적인 작업은 개발 생산성을 크게 떨어뜨립니다. 테이블 구조가 바뀔 때마다 수많은 SQL 문과 매핑 코드를 수정해야 하고, 실수로 인한 버그 발생 가능성도 높습니다.

바로 이럴 때 필요한 것이 JPA의 Entity입니다. Entity를 사용하면 데이터베이스 테이블과 자바 객체를 자동으로 매핑할 수 있어, 객체 지향적으로 데이터를 다룰 수 있습니다.

개요

간단히 말해서, Entity는 데이터베이스 테이블과 1:1로 매핑되는 자바 클래스입니다. Entity를 사용하면 복잡한 SQL 작성과 ResultSet 처리 없이도 데이터베이스 작업을 수행할 수 있습니다.

예를 들어, 회원 정보를 저장할 때 INSERT 문을 직접 작성하지 않고 객체를 저장하듯이 코드를 작성할 수 있습니다. 기존에는 SQL 중심으로 개발했다면, 이제는 객체 중심으로 개발할 수 있습니다.

테이블의 컬럼은 Entity 클래스의 필드로, 테이블의 행은 Entity 객체로 표현됩니다. @Entity 어노테이션으로 클래스를 Entity로 선언하고, @Id로 기본 키를 지정하며, @Column으로 세부적인 컬럼 매핑을 설정할 수 있습니다.

이러한 어노테이션들이 JPA에게 어떻게 테이블과 매핑할지 알려주는 메타데이터 역할을 합니다.

코드 예제

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // name 컬럼에 매핑, null 불가, 최대 50자
    @Column(nullable = false, length = 50)
    private String username;

    // email 컬럼에 매핑, 유니크 제약조건
    @Column(unique = true, nullable = false)
    private String email;

    // age 컬럼에 매핑
    private Integer age;
}

설명

이것이 하는 일: Entity 클래스는 데이터베이스 테이블의 구조를 자바 코드로 표현하고, JPA가 객체와 테이블을 자동으로 매핑할 수 있게 해줍니다. 첫 번째로, @Entity 어노테이션이 이 클래스가 JPA에서 관리되는 엔티티임을 선언합니다.

@Table(name = "users")는 실제 데이터베이스의 "users" 테이블과 매핑됨을 명시합니다. 테이블명을 생략하면 클래스명을 소문자로 변환한 이름이 기본값이 됩니다.

그 다음으로, @Id와 @GeneratedValue가 실행되면서 기본 키를 정의합니다. GenerationType.IDENTITY는 데이터베이스의 AUTO_INCREMENT 기능을 사용하여 자동으로 값을 증가시킵니다.

MySQL, PostgreSQL에서 주로 사용하는 전략입니다. @Column 어노테이션은 필드와 컬럼의 매핑을 세밀하게 제어합니다.

nullable = false는 NOT NULL 제약조건을, unique = true는 UNIQUE 제약조건을 데이터베이스에 생성합니다. length는 VARCHAR 타입의 길이를 지정합니다.

마지막으로, @Column을 생략한 필드(age)도 자동으로 같은 이름의 컬럼에 매핑됩니다. 기본적으로 카멜케이스는 스네이크케이스로 변환되므로, createdDate 필드는 created_date 컬럼에 매핑됩니다.

여러분이 이 Entity를 사용하면 복잡한 JDBC 코드 없이 객체를 저장하고 조회할 수 있습니다. JPA가 자동으로 적절한 SQL을 생성하고 실행하며, 결과를 객체로 변환해줍니다.

또한 타입 안정성이 보장되어 컴파일 시점에 오류를 발견할 수 있고, IDE의 자동완성 기능도 활용할 수 있습니다.

실전 팁

💡 Entity 클래스는 반드시 기본 생성자(파라미터 없는 생성자)가 필요합니다. JPA가 리플렉션으로 객체를 생성하기 때문입니다. Lombok의 @NoArgsConstructor를 활용하세요.

💡 @Column의 name 속성으로 필드명과 컬럼명을 다르게 매핑할 수 있습니다. 레거시 데이터베이스와 통합할 때 유용합니다.

💡 기본 키 생성 전략은 데이터베이스에 따라 달라집니다. Oracle은 SEQUENCE, MySQL은 IDENTITY를 주로 사용합니다.

💡 Entity 클래스에 Setter를 무분별하게 만들지 마세요. 의미 있는 비즈니스 메서드를 만들어 객체의 일관성을 유지하세요.

💡 @Transient 어노테이션을 사용하면 특정 필드를 데이터베이스에 저장하지 않을 수 있습니다. 임시 계산 값에 활용하세요.


2. Repository_인터페이스

시작하며

여러분이 사용자 데이터를 저장하고 조회하는 기능을 만들 때, DAO 클래스를 만들어 CRUD 메서드를 일일이 구현해본 경험이 있나요? 저장, 조회, 수정, 삭제를 위한 반복적인 코드를 계속 작성하는 것은 정말 지루한 일입니다.

이런 반복 작업은 모든 Entity마다 발생합니다. User, Product, Order 등 Entity가 늘어날 때마다 비슷한 CRUD 코드를 복사-붙여넣기 하게 되고, 코드 중복이 심해집니다.

바로 이럴 때 필요한 것이 Spring Data JPA의 Repository입니다. Repository 인터페이스만 선언하면 Spring이 자동으로 구현체를 만들어주어, 기본적인 CRUD 코드를 작성할 필요가 없습니다.

개요

간단히 말해서, Repository는 데이터 접근 계층의 인터페이스로, JpaRepository를 상속받기만 하면 기본 CRUD 메서드를 자동으로 사용할 수 있습니다. Repository를 사용하면 save(), findById(), findAll(), delete() 같은 기본 메서드를 직접 구현하지 않아도 됩니다.

예를 들어, 회원 저장 기능을 만들 때 INSERT 문을 작성하지 않고 userRepository.save(user)만 호출하면 됩니다. 기존에는 EntityManager를 직접 다루거나 JDBC Template으로 SQL을 작성했다면, 이제는 인터페이스 메서드만 호출하면 됩니다.

Spring Data JPA가 런타임에 프록시 객체를 생성하여 실제 구현을 제공합니다. JpaRepository는 PagingAndSortingRepository와 CrudRepository를 상속하므로, 페이징, 정렬, 배치 처리 등 다양한 기능을 제공합니다.

이러한 메서드들이 대부분의 일반적인 데이터 접근 요구사항을 충족시켜줍니다.

코드 예제

// JpaRepository<엔티티 타입, ID 타입>을 상속
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // 기본 제공 메서드들 (구현 불필요):
    // - save(user): 저장/수정
    // - findById(id): ID로 조회
    // - findAll(): 전체 조회
    // - delete(user): 삭제
    // - count(): 개수 조회

    // 커스텀 메서드는 다음 카드에서 설명
}

// 실제 사용 예시
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    public User createUser(User user) {
        return userRepository.save(user); // 저장
    }

    public User getUser(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("User not found"));
    }
}

설명

이것이 하는 일: Repository 인터페이스는 데이터베이스 접근을 위한 계약을 정의하고, Spring Data JPA가 런타임에 실제 구현체를 자동으로 생성하여 제공합니다. 첫 번째로, JpaRepository<User, Long>을 상속받으면 Spring Data JPA가 이 인터페이스의 프록시 구현체를 생성합니다.

제네릭의 첫 번째 타입(User)은 관리할 Entity 타입이고, 두 번째 타입(Long)은 Entity의 ID 필드 타입입니다. @Repository 어노테이션은 생략 가능하지만, 명시하면 컴포넌트 스캔과 예외 변환 기능을 활용할 수 있습니다.

그 다음으로, 상속받은 순간부터 15개 이상의 기본 메서드를 사용할 수 있게 됩니다. save()는 Entity를 영속 상태로 만들어 데이터베이스에 저장하고, ID가 있으면 UPDATE, 없으면 INSERT를 수행합니다.

findById()는 기본 키로 Entity를 조회하며, Optional로 결과를 반환하여 null 안전성을 제공합니다. findAll()은 해당 Entity의 모든 레코드를 조회합니다.

실무에서는 데이터가 많을 수 있으므로 페이징을 고려해야 합니다. delete()는 Entity를 영속성 컨텍스트에서 제거하고 데이터베이스에서 삭제합니다.

count()는 전체 레코드 수를 반환합니다. 마지막으로, Service 계층에서 Repository를 주입받아 사용합니다.

@Autowired로 의존성을 주입하면(생성자 주입 권장), Spring이 자동으로 생성한 구현체를 제공합니다. orElseThrow()는 Optional이 비어있을 때 예외를 발생시켜, null 체크 코드를 간결하게 만듭니다.

여러분이 이 Repository를 사용하면 데이터 접근 계층의 코드량이 90% 이상 줄어듭니다. 테스트도 간단해지며, 인터페이스 기반 설계로 Mock 객체를 쉽게 만들 수 있습니다.

또한 데이터베이스가 변경되어도 Repository 인터페이스는 그대로 유지되므로, 느슨한 결합을 유지할 수 있습니다.

실전 팁

💡 생성자 주입을 사용하세요. @Autowired를 필드에 직접 붙이는 것보다 테스트가 쉽고 불변성을 보장할 수 있습니다.

💡 findById()는 Optional을 반환하므로 orElse(), orElseGet(), orElseThrow()를 활용하여 null 처리를 명확히 하세요.

💡 save() 메서드는 저장과 수정 모두에 사용됩니다. ID가 없으면 새로 저장하고, 있으면 업데이트합니다.

💡 대량의 데이터를 삭제할 때는 deleteAllInBatch()를 사용하면 성능이 향상됩니다. 단일 DELETE 쿼리로 처리되기 때문입니다.

💡 Repository는 계층 분리를 위해 Service에서만 사용하고, Controller에서 직접 호출하지 마세요.


3. 쿼리_메서드

시작하며

여러분이 특정 이메일을 가진 사용자를 찾거나, 특정 나이 이상의 사용자 목록을 조회해야 한다면 어떻게 하시겠어요? Repository의 기본 메서드만으로는 이런 조건 검색이 불가능합니다.

이런 경우 전통적으로는 JPQL이나 네이티브 SQL을 작성해야 했습니다. 하지만 간단한 조건 검색을 위해 매번 쿼리를 작성하는 것은 번거롭고, 오타로 인한 런타임 오류 가능성도 있습니다.

바로 이럴 때 필요한 것이 Spring Data JPA의 쿼리 메서드입니다. 메서드 이름만 규칙에 맞게 작성하면, Spring이 자동으로 적절한 쿼리를 생성하여 실행해줍니다.

개요

간단히 말해서, 쿼리 메서드는 메서드 이름을 분석하여 자동으로 쿼리를 생성하는 기능입니다. 쿼리 메서드를 사용하면 SQL이나 JPQL을 작성하지 않고도 다양한 조건 검색이 가능합니다.

예를 들어, findByEmail() 메서드를 선언하면 email 필드로 검색하는 쿼리가 자동으로 생성됩니다. 기존에는 @Query 어노테이션으로 JPQL을 작성했다면, 이제는 메서드 이름만으로 쿼리를 정의할 수 있습니다.

find, read, query, get으로 시작하고, By 뒤에 조건 필드명을 붙이면 됩니다. And, Or, Between, LessThan, GreaterThan, Like 등의 키워드를 조합하여 복잡한 조건도 표현할 수 있습니다.

이러한 키워드들이 SQL의 WHERE 절로 자동 변환되어 정확한 쿼리를 생성합니다.

코드 예제

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // 이메일로 사용자 찾기 (WHERE email = ?)
    Optional<User> findByEmail(String email);

    // 이름과 이메일로 찾기 (WHERE username = ? AND email = ?)
    User findByUsernameAndEmail(String username, String email);

    // 나이가 특정 값 이상인 사용자들 (WHERE age >= ?)
    List<User> findByAgeGreaterThanEqual(Integer age);

    // 이름에 특정 문자열 포함 (WHERE username LIKE %?%)
    List<User> findByUsernameContaining(String keyword);

    // 이메일로 존재 여부 확인 (SELECT COUNT(*) > 0)
    boolean existsByEmail(String email);

    // 나이 범위로 검색하고 이름 오름차순 정렬
    List<User> findByAgeBetweenOrderByUsernameAsc(Integer startAge, Integer endAge);
}

설명

이것이 하는 일: 쿼리 메서드는 메서드 이름을 파싱하여 적절한 JPQL 쿼리를 생성하고, 파라미터를 바인딩하여 실행한 후 결과를 Entity 객체로 변환하여 반환합니다. 첫 번째로, findByEmail() 메서드는 "find" (조회), "By" (조건 시작), "Email" (필드명)으로 구성됩니다.

Spring Data JPA는 이를 분석하여 "SELECT u FROM User u WHERE u.email = :email" 쿼리를 생성합니다. Optional로 반환하여 결과가 없을 때를 안전하게 처리할 수 있습니다.

그 다음으로, And 키워드로 여러 조건을 결합할 수 있습니다. findByUsernameAndEmail()은 두 필드 모두 일치하는 경우만 조회합니다.

메서드 파라미터의 순서는 메서드명의 필드 순서와 일치해야 합니다. GreaterThanEqual, LessThan, Between 같은 비교 연산자 키워드를 사용하면 범위 검색이 가능합니다.

findByAgeGreaterThanEqual(25)는 25세 이상의 사용자를 조회하며, "WHERE age >= 25" 조건이 생성됩니다. Containing 키워드는 LIKE 검색을 수행하며, 자동으로 와일드카드(%)를 양쪽에 추가합니다.

마지막으로, OrderBy 키워드로 정렬 조건을 추가할 수 있습니다. OrderByUsernameAsc는 username 필드를 오름차순으로 정렬합니다.

내림차순은 Desc를 사용합니다. existsBy로 시작하면 boolean을 반환하며, COUNT 쿼리를 사용하여 존재 여부만 확인하므로 성능이 좋습니다.

여러분이 이 쿼리 메서드를 사용하면 간단한 조건 검색을 매우 빠르게 구현할 수 있습니다. 메서드명이 곧 쿼리의 의도를 나타내므로 가독성이 뛰어나며, 컴파일 시점에 메서드명 오타를 발견할 수 있습니다.

또한 쿼리 메서드는 타입 안전하므로, 파라미터 타입이 맞지 않으면 컴파일 오류가 발생합니다.

실전 팁

💡 메서드 이름이 너무 길어지면 가독성이 떨어집니다. 3개 이상의 조건이 필요하면 @Query를 고려하세요.

💡 Containing은 양쪽 와일드카드(LIKE %keyword%), StartingWith는 오른쪽만(LIKE keyword%), EndingWith는 왼쪽만 사용합니다.

💡 In 키워드로 여러 값 중 하나와 일치하는 경우를 검색할 수 있습니다. findByUsernameIn(List<String> usernames).

💡 IsNull, IsNotNull로 null 체크 쿼리를 만들 수 있습니다. findByEmailIsNull()은 이메일이 없는 사용자를 조회합니다.

💡 countBy로 시작하면 개수를 반환하고, deleteBy로 시작하면 조건에 맞는 레코드를 삭제합니다.


4. @Query_어노테이션

시작하며

여러분이 복잡한 집계 쿼리나 여러 테이블을 조인하는 검색을 구현해야 할 때, 쿼리 메서드만으로는 한계가 있습니다. 메서드 이름이 지나치게 길어지거나, 표현할 수 없는 쿼리도 있기 때문입니다.

이런 상황에서 네이티브 SQL을 사용하면 데이터베이스에 종속되어 이식성이 떨어지고, Entity 중심의 객체 지향 설계와도 맞지 않습니다. 또한 쿼리 문자열에 오타가 있어도 런타임에만 발견되는 문제가 있습니다.

바로 이럴 때 필요한 것이 @Query 어노테이션입니다. JPQL이나 네이티브 SQL을 직접 작성하여 복잡한 쿼리를 정확하게 표현하고, 파라미터 바인딩을 통해 안전하게 값을 전달할 수 있습니다.

개요

간단히 말해서, @Query는 메서드에 직접 JPQL 또는 네이티브 SQL 쿼리를 작성할 수 있게 해주는 어노테이션입니다. @Query를 사용하면 쿼리 메서드로 표현하기 어려운 복잡한 검색, 집계, 조인 쿼리를 자유롭게 작성할 수 있습니다.

예를 들어, 특정 조건에 맞는 사용자의 평균 나이를 계산하거나, 여러 테이블을 조인하여 복합적인 데이터를 조회할 때 유용합니다. 기존에는 쿼리 메서드의 이름 규칙에 얽매였다면, 이제는 원하는 쿼리를 정확하게 표현할 수 있습니다.

JPQL은 Entity 기반으로 작성하여 객체 지향적이고, 데이터베이스 독립적입니다. @Param 어노테이션으로 파라미터에 이름을 부여하고, 쿼리에서 :paramName 형식으로 참조할 수 있습니다.

이러한 명명된 파라미터 방식이 가독성을 높이고 순서 오류를 방지합니다.

코드 예제

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // JPQL 쿼리 - Entity 기반 (User는 테이블명이 아닌 Entity명)
    @Query("SELECT u FROM User u WHERE u.email = :email")
    Optional<User> findByEmailCustom(@Param("email") String email);

    // 복잡한 조건의 JPQL
    @Query("SELECT u FROM User u WHERE u.age >= :minAge AND u.username LIKE %:keyword%")
    List<User> searchUsers(@Param("minAge") Integer minAge,
                           @Param("keyword") String keyword);

    // 집계 쿼리 - 평균 나이 계산
    @Query("SELECT AVG(u.age) FROM User u WHERE u.age IS NOT NULL")
    Double calculateAverageAge();

    // 네이티브 SQL 쿼리 (nativeQuery = true)
    @Query(value = "SELECT * FROM users WHERE email = :email",
           nativeQuery = true)
    User findByEmailNative(@Param("email") String email);

    // UPDATE 쿼리 - @Modifying 필수
    @Modifying
    @Query("UPDATE User u SET u.age = :age WHERE u.id = :id")
    int updateUserAge(@Param("id") Long id, @Param("age") Integer age);
}

설명

이것이 하는 일: @Query는 메서드 실행 시 지정된 쿼리를 실행하고, @Param으로 전달된 파라미터를 쿼리에 바인딩하여 결과를 Entity나 원하는 타입으로 반환합니다. 첫 번째로, JPQL 쿼리는 테이블이 아닌 Entity를 대상으로 작성합니다.

"SELECT u FROM User u"에서 User는 Entity 클래스명이고, u는 별칭입니다. :email은 명명된 파라미터로, @Param("email")로 전달된 값이 바인딩됩니다.

이 방식은 SQL 인젝션을 방지하고 타입 안전성을 보장합니다. 그 다음으로, 여러 파라미터를 사용할 수 있습니다.

searchUsers()는 최소 나이와 키워드 두 가지 조건을 받아 검색합니다. LIKE 절에서 %:keyword%처럼 와일드카드와 파라미터를 직접 조합할 수 있습니다.

파라미터 이름과 @Param의 값이 일치해야 합니다. 집계 함수(AVG, COUNT, SUM 등)를 사용하면 단일 값을 반환하는 쿼리를 만들 수 있습니다.

calculateAverageAge()는 Double을 반환하며, null 값을 제외하고 평균을 계산합니다. 이런 통계 쿼리는 쿼리 메서드로는 표현할 수 없습니다.

마지막으로, nativeQuery = true를 설정하면 데이터베이스의 실제 SQL을 사용할 수 있습니다. 이때는 테이블명과 컬럼명을 실제 데이터베이스 이름으로 작성해야 합니다.

UPDATE나 DELETE 쿼리는 @Modifying을 반드시 함께 사용해야 하며, 반환 타입은 int(영향받은 행 수)로 합니다. 여러분이 @Query를 사용하면 쿼리 메서드의 한계를 넘어 모든 종류의 쿼리를 작성할 수 있습니다.

복잡한 비즈니스 로직을 쿼리 수준에서 처리하여 성능을 최적화할 수 있고, 통계나 리포팅 기능도 쉽게 구현할 수 있습니다. 하지만 너무 복잡한 쿼리는 유지보수가 어려우므로, 쿼리 메서드와 적절히 조합하여 사용하는 것이 좋습니다.

실전 팁

💡 JPQL은 대소문자를 구분합니다. User는 Entity명, user는 별칭입니다. 키워드(SELECT, FROM)는 대소문자 구분 없습니다.

💡 @Modifying 쿼리는 반드시 @Transactional 안에서 실행해야 합니다. Service 계층에 @Transactional을 추가하세요.

💡 네이티브 쿼리는 데이터베이스 종속적이므로, 꼭 필요한 경우(DB 특화 함수 사용 등)에만 사용하세요.

💡 DTO로 결과를 받고 싶다면 생성자 표현식을 사용하세요: "SELECT new com.example.dto.UserDto(u.id, u.username) FROM User u"

💡 @Query의 쿼리는 애플리케이션 시작 시 검증됩니다. 오타가 있으면 즉시 발견할 수 있어 런타임 오류를 줄입니다.


5. 연관관계_매핑_OneToMany

시작하며

여러분이 블로그 시스템을 만들 때, 한 명의 작성자(User)가 여러 개의 게시글(Post)을 가지는 관계를 어떻게 표현하시겠어요? 데이터베이스에서는 외래 키로 관계를 표현하지만, 객체 세계에서는 참조를 사용합니다.

이런 불일치를 직접 처리하려면 게시글을 조회할 때마다 작성자를 별도로 조회하는 추가 쿼리를 작성해야 하고, 양방향 관계를 유지하는 코드도 복잡해집니다. 객체의 참조와 테이블의 외래 키를 매핑하는 작업이 번거롭습니다.

바로 이럴 때 필요한 것이 JPA의 연관관계 매핑입니다. @OneToMany와 @ManyToOne으로 객체의 참조 관계를 선언하면, JPA가 자동으로 외래 키를 관리하고 객체 그래프 탐색을 가능하게 해줍니다.

개요

간단히 말해서, @OneToMany는 일대다 관계를 나타내며, 한 Entity가 여러 Entity를 컬렉션으로 가지는 관계를 표현합니다. @OneToMany를 사용하면 부모 Entity에서 자식 Entity 컬렉션을 직접 접근할 수 있습니다.

예를 들어, user.getPosts()로 해당 사용자의 모든 게시글을 조회할 수 있으며, JPA가 자동으로 필요한 쿼리를 생성합니다. 기존에는 두 번의 쿼리로 데이터를 따로 조회하고 수동으로 조립했다면, 이제는 객체의 참조만으로 연관된 데이터에 접근할 수 있습니다.

양방향 관계에서는 @ManyToOne과 함께 사용하여 양쪽에서 탐색이 가능합니다. mappedBy 속성으로 연관관계의 주인을 지정하고, cascade로 영속성 전이를 설정하며, orphanRemoval로 고아 객체 제거를 활성화할 수 있습니다.

이러한 옵션들이 부모-자식 관계의 생명주기를 자동으로 관리해줍니다.

코드 예제

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    // 일대다 관계: 한 명의 User가 여러 Post를 가짐
    // mappedBy: 연관관계의 주인은 Post.user 필드
    // cascade: User 저장 시 Post도 함께 저장
    // orphanRemoval: User에서 제거된 Post는 DB에서도 삭제
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Post> posts = new ArrayList<>();
}

@Entity
@Table(name = "posts")
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    // 다대일 관계: 여러 Post가 한 명의 User에 속함
    // 외래 키 컬럼명은 user_id
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
}

설명

이것이 하는 일: 연관관계 매핑은 객체의 참조와 테이블의 외래 키를 자동으로 동기화하고, 한 Entity에서 연관된 Entity로의 탐색을 가능하게 하며, 설정에 따라 영속성 전이와 생명주기를 관리합니다. 첫 번째로, User Entity의 @OneToMany는 Post 컬렉션을 가집니다.

mappedBy = "user"는 Post Entity의 user 필드가 실제 외래 키를 관리하는 연관관계의 주인임을 나타냅니다. 일대다 관계에서는 "다" 쪽이 외래 키를 가지므로, Post가 주인이 되는 것이 자연스럽습니다.

그 다음으로, Post Entity의 @ManyToOne이 실제 외래 키를 관리합니다. @JoinColumn(name = "user_id")는 데이터베이스의 user_id 컬럼이 외래 키임을 지정합니다.

fetch = FetchType.LAZY는 Post를 조회할 때 User는 즉시 로딩하지 않고, 실제 사용 시점에 조회하는 지연 로딩을 설정합니다. cascade = CascadeType.ALL은 User에 대한 모든 영속성 작업(저장, 삭제 등)이 Post에도 전파됩니다.

user.getPosts().add(post)로 Post를 추가하고 user를 저장하면, post도 자동으로 저장됩니다. 이는 부모-자식 관계에서 편리하지만, 의도하지 않은 삭제가 발생할 수 있으므로 신중히 사용해야 합니다.

마지막으로, orphanRemoval = true는 부모 Entity와의 관계가 끊어진 자식 Entity를 자동으로 삭제합니다. user.getPosts().remove(post)를 실행하면 해당 post가 데이터베이스에서도 삭제됩니다.

이는 cascade와 함께 사용하여 완전한 생명주기 관리를 제공합니다. 여러분이 연관관계 매핑을 사용하면 객체 지향적으로 데이터를 다룰 수 있습니다.

외래 키를 직접 관리하지 않고도 객체 참조만으로 연관된 데이터를 조회하고 수정할 수 있으며, cascade 옵션으로 복잡한 저장/삭제 로직을 단순화할 수 있습니다. 하지만 잘못 사용하면 N+1 문제나 성능 이슈가 발생할 수 있으므로, fetch 전략과 쿼리 최적화를 함께 고려해야 합니다.

실전 팁

💡 양방향 관계에서는 연관관계 편의 메서드를 만드세요. user.addPost(post) 메서드 안에서 post.setUser(this)도 함께 호출하여 양쪽을 동기화하세요.

💡 @OneToMany의 기본 fetch 전략은 LAZY입니다. 항상 LAZY를 사용하고, 필요한 경우에만 fetch join으로 즉시 로딩하세요.

💡 cascade는 신중히 사용하세요. 의도하지 않은 데이터 삭제를 방지하려면 CascadeType.PERSIST와 CascadeType.MERGE만 사용하는 것도 방법입니다.

💡 양방향 관계는 정말 필요한 경우에만 사용하세요. 단방향(@ManyToOne만)으로도 대부분의 요구사항을 충족할 수 있습니다.

💡 컬렉션 필드는 절대 null로 두지 마세요. new ArrayList<>()로 초기화하여 NullPointerException을 방지하세요.


6. Fetch_Join과_N+1_문제

시작하며

여러분이 10명의 사용자와 각 사용자의 게시글을 조회할 때, 쿼리가 몇 번 실행될까요? 사용자 조회 1번, 각 사용자의 게시글 조회 10번, 총 11번의 쿼리가 실행됩니다.

이것이 바로 악명 높은 N+1 문제입니다. 이런 성능 문제는 지연 로딩(LAZY)과 연관관계 매핑을 사용할 때 자주 발생합니다.

반복문 안에서 연관 Entity에 접근할 때마다 추가 쿼리가 실행되어, 데이터가 많아지면 응답 시간이 급격히 증가합니다. 바로 이럴 때 필요한 것이 Fetch Join입니다.

하나의 쿼리로 연관된 Entity를 함께 조회하여, N+1 문제를 근본적으로 해결하고 성능을 대폭 향상시킬 수 있습니다.

개요

간단히 말해서, Fetch Join은 연관된 Entity를 한 번의 쿼리로 함께 조회하는 JPQL의 기능으로, N+1 문제를 해결하는 가장 효과적인 방법입니다. Fetch Join을 사용하면 지연 로딩으로 인한 추가 쿼리가 발생하지 않습니다.

예를 들어, 사용자와 게시글을 함께 조회할 때 JOIN을 사용하여 한 번의 쿼리로 모든 데이터를 가져옵니다. 기존에는 즉시 로딩(EAGER)으로 설정하거나 여러 번의 쿼리를 실행했다면, 이제는 필요한 시점에 선택적으로 함께 조회할 수 있습니다.

LAZY를 기본으로 유지하면서, 필요할 때만 fetch join으로 최적화하는 것이 권장 패턴입니다. @Query에서 "JOIN FETCH" 키워드를 사용하고, @EntityGraph로 간단하게 표현할 수도 있습니다.

이러한 방법들이 SQL의 JOIN을 활용하여 효율적으로 데이터를 가져옵니다.

코드 예제

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // N+1 문제 발생 (LAZY 로딩)
    // 1. 사용자 조회: SELECT * FROM users
    // 2. 각 사용자의 게시글 조회: SELECT * FROM posts WHERE user_id = ? (N번 반복)
    List<User> findAll();

    // Fetch Join으로 해결
    // 한 번의 쿼리로 User와 Post를 함께 조회
    // SELECT u, p FROM User u JOIN FETCH u.posts p
    @Query("SELECT DISTINCT u FROM User u JOIN FETCH u.posts")
    List<User> findAllWithPosts();

    // @EntityGraph 사용 (더 간단한 방법)
    @EntityGraph(attributePaths = {"posts"})
    @Query("SELECT u FROM User u")
    List<User> findAllWithPostsGraph();

    // 특정 사용자의 게시글만 fetch join
    @Query("SELECT u FROM User u JOIN FETCH u.posts WHERE u.id = :id")
    Optional<User> findByIdWithPosts(@Param("id") Long id);
}

// 실제 사용 예시
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    public List<User> getUsersWithPosts() {
        // N+1 문제 없이 한 번의 쿼리로 조회
        return userRepository.findAllWithPosts();
    }
}

설명

이것이 하는 일: Fetch Join은 JPQL의 JOIN FETCH 구문으로 연관된 Entity를 즉시 로딩하고, SQL의 JOIN을 사용하여 한 번의 데이터베이스 왕복으로 모든 데이터를 가져와 N+1 문제를 방지합니다. 첫 번째로, N+1 문제가 발생하는 상황을 이해해야 합니다.

findAll()로 User를 조회하면 1번의 쿼리가 실행되고, 각 User의 posts에 접근할 때마다(지연 로딩) 추가 쿼리가 N번 실행됩니다. 10명의 사용자면 총 11번, 100명이면 101번의 쿼리가 실행되어 성능이 급격히 저하됩니다.

그 다음으로, "JOIN FETCH u.posts"가 핵심입니다. 이는 User와 Post를 INNER JOIN하여 한 번에 조회하고, Post를 즉시 로딩(프록시가 아닌 실제 객체)합니다.

DISTINCT는 중복된 User를 제거하기 위해 사용됩니다(OneToMany에서 JOIN 시 User가 중복될 수 있음). @EntityGraph는 더 간단한 대안입니다.

attributePaths = {"posts"}로 함께 로딩할 연관관계를 지정하면, 자동으로 fetch join을 수행합니다. 쿼리 메서드에도 사용할 수 있어 @Query를 작성하지 않아도 됩니다.

LEFT OUTER JOIN을 사용하므로 연관 데이터가 없어도 부모 Entity는 조회됩니다. 마지막으로, fetch join은 필요한 곳에만 선택적으로 적용해야 합니다.

모든 조회에 fetch join을 적용하면 불필요한 데이터까지 로딩하여 오히려 성능이 저하될 수 있습니다. LAZY를 기본으로 하고, 연관 데이터가 확실히 필요한 경우에만 fetch join을 사용하는 것이 최적의 전략입니다.

여러분이 Fetch Join을 사용하면 N+1 문제로 인한 성능 저하를 완전히 막을 수 있습니다. 수백 번의 쿼리가 단 한 번으로 줄어들어 응답 시간이 대폭 개선되며, 데이터베이스 부하도 크게 감소합니다.

실무에서 JPA 성능 최적화의 가장 중요한 기법이므로, 반드시 숙지하고 적용해야 합니다.

실전 팁

💡 OneToMany fetch join에서는 DISTINCT를 반드시 사용하세요. 중복된 부모 Entity를 제거하여 정확한 결과를 얻을 수 있습니다.

💡 fetch join은 별칭을 사용할 수 없습니다. "JOIN FETCH u.posts p WHERE p.status = 'ACTIVE'" 같은 필터링은 불가능합니다.

💡 페이징과 fetch join을 함께 사용하면 경고가 발생합니다. 메모리에서 페이징이 처리되므로 데이터가 많으면 위험합니다. 이 경우 @BatchSize를 고려하세요.

💡 여러 컬렉션을 동시에 fetch join할 수 없습니다(MultipleBagFetchException). 한 번에 하나의 컬렉션만 fetch join하세요.

💡 개발 환경에서 spring.jpa.show-sql=true로 실제 실행되는 SQL을 확인하면서 N+1 문제를 모니터링하세요.


7. 페이징과_정렬

시작하며

여러분이 수천 개의 게시글을 한 번에 조회하면 어떤 일이 벌어질까요? 메모리 부족, 느린 응답 시간, 데이터베이스 과부하 등 심각한 문제가 발생합니다.

실무에서는 대용량 데이터를 효율적으로 나누어 조회하는 것이 필수입니다. 이런 요구사항을 직접 구현하려면 LIMIT, OFFSET을 사용한 SQL을 작성하고, 전체 개수를 세는 COUNT 쿼리도 별도로 실행해야 합니다.

또한 정렬 조건도 동적으로 처리해야 하므로 코드가 복잡해집니다. 바로 이럴 때 필요한 것이 Spring Data JPA의 페이징 기능입니다.

Pageable 인터페이스와 Page 반환 타입을 사용하면, 복잡한 페이징 로직을 자동으로 처리하고 전체 페이지 수, 현재 페이지, 정렬 정보까지 제공받을 수 있습니다.

개요

간단히 말해서, 페이징은 대용량 데이터를 페이지 단위로 나누어 조회하는 기능으로, Pageable과 Page를 사용하여 쉽게 구현할 수 있습니다. 페이징을 사용하면 필요한 만큼만 데이터를 조회하여 메모리와 네트워크 비용을 절약할 수 있습니다.

예를 들어, 게시판에서 한 페이지에 20개씩 게시글을 보여줄 때, 전체 데이터가 아닌 현재 페이지의 20개만 조회합니다. 기존에는 LIMIT과 OFFSET을 직접 계산하고 SQL에 포함시켰다면, 이제는 PageRequest 객체만 생성하면 됩니다.

Spring Data JPA가 자동으로 쿼리에 페이징 조건을 추가하고, 전체 개수도 조회합니다. Pageable 인터페이스로 페이지 번호, 크기, 정렬 조건을 전달하고, Page 객체로 결과 데이터와 함께 페이징 메타데이터를 받습니다.

이러한 추상화가 일관된 방식으로 페이징을 처리할 수 있게 해줍니다.

코드 예제

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // 기본 페이징 - findAll()이 Pageable 지원
    // 자동으로 COUNT 쿼리도 실행됨
    Page<User> findAll(Pageable pageable);

    // 조건 검색 + 페이징
    Page<User> findByAgeGreaterThan(Integer age, Pageable pageable);

    // 정렬만 필요한 경우 (페이징 없이)
    List<User> findByUsername(String username, Sort sort);
}

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    public Page<User> getUsers(int page, int size) {
        // PageRequest.of(페이지 번호, 크기, 정렬 조건)
        // 페이지는 0부터 시작
        Pageable pageable = PageRequest.of(page, size,
                                           Sort.by("username").ascending());

        Page<User> userPage = userRepository.findAll(pageable);

        // Page 객체의 유용한 메서드들
        System.out.println("전체 요소 수: " + userPage.getTotalElements());
        System.out.println("전체 페이지 수: " + userPage.getTotalPages());
        System.out.println("현재 페이지 번호: " + userPage.getNumber());
        System.out.println("다음 페이지 존재: " + userPage.hasNext());

        return userPage;
    }
}

설명

이것이 하는 일: 페이징 기능은 Pageable 파라미터를 분석하여 LIMIT과 OFFSET을 쿼리에 추가하고, 별도의 COUNT 쿼리를 실행하여 전체 개수를 계산한 후, 결과와 메타데이터를 Page 객체에 담아 반환합니다. 첫 번째로, Repository 메서드에 Pageable 파라미터를 추가하면 자동으로 페이징이 활성화됩니다.

반환 타입을 Page<T>로 하면 결과 데이터와 함께 전체 개수, 페이지 수 등의 메타데이터를 제공합니다. 반환 타입이 List<T>면 페이징만 적용되고 메타데이터는 제공되지 않습니다.

그 다음으로, PageRequest.of()로 Pageable 객체를 생성합니다. 첫 번째 파라미터는 페이지 번호(0부터 시작), 두 번째는 페이지 크기, 세 번째는 정렬 조건입니다.

Sort.by("username").ascending()은 username 필드로 오름차순 정렬하며, descending()으로 내림차순도 가능합니다. 여러 필드로 정렬하려면 Sort.by("age", "username")처럼 여러 개를 나열합니다.

실제 쿼리를 보면 "SELECT * FROM users ORDER BY username LIMIT 20 OFFSET 0" 형태로 변환됩니다. LIMIT는 페이지 크기, OFFSET은 (페이지 번호 × 페이지 크기)로 계산됩니다.

또한 "SELECT COUNT(*) FROM users" 쿼리가 자동으로 실행되어 전체 개수를 얻습니다. 마지막으로, Page 객체는 풍부한 메타데이터를 제공합니다.

getTotalElements()는 전체 데이터 개수, getTotalPages()는 전체 페이지 수, getNumber()는 현재 페이지 번호(0 기반), hasNext()는 다음 페이지 존재 여부를 반환합니다. getContent()로 실제 데이터 리스트를 가져올 수 있습니다.

여러분이 페이징을 사용하면 대용량 데이터를 안전하게 다룰 수 있습니다. 메모리 사용량을 제한하고 응답 시간을 개선하며, 사용자에게 페이지 네비게이션을 제공할 수 있습니다.

REST API에서 Page 객체를 JSON으로 반환하면 프론트엔드에서 페이지네이션 UI를 쉽게 구현할 수 있습니다.

실전 팁

💡 페이지 번호는 0부터 시작하므로 주의하세요. 사용자에게는 1부터 보여주되, PageRequest에는 (page - 1)을 전달하세요.

💡 페이지 크기는 적절히 설정하세요. 너무 크면 메모리 문제가, 너무 작으면 쿼리 횟수가 증가합니다. 보통 10~50 사이가 적당합니다.

💡 COUNT 쿼리가 부담스러우면 Slice를 사용하세요. 전체 개수를 조회하지 않고 다음 페이지 존재 여부만 확인합니다.

💡 복잡한 쿼리에서는 @Query로 직접 COUNT 쿼리를 최적화할 수 있습니다: @Query(value = "SELECT u FROM User u", countQuery = "SELECT COUNT(u) FROM User u")

💡 Spring MVC에서는 @PageableDefault로 기본 페이징 설정을 지정하고, Controller 파라미터로 Pageable을 받으면 자동으로 바인딩됩니다.


8. Auditing_생성일시와_수정일시_자동_관리

시작하며

여러분이 게시글이 언제 작성되고 수정되었는지 추적해야 한다면, 매번 저장 전에 현재 시간을 수동으로 설정하는 코드를 작성하시겠어요? 모든 Entity에 이런 반복적인 코드를 추가하는 것은 번거롭고 실수하기 쉽습니다.

이런 시간 정보는 감사(audit) 목적으로 거의 모든 테이블에 필요합니다. 하지만 Service 계층에서 일일이 setCreatedAt(LocalDateTime.now())를 호출하면 코드가 지저분해지고, 깜빡 잊으면 시간 정보가 저장되지 않습니다.

바로 이럴 때 필요한 것이 JPA Auditing입니다. @CreatedDate와 @LastModifiedDate 어노테이션만 추가하면, JPA가 자동으로 생성일시와 수정일시를 관리하여 개발자가 신경 쓸 필요가 없습니다.

개요

간단히 말해서, JPA Auditing은 Entity의 생성 시간, 수정 시간, 생성자, 수정자를 자동으로 관리해주는 기능입니다. Auditing을 사용하면 Entity를 저장하거나 수정할 때 시간 정보가 자동으로 채워집니다.

예를 들어, 게시글을 처음 저장하면 createdAt이 현재 시간으로 설정되고, 이후 수정할 때마다 updatedAt이 자동으로 갱신됩니다. 기존에는 @PrePersist, @PreUpdate 같은 콜백 메서드를 직접 구현했다면, 이제는 선언적으로 어노테이션만 추가하면 됩니다.

Spring Data JPA의 AuditorAware 인터페이스로 현재 사용자 정보도 자동으로 기록할 수 있습니다. @EntityListeners와 @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy 어노테이션을 조합하여 완전한 감사 추적 기능을 제공합니다.

이러한 메타데이터가 규정 준수, 디버깅, 데이터 분석에 매우 유용합니다.

코드 예제

// 1. Auditing 활성화 (Spring Boot 메인 클래스나 설정 클래스에)
@SpringBootApplication
@EnableJpaAuditing
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

// 2. 공통 필드를 가진 Base Entity 생성
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false) // 생성 후 수정 불가
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

// 3. 실제 Entity는 Base Entity를 상속
@Entity
public class Post extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String content;

    // createdAt, updatedAt은 자동으로 관리됨
}

설명

이것이 하는 일: JPA Auditing은 Entity의 생명주기 이벤트를 감지하여, 저장 시점에 생성 시간을 기록하고, 수정 시점마다 수정 시간을 자동으로 갱신하여 개발자가 수동으로 시간을 설정할 필요를 없앱니다. 첫 번째로, @EnableJpaAuditing이 Spring에게 Auditing 기능을 활성화하도록 지시합니다.

이 어노테이션을 설정 클래스에 추가하면, AuditingEntityListener가 활성화되어 Entity의 생명주기 이벤트를 모니터링하기 시작합니다. 그 다음으로, @MappedSuperclass로 공통 필드를 가진 Base Entity를 만듭니다.

이 클래스는 직접 테이블에 매핑되지 않고, 상속받는 Entity들의 필드로 포함됩니다. @EntityListeners(AuditingEntityListener.class)가 이 Entity의 이벤트를 감지하도록 등록합니다.

@CreatedDate가 붙은 필드는 Entity가 처음 persist될 때(INSERT) 현재 시간으로 자동 설정됩니다. updatable = false 옵션으로 이후 수정 시에도 이 값이 변경되지 않도록 보호합니다.

@LastModifiedDate는 persist와 merge(UPDATE) 시점에 매번 현재 시간으로 갱신됩니다. 마지막으로, 실제 Entity는 BaseEntity를 상속받기만 하면 됩니다.

Post를 저장하면 JPA가 자동으로 createdAt과 updatedAt을 채워줍니다. Service 코드에서 이러한 필드를 전혀 신경 쓸 필요 없이, 비즈니스 로직에만 집중할 수 있습니다.

여러분이 JPA Auditing을 사용하면 모든 Entity에 일관된 감사 추적 기능을 적용할 수 있습니다. 데이터가 언제 생성되고 수정되었는지 정확히 추적할 수 있어, 문제 발생 시 디버깅이 쉬워지고, 규정 준수 요구사항도 충족할 수 있습니다.

코드 중복이 제거되고 실수로 시간 정보를 빠뜨리는 일도 없어집니다.

실전 팁

💡 모든 Entity에 공통으로 필요한 필드는 BaseEntity에 모아두세요. id, createdAt, updatedAt 같은 필드를 한 곳에서 관리할 수 있습니다.

💡 생성자와 수정자 정보도 기록하려면 @CreatedBy와 @LastModifiedBy를 사용하고, AuditorAware를 구현하여 현재 사용자를 제공하세요.

💡 createdAt에는 반드시 updatable = false를 설정하세요. 생성 시간은 변경되어서는 안 되기 때문입니다.

💡 LocalDateTime 대신 Instant를 사용하면 타임존 문제를 피할 수 있습니다. UTC 기준으로 저장되므로 글로벌 서비스에 적합합니다.

💡 @EnableJpaAuditing(dateTimeProviderRef = "customDateTimeProvider")로 커스텀 시간 제공자를 지정할 수 있습니다. 테스트에서 시간을 고정할 때 유용합니다.


#Java#SpringDataJPA#JPA#Repository#QueryMethods