본문 바로가기

Java/Spring

Spring 5 입문: Chapter 08B. JdbcTemplate을 사용한 업데이트 SQL, 트랜잭션(Transaction) 관리

더보기

이 프로젝트의 개발 환경

  • 개발 언어 및 개발 환경
    • OpenJDK 12
    • Spring: spring-context 5.0.2.RELEASE
    • Spring: spring-jdbc: 5.0.2.RELEASE
    • Tomcat: tomcat-jdbc: 8.5.27
    • MySQL: mysql-connector-java: 8.0.22
    • Gradle 7.3
  • 기타 환경
    • macOS Sonoma 14.1.1
    • IntelliJ IDEA 2020.3 Ultimate Edition
    • MySQL 8.2.0

JdbcTemplate을 사용한 Update 쿼리

Insert Update Delete 쿼리는 update() 메소드를 사용합니다.

이전 예제의 sp5-chap08 프로젝트 chap08 패키지에서 MemberDao 클래스를 수정합니다.

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

PreparedStatementCreator를 사용한 쿼리 실행

지금까지 예제에서 작성한 코드는 쿼리 실행을 위한 인자를 함수의 인자로 전달했습니다.

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

PreparedStatement를 사용하면 쿼리 실행을 위한 인자를 대신 전달 할 수 있습니다.

PreparedStatement를 사용하려면 PreparedStatemenCreator인터페이스를 람다로 구현 할 수 있습니다.

chap08 패키지의 MemberDao 클래스를 수정합니다.

public class MemberDao
{
	public void insert(Member member)
	{
		jdbcTemplate.update(new PreparedStatementCreator() {
			@Override
			public PreparedStatement createPreparedStatement(Connection con)
				throws SQLException
			{
				PreparedStatement pstmt = con.prepareStatement("insert 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()));
				return pstmt;
			}
		});
	}
}
코드 비고
Line 10 con.prepareStatement() Connection 객체로부터 PreparedStatement를 생성합니다.
Line 11:14 pstmt.set*() PreparedStatement 객체에 쿼리 실행을 위한 인자를 입력합니다.

PreparedStatement는 update() 메소드와 query() 메소드에서 사용 할 수 있습니다.

@Override
public int update(PreparedStatementCreator psc) throws DataAccessException { ... }

@Override
public int update(final PreparedStatementCreator psc, final KeyHolder generatedKeyHolder) { ... }

@Override
public <T> List<T> query(PreparedStatementCreator psc, RowMapper<T> rowMapper) throws DataAccessException { ... }

위 메소드에서 KeyHolder자동 생성되는 키 값을 구할 때 사용합니다.

KeyHolder를 사용한 Insert 쿼리 작성

MySQL의 AUTO_INCREMENT 컬럼은 행이 추가되면 자동으로 값이 할당됩니다.

create table 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;

따라서 코드에서 Insert 쿼리를 실행할 때는 MEMBER.ID 인자로 AUTO_INCREMENT 컬럼의 값을 할당하지 않습니다.

이때 쿼리 실행 결과로는 ID 컬럼의 값을 구할 수 없습니다. update() 메소드는 변경된 행의 개수를 리턴하기 때문입니다.

JdbcTemplate에서 자동으로 생성된 키 값을 구하려면 KeyHolder를 사용해야 합니다.

public class MemberDao
{
	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());
	}
}
코드 비고
Line 5 new GeneratedKeyHolder() GeneratedKeyHolder 객체를 생성합니다.
이 클래스는 자동 생성된 키 값을 구하는 KeyHolder 인터페이스를 구현합니다.
Line 12 new String[] { "ID" } 자동 생성되는 키 컬럼 목록을 지정합니다.
Line 7:19 jdbcTemplate.update() update() 메소드에서 KeyHolder 객체를 인자로 전달합니다.
Line 21 keyHolder.getKey() KeyHolder 객체에서 키 값을 구합니다.

예제 프로젝트를 실행합니다.

앱에서 사용 가능한 명령문에 따라서 MySQL에 데이터가 생성되고 업데이트 되는 과정을 확인합니다.

스프링의 트랜잭션 처리

어떤 작업은 SQL이 연속으로 실행되어야 합니다. SQL이 연속 실행되는 작업은 하나의 트랜잭션(Transaction)으로 관리해야합니다.

만약 하나의 SQL이라도 실패하면 모든 SQL을 실패로 간주하고 처음 상태로 되돌리는 롤백(Rollback)을 수행합니다.

반면 모든 SQL이 성공하면 DB에 실제로 반영되는 커밋(Commit)을 수행합니다.

Connection conn = null;
try {
	conn = ds.getConnection();
	conn.setAutoCommit(false);
	try (Statement stmt = conn.createStatement();
	     ResultSet rs = stmt.executeQuery("select count(*) from MEMBER")) 
	{
		rs.next();
		return rs.getInt(1);
	}
    
	conn.commit();
} catch (SQLException e) {
	if (null != conn)
		try { conn.rollback();} catch (SQLException ew) { }
    throw new RuntimeException(e);
} finally {
	if (null != conn) {
		try {
			conn.close();
		} catch (SQLException e) {
			e.printStackTrace();
		}
	}
}
코드 비고
Line 4 conn.setAutoCommit() 트랜잭션을 위해 자동 Commit을 비활성화합니다.
Line12 conn.commit() SQL이 성공하면 Commit을 수행합니다.
Line 15 conn.rollback() SQL이 실패하면 Rollback을 수행합니다.

스프링에서는 트랜잭션 처리를 위한 @Transactional 어노테이션을 제공합니다. @Transactional 어노테이션을 메소드에 등록해 트랜잭션 범위를 지정합니다.

chap08 패키지의 ChangePasswordService 클래스를 다음과 같이 수정합니다.

@Transactional
public void changePassword(String email,
			   String oldPassword,
			   String newPassword)
{
	Member member = memberDao.selectByEmail(email);
	if (null == member)
		throw new RuntimeException();
	member.changePassword(oldPassword, newPassword);
	memberDao.update(member);
}
코드 비고
Line 1 @Transactional 메소드를 트랜잭션 범위로 지정합니다.
Line 6 selectByEmail() 메소드의 두 쿼리가 하나의 트랜잭션으로 실행됩니다.
Line 9 changePassworld()
더보기

@Transactional 어노테이션이 제대로 동작하려면 플랫폼 트랜잭션 매니저(PlatformTransactionManager) 빈을 등록하고 @Transactional 어노테이션을 활성화합니다.

@Configuration
@EnableTransactionManagement
public class DbConfig
{
	@Bean(destroyMethod = "close")
	public DataSource dataSource()
	{
		...
	}
	
	@Bean
	public PlatformTransactionManager transactionManager()
	{
		DataSourceTransactionManager tm = new DataSourceTransactionManager();
		tm.setDataSource(dataSource());
		return tm;
	}
}
코드 비고
Line 2 @EnableTransactionManagement @Transaction 어노테이션을 활성화합니다.
설정 클래스의 PlatformTranscationManager 빈을 사용해 트랜잭션을 적용합니다.
Line 11:17 public platformTranscationManager transcationManager() 스프링에서 제공하는 트랜잭선 매니저 인터페이스입니다.
JDBC 환경에서는 DataSourceTranscationManager를 사용합니다.

스프링의 트랜잭션 테스트

트랜잭션이 제대로 동작하는지 확인하기 위해 JDBC 관련 실행을 로그로 출력합니다.

더보기

스프링 5 버전은 자체 로깅 모듈인 spring-jcl을 사용합니다. spring-jcl은 직접 로그를 남기는대신 다른 로깅 모듈을 사용해 로그를 기록합니다.

예를 들어 클래스 패스에 Logback이 존재하면 Logback을 사용하고 Log4j2가 존재하면 Log4j2를 사용하는 방식입니다.

따라서 사용할 로깅 모듈을 클래스 패스에 추가해야 합니다.

build.gradle 파일에서 다음 의존성을 추가합니다.

dependencies {
    implementation group: 'org.springframework', name: 'spring-context', version: '5.0.2.RELEASE'
    implementation group: 'org.springframework', name: 'spring-jdbc', version: '5.0.2.RELEASE'
    implementation group: 'org.apache.tomcat', name: 'tomcat-jdbc', version: '8.5.27'
    implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.22'
    implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.25'
    implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3'
}
코드 비고
Line 6 slf4j-api Logback 관련 의존성을 추가합니다.
Line 7 logback-classic

src/main/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="DEBUG" />
</configuration>
코드 비고
Line 12 name="org.springframework.jdbc" level="DEBUG" JDBC 관련 모듈에서 출력하는 로그 메시지를 DEBUG 수준으로 표시합니다.

애플리케이션을 실행하고 트랜잭션으로 수행되는 비밀번호 변경 명령문을 사용합니다.

정상적으로 비밀번호를 변경합니다. 그리고 출력되는 콘솔 로그를 살펴봅니다.

2023-12-18 18:12:18,191 DEBUG o.s.j.d.DataSourceTransactionManager - Acquired Connection [ProxyConnection[PooledConnection[com.mysql.cj.jdbc.ConnectionImpl@4bd2f0dc]]] for JDBC transaction
2023-12-18 18:12:18,194 DEBUG o.s.j.d.DataSourceTransactionManager - Switching JDBC Connection [ProxyConnection[PooledConnection[com.mysql.cj.jdbc.ConnectionImpl@4bd2f0dc]]] to manual commit
2023-12-18 18:12:18,214 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL query
2023-12-18 18:12:18,214 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL statement [select * from MEMBER where EMAIL = ?]
2023-12-18 18:12:18,251 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL update
2023-12-18 18:12:18,251 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL statement [update MEMBER set NAME = ?, PASSWORD = ? where EMAIL = ?]
2023-12-18 18:12:18,253 DEBUG o.s.j.c.JdbcTemplate - SQL update affected 1 rows
2023-12-18 18:12:18,255 DEBUG o.s.j.d.DataSourceTransactionManager - Initiating transaction commit
2023-12-18 18:12:18,255 DEBUG o.s.j.d.DataSourceTransactionManager - Committing JDBC transaction on Connection [ProxyConnection[PooledConnection[com.mysql.cj.jdbc.ConnectionImpl@4bd2f0dc]]]
2023-12-18 18:12:18,263 DEBUG o.s.j.d.DataSourceTransactionManager - Releasing JDBC Connection [ProxyConnection[PooledConnection[com.mysql.cj.jdbc.ConnectionImpl@4bd2f0dc]]] after transaction
2023-12-18 18:12:18,263 DEBUG o.s.j.d.DataSourceUtils - Returning JDBC Connection to DataSource
코드 비고
Line 2 Switching JDBC Connection JDBC Connection에서 트랜잭션을 시작하고 커밋한다는 로그를 확인 할 수 있습니다.
Line 8 Initiating transcation commit
Line 9 Committing JDBC transcation on Connection

이번에는 비밀번호를 잘못 입력합니다. 그리고 출력되는 콘솔 로그를 살펴봅니다.

2023-12-18 18:32:32,874 DEBUG o.s.j.d.DataSourceTransactionManager - Acquired Connection [ProxyConnection[PooledConnection[com.mysql.cj.jdbc.ConnectionImpl@4bd2f0dc]]] for JDBC transaction
2023-12-18 18:32:32,877 DEBUG o.s.j.d.DataSourceTransactionManager - Switching JDBC Connection [ProxyConnection[PooledConnection[com.mysql.cj.jdbc.ConnectionImpl@4bd2f0dc]]] to manual commit
2023-12-18 18:32:32,891 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL query
2023-12-18 18:32:32,891 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL statement [select * from MEMBER where EMAIL = ?]
2023-12-18 18:32:32,934 DEBUG o.s.j.d.DataSourceTransactionManager - Initiating transaction rollback
2023-12-18 18:32:32,935 DEBUG o.s.j.d.DataSourceTransactionManager - Rolling back JDBC transaction on Connection [ProxyConnection[PooledConnection[com.mysql.cj.jdbc.ConnectionImpl@4bd2f0dc]]]
2023-12-18 18:32:32,939 DEBUG o.s.j.d.DataSourceTransactionManager - Releasing JDBC Connection [ProxyConnection[PooledConnection[com.mysql.cj.jdbc.ConnectionImpl@4bd2f0dc]]] after transaction
2023-12-18 18:32:32,939 DEBUG o.s.j.d.DataSourceUtils - Returning JDBC Connection to DataSource
코드 비고
Line 2 Switching JDBC Connection JDBC Connection에서 트랜잭션롤백하는 로그를 확인 할 수 있습니다.
Line 5 Initiating transaction rollback
Line 6 Rolling back JDBC transcation on Connection

@Transcation과 프록시

스프링의 트랜잭션은 AOP(Aspect Oriented Programming)를 통해 처리됩니다. 다시 말해 스프링의 트랜잭션은 AOP 관점에서의 공통 기능에 해당합니다.

즉 AOP에 의해 트랜잭션을 수행하는 것은 프록시(Proxy)입니다.

스프링에서 @EnableTranscationManagement를 사용하면 @Transcation 어노테이션이 등록된 빈 객체를 찾아서 트랜잭션을 위한 프록시 객체를 생성합니다.

프록시 객체는 @Transactional 어노테이션이 등록된 메소드를 호출하면 PlatformTransactionManager를 사용해 트랜잭션을 시작합니다.

트랜잭션이 시작되면 실제 객체의 메소드를 호출하고, 메소드가 성공하면 트랜잭션을 커밋합니다.

롤백의 경우에도 마찬가지로 트랜잭션은 프록시 객체에 의해서 수행됩니다. 비밀번호 변경 코드를 살펴보면 RuntimeException을 발생시킵니다.

public void changePassword(String oldPassword,
			   String newPassword)
{
	if (!this.password.equals(oldPassword))
		throw new RuntimeException();
	this.password = newPassword;
}

트랜잭션을 위한 프록시 객체는 원본 객체의 메소드에서 RuntimeException이 발생하면 트랜잭션을 롤백합니다.

더보기

별도 설정을 하지 않으면 RuntimeException에 의해서만 트랜잭션이 롤백됩니다.

JdbcTemplate 역시 DB 연동 과정에 문제가 있으면 DataAccessException을 발생시키는데 이 역시 RuntimeException을 상속받기 때문에 트랜잭션은 롤백됩니다.

반면 SQLExceptionRuntimeException을 상속받지 않기 때문에 트랜잭션을 롤백하지 않습니다. 이 경우에도 트랜잭션을 롤백하려면 rollbackFor 속성을 사용합니다.

@Transactional(rollbackFor = SQLException.class)
public void changePassword(String email,
			   String oldPassword,
			   String newPassword)
{
	Member member = memberDao.selectByEmail(email);
	if (null == member)
		throw new RuntimeException();
	member.changePassword(oldPassword, newPassword);
	memberDao.update(member);
}

이렇게 지정된 메소드는 RuntimeException을 포함해 다른 예외에 대해서도 트랜잭션을 롤백하도록 할 수 있습니다.

반대로 특정 예외에 대해서 롤백시키지 않고 커밋할려면 noRollbackFor 속성을 사용합니다.

그 외 @Transcation 어노테이션의 주요 속성은 다음과 같습니다.

속성 타입 비고
value String 트랜잭션을 관리할 때 사용할 PlatformTranscationManager 빈의 이름을 지정합니다. 디폴트는 " "로 등록된 빈에서 자동으로 찾습니다.
propagation Propagationn 트랜잭션 전파 타입을 지정합니다. 디폴트는 Propagation.REQUIRED입니다.
isolation Isolation 트랜잭션 격리 레벨을 지정합니다. 디폴트는 Isolation.DEFAULT입니다.
timeout int 트랜잭션 제한 시간(단위:초)을 지정합니다. 디폴트는 -1로 이 경우 데이터베이스 타임아웃 시간을 사용합니다.

정리 및 복습

  • JdbcTemplate에서 Insert Update Delete 쿼리는 update() 메소드를 사용합니다.
  • MySQL의 AUTO_INCREMENT 컬럼은 행이 추가되면 자동으로 값이 할당됩니다.
  • JdbcTemplate에서 자동으로 생성된 키 값을 구하려면 KeyHolder를 사용합니다.
  • 스프링에서 트랜잭션을 사용하려면 메소드에 @Transactional 어노테이션을 등록합니다.
  • @Transactional을 사용하려면 플랫폼 트랜잭션 매니저(PlatformTransactionManager) 빈을 등록하고 @Transactional 어노테이션을 활성화합니다.
  • 스프링의 트랜잭션은 AOP(Aspect Oriented Programming)프록시(Proxy) 객체로써 동작합니다.
  • 트랜잭션을 위한 프록시 객체는 원본 메소드를 대신 호출하고 이 과정에서 RuntimeException이 발생하면 롤백합니다.
  • 별도의 예외를 사용해 트랜잭션을 롤백하려면 rollbackFor 속성을 사용합니다.
  • 반대로 특정 예외에 대해서 롤백시키지 않고 커밋할려면 noRollbackFor 속성을 사용합니다.