JPA
JPA
ORM(Object Relational Mapping) : 객체를 관계형 데이터베이스의 테이블과 매핑해 SQL 없이도 데이터 조작이 가능한 기술로, JPA는 자바에서 ORM을 표준화한 인터페이스이며 실제 구현체로는 Hibernate, EclipseLink, OpenJPA 등이 있다.
그럼 지금까지 배운 JDBC와 MyBatis, JPA의 특징을 비교하면 다음과 같은 차이점을 볼 수 있다.
| 특징 | JDBC | MyBatis | JPA |
|---|---|---|---|
| SQL 직접 작성 필요 | O | O | X |
| 자동 매핑 | X | O | O |
| 객체 지향적 | X | X | O |
| 유지보수 용이성 | 낮음 | 높음 | 높음 |
스프링 부트에서는 Hibernate를 좀 더 쉽고 편리하게 사용하기 위해 Spring Data JPA를 제공하고 있고, 애플리케이션 설정에 따라 데이터베이스에 테이블을 자동으로 만들어 주는 기능도 있어 개발자는 SQL을 사용하지 않고도 충분히 애플리케이션을 개발할 수 있다. 데이터베이스는 h2를 사용했다
JPA와 관련된 로그 출력을 활성화해 개발 환경에서 쉽게 디버깅할 수 있도록 만든다.
spring.application.name=JPA
# JPA SQL 로그 활성화
spring.jpa.show-sql=true
# JPA SQL 로그 출력 시 줄바꿈을 추가해 가독성을 높인다
spring.jpa.properties.hibernate.format_sql=true
# JPA SQL 로그 출력 시 SQL 키워드에 색상 또는 스타일을 포함해 더 읽기 쉽게 출력한다.
spring.jpa.properties.hibernate.highlight_sql = true
# JPA SQL 로그 출력 시 매핑된 객체 정보 등을 주석 형태로 출력한다.
spring.jpa.properties.hibernate.use_sql_comments=true
엔티티 객체 매핑
- 객체와 데이터베이스 테이블을 매핑하는 과정을 의미한다.
- 여러 모델 객체 중에 데이터베이스 테이블과 연동할 모델 객체에 @Entity 애노테이션을 사용해 엔티티 객체임을 선언하면 객체의 각 프로퍼티는 테이블의 컬럼으로 매핑한다.
- 엔티티 객체 생성
Member.java
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.*;
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
private String email;
private Integer age; // not null 을 설정할 때 객체 타입으로 , NOT NULL이 아닌 경우 원시 타입을 사용하면 좋다
}
- JPA 리포지터리 작성
import com.example.JPA.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository // JpaRepository를 상속한 인터페이스는 안 써도되지만, 명시적으로 나타내려면 쓰면 좋음
public interface MemberRepository extends JpaRepository<Member, Long> {
}
- MyBatis에서 데이터베이스 연동을 위한 인터페이스가 모여 있는 객체를 매퍼라고 했던 것처럼 JPA에서는 리포지터리라고 부른다.
- 애플리케이션에서는 리포지터리 인터페이스를 사용해 데이터베이스에 엔티티 객체를 저장하거나 조회, 수정, 삭제한다.
- Spring Data JPA는 데이터베이스 연동을 위한 기본 인터페이스를 이미 정의한 JpaRepository라는 인터페이스를 제공하며, 이것을 통해 SQL문을 사용하지 않고도 간편하게 데이터베이스와 연동할 수 있다.
- 애플리케이션에서 리포지터리 사용
import com.example.JPA.repository.MemberRepository;
import lombok.Builder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
@Slf4j
@RequiredArgsConstructor
public class JpaApplication implements ApplicationRunner {
private final MemberRepository memberRepository;
@Override
public void run(ApplicationArguments args) throws Exception {
memberRepository.save(Member.builder()
.name("윤서준")
.email("SeojunYoon@hanbit.co.kr")
.age(10).build());
memberRepository.save(Member.builder()
.name("'윤광철'")
.email("KwancheolYoon@hanbit.co.kr")
.age(43).build());
}
}
- @Component 애노테이션을 사용해 스프링 빈 객체로 등록한다. 그리고 클래스 내부에 final로 MemberRepository를 선언하고 @RequiredArgsConstructor 애노테이션을 사용하면 생성자를 통해 리포지터리 객체를 주입받을 수 있다.
- 애플리케이션 실행 시 스프링 부트는 스프링 컨테이너를 구성하고, 그 안에 있는 스프링 빈 객체들 중 ApplicationRunner를 구현한 객체에 대해 run 메서드를 호출해 주는데, run() 메서드는 윤서준, 윤광철 두 사용자에 대해 객체를 만들고, 리포지터리의 save() 메서드를 호출함으로써 데이터베에 저장하게 된다.
- 아이디 자동 생성 전략
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.*;
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private Integer age; // not null 을 설정할 때 객체 타입으로 , NOT NULL이 아닌 경우 원시 타입을 사용하면 좋다
}
- @GeneratedValue가 사용된 아이디 프로퍼티는 데이터베이스에서 자동으로 생성한 값이 할당된다.
- MySQL, SQL Server 데이터베이스는 아이디 자동 생성을 직접 지원하기도 한다.
직접 방법을 설정할 때는 @GeneratedValue에 매개변수로 설정할 수 있다.
- @GeneratedValue(strategy = GenerationType.IDENTITY) : 데이터베이스에 위임
- @GeneratedValue(strategy = GenerationType.SEQUENCE) : 시퀀스 객체 사용
- @GeneratedValue(strategy = GenerationType.TABLE) : 별도의 테이블 사용
- @GeneratedValue(strategy = GenerationType.AUTO) : 자동 설정(디폴트)
요즘 많은 데이터베이스가 아이디 자동 생성을 지원하기 때문에 아이디 자동 생성 전략으로 GenerationType.IDENTITY를 직접 설정해 사용하는 경우가 많다.
- 스키마 자동 생성 전략
@Entity 애노테이션을 사용한 객체의 경우 애플리케이션을 실행할 때 자동으로 테이블과 시퀀스를 생성하고, 애플리케이션 종려 시 삭제되어 정리되는 것을 확인했다. 이러한 자동 스키마 생성 및 삭제 기능은 실제로 테스트 및 운영 환경에서 이미 스키마가 생성된 경우라면 주의해야 한다.
- 애플리케이션을 실행할 때 스키마를 자동으로 생성할지, 이미 있는 스키마를 사용할지는 애플리케이션 설정의 spring.jpa.hibernate.ddl-auto를 통해 바꿀 수 있다.
JPA는 아래와 같은 다양한 설정을 지원한다.
- create : 기존에 존재하는 테이블이 있다면 무조건 삭제(drop table)하고 생성(create table)한다.
- craete-drop : 애플리케이션을 실행할 때 create와 동일하게 작동하며, 애플리케이션이 종료되면 테이블을 삭제한다. 개발에 사용하면 앱을 종료할 때 생성했던 테이블을 삭제하기 때문에 데이터베이스를 정리하는 효과가 있다. 단, 강제 종료할 경우에는 생성된 테이블들이 삭제되지 않는다.
- update : 테이블이 없을 때 새로 생성하는 것은 create와 동일하지만, 기존 테이블이 있는 경우에는 테이블을 삭제하지 않고 필요시 테이블의 컬럼만 추가한다.
- validate : DDL을 작성하지 않고 모델 객체의 엔티티 매핑 정보가 테이블에 잘 적용되는지만 검증한다. 테이블이 없는 경우에는 생성도 하지 않는다. 매핑에 문제가 있다면 예외가 발생한다.
- none(default) : DDL을 작성하지 않을 뿐 아니라 기존 엔티티 매핑을 검증하지도 않는다.
실제로 운영 중인 데이터베이스는 validate와 none만 권장하고 개발과 테스트에만 ddl-auto로 create, create-drop, update를 사용하고, 운영에는 validate 정도만 사용하는 것이 좋다.
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=myuser
spring.datasource.password=mypass
spring.jpa.hibernate.ddl-auto=create
- 엔티티 매핑 커스터마이즈
JPA에서 @Entity 애노테이션에 사용된 엔티티 모델 객체의 경우 클래스 이름은 테이블 이름으로, 프로퍼티 이름은 컬럼 이름으로 사용된다. 하지만
카멜 표기법으로 되어 있다면 JPA는 두 단어의 사이를 -로 분리한 후 테이블, 컬럼 이름과 매핑한다.
- 만약 직접 테이블 이름과 컬럼 이름을 매핑하고 싶다면 @Table, @Column 애노테이션을 사용해 지정할 수 있다.
@Entity
@Table(name = "member", indexes = {
@Index(name = "idx_name_age", columnList = "name, age"),
@Index(name = "idx_email", columnList = "email")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name")
private String name;
@Column(name = "email")
private String email;
@Column(name = "age", nullable = false, columnDefinition = "INTEGER DEFAULT 10")
private Integer age; // not null 을 설정할 때 객체 타입으로 , NOT NULL이 아닌 경우 원시 타입을 사용하면 좋다
@Transient
private String address;
- @Column에 name 뿐만 아니라 length, nullable, unique와 같은 속성들도 함께 지정할 수 있다.
- 만약 특정 데이터베이스에서만 지원하는 컬럼 정의 문법을 사용하고자 한다면 columnDefinition 속성을 사용할 수 있지만, 꼭 필요한 경우가 아니라면 사용하지 않는 것이 좋다.
- 또한 엔티티 객체의 프로퍼티 중 데이터베이스 테이블의 컬럼으로 매핑하지 않고, 애플리케이션 내부에서만 사용하려는 경우에는 @Transient 애노테이션을 사용해 해당 프로퍼티가 테이블과 매핑되지 않도록 설정할 수 있다.
@Index(프로퍼티 이름, 컬럼 리스트) 애노테이션을 사용해 인덱스를 생성할 수도 있다.
이미 스키마가 생성된 외부 데이터베이스와 연동할 때는 예시처럼 테이블 이름과 컬럼 이름을 명시적으로 매핑하는 것이 좋다. 또한 자동으로 스키마를 생성하지 않을 경우 @Table의 @Index나 @Column의 nullable 등과 같은 속성은 무시된다.
그렇다면 추가적으로 리포지터리에 작성할 수 있는 문법들을 몇 가지 살펴보겠다.
public interface MemberRepository extends JpaRepository<Member, Long> {
// 이름과 이메일을 AND 조건으로 회원 조회
List<Member> findByNameAndEmail(String name, String email);
// 이름과 이메일을 OR 조건으로 회원 조회
List<Member> findByNameOrEmail(String name, String email);
// 이름의 시작으로 회원 조회
List<Member> findByNameStartingWith(String name);
// 이름의 마지막으로 회원 조회
List<Member> findByNameEndingWith(String name);
// 이름을 포함하는 회원 조회
List<Member> findByNameContaining(String name);
// 이름을 포함하는 회원 조회 (LIKE 검색, %를 직접 포함해야 함)
// 예: findByNameLike("%현%")
List<Member> findByNameLike(String name);
// SELECT * FROM MEMBER WHERE name = ? AND (email = ? OR age > ?)
List<Member> findByNameIsAndEmailIsOrAgeIsGreaterThan(String name, String email, Integer age);
List<Member> queryByNameEqualsAndEmailIsOrAgeGreaterThan(String name, String email, Integer age);
List<Member> searchByNameAndEmailOrAgeGreaterThan(String name, String email, Integer age);
// 이름순으로 조회
List<Member> findByOrderByNameAsc();
// 이름의 역순으로 조회
List<Member> findByOrderByNameDesc();
// 이름순으로 조회하고, 이름이 같은 경우 나이의 역순으로 조회
List<Member> findByOrderByNameAscAgeDesc();
// 이름 일부 검색 후 이름순으로 조회
List<Member> findByNameContainingOrderByNameAsc(String name);
// 나이순으로 정렬 후, 나이가 가장 적은 한 명 조회
Member findFirstByOrderByAgeAsc();
// 나이순으로 정렬 후, 나이가 가장 적은 두 명 조회
List<Member> findFirst2ByOrderByAgeAsc();
List<Member> findTop2ByOrderByAgeAsc();
// 이름 일부로 검색 후 Sort 객체로 정렬
List<Member> findByNameContaining(String name, Sort sort);
// 이름 일부로 검색 후 Pageable 객체로 페이징 처리
Page<Member> findByNameContaining(String name, Pageable pageable);
}
Pageable 사용 예시
Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "name"));
JPQL과 QueryDSL의 차이 알아보기
JPQL
@Param을 사용해 파라미터를 JPQL로 전달하고, JPQL에서는 콜론(:)을 사용해 받을 수 있다.
- 한 가지 주의해야 할 점 : JPQL 자체는 대소문자를 구분하지 않지만, 엔티티 객체와 프로퍼티 이름은 대소문자를 구분하고 엔티티 이름은 반드시 별칭을 사용해야 한다는 것이다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m WHERE m.name = :name")
List<Member> findMember(@Param("name") String name);
}
- JPQL을 사용하면 표준 SQL에서와 같이 JOIN, GROUP BY, HAVING 등의 쿼리가 가능하므로, 이러한 쿼리가 필요하다면 JPQL을 사용해 리포지터리 메서드를 정의할 수 있다.
다음은 위에 대한 예이다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m.name, m.email, COUNT(a.id) as count
FROM Member m LEFT JOIN Article a ON a.member = m
GROUP BY m ORDER BY count DESC")
List<Object[]> getMemberStatusObject();
}
- 객체 배열로 전달 받았으므로 객체 배열의 인덱스 0에서 이름을, 1에서 이메일을 ,2에서 게시글 개수를 가져와 로그를 출력하는 코드는 다음과 같다.
List<Object[]> memberStatsList = memberRepository.getMemberStatsObject();
for(Object[] ob : memberStatsList) {
String name = (String)ob[0];
String email = (String)ob[1];
Long count = (Long)ob[2];
log.info("{} {} {}", name, email, count);
}
JPQL은 조회 뿐만 아니라 UPDATE, DELETE와 같은 데이터베이스에 변경하는 작업도 가능하다.
JPQL을 사용해 한 번에 값을 업데이트 할 수 있다.
- @Query 애노테이션을 사용하지만 업데이트의 경우 데이터베이스에 변경을 가하는 작업이라는 것을 명시하기 위해 @Modifying 애노테이션을 추가했고, 여러 개의 데이터를 동시에 변경할 수 있는 작업에 대해서는 영속성 컨텍스트 내에서 작업이 이루어져야 한다.
@Modifying
@Query("UPDATE Member m SET m.age = :age")
@Transcational
int setMemberAge(Integer age);
@Modifying 쿼리(JPQL UPDATE/DELETE)는 애초에 영속성 컨텍스트를 우회해 DB에 직접 명령을 날리므로 벌크 작업 이후에는 영속성 컨텍스트에 캐시되어 있는 엔티티들을 Clear하는 것이 필요하다.
@Modifying(clearAutomatically = true) // 어떤 엔티티 수정 후 변경되지 않은 엔티티의 데이터가 벌크 업데이트에 영향을 미치는 상황이라면 마찬가지로 벌크 업데이트를 수정하기 전에 영속성 컨텍스트의 변경 사항을 미리 Flush해야 할 필요성이 있으므로 아래와 같이 사용할 수 있다
@Modifying(flushAutomatically = true)
영속성 컨텍스트
JPA의 가장 큰 특징 중 하나는 영속성 컨텍스트를 지원한다는 것이다.
영속성 컨텍스트를 사용함으로써 얻을 수 있는 이점 4가지
- 1차 캐시 역할 : 한 번 조회한 엔티티 객체는 캐시 메모리에 저장되어 영속성 컨텍스트가 유지되는 동안에는 여러 번 조회하더라도 데이터베이스를 쿼리하지 않고 캐시되어 있는 객체를 반환한다.
- 변경 감지 : 엔티티 객체의 프로퍼티 변경을 감지해 자동으로 데이터베이스 UPDATE를 수행한다. JPA를 사용해 조회한 엔티티 객체는 조회할 당시의 스냅샹을 보관하는데, 스냅샷과 비교해 아무런 변경이 일어나지 않았음을 감지하고 UPDATE 작업을 하지 않는다.
- 쓰기 지연 : 엔티티 객체의 프로퍼티를 변경해 가며 여러번 save를 호출하더라도 즉시 데이터베이스에 UPDATE되지 않고, 영속성 컨텍스트가 종료될 때 비로소 최종상태가 데이터베이스에 업데이트된다.
- 엔티티 동일성 보장 : 동일한 영속성 컨텍스트가 유지되는 동안데이터베이스 내에 저장된 동일한 데이터를 다양한 방법으로 조회하더라도 새로운 객체를 만들지 않고 동일한 엔티티 객체를 변환하기 때문에 자바의 비교 연산자 ==를 사용해 비교가 가능하다.
public class JpaApplication implements ApplicationRunner {
private final MemberRepository memberRepository;
@Override
public void run(ApplicationArguments args) throws Exception {
var member1 = Member.builder()
.name("윤서준")
.email("SeojunYoon@hanbit.co.kr")
.age(10).build();
log.info("save 윤서준");
memberRepository.save(member1);
var member2 = Member.builder()
.name("윤광철")
.email("KwancheolYoon@hanbit.co.kr")
.age(43).build();
log.info("save 윤광철");
memberRepository.save(member2);
log.info("saved {}", member2);
log.info("read 윤서준");
memberRepository.findById(member1.getId()).orElseThrow();
log.info("update 윤서준");
member1.setAge(11);
memberRepository.save(member1);
log.info("updated {}", member1);
log.info("update 윤광철");
memberRepository.save(member2);
log.info("updated {}", member2);
log.info("애플리케이션 종료");
}
}
관계 매핑
각각의 테이블은 기본 키를 갖고 있고,게시글 테이블에는 해당 게시글의 작성자가 누구인지 알기 위해 회원 테이블에 저장된 작성자의 기본 키를 참조하는 외래키를 지정하면 된다.
JPA에서도 이런 관계를 지원하며 아래와 같이 객체를 생성할 수 있다.
Article.java
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String description;
@CreatedDate
private Date created;
@LastModifiedDate
private Date updated;
@ManyToOne
@JoinColumn(name = "member_id")
private Member member;
}
- @ManyToOne을 사용함으로써 멤버 테이블에서 관리되고 있는 엔티티 객체라는 것을 정의한다.
- 이것을 관계 매핑이라고 하고 @OneToOne, @OneToMany, @ManyToOne, @ManyToMany와 같은 네 가지 관계 매핑을 위한 애노테이션을 지원한다.
- 이로써 실제 데이터베이스 테이블에는 member_id라는 컬럼에 게시글 작성자 아이디가 저장된다.
@OneToMany 관계 매핑에서 주의할 점
- 패지 전략(Fetch Type)이다.
- 패치 전략이란 관계 매피오딘 엔티티 객체를 언제 조회할 것인지에 대한 전략이다.
- @ManyToOne으로 관계 매핑된 회원 정보가 바로 조회(FetchType.EAGER)되지만, 반대로 회원을 조회하면 @OneToMany로 관계 매핑된 게시글 정보는 즉시 조회되지 않고 해당 게시글 정보가 실제로 사용될 때 비로소 가져오는 게으른 조회(FetchType.LAZY) 전략을 구사한다.
- 하지만 JPA가 알아서 모든 것을 관리해주기 때문에 크게 걱정할 필요는 없다.
양방향 관계 매핑을 하는 경우에는 객체를 JSON으로 변환하거나 toString() 메서드를 통해 로그를 출력할 때 무한 루프를 돌게 된다.
이럴 때는 어느 한쪽에서 관계 매핑된 객체를 조회하지만 JSON 변환 또는 toString() 변환에서 제외하도록 애노테이션을 사용하면 된다.
@ToString.Exclude
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Article> articles;
QueryDSL
메서드 이름으로 쿼리를 작성하는 것은 필요한 쿼리를 메서드로 모두 정의해 두어야 한다는 불편함이 있고, 또한 복잡한 쿼리를 작성하면 메서드 이름이 길어져 가독성이 떨어진다는 단점이 있다.
- JPQL을 사용하면 컴파일 시에 오류를 잡아 낼 수 없고 유지보수에 어려움이 따른다.
- QueryDSL은 이러한 데이터베이스 쿼리를 자바 코드로 작성할 수 있도록 도와주는 프레임워크로, 메서드 이름이나 JPQL이 아닌 메서드 체이닝 방식으로 표현할 수 있어 컴파일 시에 오류를 잡을 수 있고 유지보수가 쉬워지는 장점이 있다.
QueryDSL은 엔티티(@Entity) 객체를 스캔해 Q클래스의 소스코드를 생성한다. 따라서 인텔리제이와 같은 개발 환경에서 소스코드를 편집할 때 해당 Q 클래스의 문법을 체크할 수 있도록 하기 위해 엔티티 객체가 추가되거나 내부 프로퍼티의 변경이 있다면 빌드해 최신 Q 클래스 소스코드로 갱신해 주는 것이 좋다.