코알못

[JPA] JPA 정복하기 - 01. JPA 소개 및 프로젝트 환경 설정 본문

JAVA

[JPA] JPA 정복하기 - 01. JPA 소개 및 프로젝트 환경 설정

코린이s 2024. 1. 6. 13:36
728x90

Spring Data JPA는 Spring 프레임워크에서 JPA기술을 편리하게 이용할 수 있는 기술이다.

매번 반복 해야했던 CRUD를 쉽게 작성 할 수 있으며 구현체 없이 인터페이스만으로도 데이터 조작이 가능하다. 그렇기에 비즈니스 코드 작성에만 집중하여 개발하면 된다. 

우선 우리는 순수 JPA와 스프링 데이터 JPA를 비교해보도록 하자!

스프링 부트 스타터(start.spring.io)를 통해 프로젝트를 쉽게 생성해보도록 하자!

dependencies 아래 항목들을 추가한다.

라이브러리 설명
Spring Web JPA가 Web 관련 지원하는 기능을 사용하기 위함
Spring Data JPA JPA 사용을 위함
H2 Database 메모리 기반 2Mb 정도의 작은 데이터 베이스 (설치 없이 이용가능)
Lombok Getter Setter와 같은 반복된 코드를 쉽게 만들기 위함

이제 Generate 를 눌러 프로젝트를 생성하면 압축파일이 생성되며 압축을 풀어서 사용하면 된다.

IDE 툴을 통해서 해당 프로젝트를 읽어 온뒤 TestController를 생성하여 테스트 하면 정상적으로 Test 문구가 출력 된다.

이제 Junit 을 이용하여 Test 를 진행하면 Test worker가 뜨고나서 좀 뒤에 테스트 코드가 실행된다.

이는 Test 코드 실행시 Gradle 을 통해서 Test 코드를 실행 시키기 때문인데 바로 IntelliJ 에서 실행시키기 위해서는 설정을 아래와 같이 바꾸면 된다. 두개 설정 모두 IntelliJ 로 수정하면

더이상 Test worker 가 뜨지 않으며 이를 통해 바로 테스트 코드를 실행함을 알 수 있다.

이제 사용하는 라이브러리중 JPA 의존성 라이브러리 보면 ORM 프레임 워크 hibernate, 로깅을 위한 sl4j 인터페이스에 log4j가 아닌 logback 구현체를 사용하며

jdbc 컨넥션 풀 관리를 위해 HikariCP를 사용하고 spring webmvc 사용, h2 클라이언트 사용하는 것을 볼 수 있다.

이제 h2를 설치 하기 위해 설치된 h2 클라이언트 버전을 보면 2.2.224 인것을 알 수 있으며 

다른 방법으로 버전 확인 하는 방법은 'Spring(https://spring.io/) 사이트 > 상단탭 Projects > Spring Boot > 설치한 버전 선택' 한 뒤에 아래와 같이 타고 들어가서

Control + F 를 통해 h2 를 찾으면 버전이 나온다.

이제 H2 다운로드 사이트(https://h2database.com/)를 가서 맞는 버전을 설치 하고 용량을 보면 실제로 크지 않다.

이제 아래와 같이 h2 를 실행 시킨다.

$ cd h2
$ ls
bin		build.bat	build.sh	docs		service		src
$ cd bin
$ ls
h2-2.2.224.jar	h2.bat		h2.sh		h2w.bat
$ chmod +x h2.sh
$ ./h2.sh

아래와 같이 원하는 경로를 지정해주면 

아래와 같이 DB 파일이 생성된다.

$ cd ~/Downloads/
$ ls
h2.mv.db

위 방식을 파일로 접근 하는것이기 때문에 lock이 잡혀서 멀티 접근이 안되니 연결을 끊고 아래와 같이 원격 접속 방식으로 변경 한다.

이제  순수 JPA를 가볍게 사용해보자!

application.yml

spring:
  datasource:
    url: jdbc:h2:tcp//localhost/~/Downloads/h2 # connection url
    username: admin # username
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create # application load 시점에 모든 테이블 drop 후 create
    properties:
      hibernate:
        format_sql: true # 쿼리가 이쁘게 나옴
        #show_sql: true # JPA 실행 쿼리를 System.out에 남김
logging:
  level:
    org.hibernate.SQL: debug # JPA 실행 쿼리를 System.out이 아닌 logger를 통해 남긴다.
    #org.hibernate.type: trace # 쿼리에 사용된 파라미터 찍히도록함

Member.java

package study.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
@Entity
public class Member {
    @Id @GeneratedValue // 알아서 식별자를 딴다.
    private Long id;
    private String username;

}

MemberJpaRepository.java 

package study.datajpa.repository;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import study.datajpa.entity.Member;

@Repository
public class MemberJpaRepository {
    /***
     * @PersistenceContext
     * - Autowired와 차이 구글링 해보니 동일하게 동작하나 JPA를 위한 어노테이션인 해당 어노테이션을 사용하는것이 좋을것 같다는 내용이 많다.
     */
    @PersistenceContext
    /***
     * EntityManager
     * - 영속(=관리=스프링에서 만든) 컨텍스트 공간에 있는것을 불러온다(현재는 영속 공간에 있는 EntityManager을 불러온다)
     * - em.persist, em.find시 영속된 공간에 저장(=캐싱)하고 찾기에 결과는 정상적으로 나오나 DB에 실제로 가보면 없다.
     * - 그래서 EntoryTransaction의 commit 메소드를 사용하여 영속 공간에서 DB로 저장된다.
     * - 쓰기 지연 : 한번에 DB에 접근하기 떄문에 DB 부하를 줄일 수 있다.
     * - 지연 로딩 : 쓸 시점에 조회하여 가져온다.
     */
    private EntityManager em;

    public Member save(Member member){
        em.persist(member);
        return member;
    }

    public Member find(Long id){
        return em.find(Member.class, id);
    }

}

아래와 같이 mac 이라면 command+n 을 누른뒤 Test 코드를 자동 생성한다.

생성하면 아래와 같이 만들어진다.

우선 테스트 함수 하나 제거하고 하나만 남겨두고 ID 같은지 체크한다.

package study.datajpa.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import study.datajpa.entity.Member;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class MemberJpaRepositoryTest {

    @Autowired
    private MemberJpaRepository memberJpaRepository;

    @Test
    void test() {
        Member member = Member.builder()
                            .username("test").build();
        Member saveMember = memberJpaRepository.save(member);
        Member findMember = memberJpaRepository.find(saveMember.getId());
        assertEquals(findMember.getId(), member.getId());
        assertEquals(findMember.getUsername(), member.getUsername());
    }

}

실행하면 오류 메세지가 발생하는데 트랜젝션이 없다는 에러가 발생하므로 transaction 

org.springframework.dao.InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call

 다시 붙여서 실행하면 

    @Transactional
    @Test
    void save() {
        Member member = Member.builder()
                            .username("test").build();
        Member saveMember = memberJpaRepository.save(member);
        Member findMember = memberJpaRepository.find(saveMember.getId());
        assertEquals(findMember.getId(), member.getId());
        assertEquals(findMember.getUsername(), member.getUsername());
    }

성공 하는것을 볼 수 있으나 insert 쿼리는 안날라 간것을 볼 수 있고

DB 조회하면 값이 저장 되지 않았다. 이는 Test시에는 자동으로 Rollback 해버린다.(DB에 아무쿼리 하지 않고 저장한 영속 캐시에서 제거 한다.)

테스트 코드에서 저장 하기 위에서는 아래와 같이 Rollback 어노테이션을 추가한다.

    @Rollback
    @Transactional
    @Test
    void save() {
    ...
    }

다시 해보면 테스트 통과 되고 아래 쿼리가 실행되며

아래와 같이 데이터도 H2에 나온다.

이제 interface를 만들고 spring Data에서 제공하는 JPA 인터페이스 JpaRepository를 상속받아서 이용해보자! 

MemberRepository.java

package study.datajpa.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.datajpa.entity.Member;

public interface MemberRepository extends JpaRepository<Member, Long> {
}

테스트 코드를 작성한다.

MemberRepositoryTest.java

package study.datajpa.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;
import study.datajpa.entity.Member;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Transactional
    @Rollback(value = false)
    @Test
    public void test(){
        Member member = Member.builder()
                .username("test2")
                .build();
        Member saveMember = memberRepository.save(member);
        Member findMember = memberRepository.findById(saveMember.getId()).orElse(Member.builder().build());
        assertEquals(member.getId(), findMember.getId());
        assertEquals(member.getUsername(), findMember.getUsername());
        assertEquals(member, findMember);
    }

}

이번에는 파라미터 찍히도록 설정하고 테스트 실행해보자!

두가지 방법이 있어 선택하면 된다.

1. application.yml 설정 추가

아래와 같이 로그가 남는다.

    insert 
    into
        member
        (username, id) 
    values
        (?, ?)
2.571+09:00 TRACE 55456 --- [main] org.hibernate.orm.jdbc.bind              : binding parameter (1:VARCHAR) <- [test2]
2024-01-06T18:45:52.571+09:00 TRACE 55456 --- [main] org.hibernate.orm.jdbc.bind              : binding parameter (2:BIGINT) <- [1]

application.yml

...
logging:
  level:
  ...
    org.hibernate.orm.jdbc.bind: trace # 쿼리에 사용된 파라미터 찍히도록함

2. p6spy 라이브러리를 추가하여 파라미터 로그를 남긴다.

이는 라이브러리 하나더 사용하는것이므로 성능에 이슈가 있을수 있으니 충분한 테스트를 거친후 진행한다.

build.gradle

dependencies {
	...
	implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
   }

이번에는 2번을 이용하여 테스트 하도록 한다.

테스트 실행해보면 아래와 같이 성공 했으며 create insert 정상적으로 나갔고 파라미터 로그도 찍힌것을 볼 수 있다.

쿼리 로그의 경우 파라미터 로그 찍기 위해 사용한 p6spy 로그도 같이 찍혀서 2번 실행한것 처럼 보이니 참고 해야 한다. 

데이터도 정상적으로 삽입 되었다.

이처럼 JPA를 직접 구현하는 방식 보다는 spring data JPA 를 사용하는것이 더 편리하다.

이제 조인시 사용하는 fetch 타입인 LAZY ,EAGER 차이를 알아보자!

Member 와 Team 은 team_id가 FK로 연관되어있는 테이블이며 하나의 Team 은 여러 Member 를 포함 할 수 있다.

Member 엔티티의 입장에서 Team 엔티티는 N:1 이니 Member Class 에서 Team 정의시 @ManyToOne을 사용하고

Team 엔티티 입장에서는 Member 엔티티가 1:N 이니 Team Class 에서 Member 정의시 @OneToMany를 사용한다.

이제 아래와 같이 Team 엔티티를 생성하고 Member 엔티티를 수정해보자!

Team.java

package study.datajpa.entity;

import jakarta.persistence.*;
import lombok.*;

import java.util.ArrayList;
import java.util.List;

@ToString(of = {"id","name"})
@AllArgsConstructor
@Builder
@NoArgsConstructor
@Getter
@Entity
public class Team {

    @Id @GeneratedValue
    @Column(name = "team_id")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team") // FK 없는 Entity에 mappedBy 설정 > 연관 되어 있는 변수명
    private List<Member> members = new ArrayList<>();


}

Member.java

package study.datajpa.entity;

import jakarta.persistence.*;
import lombok.*;

@ToString(of = {"id","username","age"}) // 나중에 toString 으로 데이터 확인하기 위함 > 정의한 값만 출력
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Entity
public class Member {
    @Id @GeneratedValue // 알아서 식별자를 딴다.
    @Column(name = "member_id")
    private Long id;
    private String username;
    private int age;

    /***
     * - FetchType.LAZY(지연로딩)은 먼저 해당 클래스 쿼리 날리고 연관 테이블은 사용할때 쿼리 한번더 호출 하므로
     *   2번 쿼리 실행 되지만 사용할때만 호출 된다는 장점이 있다.(매번 같이 조회하지 않는다면 사용)
     * - 항상 연관 테이블 같이 사용한다면 EAGER(즉시로딩)를 사용해서 쿼리 한번에 호출 한다.
     * - 그러나 지연 로딩이 JPA 장점을 활용할 수 있고, 즉시 로딩 설정후 두개의 연관 테이블을 같이 부르지 않는 상황이 되면
     *   부르지 않은 테이블을 데이터 수만큼 N 번 호출 하는 문제가 있으므로 기본으로 LAZY를 사용하고
     *   즉시 로딩과 같이 사용하는 케이스가 있다면 다른 사용하는 방법(JPQL fetch join이나, 엔티티 그래프 기능)이 있어 그 방법으로 불러오는게 좋은것 같다.
     */
    @ManyToOne
    @JoinColumn(name = "team_id") // FK
    private Team team;

    public Member(String username, int age, Team team){
        this.username = username;
        this.age = age;
        if (team != null){
            changeTeam(team);
        }
    }

    public void changeTeam(Team team){
        this.team = team;
        team.getMembers().add(this);
    }
}

이제 테스트 코드를 작성한다.

MemberTest.java

package study.datajpa.entity;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

import static org.junit.jupiter.api.Assertions.*;

@Rollback(value = false)
@Transactional
@SpringBootTest
class MemberTest {

    @PersistenceContext
    EntityManager em;

    @Test
    public void test(){
        Team teamA = Team.builder()
                .name("teamA")
                .build();
        Team teamB = Team.builder()
                .name("teamB")
                .build();
        em.persist(teamA);
        em.persist(teamB);
        Member member1 = Member.builder()
                .username("member1")
                .age(10)
                .team(teamA).build();
        Member member2 = Member.builder()
                .username("member2")
                .age(20)
                .team(teamA).build();
        Member member3 = Member.builder()
                .username("member3")
                .age(30)
                .team(teamB).build();
        Member member4 = Member.builder()
                .username("member4")
                .age(40)
                .team(teamB).build();
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        em.flush(); // query call
        em.clear(); // cache delete
        //Member findMember = em.find(Member.class);
        //System.out.println(findMember.getUsername().getClass());
        //System.out.println("TEAM NAME : " + findMember.getUsername());

        List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
        members.stream().forEach(s -> System.out.println("# data=> "+s));
    }

    @Test
    void changeTeam() {
    }
}

이 경우 Member 관련 데이터만 출력하며 Member Class 에 정의한 Team Class가 기본으로 EAGER 타입이므로 Team 테이블 데이터를 쓰지 않아도 불러온다.

심지어 Team 의 데이터 수만큼 조회하는것을 볼 수 있다. 만약에 Team 데이터 수가 100개고 모두 사용한다면 100번 조회 될것이다. 

그렇다면 Member Class 에 Team Class 를 LAZY 타입으로 변경해보자.

Member.java

..
public class Member {
..
@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id") // FK
    private Team team;
..
}

다시 테스트 코드를 돌려보면 Team 테이블은 사용하지 않으므로 조회 되지 않는다.

그럼 EAGER 를 사용하기 좋을때는 언제일까?

예제로 알아보자! 우선 Member 를 다시 EAGER fetch 타입으로 변경하고 지금처럼 member만 조회하지 않고 같이 조회해보자!

MemberTest.java

package study.datajpa.entity;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

import static org.junit.jupiter.api.Assertions.*;

@Rollback(value = false)
@Transactional
@SpringBootTest
class MemberTest {

    @PersistenceContext
    EntityManager em;

    @Test
    public void test(){
        Team teamA = Team.builder()
                .name("teamA")
                .build();
        Team teamB = Team.builder()
                .name("teamB")
                .build();
        em.persist(teamA);
        em.persist(teamB);
        Member member1 = Member.builder()
                .username("member1")
                .age(10)
                .team(teamA).build();
        Member member2 = Member.builder()
                .username("member2")
                .age(20)
                .team(teamA).build();
        Member member3 = Member.builder()
                .username("member3")
                .age(30)
                .team(teamB).build();
        Member member4 = Member.builder()
                .username("member4")
                .age(40)
                .team(teamB).build();
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        em.flush(); // query call
        em.clear(); // cache delete
        Member findMember = em.find(Member.class, member1.getId());
        //System.out.println(findMember.getUsername().getClass());
        System.out.println("TEAM NAME : " + findMember.getTeam()+"| MEMBER NAME : "+findMember.getUsername());

        //List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
        //members.stream().forEach(s -> System.out.println("# data=> "+s));
    }

    @Test
    void changeTeam() {
    }
}

실행해보면 Member 만 조회한다는 정의 없이 사용하니 조인 쿼리로 한번에 나간다.

만약 LAZY 였으면 두번 쿼리 호출 됐을것이다.

이처럼 항상 같이 사용하는 테이블이면 EAGER가 문제 되지 않고 오히려 한번에 조회하니 성능적으로 더 좋지만 첫번째 예제 처럼 N 번 조회 쿼리를 호출하여 DB 부하를 줄 수 있는 상황이 될 수 있다.

그렇기에 우선 LAZY 를 사용하고 필요시 추후 배울 기술 JPQL fetch join 이나 엔티티 그래프 기능을 사용하여 EAGER 와 같이 조회하도록 하자!

끝!

728x90
Comments