[Spring] DB 연동

첫걸음 - 8

Posted by owin2828 on 2019-12-30 16:05 · 18 mins read

1.스프링의 DB 연동 이점


  • 스프링은 기존의 jdbc의 단점(구조적으로 반복되는 비핵심기능들)을 보완

    jdbc는 여기를 참조

  • 자바8의 람다를 사용하면 더 효율적인 개선 가능
  • 트랙잭션의 관리가 쉬움

    트랜잭션은 데이터베이스 뿐 아니라, 한번에 처리해야할 일의 묶음, 단위를 보장하는 의미로 사용

1-1. Maven 프로젝트 생성

    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>5.0.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.apache.tomcat</groupId>
        <artifactId>tomcat-jdbc</artifactId>
        <version>8.5.27</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.16</version>
    </dependency>
  • spring-jdbc: JdbcTemplate 등 JDBC 연동에 필요한 기능을 제공
  • tomcat-jdbc: DB 커넥션풀 기능을 제공
  • mysql-connector-java: MySQL 연결에 필요한 JDBC 드라이버 제공

    DB 커넥션풀은 여기를 참조

1-2. DB 테이블 생성

  • MySQL 5.7.27 버전 사용
  • ID(Primary Key), EMAIL(Unique Key), PASSWORD, NAME, REGDATE을 필드로 갖는 테이블 MEMBER 생성

1-3. DataSource 설정

JDBC API는 DriverManager 외에 DataSource를 이용해 DB 연결을 구하는 방법을 다음과 같이 정의

    Connection conn = null;
    try{
        // datasource는 생성자나 설정 매서드를 이용해 주입
        conn = dataSource.getConnection();
        ...
  • 스프링이 제공하는 DB연동 기능은 DataSource를 사용해 DB Connection을 구함
  • DB 연동에 사용할 DataSource를 스프링 Bean으로 등록하고,
    DB 연동 기능을 구현한 Bean객체는 DataSource를 주입받아 사용
      // AppCtx.java
      @Configuration
      public class AppCtx {
    
              /* 
              * datasource() 매서드를 통해 DataSource를 스프링 Bean으로 등록
              * 아래의 destroyMethod = "close"를 지정함으로써, 
              * 커넥션 풀에 보관된 Connection들을 닫는 매서드 호출까지를 Bean의 생명주기로 지정
              */
          @Bean(destroyMethod = "close")
          public DataSource dataSource() {
              // DataSource 객체 생성
              DataSource ds = new DataSource();
              // JDBC 드라이버 클래스를 지정, 여기서는 MYSQL 드라이버 클래스 사용
              ds.setDriverClassName("com.mysql.jdbc.Driver");
              // URL 지정
              ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8");
              // 계정 & 암호 지정
              ds.setUsername("spring5");
              ds.setPassword("spring5");
              ...
              return ds;
          }
              ...
          @Bean
          public MemberDao memberDao() {
              return new MemberDao(dataSource());
          }
              ...
      }
    


2. JdbcTemplate을 이용한 쿼리 실행


스프링에서는 DataSource, Connection, Statement 및 ResultSet을 직접 사용하지 않고,
JdbcTemplate을 이용해 편리하게 쿼리 실행 가능

DB의 데이터에 접근하는 자세한 방법은 여기를 참조

2-1. JdbcTemplate 생성

    // MemberDao.java
    public class MemberDao {

        private JdbcTemplate jdbcTemplate;

            // 필요한 dataSorce 객체를 주입받음
        public MemberDao(DataSource dataSource) {
            this.jdbcTemplate = new JdbcTemplate(dataSource);
        }
        ...
    }


2-2. JdbcTemplate을 이용한 조회 쿼리 실행

  • JdbcTemplate 클래스는 SELECT 쿼리 실행을 위한 query() 매서드를 다음과 같이 제공
    • List quey(String sql, RowMapper rowMapper)
    • List quey(String sql, Object[] args, RowMapper rowMapper)
    • List quey(String sql, RowMapper rowMapper, Object... args)
  • sql의 파라미터가 아래와 같이 인덱스 기반 파라미터를 가진 쿼리라면,
    args 파라미터를 이용하여 각 인덱스의 파라미터의 값을 지정

    select * from member where email=?

  • RowMapper의 mapRow() 매서드는 실행 결과로 구한 ResultSet의 한 행을 읽어와 자바 객체로 변환해주는 매퍼 기능 제공

    ResultSet이란 SELECT문을 사용하여 얻어온 레코드 값들을 테이블의 형태로 갖게 되는 객체

      // MemberDao.java
      public class MemberDao {
              ...
          // 조회 쿼리 기능을 구현
          public Member selectByEmail(String email) {
              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);
    
                      /* 
                      * query() 매서드는 쿼리를 실행한 결과가 존재하지 않으면 0인 list를 반환하므로
                      * list가 비어있는지 여부로 결과가 존재하지 않는지 확인할 수 있음
                      */
              return results.isEmpty() ? null : results.get(0);
          }
    
  • selectByEamil 매서드의 JdbcTemplate query() 매서드는 다음과 같이 인덱스 파라미터(물음표)를 포함
  • 인덱스 파라미터에 들어갈 값은 query() 매서드 맨 마지막 부분의 email 에서 전달
      List<Member> results = jdbcTemplate.query(
          "select * from MEMBER where EMAIL = ?",
          new RowMapper<Member>() {...코드생략},
              email); // 물음표에 해당하는 값 전달
    


2-3. 결과가 1행인 경우 사용할 수 있는 queryForObject() 매서드

  • count(*)과 같이 결과가 한 행으로 실행되는 경우 사용
      // MemberDao.java
      public class MemberDao {
          ...
          public int count() {
              Integer count = jdbcTemplate.queryForObject(
                      "select count(*) from MEMBER", Integer.class);
              return count;
          }
      }
    
  • queryForObject() 매서드의 두 번째 파라미터는 칼럼을 읽어올 때 사용할 타입을 지정
  • 실행 결과 칼럼이 두 개 이상이면 RowMapper를 파라미터로 전달해 결과 생성

    queryForObject() 매서드를 사용하려면 쿼리 실행 결과는 반드시 한 행이어야 함
    만약 행이 없거나, 두 개 이상이면 익셉션이 발생

2-4. JdbcTemplate을 이용한 변경 쿼리 실행

  • JdbcTemplate 클래스는 INSERT, UPDATE, DELETE 쿼리 실행을 위한 update() 매서드를 다음과 같이 제공
    • int update(String sql)
    • int update(String sql, Object… args)
  • update() 매서드는 쿼리 실행 결과로 변경된 행의 개수를 반환

      // MemberDao.java
      public class MemberDao {
          ...
          public void update(Member member) {
              jdbcTemplate.update(
                      "update MEMBER set NAME = ?, PASSWORD = ? where EMAIL = ?",
                      member.getName(), member.getPassword(), member.getEmail());
          }
    


2-5. PreparedStatementCreator를 이용한 쿼리 실행

앞선 예제 코드의 경우, 첫 번째, 두 번째 세 번째 파라미터의 값으로 각각 접근자를 이용해 인덱스 파라미터의 값을 전달
하지만 직접 인덱스 파라미터의 값을 설정해야 할 경우, 설정자를 이용해 설정 가능

  • PreparedStatementCreator를 인자로 받는 매서드를 이용해 아래와 같이 사용
      jdbcTemplate.update(new PreparedSatementCreator(){
              @Override
              public PreparedStatement createPreparedStatement(Connection con)
              throws SQLException{
                      // 파라미터로 전달받은 Connection을 이용해 PreparedStatement 생성
                      PreparedStatement pstmt = con.prepareStatement(
                          "insert into MEMBER (EMAIL, PASSWORD, NAME, REGDATE) values (?,?,?,?)");
                      // 인덱스 파라미터 값 설정
                      pstm.setString(1, member.getEmail());
                      pstm.setString(2, member.getPassword());
                      pstm.setString(1, member.getName());
                      pstm.setString(1, Timestamp.valueOf(member.getRegisterDateTime()));
                      // 생성한 PreparedStatement 객체 리턴
                      return pstm;
              }
      });
    


2-6. INSERT 쿼리 실행 시 KeyHolder를 이용해 자동 생성 키 값 구하기

  • MySQL의 AUTO_INCREMENT 칼럼을 가진 테이블에 값을 삽입하면 해당 칼럼의 값이 자동으로 생성되므로,
    다음과 같이 INSERT 쿼리에 자동 증가 칼럼에 해당하는 값이 지정되지 않음
      jdbcTemplate.update(
              "insert into MEMBER (EMAIL, PASSWORD, NAME, REGDATE) values (?,?,?,?)",
              member.getEmail(), member.getPassword(), member.getName(), 
              new Timestamp(member.getRegisterDateTime()));
    
  • 이러한 키 값을 KeyHolder를 사용해 다음과 같이 구할 수 있다.
      // MemberDao.java
      public class MemberDao {
          ...
          public void insert(Member member) {
              KeyHolder keyHolder = new GeneratedKeyHolder();
              jdbcTemplate.update(new PreparedStatementCreator() {
                  @Override
                  public PreparedStatement createPreparedStatement(Connection con)
                          throws SQLException {
                      // 파라미터로 전달받은 Connection을 이용해서 PreparedStatement 생성
                      PreparedStatement pstmt = con.prepareStatement(
                              "insert into MEMBER (EMAIL, PASSWORD, NAME, REGDATE) " +
                              "values (?, ?, ?, ?)",
                              new String[] { "ID" }); 
                                                      // 여기서 자동 증가하는 key값을 두 번째 파라미터로 전달 
                      // 인덱스 파라미터 값 설정
                      pstmt.setString(1, member.getEmail());
                      pstmt.setString(2, member.getPassword());
                      pstmt.setString(3, member.getName());
                      pstmt.setTimestamp(4,
                              Timestamp.valueOf(member.getRegisterDateTime()));
                      // 생성한 PreparedStatement 객체 리턴
                      return pstmt;
                  }
              }, keyHolder);
              Number keyValue = keyHolder.getKey();
              member.setId(keyValue.longValue());
          }
              ...
    
  • keyHolder 구현 클래스인 GeneratedKeyHolder 객체를 생성
  • preparedStatement의 두 번째 파라미터로 new String[] {“ID”}(자동 증가 칼럼)을 전달
  • update() 매서드에 두 번째 파라미터로 keyHolder를 전달

    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcTemplate.update(new PreparedStatementCreator(){…생략}, keyHolder);


여기까지 진행 후, Main 실행시, The server time zone value ‘KST’ ~~~ 라는 익셉션이 발생
MySQL 5.1.X 이후 KST 타임존을 인식하지 못하는 에러가 발생
/etc/mysql/mysql.condf.d 설정 변경(경로는 상이할 수 있음)

3. 트래잭션 처리


  • 트랜잭션(Transaction)이란?: 두 개 이상의 쿼리를 한 작업으로 실행할 때 사용
  • Commit, Rollback을 통해 전부 반영하거나 기존 상태로 되돌림
  • JDBC는 Connection의 setAutoCommit(false)을 이용해 다음과 같이 트랜잭션을 시작하고 반영하거나 취소함
      Connection conn = null;
      try{
          ...
          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){}
      }
    

위와 같은 방식은 코드로 직접 트랜잭션의 범위를 설정하기 때문에, 개발자가 커밋이나 롤백을 누락하기 쉬움
이에 다음에 나오는 스프링이 제공하는 방식을 사용

3-1. @Tracsactional을 이용한 트랜잭션 처리

  • 트랜잭션 범위에서 실행하고 싶은 매서드에 다음과 같이 @Transactional 어노테이션을 붙임
      // ChangePasswordService.java
      public class ChangePasswordService {
    
          private MemberDao memberDao;
            
          /* 어노테이션을 사용해 트랜잭션 범위 설정
          * 따라서 memberDao.selectByEmail()에서 실행하는 쿼리와
          * member.changePassword()에서 실행하는 쿼리는 한 트랜잭션에 묶임
          */
          @Transactional
          public void changePassword(String email, String oldPwd, String newPwd) {
              Member member = memberDao.selectByEmail(email);
              if (member == null)
                  throw new MemberNotFoundException();
    
              member.changePassword(oldPwd, newPwd);
    
              memberDao.update(member);
          }
          ...
      }
    
  • @Transactional 어노테이션의 올바른 작동을 위해 다음과 같은 설정이 필요
    • 플랫폼 트랜잭션 매니저(PlatformTransactionManager) Bean 설정
    • @Transactional 어노테이션 활성화 설정
        // AppCtx.java
        @Configuration
        // 다음 어노테이션을 통해 @Transactional 어노테이션 활성화
        @EnableTransactionManagement
        public class AppCtx {
      
        ....
        @Bean
            // 플랫폼 트랜잭션 매니저 Bean 설정
        public PlatformTransactionManager transactionManager() {
            DataSourceTransactionManager tm = new DataSourceTransactionManager();
            tm.setDataSource(dataSource());
            return tm;
        }
      


3-2. @Transactional과 프록시

  • 스프링은 @Transactional 어노테이션을 이용해 트랜잭션을 처리시, 내부적으로 AOP를 사용
  • 트랜잭션 처리는 프록시를 통해 이루어짐

      // MainForCPS.java
      public class MainForCPS {
    
          public static void main(String[] args) {
              AnnotationConfigApplicationContext ctx = 
                      new AnnotationConfigApplicationContext(AppCtx.class);
              /*
              * 아래 코드를 실행시, ChangePasswordService 객체 대신
              * 트랜잭션 처리를 위해 생성한 프록시를 리턴함
              */
              ChangePasswordService cps = 
                      ctx.getBean("changePwdSvc", ChangePasswordService.class);
              ...
      }
    

    위의 코드가 실행되기까지 과정은 다음과 같다.

    • MainForCPS -> 프록시 (changePasswordService) -> 플랫폼 트랜잭션 매니저 -> 실제 기능(ChansgePasswordService)
      프록시 객체는 @Transactional 어노테이션이 붙은 매서드를 호출하면 플랫폼 트랜잭션 매니저를 사용해 트랜잭션을 시작
    • 결과에 따라 커밋하거나 롤백

3-3. 트랜잭션 전파

  • 트랜잭션 전파: 트랜잭션이 이미 실행되고 있는데, 내부에서 또 다른 트랜잭션이 수행되는 경우
  • 스프링에서 @Transactional의 propagation 속성은 기본값이 Propagation.REQUIRED로서
    이미 수행된 트랜잭션이 있다면, 트랜잭션을 새로 생성하지 않음(기존 트랜잭션 그대로 사용)

@Transactional의 주요 속성

  • value / propagation / isolation / timeout

Propagation의 주요 속성

  • REQUIRED / MANDATORY / REQUIRES_NEW / SUPPORTS / NOT_SUPPORTED / NEVER / NESTED

Isolation의 주요 속성

  • DEFAULT / READ_UNCOMMITED / READ_COMMITED / REPEATABLE_READ / SERIALIZABLE