[Springboot] JPA란?

건희·2025년 6월 22일

목차

  1. JPA란?
  2. JPA의 핵심 개념
  3. JPA vs MyBatis vs JDBC
  4. JPA의 장단점
  5. Spring Data JPA
  6. 실제 프로젝트 예제
  7. JPA 어노테이션
  8. JPA 쿼리 방법
  9. JPA 생명주기
  10. 성능 최적화
  11. 모범 사례

JPA란?

JPA(Java Persistence API)는 Java 객체와 데이터베이스 테이블 간의 매핑을 위한 Java 표준 ORM(Object-Relational Mapping) 기술이다.

핵심 개념

  • ORM(Object-Relational Mapping): 객체와 관계형 데이터베이스 간의 매핑
  • 표준: Java EE(현재 Jakarta EE)의 표준 스펙
  • 구현체: Hibernate, EclipseLink, OpenJPA 등
  • 목적: SQL 작성 없이 객체 지향적으로 데이터베이스 조작

JPA의 역할

// JPA 없이 (JDBC)
String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setLong(1, userId);
ResultSet rs = pstmt.executeQuery();
User user = new User();
if (rs.next()) {
    user.setId(rs.getLong("id"));
    user.setName(rs.getString("name"));
    user.setEmail(rs.getString("email"));
}

// JPA 사용
User user = userRepository.findById(userId).orElse(null);

JPA의 핵심 개념

1. Entity (엔티티)

데이터베이스 테이블과 매핑되는 Java 클래스

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "user_name", nullable = false)
    private String name;
    
    @Column(unique = true)
    private String email;
    
    // getters and setters
}

2. Persistence Context (영속성 컨텍스트)

엔티티를 영구 저장하는 환경으로, 엔티티의 생명주기를 관리

3. EntityManager

엔티티를 관리하는 인터페이스로, CRUD 작업을 수행

4. Transaction (트랜잭션)

데이터베이스 작업의 원자성을 보장하는 단위


JPA vs MyBatis vs JDBC

구분JPAMyBatisJDBC
레벨ORMSQL MapperDatabase API
SQL 작성자동 생성수동 작성수동 작성
객체 지향⭐⭐⭐⭐⭐⭐⭐⭐
성능⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
학습 곡선⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
유지보수⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

각각의 특징

JPA

  • 객체 지향적 설계
  • SQL 자동 생성
  • 데이터베이스 독립적
  • 복잡한 쿼리 최적화 어려움

MyBatis

  • SQL 직접 제어
  • 성능 최적화 용이
  • 복잡한 쿼리 처리 강점
  • 반복적인 CRUD 작업 번거로움

JDBC

  • 가장 낮은 레벨
  • 완전한 SQL 제어
  • 최고 성능
  • 반복적인 보일러플레이트 코드

JPA의 장단점

장점

1. 생산성 향상

// JPA 사용 시
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // 기본 CRUD 메서드 자동 제공
    // findById, save, delete, findAll 등
}

// 사용 예시
User user = userRepository.findById(1L).orElse(null);
userRepository.save(newUser);
userRepository.deleteById(1L);

2. 객체 지향적 설계

@Entity
public class User {
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Order> orders = new ArrayList<>();
    
    public void addOrder(Order order) {
        orders.add(order);
        order.setUser(this);
    }
}

3. 데이터베이스 독립성

# H2 (개발)
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

# MySQL (운영)
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect

# PostgreSQL (운영)
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect

4. 타입 안전성

// 컴파일 타임에 오류 검출
List<User> users = userRepository.findByName("John"); // 타입 안전

단점

1. 학습 곡선

  • JPA 개념 이해 필요
  • 성능 최적화 복잡

2. 성능 오버헤드

  • N+1 문제
  • 불필요한 쿼리 발생 가능

3. 복잡한 쿼리 처리

// 복잡한 쿼리는 JPQL이나 네이티브 쿼리 필요
@Query("SELECT u FROM User u JOIN u.orders o WHERE o.total > :amount")
List<User> findUsersWithHighValueOrders(@Param("amount") BigDecimal amount);

Spring Data JPA

Spring Data JPA는 JPA를 더욱 쉽게 사용할 수 있도록 도와주는 Spring 모듈이다.

1. Repository 인터페이스

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // 기본 CRUD 메서드 자동 제공
    // findById, save, delete, findAll, count 등
    
    // 메서드 이름으로 쿼리 생성
    List<User> findByName(String name);
    List<User> findByEmailContaining(String email);
    Optional<User> findByEmail(String email);
    
    // @Query 어노테이션으로 커스텀 쿼리
    @Query("SELECT u FROM User u WHERE u.age > :age")
    List<User> findUsersOlderThan(@Param("age") int age);
}

2. 자동 구현체 생성

Spring Data JPA는 런타임에 Repository 인터페이스의 구현체를 자동으로 생성한다.

// 개발자가 작성하는 코드
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByName(String name);
}

// Spring이 자동으로 생성하는 구현체 (개념적)
public class UserRepositoryImpl implements UserRepository {
    @Override
    public List<User> findByName(String name) {
        // JPA를 사용한 실제 구현
        return entityManager.createQuery(
            "SELECT u FROM User u WHERE u.name = :name", User.class)
            .setParameter("name", name)
            .getResultList();
    }
}

실제 프로젝트 예제

1. Entity 정의

@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "user_name", nullable = false, length = 100)
    private String name;
    
    @Column(unique = true, nullable = false)
    private String email;
    
    @Column(nullable = false)
    private String password;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private UserRole role = UserRole.USER;
    
    @Column(name = "created_at")
    @CreationTimestamp
    private LocalDateTime createdAt;
    
    @Column(name = "updated_at")
    @UpdateTimestamp
    private LocalDateTime updatedAt;
    
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<RefreshToken> refreshTokens = new ArrayList<>();
}

@Entity
@Table(name = "refresh_tokens")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true)
    private String token;
    
    @Column(name = "expiry_date", nullable = false)
    private LocalDateTime expiryDate;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
}

2. Repository 정의

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // 메서드 이름으로 쿼리 생성
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    List<User> findByRole(UserRole role);
    
    // @Query로 커스텀 쿼리
    @Query("SELECT u FROM User u WHERE u.createdAt >= :startDate")
    List<User> findUsersCreatedAfter(@Param("startDate") LocalDateTime startDate);
    
    // 네이티브 쿼리
    @Query(value = "SELECT * FROM users WHERE user_name LIKE %:keyword%", nativeQuery = true)
    List<User> findUsersByNameKeyword(@Param("keyword") String keyword);
}

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    
    Optional<RefreshToken> findByToken(String token);
    
    @Query("SELECT rt FROM RefreshToken rt WHERE rt.user.id = :userId AND rt.expiryDate > :now")
    List<RefreshToken> findValidTokensByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now);
    
    void deleteByUser(User user);
    
    @Modifying
    @Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < :now")
    void deleteExpiredTokens(@Param("now") LocalDateTime now);
}

3. Service에서 사용

@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
    
    private final UserRepository userRepository;
    private final RefreshTokenRepository refreshTokenRepository;
    private final PasswordEncoder passwordEncoder;
    
    public User createUser(User user) {
        // 이메일 중복 검사
        if (userRepository.existsByEmail(user.getEmail())) {
            throw new RuntimeException("이미 존재하는 이메일입니다.");
        }
        
        // 비밀번호 암호화
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        
        return userRepository.save(user);
    }
    
    public User findByEmail(String email) {
        return userRepository.findByEmail(email)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
    }
    
    public List<User> findUsersByRole(UserRole role) {
        return userRepository.findByRole(role);
    }
    
    public void deleteUser(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        
        // 연관된 refresh token도 함께 삭제
        refreshTokenRepository.deleteByUser(user);
        userRepository.delete(user);
    }
}

4. Controller에서 사용

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
    
    private final UserService userService;
    
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(user);
    }
    
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody @Valid User user) {
        User createdUser = userService.createUser(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
    }
    
    @GetMapping("/role/{role}")
    public ResponseEntity<List<User>> getUsersByRole(@PathVariable UserRole role) {
        List<User> users = userService.findUsersByRole(role);
        return ResponseEntity.ok(users);
    }
}

JPA 어노테이션

1. 기본 어노테이션

@Entity                 // 엔티티 클래스임을 명시
@Table(name = "users")  // 테이블 이름 지정
@Id                    // 기본키 지정
@GeneratedValue        // 기본키 자동 생성
@Column               // 컬럼 매핑
@Transient            // 데이터베이스에 저장하지 않음

2. 관계 매핑 어노테이션

@OneToOne             // 1:1 관계
@OneToMany            // 1:N 관계
@ManyToOne            // N:1 관계
@ManyToMany           // N:N 관계
@JoinColumn           // 외래키 컬럼 지정
@JoinTable            // 조인 테이블 지정

3. 상속 매핑 어노테이션

@Inheritance          // 상속 매핑
@DiscriminatorColumn  // 구분 컬럼
@DiscriminatorValue   // 구분 값

4. 쿼리 어노테이션

@Query               // JPQL 쿼리 정의
@NamedQuery          // 이름이 있는 쿼리
@NamedQueries        // 여러 개의 이름이 있는 쿼리

JPA 쿼리 방법

1. 메서드 이름으로 쿼리 생성

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // 기본 패턴
    List<User> findByName(String name);
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    
    // 조건 조합
    List<User> findByNameAndEmail(String name, String email);
    List<User> findByNameOrEmail(String name, String email);
    
    // 비교 연산자
    List<User> findByAgeGreaterThan(int age);
    List<User> findByAgeLessThanEqual(int age);
    List<User> findByAgeBetween(int minAge, int maxAge);
    
    // 문자열 패턴
    List<User> findByNameContaining(String name);
    List<User> findByNameStartingWith(String prefix);
    List<User> findByNameEndingWith(String suffix);
    List<User> findByNameLike(String pattern);
    
    // 정렬
    List<User> findByNameOrderByAgeAsc(String name);
    List<User> findByNameOrderByAgeDesc(String name);
    
    // 페이징
    Page<User> findByName(String name, Pageable pageable);
    
    // 제한
    List<User> findTop3ByOrderByAgeDesc();
    List<User> findFirstByName(String name);
}

2. @Query 어노테이션

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // JPQL 쿼리
    @Query("SELECT u FROM User u WHERE u.age > :age")
    List<User> findUsersOlderThan(@Param("age") int age);
    
    // 복잡한 조건
    @Query("SELECT u FROM User u WHERE u.name LIKE %:keyword% OR u.email LIKE %:keyword%")
    List<User> searchUsers(@Param("keyword") String keyword);
    
    // 조인 쿼리
    @Query("SELECT u FROM User u JOIN u.orders o WHERE o.total > :amount")
    List<User> findUsersWithHighValueOrders(@Param("amount") BigDecimal amount);
    
    // 집계 함수
    @Query("SELECT COUNT(u) FROM User u WHERE u.role = :role")
    long countUsersByRole(@Param("role") UserRole role);
    
    // 네이티브 쿼리
    @Query(value = "SELECT * FROM users WHERE created_at >= :date", nativeQuery = true)
    List<User> findUsersCreatedAfter(@Param("date") LocalDate date);
}

3. Specification (동적 쿼리)

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

// 사용 예시
@Service
public class UserService {
    
    public List<User> findUsers(UserSearchCriteria criteria) {
        Specification<User> spec = Specification.where(null);
        
        if (criteria.getName() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.like(root.get("name"), "%" + criteria.getName() + "%"));
        }
        
        if (criteria.getMinAge() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.greaterThanOrEqualTo(root.get("age"), criteria.getMinAge()));
        }
        
        if (criteria.getRole() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.equal(root.get("role"), criteria.getRole()));
        }
        
        return userRepository.findAll(spec);
    }
}

JPA 생명주기

1. 엔티티 상태

// 1. New (비영속)
User user = new User();
user.setName("John");

// 2. Managed (영속)
entityManager.persist(user); // 또는 repository.save(user)

// 3. Detached (준영속)
entityManager.detach(user); // 또는 트랜잭션 종료

// 4. Removed (삭제)
entityManager.remove(user); // 또는 repository.delete(user)

2. 생명주기 이벤트

@Entity
@EntityListeners(AuditingEntityListener.class)
public class User {
    
    @PrePersist
    public void prePersist() {
        System.out.println("저장 전 호출");
        this.createdAt = LocalDateTime.now();
    }
    
    @PostPersist
    public void postPersist() {
        System.out.println("저장 후 호출");
    }
    
    @PreUpdate
    public void preUpdate() {
        System.out.println("수정 전 호출");
        this.updatedAt = LocalDateTime.now();
    }
    
    @PostUpdate
    public void postUpdate() {
        System.out.println("수정 후 호출");
    }
    
    @PreRemove
    public void preRemove() {
        System.out.println("삭제 전 호출");
    }
    
    @PostRemove
    public void postRemove() {
        System.out.println("삭제 후 호출");
    }
    
    @PostLoad
    public void postLoad() {
        System.out.println("조회 후 호출");
    }
}

성능 최적화

1. N+1 문제 해결

// ❌ N+1 문제 발생
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findAll(); // 각 User마다 orders를 별도 쿼리로 조회
}

// ✅ Fetch Join 사용
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    @Query("SELECT DISTINCT u FROM User u JOIN FETCH u.orders")
    List<User> findAllWithOrders();
}

// ✅ @EntityGraph 사용
@Entity
public class User {
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    @EntityGraph(attributePaths = {"orders"})
    private List<Order> orders;
}

2. 페이징 최적화

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // 페이징
    Page<User> findByName(String name, Pageable pageable);
    
    // 슬라이싱 (카운트 쿼리 없음)
    Slice<User> findByName(String name, Pageable pageable);
    
    // 커스텀 카운트 쿼리
    @Query(value = "SELECT u FROM User u WHERE u.name = :name",
           countQuery = "SELECT COUNT(u.id) FROM User u WHERE u.name = :name")
    Page<User> findByNameWithCustomCount(@Param("name") String name, Pageable pageable);
}

3. 배치 처리

@Service
@Transactional
public class UserService {
    
    public void batchInsert(List<User> users) {
        int batchSize = 100;
        for (int i = 0; i < users.size(); i += batchSize) {
            List<User> batch = users.subList(i, Math.min(i + batchSize, users.size()));
            userRepository.saveAll(batch);
            entityManager.flush();
            entityManager.clear();
        }
    }
}

모범 사례

1. 엔티티 설계

@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, length = 100)
    private String name;
    
    @Column(unique = true, nullable = false)
    private String email;
    
    @Column(nullable = false)
    private String password;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private UserRole role;
    
    @CreatedDate
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
    
    @Version
    private Long version; // 낙관적 락
    
    // 연관관계는 LAZY 로딩
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
    
    // 편의 메서드
    public void addOrder(Order order) {
        orders.add(order);
        order.setUser(this);
    }
}

2. Repository 설계

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // 기본 CRUD는 JpaRepository에서 제공
    
    // 비즈니스 로직에 맞는 메서드명
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    
    // 복잡한 쿼리는 @Query 사용
    @Query("SELECT u FROM User u WHERE u.createdAt >= :startDate AND u.role = :role")
    List<User> findActiveUsersByRole(@Param("startDate") LocalDateTime startDate, 
                                   @Param("role") UserRole role);
    
    // 성능 최적화를 위한 페이징
    Page<User> findByNameContaining(String name, Pageable pageable);
}

3. Service 설계

@Service
@Transactional(readOnly = true) // 읽기 전용 트랜잭션
@RequiredArgsConstructor
public class UserService {
    
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    @Transactional // 쓰기 트랜잭션
    public User createUser(User user) {
        // 비즈니스 로직 검증
        if (userRepository.existsByEmail(user.getEmail())) {
            throw new RuntimeException("이미 존재하는 이메일입니다.");
        }
        
        // 데이터 변환
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        
        return userRepository.save(user);
    }
    
    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
    }
    
    @Transactional
    public void deleteUser(Long id) {
        User user = findById(id);
        userRepository.delete(user);
    }
}

마무리

JPA는 Java 개발자에게 객체 지향적인 데이터베이스 접근 방식을 제공하는 강력한 ORM 기술이다.

핵심 포인트:

  • 객체 지향적 설계: SQL 없이 객체로 데이터베이스 조작
  • 생산성 향상: 반복적인 CRUD 코드 자동화
  • 데이터베이스 독립성: 다양한 데이터베이스 지원
  • Spring Data JPA: 더욱 간편한 JPA 사용

JPA를 올바르게 사용하면 개발 생산성을 크게 향상시키고, 유지보수 가능한 코드를 작성할 수 있다. 다만 성능 최적화와 복잡한 쿼리 처리에 대한 이해가 필요하므로, 프로젝트의 요구사항에 맞게 적절히 선택하여 사용하는 것이 중요하다.

profile
💻 🍎

0개의 댓글