JPA 사용 팁과 주의사항

김종하·2023년 7월 27일
0

JPA

목록 보기
10/10

최대한 단순한 구조로 설계할 것

JPA 는 많은 기능을 지원한다. 객체간 서로 참조를 하는 부분도 양방향 연관관계를 잘 사용만 하면 큰 문제 없이 사용할 수 있다. 하지만 이런 방법을 잘 사용하는게 최선이라고는 할 수 없다. 객체간의 관계가 복잡해지면, 사용하는 입장에서 관계들을 이해하는게 어려울 수 밖에 없다. 따라서 가능하다면 연관관계를 줄이고, 필요한 경우 단방향 연관관계를 통해서 문제를 해결하도록 하자.

연관관계 Tip

@ManyToOne

실무에서 가장 많이 사용하는 N:1 연관관계이다.
테이블 입장에서 생각을 해봤을 때도 N 쪽에 FK 가 들어가는 만큼 비즈니스 코드와 그에 따른 SQL이 자연스럽게 이어질 가능성이 높다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private int age;
    @ManyToOne
    private Team team;

    public Member(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void joinTeam(Team team) {
        this.team = team;
    }
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Team {

    @Id
    @GeneratedValue
    private Long id;
    private String name;

    public Team(String name) {
        this.name = name;
    }
}

///// Member 의 ManyToOne 활용

    public static void main(String[] args) {
 		EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Team teamA = new Team("teamA");
  	        em.persist(teamA);

    	    Member member = new Member("jaden", 29);
        	em.persist(member);

  	        em.flush();
            em.clear();

        	Member findMember = em.find(Member.class, member.getId());

        	findMember.joinTeam(teamA);
        	em.persist(findMember);
            tx.commit();
            
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();
    }

// mfindMember.joinTeam(teamA) 쿼리 결과는 member 객체를 조작해 
// Member 테이블에 대한 update 쿼리가 발생하여 자연스럽다. 
update
	Member 
    	set
    		age=?,
        	name=?,
        	team_id=? 
        where
            id=?

@OneToMany

불가피하게 사용하게 된다면 @JoinColumn 을 사용한다. 그렇지 않으면 기본적으로 JoinTable 방식을 사용하게 되어 의도하지 않은 테이블을 생성하게 된다. 의도한 경우가 아니라면 @JoinColumn 을 사용한다.
따라서 1:n 단방향 매핑을 사용해야하는 경우라면 n:1 양방향 매핑을 고려한다.(권장한다)

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private int age;

    public Member(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Team {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    @OneToMany
    @JoinColumn(name = "member_id")
    private List<Member> members = new ArayList<>();

    public Team(String name) {
        this.name = name;
    }

    public void addMember(Member member) {
        members.add(member);
    }
}

///// team 에 OneToMany 활용
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        Member member = new Member("jaden", 29);
        em.persist(member);

        Team teamA = new Team("teamA");
        em.persist(teamA);

        em.flush();
        em.clear();

        Member findMember = em.find(Member.class, member.getId());
        Team findTeam = em.find(Team.class, teamA.getId());

        findTeam.addMember(findMember);
        
// findTeam.addMember(findMember) 쿼리 결과는 team 을 조작하지만
// Member 테이블에 대한 update 쿼리가 발생하여 부자연스럽다. 
// 더불어 업데이트할 member 를 조회해야 해 성능적으로도 불리하다.

Hibernate: 
    select
        members0_.member_id as member_i4_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.age as age2_0_1_,
        members0_.name as name3_0_1_ 
    from
        Member members0_ 
    where
        members0_.member_id=?
Hibernate: 
    /* create one-to-many row me.jaden.Team.members */ update
        Member 
    set
        member_id=? 
    where
        id=?

@OneToOne

1:1 연관관계는 FK 를 원하는 테이블에 둘 수 있다.
예를들어 회원과 락커라는 엔티티가 있다면 회원 테이블에 락커ID 를 FK 로 둬도, 락커 테이블에 회원ID 를 FK로 두어도 된다.
비즈니스 적으로 회원이 주도적이라고 생각한다면, FK를 회원에 두고 사용하는 것이 편할 것이다.
예를들어 회원이 락커를 등록할 수 있다고 생각해보자.

// 회원 엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private int age;
    @OneToOne
    @JoinColumn(name = "locker_id")
    private Locker locker;

    public Member(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void registerPersonalLocker(Locker locker) {
        this.locker = locker;
    }
}

// 락커 엔티티
@Entity
@NoArgsConstructor
@Getter
public class Locker {

    @Id
    @GeneratedValue
    private Long id;
    private int number;

    public Locker(int number) {
        this.number = number;
    }
}

// 회원이 락커 등록
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        try {
            Locker firstLocker = new Locker(1);
            em.persist(firstLocker);

            Member member = new Member("jaden", 29);
            em.persist(member);

            em.flush();
            em.clear();

            Locker getLocker = em.find(Locker.class, firstLocker.getId());
            Member getMember = em.find(Member.class, member.getId());

			# 회원이 락커를 등록한다.
            getMember.registerPersonalLocker(getLocker);

            tx.commit();
       }... 

// getMember.registerPersonalLocker(getLocker); 에 대한 SQL
        update
            Member 
        set
            age=?,
            locker_id=?,
            name=? 
        where
            id=?

의도한 대로 동작하고 있는 것을 확인할 수 있었다. 하지만, DB관점에서 생각해도록 하자.
어떠한 회원은 락커를 등록하지 않을 수 도있다. 그러면 회원 테이블의 FK 에는 null 이 들어갈 것이다.
혹은, 한명의 회원이 여러개의 락커를 사용할 수 있게 비즈니스 로직을 수정해야 한다면 테이블이 수정되어야 한다. 그렇다면 FK를 락커로 옮기고 단방향으로 Member를 통해 락커를 등록할 수 있을까? 아쉽지만 JPA 는 해당 부분을 지원하기 어렵다. 대신 양방향 관계를 맺어 사용할 수 는 있다.

@Entity
@NoArgsConstructor
@Getter
public class Locker {

    @Id
    @GeneratedValue
    private Long id;
    private int number;
    @OneToOne
    @JoinColumn(name = "member_id")
    private Member owner;

    public Locker(int number) {
        this.number = number;
    }

    public void setOwner(Member owner) {
        this.owner = owner;
    }
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private int age;
    @OneToOne(mappedBy = "owner")
    private Locker locker;

    public Member(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void registerPersonalLocker(Locker locker) {
        this.locker = locker;
        locker.setOwner(this);
    }
}

// FK 는 락커테이블에 있지만 양방향관계를 통해 Member 에서 락커등록
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
	try {
            Locker firstLocker = new Locker(1);
            em.persist(firstLocker);

            Member member = new Member("jaden", 29);
            em.persist(member);

            em.flush();
            em.clear();


            Locker getLocker = em.find(Locker.class, firstLocker.getId());
            Member getMember = em.find(Member.class, member.getId());

            System.out.println("====");
            getMember.registerPersonalLocker(getLocker);

 			tx.commit();
       }... 
       
#  getMember.registerPersonalLocker(getLocker); 에 대한 SQL 쿼리
		 update
            Locker 
        set
            number=?,
            member_id=? 
        where
            id=?

다만, 이 경우 지연로딩(LazyLoading)을 사용할 수 없게된다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private int age;
    @OneToOne(mappedBy = "owner", fetch = FetchType.LAZY) // lazyLoading 설정
    private Locker locker;

    public Member(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void registerPersonalLocker(Locker locker) {
        this.locker = locker;
        locker.setOwner(this);
    }
}

///// 사용코드

        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        try {
            Locker firstLocker = new Locker(1);
            em.persist(firstLocker);

            Member member = new Member("jaden", 29);
            em.persist(member);

            em.flush();
            em.clear();


            System.out.println("====");
            Member getMember = em.find(Member.class, member.getId()); // member 를 조회할 때 locker 를 lazyLoading 해오는지 확인해보자
            tx.commit();

# 쿼리 확인 
====
Hibernate: 
    select
        member0_.id as id1_1_0_,
        member0_.age as age2_1_0_,
        member0_.name as name3_1_0_,
        member0_.team_id as team_id4_1_0_ 
    from
        Member member0_ 
    where
        member0_.id=?
Hibernate: 
    /* load me.jaden.Locker */ select
        locker0_.id as id1_0_1_,
        locker0_.number as number2_0_1_,
        locker0_.member_id as member_i3_0_1_,
        member1_.id as id1_1_0_,
        member1_.age as age2_1_0_,
        member1_.name as name3_1_0_,
        member1_.team_id as team_id4_1_0_ 
    from
        Locker locker0_ 
    left outer join
        Member member1_ 
            on locker0_.member_id=member1_.id 
    where
        locker0_.member_id=?

쿼리 결과를 보면 알 수 있듯이 lazyLoading 을 설정해도 eagerLoading 을 해버린다.
이유는 JPA 가 프록시 객체를 생성할 때 Member 의 Locker 가 null 인지 아닌지 확인하기 FK가 Locker 에 있기 때문에 Locker 를 어쩔 수 없이 조회해버리기 때문이다.

@ManyToMany

@ManyToMany 는 실무에서 잘 사용하지 않는다. (연결테이블을 JPA가 관리함으로 연결테이블의 컬럼들을 컨트롤 할 수 없기 떄문)
관계형 DB 에서는 다대다 관계를 테이블 2개로는 풀어낼 수 없다. 연결테이블을 만들어 일대다+다대일 관계로 풀어내야 한다.
그래서 @ManyToMany 를 사용하면 JPA 는 매핑정보를 보고 연결테이블을 생성해 사용하게되는데, 단순 열결이 목적이라면 불필요하면 엔티티 클래스를 생성하지 않아도 된다는 장점이 있을 수 있지만, 실무에서 단순 연결만하는 경우가 극히 드물기 떄문에 일반적으로 @ManyToMany 를 사용하기 보다는 연결테이블을 엔티티로 만들어 필요한 기능들을 집어넣어 사용하게 된다.

JPA 와 프록시

LazyLoading 과 같은 기법을 사용하기 위해 JPA는 프록시 객체를 활용한다.
JPA 가 프록시를 사용하기에 주의해야할 부분이 몇 가지 있는데, 비즈니스 로직에서 객체의 타입을 체크해야할 때, 해당 타입이 원본 객체일지 프록시 객체일지 명확한 구분이 힘들기에 == 대신 instance of 를 통해 타입을 비교한다.

왜 구분이 어려운가?

영속성컨택스트에 프록시객체로 저장되면 프록시객체가 반환되고, 실제객체가 저장되면 실제객체가 반환되기 때문이다.
만약 프록시 객체가 들어간 상태에서 xxxx.getClass() == xxx.class 와 같이 타입을 비교하면 false 가 나오게 되어 원하지 않는 결과를 얻게된다.

JPQL 주의사항

JPQL 을 통해 연관관계를 가진 경로를 탐색할 시 묵시적 내부 조인이 발생한다. (join 을 명시적으로 사용하지 않아도 join이 된다)
팀 전체가 JPA와 JPQL에 익숙하다면 괜찮을지 모르겠지만, 그렇지 않다면 묵시적 내부조인이 일어나는 경우에도 명시적으로 join 을 사용하도록 하자.

Fetch join 주의사항

  1. 페치조인 대상에는 별칭을 줄 수 없다
    하이버네이트를 사용하면 가능하지만 가급적 사용하지 않는다. (데이터 정합성 문제 발생 가능성 있음), 다만 fetch join 대상에서 fetch join 이 일어날 경우는 사용할 수 도 있다.
  2. 둘 이상의 컬렉션은 페치조인 할 수 없다.
  3. 컬렉션 페치조인 시 페이징을 사용할 수 없다. (데이터 뻥튀기 문제)
    일대일, 다대일은 가능하다. (뻥튀기 안일어나니)
    하이버네이트를 사용하면 메모리에서 페이징 처리를 애플리케이션 레벨에서 진행하는데 당연히 OOM 위험이 생기게 된다.

컬렉션 페치조인 페이징을 해결하는 방법?
1. 일대다 관계인 경우 반대방향(다대일)에서 쿼리를 날려 페이징을 한다.
2. 페치조인 없이 조회 한 후 @BatchSize 활용해 lazyLoading 을 한다. (비교적 n+1 문제에서 자유로움)
batch fetch size 는 글로벌 설정도 가능하다.

변경감지와 merge()

엔티티를 수정해야하는 경우 JPA 의 변경감지 기능을 사용한다.
merge() 를 사용할 경우 엔티티 전체를 변경하는 형태이기에, 변경하지 않은 필드에 null 이나 0 과같은 값이 세팅될 수 있기 때문이다.

fetchType EAGER

n+1 문제를 해결하기 위해 fetchType 을 eager 로 설정한다고 해도 JPQL 이 실행되는 경우에는 n+1 문제가 해결되지 않는다. JPQL 은 그대로 SQL 로 번역되어 DB에 결과를 가져오고 JPA가 eager 패치타입을 확인 후 아직 채워지지 않은 데이터들을 채우기 위해 n+1 select 쿼리가 발생한다.
entityManager.find() 만을 사용하는 경우라면 원하는대로 join 쿼리를 JPA 가 생성해 보내지만, JPQL 을 사용하는 순간 원하지 않는 N+1 문제가 발생할 수 있음으로 fetchtype 을 EAGER 로 설정하지 않도록 하자

일대다관계 fetch join

일대다관계(컬렉션)을 조인하여 가져올 때 조회의 주체를 기준으로 데이터가 부풀려진다. (n 관계의 데이터를 조회하니 당연한 일이다.) 이를 해결하기 위해서는 distinct 를 사용해 중복을 제거해야할 필요가 있다. 다만 hibernate6 기준으로는 일대다관계를 fetch join 하는 경우 distinct 를 선언하지 않아도 적용된 데이터를 가져온다.

  • distinct 는 DB 의 distinct 와는 다르다. SQL 의 distinct 는 완전한 중복일 때 중복을 제거하고, fetch join 시 JPA 가 하는 distinct 는 조회 주체의 ID를 기준으로 컬렉션을 만들어주는 개념이라고 보는게 좋다.
  • 컬렉션 fetch join 시 페이징 처리를 해서는 안된다. (하이버네이트는 페이징할 모든 데이터를 메모리에 전부 가져와 어플리케이션 레벨에서 페이징을 시도한다. -> OOM 위험)
  • 컬렉션 패치조인은 1개만 사용가능하다.
    1:n:n -> 불가,
    (1:n) * 2 -> 불가

일대다관계(컬렉션)을 가져오면서 페이징이 필요한 경우에는, xxxToOne 관계는 모두 fetchJoin 을 통해 가져온 후, 컬렉션은 지연로딩을 사용하여 가져온다. 다만 이 때 batchSize 를 설정함으로써 성능최적화를 이끌어낸다.

-- yml 을 통한 global 배치사이즈 설정
spring:
 jpa: 
  properties:
   default_batch_fetch_size: 100 # 배치사이즈 100
   
-- @BatchSize 애노테이션 설정
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    @BatchSize(size = 100)
    private List<OrderItem> orderItems = new ArrayList<>();

OSIV

spring.jpa.open-in-view: true (기본값)
기본값에 따라 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다.
-> API controller 나 viewTemplate 에서 지연로딩이 가능해짐
-> 커넥션을 너무 오래물고 있어 커넥션이 부족해지는 상황을 초래한다.

OSIV 설정을 끄면 트랜잭션이 종료될 때 반환한다.

Spring Data JPA page 활용 (DTO반환)

page.map 을 활용하면 쉽게 변환 가능

    public static Page<MemberDto> pageable(Page<Member> members) {
        return members.map(member -> new MemberDto(member.getUsername(), member.getAge()));
    }

Spring 데이터 JPA 사용과 @GeneratedValue

Spring Data Jpa 를 사용하면, JpaRepository 인터페이스의 구현체 SimpleJpaRepository 를 사용하는 경우가 많다.
이 때, save() 를 확인해보면 새로운 객체인 경우 persist() 를 호출하고 기존에 있는 객체라면 merge() 를 호출하는 다음의 코드를 확인 할 수 있다.

	@Transactional
	@Override
	public <S extends T> S save(S entity) {

		Assert.notNull(entity, "Entity must not be null");

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}

새로운 객체의 판단여부는 id 값이 null인지(래퍼런스타입) 혹은 0인지(프리미티브타입)를 통해 판단하게 되는데, @GeneratedValue 를 사용한다면 persist 이후에 id 가 세팅되지만, 만약 직접 id 를 생성해주는 경우라면 null 이 아니기에 merge 가 호출되고 이는 성능악화(select 이후 save) 를 야기한다.
따라서 직접 id 를 세팅해주는 경우라면 Pesistable 을 엔티티에서 구현하여 isNew() 메소드를 오버라이딩해야한다.
가장 간단한 방법은 createdDate 를 사용하여 해당 필드가 null 이라면 새로운객체라고 판단하는 방법이 있다.

@Entity
public class Item implements Persistable<String> {
    @Id
    private String id;
    
    @CreatedDate
    private LocalDateTime createdDate;

    @Override
    public String getId() {
        return this.id;
    }

    @Override
    public boolean isNew() {
        return createdDate == null;
    }
}
```

0개의 댓글