스프링 입문 - Ch 6. 스프링 DB 접근 기술(1)

seren-dev·2022년 3월 29일
0

스프링 입문

목록 보기
9/11
  • 심플한 H2 DB 설치
  • JDBC: DB와 애플리케이션 서버 연결
  • JdbcTemplate: 애플리케이션에서 DB로 SQL을 편리하게 날릴 수 있음
  • JPA: SQL을 아예 JPA라는 기술로 DB에 등록, 수정, 삭제
    객체를 바로 DB에 쿼리 없이 저장
  • 스프링 데이터 JPA: JPA를 편리하게 사용할 수 있도록 하는 기술

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으로 실행

데이터베이스 파일 생성 방법

  • jdbc:h2:~/test (최초 한번)
  • ~/test.mv.db 파일 생성 확인
    이후부터는 jdbc:h2:tcp://localhost/~/test 이렇게 접속

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)
);
  • Long이지만 bigint
  • 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'
  • 자바는 기본적으로 DB를 연결하려면 jdbc 드라이버가 있어야 함
  • 데이터베이스가 제공하는 클라이언트: 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 리포지토리 구현

주의! 이렇게 JDBC API로 직접 코딩하는 것은 20년 전 이야기이다. 따라서 고대 개발자들이 이렇게 고생하고 살았구나 생각하고, 정신건강을 위해 참고만 하고 넘어가자.

src/main/javarepository폴더 내에 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(?)";
  • getConnection 을 통해 데이터베이스 커넥션을 얻음
  • ResultSet은 결과를 받음
  • 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 함수

  • 역순으로 리소스 반환, exception등 복잡

findById 함수

  • String sql = "select * from member where id = ?";
  • rs = pstmt.executeQuery();

findAll 함수

  • String sql = "select * from member";

getConnection 함수 (참고)

  • return DataSourceUtils.getConnection(dataSource);
  • 데이터베이스 커넥션을 똑같은 걸로 유지해 줌
  • close 할 때도 DataSourceUtils.releaseConnection(conn, dataSource);

스프링 설정 변경(StringConfig 수정)

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분

  • JdbcMemberRepository는 dataSource 필요
  • 스프링이 제공하는 dataSource
  • configuration 한 것도 스프링 빈으로 관리함
  • DataSource는 데이터베이스 커넥션을 획득할 때 사용하는 객체다.
  • 스프링 부트는 데이터베이스 커넥션 정보(resources/application.properties)를 바탕으로 자체적으로 DataSource를 생성하고 스프링 빈으로 만들어둔다. 그리고 @Autowired로 SpringConfig에서 주입(DI)
  • 스프링이 제공하는 configuration만 손댐
  • 다형성 활용: 구현체만 바꿔끼움
  • 원래는 멤버서비스에서 수정하고 해야하는데 configuration만 수정하면 됨

  • 개방-폐쇄 원칙(OCP, Open-Closed Principle)
    • 확장에는 열려있고, 수정, 변경에는 닫혀있다.
  • 스프링의 DI(Dependencies Injection)을 사용하면 기존 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경할 수 있다.
  • 회원을 등록하고 DB에 결과가 잘 입력되는지 확인하자.
  • 데이터를 DB에 저장하므로 스프링 서버를 다시 실행해도 데이터가 안전하게 저장된다.

스프링 통합 테스트

이전에 했던 Test는 순수한 자바 코드로만 테스트한 것임

  • MemberServiceTest복사해서 MemberServiceIntegrationTest 생성
    @SpringBootTest @Transactional을 클래스 위에 추가
  • @BeforeEach 부분 삭제
    • 이전에는 직접 객체 생성
    • 이제는 스프링 컨테이너한테 멤버 서비스, 멤버 리포지토리 내놔
  • 테스트는 제일 편한 방법을 쓰자 (필드 주입)
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
  • @AfterEach 부분 삭제

회원가입 테스트 돌리면 DB에 데이터가 남아있어서 오류 발생

  • H2 데이터베이스에 delete from member
  • 보통은 테스트 전용 DB 따로 구축

스프링 띄우면서 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와 동일
  • 스프링 JdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서 본 반복 코드를 대부분 제거해준다. 하지만 SQL은 직접 작성해야 한다.
  • 실무에서도 많이 쓰인다.

JdbcTemplateMemberRepository 생성
jdbcTemplatedataSource 필요
스프링이 자동으로 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 사용
  • 테이블명, pk(id)
  • 쿼리 짤 필요 없음
  • executeAndReturnKey로 키를 받아서 member.setId
  • document 보고 사용하기

springconfig 파일도 수정

	@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를 잘 활용해야 겠지요?)부터 시작한다.
  • 그리고 테스트를 모든 곳에 하는 것 보다는 핵심 비즈니스 로직에 단위 테스트로 먼저 접근하는 것이 가장 효율이 높다.
  • 특히 단순 조회 로직보다는, 실제 비즈니스 로직으로 데이터가 저장되고, 변경되는 쪽이 더 좋다.

0개의 댓글