지금까지는 메모리를 이용하는 임시DB를 만들어서 회원들을 저장해왔지만 이 방식은 서버가 종료되면 데이터도 같이 날라가는 치명적인 단점이 존재했습니다.
이를 극복하기 위해 H2 데이터베이스를 활용해, 서버가 종료되어도 데이터를 저장할 수 있도록 설계를 바꿔보겠습니다.
우선 아래의 H2 데이터베이스 사이트에 접속해서 1.4.200 버전을 다운로드합니다.
다운로드가 완료되면 첫번째로 h2/bin
디렉토리에 들어가서 h2.sh
파일의 권한을 변경해주는 작업이 필요합니다. (원도우 사용자는 필요 X)
아래와 같이 chmod 755 h2.sh
명령어를 통해 권한을 변경해줍니다.
⚠️ chmod 755
소유자는 모든 권한 (읽기, 쓰기, 실행) 을 가지고 그룹 및 그 외 사용자는 읽기와 실행만 가능하도록 설정해주는 리눅스 명령어입니다.
권한을 변경해준 이후에 ./h2.sh
명령어로 파일을 실행시키면 아래와 같이 H2 데이터베이스 콘솔에 접속할 수 있는 사이트가 켜집니다. (원도우 사용자는 ./h2.bat
)
맨 처음 접속할 때는 JDBC URL을 jdbc:h2:~/test
로 설정하고 연결을 클릭해 자신의 홈 디렉토리에 /test.mv.db
파일을 만들어주고, 그 다음부터는 JDBC URL을 jdbc:h2:tcp://localhost/~/test
로 설정해 접속해주시면 됩니다.
회원 데이터를 저장할 회원 테이블을 생성하기 위해 아래의 SQL문을 입력하고 Ctrl + 엔터
를 눌러 테이블을 생성해줍니다.
drop table if exists member CASCADE;
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
insert into member(name) values('Spring')
SQL문을 입력하고 SELECT * FROM MEMBER
로 확인해보면 아래와 같이 'Spring'
데이터가 입력된 것을 확인할 수 있습니다.
Jdbc는 자바에서 데이터베이스에 접속할 수 있도록 하는 자바 API입니다. 순수 Jdbc만 이용해서 데이터베이스와 연결시키는 방식은 20년전 고대(?)의 방식이지만 '20년전에는 이렇게 구성을 했구나' 하고 알아보는 차원에서 순수 Jdbc만을 이용해 데이터베이스와 연동을 시켜보겠습니다.
Jdbc 라이브러리를 프로젝트에 추가 시켜주기 위해 아래의 코드를 build.gradle
파일에 입력해줍니다.
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
Jdbc를 이용한 회원 리포지토리를 구현하기 위해 /hello.hellospring/repository
하위에 JdbcMemberRepository
를 생성하고 아래의 코드를 작성해줍니다.
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);
}
}
마지막으로 SpringConfig
파일을 아래와 같이 작성해 데이터베이스 연결을 기존의 MemoryMemberRepository
에서 JdbcMemberRepository
로 변경하면 설정이 마무리됩니다.
@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 MemoryMemberRepository();
return new JdbcMemberRepository(dataSource);
}
MemoryMemberRepository
와 연결된 부분을 주석처리하고 JdbcMemberRepository(dataSource)
를 넣어줍니다.DataSource
는 데이터베이스 커넥션을 획득할 때 사용하는 객체입니다. 스프링부트는 데이터베이스 커넥션 정보를 바탕으로 DataSource
를 생성하고 스프링 빈을 만들어둠으로써 DI, 즉 의존성을 주입받을 수 있습니다.이렇게 스프링의 의존성 주입을 사용하면 기존 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경할 수 있으며 프로젝트를 실행해 확인해보면 위에서 H2 데이터베이스에 저장해 놓았던 'Spring'
을 확인할 수 있습니다.
실제 프로젝트를 실행시켜봄으로써 H2 데이터베이스와 프로젝트가 연결된 것을 확인하였지만 테스트 코드로도 이를 확인해보기 위해 /test/java/hello.spring/service
하위에 MemberServiceIntegrationTest
를 생성하고 아래와 같이 테스트 코드를 작성해보겠습니다.
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest2 {
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
@Test
void 회원가입() {
//given
Member member = new Member();
member.setName("hello");
//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));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
}
@SpringBootTest
어노테이션을 작성해 스프링 컨테이너와 테스트를 함께 실행하도록 합니다.@Transactional
어노테이션을 작성해줍니다. @Autowired
어노테이션으로 memberService
와 memberRepository
가 필요한 의존 객체의 타입에 해당하는 빈을 스프링 IoC 컨테이너가 찾아서 주입해주도록 합니다.⚠️ 트랜잭션이란?
- 모든 작업들이 성공적으로 완료되어야 작업 묶음의 결과를 적용하고, 어떤 작업에서 오류가 발생했을 때는 이전에 있던 모든 작업들이 성공적이었더라도 없었던 일처럼 완전히 되돌리는 것이 트랜잭션의 개념입니다.
- 데이터베이스를 다룰 때 트랜잭션을 적용하면 데이터 추가, 갱신, 삭제 등으로 이루어진 작업을 처리하던 중 오류가 발생했을 때 모든 작업들을 원상태로 되돌릴 수 있습니다.
-출처: Tecoble
작성된 테스트 코드를 실행시키면 스프링이 띄워지면서 함께 테스트를 진행하게 됩니다.
아래와 같이 테스트가 정상적으로 통과된 모습을 확인할 수 있습니다.
🙏 이 포스트는 김영한 개발자님의 <스프링 입문 강의> 를 듣고 공부한 내용을 바탕으로 작성되었습니다.
nICE👌