본문 바로가기
Java/Spring입문

Spring 입문(12) 스프링 DB 접근 기술: 순수 JDBC(스프링 통합테스트)

by 이쟝 2022. 3. 11.
인프런의 김영한님의 스프링입문 강의를 듣고 정리한 내용입니다.
스프링 입문 강의
1. H2 데이터베이스 설치
2. 순수 Jdbc(스프링 통합 테스트)
3. 스프링 Jdbc Template
4. JPA
5. 스프링 데이터 JPA

2. 순수 JDBC 

- 애플리케이션에서 DB를 접근, 데이터를 넣고 빼는 것을 순수 Jdbc 버전, 즉 옛날 방식(?)으로 구현해 봅니다.

1. 환경 설정(자바와 DB를 연결하기 위함)

*build.gradle 파일에 jdbc, h2 데이터베이스 관련 라이브러리 추가*

dependencies {
	//implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	//implementation 'org.springframework.boot:spring-boot-starter-web'
	//testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	runtimeOnly 'com.h2database:h2'
}

Java는 DB를 연결하려면 jdbc-driver가 꼭 필요하다!!

- 위는 자바가 DB를 사용하기 위해 필요한 Jdbc 드라이버를 위한 라이브러리, 아래는 h2가 제공하는 DB 클라이언트를 위한 라이브러리이다. 

 

*스프링 부트 데이터베이스 연결 설정 추가*

src > main > resources > application.properties

DB와 연결하려면 접속정보를 넣어야 한다. (옛날에는 개발자가 다 했지만 이제는 스프링 부트가 해준다!)
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa

데이터베이스의 경로를 지정해 주었고 드라이버를 설정, 데이터베이스 회원 정보를 등록했다. 위와 같이 코드 작성을 완료했다면 DB에 접근하기 위한 준비는 끝!

 

2. Jdbc 레포지토리 구현

- Jdbc API를 가지고 Jdbc리포지토리를 구현한다.

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

Jdbc 회원 레포지토리 (JdbcMemberRepository 파일 생성)

src > main > java > repository > JdbcMemberRepository.java 클래스(MemberRepository를 구현)
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;  // DB와 연결하려면 dataSource필요
    public JdbcMemberRepository(DataSource dataSource) { // 스프링을 통해서 datasource 주입
        this.dataSource = dataSource;
    }
    
   Connection conn = null;
   PreparedStatement pstmt = null;
   ResultSet rs = null;

    @Override
    public Member save(Member member) {
        String sql = "insert into member(name) values(?)";
        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 = ?";
        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;;
        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 = ?";
        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();}
            if (pstmt != null) {pstmt.close();}
            if (conn != null) {close(conn);}
        } catch (SQLException e) {e.printStackTrace();}
    }

    private void close(Connection conn) throws SQLException {
        DataSourceUtils.releaseConnection(conn, dataSource);
    }
}
더보기
   private final DataSource dataSource;  // DB와 연결하려면 dataSource필요
    public JdbcMemberRepository(DataSource dataSource) { // 스프링을 통해서 datasource 주입
        this.dataSource = dataSource;
    }

- 레포지토리는 dataSource라는 DataSource 객체를 갖고, 이 DataSource는 JdbcRepository가 생성될 때 스프링에 의해 주입된다. 

    private Connection getConnection() {
        return DataSourceUtils.getConnection(dataSource);
    }

- DB에서 데이터를 가지고 오기 위해 연결을 하기 위해서는 Connection이 필요한데 이는 DataSourceUtils에 의해 dataSource를 넘겨 가져올 수 있다. (마찬가지로 DataSourceUtils를 통해 Connection을 release 한다.)

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

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

- sql을 정의하고 Connection, PreparedStatement, ResultSet객체를 정의하고 진행한다. 

- conn을 받고, sql과 RETURN_GENERATED_KEYS을 설정하고 pstmt에 받는다다. 이때 RETURN_GENERATED_KEYS는 DB에 insert 할 때 insert를 한 후에야 할 수 있는 key값인 1,2,3... 과 같은 값들을 가져올 수 있도록 설정한다.

- setString( )은 1로 sql의 '?'와 매칭을 하고 그곳에 getName()으로 회원의 이름을 넣는다.

- executeUpdate() DB에 쿼리를 전송하고, 다음 줄에서 생성된 key를 받아 rs에 받는다. 이때 위에서 설정한 RETURN_GENERATED_KEYS과 연관이 있다.

- 결과 값(생성된 key값)이 있으면 이를 꺼내 Long으로 바꾼 후 회원의 id로 설정해 준 후 member 객체를 반환한다.

- 마지막으로 예외처리를 모두 한 후에는 받아온 자원들을 close로 해제해야 한다. 해제해 주지 않으면 db connection이 계속 쌓이고 이후 문제의 원인이 될 수 있다.

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

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

- save() 함수와 다른 점은 executeQuery()를 사용한다. DB에 저장(갱신)할 때는 executeUpdata()를 사용하고 DB를 조회할 때는 executeQuery()를 사용한다.

- 결괏값을 rs로 받고 값이 있다면(검색되는 회원 객체가 있다면) 회원 객체를 생성, 반환된 회원 객체의 id와 name을 가져와 세팅하고 해당 객체를 반환한다.

- findByName() 함수는 name변수로 특정 회원을 검색하는 함수이고, 동작은 findById() 함수와 비슷하다. 

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

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

- 등록된 모든 회원을 검색하기 때문에 sql은 비교적 간단하다.

- 쿼리를 전송해 받은 결과값 rs가 있다면 ArrayList를 하나 생성, rs를 돌며 회원 객체를 생성, id와 name을 세팅하고 ArrayList에 담아 리스트를 반환한다.

3. Config 파일 수정

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

기존에는 해당 파일에서 memberRepository가 MemberMemoryRepository 구현체를 사용하고 있었던 것을 수정한다. 
package hello.hellospring;

import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.MemberRepository;
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는 생성자가 하나일 때 생략 가능하므로, 이때 @Autowired 또한 생략 가능하다.

(DataSource는 DB Connection을 획득할 때 사용하는 객체로 스프링 부트는 DB Connection 정보를 바탕으로 DataSource를 생성하고 스프링 빈으로 만들어 둬서 DI를 받을 수 있다. )
 
중요한 것은 기존의 다른 어떤 코드를 변경하지 않고 단순히 인터페이스를 확장한 JdbcMemberRepository를 만들고 Config 파일만 변경하여 리포지토리 구현체를 변경했다는 것이다.

인터페이스를 두고 손쉽게 구현체를 변경하는 것을 '다형성'을 활용했다고 하며, 스프링은 이런 것을 편리하게 사용할 수 있도록 지원한다.

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

 

 

- 구현클래스 추가 이미지에서 memberService는 레포지토리 인터페이스를 의존하고, 이 인터페이스는 MemoryMemberRepositoryJdbcMemberRepository 구현체로 각각 구현되어있다.
- 스프링 설정 이미지에서 아래 이미지는 기존에 MemoryMemberRepository를 사용하던 memberService가 Config 변경을 통해 JdbcMemberRepository을 사용하는 것을 보여준다.

- 개방-폐쇄 원칙(OCP, Open-Closed Principle): 확장에는 열려있고, 수정, 변경에는 닫혀있는 개발방식
- 객체지향의 다형성 개념을 활용해서 기능을 완전히 변경하더라도 애플리케이션 전체 코드를 수정하지 않고 조립(Configuration)만을 수정, 변경하는 것을 의미한다.


- 스프링의 DI(Dependencies Injection)을 사용하면 "기존 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경"할 수 있다.
- 회원을 등록하고 DB에 결과가 잘 입력되는지 확인한다. 
- 데이터를 MemoryMemberRepository가 아닌 DB에 저장하기 때문에 스프링 서버를 내렸다가 다시 시작하더라도 이전 데이터가 저장된다. 

 

서버를 실행시키면 데이터베이스와 연동이 된 것을 확인할 수 있다. 웹페이지에서 회원등록으로 회원을 등록해도 DB에서 보이고, DB에서 insert 쿼리문으로 회원을 등록해도 웹페이지에서 추가된 것을 확인할 수 있다. 


스프링 통합 테스트

스프링 컨테이너와 DB까지 연결한 통합 테스트를 진행한다.

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

 

*회원 서비스 스프링 통합 테스트*

test > java > hello > hellospring > service > MemberServiceIntegerationTest 파일 생성(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.assertThrows;

@SpringBootTest
@Transactional
class MemberServiceIntegerationTest {

    // 테스트 코드 만들때는 제일 편한 방법으로 해도 된다.
    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

    @Test
    void 회원가입() {
        //given
        Member member = new Member();
        member.setName("spring");

        //when
        Long saveId = memberService.join(member); // login 검증

        //then(결과)
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

    @Test
    void 중복_회원_예외(){
        //given
        Member member1 = new Member();
        member1.setName("Spring");

        Member member2 = new Member();
        member2.setName("Spring"); // 중복된 이름으로 가입

        //when
        memberService.join(member1);

        //import static org.junit.jupiter.api.Assertions.assertThrows;
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 이름입니다.");
    }
}

회원 가입 메서드가 실행하면 오류가 난다!(DB에도 spring이라는 이름이 있기 때문에!!)

오류를 없애기 위해서 delete from member;로 DB에 내용을 지우고 실행하면 정상 작동된다!!!

`@SpringBootTest` : 스프링 컨테이너와 테스트틀 함께 실행한다. 
- 통합 테스트 전에는 @BeforeEach를 사용해 각 테스트를 실행하기 전 회원 서비스와 레포지토리를 직접 객체 생성해서 주입했지만, @SpringBootTest@Autowired를 사용해서 컨테이너에서 스프링을 통해 서비스와 레포지토리를 주입할 수 있다.(MemberRepository 인터페이스는 이전의 MemoryMemberRepository 객체에서 JdbcMemoryRepository로 변경되었다. 구현체가 변경됨!)

'@Transactional`: 테스트 시작 전 트랜잭션을 실행하고, 테스트 완료 후 롤백을 수행해 DB에 데이터가 반영되지 않도록 해서 다음 테스트에 영향을 주지 않는다.
@Transcational이 없다면 테스트 후 DB에 데이터가 반영돼서 데이터가 쌓이게 된다.  

-> 통합 테스트 전에는 @afterEach사용해서 매번 테스트가 끝날 때마다 데이터를 삭제했지만 이제는 @Transactional을 사용하면 된다!

스프링과 DB를 모두 연결해서 테스트하는 통합 테스트도 좋지만 이전에 순순한 자바 코드로 한 단위 테스트가 훨씬 좋은 테스트일 확률이 높다. 단위 테스트는 통합 테스트보다 훨씬 속도도 빠르고, 단위로 쪼개서 테스틀 해주기 때문이다.

 

상황에 따라 통합 테스트나 단위 테스트를 설정한다!