Part 5) AOP와 트랜잭션 (Ch19 & Ch20 )
* 이 카테고리의 글은 <코드로 배우는 스프링 웹 프로젝트> 에서 각 파트별로 진행하는 프로젝트의 흐름을 보기 위해 각 장의 내용을 간단히 요약한 것이다.
* 출처: 코드로 배우는 스프링 웹 프로젝트 개정판, 구멍가게 코딩단, 남가람북스
Ch19 스프링에서 트랜잭션 관리
- 비즈니스에서는 쪼개질 수 없는 하나의 단위 작업을 말할 때 '트랜잭션(Transaction)'이라는 용어를 사용한다.
- '한 번에 이루어지는 작업의 단위'를 트랜잭션으로 간주한다.
- 트랜잭션의 성격을 'ACID 원칙'으로 설명하곤 하는데 다음과 같다.
원자성(Atomicity) | 하나의 트랜잭션은 모두 하나의 단위로 처리되어야 한다. 좀 더 쉽게 말하자면 어떤 트랜잭션이 A와 B로 구성된다면 항상 A,B의 처리결과는 동일한 결과여야 한다. 즉 A는 성공했지만, B는 실패할 경우 A,B는 원래 상태로 되돌려져야 한다. 어떤 작업이 잘못되는 경우 모든 것은 다시 원점으로 되돌아가야만 한다. |
일관성(Consistency) | 트랜잭션이 성공했다면 데이터베이스의 모든 데이터는 일관성을 유지해야만 한다. 트랜잭션으로 처리된 데이터와 일반 데이터 사이에는 전혀 차이가 없어야만 한다. |
격리(Isolation) | 트랜잭션으로 처리되는 중간에 외부에서의 간섭은 없어야만 한다. |
영속성(Durability) | 트랜잭션이 성공적으로 처리되면, 그 결과는 영속적으로 보관되어야 한다. |
- 트랜잭션에서 가장 흔한 예제는 '계좌 이체'이다. '계좌 이체'라는 행위가 내부적으로는 하나의 계좌에서는 출금이 이루어져야하고, 이체의 대상 계좌에서는 입금이 이루어져야만 한다. '계좌 이체'는 엄밀하게 따져보면 '출금'과 '입금'이라는 각각의 거래가 하나의 단위를 이루게 되는 상황이다.
- 비즈니스에서 하나의 트랜잭션은 데이터베이스 상에서는 하나 혹은 여러 개의 작업이 같은 묶음을 이루는 경우가 많다.
- 예를 들어 비즈니스 계층에서 '계좌 이체'는 bankTransfer()라는 메서드로 정의되고, 계좌 내에 입금과 출금은 deposit()(입금), withdraw()(출금)이라는 메서드로 정의된다고 가정해보자.
- deposit()과 withdraw()는 각자 고유하게 데이터베이스와 커넥션을 맺고 작업을 처리한다. 문제는 withdraw()는 정상적으로 처리되었는데, deposit()에서 예외가 발생하는 경우이다. 이미 하나의 계좌에서는 돈이 빠져나갔지만, 상대방의 계좌에는 돈이 입금되지 않은 상황이 될 수 있다.
- '트랜잭션으로 관리한다.' 혹은 '트랜잭션으로 묶는다'는 표현은 프로그래밍에서는 'AND' 연산과 유사하다.
- 영속 계층에서 withdraw()와 deposit()은 각각 데이터베이스와 연결을 맺고 처리하는데 하나의 트랜잭션으로 처리해야 할 경우에는 한쪽이 잘못되는 경우에 이미 성공한 작업까지 다시 원상태로 복구되어야 한다.
- 별도의 패턴이나 프레임워크를 사용하지 않는 순수하게 JDBC를 이용하는 코드라면 withdraw()와 deposit()의 내부는 아래와 같이 Connection을 맺고 처리하도록 작성될 것이다. (아래 코드에서 각 메서드마다 connection을 맺고 있는 점을 주목한다.)
public boolean deposit() { Connection con = ... try{ con = .... }catch(Exception e) { }finally { try {con.close();} } } |
public boolean withdraw() { Connection con = ... try{ con = .... }catch(Exception e) { }finally { try {con.close();} } } |
- withdraw()와 deposit()이 위와 같이 고유하게 연결을 맺고 처리되는 구조라면 bankTransfer()를 작성할 때는 어느 한쪽이 실패할 때를 염두에 두는 코드를 복잡하게 만들어야 한다.
- 스프링은 이러한 트랜잭션 처리를 간단히 XML 설정을 이용하거나, 어노테이션 처리만으로 할 수 있다.
19.1 데이터베이스 셜계와 트랜잭션
- 데이터베이스의 저장 구조를 효율적으로 관리하기 위해서 흔히 '정규화'라는 작업을 한다.
- '정규화'의 가장 기본은 '중복된 데이터를 제거'해서 데이터 저장의 효율을 올리자는 것이다.
- 정규화를 진행하면 1) 테이블은 늘어나고 2) 각 테이블의 데이터 양은 줄어드는 것이 일반적이다.
- 정규화를 진행하면서 원칙적으로 칼럼으로 처리되지 않는 데이터는 다음과 같다.
* 시간이 흐르면 변경되는 데이터를 칼럼으로 기록하지 않는다.
-> 대표적으로 사용자의 생년월일의 경우 칼럼에 기록하지만, 현재 나이는 칼럼으로 유지하지 않는다. (만일 나이에 대한 연산이 너무 빈번하다면 칼럼으로 설정할 가능성이 있긴하다.)
* 계산이 가능한 데이터를 칼럼으로 기록하지 않는다.
-> 주문과 주문 상세가 별도의 테이블로 분리되어 있다면 사용자가 한 번에 몇 개의 상품을 주문했는지 등은 칼럼으로 기록하지 않는다. (집합 연산이 성능에 영향을 주는 경우에만 칼럼으로 고려한다.)
* 누구에게나 정해진 값을 이용하는 경우 데이터베이스에서 취급하지 않는다.
-> 예를 들어 2018년 1월 1일은 '월요일'이었고 이 사실은 동일한 시간대를 사용하는 모든 사람들에게는 통용되기 때문에 데이터베이스에 기록하지 않는다.
- 정규화가 잘 되었거나, 위와 같은 규칙들이 반영된 데이터베이스의 설계에서는 '트랜잭션'이 많이 일어나지는 않는다.
- 정규화가 진행될수록 테이블은 점점 더 순수한 형태가 되어가는데, 순수한 형태가 될수록 '트랜잭션 처리'의 대상에서 멀어진다.
- 정규화를 진행할수록 테이블은 더욱 간결해지지만 반대로 쿼리 등을 이용해서 필요한 데이터를 가져온느 입장에서는 점점 불편해진다. 현재 상황을 알기 위해서는 단순히 조회를 하는 것이 아니라 직접 조인(join)이나 서브쿼리(subquery)를 이용해서 처리해야 하기 때문이다.
- 조인이나 서브쿼리를 이용하게 되면 다시 성능의 이슈가 발생할 수 있다. 매번 계산이 발생하도록 만들어지는 쿼리의 경우 성능이 저하되기 때문에 많은 양의 데이터를 처리해야 하는 상황에서는 바람직하지 않을 수 있다.
- 이러한 상황에서는 흔히 '반정규화(혹은 역정규화)'를 하게 된다. 정규화의 반대이므로 중복이나 계산되는 값을 데이터베이스 상에 보관하고, 대신에 조인이나 서브쿼리의 사용을 줄이는 방식이다.
- 반정규화의 가장 흔한 예가 '게시물의 댓글'의 경우이다. 정규화의 규칙을 따르다면 게시물 테이블과 댓글 테이블은 아래와 같은 구조를 가지는 것이 일반적이다.
- 정규화를 했다면 tbl_board 테이블에는 위와 같이 게시물에 대한 정보들만으로 칼럼이 구성되어야 하고, tbl_reply 테이블을 이용해서 댓글들을 보관하게 된다.
- 문제는 게시물의 목록 페이지에서 일반적으로 댓글의 숫자도 같이 표시된다는 데 있다. 댓글을 추가한 뒤에 댓글의 숫자를 표시하려면 조인을 하거나 서브쿼리를 이용해서 처리하게 된다. 이러한 상황에서는 흔히 tbl_board 테이블에 댓글의 숫자를 칼럼으로 처리하는 경우가 많다. 댓글의 숫자를 칼럼으로 처리하게 되면 게시물의 목록을 가져올 경우에는 tbl_reply 테이블을 이용해야 하는 일이 없기 때문에 성능상으로 좀 더 이득을 볼 수 있게 된다.
- 반정규화는 이처럼 중복이나 계산의 결과를 미리 보관해서 좀 더 빠른 결과를 얻기 위한 노력이다.
- 반정규화를 하게 되면 쿼리가 단순해지고 성능상으로도 얻을 수 있는 이득이 있지만, 대신에 댓글이 추가될 때에는 댓글을 의미하는 tbl_reply 테이블에 insert 하고, 댓글의 숫자는 tbl_board 테이블에 update를 시켜주는 작업이 필요하다. 두 작업은 하나의 트랜잭션으로 관리되어야 하는 작업이다.
19.2 트랜잭션 설정 실습
- 스프링의 트랜잭션 설정은 AOP와 같이 XML을 이용해서 설정하거나 어노테이션을 이용해서 설정이 가능하다.
- 우선 스프링의 트랜잭션을 이용하기 위해서는 Transaction Manager라는 존재가 필요하다.
- pom.xml에 spring-jdbc, spring-tx 라이브러리를 추가하고, mybatis, mybatis-spring, hikari 등의 라이브러리를 추가한다.
- root-context.xml에서는 Namespaces 탭에서 'tx'항목을 체크한다.
- root-context.xml에는 트랜잭션을 관리하는 빈(객체)을 등록하고, 어노테이션 기반으로 트랜잭션을 설정할 수 있도록 <tx:annotation-driven> 태그를 등록한다.
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"></property>
</bean>
- <bean>으로 등록된 transactionManager와 <tx:annotation-driven> 설정이 추가된 후에는 트랜잭션이 필요한 상황을 만들어서 어노테이션을 추가하는 방식으로 설정하게 된다.
19.2.2 예제 테이블 생성
- 트랜잭션의 실습은 간단히 2개의 테이블을 생성하고, 한 번에 두 개의 테이블에 insert 해야 하는 상황을 재현하도록 한다. 예제로 사용할 테이블을 생성한다.
- org.zerock.mapper 패키지에 Sample1Mapper 인터페이스, Sample2Mapper 인터페이스를 추가한다.
19.2.3 비즈니스 계층과 트랜잭션 설정
- 트랜잭션은 비즈니스 계층에서 이루어지므로, org.zerock.service 계층에서 Sample1Mapper, Sample2Mapper를 사용하는 SampleTxService 인터페이스, SampleTxServiceImpl 클래스를 설계한다.
- 테스트 클래스를 생성하여 테스트를 설계한다.
- testLong()은 50바이트가 넘고 500바이트를 넘지 않는 길이의 어떤 문자열을 이용해서 tbl_sample1, tbl_sample2 테이블에 insert를 시도한다. testLong()을 실행하면 tbl_sample1에는 데이터가 추가되지만(테이블 생성시 col1 열의 문자 크기를 500바이트로 지정), tbl_sample2에는 길이의 제한으로 인해서 insert가 실패하게 된다. (테이블 생성시 col2 열의 문자 크기를 50바이트로 지정)
- 테스트 코드를 실행했을 때에는 아래와 같은 결과를 보게 된다.
- 테스트에 사용한 문자열은 82바이트였으므로 tbl_sample1에는 아래와 같이 정상적으로 insert가 되었고, tbl_sample2에는 insert에 실패한다.
19.2.4 @Transactional 어노테이션
- 위의 결과를 보면 트랜잭션 처리가 되지 않았기 때문에 하나의 테이블에만 insert가 성공한 것을 볼 수 있다.
- 만일 트랜잭션 처리가 되었다면 tbl_sample1과 tbl_sample2 테이블 모두에 insert가 되지 않았어야 하므로, 트랜잭션 처리가 될 수 있도록 SampleTxServiceImpl의 addData()에 @Transactional을 추가한다.
- 트랜잭션은 AOP와 마찬가지로 아이콘을 통해서 트랜잭션 처리가 된 메서드를 구분해 준다.
- 정확한 테스트를 위해 이전에 성공한 tbl_sample1의 데이터를 삭제하고 commit 한다.
- 양쪽 테이블에 모든 데이터가 없는 것을 확인한 후 다시 테스트 코드를 실행한다. 동일한 코드였지만 @Transactional이 추가된 후에는 실행 시 rollback() 되는 것을 확인할 수 있다.
- 데이터베이스에서도 tbl_sample1, tbl_sample2 테이블 모두 아무 데이터가 들어가지 않는 것을 확인할 수 있다.
19.2.5 @Transactional 어노테이션 속성들
- 스프링에서는 트랜잭션을 처리하기 위해서 제공되는 @Transactional 어노테이션을 이용하면 간단히 트랜잭션 설정을 완료할 수 있다.
- 이때 지정할 수 있는 속성들은 다음과 같다. @Transactional 어노테이션은 몇 가지 중요한 속성을 가지고 있으니, 경우에 따라서는 속성들을 조정해서 사용해야 한다.
속성 종류 | 속성명 | 설명 |
전파(Propagation)속성 | PROPAGATION_MANDATORY | 작업은 반드시 특정한 트랜잭션이 존재한 상태에서만 가능 |
PROPAGATION_NESTED | 기존에 트랜잭션이 있는 경우, 포함되어서 실행 | |
PROPAGATION_NEVER | 트랜잭션 상황하에 실행되면 예외 발생 | |
PROPAGATION_NOT_SUPPORTED | 트랜잭션이 있는 경우에는 트랜잭션이 끝날 때까지 보류된 후 실행 | |
PROPAGATION_REQUIRED | 트랜잭션이 있으면 그 상황에서 실행, 없으면 새로운 트랜잭션 실행 (기본 설정) |
|
PROPAGATION_REQUIRED_NEW | 대상은 자신만의 고유한 트랜잭션으로 실행 | |
PROPAGATION_SUPPORTS | 트랜잭션을 필요로 하지 않으나, 트랜잭션 상황하에 있다면 포함되어서 실행 | |
격리(Isolation) 레벨 | DEFAULT | DB 설정, 기본 격리 수준 (기본 설정) |
SERIALIZABLE | 가장 높은 격리, 성능 저하의 우려가 있음 | |
READ_UNCOMMITED | 커밋되지 않은 데이터에 대한 읽기를 허용 | |
READ_COMMITED | 커밋된 데이터에 대해 읽기를 허용 | |
REPEATEABLE_READ | 동일 필드에 대해 다중 접근 시 모두 동일한 결과를 보장 | |
Read-only 속성 | true인 경우 insert, update, delete 실행 시 예외 발생. 기본 설정은 false | |
Rollback-for-예외 | 특정 예외가 발생 시 강제로 Rollback | |
No-rollback-for-예외 | 특정 예외의 발생 시에는 Rollback 처리되지 않음 |
* 위의 속성들은 모두 @Transactional을 설정할 때 속성으로 지정할 수 있다.
19.2.6 @Transactional 적용 순서
- 스프링은 간단한 트랜잭션 매니저의 설정과 @Transactional 어노테이션을 이용한 설정만으로 애플리케이션 내의 트랜잭션에 대한 설정을 처리할 수 있다.
- @Transactional 어노테이션의 경우 위와 같이 메서드에 설정하는 것도 가능하지만, 클래스나 인터페이스에 선언하는 것 역시 가능하다. - 어노테이션의 우선순위는 다음과 같다.
* 메서드의 @Transactional 설정이 가장 우선시 된다.
* 클래스의 @Transactional 설정은 메서드보다 우선순위가 낮다.
* 인터페이스의 @Transactional 설정이 가장 낮은 우선순위이다.
- 위의 규칙대로 적용되는 것을 기준으로 작성하자면 인터페이스에는 가장 기준이 되는 @Transactional과 같은 설정을 지정하고, 클래스나 메서드에 필요한 어노테이션을 처리하는 것이 좋다.
Ch20 댓글과 댓글 수에 대한 처리
- 실습할 예제는 단순히 댓글을 추가하면 tbl_reply 테이블에 insert하고, tbl_board 테이블에는 댓글의 수를 의미하는 replyCnt라는 칼럼을 추가해서 해당 게시물 댓글의 수를 update 한다.
- tbl_board 테이블에는 replyCnt 칼럼을 추가한다.
- 기존에 댓글이 존재했다면 replyCnt에 반영해 두어야 하므로 아래의 쿼리를 실행한다.
update tbl_board set replycnt = (select count(rno) from tbl_reply
where tbl_reply.bno = tbl_board.bno);
20.1 프로젝트 수정
- 데이터 베이스가 수정되었으므로, BoardVO 클래스와 MyBatis의 SQL, BoardService 등을 수정해 줄 필요가 있다.
20.1.1 BoardVO,BoardMapper 수정
- 먼저 BoardVO 클래스에는 댓글의 숫자를 의미하는 인스턴스 변수를 하나 추가해야한다.
- 그리고 BoardMapper 인터페이스에는 새롭게 replyCnt를 업데이트 하는 메서드를 추가해야한다.
- 새로 추가된 updateReplyCnt()는 해당 게시물의 번호인 bno와 증가나 감소를 의미하는 amount 변수에 파라미터를 받을 수 있도록 처리한다. 이것은 댓글이 등록되면 1이 증가하고, 댓글이 삭제되면 1이 감소하기 때문이다.
- MyBatis의 SQL을 처리하기 위해서는 기본적으로 하나의 파라미터 타입을 사용하기 때문에 위와 같이 2개 이상의 데이터를 전달하려면 @Param이라는 어노테이션을 이용해서 처리할 수 있다.
- 댓글이 추가되면 반정규화된 tbl_board 테이블에 replyCnt 칼럼이 업데이트되어야 하기 때문에 BoardMapper.xml에 updateReplyCnt 구문을 추가해야만한다.
- BoardMapper.xml의 게시물의 목록을 처리하는 부분에서는 새롭게 추가된 replycnt 칼럼을 가져오도록 인라인뷰 내에 추가하고, 바깥쪽 select에도 추가한다.
20.1.2 ReplyServiceImpl의 트랜잭션 처리
- ReplyServiceImpl 클래스는 기존에는 ReplyMapper만 이용했지만, 반정규화 처리가 되면서 BoardMapper를 같이 이용해야 하는 상황이 되었다.
- ReplyServiceImpl에서 새로운 댓글이 추가되거나 삭제되는 상황이 되면 BoardMapper와 ReplyMapper를 같이 이용해서 처리하고, 이 작업은 트랜잭션으로 처리되어야 한다.
- ReplyServiceImpl 클래스를 수정한다.
- 기존과 달라지는 점은 기존에는 ReplyMapper만을 주입하기 때문에 스프링 4.3부터 지원하는 자동주입을 이용할 수 있었지만, 추가적으로 BoardMapper를 이용하면서 자동주입 대신 @Setter를 통한 주입이 이루어진다는 것이다.
* 변경 전)
* 변경 후)
- 그리고 ReplyServiceImpl의 댓글 등록과 삭제를 담당하는 메서드는 @Transactional의 처리가 필요하다.
- 댓글 등록의 경우에는 파라미터로 전달받은 ReplyVO 내에 게시물의 번호가 존재하므로 이를 이용해서 댓글을 추가한다.
- 댓글 삭제는 전달되는 파라미터가 댓글의 번호인 rno만을 받기 때문에 해당 댓글의 게시물을 알아내는 과정이 필요하다.
(파라미터로 게시물의 번호 bno를 받을 수 있다면 좋겠지만 그럴 경우 ReplyController까지 같이 수정될 필요가 있다.)
20.1.3 화면 수정
- BoardController나 ReplyController 자체는 크게 수정할 것이 없지만, 게시물의 목록 화면에서는 댓글의 숫자가 출력될 수 있도록 수정해 줄 필요가 있다.
- views 폴더 내 board/list.jsp 파일의 일부에 댓글의 숫자를 반영한다.
- 그럼 화면에 게시물 옆 댓글의 숫자가 표시된다.