반응형

들어가며

ORM?

  • Object-Relational Mapping (객체와 관계형 데이터베이스 매핑, 객체와 DB의 테이블이 매핑을 이루는 것)
  • 객체가 테이블이 되도록 매핑 시켜주는 프레임워크이다.
  • 프로그램의 복잡도를 줄이고 자바 객체와 쿼리를 분리할 수 있으며 트랜잭션 처리나 기타 데이터베이스 관련 작업들을 좀 더 편리하게 처리할 수 있는 방법

JPA?

  • Java Persistence API (자바 ORM 기술에 대한 API 표준 명세)
  • 한마디로 ORM을 사용하기 위한 인터페이스를 모아둔 것 이라고 볼 수 있다.
  • 자바 어플리케이션에서 관계형 데이터베이스를 사용하는 방식을 정의한 인터페이스이다.
  • MyBatis는 ORM이 아니고 SQL Mapper임

Hibernate?

  • JPA를 사용하기 위해서 JPA를 구현한 ORM 프레임워크중 하나.
  • JPA 인터페이스의 실제 구현부를 담당함

JPA의 장점

  • 객체 지향적인 코드로 인해 더 직관적이고 비즈니스 로직에 더 집중할 수 있게 도와줌
  • 객체 지향적으로 데이터를 관리할 수 있기 때문에 전체 프로그램 구조를 일관되게 유지할 수 있음
  • SQL을 직접 작성하지 않고 객체를 기준으로 동작하기 때문에 유지보수가 쉬움 (ex. 컬럼 수정시 해당 모델 객체 필드만 수정해주면 끝)
  • 쿼리 문법 오류를 런타임시점이 아닌 컴파일시점에 미리 알 수 있음
  • DBMS에 대한 코드 종속성이 줄어듬

JPA의 단점

  • 학습 비용이 높음
  • 통계와 같은 복잡한 쿼리 사용시 불리함 (실시간 처리용 쿼리에 최적화)
  • 잘못 사용할 경우 실제 SQL문을 직접 작성하는 것보다 성능이 떨어질 수 있음

Spring JPA 기본

설명

  • spring boot에 jpa를 연동하는 기본 예제
  • DBMS는 MySQL을 사용

테이블 생성

CREATE TABLE IF NOT EXISTS student
(
    studentSeq BIGINT NOT NULL AUTO_INCREMENT,
    name       VARCHAR(200),
    age        INTEGER,
    PRIMARY KEY (studentSeq)
);

Dependency 추가

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>6.0.6</version>
</dependency>

properties 설정

# MySQL
spring.datasource.url=jdbc:mysql://mysql.server.com:3306/test?characterEncoding=UTF-8&serverTimezone=Asia/Seoul&useSSL=false
spring.datasource.username=test
spring.datasource.password=test
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# SQL문에 바인딩된 파라미터값을 로그로 출력하기 위한 설정.
logging.level.org.hibernate.type.descriptor.sql=trace

# 테이블 자동 생성하지 않도록 설정.
spring.jpa.hibernate.ddl-auto=none

# 변수명을 그대로 칼럼명으로 지정 가능하도록 설정 (ex. 카멜케이스)
spring.jpa.hibernate.naming.implicit-strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

# 하이버네이트가 실행하는 모든 SQL문을 콘솔로 출력해 준다.
spring.jpa.properties.hibernate.show_sql=true

# 콘솔에 출력되는 JPA 실행 쿼리를 가독성있게 표현한다.
spring.jpa.properties.hibernate.format_sql=true

# 디버깅이 용이하도록 SQL문 이외에 추가적인 정보를 출력해 준다.
spring.jpa.properties.hibernate.use_sql_comments=false

# 데이터베이스 방언 설정
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

Entity 클래스 생성

  • @Id
    • 기본키(PK) 지정
  • @GeneratedValue
    • 기본키 생성 전략 설정
    • IDENTITY는 기본키 생성을 DB에 위임하는 방식
// lombok의 @Data나 @ToString 사용하면 toString() 메소드 관련 StackOverFlow 오류 발생할 수 있음
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Entity(name = "student")
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long studentSeq;
    private String name;
    private Integer age;
}

Repository 클래스 생성

@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
    List<Student> findByName(String name);
}

CRUD 테스트

@Slf4j
@SpringBootTest
public class JpaTest {
    @Autowired
    private StudentRepository studentRepository;

    @Test
    public void insert() {
        studentRepository.save(new Student(null, "john", 25));
        studentRepository.save(new Student(null, "tom", 30));
    }

    @Test
    public void update() {
        studentRepository.save(new Student(1L, "john2", 26));
    }

    @Test
    public void delete() {
        //studentRepository.delete(new Student(1L, null, null));
        studentRepository.deleteById(1L);
    }

    @Test
    public void select() {
        List<Student> students1 = studentRepository.findAll();
        List<Student> students2 = studentRepository.findByName("john");
        Optional<Student> student = studentRepository.findById(1L);

        log.info("findAll : {}", students1);
        log.info("findByName : {}", students2);
        log.info("findById : {}", student);
    }
}

join

설명

  • 객체 조인을 표현하기 위해 멤버로 해당 Entity 객체를 필드로 두고 어노테이션을 달아 명시함
  • 어노테이션 종류
    • @OneToMany
    • @ManyToOne
    • @OneToOne

테스트 테이블

create table category
(
    categorySeq  integer primary key auto_increment,
    categoryName varchar(100)
);

create table book
(
    bookSeq     integer primary key auto_increment,
    bookName    varchar(500),
    bookCategorySeq integer
);

create table bookDetail
(
    bookDetailSeq integer primary key auto_increment,
    bookPrice     integer,
    bookSeq       integer
);

@OneToMany

  • @OneToMany
    • 양방향 조인일 경우 @JoinColumn 없이 mappedBy 속성만으로 사용 가능
    • mappedBy에 들어갈 내용은 Owner Entity의 이름
    • default fetch type : FetchType.LAZY
  • @JoinColumn
    • referencedColumnName : Owner Entity에 조인 필드
    • name : 상대 Entity의 조인 필드
@Getter
@Setter
@Entity(name = "category")
public class Category {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer categorySeq;
    private String categoryName;

    // @OneToMany(mappedBy = "category")
    @OneToMany
    @JoinColumn(referencedColumnName = "categorySeq", name = "bookCategorySeq")
    private List<Book> books;
}

@ManyToOne

  • default fetch type : FetchType.EAGER
@Getter
@Setter
@Entity(name = "book")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer bookSeq;
    private String bookName;

    @ManyToOne
    @JoinColumn(referencedColumnName = "categorySeq", name = "bookCategorySeq")
    private Category category;
}

@OneToOne

  • default fetch type : FetchType.EAGER
@Getter
@Setter
@Entity(name = "book")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer bookSeq;
    private String bookName;

    @OneToOne
    @JoinColumn(referencedColumnName = "bookSeq", name = "bookSeq")
    private BookDetail bookDetail;
}

@Getter
@Setter
@Entity(name = "bookDetail")
public class BookDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer bookDetailSeq;
    private Integer bookPrice;
}

@OneToOne 조회 쿼리 살펴보기

  • Book과 BookDetail은 1:1 관계이기 때문에 inner join으로 한 번에 조회해오면 될 것 같은데 아래 코드를 수행해보면 book 조회, bookDetail 조회 각각 1번씩 총 2번 조회해오고있음
truncate book;
truncate bookDetail;

insert into book (bookName, bookCategorySeq) values ('book-1-1', 1);
insert into bookDetail (bookPrice, bookSeq) values (2000, 1);
// LAZY fetch되는 데이터 접근시 @Transactional 없으면 오류 발생
@Transactional(readOnly = true)
@Test
public void select1() {
    Book book = bookRepository.getOne(1);

    System.out.println(book.getBookName());
    System.out.println(book.getBookDetail().getBookPrice());
}
Hibernate: 
    select
        book0_.bookSeq as bookseq1_0_0_,
        book0_.bookName as bookname2_0_0_ 
    from
        book book0_ 
    where
        book0_.bookSeq=?
Hibernate: 
    select
        bookdetail0_.bookDetailSeq as bookdeta1_1_0_,
        bookdetail0_.bookPrice as bookpric2_1_0_ 
    from
        bookDetail bookdetail0_ 
    where
        bookdetail0_.bookDetailSeq=?
book-1-1
2000

@OneToOne 쿼리 한 번으로 조회하기

  • JPQL을 활용한 join fetch로 한 번에 조회
@Repository
public interface BookRepository extends JpaRepository<Book, Integer> {
    @Query("select b from book b join fetch b.bookDetail where b.bookSeq = :bookSeq")
    Book getOneJoinFetch(Integer bookSeq);
}
@Transactional(readOnly = true)
@Test
public void select1() {
    Book book = bookRepository.getOneJoinFetch(1);

    System.out.println(book.getBookName());
    System.out.println(book.getBookDetail().getBookPrice());
}
Hibernate: 
    select
        book0_.bookSeq as bookseq1_0_0_,
        bookdetail1_.bookDetailSeq as bookdeta1_1_1_,
        book0_.bookName as bookname2_0_0_,
        bookdetail1_.bookPrice as bookpric2_1_1_ 
    from
        book book0_ 
    inner join
        bookDetail bookdetail1_ 
            on book0_.bookSeq=bookdetail1_.bookDetailSeq 
    where
        book0_.bookSeq=?
book-1-1
2000

JPA의 N+1 문제

설명

  • 위 예제에서 모든 Category를 조회해온 후 각 카테고리의 Book 목록을 사용한다고 가정
  • @OneToMany의 기본 fetch type은 LAZY이기 때문에 Category 목록을 조회해올 때 Book 목록을 함께 조회하지 않고, Book 목록 사용 코드가 발생하면 그 때 조회한다
  • 이처럼 모든 Category를 조회하기 위해 한 번, 각 Book 목록 접근시에도 N번의 조회 쿼리가 발생하는 이슈를 말한다

테스트 데이터 추가

truncate category;
truncate book;

insert into category (categoryName) values ('category-1');
insert into category (categoryName) values ('category-2');
insert into book (bookName, bookCategorySeq) values ('book-1-1', 1);
insert into book (bookName, bookCategorySeq) values ('book-1-2', 1);
insert into book (bookName, bookCategorySeq) values ('book-1-3', 1);
insert into book (bookName, bookCategorySeq) values ('book-2-1', 2);
insert into book (bookName, bookCategorySeq) values ('book-2-2', 2);
insert into book (bookName, bookCategorySeq) values ('book-2-3', 2);

Category별 Book 목록 출력

@Transactional(readOnly = true)
@Test
public void nPlusOneIssue() {
    List<Category> categories = categoryRepository.findAll();

    categories.forEach(category -> {
        System.out.println(
            category.getBooks()
                .stream()
                .map(Book::getBookName)
                .collect(Collectors.joining(", "))
        );
    });
}

실행 결과

  • Category 전체 조회하는 쿼리 1번, 각 Category의 Book 목록을 조회하기 위해 Category 개수(2개)만큼 추가 조회 발생
Hibernate: select category0_.categorySeq as category1_1_, category0_.categoryName as category2_1_ from category category0_
Hibernate: select books0_.bookCategorySeq as bookcate3_0_0_, books0_.bookSeq as bookseq1_0_0_, books0_.bookSeq as bookseq1_0_1_, books0_.bookName as bookname2_0_1_, books0_.bookCategorySeq as bookcate3_0_1_ from book books0_ where books0_.bookCategorySeq=?
book-1-1, book-1-2, book-1-3
Hibernate: select books0_.bookCategorySeq as bookcate3_0_0_, books0_.bookSeq as bookseq1_0_0_, books0_.bookSeq as bookseq1_0_1_, books0_.bookName as bookname2_0_1_, books0_.bookCategorySeq as bookcate3_0_1_ from book books0_ where books0_.bookCategorySeq=?
book-2-1, book-2-2, book-2-3

solution 1 - join fetch

  • JPQL을 활용한 join fetch를 통해 Category, Book 데이터를 한 번에 조회한다
@Repository
public interface CategoryRepository extends JpaRepository<Category, Integer> {
    @Query("select distinct(c) from category c join fetch c.books")
    List<Category> findAllJoinFetch();
}
@Test
public void nPlusOneIssue() {
    List<Category> categories = categoryRepository.findAllJoinFetch();

    categories.forEach(category -> {
        System.out.println(
            category.getBooks()
                .stream()
                .map(Book::getBookName)
                .collect(Collectors.joining(", "))
        );
    });
}
Hibernate: 
    select
        distinct category0_.categorySeq as category1_1_0_,
        books1_.bookSeq as bookseq1_0_1_,
        category0_.categoryName as category2_1_0_,
        books1_.bookName as bookname2_0_1_,
        books1_.bookCategorySeq as bookcate3_0_1_,
        books1_.bookCategorySeq as bookcate3_0_0__,
        books1_.bookSeq as bookseq1_0_0__ 
    from
        category category0_ 
    inner join
        book books1_ 
            on category0_.categorySeq=books1_.bookCategorySeq
book-1-1, book-1-2, book-1-3
book-2-1, book-2-2, book-2-3

solution 2 - @EntityGraph

  • join fetch와 원리는 같음
  • join fetch는 inner join, @EntityGraph는 left outer join을 활용함
@Repository
public interface CategoryRepository extends JpaRepository<Category, Integer> {
    @EntityGraph(attributePaths = {"books"})
    @Query("select distinct(c) from category c")
    List<Category> findAllJoinFetch();
}
Hibernate: 
    select
        distinct category0_.categorySeq as category1_1_0_,
        books1_.bookSeq as bookseq1_0_1_,
        category0_.categoryName as category2_1_0_,
        books1_.bookName as bookname2_0_1_,
        books1_.bookCategorySeq as bookcate3_0_1_,
        books1_.bookCategorySeq as bookcate3_0_0__,
        books1_.bookSeq as bookseq1_0_0__ 
    from
        category category0_ 
    left outer join
        book books1_ 
            on category0_.categorySeq=books1_.bookCategorySeq
book-1-1, book-1-2, book-1-3
book-2-1, book-2-2, book-2-3

QueryDSL 설정하기

QueryDSL?

  • JpaRepository나 JPQL은 복잡하고 동적인 쿼리 작성이 어렵다
  • QueryDSL은 쿼리를 자바 코드로 만들 수 있게 해주는 방법을 제공한다
  • 요청할 쿼리를 자바 코드로 작성하기 때문에 문법 오류를 컴파일 시점에 파악할 수 있다
  • 동적 쿼리 작성이 쉽다

pom.xml 설정

Java일 경우
<build>
    ...
    <resources>
        <resource>
            <directory>src/main/resources</directory>
        </resource>
    </resources>
    <plugins>
        ...
        <plugin>
            <groupId>com.mysema.maven</groupId>
            <artifactId>apt-maven-plugin</artifactId>
            <version>1.1.3</version>
            <executions>
                <execution>
                    <goals>
                        <goal>process</goal>
                    </goals>
                    <configuration>
                        <outputDirectory>${project.build.directory}/generated-sources/java</outputDirectory>
                        <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
</dependency>
Kotlin일 경우
<build>
    ...
    <plugins>
        ...
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <configuration>
                <args>
                    <arg>-Xjsr305=strict</arg>
                </args>
                <compilerPlugins>
                    <plugin>spring</plugin>
                </compilerPlugins>
            </configuration>
            <executions>
                <execution>
                    <id>compile</id>
                    <phase>process-sources</phase>
                    <goals>
                        <goal>compile</goal>
                    </goals>
                </execution>
                <execution>
                    <id>kapt</id>
                    <phase>generate-sources</phase>
                    <goals>
                        <goal>kapt</goal>
                    </goals>
                    <configuration>
                        <sourceDirs>
                            <sourceDir>src/main/kotlin</sourceDir>
                        </sourceDirs>
                        <annotationProcessorPaths>
                            <annotationProcessorPath>
                                <groupId>com.querydsl</groupId>
                                <artifactId>querydsl-apt</artifactId>
                                <version>${querydsl.version}</version>
                                <classifier>jpa</classifier>
                            </annotationProcessorPath>
                        </annotationProcessorPaths>
                    </configuration>
                </execution>
                <execution>
                    <id>test-compile</id>
                    <phase>test-compile</phase>
                    <goals>
                        <goal>test-compile</goal>
                    </goals>
                    <configuration>
                        <sourceDirs>
                            <sourceDir>src/test/kotlin</sourceDir>
                            <sourceDir>target/generated-sources/kapt/test</sourceDir>
                        </sourceDirs>
                    </configuration>
                </execution>
            </executions>
            <dependencies>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-allopen</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
</dependency>

JpaConfig 설정

@Configuration
public class JpaConfig {
    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager);
    }
}

Entity 생성

@Getter
@Setter
@Entity(name = "category")
public class Category {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer categorySeq;
    private String categoryName;
}

메이븐 컴파일 실행

mvn compile

Q클래스 생성 확인

메이븐 컴파일이 완료되면 지정된 경로에 Entity별 Q클래스가 생성된다

Simple CRUD Repisitory 클래스 작성

@RequiredArgsConstructor
@Repository
public class CategoryRepository {
    private final EntityManager entityManager;
    private final JPAQueryFactory queryFactory;
    private final QCategory category = QCategory.category;

    public void insert(Category params) {
        entityManager.persist(params);
    }

    public void update(Category params) {
        queryFactory
            .update(category)
            .where(category.categorySeq.eq(params.getCategorySeq()))
            .set(category.categoryName, params.getCategoryName())
            .execute();
    }

    public void delete(Integer categorySeq) {
        queryFactory
            .delete(category)
            .where(category.categorySeq.eq(categorySeq))
            .execute();
    }

    public List<Category> findAll(String categoryName) {
        return queryFactory
            .selectFrom(category)
            .where(
                categoryName == null ? null : category.categoryName.eq(categoryName)
            )
            .fetch();
    }
}

CRUD 테스트

@SpringBootTest
public class QueryDslCrudTest {
    @Autowired
    private CategoryRepository categoryRepository;

    @Transactional
    @Rollback(false)
    @Test
    public void insert() {
        categoryRepository.insert(new Category(null, "category-insert"));
    }

    @Transactional
    @Rollback(false)
    @Test
    public void update() {
        categoryRepository.update(new Category(1, "category-update"));
    }

    @Transactional
    @Rollback(false)
    @Test
    public void delete() {
        categoryRepository.delete(1);
    }

    @Transactional(readOnly = true)
    @Test
    public void select() {
        List<Category> categories1 = categoryRepository.findAll(null);
        List<Category> categories2 = categoryRepository.findAll("category-1");

        System.out.println(categories1.stream().map(Category::getCategoryName).collect(Collectors.joining(", ")));
        System.out.println(categories2.stream().map(Category::getCategoryName).collect(Collectors.joining(", ")));
    }
}

QueryDSL 심화

join

QCategory category = QCategory.category;
QBook book = QBook.book;

List<Category> categories = queryFactory
    .selectFrom(category)
    .innerJoin(category.books, book)
    // .leftJoin(category.books, book) // left outer join
    .fetchJoin()
    .distinct()
    .fetch();

dynamic query

QCategory category = QCategory.category;
Integer categorySeq = null;
String categoryName = "category";

List<Category> categories = queryFactory
    .selectFrom(category)
    .where(
        categorySeq == null ? null : category.categorySeq.eq(categorySeq),
        category.categoryName.isNotEmpty().or(category.categoryName.like(categoryName))
    )
    .fetch();

group by / having

QCategory category = QCategory.category;

List<Tuple> tuples = queryFactory
    .select(category.categoryName, category.categoryName.count())
    .from(category)
    .groupBy(category.categoryName)
    .having(category.categoryName.count().gt(0))
    .fetch();

order by

QCategory category = QCategory.category;

List<Category> categories = queryFactory
    .selectFrom(category)
    .orderBy(category.categoryName.desc(), category.categorySeq.asc())
    .fetch();

paging

QCategory category = QCategory.category;

QueryResults<Category> result = queryFactory
    .selectFrom(category)
    .offset(0)
    .limit(10)
    .fetchResults();

List<Category> categories = result.getResults();
long total = result.getTotal();
long offset = result.getOffset();
long limit = result.getLimit();

subquery

QProduct product = QProduct.product;
QHistory history = QHistory.history;

List<Product> products = queryFactory
    .selectFrom(product)
    .where(
        product.productSeq.in(
            JPAExpressions
                .select(history.productSeq)
                .from(history)
                .groupBy(history.productSeq)
                .having(history.amount.sum().goe(5000))
        )
    )
    .fetch();

다른 모델로 매핑하여 조회

@Getter
@Setter
public class OtherCategory {
    private Integer categorySeq;
    private String otherCategoryName;
}
QCategory category = QCategory.category;

List<OtherCategory> categories = queryFactory
    .select(Projections.bean(OtherCategory.class, 
        category.categorySeq, 
        category.categoryName.as("otherCategoryName")
    ))
    .from(category)
    .fetch();

참고

반응형

'Development > Spring' 카테고리의 다른 글

[Spring] Spring Cloud Config  (0) 2020.12.27
[Spring] SQL Mapper (with MyBatis)  (0) 2020.12.27
[Spring] WebSocket  (0) 2020.12.27
[Spring] Dependency Injection  (0) 2020.12.27
[Spring] Distributed Lock (with MySQL, Redis)  (4) 2020.12.27

+ Recent posts