티스토리 뷰

728x90
반응형

스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 김영한 인프런 강의 참고

이전 포스팅 : 2023.02.17 - [Web Programming/Spring&Spring Boot] - [Spring Boot] 입문 - 순수 JDBC로 DB 연결하기(feat. mysql) - 2

 

[Goals]

  • JDBC Template
  • 통합 테스트

 

JDBC Template


순수 JDBC api를 사용하여 DB에 접근할 때 필수적으로 처리해줘야 하는 로직들이 있다.

Connection 객체를 생성하고, Statememt 객체를 생성하고 … 다시 해제하고 … 예외처리 하고…

(참고 : [Spring] 입문 - 순수 JDBC로 DB 연결하기(feat. mysql) - 1)

이전의 예제 코드만 보더라도, 핵심 서비스 로직을 제외 했는데도, 중복되는 부분이 적지 않다.

// ...

@Override
public Member save(Member member) {
    String sql = "insert into member(name) values(?)";

    // DB의 연결 정보를 가지고 일정 시간 동안 DB와 연결할 수 있도록 통로 역할을 하는 객체
    Connection conn = null;
    // 다양한 SQL 구문들을 정의하고 바인딩 하는 방법들과 실제 DB로 전송하는 방법들이 정의된 객체
    PreparedStatement pstmt = null;
    // sql 쿼리 결과를 저장하는 데이터 집합 객체
    ResultSet rs = null;

    try {
        conn = getConnction();
        pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);

    // ...

    } catch (Exception e) {
        throw new IllegalStateException(e);
    } finally {
        close(conn, pstmt, rs);
    }
}

// ...

// 할당한 리소스 해제
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();
    }
}

// Connection 객체 리소스 해제
private void close(Connection conn) throws SQLException {
    DataSourceUtils.releaseConnection(conn, dataSource);
}

개발자라면 이런 중복 코드들을 없애고 싶은 마음이 드는 건 당연하다. 고맙게도 Jdbc api가 이러한 중복 코드를 최소화할 수 있도록 라이브러리를 제공한다.

JDBC Template은 JDBC 코어 패키지의 중심 클래스입니다. JDBC 사용을 단순화하고 일반적인 오류를 방지하는 데 도움이 됩니다. - spring 공식 문서

 

라이브러리 이름이 JDBC Template인 이유는 객체 지향 디자인 패턴 중 템플릿 메서드 패턴(Template Method Pattern)을 적용하여 중복을 제거했기 때문이다.

(참고 : https://coding-factory.tistory.com/712)

사용하는 방법은 매우 간단하다. 순수 JDBC와 동일한 환경설정을 하면 된다.

build.gradle

// ...

dependencies {
	// ...
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	// ...
}

이제 바로 JDBC Template api를 사용할 수 있다. 이를 사용하여 MemberRepository를 다시 구현해 보겠다.

.../repository/JdbcTemplateMemberRepository.java

// ...

public class JdbcTemplateMemberRepository implements MemberRepository{

    private final JdbcTemplate jdbcTemplate;

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

    // ...
    
}

먼저 JdbcTemplate 타입의 인스턴스 변수를 선언한다. 이 객체는 스프링으로부터 DI를 받을 수 없다.

대신 먼저 클래스 생성자에서 이전처럼 DataSource를 주입받는다.

그 다음 JdbcTemplate 생성자를 통해 JdbcTemplate 객체를 새로 생성해 주는데, 이때 주입받은 dataSouce를 매개변수 값으로 넣어 준다.

그럼 이제 JdbcTemplate 객체로 api를 사용하여 구현하면 된다.

.../repository/JdbcTemplateMemberRepository.java

// ...

public class JdbcTemplateMemberRepository implements MemberRepository{

    // ...

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

   // ...
}

먼저 save 메서드 구현이다.

SimpleJdbcInsert는 테이블에 대한 간편한 삽입 기능을 제공하는 클래스로, 실제 삽입은 JdbcTemplate을 사용한다. 때문에 객체를 생성할 때 JdbcTemplate 타입의 객체를 매개변수 값으로 넣어준다.

  • withTableName(String tableName) : 삽입에 사용할 테이블 명
  • usingGeneratedKeyColumns(String ... columnNames) : 자동 생성 키가 있는 열의 이름을 지정
  • executeAndReturnKey(SqlParameterSource parameterSource) : 전달된 값을 사용하여 삽입을 실행하고 생성된 키를 반환

여기서 SqlParameterSource는 간단히 말해서 sql에 들어갈 parameter Map 객체를 처리하는 인터페이스다. 구현체인 MapSqlParameterSource를 사용하면 자바의 Map 객체를 테이블 속성 및 속성 값과 매핑시켜 삽입하는 sql문을 만들 수 있다.

// ...

public class JdbcTemplateMemberRepository implements MemberRepository{

    // ...

    @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());
        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은 반복적인 로직을 거의 없앴지만 sql문은 직접 작성해 줘야 한다.

  • query(String sql, RowMapper<T> rowMapper, Object ... args) : 주어진 SQL을 실행한다. 이때 PreparedStatement를 생성하고, 주어진 인수 목록을 바인딩 하여 실행한다. 그 결과 각 행을 RowMapper를 통해 매핑시킨다.

(참고로 반환 타입은 List<T>로 매개변수 타입에 따라 정해진다)

query 메서드 안에 이전과 같이 String 타입으로 작성된 sql문을 넣어준다. 이때 두 번째 인자 값으로 RowMapper 타입의 객체가 필요한데. 이를 반환하는 메서드를 따로 구현해 준다.

RowMapper는 행 단위로 ResultSet의 행을 매핑하기 위해 JdbcTemplate에서 사용하는 인터페이스다. 우리는 이 인터페이스의 mapRow라는 메서드를 구현한 구현체를 매개변수 값으로 넘겨줘야 한다.

  • T mapRow(ResultSet rs, int rowNum) throws SQLException
    • rs : 매핑할 ResultSet으로 쿼리 결과 값이 저장됨 (현재 행에 대해 미리 초기화됨)
    • rowNum : 현재 행의 번호

Member 객체를 생성해 rs로부터 idname 값을 가져와 대입해 준 뒤 반환한다.

또한 RowMapper 인터페이스는 추상 메서드가 mapRow 1개 밖에 없는 함수형 인터페이스이기 때문에 람다식으로 바꿀 수 있다.

아래의 코드를 람다식으로 바꾸면 위와 같은 코드가 된다.

// 함수형 인터페이스 객체를 생성과 동시에 구현
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;
	    }
	};
}

이제 마지막으로 spring 설정 파일에서 MemberRepository 인터페이스의 구현체를 변경해 준다.

…/SpringConfig.java

// ...

@Bean
MemberRepository memberRepository() {
    // return new MemoryMemberRepository();
    // return new JdbcMemberRepository(dataSource);
    return new JdbcTemplateMemberRepository(dataSource);
}

 

통합 테스트


이제 위 코드를 실제로 테스트 해봐야 한다. 이전의 테스트 코드는 오로지 spring의 도움 없이 java의 힘 만으로 테스트를 했다면, 지금은 DB와 연결해야 하므로 spring을 띄워야 한다. 이전의 테스트는 메서드 단위의 테스트로 단위 테스트라 한다면, 지금 작성하는 것은 어플리케이션을 띄워야 하므로 통합 테스트로 넘어간다.

Spring Boot는 어노테이션을 통해 이를 매우매우 간단하게 지원해 준다.

  • @SpringBootTest : 스프링 컨테이너와 테스트를 함께 실행
  • @Transactional : 테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백

기존의 MemberServiceTest를 그대로 복사한 뒤, 아래와 같이 수정만 해주면 된다.

.../service/MemberServiceIntegrationTest.java

// ...

@SpringBootTest  // 스프링 컨테이너와 테스트를 함께 실행
@Transactional  //  테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백
public class MemberServiceIntegrationTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

		@Test
    public void 회원가입() throws Exception {

		// Given
    Member member = new Member();
    member.setName("hello");
    
    // When
    Long saveId = memberService.join(member);

    // Then
    // Assertions.assertThat(memberRepository.findById(saveId).get()).isEqualTo(member);
		Member findMember = memberRepository.findById(saveId).get();
    assertEquals(member.getName(), findMember.getName());

// ...

@Transactional 덕분에 각 테스트 메소드는 실행 중 생성되는 데이터가 DB에 Commit 되기 전에 Rollback을 하므로 테스트 DB는 항상 초기 상태를 유지된다.

@Autowired를 속성에 사용한 이유는, 단지 테스트 코드를 더 직관적이고 작성하기 편리하게 하기 위함이다.

Assertions.assertThat(memberRepository.findById(saveId).get()).isEqualTo(member)를 주석처리한 뒤 새로 검증 코드를 작성해야 한다.

이전에는 메모리 DB를 사용했기 때문에 생성한 객체를 다시 메모리에 저장하는 형태라서 사실상 같은 객체를 참조하고 있으므로 테스트를 통과한다.

하지만 외부 DB를 사용하게 되면, 외부 DB에서 가져온 객체는 새로운 메모리에 올라가기 때문에 기존에 코드로 생성한 객체와는 다른 메모리에 위치하게 된다.

따라서 검증 코드를 객체의 name값을 비교하도록 약간 수정한다. 다른 테스트 메소드들도 동일하게 수정해 준다.

위와 같은 테스트 방식은 애플리케이션의 설정, 모든 Bean을 모두 로드하기 때문에 운영환경과 가장 유사한 테스트가 가능하다. 단, 테스트 속도는 느려질 수밖에 없다.

실제로 비교해 보면 Spring 컨테이너 및 설정들을 띄우는 MemberServiceIntegrationTest는 전체 테스트가 완료되는데 2.4s지만, Java만을 사용하는 MemberServiceTest는 139ms로 확연하게 차이가 난다.

실무에서도 어쩔 수 없는 상황을 제외하면, 통합 테스트보다는 단위 테스트가 더 좋은 테스트일 확률이 높다고 한다.

Reference

728x90
반응형
댓글