스프링 입문 (7) - DB 접근 기술
H2 데이터베이스 설치 : https://www.h2database.com/html/main.html
설치가 완료되면 웹콘솔이 뜨는데, 처음엔 연결을 누르고 C:\Users\%user%에 test.mv.db 파일이 만들어진 것을 확인해야 한다.
확인이되었으면 이후엔 콘솔 주소를 localhost으로 바꾸고 소켓을 통해서 접근(파일 충돌 방지)해야한다. :
H2 콘솔에서 JDBC URL을 다음과 같이 변경해주면 된다
jdbc:h2:tcp://localhost/~/test
테이블 생성 (테이블 관리를 위해 프로젝트/sql/ddl.sql 파일을 만들어준다)
drop table if exists member CASCADE;
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
실행버튼을 눌러 테이블을 생성한다.
조회도 해본다. 명령창에 아무것도 없는 상태에서 왼쪽의 MEMBER테이블을 클릭하면 자동으로 조회 명령어가 채워진다.
순수 JDBC
build.gradle 파일에 jdbc, h2 DB 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
스프링부트 DB 연결 설정 추가
resources/application.properties
spring.application.name=hello-spring
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
hello/hellospring/repository/JdbcMemberRepository.java 추가 (옛날에 하던 방식을 체험해보기 위함)
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.jdbc.datasource.DataSourceUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class JdbcMemberRepository implements MemberRepository {
private final DataSource dataSource;
public JdbcMemberRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Member save(Member member) {
String sql = "insert into member(name) values(?)";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
pstmt.setString(1, member.getName());
pstmt.executeUpdate();
rs = pstmt.getGeneratedKeys();
if (rs.next()) {
member.setId(rs.getLong(1));
} else {
throw new SQLException("id 조회 실패");
}
return member;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public Optional<Member> findById(Long id) {
String sql = "select * from member where id = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, id);
rs = pstmt.executeQuery();
if(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return Optional.of(member);
} else {
return Optional.empty();
}
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public List<Member> findAll() {
String sql = "select * from member";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
rs = pstmt.executeQuery();
List<Member> members = new ArrayList<>();
while(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
members.add(member);
}
return members;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public Optional<Member> findByName(String name) {
String sql = "select * from member where name = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, name);
rs = pstmt.executeQuery();
if(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return Optional.of(member);
}
return Optional.empty();
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
private Connection getConnection() {
return DataSourceUtils.getConnection(dataSource);
}
private void close(Connection conn, PreparedStatement pstmt, ResultSet rs) {
try {
if (rs != null) {
rs.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
try {
if (pstmt != null) {
pstmt.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
try {
if (conn != null) {
close(conn);
}
} catch (SQLException e) {
e.printStackTrace();
}
}
private void close(Connection conn) throws SQLException {
DataSourceUtils.releaseConnection(conn, dataSource);
}
}
service/SpringConfig 설정 변경
@Bean
public MemberRepository memberRepository() {
// return new MemoryMemberRepository();
// 코드 변경 없이 Config만 변경하니 JDBC로 변경되었음
return new JdbcMemberRepository(dataSource);
}
dataSource를 스프링 빈에서 받아오도록 추가(public class SpringConfig 밑에 넣으면됨)
private DataSource dataSource;
@Autowired
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
DataSource는 데이터베이스 커넥션을 획득할 때 사용하는 객체다. 스프링 부트는 데이터베이스 커넥션 정
보를 바탕으로 DataSource를 생성하고 스프링 빈으로 만들어둔다. 그래서 DI를 받을 수 있는 것.
이제 서버를 다시 실행하여 잘 동작되는지 테스트해보자.
http://localhost:8080/ 에 접속하여 회원가입을 해보고, 회원 목록으로 가서 잘 동작하는지 확인한다.
잘 동작한다. 이제 서버를 재시작하여도 DB에 저장이 되고 불러와지는지 테스트해보자.
서버를 재시작했는데도 남아있다! DB에 잘 저장된 것이다.
H2콘솔에서도 잘 저장된 것을 확인할 수 있다.
의존관계를 표현하자면 다음과 같다.
* 개방-폐쇄 원칙(OCP, Open-Closed Principle)
- 확장에는 열려있고, 수정, 변경에는 닫혀있다.
* 스프링의 DI(Dependencies Injection)을 사용하면 기존 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경할 수 있다.
스프링 통합 테스트
hello/hellospring/service/MemberServiceTest.java를 복사하여 MemberServiceInjectionTest로 붙여넣는다.
1.
class명 위에 @SpringBootTest, @Transactional을 붙여준다.
2.
기존에는 새로운 객체를 생성해주었지만, 이제는 스프링빈에서 가져오도록 한다.
MemberService memberService;
▼
@Autowired MemberService meberService;
MemoryMemberRepository memberRepository;
▼
@Autowired MemberRepository memberRepository;
3.
또한 AfterEach와 BeforeEach또한 이제 DB에서 가져오기때문에 필요없으므로 코드를 지운다.
4.
이제 테스트를 실행해보자.
만약 오류가 뜬다면 테스트코드에서 만드는 회원의 이름이 이미 DB에 있기때문에 그런것이므로, H2콘솔에서 delete from member를 실행해서 DB를 지우고 다시 실행하자.
테스트결과 트리를 확인해보면 스프링까지 실행되어 테스트가 진행되는것을 확인할 수 있다. 어떤 항목이냐에 따라 필터가 되어 해당실행된 것에 관한 로그만 확인할 수 있다.
만약 @Transactional을 지우고 실행한다면, 회원가입 테스트 후 DB에 테스트한 멤버가 남아있게 된다. 그러면 AfterEach를 하고 또 어쩌고해야하니.... 그걸 트랜잭션 애노테이션이 테스트를 편리하게 하도록 변경해준것이다. (롤백을 해줌)
이제 테스트를 무한히 할 수 있다!
스프링 JdbcTemplate
스프링 JdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서 본 반복 코드를 대부분 제거해준다. 하지만 SQL은 직접 작성해야 한다.
JdbcTemplate가 이런식으로 작동한다는 정도만 알고 넘어가면 되니, 전체를 무리하게 이해하려고 하지 않아도 된다.
hello/hellospring/repository/JdbcTemplateMemberRepository.java
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class JdbcTemplateMemberRepository implements MemberRepository {
private final JdbcTemplate jdbcTemplate;
// 인젝션을 받아옴
// 생성자가 1개이면 @Autowired 생략 가능
public JdbcTemplateMemberRepository(DataSource dataSource) {
jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public Member save(Member member) {
SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
jdbcInsert.withTableName("member").usingGeneratedKeyColumns("id");
Map<String, Object> parameters = new HashMap<>();
parameters.put("name", member.getName());
Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
member.setId(key.longValue());
return member;
}
@Override
public Optional<Member> findById(Long id) {
List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
return result.stream().findAny();
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper(), name);
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
return jdbcTemplate.query("select * from member", memberRowMapper());
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
}
SpringConfig에서 JdbcTemplate를 받도록 설정을 변경한다
@Bean
public MemberRepository memberRepository() {
// return new MemoryMemberRepository();
// 코드 변경 없이 Config만 변경하니 JDBC로 변경되었음
// return new JdbcMemberRepository(dataSource);
return new JdbcTemplateMemberRepository(dataSource);
}
JPA
- JPA는 기존의 반복 코드는 물론이고, 기본적인 SQL도 직접 만들어서 실행해준다.
- JPA를 사용하면 SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임을 전환할 수 있다.
- JPA를 사용하면 개발 생산성을 크게 높일 수 있다.
- 하나의 인터페이스이며, 객체와 ORM(Object, Relational, Mapping)으로 이루어져 있다.
build.gradle에 JPA, h2 DB 라이브러리 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-devtools'
// jpa가 jdbc를 포함하므로 지워도 됨
// implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
}
resources/application.properties에 설정 추가
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
- show-sql : JPA가 생성하는 SQL을 출력한다.
- ddl-auto : JPA는 테이블을 자동으로 생성하는 기능을 제공하는데, none를 사용하면 해당 기능을 끈다.
- create를 사용하면 엔티티 정보를 바탕으로 테이블을 직접 생성해준다.
- 일단 지금은 테이블이 이미 생성되었으므로 기능을 끈다.
JPA 엔티티 매핑
package hello.hellospring.domain;
import jakarta.persistence.*;
// JPA가 관리하는 엔티티다!
@Entity
public class Member {
// @Id : PK (Primary Key, DB 용어입니다)
// IDENTITY = DB가 알아서 순서대로 ID를 생성해주도록 한다.
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
hello/hellospring/repository/JpaMemberRepository.java
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.Optional;
public class JpaMemberRepository implements MemberRepository{
private final EntityManager em;
public JpaMemberRepository(EntityManager em) {
this.em = em;
}
@Override
public Member save(Member member) {
// insert query를 만들어 자동으로 처리해줌
em.persist(member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id);
return Optional.ofNullable(member);
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = em.createQuery("select m from Member m where m.name = :name", Member.class)
.setParameter("name", name)
.getResultList();
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
// 객체를 대상으로 쿼리를 날리면 SQL로 번역됨
// 객체 자체를 select
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
}
서비스 계층에 트랜잭션 추가
MemberService.java 클래스명 위에 @Transactional을 추가한다.
- 스프링은 해당 클래스의 메서드를 실행할 때 트랜잭션을 시작하고, 메서드가 정상 종료되면 트랜잭션을 커밋한다. 만약 런타임 예외가 발생하면 롤백한다.
- JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행해야 한다.
SpringConfig 변경
dataSource대신 EntityManager를 이용하여 JPA를 사용하도록 함.
package hello.hellospring.service;
import hello.hellospring.repository.*;
import jakarta.persistence.EntityManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {
private DataSource dataSource;
private EntityManager em;
public SpringConfig(EntityManager em) {
this.em = em;
}
/*
@Autowired
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
*/
// MemberController은 따로 등록 못해줘서 Autowired해줘야함
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
// return new MemoryMemberRepository();
// 코드 변경 없이 Config만 변경하니 JDBC로 변경되었음
// return new JdbcMemberRepository(dataSource);
// return new JdbcTemplateMemberRepository(dataSource);
return new JpaMemberRepository(em);
}
}
통합테스트를 다시 실행해보면, 정상적으로 모두 통과되는 것을 알 수 있다.
로그를 보면 SQL문이 생성되어 실행되는 것을 확인할 수 있다.
스프링 데이터 JPA
스프링 부트와 JPA만 사용해도 개발 생산성이 많이 증가되고, 개발해야 할 코드도 줄어든다. 여기에 스프링 데이터 JPA를 사용하면 리포지토리에 구현 클래스 없이 인터페이스 만으로 개발을 완료할 수 있다. 기본 CRUD 기능 또한 제공한다.
반복 코드가 줄어듬으로 인해서 개발자는 핵심 비즈니스 로직을 개발하는데에 집중할 수 있으며, RDB 사용시 스프링 데이터 JPA는 필수라고 볼 수 있다.
hello/hellospring/repository/SpringDataJpaRepository.java
인터페이스를 만들어주자.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SpringDataJpaRepository extends JpaRepository<Member, Long>, MemberRepository{
// JPQL select m from Member m where m.name = ?
// 위 명령어를 SQL로 실행한다.
// 메소드만으로도 구현이 끝난것!
@Override
Optional<Member> findByName(String name);
}
인터페이스이므로 implements가 아닌 extends로 인터페이스를 상속받으며, JpaRepository를 상속받아 Member객체에 대한 PK type은 Long... 그리고 다중상속을오 MemberRepository까지 해주면 스프링 데이터 JPA가 알아서 SpringDataJpaMemberRepository을 스프링 빈에 등록해준다.
메소드를 전혀 구현할 필요 없이, 이름과 파라미터만 적어주면 구현이 끝나 SQL로 실행되게 된다.
메소드이름을 findByNameAndId 이런식으로 파라미터와 함께 바꾸면 또 자동으로 적용되기도 한다.
SpringConfig
package hello.hellospring;
import hello.hellospring.repository.*;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SpringConfig {
private final MemberRepository memberRepository;
@Autowired
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository);
}
}
Config를 통해 스프링 데이터 JPA가 자동으로 SpringDataJpaMemberRepository를 보고 구현체를 만들어 스프링 빈에 등록한다.
이는 스프링 데이터 JPA에서 기본적인 메소드들을 제공해주는 덕분에 가능한 것이다.
다음의 관계도를 살펴보자.
- 인터페이스를 통한 기본적인 CRUD
- findByName(), findByEmail() 처럼 메소드 이름만으로 조회 기능 제공
- 페이징 기능 자동 제공
- 참고 : 실무에서는 JPA와 스프링 데이터 JPA를 기본으로 쓰며, 복잡한 동적 쿼리는 Querydsl 라이브러리를 사용해 쿼리도 자바 코드로 안전하게 작성할 수 있고, 동적 쿼리도 편리하게 작성할 수 있다. 그래도 어렵다면 JPA가 제공하는 네이티브 쿼리를 사용하거나 JdbcTemplate를 사용하면 된다.
사진, 코드 출처 - https://inf.run/hivx6