본문 바로가기

Spring 프레임워크/실습

Spring의 DB

Spring에서는 JDBC, MyBatis, JPA 등 여러 가지 방법을 이용하여 다양한 데이터베이스에 접근할 수 있으며 Spring Data 프로젝트에서는 JDBC, JPA, MongoDB 등 다양한 DB 접근 모듈이 제공되고 있다. 이번 포스트에서는 이런 프로젝트가 아닌 일반 JDBC와 JdbcTemplate을 다루도록 하겠다.

JDBC

DriverManager

Java에서 데이터베이스에 접근할 수 있는 기술인 JDBC는 DriverManager라는 클래스를 사용하여 데이터베이스에 연결할 수 있다. 이 드라이버 매니저는 JDBC 드라이버를 관리하는 클래스로 애플리케이션은 이를 이용하여 여러 종류의 데이터베이스에 유연하게 접속할 수 있다. 이는 사전에 데이터베이스의 제조사들이 자신들의 데이터베이스에 접속하기 위한 드라이버를 미리 작성해두었기 때문이다.

 

그래서 다음처럼 드라이버를 불러오기만 하면 getConnection 메서드를 이용하여 해당 데이터베이스의 커넥션을 얻어와서 쿼리를 실행할 수 있다. 그러나 최근에는 자동으로 시스템 프로퍼티에 등록된 드라이버를 로드하기 때문에 해당 구문을 사용하지 않아도 잘 동작한다.

System.out.println("===== INITIATED JDBC CONNECTION =====");

// 쿼리에 필요한 객체 초기화
Board board = null;
Connection connection = null;
PreparedStatement preparedStatement = null;
ResultSet resultSet = null;

try {
    // mysql driver 등록.
    Class.forName("com.mysql.jdbc.Driver");
    // localhost의 3306 포트의 mysql의 spring5fs 데이터베이스로 spring5/spring5 계정으로 연결.
    connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/spring5fs", "spring5", "spring5");
    // 파라미터를 받을 수 있는 "SELECT * FROM BOARD WHERE WRITER=?" 쿼리 작성.
    preparedStatement = connection.prepareStatement("SELECT * FROM BOARD WHERE WRITER=?");
    preparedStatement.setString(1, "Newbie");
    // 쿼리 실행
    resultSet = preparedStatement.executeQuery();

    if(resultSet.next()){
        board = new Board(
            // 쿼리 실행 결과를 이용하여 객체 생성 및 출력
            resultSet.getLong("SEQ"),
            resultSet.getString("TITLE"),
            resultSet.getString("WRITER"),
            resultSet.getString("CONTENT"),
            resultSet.getTimestamp("REGDATE"),
            resultSet.getInt("CNT"));

        System.out.println(board);
    }
} catch (ClassNotFoundException e){
    System.out.println("===== FAILED JDBC CONNECTION(ClassNotFoundException) =====");
} catch (SQLException e){
    System.out.println("===== FAILED JDBC CONNECTION(SQLException) =====");
} finally {
    // 데이터베이스 연결 객체 할당 해제.
    if(resultSet != null){
        try {
            resultSet.close();
        } catch (SQLException e){
            System.out.println("===== FAILED JDBC CONNECTION CLOSING(SQLException) =====");
        }
    }

    if(preparedStatement != null){
        try {
            preparedStatement.close();
        }catch (SQLException e){
            System.out.println("===== FAILED JDBC CONNECTION CLOSING(SQLException) =====");
        }
    }

    if(connection != null){
        try {
            connection.close();
        }catch (SQLException e){
            System.out.println("===== FAILED JDBC CONNECTION CLOSING(SQLException) =====");
        }
    }
}

Class.forName을 이용하는 이유는 여기에 잘 설명되어 있다. 클래스를 메모리에 적재하여 해당 클래스의 static 블록이 실행되고 해당 블록에서 자기 자신을 드라이버 매니저에 등록하는 과정을 거치기 때문에 위처럼 메모리에 불러주기만 해도 사용할 수 있는 것이다. 물론 특별한 경우가 아닌 이상 드라이버에 등록되어 있기 때문에 굳이 사용할 일은 없을 것이다.

 

드라이버를 불러왔다면 드라이버 매니저의 getConnection 메서드를 이용하여 커넥션 객체를 얻어올 수 있다. 이때 사용하는 "jdbc:mysql://localhost:3306/spring5fs" 같은 특이한 URL 프로토콜은 JDBC에서 localhost 주소의 3306 포트에 위치한 MySQL 데이터베이스의 spring5fs 데이터베이스를 이용하겠다는 것이다. 드라이버 매니저는 이 URL을 이용하여 적절한 드라이버를 찾을 수 있다.

MariaDB [spring5fs]> select * from board;
+-----+-------------+--------+-----------------------------+---------------------+------+
| SEQ | TITLE       | WRITER | CONTENT                     | regdate             | CNT  |
+-----+-------------+--------+-----------------------------+---------------------+------+
|  15 | Hello World | Newbie | I'm here with my laptop.    | 2021-03-04 17:27:16 |    0 |
|  18 | Cruel World | Newbie | I'm here without my laptop. | 2021-03-19 15:29:42 |    0 |
+-----+-------------+--------+-----------------------------+---------------------+------+
2 rows in set (0.000 sec)

현재 데이터베이스에는 위와 같은 레코드가 저장되어 있으며 위의 코드를 실행시키면 다음처럼 정상적으로 데이터를 읽어서 출력시키는 것을 볼 수 있다.

DataSource

그러나 자바 공식 문서에서는 이렇게 직접 드라이버 매니저에서 접근하기보다 DataSource 인터페이스를 활용하는 것을 권장하고 있다. 말 그대로 data의 source, 즉 데이터베이스 서버에 접근하는 역할을 담당하는 인터페이스로 이 역시 여러 데이터베이스 제조사에 의해 미리 작성되어 있으며 크게 다음과 같은 세 가지가 있다.

  • Connection 객체 반환.
  • Connection Pool을 지원하는 Connection 객체 반환.
  • Distributed Transaction과 Connection Pool을 지원하는 Connection 객체 반환.

기본적으로 데이터베이스에 쿼리를 실행할 수 있는 커넥션 객체를 반환하지만 추가적인 기능(커넥션 풀, 분산 트랜잭션 등)을 지원하는 DataSource 구현체를 제공하기도 하는데 대표적으로 Tomcat JDBC가 초기 커넥션 풀 크기나 최대 커넥션 개수, 커넥션 유효 검사 기능 등 다양한 커넥션 풀 관리 기능을 제공하고 있다. 사용하는 것은 드라이버 매니저와 비슷하게 getConnection 메서드를 이용하여 커넥션 객체를 얻어서 쿼리를 실행한다.

MysqlDataSource dataSource = new MysqlDataSource();
dataSource.setServerName("localhost");
dataSource.setPort(3306);
dataSource.setDatabaseName("spring5fs");
...

try {
    connection = dataSource.getConnection("spring5", "spring5");
    preparedStatement = connection.prepareStatement("SELECT * FROM BOARD WHERE WRITER=?");
    ...    

실행 결과 정상적으로 출력되는 것을 볼 수 있다.

DriverManager와 DataSource로 얻은 커넥션 객체는 동일한 타입이기 때문에 어렵지 않게 적용할 수 있다.

 

그런데 데이터베이스에서 데이터를 읽어오는 간단한 작업만 하려고 해도 비즈니스 로직보다 예외 처리, 후처리가 더 많은 것을 볼 수 있다. 당장 finally 블록만 보더라도 Connection, PreparedStatement, ResultSet을 일일이 close 해주고 또 close 할 때 발생할 수 있는 예외를 처리해줘야 하며 매 쿼리마다 이런 과정을 반복해야 하기 때문에 영 번거로운 게 아닌데 이를 어떻게 할 수 있을까? 이는 템플릿 메서드 패턴이 적용된 스프링의 JdbcTemplate을 사용하면 부담을 덜 수 있다.

JdbcTemplate

스프링에서는 기존에 JDBC를 그냥 사용할 때 겪었던 문제점(반복되는 try-catch, 자원 해제 등)을 해결할 수 있는 JdbcTemplate이란 클래스를 제공한다.

...
List<ResultObject> results = jdbcTemplate.query(
                                 "...", // Query here
                                 (ResultSet rs, int rowNum) -> { 
                                     ...
                                 }, // Row Mapper here
                                 ...); // Parameter here
return results;

JdbcTemplate 객체를 사용하면 위처럼 쿼리, 해당 쿼리의 결과로 수행할 작업, 쿼리의 파라미터만 있어도 간단하게 실행할 수 있다. 이전처럼 커넥션 객체를 얻어오고 해제하는 작업은 내부적으로 처리하고 있기 때문에 사용자가 따로 구현할 필요가 없어 비즈니스 로직에 좀 더 집중할 수 있다는 장점이 있다.

 

스프링에서 JdbcTemplate을 사용하려면 어쨌든 데이터베이스와 커넥션을 맺어야 하기 때문에 JDBC처럼 DataSource 객체가 필요하다. 그러므로 다음처럼 생성자로 DataSource 인터페이스의 구현체를 넘겨줘야 한다. 여기서는 MySQL 데이터베이스를 사용하고 있기 때문에 MysqlDataSource 객체를 넘겨준다.

@Bean
public MysqlDataSource dataSource(){
  MysqlDataSource mysqlDataSource = new MysqlDataSource();
  mysqlDataSource.setUser("spring5");
  mysqlDataSource.setPassword("spring5");
  mysqlDataSource.setDatabaseName("spring5fs");
  mysqlDataSource.setServerName("localhost");
  mysqlDataSource.setPort(3306);
  return mysqlDataSource;
}

@Bean
public JdbcTemplate jdbcTemplate(){
  return new JdbcTemplate(dataSource());
}

이전의 MysqlDataSource와 연동하여 간편하게 사용할 수 있으며 이름부터 JdbcTemplate인 만큼 사용하는 쿼리도 Jdbc와 비슷하기 때문에 쿼리 작성에도 큰 어려움이 없다.

public List<Board> readAllByWriter(String writer){
    return jdbcTemplate.query("SELECT * FROM BOARD WHERE WRITER=?",
        (rs, rowNum) -> {
            return new Board(
                rs.getLong("SEQ"),
                rs.getString("TITLE"),
                rs.getString("WRITER"),
                rs.getString("CONTENT"),
                rs.getTimestamp("REGDATE"),
                rs.getInt("CNT"));
            },
            writer);
}

이전에 JDBC를 사용했을 때는 온갖 객체를 생성하고 해제하고 예외처리하는 등 반복되고 복잡한 부분이 많았는데 JdbcTemplate에서는 그런 일 없이 간편하게 사용할 수 있는 것을 볼 수 있다.

class BoardRowMapper implements RowMapper<Board> {

    @Override
    public Board mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Board(
            rs.getLong("SEQ"),
            rs.getString("TITLE"),
            rs.getString("WRITER"),
            rs.getString("CONTENT"),
            rs.getTimestamp("REGDATE"),
            rs.getInt("CNT"));
    }
}

두 번째 매개변수로 사용되는 람다식은 원래 RowMapper라는 인터페이스를 구현한 클래스인데 한두 번 사용된다면 람다식으로 적어도 좋지만 여러 곳에서 같이 사용된다면 위처럼 별도의 클래스로 분리하여 필요할 때마다 매개변수로 전달하여 사용할 수도 있다.

 

테이블에 있는 각 레코드들은 모두 동일한 필드를 갖고 있기 때문에 위처럼 각 레코드를 담는 Board 클래스를 작성해서 쿼리 결과를 해당 클래스의 객체들로 매핑, 반환하도록 하는 것이 RowMapper의 역할이다. 쿼리 결과의 각 컬럼값은 자료형을 알 수 없기 때문에 ResultSet 객체의 getLong, getString 메서드를 이용하여 Long 타입이나 String 타입으로 각각 읽어올 수 있다.

query vs queryForObject

JdbcTemplate에서 쿼리 결과를 받아올 때 쿼리에 따라 여러 행의 레코드가 반환되거나 집계 함수로 인해 항상 한 행의 레코드만 반환될 수 있다. 특히 후자의 경우 항상 정수 값이나 실수 값으로 반환되기 때문에 굳이 RowMapper를 정의해서 매핑할 필요가 필요 없는데 이럴 땐 queryForObject라는 메서드를 사용할 수 있다.

public int countAll(){
    return jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM BOARD",
        Integer.class);
}

예를 들어 위와 같은 COUNT 집계 함수의 결과는 항상 하나의 레코드만 존재한다. 그리고 항상 정수 값으로 반환되기 때문에 위처럼 queryForObject 메서드를 이용하여 쿼리 결과를 Integer, 즉 정수형으로 변환하여 반환할 수 있다.

MariaDB [spring5fs]> select * from board;
+-----+-------------+--------+--------------------------+---------------------+------+
| SEQ | TITLE       | WRITER | CONTENT                  | regdate             | CNT  |
+-----+-------------+--------+--------------------------+---------------------+------+
|  15 | Hello World | Newbie | I'm here with my laptop. | 2021-03-04 17:27:16 |    0 |
|  18 | Cruel World | Newbie | World                    | 2021-03-19 17:11:15 |    0 |
|  19 | Hello!      | Guest  | World                    | 2021-03-19 17:10:36 |    0 |
+-----+-------------+--------+--------------------------+---------------------+------+
3 rows in set (0.000 sec)

MariaDB [spring5fs]> select count(*) from board;
+----------+
| count(*) |
+----------+
|        3 |
+----------+
1 row in set (0.000 sec)

위처럼 3개의 레코드가 존재하는 테이블의 경우 "SELECT COUNT(*) FROM BOARD" 쿼리를 실행시키면 단 하나의 컬럼 count(*)에 3이 담겨서 반환된다. 이는 정수형으로 변환될 수 있기 때문에 두 번째 매개변수로 전달된 정수형의 래퍼 클래스인 Integer로 변환되어 반환된다.

MariaDB [spring5fs]> select count(*), max(seq) from board;
+----------+----------+
| count(*) | max(seq) |
+----------+----------+
|        3 |       19 |
+----------+----------+
1 row in set (0.001 sec)

그런데 만약 위처럼 레코드에 여러 컬럼이 있다면 어떨까? 이 경우 그냥 정수나 실수 값으로 변환할 수가 없기 때문에 매핑이 필요하다. 이 경우는 query 메서드처럼 RowMapper를 구현해서 전달해줄 필요가 있다.

public int[] countAllAndMaxSeq(){
    return jdbcTemplate.queryForObject(
        "SELECT COUNT(*) as COUNT, MAX(SEQ) as MAX_SEQ FROM BOARD",
        (rs, rowNum) -> {
            return new int[]{rs.getInt("COUNT"), rs.getInt("MAX_SEQ")};
        });
}

위의 예시에서는 두 개의 컬럼에 있는 데이터를 배열에 담아 반환하였다.

 

좀 헷갈릴 수 있지만 queryForObject 메서드는 query 메서드와 달리 오직 한 행의 레코드만 존재해야 한다.

public Board readSingleByWriter(String writer){

    return jdbcTemplate.queryForObject(
        "SELECT * FROM BOARD WHERE WRITER=?",
        boardRowMapper,
        writer);
}

위처럼 사용했을 때 "WRITER=?" 조건에 맞는 레코드가 두 개 이상 혹은 하나도 반환되지 않는다면 다음처럼 결과의 크기가 부적절하다는 에러와 함께 IncorrectResultSizeDataAccessException이 발생한다.

update

위의 query, queryForObject 메서드는 데이터를 조회하는 SELECT 쿼리에 사용한다. 그렇다면 CRUD의 CUD, 즉 INSERT(Create), UPDATE(Update), DELETE(Delete)는 어떻게 사용할 수 있을까? 이때는 update 메서드를 사용한다.

public void insert(String title, String writer, String content){
    jdbcTemplate.update(
        "INSERT INTO BOARD(TITLE, WRITER, CONTENT, REGDATE) VALUES (?, ?, ?, ?)",
        title, writer, content, Timestamp.valueOf(LocalDateTime.now()));
}

쿼리문 특성상 조회가 아닌 경우 반환 값이 따로 없기 때문에 이전처럼 RowMapper는 필요하지 않고 쿼리와 매개 변수만 잘 맞춰서 전달해주면 된다. 대신 실행된 쿼리에 영향을 받은 레코드의 개수가 update 메서드의 반환 값으로 전달된다.

memberService.insert("Inserted title", "Inserted writer", "Inserted content");
System.out.printf("Inserted record: %s\n", memberService.readOneByWriter("Inserted writer"));

memberService.updateByWriter("Edited title", "Inserted writer", "Edited content");
System.out.printf("Inserted record: %s\n", memberService.readOneByWriter("Inserted writer"));

memberService.deleteByWriter("Inserted writer");
System.out.printf("Is 'Inserted writer' not exists?: %s\n", memberService.readAllByWriter("Inserted writer").isEmpty());

정상적으로 삽입, 수정, 삭제된 것을 볼 수 있다.

execute

SQL 언어는 크게 DML, DDL, DCL 그리고 TCL이 있다. 이 중 트랜잭션에 사용되는 TCL과 query, update로 다룬 DML을 제외하고 남은 DDL, DCL 즉 테이블 생성이나 권한 부여는 execute 메서드로 처리할 수 있다.

public void createTable(){
    jdbcTemplate.execute("CREATE TABLE DELETE_ME (" +
        "ID INT," +
        "PASSWORD VARCHAR(50))");
}
MariaDB [spring5fs]> describe delete_me;
+----------+-------------+------+-----+---------+-------+
| Field    | Type        | Null | Key | Default | Extra |
+----------+-------------+------+-----+---------+-------+
| ID       | int(11)     | YES  |     | NULL    |       |
| PASSWORD | varchar(50) | YES  |     | NULL    |       |
+----------+-------------+------+-----+---------+-------+
2 rows in set (0.020 sec)

정상적으로 테이블이 생성된 것을 볼 수 있다.

PreparedStatementCreator

위의 JDBC, JdbcTemplate에서는 쿼리문과 쿼리문에 사용되는 파라미터를 동시에 전달했지만 이전처럼 PreparedStatement를 사용해서 쿼리를 작성할 수 있다. 정확히는 PreparedStatementCreator 인터페이스를 구현한 클래스의 객체를 쿼리 메서드에 전달해야 한다. 

List<Board> boards = jdbcTemplate.query(
        new PreparedStatementCreator() {
            @Override
            public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
                PreparedStatement preparedStatement = con.prepareStatement(
                        "SELECT * FROM BOARD WHERE WRITER=?");
                preparedStatement.setString(1, writer);
                return preparedStatement;
            }
        },
        boardRowMapper);
return boards.isEmpty() ? null : boards.get(0);

위의 코드처럼 익명 객체로 PreparedStatementCreator 인터페이스를 구현한 객체를 생성하여 createPreparedStatement 메서드를 재정의하거나 RowMapper 때처럼 람다식으로 작성할 수 있다. 이 클래스에서 재정의하는 createPreparedStatement 메서드는 매개변수로 커넥션 객체를 받는데 이는 이전에 JDBC를 직접 사용했을 때 드라이버 매니저나 데이터 소스에서 얻어오던 그 커넥션 객체다. 따라서 prepareStatement 메서드를 사용하여 기존의 PreparedStatement 방식으로 쿼리를 실행할 수 있다.

Generated Keys

컬럼 중 AUTO_INCREMENT 속성이 달린 컬럼은 레코드 삽입 시 따로 값을 지정하지 않아도 테이블에서 자동으로 오름차순으로 부여받는다. 주로 테이블 레코드의 고윳값, Primary Key를 지정하는 데 사용되는데 JdbcTemplate에서 update 메서드로 삽입한 레코드의 키를 알려면 어떻게 해야 할까? 이는 KeyHolder라는 것을 사용한다.

 

공식 문서를 참고하면 update 메서드에는 다음과 같은 시그니처가 있다.

public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) throws DataAccessException

첫 번째 매개변수로는 위에서 사용했던 PreparedStatementCreator 객체를 받으며 두 번째 매개변수로는 KeyHolder 객체를 받는다. 이 KeyHolder는 말 그대로 테이블에 레코드를 삽입할 때 자동으로 생성되는 키 값을 담고 있는 객체로 PreparedStatementCreator 객체에서 별도로 지정되는 자동 생성(MySQL에서는 AUTO_INCREMENT) 컬럼 값을 담는다. 아래 코드를 보자.

public int insert(String title, String writer, String content) {
    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcTemplate.update(con -> {
                PreparedStatement preparedStatement = con.prepareStatement(
                        "INSERT INTO BOARD(TITLE, WRITER, CONTENT, REGDATE) VALUES (?, ?, ?, ?)",
                        new String[]{"SEQ"});
                preparedStatement.setString(1, title);
                preparedStatement.setString(2, writer);
                preparedStatement.setString(3, content);
                preparedStatement.setTimestamp(4, Timestamp.valueOf(LocalDateTime.now()));
                return preparedStatement;
            }
            , keyHolder);

    return keyHolder.getKey().intValue();
}

이전과 달리 prepareStatement 메서드의 매개변수로 문자열 배열이 전달된 것을 볼 수 있다. 이 배열에 들어간 문자열들이 테이블에서 자동 생성될 컬럼들의 이름으로 해당 컬럼에 생성된 값들이 update 메서드에 전달된 KeyHolder 객체에 전달된다. 그렇기 때문에 getKey 메서드로 Number 자료형의 값을 꺼내 쓸 수 있다.

int insertedRecords = memberService.insert("Inserted title", "Inserted writer", "Inserted content");
System.out.printf("Inserted record: %s\n", memberService.readOneByWriter("Inserted writer"));
System.out.printf("- ID: %d\n", insertedRecords);
MariaDB [spring5fs]> select * from board;
+-----+--------------+-----------------+--------------------------+---------------------+------+
| SEQ | TITLE        | WRITER          | CONTENT                  | regdate             | CNT  |
+-----+--------------+-----------------+--------------------------+---------------------+------+
|  15 | Hello World  | Newbie          | I'm here with my laptop. | 2021-03-04 17:27:16 |    0 |
|  18 | Cruel World  | Newbie          | World                    | 2021-03-19 17:11:15 |    0 |
|  19 | Hello!       | Guest           | World                    | 2021-03-19 17:10:36 |    0 |
|  32 | Edited title | Inserted writer | Edited content           | 2021-03-20 00:26:24 |    0 |
+-----+--------------+-----------------+--------------------------+---------------------+------+
4 rows in set (0.000 sec)

들여쓰기 때문에 조금 헷갈릴 수 있지만 자동 생성되는 컬럼을 지정하는 문자열 배열은 커넥션 객체의 prepareStatement 메서드에, KeyHolder 객체는 JdbcTemplate의 update 메서드에 들어간다.

예외 처리

이전에 JDBC를 사용할 때 try-catch로 처리하던 SQLException은 스프링에서 DataAccessException이란 예외 또는 그 자식 클래스로 변환되어 처리된다. 스프링에서는 JDBC만 사용하는 게 아니라 앞서 언급했던 JPA(hibernate), MyBatis 등도 사용할 수 있는데 각 기술마다 데이터베이스 접근 시 발생할 수 있는 예외가 모두 다르다. 만약 이를 일일이 처리해줘야 한다면 구현 기술마다 코드가 달라질 것이고 관리하기도 까다롭기 때문에 스프링에서는 이를 추상화시켜서 자체적인 예외 타입으로 변환하는 것이다.

 

이렇게 예외를 추상화하는 목적은 사용자에게 데이터베이스 접근 기술에 의존적인 예외 처리가 아니라 세부적인 내용은 몰라도 사용자가 어떤 종류의 예외가 발생했는지를 인지하고 그에 따른 예외 처리를 수행할 수 있도록 하기 위해서다. 예를 들어 위에서 보던 JDBC의 SQLException은 스프링의 DataAccessException의 자식 클래스인 BadSqlGrammarException으로 변환된다. 이는 실행된 SQL 쿼리가 올바르지 않을 때 발생하는 예외로 이름에서도 이를 유추할 수 있다.

 

어쨌든 발생하는 예외를 처리하려면 JdbcTemplate의 메서드를 사용할 때 try-catch로 감싸야 하지만 직접 JDBC를 사용할 때와 달리 예외 처리가 강제되지 않는다는 이점이 있다.

@Transactional

데이터베이스에 실행하는 쿼리, 즉 트랜잭션이 중간에 실패하거나 올바르지 않아서 ACID 원칙을 깨는 경우 해당 트랜잭션의 전체 쿼리가 데이터베이스에 반영되지 않고 취소되어야 한다. 이를 위해서는 JDBC에서 커넥션 객체의 setAutoCommit 메서드를 이용하여 자동 커밋을 비활성화하고 예외가 발생했다면 rollback 메서드로 롤백을, 예외가 발생하지 않았다면 commit 메서드를 이용하여 데이터베이스에 변경사항을 커밋해야 한다.

 

하지만 스프링에서는 @Transactional이라는 어노테이션을 제공하여 자동으로 롤백과 커밋을 처리한다. 정확히는 트랜잭션 범위를 해당 어노테이션이 붙은 메서드로 지정하여 해당 메서드 내부의 작업을 하나의 트랜잭션으로 묶어주는 것이다. 이 어노테이션을 적용하면 개발자는 쿼리 작성과 데이터 처리에 좀 더 집중할 수 있다. 이렇게 '트랜잭션 처리'와 '비즈니스 로직'으로 관심사를 나누는 것은 이전 포스트에서 다뤘던 AOP의 일종이라고 할 수 있다. 이 어노테이션이 적용된 클래스들에 대해 프록시 객체를 만들어서 요청을 대신 처리하며 트랜잭션을 커밋하거나 롤백하는 등 관리할 수 있는 것이다.

 

이 어노테이션을 사용하려면 스프링 설정 클래스에서 @EnableTransactionManagement 어노테이션을 붙여서 @Transactional 어노테이션을 활성화시키고 플랫폼 트랜잭션 매니저를 Bean 객체로 등록해야 한다. 대부분 설정 클래스(@Configuration)에서 진행하며 다음처럼 등록할 수 있다.

@Bean
public PlatformTransactionManager transactionManager(){
    return new DataSourceTransactionManager(dataSource());
}

PlatformTransactionManager는 스프링에서 제공하는 인터페이스로 getTransaction, rollback, commit 메서드가 정의되어 있다. 스프링의 트랜잭션 관리의 가장 기본적인 인터페이스며 이 인터페이스를 구현한 각 데이터베이스의 트랜잭션 매니저 클래스가 스프링에 제공된다. 대표적으로 JDBC의 DataSourceTransactionManager가 있다.

 

이런 트랜잭션 매니저를 직접 메서드 호출하며 사용하여 트랜잭션을 관리(Programmatic)할 수도 있고 위에서 사용한 것처럼 @Transactional 어노테이션을 붙여서 트랜잭션을 관리(Declarative)할 수도 있다. 대개 사용자의 애플리케이션이 이를 직접 사용할 일은 별로 없고 대신 데이터베이스에 맞는 트랜잭션 매니저를 컨테이너에 등록하여 사용한다.

 

Declarative 방식의 트랜잭션 관리는 스프링의 AOP를 기반으로 이루어진다. 이전 포스트에서 AOP를 이용하여 메서드의 실행 전후로 공통 기능을 실행했던 것처럼 트랜잭션 범위로 지정된 메서드를 프록시 객체가 실행하면서 커밋과 롤백을 적용하는 것이다. 프록시 객체는 TransactionInterceptor라는 클래스를 사용하여 PlatformTransactionManager와 상호작용하며 트랜잭션 범위로 지정된 메서드 전후로(around) 트랜잭션을 적용한다. 그래서 프록시 객체가 트랜잭션 매니저, PlatformTransactionManager에 정의된 getTransaction 메서드를 이용하여 트랜잭션(TransactionStatus)을 얻어오고 비즈니스 로직을 수행하다가 성공 여부에 따라 트랜잭션을 commit 하거나 rollback 하는 것이다.

Transaction Propagation

@Transactional
public Board readSingleByWriter(String writer){
    List<Board> boards = jdbcTemplate.query(new PreparedStatementCreator() {
        @Override
        public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
            PreparedStatement preparedStatement = con.prepareStatement(
                "SELECT * FROM BOARD WHERE WRITER=?");
            preparedStatement.setString(1, writer);
            return preparedStatement;
        }
    }, boardRowMapper);
    return boards.isEmpty() ? null : boards.get(0);
}
    

@Transactional
public void updateByWriter(String title, String writer, String content){
    if(readSingleByWriter(writer) != null){
        jdbcTemplate.update(
                "UPDATE BOARD SET TITLE=?,CONTENT=? WHERE WRITER=?",
                title, content, writer);
    }        
}

스프링의 @Transactional은 기본적으로 이미 진행 중인 트랜잭션이 있다면 해당 트랜잭션에 현재 작업을 포함하고 그렇지 않다면 새로운 트랜잭션을 생성한다. 예를 들어 위와 같은 코드에서는 updateByWriter 메서드에서 readSingleByWriter 메서드를 호출하고 있다. 두 메서드는 모두 @Transactional 어노테이션이 붙어있는데 updateByWriter에서 시작한 트랜잭션은 readSingleByWriter에서 같이 사용한다. 즉 두 메서드가 하나의 트랜잭션으로 묶이는 것이다.

 

이를 트랜잭션의 전파라고 하는 것 같은데 이에 대해서는 좀 더 조사해볼 필요가 있을 것 같다.

 

 

[참고 | docs.oracle.com/javase/8/docs/api/java/sql/DriverManager.html]

[참고 | docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jdbc/core/JdbcTemplate.html]

[참고 | dzone.com/articles/how-does-spring-transactional]

[참고 | stackoverflow.com/questions/1099025/spring-transactional-what-happens-in-background]

[참고 | docs.spring.io/spring-framework/docs/4.2.x/spring-framework-reference/html/transaction.html#transaction-declarative]