- 김영한 강사님이 제공하시는 인프런 - "스프링 입문 - 코드로 배우는 스프링 부트, 웹, MVC, DB 접근 기술" 강의를 듣고 정리한 내용입니다.
- 강의 링크
스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술
H2 데이터베이스란?
설치
H2 데이터베이스 설치 사이트 이 사이트에 들어가서 1.4.200 버전 Window installer을 설치한다.
H2 데이터베이스의 위치를 설정하고 다운로드를 마무리해 준다.
H2 데이터베이스 실행 시키는 방법 2가지
1). 실행파일 선택
H2를 설치했던 위치로 가 H2/bin/h2.hat 파일을 실행한다.
2). cmd로 실행
cmd 창을 열고 H2를 설치했던 위치로 접근 한 후, H2 폴더 안의 bin 폴더로 이동한다. h2.bat 파일을 실행시킨다.
h2.bat 파일을 실행하면
이런 화면이 뜬다. H2 데이터베이스 경로와 사용자명 등을 입력해주고 연결 버튼을 누른다. 그러면 H2 데이터베이스여 접속 된다.
다른 데이터베이스와 다르게 H2 데이터베이스는 파일에 직접 접근하는 방식(jsbc:h2:~/파일이름)을 제공한다.
하지만 파일에 직접 접근하는 방식을 사용하면 동시성 문제가 발생할 수 있으므로 H2 데이터베이스 사용 시 처음 DB 파일 생성할 때만 파일에 직접 접근하고, 이후 DB 파일 접슨 시에는 DB 서버를 통하도록 한다.
따라서 데이터베이스 파일 처음 생성 시 JDBC URL을 최초 한번은 jdbc:h2:~/test로 해야 하고 이후부터는 jdbc:h2:tcp://localhost/~/test 이렇게 접속해야 한다.
H2 데이터베이스에 접근해서 member 테이블 생성한다.
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
select 명령어로 만든 member 테이블을 조회 가능!
SELECT * FROM MEMBER
insert 명령어로 member 테이블에 값을 추가
insert into member(name) values('spring1')
값을 추가한 후 다시 select 명령어로 member 테이블을 조회하면 정상적으로 데이터가 추가된 것을 확인할 수 있다.
spring스터디/hello-spring/sql/ddl.sql 파일을 새로 만들어 sql을 관리한다.
이번에는 memory가 아닌 DB에 연동해서 직접 데이터를 추가하고, 저장하고, 빼는 것을 할 것이다.
build.gradle 파일에 jdbc, h2 데이터베이스 관련 라이브러리 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
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'
}
}
스프링 부트 데이터베이스 연결 설정 추가
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
코드를 추가한다.
⚠️이렇게 JDBC API로 직접 코딩하는 것은 20년 전 이야기이다! 참고만 하고 넘어가자!
Jdbc 회원 리포지토리
java/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);
}
}
build.gradle 파일에 jdbc 관련 라이브러리를 추가했음에도
import org.springframework.jdbc.datasource.DataSourceUtils;
에서 에러가 계속 발생했는데 gradle refresh를 하고 Project 재시작을 통해 해결했다.
package hello.hellospring;
import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.JdbcTemplateMemberRepository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.service.MemberService;
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 MemoryMemberRepository();
return new JdbcMemberRepository(dataSource);
}
}
이렇게 코드를 수정해주고 실행한 다음 http://localhost:8080/members 회원 목록 페이지에 들어가면 아까 sql에 저장했던 회원의 목록을 볼 수 있다.
추가적으로 회원 가입 창에서 새롭게 회원을 등록하고
H2 데이터베이스에서 확인을 해보면 정상적으로 추가가 된것도 확인할 수 있다.
🤔스프링을 왜 사용하는가?
⇒다형성을 활용할 수 있기 때문에!
스프링 컨테이너에서 설정을 바꿔, 기존 MemoryMemberRepository을 spring bean에서 빼고 Jdbc MemberRepository를 새로운 spring bean으로 등록
스프링 컨테이너와 DB까지 연결한 통합 테스트를 진행해 볼 것이다.
java/hello/hellospring/service/MemberServiceIntegrationTest.java 파일을 만든다.
기존에 만들었던 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.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
@Test
public void join() throws Exception {
//Given
Member member = new Member();
member.setName("hello");
//When
Long saveId = memberService.join(member);
//Then
Member findMember = memberRepository.findById(saveId).get();
assertEquals(member.getName(), findMember.getName());
}
@Test
public void 중복_회원_예외() throws Exception {
//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에 데이터가 남지 않으므로 다음 테스트에 영향을 주지 않는다.단위 테스트 | 통합 테스트 |
---|---|
코드의 가장 작은 단위(예: 메서드, 함수)에서 독립적으로 실행 | DB까지 연동해서 여러 코드 단위가 함께 작동하는 것을 테스트 |
순수한 단위 테스트가 훨씬 좋은 테스트일 확률이 높다
JdbcTemplate을 사용한 회원 리포지토리 java/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.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){
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;
};
}
}
작성한 JdbcTemplate을 사용할 수 있도록 스프링 설정을 변경해준다.
java/hello/hellospring/SpringConfig.java 파일을
@Bean
public MemberRepository memberRepository() {
//return new MemoryMemberRepository();
//return new JdbcMemberRepository(dataSource);
return new JdbcTemplateMemberRepository(dataSource);
}
이렇게 고쳐준다.
코드를 작성한 후 아까 만들었던 통합 테스트 코드를 그대로 가져와서 실행하면
정상적으로 작동하는 것을 확인할 수 있다.
JPA란?
⇒ 자바 기반 애플리케이션에서 데이터베이스와의 상호작용을 쉽게 하고, 데이터 영속성(Persistence)을 관리할 수 있도록 하는 표준 API
ORM(Object-Relational Mapping)
⇒ 자바 객체와 관계형 데이터베이스의 테이블을 매핑
// 프로젝트의 의존성을 선언
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
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'
}
}
spring-boot-starter-data-jpa 는 내부에 jdbc 관련 라이브러리를 포함해서 jdbc는 제거해도 된다.
resources/application.properties에
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
내용을 추가해줘야 한다.
java/hello/hellospring/domain/Member.java 파일에 JPA 엔티티 매핑 내용을 추가해준다.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
...
}
엔터티(Entity)란?
java/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) {
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() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
}
EntityManager
: 엔티티 매니저는 JPA의 핵심 인터페이스로, 엔터티의 영속성 상태를 관리하고, 내부적으로 데이터베이스와의 상호작용을 담당
JPQL(Java Persistence Query Language)
java/hello/hellospring/service/MemberService.java 파일에
import org.springframework.transaction.annotation.Transactional
@Transactional
public class MemberService {}
트랜잭션을 추가한다.
java/hello/hellospring/SpringConfig.java 파일을
@Configuration
public class SpringConfig {
private final DataSource dataSource;
private final EntityManager em;
@Autowired
public SpringConfig(DataSource dataSource, EntityManager em) {
this.dataSource = dataSource;
this.em = em;
}
...
@Bean
public MemberRepository memberRepository() {
//return new MemoryMemberRepository();
//return new JdbcMemberRepository(dataSource);
//return new JdbcTemplateMemberRepository(dataSource);
return new JpaMemberRepository(em);
}
JPA를 사용할 수 있게 수정해준다.
정상적으로 동작하는지 확인하기 위해 테스트를 돌려 확인한다.
정상적으로 동작되는 것을 볼 수 있다.
스프링 부트와 JPA에 스프링 데이터 JPA까지 사용하면, 리포지토리에 구현 클래스 없이 인터페이스 만으로 개발을 완료할 수 있다. 기본 CRUD 기능도 제공해줘 개발 코드들이 줄어든다.
앞의 JPA 설정을 그대로 사용한다.
java/hello/hellospring/repository/SpringDataJpaMemberRepository.java 파일을 인터페이스로 만든다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository{
Optional<Member> findByName(String name);
}
스프링 데이터 JPA가 JpaRepository를 받고 있으면 구현체를 자동으로 만들어서 스프링 빈에 자동으로 등록한다. 우리는 자동으로 만들어진 걸 가져와서 사용하면 된다.
SpringConfig.java 파일을 수정해준다.
@Configuration
public class SpringConfig {
private final MemberRepository memberRepository;
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository);
}
}
스프링 데이터 JPA가 SpringDataJpaMemberRepository 를 스프링 빈으로 자동 등록해준다.
정상적으로 동작하는지 확인하기 위해 또 테스트를 돌려 확인한다.
정상적으로 동작되는 것을 볼 수 있다.