JPA - 자바 표준 ORM

zunzero·2022년 8월 10일
0

스프링, JPA

목록 보기
4/23

ORM

Object-Relational Mapping

객체 지향 애플리케이션과 관계형 DB의 충돌

객체에 필드가 변경되었을 때, SQL문에 변경사항이 너무 많은 문제점이 있다.
또한 객체를 관계형 디비에 저장하려 하면 복잡한 과정이 필요하다는 문제점도 있다.
그래서 지금까지는 객체를 테이블에 맞추어 모델링할 수 밖에 없었다고 한다.

이 방법은 너무 불편해서 객체를 자바 컬렉션 저장하듯 DB에 저장할 수는 없을까? 라는 고민 끝에 등장한 것이 ORM이다.
JPA는 자바 표준 ORM으로써, 객체는 객체대로 설계하고 관계형 디비는 관계형 디비대로 설계한 후 ORM 프레임워크가 중간에서 매핑하도록 한다.

JPA를 사용해야 하는 이유

SQL 중심적인 개발에서 객체 중심으로 개발을 하게 될 것이다.
SQL문은 JPA가 알아서 처리해 줄 것이다.!
이로 인해 생산성, 유지보수, 성능 문제가 해결되고, 패러다임 불일치 문제 또한 해결되며, JPA는 각각의 디비에 해당하는 방언을 알아서 처리해주기 때문에 특정 데이터베이스에 종속적으로 작성하지 않아도 된다.
하이버네이트는 40가지 이상의 방언을 지원한다고 한다.

데이터베이스 방언

JPA 성능 최적화 기능

  1. 1차 캐시와 동일성(identity) 보장
    같은 트랜잭션 안에서는 같은 엔티티를 반환하여 약간의 조회 성능이 향상된다.
  2. 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
    트랜잭션을 커밋할 때까지 SQL을 모으고, 커밋 시점에 한 번에 전송한다.
  3. 지연 로딩(Lazy Loading)
    객체가 실제 사용될 때 로딩한다.

JPA 구동 방식

영속성 컨텍스트

영속성 컨텍스트란 엔티티를 영구 저장하는 환경이다. 이는 논리적인 개념으로, 눈에 보이지는 않는다.
엔티티 매니저를 통해서 영속성 컨텍스트에 접근할 수 있다.

스프링 프레임워크 같은 컨테이너 환경(J2EE)에서는 엔티티 매니저와 영속성 컨텍스트가 N:1의 관계를 가진다.

엔티티의 생명주기

  • 비영속 (new or transient)
    영속성 컨텍스트와 전혀 관계가 없는 새로운 상태를 말한다.
  • 영속 (managed)
    영속성 컨텍스트에 관리되는 상태를 말한다.
  • 준영속 (detached)
    영속성 컨텍스트에 저장되었다가 분리된 상태를 말한다.
  • 삭제 (removed)
    삭제된 상태이다.

영속성 컨텍스트의 이점

  • 1차 캐시
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연 (transactional write-behind)
  • 변경 감지 (Dirty Checking)
  • 지연 로딩 (Lazy Loading)

변경 감지를 제외한 나머지 이점들은 위에서 설명했다.
1차 캐시와 디비의 동일성을 보장하는 장면이다.
member2를 처음 조회했을 때 1차 캐시에 없다면, 1차 캐시에 저장 후 member2를 반환한다.
이후 다시 member2를 조회했을 때는 1차 캐시를 조회하여 조회 성능을 약간 높일 수 있다.
persist 메서드를 이용해 memberA를 저장할 때는 insert문 생성과 동시에 1차 캐시에 저장하게 된다.
추가로 memberB 또한 영속성 컨텍스트에 persist 하게 되면 총 2개의 insert SQL문이 쓰기 지연 SQL 저장소에 저장되고, 후에 트랜잭션이 커밋되는 시점에 SQL문이 DB로 날아갈 것이다.
commit 시점에 flush(플러쉬)가 우선 발생한다.
flush는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다.
flush가 실행되면...

1. 변경 감지 (dirty checking)가 동작해서 영속성 컨텍스트에 있는 모든 엔티티를 스냅샷과 비교해서 수정된 엔티티를 찾는다. 
2. 수정된 엔티티는 수정 쿼리를 만들어 쓰기 지연 SQL 지연 저장소에 등록한다.
3. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송한다.

데이터베이스에 변경내용을 SQL로 전달하지 않고, 트랜잭션만 커밋하면 어떤 데이터도 데이터베이스에 반영되지 않기 때문에 트랜잭션을 커밋하기 전에 반드시 플러시를 호출해서 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영해야 한다.

이러한 이유로 영속성 컨텍스트에는 등록과 조회, 삭제 기능만 있지, 수정 기능이 따로 있지 않다.
EntityManager.persity(엔티티), EntityManager.find(엔티티), EntityManager.remove(엔티티)

만약 memberA 객체(엔티티)에 대한 내용 중 수정하고 싶은 것이 있다면, 다음과 같이 하면 된다.

memberA.setUsername("hi");
memberA.setAge(10);

// em.update(memberA) -> 이런 기능은 존재하지 않는다.

transaction.commit();

memberA는 영속성 컨텍스트에서 관리되고 있기 때문에, 엔티티의 데이터를 수정하면 커밋 시점에 변경 감지가 동작해서 update 쿼리가 자동으로 생성되는 것이다.
아래는 변경감지를 나타낸 그림이다.
즉, flusth는 영속성 컨텍스트의 변경 내용을 디비에 반영하는 작업이다. (영속성 컨텍스트의 변경내용을 디비에 동기화)

변경 감지 -> 수정된 엔티티에 대한 update 쿼리 생성 후 쓰기 지연 SQL 저장소에 등록
-> 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송

영속성 컨텍스트를 flush 하는 경우는 다음과 같다.

1. em.flush
2. 트랜잭션 커밋
3. JPQL 쿼리 실행

준영속 상태

영속 상태의 엔티티가 영속성 컨텍스트에서 분리된 상태이므로, 영속성 컨텍스트가 제공하는 기능을 사용하지 못한다.
준영속 상태로 만드는 방법은 다음과 같다.

1. em.detach(엔티티)
2. em.clear()
3. em.close()
    

객체와 테이블 생성 & 매핑

  • @Entity
    JPA가 관리할 객체에 붙는 애노테이션이다.
    기본 생성자는 필수로 있어야 한다.
    final, enum, interface, inner 클래스는 사용할 수 없다.
  • @Table
    데이터베이스에 테이블이 생성될 때 설정 값을 적용할 수 있게 한다.
    예를 들면, 테이블 이름은 기본적으로 @Entity 애노테이션이 붙은 클래스의 이름으로 생성되지만, @Table 애노테이션의 속성값을 이용해 변경 가능하다.
  • @Id, @Generated
    @Id 애노테이션이 붙은 컬럼은 데이터베이스 PK와 매핑한다.
    해당 컬럼에 @Generated 애노테이션이 함께 붙어있다면, 이는 기본 키 생성을 데이터베이스에 위임하는 것이다. (ex. AUTO_INCREMENT)
  • @Column
    컬럼에 제약조건을 추가하고 싶으면 @Column 애노테이션에 속성으로 설정할 수 있다.
  • @Transient
    @Transient 애노테이션이 붙은 필드는 테이블에 컬럼으로 매핑되지 않는다.
  • @Enumerated(EnumType.STRING)
    해당 애노테이션은 Enum 타입의 데이터를 매핑할 때 반드시 사용해야 한다.
    기본 값이 ORDINAL인데 이는 enum의 순서를 데이터베이스에 저장하기 때문에 순서에 변화가 생기면 큰 문제가 발생한다.
    따라서 문자 그대로를 데이터베이스에 저장하는 EnumType.STRING 옵션을 반드시 설정해주어야 한다.

데이터베이스 스키마 자동 생성

데이터베이스 방언에 맞게 DDL을 자동으로 생성한다.
이 때 옵션으로 create, create-drop, update, validate, none이 있는데 개발 상황에 알맞게 맞춰 사용하면 된다.

  • 개발초기
    create, update
  • 테스트 서버
    update, validate
  • 스테이징과 운영서버
    validate, none

운영 장비에는 절대 create, create-drop, update를 사용하면 안 된다.

연관관계 매핑

회원과 팀의 관계를 예시로 사용하려 한다.
회원과 팀은 서로 N:1의 관게이다. (다대일 관계)
이를 ERD로 표현하면 아래와 같다.
TEAM_ID 라는 외래키를 통해 조인해서 연관된 테이블을 양방향으로 찾을 수 있다.
반면 이를 팀과 회원 객체 사이의 연관관계로 표현하면 아래와 같다.
Member 객체는 소속 Team을 알 수 있으나 Team 객체는 소속된 Member에 대해 알 수가 없다.
객체는 참조를 통해 연관된 객체의 값을 가져오기 때문에, Member 객체가 Team 객체를 참조할 수는 있지만 Team 객체는 Member 객체를 참조할 수가 없다.
따라서 이는 현재 단방향 관계이다.
이를 양방향으로 매핑하고자 하면 다음과 같이 표현할 수 있다.
Team 클래스에 members에 대한 리스트를 갖고 있으면 Team -> Member 단방향 관계가 만들어져, Team과 Member 양방향 객체 연관관계가 만들어진다.

@Entity
public class Member {
	@Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    @Colomn(name = "USERNAME")
    private String name;
    
    private int age;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")	// 외래 키 지정 (디비 컬럼명을 적어주어야 함.)
    private Team team;	// 참조 객체
    ...
@Entity
public class Team {
	@Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "team")	// 참조된 객체 (연관관계를 맺을 객체 지정)
    List<Member> members = new ArrayList<>();
    ...

테이블 간의 관계에서는 외래키 하나로 두 테이블 양쪽에 대한 연관관계를 가질 수 있다.
하지만 객체 간의 관계에서는 단방향 관계가 두 개 모여 양방향 관계처럼 보이는 것 뿐이다.

연관관계의 주인

두 객체를 다대일 관계로 엮게 되면, 관계의 주인이 필요하다.
연관관계의 주인만이 외래키를 관리하고, 주인이 아닌 쪽은 읽기만 가능하다.
이처럼 주인이 아닌 객체에서는 값이 등록되지 않기 때문에, 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하는 연관관계 메서드가 필요하다.

테이블 상 외래키가 있는 객체를 주인으로 지정한다. (N쪽에 항상 외래키가 위치하므로 이쪽이 주인)

사실 단방향 매핑만으로도 연관관계 매핑은 완료가 된 것이다.
하지만 반대 방향으로의 단방향 매핑을 생성하여 양방향 연관관계를 맺는 것은 역방향으로 조회할 일이 많음을 예상하여 해당 기능을 추가하는 것이다.

  • 다대일
    @ManyToOne + @JoinColumn(name="테이블에 적힌 외래키의 컬럼명")
    연관관계의 주인이 된다.
  • 일대다
    @OneToMany(mappedBy="연관관계를 맺을 객체 지정")
  • 일대일
    @OneToOne + @JoinColumn or (mappedBy=)
    일대일 관계는 외래키를 어디에 두는 상관 없기 때문에 개발자가 알아서 지정하면 된다.
  • 다대다
    @ManyToMany
    하지만 다대다 관계를 그대로 쓰는 일은 거의 없을 것이다.
    다대다 관계는 다대일과 일대다 관계로 풀어 사용해야 한다.
    이유는 다대다 관계를 직접 사용하게 되면, 각각의 pk를 fk로 갖는 클래스가 자동으로 생기게 되는데, 이 클래스는 필드값이나 메서드를 추가하는 등의 수정이 불가하기 때문에 잘 사용하지 않는다.

즉시로딩과 지연로딩

멤버 혹은 팀을 조회할 때, 그와 연관된 객체를 함께 조회해야할까?
지연 로딩을 사용하면 프록시를 통해 조회를 하게 된다.
다음과 같은 코드로 예시를 보자.

Team team = member.getTeam()	// 프록시 조회,
team.getName();					// 실제 team을 사용하는 시점에 초기화(DB 조회)

만약 연관된 두 객체를 함께 조회해야 할 일이 많다면, 즉시로딩을 사용해서 함께 조회하면 된다.
하지만 그런 상황이 아니라면, 가급적 지연로딩만 사용하는 것이 좋다.
즉시 로딩은 JPQL에서 상상도 못한 쿼리가 나갈 수 있어, 1+N 문제를 일으키기도 한다.

@ManyToOne(fetch=FetchType.XXXX) <- LAZY or EAGER

JPQL

소개

JPA를 사용하면 테이블이 아닌 엔티티 객체를 중심으로 개발을 하게 된다.
그래서 검색을 위한 SQL 문도 테이블이 아닌 엔티티 객체를 대상으로 하게 된다.
JPA는 SQL을 추상화한 JPQL이라는 객체 지향 쿼리 언어를 제공하는데, 이는 기존의 SQL 문법과 굉장히 유사하다.
차이점은 SQL은 데이터베이스 테이블을 대상으로 쿼리하는 데에 반해, JPQL은 엔티티 객체를 대상으로 쿼리한다는 것이다.
JPQL은 SQL을 추상화했기 때문에, 특정 데이터베이스 SQL에 의존적이지 않다.

List<Member> result = em.createQuery("select m from Member m where m.age > 18", Member.class)
	.getResultList();

위 소스 코드의 실행 결과는 다음과 같다.

select
	m.id as id,
    m.age as age,
    m.USERNAME as USERNAME,
    m.TEAM_ID as TEAM_ID
from 
	Member m
where
	m.age>18

쿼리문을 직접 작성하지 않고, 자바 코드로 JPQL을 작성할 수 있는 QueryDSL이 있는데 이는 단순하고 쉽다. 또한 동적쿼리를 작성하기에도 편리하고.
하지만 이번 포스팅에서는 QueryDSL에 대해 다루지 않겠다.

문법

집합과 정렬

GROUP BY, HAVING, ORDER BY

select
	m,
	COUNT(m),
    SUM(m.age),
    AVG(m.age),
    MAX(m.age),
    MIN(m.age),
from Member as m	// as는 생략 가능

페이징

String jpql = "select m from Member m order by m.name desc";
List<Member> result = em.createQuery(jpql, Member.class)
	.setFirstResult(10)	// 조회 시작 위치, 0부터 시작
    .setMaxResults(20)	// 조회할 데이터 수
    .getResultList();

조인

내부조인, 외부조인(left join), 세타 조인

ON 절을 활용해 조인할 수 있다.

  1. 조인 대상 필터링
    ex. 회원과 팀을 조인하면서, 팀 이름이 A인 팀만 조인
    JPQL: select m, t from Member m left join m.team t on t.name = 'A'
    SQL: select m., t. from Member m left join Team t on m.TEAM_ID = t.id and t.name = 'A'
  2. 연관관계 없는 엔티티 외부 조인
    ex. 회원의 이름과 팀의 이름이 같은 대상 외부 조인
    JPQL: select m, t from Member m left join Team t on m.username = t.name
    SQL: select m., t. from Member m left join Team t on m.username = t.name

페치 조인 (join fetch)

페치조인은 실무에서 매우 종요하다고 한다.
이는 SQL의 조인 종류가 아니라, JPQL에서 성능 최적화를 위해 제공하는 기능이다.
연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능으로, join fetch 명령어를 사용한다.

JPQL: select m from Member m join fetch m.team
SQL: select m.*, t.* from Member m inner join Team t on m.TEAM_ID = T.id

아래는 페치 조인을 사용한 소스코드와 그 실행 결과이다.

String jpql = "select m from Member m join fetch m.team";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();

for (Member member: members) {
	System.out.println("username = " + member.getUsername() + ", " +
    	"teamName = " + member.getTeam().name());
}        
username = 회원1, teamName = 팀A
username = 회원2, teamName = 팀A
username = 회원3, teamName = 팀B

... 뭐 더 있고 하지만 fetch join은 조금 어려우니 나중에 추가로 포스팅 하겠다!

참고

인프런 김영한 강사님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의를 참고하여 포스팅하였습니다.

profile
나만 읽을 수 있는 블로그

0개의 댓글