[스프링] 스프링5 프로그래밍 입문 - 8장 DB 연동

June·2021년 5월 26일
0

JDBC 프로그래밍의 단점을 보완하는 스프링

JDBC API를 이용하면 사실상 데이터 처리와는 상관 없는 구조적인 코드가 반복된다. 이를 줄이기 위해 템플릿 메서드 패턴과 전략 패턴을 함께 사용한다. 스프링은 이 두 패턴을 엮은 JdbcTemplate을 제공한다.

스프링의 장점 중 하나는 트랜잭션 관리가 쉽다는 것이다. 커밋과 롤백 처리는 스프링이 알아서 처리해준다.

스프링을 사용하면 트랜잭션을 적용하고 싶은 메서드에 @Transactional 애노테이션을 붙이기만 하면 된다.

프로젝트 준비

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
		http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>sp5</groupId>
  <artifactId>sp5-chap08</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <dependencies>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-context</artifactId>
      <version>5.0.2.RELEASE</version>
    </dependency>
    <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>5.1.45</version>
    </dependency>

    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-api</artifactId>
      <version>1.7.25</version>
    </dependency>

    <dependency>
      <groupId>ch.qos.logback</groupId>
      <artifactId>logback-classic</artifactId>
      <version>1.2.3</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.7.0</version>
        <configuration>
          <source>1.8</source>
          <target>1.8</target>
          <encoding>utf-8</encoding>
        </configuration>
      </plugin>
    </plugins>
  </build>

</project>

커넥션 풀이란?
실제 서비스 운영 환경에서는 서로 다른 장비를 이요해서 자바 프로그램과 DBMS를 실행한다. 자바 프로그램에서 DBMS로 커넥션을 생성하는데 비용이 매우 크다.

최초 연결에 따른 응답 속도 저하와 동시 접속자가 많을 때 발생하는 부하를 줄이기 위해 사용하는 것이 커넥션 풀이다. 커넥션 풀은 일절 개수의 DB 커넥션을 미리 만들어두는 기법이다. DB 커넥션이 필요한 프로그램은 커넥션 풀에서 커넥션을 가져와 사용한 뒤 커넥션을 다시 풀에 반납한다. 커넥션을 미리 생성해두기 때문에 커넥션을 사용하는 시점에서 커넥션을 생성하는 시간을 아낄 수 있다. 또한 동시 접속자가 많더라도 커넥션을 생성하는 부하가 적기 때문에 더 많은 동시 접속자를 처리할 수 있다. 커넥션도 일정 개수로 유지해서 DBMS에 대한 부하를 일정 수준으로 유지할 수 있게 해준다.

/src/sql/ddl.sql

create user 'spring5'@'localhost' identified by 'spring5';

create database spring5fs character set = utf8;

grant all privileges on spring5fs.* to 'spring5'@'localhost';

create table spring5fs.MEMBER (
  ID int auto_increment primary key,
  EMAIL varchar(255),
  PASSWORD varchar(100),
  NAME varchar(100),
  REGDATE datetime,
  unique key (EMAIL)
) engine = InnoDB character set = utf8;

MySQL 시작하기

제어판 -> 서비스

MySQl 실행 -> \sql -> \connect root@localhost -> 비밀번호 설정 (1234) -> 나머지는 위의 명령어 실행

cmd에서 아래 명령어 실행

C:\Users\injoo>mysql -u spring5 -p spring5fs
Enter password: *******
insert into MEMBER(EMAIL, PASSWORD, NAME, REGDATE) values ('madvirus@madvirus.net', '1234', 'cbk', now());

DataSource 설정

스프링이 제공하는 DB 연동 기능은 DataSource를 사용해서 DB Connection을 구한다. DB 연동에 사용할 DataSource를 스프링 빈으로 등록하고 DB 연동 기능을 구현한 빈 객체는 DataSource를 주입받아 사용한다.

AppCtx

package config;


import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@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/spring5fs?characterEncoding=utf8");
        ds.setUsername("spring5");
        ds.setPassword("spring5");
        ds.setInitialSize(2);
        ds.setMaxActive(10);
        return ds;
    }
}

커넥션 풀은 커넥션을 생성하고 유지한다. 커넥션 풀에 커넥션을 요청하면 해당 커넥션은 활성(active) 상태가 되고, 커넥션을 다시 커넥션 풀에 반환하면 유휴(idle) 상태가 된다.

DBQuery

package dbquery;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;

public class DbQuery {

    private DataSource dataSource;

    public DbQuery(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public int count() {
        Connection conn = null;
        try {
            conn = dataSource.getConnection(); // 풀에서 구함
            try (Statement stmt = conn.createStatement();
            ResultSet rs = stmt.executeQuery("select count(*) from MEMBER")) {
                rs.next();
                return rs.getInt(1);
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        } finally {
            if (conn != null) {
                try {
                    conn.close(); // 풀에 반환
                } catch (SQLException e) {

                }
            }
        }
    }
}

커넥션 사용이 끝나고 커넥션을 종료하면 실제 커넥션을 끊지 않고 풀에 반환한다. 풀에 반환된 커넥션은 다시 유휴 상태가 된다.

maxActive는 활성 상태가 가능한 최대 커넥션 개수를 지정한다. 활성 상태 커넥션이 40개인데 풀에 다시 커넥션을 요청하면 다른 커넥션이 반환될 때까지 대기한다. 이 대기 시간이 maxWait이다. 대기 시간 내에 풀에 반환된 커넥션이 있으면 해당 커넥션을 구하고, 대기 시간내에 반환된 커넥션이 없으면 익셉션이 발생한다.

커넥션 풀을 초기화할 때 최소 수준의 커넥션을 미리 생성하는 것이 좋다. 이때 생성할 커넥션 개수를 initialSize로 정한다.

커넥션 풀에 생성된 커넥션은 지속적으로 재사용된다. DBMS 설정에 따라 일정 시간 내에 쿼리를 실행하지 않으면 연결을 끊기도 한다. 커넥션 풀에 특정 커넥션이 5분 넘게 유휴 상태로 존재한다고 하자. 이 경우 DBMS는 해당 커넥션의 연결을 끊지만 커넥션은 여전히 풀 속에 남아있다. 이 상태에서 해당 커넥션을 풀에서 가져와 사용하면 연결이 끊어진 커넥션이므로 익셉션이 발생한다.

커넥션 풀의 커넥션이 유효한지 주기적으로 검사해야하는데, 이와 관련된 속성이 minEvictableidleTimeMills, timeBetweenEvictionRunsMillstestWhileIdle이다.

AppCtx

@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/spring5fs?characterEncoding=utf8");
        ds.setUsername("spring5");
        ds.setPassword("spring5");
        ds.setInitialSize(2);
        ds.setMaxActive(10);
        ds.setTestWhileIdle(true); //유휴 커넥션 검사
        ds.setMinEvictableIdleTimeMillis(100 * 60 * 3); //최소 유휴 시간 3분
        ds.setTimeBetweenEvictionRunsMillis(1000 * 10); //10초 주기
        return ds;
    }
}

JdbcTemplate을 이용한 쿼리 실행

스프링을 사용하면 DataSource나 Connection, Statement, ResultSet을 직접 사용하지 않고 JdbcTemplate을 이용해서 편리하게 쿼리를 실행할 수 있다.

MemberDao

package dbquery;

import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;

public class MemberDao {
    
    private JdbcTemplate jdbcTemplate;

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

AppCtx

package config;

import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import spring.MemberDao;

@Configuration
public class AppCtx {

    ... 

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

JdbcTemplate을 이용한 조회 쿼리 실행

JdbcTemplate 클래스는 SELECT 쿼리 실행을 위한 query() 메서드를 제공한다.
query() 메서드는 sql 파라미터로 전달받은 쿼리를 실행하고 RowMapper를 이용해서 ResultSet의 결과를 자바 객체로 변환한다.

RowMapper 인터페이스

public interface RowMapper<T> {
    T mapRow(ResultSet rs, int rowNum) throws SQLException;
}

RowMapper의 mapRow() 메서드는 SQL 실행결과로 구한 ResultSet에서 한 행의 데이터를 읽어와 자바 객체로 변환하는 매퍼 기능을 구현한다. RowMapper 인터페이스를 구현한 클래스를 작성할 수도 있지만 임의 클래스나 람다식으로 RowMapper의 객체를 생성해서 query() 메서드에 전달할 때도 많다.

MemberDao

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());
                    return member;
                }
            }, email);
        return results.isEmpty() ? null : results.get(0);
    }
    
    ... 
}

MemberDao

package spring;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;

public class MemberDao {

    ...

    public List<Member> selectAll() {
        List<Member> results = jdbcTemplate.query("select * from MEMBER", 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());
                return member;
            }
        });
        return results;
    }
}
    public int count() {
        Integer count = jdbcTemplate.queryForObject("select count(*) from MEMBER", Integer.class);
        return count;
    }

count(*) 쿼리는 결과가 한 행 뿐이니 쿼리 결과를 List로 받기보다는 Integer와 같은 정수 탕비으로 받으면 편리할 것이다. queryForObject() 메서드를 사용하려면 쿼리 실행 결과는 반드시 한 행이어야 한다.

JdbcTemplate을 이용한 변경 쿼리 실행

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

PreparedStatementCreator를 이용한 쿼리 실행

PreparedStatement의 set 메서드를 이용해서 직접 인덱스 파라미터의 값을 설정할 때도 있다. 이 경우 PreapredStatementCreator를 인자로 받는 메서드를 이용해서 직접 preapredStatement를 생성하고 설정해야 한다.

    public void update(Member member) {
//        jdbcTemplate.update("update MEMBER set NAME = ?, PASSWORD = ? where EMAIL = ?",
//            member.getName(), member.getPassword(), member.getEmail());
        jdbcTemplate.update(new PreparedStatementCreator() {
            @Override
            public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
                // 파라미터로 전달받은 Connection을 이용해서 preparedStatement 생성
                PreparedStatement pstmt = con.prepareStatement(
                    "inser into MEMBER (EMAIL, PASSWORD, NAME, REGDATE) values (?, ?, ?, ?)");
                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;
            }
        });

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

JdbcTemplate은 자동으로 생성된 키 값을 구할 수 있는 방법을 제공하고 있다. 그것은 바로 KeyHolder를 사용하는 것이다.

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

String 배열인 {"ID"}는 자동 생성되는 키 칼럼 목록을 지정할 때 사용한다.

MemberDao 테스트하기

MainForMemberDao

package main;

import config.AppCtx;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import spring.Member;
import spring.MemberDao;

public class MainForMemberDao {

    private static MemberDao memberDao;

    public static void main(String[] args) {
        AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(
            AppCtx.class);

        memberDao = ctx.getBean(MemberDao.class);

        selectAll();
        updateMember();
        insertMember();

        ctx.close();
    }

    private static void selectAll() {
        System.out.println("-----selectAll");
        int total = memberDao.count();
        System.out.println("전체 데이터: " + total);
        List<Member> members = memberDao.selectAll();
        for (Member m : members) {
            System.out.println(m.getId() + ":" + m.getEmail() + ":" + m.getName());
        }
    }

    private static void updateMember() {
        System.out.println("-------updateMember");
        Member member = memberDao.selectByEmail("madvirus@madvirus.net");
        String oldPw = member.getPassword();
        String newPw = Double.toHexString(Math.random());
        member.changePassword(oldPw, newPw);

        memberDao.update(member);
        System.out.println("암호 변경: " + oldPw + ">" + newPw);
    }

    private static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMddHHmmss");

    private static void insertMember() {
        System.out.println("----insertMember");

        String prefix = formatter.format(LocalDateTime.now());
        Member member = new Member(prefix + "@test.com", prefix, prefix, LocalDateTime.now());
        memberDao.insert(member);
        System.out.println(member.getId() + " 데이터 추가");
    }
}
-----selectAll
전체 데이터: 1
null:madvirus@madvirus.net:cbk
-------updateMember
암호 변경: 1234>0x1.cf0c3fc96aeaap-1
----insertMember
3 데이터 추가

스프링의 익셉션 변환 처리

SQL 문법이 잘못됐을 때 발생한 메세지를 보면 익셉션 클래스가 org.spring.framework.jdbc 패키지에 속한 BadSqlGrammarException 클래스임을 알 수 있다.

JDBC API를 사용하는 과정에서 SQLException이 발생하면 이 익셉션을 알맞은 DataAccessException으로 변환해서 발생한다.

예를 들어 MySQL용 JDBC 드라이버는 SQL 문법이 잘못된 경우 SQLException을 상속받은 MySQLSyntaxErrorException을 발생시키는데 JdbcTemplate은 이 익셉션을 DataAccessException을 상속받은 BadSqlGrammarExcepation으로 변환한다.

그렇다면 스프링은 왜 SQLException을 그대로 전파하지 않고 SQLException을 DataAccessException으로 변환할까?

주된 이유는 연동 기술에 상관없이 동일하게 익셉션을 처리할 수 있도록 하기 위함이다. 스프링은 JDBC뿐만 아니라 JPA, 하이버네이트 등에 대한 연동을 지원하고 MyBatis는 자체적으로 스프링 연동 기능을 제공한다. 그런데 각각의 구현기술마다 익셉션을 다르게 처리해야 한다면 개발자는 기술마다 익셉션 처리 코드를 작성해야 할 것이다.

트랜잭션 처리

두 개 이상의 쿼리를 한 작업으로 실행해야 할 때 사용하는 것이 트랜잭 션(transaction)이다. 트랜잭션은 여러 쿼리를 논리적으로 하나의 작업으로 묶어준다. 한 트랜잭션으로 묶인 쿼리 중 하나라도 실패하면 전체 쿼리를 실패로 간주하고 실패 이전에 실행한 쿼리를 취소한다.
쿼리 실행 결과를 취소하고 DB를 기존 상태로 되돌리는 것을 롤백(rollback)이라고 부른다. 반면에 트랜잭션으로 묶인 모든 쿼리가 성공해서 쿼리 결과를 DB에 실제로 반영하는 것을 커밋(commit)이라고 한다.

JDBC는 Connection의 setAutoCommit(false)를 이용해서 트랜잭션을 시작하고 commit()과 rollback()을 이용해서 트랜잭션을 반영(커밋)하거나 취소(롤백)한다.

@Transactional을 이용한 트랜잭션 처리

스프링이 제공하는 @Transactional 애노테이션을 사용하면 트랜잭션 범위를 매우 쉽게 지정할 수 있다.

스프링은 @Transactional 애노테이션이 붙은 changePassword() 메서드를 동일한 트랜잭션 범위에서 실행한다. @Transactional 애노테이션이 제대로 동작하려면 다음의 두 가지 내용을 스프링 설정에 추가해야 한다.

  1. 플랫폼 트랜잭션 매니저(PlatformTransactionManager) 빈 설정
  2. @Transactional 애노테이션 활성화 설정

AppCtx

package config;

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

PlatfromTransactionManager는 스프링이 제공하는 트랜잭션 매니저 인터페이스이다. 스프링은 구현기술에 상관없이 동일한 방식으로 트랜잭션을 처리하기 위해 이 인터페이스를 사용한다. JDBC는 DataSourceTransactionManager 클래스를 PlatformTransactionManager로 사용한다.

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

ChangePasswordService

public class ChangePasswordService {

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

    public void setMemberDao(MemberDao memberDao) {
        this.memberDao = memberDao;
    }
}

AppCtx

@Configuration
@EnableTransactionManagement
public class AppCtx {
   ...
    
    @Bean
    public ChangePasswordService changePwdSvc() {
        ChangePasswordService pwdSvc = new ChangePasswordService();
        pwdSvc.setMemberDao(memberDao());
        return pwdSvc;
    }
}

ChangePasswordService 클래스를 빈으로 추가했다.

MainForCPS

public class MainForCPS {

    public static void main(String[] args) {
        AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(
            AppCtx.class);

        ChangePasswordService cps = ctx.getBean("changePwdSvc", ChangePasswordService.class);

        try {
            cps.changePassword("madvirus@madvirus.net", "1234", "1111");
            System.out.println("암호를 변경했습니다.");
        } catch (MemberNotFoundException e) {
            System.out.println("회원 데이터가 존재하지 않습니다");
        } catch (WrongIdPasswordException e) {
            System.out.println("암호가 올바르지 않습니다");
        }

        ctx.close();
    }
}

커밋되는지 확인하기 위해 로그를 남겨보자. 스프링 5버전은 자체 로깅 모듈인 spring-jcl을 사용한다. 이 로깅 모듈은 직접 로그를 남기지 않고 다른 로깅 모듈을 사용해서 로그를 남긴다. 예를 들어 클래스 패스에 Logback이 존재하면 Logback을 이용해서 로그를 남기고 Log4j2가 존재하면 Log4j2를 이용해서 로그를 남긴다. 따라서 사용할 로깅 모듈만 클래스 패스에 추가해주면 된다.

    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-api</artifactId>
      <version>1.7.25</version>
    </dependency>

    <dependency>
      <groupId>ch.qos.logback</groupId>
      <artifactId>logback-classic</artifactId>
      <version>1.2.3</version>
    </dependency>

pom.xml에 추가

resources/logback.xml

<?xml version="1.0" encoding="UTF-8" ?>

<configuration>
  <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d %5p %c{2} - %m%n</pattern>
    </encoder>
  </appender>
  <root level="INFO">
    <appender-ref ref="stdout"/>
  </root>

  <logger name="org.springframework.jdbc" level="DEBUC"/>
</configuration>

2021-06-09 20:02:10,538 DEBUG o.s.j.d.DataSourceTransactionManager - Switching JDBC Connection [ProxyConnection[PooledConnection[com.mysql.jdbc.JDBC4Connection@290222c1]]] to manual commit
2021-06-09 20:02:10,553 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL query
2021-06-09 20:02:10,553 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL statement [select * from MEMBER where EMAIL = ?]
2021-06-09 20:02:10,585 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL update
2021-06-09 20:02:10,585 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL statement [update MEMBER set NAME = ?, PASSWORD = ? where EMAIL = ?]
2021-06-09 20:02:10,585 DEBUG o.s.j.c.JdbcTemplate - SQL update affected 1 rows
2021-06-09 20:02:10,585 DEBUG o.s.j.d.DataSourceTransactionManager - Initiating transaction commit
2021-06-09 20:02:10,585 DEBUG o.s.j.d.DataSourceTransactionManager - Committing JDBC transaction on Connection [ProxyConnection[PooledConnection[com.mysql.jdbc.JDBC4Connection@290222c1]]]
2021-06-09 20:02:10,585 DEBUG o.s.j.d.DataSourceTransactionManager - Releasing JDBC Connection [ProxyConnection[PooledConnection[com.mysql.jdbc.JDBC4Connection@290222c1]]] after transaction
2021-06-09 20:02:10,585 DEBUG o.s.j.d.DataSourceUtils - Returning JDBC Connection to DataSource
암호를 변경했습니다.

도대체 트랜잭션을 시작하고, 커밋하고, 롤백을 누가 어떻게 처리하는걸까? -> 프록시

@Transactional과 프록시

여러 빈 객체에 공통으로 적용되는 기능을 구현하는 방법으로 AOP가 나왔는데, 트랜잭션도 공통 기능 중 하나이다. 스프링은 @Transactional 애노테이션을 이용해서 트랜잭션을 처리하기 위해 내부적으로 AOP를 사용한다

실제로 @Transactional 애노테이션을 적용하기 위해 @EnableTransactionManagement 태그를 사용하면 스프링은 @Transactional 애노테이션이 적용된 빈 객체를 찾아서 알맞은 프록시 객체를 생성한다.

ChangePasswordService 클래스의 메서드에 @Transactional 애노테이션이 적용되어있으므로 스프링은 트랜잭션 기능을 적용한 프록시 객체를 생성한다. MainForCPS 클래스에서 getBean("changePwdSvc", ChangePasswordService.class) 코드를 실행하면, ChangePasswordService 객체 대신에 트랜잭션 처리를 위해 생성한 프록시 객체를 리턴한다.

@Transactional 애노테이션이 붙은 메서드를 호출하면 PlatformTransactionManager를 사용해서 트랜잭션을 시작한다. 트랜잭션을 시작한 후 실제 객체의 메서드를호출하고, 성공적으로 실행되면 트랜잭션을 커밋한다.

@Transactional 적용 메서드의 롤백 처리

@Transactional을 처리하기 위한 프록시 객체는 원본 객체의 메서드를 실행하는 과정에서 RuntimeException이 발생하면 트랜잭션을 롤백한다.

JdbcTemplate은 DB 연동 과정에 문제가 있으면 DataAccessException을 발생한다고 했는데 DataAccessException 역시 RuntimeException을 상속받고 있다. 따라서 JdbcTemplate의 기능을 실행하는 도중 익셉션이 발생해도 프록시는 트랜잭션을 롤백한다.

SQLException은 RuntimeException을 상속받고 있지 않으므로 SQLException이 발생하면 트랜잭션을 롤백하지 않는다. RuntimeException 뿐만 아니라 SQLException이 발생하는 경우에도 트랜잭션을 롤백하고 싶다면 @Transactional의 rollbackFor 속성을 사용해야 한다.

@Transactional의 주요 속성

@Transacional에도 속성 값을 줄 수 있다. 잘 사용하지는 않지만, 있다는 것만 알아두자.

트랜잭션 격리 레벨은 동시에 DB에 접근할 때 그 접근을 어떻게 제어할지에 대한 설정을 다룬다. 트랜잭션 격리 레벨을 SERIALIZABLE로 설정하면 동일 데이터에 100 개 연결이 접근하면 한 번에 한 개의 연결만 처리한다. 전반적인 응답 속도가 느려지는 문제가 발생한다.

트랜잭션 전파

@Transactional의 propagation 속성은 기본값이 Propagation.REQUIRED이다. REQUIRED는 현재 진행 중인 트랜잭션이 존재하면 해당 트랜잭션을 사용하고 존재하지 않으면 새로운 트랜잭션을 생성한다고 한다.

만약 Transaction 범위의 메서드를 처음 호출하면 새로운 트랜잭션을 시작하고, 그 메서드에서 다른 메서드를 호출하면 존재하는 트랜잭션을 그대로 사용한다.

만약 Transaction인 메서드에서 트랜잭션 범위가 아닌 메서드를 호출하면, 비록 안붙어있지만 트랜잭션 범위에서 쿼리를 실행할 수 있다.

0개의 댓글