본문 바로가기
공부 및 활동/스프링 강의 정리

[스프링 입문] 6. 스프링 DB 접근 기술 1

by KChang 2021. 9. 27.

1. H2 데이터베이스 설치

이번 강의에서는 교육용 Database인 H2를 사용한다.

H2

  • 가볍고 용량이 작다는 장점
  • 웹 화면으로 콘솔 화면 또한 제공하여 사용하기 편리하다.

설치 주소 : H2

h2

위 사진에서 연결을 바로 누른다. (변경할 것 x)

실행 완료

이제 왼쪽 맨위에 빨간색 버튼을 눌러 밖으로 나간 후, 명령프롬프트에서 test.mv.db파일이 생성되었는지 확인한다.

testdv

 

위 파일이 생성된 것을 확인한 후, 웹 콘솔창으로 돌아가 다음과 같이 JDBC url를 변경한다.

소켓

이전에는 파일을 통해 H2에 접근했다면 이제부터는 소켓을 통해 접근한다.

파일로 접근하는 방식은 애플리케이션과 웹 콘솔이 동시에 동작시 오류가 생길 위험이 있지만, 소켓을 통해 H2에 접속하면 여러 군데서 접근이 가능하다.

 

 

테이블 작성

Member 테이블을 생성한다.

drop

기존에 작성한 회원 객체 클래스

package hello.hellospring.domain;

public class Member {

    private Long id; // 시스템이 저장하는 id
    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

회원 객체의 멤버 변수 중 id는 java에서 Long이지만, sql에서는 bigint로 표현된다.

generated by default as identity는 해당 속성 값을 지정하지 않고 insert시 DB가 자동으로 값을 채워주는 코드이다.(default)

유사하게, 회원 레파지토리에서 squence의 값을 하나씩 증가시켜 id로 저장해준 것과 같은 것으로 이해할 수 있다.

DB

1) 입력

INSERT INTO MEMBER(NAME) VALUES('spring')

2) 출력

SELECT * FROM MEMBER

 

2. 순수 Jdbc

20년 전에 사용하던 방법을 이용하려고 한다.(현재 거의 사용 x)

1) java와 database 연결하기 위해 환경 설정

build.gradle

    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    runtimeOnly 'com.h2database:h2'

위 라이브러리는 각각 jdbc, h2 데이터베이스와 관련된 라이브러리이다.

implementation : java가 DB를 사용하기 위해 필요한 Jdbc 드라이버를 위한 라이브러리

runtimeOnly : h2가 제공하는 DB클라이언트를 위한 라이브러리

 

2) 스프링 부트가 데이터베이스를 연결할 수 있도록 연결 설정을 추가한다.

source/application.properties 파일에 다음과 같은 코드를 작성한다.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa

데이터베이스의 경로를 지정해 주었고 드라이버를 설정, 데이터베이스 회원 정보를 등록하였다.

위와 같은 코드 작성을 완료하였다면 데이터베이스에 접근할 준비가 완료된 것이다.

 

3) Jdbc API를 이용해 Jdbc 레포지토리를 구현한다.

이전까지, 레포지토리 인터페이스를 MemoryMemberRepository로 구현하였다면, 이제는 실제 데이터베이스로 레파지토리를 구현한다.

repository/JdbcMemberRepository

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);
    }
}

구현한 클래스를 위에서부터 살펴보면 해당 레포지토리는 dataSource라는 DataSource 객체를 갖는다.

이 DataSource는 JdbcRepository가 생성될 때 스프링에 의해 주입된다.

 

DB에서 데이터를 가지고 오기 위해, 연결을 하기 위해서는 Connection이 필요하다.

 

이는, DataSourceUtils에 의해 dataSource를 넘겨 가져올 수 있다.

(DataSourceUtils를 통해 Connection을 release 한다.)

 

save() 함수 : 회원을 DB에 등록하는 함수

findById() 함수 : id로 특정 회원을 검색하는 함수

findAll() 함수 : 등록된 모든 회원을 리스트 형태로 검색하는 함수

findByName() 함수 : name변수로 특정 회원을 검색하는 함수

 

Jdbc 회원 레포지토리 구현을 마쳤다면 Config파일을 수정한다.

SpringConfig파일은 4. 스프링 빈과 의존관계 에서 설명한 것처럼 @Bean을 사용해 스프링 빈을 등록하는 파일이다.

기존 해당 파일에서는 memberRepository가 MemberMemoryRepository 구현체를 사용하고 있었다.

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;

    @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);
    }
}

MemoryMemberRepository를 사용하고 있던 회원 레포지토리를 JdbcMemberRepository DB구현체를 사용하도록 변경한다.

이때, Config파일이 생성되면서 DataSource를 스프링으로부터 받아 멤버 변수로 갖고 있다가 레포지토리가 스프링 빈으로 등록될 때 이 dataSource를 넘겨주도록 설정한다.

(참고로, @Autowired는 생성자가 하나일 때 생략 가능하다.)

(DataSource는 DB Connection을 획득할 때 사용하는 객체로 스프링 부트는 DB Connection 정보를 바탕으로 DataSource를 생성하고 스프링 빈으로 만들어 두어 DI를 받을 수 있다.)

 

중요

기존의 다른 어떤 코드를 변경하지 않고 단순히 인터페이스를 확장한 JdbcMemberRepository를 만들고 Config 파일만 변경하여 레포지토리 구현체를 변경하였다.

인터페이스를 두고 손쉽게 구현체를 변경하였다.

이것을, 다형성을 활용했다고 하며, 스프링은 이런 것을 편리하게 사용할 수 있도록 지원한다.

 

기존에는 서비스의 코드가 레포지토리를 의존하는 코드라면 레포지토리가 변경될 때 서비스 코드까지 변경해야 하는 경우가 생겼다면, 이제는 스프링의 DI를 사용하여 기존 코드를 전혀 손대지 않고 설정만으로 구현 클래스를 변경할 수 있다.

이미지

멤버 서비스는 레포지토리 인터페이스를 의존하고, 이 인터페이스는 MemoryMemberRepository와 JdbcMemberRepository 구현체로 각각 구현되어있다.

이미지2

기존에 MemoryMemberRepository를 사용하던 멤버 서비스가 Config 변경을 통해 JdbcMemberRepository을 사용하는 것을 보여준다.

 

이러한 것을 개방-폐쇄 원칙(OCP)라고 한다.

개방-폐쇄 원칙(OCP)

  • 확장에는 열려있고, 수정과 변경에는 닫혀있는 개발 방식
  • 객체지향의 다향성 개념을 활용하여 기능을 완전히 변경하더라도 애플리케이션 전체 코드를 수정하지 않고 조립(Configuration)만을 수정, 변경하는 것

 

프로젝트 실행

결과1확인2

이제는 데이터를 메모리 레파지토리가 아닌 DB에 저장하기 때문에 스프링 서버를 내렸다가 다시 시작하더라도 이전 데이터가 저장된다.

회원 가입 화면에서 새로운 회원을 등록하고 다시 한번 확인하면 새로운 회원이 추가된 회원 목록을 확인할 수 있다.

목록2실행3

 

3. 스프링 통합 테스트

'3.회원 관리 예제 - 백엔드 개발' 에서 진행했던 테스트는 스프링을 띄우지 않고 진행했던 순수한 자바 코드로 진행한 단위 테스트이다.

DB 연결을 마쳤으니 스프링 컨테이너와 DB를 연결하여 실행하는 통합 테스트를 진행한다.

 

 

test/java/hello.hellospring/service/MemberServiceIntegrationTest

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 회원가입() {

        Member member = new Member();
        member.setName("hello");

        Long saveId = memberService.join(member);


        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");

        memberService.join(member1);

        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));

        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");


    }

}

@SpringBootTest를 사용하면 스프링 컨테이너와 테스트를 같이 실행한다.

이전에는 @BeforeEach를 사용해 각 테스트를 실행하기 전 회원 서비스와 레파지토리를 직접 객체 생성, 주입했다.

이제는 @SpringBootTest@Autowired를 사용해 컨테이너에서 스프링을 통해 서비스와 레파지토리를 주입할 수 있다.

(회원 레파지토리는 이전의 MemoryMemberRepository 객체에서 JdbcMemberRepository를 사용하는 MemberRepository 객체로 변경)

 

 

프로젝트 테스트 실행

Transactionalhello

중복된 회원이 존재한다는 에러 메세지이다.

현재 입력하려는 hello가 이미 DB에 데이터가 존재하여 중복이 발생한다.

이전 입력 내용을 지우기 위해 delete from member로 모든 데이터를 지우고 진행한다.

 

 

테스트가 정상 작동한다.

지우고 실행

이러한 것을 한 번에 처리하기 위해 @Transational 애노테이션을 사용해야 한다.

TransactionlTest

 

@Transational 애노테이션은 테스트 시작 전 트랜잭션을 실행, 실행 후 롤백을 수행하여 DB에 데이터가 반영되지 않는다.

입력 초기화

그러므로 @Transational을 사용하면 다음 테스트에 영향을 주지 않는다.

 

이번에 배운 스프링과 DB를 모두 연결한 통합 테스트를 진행하였지만 그렇다고 이전에 수행한 순수 단위 테스트가 의미 없는 것은 아니다.

이전의 테스트를 진행하면 위의 통합 테스트보다 훨씬 속도가 빠름을 확인할 수 있다.

상황

이렇게 상황에 따라 컨테이너와 DB를 올릴 필요 없이 테스트를 할 수 있는 단위 테스트가 더 좋은 경우일 수 있다.

어떻게 단위 테스트를 잘 작성하느냐 또한 고민해볼 만한 문제이다.

 

4. 스프링 JdbcTemplate

  • 순수 Jdbc와 동일한 환경설정을 하면 된다.
  • 스프링 JdbcTemplate, MyBatis와 같은 라이브러리는 순수 Jdbc API 코드에서의 반복되는 부분을 대부분 제거해 준다는 장점이 있다.
  • SQL은 직접 작성해야 한다.

이전 '3.'에서 Jdbc를 사용해서 구현한 JdbcMemberRepository를 JdbcTemplate 라이브러리를 사용해 더 간단하게 구현한다.

 

JdbcTemplate를 사용하기 위한 설정은 순수 Jdbc를 사용하기 위한 설정과 동일하므로 바로 리포지토리를 구현해 본다.

 

repository/JdbcTemplateMemberRepository

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;


    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 (rs, rowNum) -> {

            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));

            return member;
        };
    }
}

JdbcMemberRepository와 같은 코드를 훨씬 짧고 간단하게 작성하였다.

 

JdbcTemplate을 사용하기 위해 생성자에서 dataSource를 받아 jdbcTemplate을 생성할 때 이를 인자로 넘겨주고 jdbcTemplate 객체를 생성한다.

(이때, 생성자가 하나이기 때문에 @Autowired를 생략할 수 있다.)

    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

 

save() 함수에서는 위 코드와 같이 jdbcTemplate을 사용하여 sql 쿼리 작성을 생략할 수 있다.

설정을 통해 쿼리를 작성, 전송하여 생성된 key를 받고 반환된 id를 setting 하여 회원 가입된 회원 객체를 반환한다.

   @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;
    }

 

나머지 함수에서는 중복된 코드를 memberRowMapper() 함수로 작성하고 JdbcTemplate라이브러를 사용하여 이를 sql과 함께 넘겨주어 sql의 결과를 List 형태로 받는다.

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 자세한 방법 사이트

 

jdbcTemplate을 사용하는 레파지토리를 구현하였으니 이제 구현체를 사용하도록 설정해야한다.

SpringConfig/memberRepository 생성자 부분 수정

    @Bean
    public MemberRepository memberRepository(){

//        return new MemoryMemberRepository();
//        return new JdbcMemberRepository(dataSource);
        return new JdbcTemplateMemberRepository(dataSource);
    }

JdbcTemplateMemberRepository를 사용하도록 변경, 생성 시 dataSource를 넘겨준다.

 

프로젝트를 실행한다.

위에서 DB까지 연동하여 test하는 통합 테스트 코드를 작성하였기 때문에, 이제는 웹 애플리케이션을 직접 실행하여 회원가입, 회원 조회를 할 필요 없이 MemberServiceIntegrationTest 코드를 실행하여 테스트를 진행한다.

실행 완료

JdbcTemplate 버전으로 DB까지 연동하여 작동하는 테스트가 성공적으로 실행된 것을 확인할 수 있다.

 

 


강의

참고자료

댓글