8 minute read

ORM(Object Relational Mapping)

ORM은 객체 지향 프로그래밍 언어에서 관계형 데이터베이스의 데이터를 객체로 매핑하여 데이터베이스와의 변환 작업을 자동화하는 기술을 의미한다.

데이터베이스의 테이블을 객체지향 프로그래밍의 언어의 자바쪽 표준은 JPA이다. ORM 기술은 객체그래프탐색과 데이터베이스 독립성등의 장점을 가지고 있다.

JPA

Java Persistence Api 의 약자로 자바 진영의 ORM 표준이다. ORM 이란 데이터베이스를 객체로 mapping해줘서 프로그래머가 다룰수 있게 도와주는 기술이다. JPA에서는 데이터베이스 테이블(튜플)과 mapping되는 object를 엔티티 라고 부른다.

@Getter
@NoArgsConstructor
@Entity
public class Album extends BaseTimeModel {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 255, nullable = false)
    private String title;


    @OneToMany(mappedBy = "album")
    private List<Music> musics = new ArrayList<>();

    public void update(Album album) {
        this.title = album.getTitle();
    }
    public void add_music(Music music) {
        this.musics.add(music);
    }

    @Builder
    public Album(String title, Long id) {
        this.id = id;
        this.title = title;
    }
}

엔티티 클래스는 @Entity 라는 어노테이션을 붙여주고, NoArgsConstructor(스프링 컨테이너 bean 등록을 위해 필요, 다른 생성자가 있있으면 반드시 필요함), id 필드를 만들어줘야 한다. @Entity 어노테이션은 id에 대한 setter 제공 및 클래스에 대한 직렬화(DB와 문자열의 형태로 통신) 기능등을 제공하고 bean의 형태로 spring 컨테이너헤서 관리할수 있게 도와준다.

쓰기지연

JPA에서의 쓰기지연(Write-behind)은 영속성 컨텍스트(Persistence Context)가 엔티티의 변경사항을 최적화하여 데이터베이스에 반영을 지연시키는 메커니즘을 말한다.

JPA의 쓰기지연 메커니즘은 다음과 같은 방식으로 동작한다.

  1. 트랜잭션 내에서 변경 감지: JPA는 엔티티 매니저(Entity Manager)를 사용하여 트랜잭션 내에서 엔티티의 상태 변화를 감지합니다. 즉, 엔티티의 필드 값이 변경되거나 새로운 엔티티가 추가되거나 삭제되는 등의 상태 변화를 추적합니다.

  2. 쓰기지연 큐에 등록: 엔티티 매니저는 변경된 엔티티를 쓰기지연 큐에 등록한다. 이 때, 변경된 내용이 기록되는 것이 아니라 변경 사항에 대한 메타데이터만을 유지합니다.

  3. 트랜잭션 커밋 시점에 변경 사항 반영: JPA는 트랜잭션의 커밋 시점에 쓰기지연 큐에 등록된 엔티티들의 변경 사항을 한 번에 데이터베이스에 반영합니다. 이로 인해 여러 개의 변경 사항을 한 번의 데이터베이스 쿼리로 처리할 수 있으며, 데이터베이스에 대한 I/O 비용을 최소화할 수 있다.

만약 기본키생성전략이 IDENTITY 라면 신규 write 시 쓰기지연이 동작하지 않는다.

기본키 생성전략

  • AUTO
    • DB벤더에 따라 자동으로 다음의 3가지 전략중 하나를 선택
  • IDENTITY
    • 기본키 생성을 DB에 위임한다.
    • MYSQL의 경우 AUTO INCREMENT
  • SEQUENCE
    • DB의 시퀀스 객체를 이용해 유일한값을 생성한다.
    • DB벤더에 의존적이다. (Oracle, DB2, H2)
  • TABLE
    • 시퀀스 역활을 하는 테이블을 만들어 ID를 할당한다.

IDENTITY 전략의 경우 엔티티 저장시 새로운 기본키를 그때 그때 increment하여 발행해야 하기 때문에, 쓰기지연이 동작하지 않는다. 시퀀스나 테이블 전략 사용시 save를 할때 두개의 connection(id채번, 저장)을 가져와서 진행한다. 그렇다면 매번 저장할때 기본키 채번을 위한 쿼리가 발생하므로 쓰기지연의 의미가 없어지는것 아닐까? 시퀀스나 테이블 전략의 경우 id채번을 할때 1개가 아니라 n개의 id를 한번에 채번해서 추후 엔티티 생성에 활용한다. 또한 id채번을 할때 별개의 connection을 사용하므로 다른 save시 id채번할때 막히지 않도록 구현되어있고, 테이블 전략사용시에도 채번된 id의 롤백을 방지한다.

지연로딩과 N+1 문제

// Pattern 엔티티
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "music_id", nullable = false)
private Music music;

JPA를 통해 엔티티하나를 읽어오려 할때, 외래키로 참조하는 다른 엔티티가 있는 경우, 이를 처음부터 같이 읽어오는것이 아니라 프록시의 형태로 남겨둔뒤, 향후 필요하게 될때 쿼리를 날려 엔티티의 정보를 가져온다. 이런 동작을 지연로딩(LAZY LOADING) 이라고 부른다. 지연로딩은 객체그래프탐색의 핵심원리이다. fetch 속성에 아무것도 지정하지 않으면 지연로딩이 기본값으로 적용된다. 따라서 위의 코드에서 (fetch = FetchType.LAZY)는 제거해도 똑같이 동작한다.

// Music 엔티티
@OneToMany(mappedBy = "music", fetch = FetchType.LAZY)
private List<Pattern> patterns = new ArrayList<>();
// Music 엔티티
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "composer_music",
        joinColumns = @JoinColumn(name = "composer_id", referencedColumnName = "id"),
        inverseJoinColumns = @JoinColumn(name = "music_id", referencedColumnName = "id"))
private List<Composer> composers = new ArrayList<>();
// Composer 엔티티
@ManyToMany(mappedBy = "composers", fetch = FetchType.LAZY)
private List<Music> musics = new ArrayList<>();

이는 엔티티에서 역참조를 통해 컬렉션을 가져올때도 마찬가지다. 컬렉션을 DB에서 같이 로딩하는것이 아니라, 컬렉션 필드에 접근하게 되면 로딩이 된다. 그렇다면 엔티티 요청시 연관된 엔티티나 컬렉션을 같이 쿼리하여 불러올수 없을까?

// Music 엔티티
@OneToMany(mappedBy = "music", fetch = FetchType.EAGER)
private List<Pattern> patterns = new ArrayList<>();
// Music 엔티티
@OneToMany(mappedBy = "music", fetch = FetchType.EAGER)
private List<Pattern> patterns = new ArrayList<>();
// Music 엔티티
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "composer_music",
        joinColumns = @JoinColumn(name = "composer_id", referencedColumnName = "id"),
        inverseJoinColumns = @JoinColumn(name = "music_id", referencedColumnName = "id"))
private List<Composer> composers = new ArrayList<>();
// Composer 엔티티
@ManyToMany(mappedBy = "composers", fetch = FetchType.EAGER)
private List<Music> musics = new ArrayList<>();

FetchType.EAGER 속성을 통해 엔티티를 읽어올때 연관된 엔티티나 컬렉션을 읽어오기 위한 쿼리를 같이 생성하여 동시에 불러올수 있다. 그렇다면 EAGER 속성이 LAZY보다 좋아 보이는데 기본값은 왜 지연로딩을 적용할까? 이 질문에 대한 답은 바로전에 설명한 문장에서 찾아볼수 있다.

연관된 엔티티를 한개 가지고 있는 엔티티를 읽어오게 된다고 가정하면, 엔티티와 연관 엔티티를 읽어오기 위한 쿼리가 2개 생성된다. 만약 엔티티를 한개가 아니라 n개 읽어온다면 쿼리의수는 1(컬렉션 쿼리) + n(컬렉션의 엔티티 하나당 연관된 엔티티 읽어오기) = n+1 번의 쿼리가 발생하게 된다. 이런 문제를 N+1 문제라 부르며, JPA 에선 기본전략을 지연로딩(FetchType.LAZY)으로 설정하여 이현상을 방지하고 있다.

또한 FetchType.EAGER를 사용한다고 해도, jpql 쿼리가 아닌 JPA 쿼리를 사용하게 되면 자동으로 fetch join을 적용해준다.

EntityManager, 영속성컨텍스트

엔티티들에 대해서 생성, 삭제, 수정, 관리를 해주고 트랜잭션 api 객체(bean) 를 EntityManager 라고 부르며 EntityManagerFactory에서 생성된다. 엔티티매니저를 통해 등록된(영속화된) 엔티티들의 저장소를 영속성컨텍스트 라고 부른다. 스프링에서의 기본전략은 각각의 트랜잭션마다 영속성컨텍스트가 생성되고 트랜잭션이 종료될때 영속성컨텍스트가 종료된다.

private final EntityManager em;

public AlbumRepositoryJPAImpl(EntityManager em) { this.em = em; }

@Override
@Transactional
public Album save(Album album) {
    em.persist(album);
    return album;
}

persist(Entity e) 함수를 통해 엔티티를 비영속에서 영속상태로 전환시킬수 있다. 영속이란 영속성 컨텍스트에서 등록된 상태를 의미하며 식별자(ID)값을 반드시 가지고 있다. 트랜잭션이 커밋되면 영속성 컨텍스트에서 객체가 flush되어 실제 db에 튜플의 형태로 저장되며 이를 쓰기지연이라고 한다.

다만 기본키 전략에 따라 동작 과정이 달라질수 있다. SEQUENCE 의 경우 id 값을 채우기 위해 select 문이 실행되고, 트랜잭션이 끝나는 COMMIT 시점에 insert 문이 실행된다. IDENTITY 전략의 경우 기본키 생성을 DB에 위임하는 기법으로 DB마다 동작과정의 차이가 존재한다. MYSQL의 경우 auto increment 를 사용하므로 그냥 바로 save 쿼리가 날려 기본키를 받아온다. 테이블전략의 경우 SEQUENCE 와 유사하게 작동하게 된다.

영속시 엔티티의 스냅샷도 같이 저장이 되는데 이는 향후 객체의 수정작업에 이용이 된다. 엔티티를 수정한후 커밋를 하게 되면 영속성 컨텍스트에서 flush가 작동하고 dirty checking이 작동하게 된다. 이때 사용자가 업데이트를 위해 특정 로직을 실행하지 않더라고 update 쿼리가 생성되어 DB에 반영을 해준다.


@Override
public Album getById(Long id) {
    return em.getReference(Album.class, id);
}

@Override
@Transactional
public void deleteById(Long id) {
    em.remove(getById(id));
}

remove(Entity e) 함수를 통해 엔티티를 제거하는 delete sql을 영속성 컨텍스트에 등록한다. 향후 커밋시 flush 되어 DB의 테이블에서 튜플이 삭제된다.

getRefererce(Entity.class, ID)를 통해 해당 ID를 갖는 엔티티의 프록시 객체를 가져올수 있다. 프록시 객체를 가져올때는 DB hit가 발생하지 않으므로, 연관관계 설정을 할때 유용하게 사용될수 있다. find(Long id) 함수를 통해, 해당 식별자를 가진 엔티티를 검색할수 있다. 이때 가장먼저 영속성컨텍스트를 뒤져보고(1차캐시) 없으면 실제로 DB에 쿼리를 날린다.

1차 캐시와 2차 캐시

영속성 컨텍스트 내부에는 엔티티를 보관하는 저장소가 있는데 이것을 1차 캐시라 한다. 각 트랜잭션의 시작부터 종료까지 유효하며, JPQL쿼리 를 사용하면 1차캐시에 상관없이 DB에 쿼리를 날린다.

하이버네이트를 포함한 대부분의 JPA 구현체들은 애플리케이션 범위의 캐시를 지원하는데 이것을 공유 캐시 또는 2차 캐시라고 하며, 어플리케이션을 종료할때 까지 유지된다.

더티 채킹

JPA에서는 더티채킹을 통해 엔티티들의 스냅샷을 보존후, 트랜잭션 종료시 변경사항이 발생한다면 자동으로 이를 DB에 반영한다. 네이티브 쿼리, detached된 엔티티는 더티채킹이 일어나지 않는다.

Entity의 컬럼과 DB테이블의 컬럼의 매핑

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

엔티티 클래스에는 @Id 어노테이션이 붙은 식별자 필드를 반드시 생성해줘야 한다. 꼭 Long 타입이어야 하는것은 아니고 JPA에서 허용하는 타입중 하나를 선택하면 된다. @GeneratedValue를 통해 어떻게 기본키를 생성하여 붙일것인지를 결정하고, 지정하지 않는다면 직접 setID를 해줘야 한다.

기본키 생성 전략은 데이터베이스에 따라 달라질수 있다. IDENTITY의 경우 MYSQL의 시퀀스전략을 사용하며 persist 명령을 내릴때 기본키를 얻어오기위해 바로 insert 쿼리(별개의 connection 객체 사용)를 날리므로 영속성컨텍스트의 쓰기지연이 발생하지 않는다.

@Column(length = 255, nullable = false)
private String title;

@Column을 통해, 테이블의 컬럼에 해당하는 엔티티의 필드를 만들수 있다. @Column 어노테이션에 부여할수 있는 속성들은 필드의 데이터타입에 따라 달라진다.

1:N 연관관계 매핑

// Pattern 엔티티
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "music_id", nullable = false)
private Music music;

@ManyToOne 어노테이션을 사용하면, 해당 필드는 데이터베이스 테이블상에서 다른 테이블을 참조하는 외래키의 역활을 하게 된다. 그리고 music 테이블이 외래키를 가지고 있으므로 연관관계의 주인이 된다. @JoinColumn을 통해 테이블상의 외래키에 사용할 컬럼명, null 허용여부를 설정할수 있다.

// Music 엔티티
@OneToMany(mappedBy = "music")
private List<Pattern> patterns = new ArrayList<>();

위와 같이 1:N 관계를 맺고 있는 상황에서 연관관계의 주인이 아닌 엔티티에서 역참조를 하여 리스트를 가져오는것이 가능하다. mappedBy를 통해 연관관계의 주인인 엔티티의 외래키 필드 지정하면 된다 (private Music music). @OnetoMany가 설정된 필드에 접근하게 되면, JPA에서 매핑되는 튜플들을 리스트로 만들어 넣어주므로(없다면 0개인 리스트) 이필드를 init 하는여부는 상관이 없다고 한다. 하지만 엔티티를 테스트하거나 새로운 앤티티를 만드는 과정에서 버그를 줄여줄수 있을것 같다.

현재 music과 pattern은 서로 참조/역참조가 가능하므로, 양방향 연관관계를 가지고 있다. 이때 패턴을 새로 생성했다고 가정해보자. music이 연관관계의 주인이므로 pattern의 외래키에 music만 잘 매핑시키면 db상에서는 문제가 없다. 하지만 순수 객체상태 관점에서볼때 music.patterns 에는 새로 생성한 pattern이 들어있지 않으므로 모순이 발생할수 있다. 따라서 music.patterns.add(Pattern p)를 통해 모순을 제거하는것이 권장된다. 이는 삭제, 업데이트시에도 마찬가지다.

N:N 연관관계 매핑

// Music 엔티티
@ManyToMany
@JoinTable(name = "composer_music",
        joinColumns = @JoinColumn(name = "composer_id", referencedColumnName = "id"),
        inverseJoinColumns = @JoinColumn(name = "music_id", referencedColumnName = "id"))
private List<Composer> composers = new ArrayList<>();

@ManyToMany를 통해 다다대 관계를 쉽게 나타낼수 있다. @joinTable 어노테이션의 name은 양방향 관계를 저장할 연결 테이블의 이름, joinColumns는 연결테이블에서 참조테이블을 나타내는 필드의 이름을, inverseJoinColumns능 역방향의 (self) 테이블을 나타내는 필드의 이름을 명시한다.

@ManyToMany 역시 당연하게도 역방향 참조가 가능하며 mappedby를 통해 역참조 하고자하는 테이블의 외래키필드(private List composers) 를 명시하면 된다. 명시된 테이블은 다대다관계에서 OneToMany 처럼 연관관계의 주인이 된다. @OnetoMany와 마찬가지로 객체관점의 모순이 생기지 않도록 주의한다. 다대다 관계를 사실 실무에서는 잘 쓰지 않는다고 한다. 다대다 관계보다는 일대다, 다대일을 통해 명확한 역활을 가지고 있는 테이블로 구현하여 활용한다고 한다.

@Transactional

@Transactional
public Album save(Album album) {
    em.persist(album);
    return album;
}

트랜잭션이 필요할때 사용하는 스프링에서 제공하는 어노테이션이다. 해당 어노테이션을 퍼블릭 메소드에 적용시, 메소드안에서 일어나는 DB CUD 작업들은 하나의 트랜젝션으로 묶이게 된다. 만약 메소드 실행도중 fault 발생시 모두 rollback 한다. 클래스레벨에 선언시, 클래스의 모든 메소드에 적용된다. JPA의 영속성 컨텍스트 기능을 사용하게 되면 sql들을 누적시켜 놓았다가 메소드가 종료되면서 commit이 발생되고 한번에 flush 하는 쓰기지연이 발생한다 (하지만 엔티티의 기본키 전략에 따라 동작이 달라질수 있다).

import org.springframework.transaction.annotation.Transactional;
import javax.transaction.Transactional;

“javax.transaction.Transactional” 이라는 동일한 이름의 어노테이션도 존재하는데, 역활은 유사하지만 Java EE 7 환경을 위해 필요한것으로 스프링 환경에서는 “org.springframework.transaction.annotation.Transactional” 을 import 해서 사용한다.

JPA 표준 트랜잭션 중첩시에의 전파 전략

Transaction이 중첩되었을때에는 여러가지 전파(Propagation) 전략이 있다.

  • REQUIRED, 만약 부모 메소드에 트랜잭션이 없다면 새롭게 트랜잭션(물리)이 생성된다, 있으면 같은 물리 트랜잭션으로 병합되어 실행된다.
    • 부모와 자식은 각각 논리 트랜잭션을 가지며, 여기서 한곳이라도 예외가 터지면 물리 트랜잭션은 롤백된다.
  • MANDATORY, 만약 부모 메소드에 트랜잭션이 없다면 예외가 터진다.
  • REQUIRES_NEW, 자식 메소드에서 새로운 커넥션(물리 트랜잭션)을 할당받아 트랜잭션을 생성하여 독립적으로 행동한다.
  • NESTED (DBMS특성에 따라 지원 혹은 미지원), 새로운 커넥션을 할당받아 트랜잭션을 생성하여 독립적으로 행동하되 데이터베이스의 savepoint(checkpoint)를 사용하여 부모가 롤백시 전체가 롤백된다.
  • NEVER, 어떠한 경우에도 트랜잭션이 있으면 예외가 터진다.
  • SUPPORTS, 기존에 트랜잭션이 있으면 트랜잭션 이용, 없으면 트랜잭션 없이 동작
  • NOT_SUPPORTED,

마이바티스와 JPA중에 무엇을 고를까요

  • 러닝 커브의 차이가 있다. (JPA의 러닝커브가 높다.)
    • ORM 근원기술들의 장단점
      • 객체그래프탐색, 데이터베이스 디커플링 (장점)
      • 지연로딩, 쓰기지연, JPQL쿼리

참고자료

https://stackoverflow.com/questions/26387399/javax-transaction-transactional-vs-org-springframework-transaction-annotation-tr https://stackoverflow.com/questions/20715143/to-initialize-or-not-initialize-jpa-relationship-mappings

Leave a comment