[Spring Boot] 스프링 DB 접근 기술
지난 블로그에 이어서 같은 예제로 외부 DB를 연결하고 컨테이너에 존재하던 메모리 구현체를 DB로 바꿔 꼈다. 그리고 JDBC(Java DataBase Connectivity : 자바에서 DB 프로그래밍을 하기 위해 사용하는 API)를 이용해서 리포지토리들을 구현해줬다. 오늘 수업에서는 순수 JDBC를 이용한 것으로, 약 20년 전에는 이런 식으로 코딩을 했다고 한다. 그래도 작동 원리 이해?를 위해서 공부해보고 넘어갔다.
1. H2 데이터 베이스 설치
H2는 개발이나 테스트 용도로 가볍고 편리하게 사용할 수 있는 DB로, 웹 화면을 제공한다.
먼저 아래 사이트에 가서 H2 데이터 베이스를 설치한다. 주의할 점은 1.4.200 버전을 설치해야 한다는 것이다. 최신 버전은 일부 기능이 정상적으로 작동하지 않는다고. 나는 Platform-Independent.zip 으로 다운 받았다.
https://www.h2database.com/html/download-archive.html
Archive Downloads
www.h2database.com
그리고 나서는 압축을 풀어주고, 터미널을 켜서 압축을 풀어준 위치로 이동한다.
$ cd bin
$ chmod 755 h2.sh (Mac 사용자만)
$ ./h2.sh (Window는 h2.bat)
이렇게 하고 나면 웹 브라우저에 h2 데이터베이스 콘솔이 뜰 것이다. 그러면 JDBC URL에 jdbc:h2:~/test 을 입력해준다. (최초 한 번만)
그리고 Connect를 누르고 나면, 뭔가 파일이 생성한 것처럼 들어가진다. 그럼 이제 새로운 터미널을 켜서 ~/test.mv.db 파일이 생성되었는지 확인한다. 나같은 경우는 메인 디렉토리(yeonjulee) 안에 있었다. 이렇게 DB 파일이 생성되었음을 확인하고 나면 앞으로 h2 콘솔에서는 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)
);
H2 데이터베이스 콘솔에서 위의 sql 문을 가지고 member 테이블을 생성해준다.
2. 순수 JDBC
먼저 build.gradle 파일에 jdbc, h2 데이터베이스 관련 라이브러리를 추가해야 한다.
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
}
위의 내용들을 build.gradle에 추가해준다.
그리고 스프링 부트에 새로운 DB 연결을 알려줘야 하므로 src/main/resources/application.properties 에 다음 처럼 추가해준다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
스프링부트 2.4부터는 spring.datasource.username=sa 를 꼭 추가해주어야 한다. 그렇지 않으면 Wrong user name or password 오류가 발생한다. 참고로 다음과 같이 마지막에 공백이 들어가면 같은 오류가 발생한다. spring.datasource.username=sa 공백 주의, 공백은 모두 제거해야 한다.
JDBC 리포지토리 구현
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); // RETURN_GENERATED_KEYS : 1번, 2번 id 값을 얻을 수 있엇음.
pstmt.setString(1, member.getName()); // parameter ?랑 여기서 매칭이 됨.
pstmt.executeUpdate(); //DB에 실제 쿼리가 날라가는 타임
rs = pstmt.getGeneratedKeys(); //28번째 줄에 RETURN_GENERATED_KEYS와 매칭되어서 할 수 있음. db instance에서 id를 꺼내줌.
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(); //sql 날리기
pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, id);
rs = pstmt.executeQuery(); // 조회는 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 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);
}
}
@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);
}
}
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);
}
}
각각의 코드가 어떤 의미인지는 주석에 포함되어 있다. 강의를 들으면서도 요즘은 이렇게 순수 JDBC로 짜지 않기 떄문에 자세히 알아둘 필요는 없다고 하셨다.
다음은 스프링 설정 변경 (메모리 구현체에서 실제 디비로) 을 해줘야 한다.
SpringConfig 파일을 다음과 같이 수정해준다.
package hello.hellospring;
import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.service.MemberService;
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 final DataSource dataSource;
public SpringConfig(DataSource dataSource){
this.dataSource = dataSource;
}
@Bean
public MemberService memberService(){
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository(){
return new JdbcMemberRepository(dataSource);
}
}
*DataSource는 데이터베이스 커넥션을 획득할 때 사용하는 객체이다. 스프링 부트는 데이터베이스 커넥션 정보를 바탕으로 DataSource를 생성하고 스프링 빈으로 만들어둔다. 그래서 DI를 받을 수 있다.
** 수정된 내용은 MemberRepository memebrRepository()에서 호출된 객체이다. return new MemoryMemberRepository 에서 JdbcMemberRepository(datasource)로 수정되었다! 정말 단순한 바꿔 끼기로 DB를 수정할 수 있다. 이게 객체 지향의 장점이라고 할 수 있다.
구현 클래스에 Jdbc MemberRepository가 추가되었고 손쉽게 확장이 가능해졌다. 객체 지향 설계에는 SOLID 원칙이 있다. 고려해야 할 다섯가지 원칙을 정의해둔 것인데, 위의 방식으로 프로그래밍을 하게 되면 개방-폐쇄 원칙(Open-Closed Principle)을 준수한 것으로 볼 수 있다. 즉, 확장에는 열려있고, 수정이나 변경에는 닫혀있는 구조이다.
※객체 지향 설계 원리 SOLID
의존관계 역전 원칙 - 위키백과, 우리 모두의 백과사전
객체 지향 프로그래밍에서 의존관계 역전 원칙은 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다. 이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인
ko.wikipedia.org
스프링의 DI(Dependency Injection)는 기존 코드를 전혀 손대지 않고 (MemberRepository 등과 같은 코드는 일절 수정하지 않았다) 설정만으로 구현 클래스를 변경할 수가 있다. 또 이제는 메모리 구현체에서 벗어났기 떄문에 서버를 종료하고 재시작해도 회원 정보가 모두 남아있다!
3. 스프링 통합 테스트
스프링 컨테이너와 DB까지 연결한 통합 테스트를 진행해볼 수 있다. 지금까지 우리가 진행한 MemberServiceTest는 단순히 JVM 내에서 자바 코드를 테스트 해본 것이다. 이제는 스프링부트가 잘 작동하는지, 그리고 연결한 데이터베이스가 잘 작동하는지 테스트 해봐야 한다. 다음과 같은 코드를 service 테스트 디렉토리에 MemberServiceIntegrationTest로 추가해주었다. 코드 자체는 MemberServiceTest와 거의 유사하다.
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
//MemberServiceTest는 그냥 JVM 상에서 자바로 테스트 한 거(단위 테스트 - 시간이 매우 짧게 걸리므로 효율적일 때가 많음)였다면 스프링부트로도 테스트(통합 테스트)를 해봐야 함.
//Spring container 없이 테스트 해보는 훈련(단위 테스트 훈련)을 해야 함. 그게 더 좋은 테스트일 확률이 높음.
@SpringBootTest
@Transactional //이 annotation을 해두면 테스트한 데이터가 디비에 반영되지 않음. afterEach 해서 매번 디비의 마지막 쿼리를 지워주는 것(롤백)과 같은 역할
class MemberServiceIntegrationTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
public void 회원가입() throws Exception {
//Given
Member member = new Member();
member.setName("spring");
//When
Long saveId = memberService.join(member);
//Then
Member findMember = memberRepository.findById(saveId).get();
assertEquals(member.getName(), findMember.getName());
}
@Test
public void 중복회원예외(){
//given
Member member1 = new Member();
member1.setName("Spring");
Member member2 = new Member();
member2.setName("Spring");
//when
memberService.join(member1);
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
}
* @SpringBootTest 애노테이션을 이용하면 테스트 시에도 스프링부트를 켜서 테스트해준다.
** @Transactional 애노테이션을 이용하면 테스트에서 실행시킨 쿼리를 롤백 해준다. 따라서 DB에 commit 하지 않게 되는 것이다. 단위 테스트에서 afterEach 메소드가 해주던 역할을 한다고 보면 된다. 만약 다른 메소드는 DB에 commit 하지 않길 바라지만 일부 테스트에서는 commit 하길 바란다면, @Commit 애노테이션도 활용할 수가 있다.
*** JVM에서 자바 코드를 테스트하는 것을 단위 테스트, 스프링부트를 켜서 테스트 해보는 것을 통합 테스트라고 한다. 단위 테스트를 잘 활용하면 효율적이면서도 유용하게 사용할 수 있다.