H2 데이터베이스 설치
실무에서는 MySQL, Oracle 많이 사용
H2 데이터베이스는 교육용,
h2 데이터베이스는 꼭 다음 링크에 들어가서 1.4.200 버전을 설치해주세요.
최근에 나온 2.0.206 버전을 설치하면 일부 기능이 정상 동작하지 않습니다.
https://www.h2database.com/html/download-archive.html
git bash에서 C:\Program Files (x86)\H2\bin
폴더에서 ./h2.bat으로 실행
데이터베이스 파일 생성 방법
H2 데이터베이스에 다음을 실행 (Ctrl + Enter)
drop table if exists member CASCADE;
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
generated by default as identity
: 값을 세팅하지 않고 추가하면 DB가 자동으로 id 값을 채워줌insert into member (name) values('spring')
SELECT * FROM MEMBER
프로젝트에서
sql
폴더 따로 만들고 폴더 내에ddl.sql
파일 안에 테이블 생성하는 sql문 저장하여 관리
순수 JDBC
데이터가 메모리에 저장되는게 아니라 DB에 insert, select 쿼리를 넣음.
build.gradle
파일에 jdbc, h2 데이터베이스 관련 라이브러리 추가implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
resources/application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
resources/application.properties
으로 세팅하면 스프링부트가 dataSource를 생성하고 주입할 수 있음build.gradle
에서 gradle 버튼 클릭해서 리프레쉬주의! 이렇게 JDBC API로 직접 코딩하는 것은 20년 전 이야기이다. 따라서 고대 개발자들이 이렇게 고생하고 살았구나 생각하고, 정신건강을 위해 참고만 하고 넘어가자.
src/main/java
의 repository
폴더 내에 JdbcMemberRepository 클래스를 생성하고 implements MemberRepository
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);
}
}
Save 함수
String sql = "insert into member(name) values(?)";
pstmt.setString(1, "")
하면 1이 sql의 ?랑 매칭pstmt.executeUpdate()
: DB에 실제 쿼리가 날라감rs = pstmt.getGeneratedKeys();
키(ID)가 1번이면 1, 2번이면 2를 반환rs = pstmt.getGeneratedKeys(); //ResultSet 타입으로 결과 받아옴
if (rs.next()) {
// 이부분에서 rs.getLong("컬럼명"); 이렇게하면 키값을 불러올 수 없다.
// 무조건 rs.getLong(1); 이렇게 해야한다.
member.setId(rs.getLong(1));
} ...
close
로 리소스 반환close 함수
findById 함수
String sql = "select * from member where id = ?";
rs = pstmt.executeQuery();
findAll 함수
String sql = "select * from member";
getConnection 함수 (참고)
return DataSourceUtils.getConnection(dataSource);
DataSourceUtils.releaseConnection(conn, dataSource);
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 DataSource dataSource;
@Autowired
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
//return new MemoryMemberRepository();
return new JdbcMemberRepository(dataSource);
}
}
14분~15분
DataSource
는 데이터베이스 커넥션을 획득할 때 사용하는 객체다.- 스프링 부트는 데이터베이스 커넥션 정보(
resources/application.properties
)를 바탕으로 자체적으로 DataSource를 생성하고 스프링 빈으로 만들어둔다. 그리고@Autowired
로 SpringConfig에서 주입(DI)
- 스프링이 제공하는
configuration
만 손댐- 다형성 활용: 구현체만 바꿔끼움
- 원래는 멤버서비스에서 수정하고 해야하는데
configuration
만 수정하면 됨
스프링 통합 테스트
이전에 했던 Test는 순수한 자바 코드로만 테스트한 것임
MemberServiceTest
복사해서 MemberServiceIntegrationTest
생성@SpringBootTest @Transactional
을 클래스 위에 추가@BeforeEach
부분 삭제@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
@AfterEach
부분 삭제회원가입 테스트 돌리면 DB에 데이터가 남아있어서 오류 발생
delete from member
스프링 띄우면서 test
@Transactional
사용 이유
@Transactional
을 사용하지 않고 또 테스트하면 에러가 남, H2 디비에 테스트에서 한게 저장이 되어있음@Transactional
을 사용하면 테스트를 실행할 때 트랜잭션을 먼저 실행하고 쿼리한 다음에 테스트가 끝나면 롤백함package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
@Test
void 회원가입() {
//given
Member member = new Member();
member.setName("spring");
//when
Long saveId = memberService.join(member);
//then
Member findMember = memberService.findOne(saveId).get();
assertThat(member.getName()).isEqualTo(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));
//이 로직을 넣으면 이 예외가 터져야 함
//then
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
}
@SpringBootTest
: 스프링 컨테이너와 테스트를 함께 실행한다.@Transactional
: 테스트 케이스에 이 애노테이션이 있으면, 테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백한다. 이렇게 하면 DB에 데이터가 남지 않으므로 다음 테스트에 영향을 주지 않는다. 테스트 메소드마다 일일히 동작한다.단위 테스트: 순수하게 자바 코드로만 하면서 최소한으로 테스트 (훨씬 good), 스프링 개입없이 도메인 로직을 테스트한다.
통합 테스트: 스프링 컨테이너와 DB와 연동
통합테스트는 실제 스프링을 띄워서 실행되기 때문에 실제 빈이 주입된다.
실제 빈 주입이 아닌 가짜객체로도 테스트가 가능하다.
SpringConfig에서 @Bean으로 수동 등록한 Repository가 있다면 테스트에서 주입 받는다.
스프링 JdbcTemplate
build.gradle
는 순수 Jdbc와 동일JdbcTemplateMemberRepository
생성
jdbcTemplate
은 dataSource
필요
스프링이 자동으로 dataSource
인젝션 해줌
private final JdbcTemplate jdbcTemplate;
//@Autowired // 생성자가 1개라서 생략가능
public JdbcTemplateMemberRepository(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
findById 함수
@Override
public Optional<Member> findById(Long id) {
List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
return result.stream().findAny();
}
memberRowMapper 함수
private RowMapper<Member> memberRowMapper() {
// return new RowMapper<Member>() {
// @Override
// public Member mapRow(ResultSet rs, int rowNum) throws SQLException {
// Member member = new Member();
// member.setId(rs.getLong("id"));
// member.setName(rs.getString("name"));
// return member;
// }
// };
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
}
RowMapper
에서 Alt + Enter하여 람다식으로 변환jdbcTemplate.query()
에서 쿼리 날리고, 그 결과를 memberRowMapper
를 통해서 매핑을 하고 리스트로 반환save 함수
SimpleJdbcInsert
사용executeAndReturnKey
로 키를 받아서 member.setIdspringconfig
파일도 수정
@Bean
public MemberRepository memberRepository() {
//return new MemoryMemberRepository();
//return new JdbcMemberRepository(dataSource);
return new JdbcTemplateMemberRepository(dataSource);
}
SpringConfig
에서 dataSource
를 받아서 JdbcTemplateMemberRepository
에 직접 주입하고 있습니다. 이렇게 직접 주입해도 되고, 직접 주입 받지 않고 @Autowired
로 주입 받아도 됩니다. 여러가지 방법이 가능합니다.여기서
JdbcTempalteMemberRepository
를@Bean
을 사용해서 수동으로 스프링 빈으로 등록하는데, dataSource가 생성자의 필수 파라미터 이기 때문에 SpringConfig에서는 dataSource를 꼭 받아와서 넣어주어야 합니다.
MemberServiceIntegrationTest
로 테스트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;
//@Autowired
public JdbcTemplateMemberRepository(DataSource dataSource) {
this.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 new RowMapper<Member>() {
// @Override
// public Member mapRow(ResultSet rs, int rowNum) throws SQLException {
// Member member = new Member();
// member.setId(rs.getLong("id"));
// member.setName(rs.getString("name"));
// return member;
// }
// };
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
}
테스트 코드 작성 노하우
- 가장 쉬운 유틸리티 클래스처럼 의존관계가 없는 부분부터 시작하는게 좋다.
- 그리고 스프링 컨테이너에서 실행하는 통합 테스트 보다는 단위 테스트(대신 실무에서는 mockito를 잘 활용해야 겠지요?)부터 시작한다.
- 그리고 테스트를 모든 곳에 하는 것 보다는 핵심 비즈니스 로직에 단위 테스트로 먼저 접근하는 것이 가장 효율이 높다.
- 특히 단순 조회 로직보다는, 실제 비즈니스 로직으로 데이터가 저장되고, 변경되는 쪽이 더 좋다.