[Spring] DB연동

Fortice·2021년 1월 21일
0

Spring

목록 보기
6/13
post-thumbnail

DB연동

0. JDBC vs JDBC Template

스프링의 JDBC Template를 사용하면 기본적인 JDBC의 코드를 간단하게, 효율적으로 작성할 수 있다.

  1. 구조적인 반복 줄이기
    • 템플릿 메소드 패턴과 전략 패턴을 엮은 JdbcTempate 클래스를 제공하여 Connection, Exception 처리 때문에 반복되는 코드를 처리할 수 있다.
  2. 트랜잭션 관리 용이
    • JDBC에서 commit(), rollback()메소드를 이용해 관리하던 트랜잭션을 @Transaction어노테이션을 통해 간단히 처리 가능하다.

이러한 장점들로 핵심 부분에 집중하여 개발을 할 수 있다.

이를 실습하기 위해 모듈을 받아야 한다.

  • spring-jdbc
    • JDBC Template등 JDBC 연동에 필요한 기능 제공
    • 트랜젝션 기능을 제공하는 spring-tx 모듈 포함
  • tomcat-jdbc
    • DB 커넥션풀 기능 제공
  • mysql-connector-java
    • MySQL 연결에 필요한 JDBC 드라이버 제공

커넥션 풀
커넥션 풀은 최초 DB 연결에 따른 응답속도 저하와 동시 사용자가 많을 때 발생하는 부하를 줄이기 위해 일정 개수의 DB Connection을 미리 만들어 두는 기법이다.

커넥션 풀에 커넥션을 요청하면 해당 커넥션은 활성(Active)상태가 되고, 커넥션을 반환하면 유휴(Idle)상태가 된다.

DB Connection 시간은 매우 길다. 이는 전체 성능에 영향을 주는데, DB 접속을 요구하는 사용자가 많으면 사용자마다 Connection을 생성해 부하를 준다. 미리 생성된 Connection을 사용해 이런 부하를 줄일 수 있다.

커넥션 풀을 제공하는 모듈로는 Tomcat JDBC, HikariCP, DBCP, c3p0 등이 존재한다.

1.JDBC Templage

1-1. DataSource

JDBC 에서 DriverManager로 연결하던 것과 달리 스프링에서는 DataSource를 사용해서 DB Connection을 구한다. 연동에 사용할 DataSource를 스프링 빈으로 등록하고, 연동 기능을 구현한 빈 객체는 DataSource를 주입받아 사용한다.

Tomcat JDBC에서는 javax.sql.DataSource에 기능을 추가한 DataSource를 제공한다.

public class DataSource extends DataSourceProxy implements javax.sql.DataSource,MBeanRegistration, org.apache.tomcat.jdbc.pool.jmx.ConnectionPoolMBean, javax.sql.ConnectionPoolDataSource

DataSource를 사용한 연결 방식은 이렇다.

@Configuration
public class DbConfig {

	@Bean(destroyMethod = "close")
	public DataSource dataSource() {
		DataSource ds = new DataSource();
		ds.setDriverClassName("com.mysql.jdbc.Driver");
		ds.setUrl("jdbc:mysql://localhost/spring?characterEncoding=utf8");
		ds.setUsername("spring");
		ds.setPassword("springstudy");
		ds.setInitialSize(2); // 최초 생성 커넥션 수
		ds.setMaxActive(10); // 최대 커넥션 수
		ds.setTestWhileIdle(true); // 연결 유효성 검사
		ds.setMinEvictableIdleTimeMillis(1000 * 60 * 3); // 최대 유휴 상태 시간(ms)
		ds.setTimeBetweenEvictionRunsMillis(10 * 1000); // 검사 주기(ms)
		return ds;
	}
}

setter 메소드들은 DataSource가 상속한 DataSourceProxy에 정의되어있다. 위 설정 외에도 많은데 이러한 setter 메소드들을 사용해 연결 정보, 커넥션 풀 최대 개수, 유휴시간 등 설정을 정할 수 있다.

DataSourcegetConnection()로 커넥션을 가져와 활성상태로 만들고, close()시 유휴상태가 된다. 유휴상태의 커넥션이 없을 경우 maxWait 대기시간 동안 대기하다 반환된 커넥션을 사용하고, 시간 내 반환이 없을 경우 익셉션을 발생시킨다.

DBMS 설정으로 특정 시간 내 쿼리가 실행되지 않는 커넥션은 끊을 수 있다. DBMS가 연결을 끊었는데, 커넥션 풀의 커넥션은 유지되는 상황이 발생할 수 있기 때문에 DBMS 설정과 커넥션 풀 설정을 맞추어주어야한다. 이런 경우에 쿼리 요청 시 익셉션이 발생한다.

매 시간마다 사용자가 있는 것은 아니기에 위와 같은 상황을 방지하기 위해 커넥션이 유효한지 지속적으로 검사해야 한다.

1-2. JDBC Template

JDBC Template를 사용하면 DataSource, Connection, Statement, ResultSet을 직접 사용하지 않고 편리하게 쿼리를 실행할 수 있다.

JDBC Template를 사용해 쿼리용 객체 DAO를 만들어 본다. JdbcTemplateDataSource를 주입하도록 생성자를 주입한다. 이 또한 마찬가지로 Bean으로 등록해 사용한다.

public class MemberDao {

    private JdbcTemplate jdbcTemplate;

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

@Configuration
public class AppCtx {

    @Bean(destroyMethod = "close")
    public DataSource dataSource() {
        DataSource ds = new DataSource();
        ds.setDriverClassName("com.mysql.jdbc.Driver");
        ds.setUrl("jdbc:mysql://localhost/spring?characterEncoding=utf8");
        ...
        return ds;
    }

    @Bean
    public MemberDao memberDao() {
        return new MemberDao(dataSource());
    }
 }

1-3. 조회(SELECT) 쿼리 실행

JDBC Template는 수많은 쿼리 메소드를 제공한다. 따라서 상황에 맞게 찾아 쓰면 된다.

대표적인 조회 쿼리는 다중 결과로 query()메소드, 단일 결과로 queryForObject()를 사용한다. 매개변수에 Query용으로 String, PreparedStatementCreator, Result용으로 ResultSetExtractor<T>, RowMapper<T>를 사용한다. Query는 PreparedStatement 형태로 ?의 순서와 args를 매칭해 쿼리를 완성할 수 있다. 자주 사용하는 방식은 아래와 같다.

다중 결과

  • List query(String sql, @Nullable Object[] args, RowMapper rowMapper)
  • List query(String sql, RowMapper rowMapper, @Nullable Object... args)

    RowMapper

    RowMapper는 함수형 인터페이스로 RowMapper의 mapRow(ResultSet, int) 메소드로 ResultSet의 한 행의 데이터를 읽어와 자바 객체로 변환하는 역할을 한다.

    List<Member> results = jdbcTemplate.query(
      "select * from MEMBER where EMAIL = ?",
      new RowMapper<Member>() {
          @Override
          public Member mapRow(ResultSet rs, int rowNum) throws SQLException {
              Member member = new Member(
                  rs.getString("EMAIL"),
                  rs.getString("PASSWORD"),
                  rs.getString("NAME"),
                  rs.getTimestamp("REGDATE").toLocalDateTime()
              );
              member.setId(rs.getLong("ID"));
              return member;
          }
      }, email); 

    람다식을 활용해 임의 클래스를 이용한 코드보다 간결하게 만들 수 있다.

    List<Member> results = jdbcTemplate.query(
      "select * from MEMBER where EMAIL = ?",
      (ResultSet rs, int rowNum) -> {
          Member member = new Member(
              rs.getString("EMAIL"),
              rs.getString("PASSWORD"),
              rs.getString("NAME"),
              rs.getTimestamp("REGDATE").toLocalDateTime()
          );
          member.setId(rs.getLong("ID"));
          return member;
      }, email);

단일 결과

  • T queryForObject(String sql, Class requiredType, @Nullable Object... args)
  • T queryForObject(String sql, RowMapper rowMapper, @Nullable Object... args)

queryForObject는 반드시 결과가 한 행이어야한다. 0개 or 2개 이상의 결과가 나오면 익셉션이 발생한다. 따라서 정확히 한 행이 나오는 쿼리가 아니면 query() 메소드를 사용하는게 좋다.

1-4. 변경(UPDATE, DELETE, INSERT) 쿼리 실행

데이터베이스 조작에는 update()메소드를 사용한다. 리턴 값인 int는 변경된 행의 개수이다.

  • int update(String sql)
  • int update(String sql, Object...args)
  • int update(PreparedStatementCreator psc, final KeyHolder kh)

PreparedStatementCreator, KeyHolder

PreparedStatementCreator는 함수형 인터페이스로 createPreparedStatement(Connection)메소드로 파라미터로 받은 Connection을 이용해 PreparedStatement 객체를 생성하고 인덱스 파라미터를 알맞게 설정한 뒤 리턴한다.

update의 경우 int형으로 변경된 행의 수를 반환하므로 결과를 알고싶어도 알 수 없다. KeyHolder를 사용해 update로 insert된 행의 자동으로 생성된(AUTO_INCREMENT) 키값을 알 수 있다.

public void insert(Member member) {
  KeyHolder keyHolder = new GeneratedKeyHolder();
  jdbcTemplate.update(new PreparedStatementCreator() {
      @Override
      public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
          PreparedStatement pstmt = con.prepareStatement(
              "insert into MEMBER (EMAIL, PASSWORD, NAME, REGDATE) " +
              "values (?, ?, ?, ?)",
              new String[] { "ID" }
          );
          pstmt.setString(1, member.getEmail());
          pstmt.setString(2, member.getPassword());
          pstmt.setString(3, member.getName());
          pstmt.setTimestamp(
              4,
              Timestamp.valueOf(member.getRegisterDateTime())
          );
          return pstmt;
          }
      }, keyHolder);
      Number keyValue = keyHolder.getKey();
      member.setId(keyValue.longValue());
  }

Key값의 경우 getKey(), getKeyList(), getKeys()로 알 수 있고 각 결과는 아래처럼 나온다. 책에서는 new String[] { "ID" }가 AUTO_INCREMENT 컬럼을 지정해준다 했는데, DB 설정을 따르는 것 같고, 임의로 REGDATE 컬럼을 지정했을 때 별 차이는 없었다.

1-4 스프링의 예외 처리

스프링은 SQL 관련 익셉션이 발생했을 때, 해당 익셉션을 JDBC Template이 DataAccessException으로 변환하고 알맞은 익셉션을 내보낸다. MySQL 드라이버는 Syntax 에러로 발생한 SQLException을 상속받은 MySQL용 MySQLSyntaxErrorException으로 바꾼다. 마찬가지로 스프링이 DataAccessException 익셉션을 상속받은 BadGrammarSQLException으로 변환하여 알려준다. 이는 연동 기술에 상관없이 동일하게 익셉션을 처리하기 위해서이다. 만약 이런 변환이 없다면 연동 기술에 따라 코드를 수정해야할 것이다.

DataAccessException은 런타임 익셉션으로 JDBC의 SQLException과 달리 필요한 경우에만 try/catch 구문으로 처리해 주면 된다.

2. Transaction 처리

2-1. Transaction 이란?

트랜잭션이란 데이터베이스의 상태를 변환시키는 하나의 논리적 기능을 수행하기 위한 작업의 단위 또는 한꺼번에 모두 수행되어야 할 일련의 연산들을 의미한다.

보통의 작업은 하나의 쿼리로 완료할 수 없다. 여러개의 쿼리를 사용해서 하나의 작업을 완료하는데, 만약 중간에 문제가 발생해 끝까지 처리가 되지 않았다면 이전의 쿼리 수행에 따른 문제가 발생한다.

따라서 이런 경우 실행 결과를 취소하고, 실패하기 전으로 돌려야하는데, 이를 롤백(rollback)이라 한다. 반대로 트랜잭션으로 묶인 코드들을 정상 수행하고나서 이를 실제 DB에 정상 적용하는 것을 커밋(commit)이라 한다.

JDBC에서는 트랜잭션의 시작을 setAutoCommit(false)로 시작하고, 롤백을 rollback()으로, 커밋을 commit()으로 실행한다.

  Connection conn = null;
       try {
           conn = DriverManager.getConnection(url, user, pw);
           conn.setAutoCommit(false);
           //... 쿼리 실행
           conn.commit(); 
       } catch(SQLException ex) {
           if (conn != null)
               try {conn.rollback(); } catch (SQLException e) {}
       } finally {
           if (conn != null)
               try {conn.close();} catch (SQLException e) {}
       }

2-2. Spring Transaction 처리

스프링은 @Transactional 어노테이션을 이용해 쉽게 트랜잭션 범위를 지정할 수 있다. 트랜잭션 범위에서 실행하고 싶은 메서드에 @Transactional 어노테이션을 붙여주면 된다.

어노테이션을 붙인 메소드와, 그 안에서 돌아가는 메소드들이 전부 Transaction으로 묶인다. 아래의 경우 tranFunc 메소드 아래 insert, delete 메소드까지 묶이게 된다.

@Transactional
public class Test {
  public void tranFunc() {
      insert();
      delete();
  }
}

어노테이션을 사용하기 위해 두가지 설정이 필요하다.

  • 플랫폼 트랜잭션 매니저 빈 설정

  • @Transaction 어노테이션 활성화 설정

    아래는 설정파일 내용이다.

@Configuration
@EnableTransactionManagement
public class AppCtx {

    @Bean(destroyMethod = "close")
    public DataSource dataSource() {
        DataSource ds = new DataSource();
        ds.setDriverClassName("com.mysql.jdbc.Driver");
        ...
        ds.setTimeBetweenEvictionRunsMillis(10 * 1000);
        return ds;
    }

    @Bean
    public PlatformTransactionManager transactionManager() {
        DataSourceTransactionManager tm = new DataSourceTransactionManager();
        tm.setDataSource(dataSource());
        return tm;
    }
}

PlatformTransactionManager는 스프링이 제공하는 트랜잭션 매니저 인터페이스 이다. DataSourceTransactionManager에 트랜잭션 연동에 사용할 DataSource를 지정한다. 생성자로도 지정 가능하며, get으로 설정된 DataSource를 얻을 수 있다.

@EnableTransactionManagement 어노테이션은 @Transactional 어노테이션이 붙은 메소드를 트랜잭션 범위에서 실행하는 기능을 활성화한다. 등록된 플랫폼 트랜잭션 매니저 빈을 사용해 적용한다.

2-3. Trasaction Logging

트랜잭션이 제대로 수행하는지 확인하기 위해 로그 모듈을 사용한다. 스프링 5 버전은 자체 로깅 모듈인 spring-jcl을 사용한다. 이는 직접 로그를 남기지 않고 다른 모듈을 사용해 로그를 남기기 때문에, 이번에는 Logback을 사용한다.

2-4. Transaction과 프록시

트랜잭션은 AOP를 사용하기에 매우 좋은 기능이다. 스프링은 @Transactional어노테이션을 통해 트랜잭션을 처리하기 위해 내부적으로 AOP를 사용한다. 따라서 트랜잭션 또한 프록시를 통해서 처리가 이루어진다.

@Transactional을 적용한 빈을 getBean()으로 불러오면 해당 객체 대신 프록시 객체를 리턴한다. (실습을 해봤지만, 예상과 다르게 프록시 객체를 얻을 수 없었다)

2-5. Transaction 롤백 처리

스프링에서 트랜잭션의 롤백은 RuntimeException이 발생했을 때 진행된다. RuntimeException이 아닌 경우는 별도의 rollbackFor 설정을 통해 익셉션 발생 시 롤백시킬 수 있다.

@Transactional(rollbackFor = {SQLException.class, IOException.class}

반대로 제외의 경우 noRollbackFor 속성을 적용하면 된다.

2-6. @Transactional의 주요 속성

자주 사용하지는 않지만 알아두면 좋다.

  • value[String] : PlatformTransactionManager 빈의 이름(default = "")
  • propagation[Propagation] : 트랜잭션 전파 타입(default = Propagation.REQUIRED)
  • isolation[Isolation] : 트랜잭션 격리 레벨 (default = Isolation.DEFAULT)
  • timeout[int] : 트랜잭션 제한 시간 (default = -1 --> 데이터베이스 타임아웃 사용[초단위])

value의 경우 지정하지 않는 경우 PlatformTransactionManager 빈을 찾아 등록한다. 해당 타입의 빈이 두개 이상일 경우 익셉션이 발생하므로 지정해줘야한다.

Propagation트랜잭션 수행 여부에 관한 설정이다.

  • REQUIRED : 메서드 수행 시 트랜잭션 필요, 트랜잭션 존재 시 실행, 없을 시 생성 후 실행
  • MANDATORY : 메서드 수행 시 트랜잭션 필요, 트랜잭션 존재 시 실행, 없을 시 익셉션
  • REQUIRES_NEW : 항생 새로운 트랜잭션 실행, 진행중인 트랜잭션 일시 중지
  • SUPPORTS : 트랜잭션 불필요, 진행 중인 트랜잭션 존재 시 사용, 사용 안해도 정상 동작
  • NOT_SUPPORTED : 트랜잭션 불필요, 진행 중인 트랜잭션 존재 시 메서드 실행시간 중 일시정지.
  • NEVER : 트랜잭션 불필요, 진행 중인 트랜잭션 존재 시 익셉션
  • NESTED : 기본 REQUIRED, 진행 중인 트랜잭션 존재 시 중첩 실행, JDBC 3.0이상

Isolation 열거 타입은 DB 동시 접근에 대한 설정이다.

  • DEFAULT : 기본 설정
  • READ_UNCOMMITTED : 다른 트랜잭션이 커밋하지 않은 데이터를 읽을 수 있다.
  • READ_COMMITTED : 다른 트랜잭션이 커밋한 데이터를 읽을 수 있다.
  • REPEATABLE_READ : 처음에 읽어 온 데이터와 두 번째 읽어 온 데이터가 동일한 값을 갖는다.
  • SERIALIZABLE : 동일한 데이터에 대해서 동시에 두 개 이상의 트랜잭션을 수행할 수 없다.

2-6. @EnableTransactionManagement 어노테이션 주요 설정

  • proxyTargetClass : 클래스를 이용해서 프록시를 생성할지 여부 [true/false]
  • order : AOP 적용 순서를 지정. int값 낮은 순

2-7. Transaction Propagation

Propagation의 동작방식을 알아보자.

public class First {
    private Second second;

    @Transactional
    public void next() {
        second.complete();
    }

    public void setSecond(Second second) {
        this.second = second;
    }
}

public class Second {
    @Transactional
    public void complete(){
    }
}

위의 코드에서 First, Second 클래스의 메소드 둘 다 @Transactional 어노테이션이 설정되어있다. 그런데 First 클래스의 next() 메소드에서 Second 클래스의 트랜잭션 메소드를 호출한다. 이 경우 어떻게 될까?

REQUIRED의 경우 현재 진행중인 트랜잭션이 존재하면 해당 트랜잭션을 사용하고 존재하지 않으면 새로 시작한다. 따라서 next() 호출 시 트랜잭션이 생성, 시작하고 complete() 호출 시 이미 트랜잭션이 존재하기에 해당 트랜잭션을 그대로 사용한다.

REQUIRED_NEW의 경우 항상 새로 시작하므로, 두 메소드 호출 시 각각 트랜잭션을 생성하여 시작한다.

위에서도 설명했지만, 트랜잭션의 겹침이 없을 경우, JDBC Template가 진행중인 트랜잭션이 존재하면 그 범위 내에서 실행하도록 한다.

profile
서버 공부합니다.

0개의 댓글