본문 바로가기

프로잭트(일기장)/회사 공부 자료

Spring Data JPA 를 이용한 DB 개발

Spring Data JPA 를 이용한 DB 개발

JPA란 무엇인가?

JPA는 자바 진영의 ORM 기술 표준이다. 애플리케이션과 JDBC 사이에서 동작한다. 자바 퍼시스턴스 API(Java Persistence API,JPA)는 관계형 데이터베이스에 접근하기 위한 표준 ORM 기술을 제공하며, 기존에 EJB에서 제공되던 엔터티 빈(Entity Bean)을 대체하는 기술이다. 이를 구현한 구현체로는 Hibernate, OpenJPA, EclipseLink 와 같은것들이 있고 이에 따른 표준 인터페이스가 바로 JPA인 것이다. 

ORM이란?

객체와 관계형 데이터베이스를 매핑한다. ibatis나 mybatis는 ORM이 아니다. SQL 구문을 Mapping 하여 실행하는 매퍼이지 ORM이 아니다.
ORM 기술 표준을 구현한  프레임워크는 위에 언급한 하이버네이트가 대표적이다. 우리는 Spring-DATA-JPA를 이용하여 객체 관점에서 DB에 접근하는 형태로 어플리케이션을 개발할 것이고 그 구현기술로 하이버네이트를 적용할 예정이다.

왜 JPA를 사용해야 하는가

생산성 : 반복적인 SQL을 작업과 CRUD 작업을 개발자가 직접안해도 된다.
유지보수 : 객체의 수정에 따른 SQL 수정 작업을 개발자가 직접안해도 된다.
패러다임 불일치 해결 : 객체와 관계형 데이터베이스를 매핑하는 과정에서 발생하는 문제를 개발자가 직접 안해도 된다.
성능 : 캐싱을 지원하여 SQL이 여러번 수행되는것을 최적화 한다.
데이터 접근 추상화와 벤더 독립성 : 관계형데이터 베이스를 어떤 벤더를 사용할지에 따라 맵핑 방법도 다르다. 밴더에는 mysql, oracle, h2 등등이 있다. 이 매핑 작업을 개발자가 직접 안해도 된다.
표준 : 표준을 알아두면 다른 구현기술로 쉽게 변경할 수 있다.

JPA를 이용하여 객체 매핑을 할 경우 아래와 같은 어노테이션을 사용할 수 있다. 


우리는 이전 강좌에서 H2 를 이용한 기본 설정(자동구성) 어플리케이션을 작성하였다. 그러나, 실무에서의 경우 h2 보다는 오라클이나 MySQL과 같은 상용 RDBMS를 사용하므로 새 어플리케이션을 작성하면서 MySQL로 연동을 해보도록 한다. 

이번 챕터에서는 MySQL 을 이용하여 부서와 부서원 관리 어플리케이션을 작성해볼 예정이다. 이를 위해 DB 연동 설정과 Java 소스상에서 공통 반복 코드들을 어노테이션 기반으로 처리해줄 롬복 (lombok) 을 적용해볼 것이다. 롬복에 대해서 자세한 정보는 공식 사이트(https://projectlombok.org) 에서 확인하기 바란다. 이 내용들을 알기 위해서는 Persistance Layer의 개념과 Spring-Data-JPA와 Hibernate 의 개념, 그리고 영속화 연관관계에 대해서도 이해해야 하나, 이 내용들을 정리하기 위해서는 따로 상당한 양의 강의자료가 필요하므로 이 내용들은 Hibernate 관련 강의에서 재정리 하도록 하자. 

먼저 application.properties 파일에 JDBC 커넥션은 아래와 같이 정의할 수 있다. 

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driverClassName=org.h2.Driver


JDBC 연동을 위해서는 spring-boot-starter-jdbc 나 spring-boot-starter-data-jpa 스타터 번들이 추가 되어있어야 한다.

URL 에는 주소 + DB 스키마 명 (여기서는 test로 명명) 조합으로 구성되며 username 과 password는 해당 DB 스키마에 접근하는 사용자 계정 정보를 뜻한다. 

이 밖에 톰캣에서 제공하는 Connection Pool 을 사용할 경우 아래와 같은 속성 항목을 설정해줄 필요도 있다. (참고 : https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html)

# Number of ms to wait before throwing an exception if no connection is available.
spring.datasource.tomcat.max-wait=10000

# Maximum number of active connections that can be allocated from this pool at the same time.
spring.datasource.tomcat.max-active=50

# Validate the connection before borrowing it from the pool.
spring.datasource.tomcat.test-on-borrow=true


물론 JNDI를 사용하여 데이터소스에도 접근할 수 있다. 


MySQL DB 연동을 위한 기본적인 설정 

예제를 작성하기 위해, application.properties 파일에 아래와 같이 MySQL 접속 정보를 기술하도록 한다. 

# Hibernate config
# Hibernate ddl auto (create, create-drop, update, validate, none): with "update" the database schema will be automatically updated accordingly to java entities found in the project
spring.jpa.hibernate.ddl-auto=update
spring.jpa.generate-ddl=true
spring.jpa.database=MYSQL
# Show or not log for each sql query
spring.jpa.show-sql=true
# Allows Hibernate to generate SQL optimized for a particular DBMS
spring.jpa.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
spring.jpa.properties.hibernate.format_sql=true
spring.datasource.validation-query=SELECT 1
spring.datasource.test-on-borrow=false
spring.datasource.test-on-return=false // 반환시 커넥션 풀이 유효한지 검사

# JDBC Connection url for the database
spring.datasource.url=jdbc:log4jdbc:mysql://localhost:3306/howling?useUnicode=true&charaterEncoding=utf-8
spring.datasource.username=userId
spring.datasource.password=pass
spring.datasource.driverClassName=net.sf.log4jdbc.sql.jdbcapi.DriverSpy

# message
howling.hello.message=[develop mode] Hello libqa.com!
# view Resolver cache config
environment.viewResolver.cached=false

# log level config
logging.level.org.springframework.web=INFO
logging.level.org.hibernate=INFO

주목할 점은 하이버네이트 설정상에서의 옵션이 create, create-drop, update 등이 존재하는데 create  모드는 기존테이블 삭제 후 다시 생성하는 반면,  create-drop 모드는 종료 시점에 테이블 DROP을 하고 update 모드의 경우는 변경분만 반영하는 특성이 있다. 나머지 validate는 엔티티와 테이블이 정상 매핑되었는지만 확인 하는 옵션이고 none의 경우는 ddl 옵션을 사용하지 않는 것으로 실 서비스 모드에서는 보통 none 으로 처리한다. 


build.gradle 에 아래와 같이 관련 모듈을 추가해준다. 여기서는 thymeleaf 를 이용하여 뷰 영역을 표현할 예정이다. 자세한 스펙에 대해서는 공식 사이트를 참고한다.  (http://www.thymeleaf.org)

dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.springframework.boot:spring-boot-starter-thymeleaf')
compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('org.springframework.boot:spring-boot-starter-tomcat')
compile('org.projectlombok:lombok:1.16.0')
compile('mysql:mysql-connector-java:5.1.28')
compile('org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc3:1.16')
compile('com.h2database:h2')
testCompile('org.springframework.boot:spring-boot-starter-test')
}


SQL 을 console에 출력하기 위해서 resources 하위에 log4jdbc.log4j2.properties 를 추가하고 로그 레벨을 조정하기 위해 logback.xml 을 추가해주도록 한다. 
log4jdbc.log4j2.properties 

log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator


 logback.xml 

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/base.xml"/>

<!-- log4jdbc-log4j2 -->
<logger name="jdbc.sqlonly" level="ERROR"/>
<logger name="jdbc.sqltiming" level="INFO"/>
<logger name="jdbc.audit" level="INFO"/>
<logger name="jdbc.resultset" level="ERROR"/>
<logger name="jdbc.resultsettable" level="ERROR"/>
<logger name="jdbc.connection" level="INFO"/>
<logger name="com.libqa.web" level="DEBUG"/>
</configuration>


이제 도메인 파일을 작성할 예정인데, 패키지 경로는 com.libqa 하위에 entity를 선언하여 Member 엔티티와 Team 엔티티를 선언한다. 팀 엔티티는 1:N의 연관관계로 멤버 엔티티를 소유한다. 

아래와 같이 엔티티를 작성한다. 
Team.java

package com.libqa.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.Collection;

/**
* @Author : yion
* @Date : 2016. 12. 27.
* @Description :
*/
@Data
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class Team {


@Id
@Column(name = "seq")
@GeneratedValue(strategy = GenerationType.AUTO)
private int seq;

@Column(name = "team_name")
private String teamName;


@OneToMany(cascade = CascadeType.ALL, mappedBy = "team", fetch = FetchType.LAZY) // @OneToMany의 fetch 기본전략은 LAZY이다.
private Collection<Member> member;

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

}

Member.java

package com.libqa.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.Date;

/**
* @Author : yion
* @Date : 2016. 12. 19.
* @Description : 회원 정보 엔티티
*/
@Data
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class Member {

@Id
@Column(name = "seq")
@GeneratedValue(strategy = GenerationType.AUTO)
private int seq;

@Column(name = "name")
private String name;

@ManyToOne(optional = false)
@JoinColumn(name = "team_seq") // @ManyToOne의 fetch 기본전략은 EAGER이다.
private Team team;

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

}


이 엔티티를 접근하는 각각의 Repository를 작성한다. 

TeamRepository.java 

package com.libqa.web.member.repository;

import com.libqa.entity.Team;
import org.springframework.data.jpa.repository.JpaRepository;

/**
* @Author : yion
* @Date : 2016. 12. 27.
* @Description :
*/
public interface TeamRepository extends JpaRepository<Team, Integer> {
}


MemberRepository.java 

package com.libqa.web.member.repository;

import com.libqa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

/**
* @Author : yion
* @Date : 2016. 12. 20.
* @Description : User 리파지토리 인터페이스
*/
public interface MemberRepository extends JpaRepository<Member, Integer>{}


아직 아무론 비즈니스가 정의되어 있진 않지만, 해당 Repository를 호출하여 비즈니스 로직을 작성할 서비스를 정의해보도록 한다. 여기서는 RepositoryService라는 인터페이스로 정의 하였다. 

public interface RepositoryService {

}

아래는 RepositoryServiceImpl 이다. 

package com.libqa.web.member.service;

import com.libqa.entity.Member;
import com.libqa.entity.Team;
import com.libqa.web.member.repository.MemberRepository;
import com.libqa.web.member.repository.TeamRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;
import java.util.List;

/**
* @Author : yion
* @Date : 2016. 12. 27.
* @Description :
*/
@Slf4j
@Service
public class RepositoryServiceImpl implements RepositoryService {

@Autowired
private MemberRepository memberRepository;

@Autowired
private TeamRepository teamRepository;

public RepositoryServiceImpl() {
}

}

이제 서버를 기동해보자.
하이버네이트 DDL모드를 update로 실행 하였는데, 서버 기동시 올라오는 Console의 로그상에서 아래와 같이 테이블 생성 스크립트가 실행됨을 확인할 수 있다. 

2016-12-27 15:44:20.491  INFO 1886 --- [ main] jdbc.sqltiming : create table team (seq integer not null auto_increment, team_name varchar(255), primary key 
(seq)) 

2016-12-27 15:44:20.480  INFO 1886 --- [ main] jdbc.sqltiming : create table member (seq integer not null auto_increment, name varchar(255), team_seq integer 
not null, primary key (seq)) 

alter table member add constraint FKhqim285vtvxxsoaqj6by2slc2 foreign key (team_seq) references 
...

정상적으로 실행이 되었다면 DB 클라이언트등으로 설정한 DB에 접근해보도록 하자. 

위 두개의 테이블이 제대로 생성 되었음을 확인 할 수 있다. 



이제 html 폼을 이용하여 팀과 회원정보를 핸들링 해보도록 하자. 

먼저 팀명을 입력해서 팀을 생성하는 폼을 호출 해본다. 

DeptController.java 

package com.libqa.web.member.controller;

import com.libqa.entity.Team;
import com.libqa.web.member.service.RepositoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

/**
* @Author : yion
* @Date : 2016. 12. 19.
* @Description : 사용자 정보를 조회하는 controller
*/

@Controller
public class DeptController {

@Autowired
private RepositoryService repositoryService;

/**
* 팀 등록 폼 페이지
*
* @param model
* @return
*/
@GetMapping("/form")
public String form(Model model) {
model.addAttribute("title", "create your team!");
model.addAttribute("team", new Team());
return "form";
}

}

진입점은 "/form" 이라는 주소를 통해 GET방식으로 호출하며, 폼으로 표현할 객체를 생성해서 form.html 파일에 addAttribute 한 후 리턴해주었다. 이전의 자동설정 관련 내용에서 예제를 작성할 때 html 파일은 resources 하위에 templates 폴더 밑으로 작성했던것을 기억할 수 있을 것이다. form.html 파일을 작성해보자.


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Getting Started: Handling Form Submission</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1><p th:text="${title}" /></h1>
<form action="#" th:action="@{/add}" th:object="${team}" method="post">
<p>seq: <input type="text" th:field="*{seq}" /></p>
<p>name: <input type="text" th:field="*{teamName}" /></p>
<p><input type="submit" value="Submit" /> <input type="reset" value="Reset" /></p>
</form>
</body>
</html>

form action 으로 add URL을 정의하였고 실제 Team 엔티티에 대응하는 input 을 생성해주었다. (현재 seq 필드는 자동증가값이므로 사실 여기서 입력하는 것은 의미가 없다) 

실제 저장 버튼을 누르면 컨트롤러에 정의한 "/add" 가 호출되어야 하므로 아래와 같이 저장로직이 들어가는 add 메서드를 구현해준다.

@PostMapping("/add")
public String add(@ModelAttribute Team team, Model model) {
Team entity = repositoryService.addTeam(team);

model.addAttribute("result", entity);
return "result";
}

 여기에서는 서비스 레이어의 addTeam을 호출하므로 관련하여 interface를 작성하고, implements 를 작성해주도록 한다.

주요 로직은 해당 엔티티를 저장하는 것이므로 아래와 같이 간단하게 표현할 수 있다. 

@Override
public Team addTeam(Team team) {
return teamRepository.save(team);
}

실행 후 결과를 보여주는 result.html 파일을 작성해보자. 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Getting Started: Handling Form Submission</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1>Result</h1>
<p th:text="'seq: ' + ${result.seq}" />
<p th:text="'teamName: ' + ${result.teamName}" />
<a href="/form">팀 등록하기</a> |
<a th:href="@{|/addUser/${result.seq}|}">이 팀의 구성 원등록하기</a>


</body>
</html>

http://localhost:8080/form 에 접근하여 팀명을 입력 한 후 submit을 눌러보면 입력된 팀명이 제대로 결과 페이지에 출력되는  것을 확인 할 수 있을 것이다. 





이때 로그를 잘 살펴보면 하이버네이트의 insert 쿼리가 동작하는 것을 확인 할 수 있다. 

Hibernate: 
    insert 
    into
        team
        (team_name) 
    values
        (?)

DB에 접근하여 살펴보면 입력된 값이 제대로 저장 된 것을 확인 할 수 있을 것이다. 

이제 팀의 구성원을 등록하고, 팀 전체 목록을 조회하는 페이지를 작성해보도록 한다. 

result 페이지에서 구성원 등록을 클릭하면  /addUser/{seq} 형태로 팀 고유번호를 같이 넘겨받아 팀에 해당하는 정보를 기반으로 사용자 정보를 작성할 수 있어야 한다. 

@GetMapping("/addUser/{seq}")
public String addUser(@PathVariable int seq, Model model) {
Team team = repositoryService.findTeamBySeq(seq);
model.addAttribute("title", "add user");
model.addAttribute("team", team);
model.addAttribute("member", new Member());

return "addUser";
}

addUser.html 은 팀 정보 저장 페이지와 동일하되, 팀 번호를 hidden으로 물고 다니면서 해당 사용자가 현재 팀의 소속으로 저장된 다는 것을 알고 알려줘야 한다. 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Getting Started: Handling Form Submission</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1><p th:text="${team.teamName}" /> 에 구성원을 등록하세요. </h1>
<form action="#" th:action="@{/insert}" th:object="${member}" method="post">
<input type="hidden" th:value="${team.seq}" name="seq" />
<p>seq: <input type="text" th:field="*{seq}" /></p>
<p>name: <input type="text" th:field="*{name}" /></p>

<p><input type="submit" value="Submit" /> <input type="reset" value="Reset" /></p>
</form>
</body>
</html>

저장로직은 /insert 를 호출하는데 소스는 아래와 같다. 

@PostMapping("/insert")
public String insert(@ModelAttribute Member member, @RequestParam int seq, Model model) {
Member entity = repositoryService.addMember(member, seq);

model.addAttribute("result", entity);
return "user";
}


서비스 영역의 addMember  로직은 간단한 저장로직과 다른데, 이는 이 테이블 엔티티의 관계가 Team을 기준으로 연관관계에 있기 때문에 해당 Team 정보가 존재해야 저장이 되는 형태로 구현이 되기 때문에 아래와 같이 로직을 구현해주어야 한다. 

@Override
public Member addMember(Member member, int seq) {
Team team = teamRepository.findOne(seq);
Member saveEntity = new Member(team, member.getName());
return memberRepository.save(saveEntity);
}

먼저 팀 Seq 번호로 팀 정보를 조회하고, 해당 팀과 연관관계라는 것을 Member에 세팅 한 후 member 엔티티를 저장해야 한다. 만약 이 정보를 가지고 있지 않을 경우, 팀 연관정보를 찾지 못해 에러가 발생하게 된다. 

끝으로 저장된 유저 정보 결과를 보여주고 전체 팀 목록을 조회하는 페이지를 구현해주면 된다.

user.html 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Getting Started: Serving Web Content</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1>Result</h1>
<p th:text="'사용자 번호: ' + ${result.seq}" />
<p th:text="'이름: ' + ${result.name}" />
<a href="/list">팀 목록 조회</a> |
</body>
</html>

팀 목록을 조회하면 Controller 에서는 service 에 find를 요청할 것이고, 서비스는 team 엔티티의  리스트 정보를 teamRepository 호출로 리턴 될 것이다. 
DeptController.java 

@GetMapping("/list")
public String list(Model model) {
List<Team> teamList = repositoryService.findTeamAll();
model.addAttribute("teamList", teamList);

return "list";
}

RepositoryServiceImpl.java 

@Override
public List<Team> findTeamAll() {
return teamRepository.findAll();
}


최종적으로 list.html 에서는 리턴된 teamList 의 값을 출력하면 된다. 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Getting Started: Handling Form Submission</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1>Team List</h1>
<ul>
<li th:each="team : ${teamList}" th:text="${team.teamName}"></li>
</ul>

<a href="/form">팀 등록하기</a>

</body>
</html>

결과는 아래와 같다. 



이 전체 소스는 Github 의 chapter4 브랜치에 올라가 있으므로 전체 소스를 참고하도록 한다. (https://github.com/gliderwiki/SpringBootActuator)


CrudRepository 인터페이스 살펴보기 

실제 Spring-Data-JPA 의 CrudRepository 를 살펴보면 크게 아래와 같은 메서드들을 정의 해 놓을 것을 확인 할 수 있다. 1번부터 6번까지 메서드 이외에도 기본적으로 제공되는 다른 인터페이스 들도 있지만 일단 제일 중요한 부분이므로 한번 살펴보도록 하자. 


1: 주어진 엔티티를 저장한ㅇ다. 
2: ID로 주어진 엔티티를 조회한다. 해당 키의 Entity 타입을 리턴한다.
3: 전체 엔티티를 리턴한다.
4: 엔티티의 갯수를 리턴한다.
5: 주어진 엔티티를 삭제한다. 
6: 주어진 아이디가 존재하는지 리턴한다. 
이러한 인터페이스는 각 DB에 의존적이지 않게 기본적인 퍼시스턴스 기술의 기능을 제공하게 된다. 

개발자들이 흔히 궁금한 점은, 과연 저런 메서드가 제공 된다고 해서 원하는 수준의 쿼리를 뽑아 낼 수 있을 것인가일텐데, 이에 대해서는 아래의 쿼리 메서드를 살펴보면 어느정도 해답이 될거라고 본다.


기본적으로 알아야 할 쿼리 메서드 작성 


만약 이정도의 쿼리 메서드로 해결하기 어려운 경우에는 JPQL(Java Persistence Query Language)이라던가, QueryDSL(http://www.querydsl.com)과 같은 관련 기술들의 도입을 검토해서 활용하면 충분히 대용량 서비스를 제공할 수 있는 수준의 잇점이 있을것이라고 판단한다.


링크 : http://www.libqa.com/wiki/730