DevYGwan
@Transactional 잘 모르고 쓰면 오히려 독이 될 수 있다. 본문
Spring으로 서버를 개발하는 개발자라면, @Transactional 어노테이션은 많이 익숙할 것이라고 생각합니다. 서비스 로직을 작성할때 대부분 한번에 DB접근으로 끝나는 것이 아닌 여러번의 DB접근을 할 상황이 생기고 이를 한 트랜잭션으로 묶기 위해서 주로 사용하는 것이 @Transactional 어노테이션입니다. 여태까지 이렇게 간단히 한 트랜잭션으로 DB접근을 묶고 싶을때 이 어노테이션을 사용했습니다. 그러다보니 생긴 문제가 있어 이를 공유하려고 합니다.
회사에서 카카오 발신 프로필 제거 로직을 구현하던 도중 제가 처한 문제 상황은 다음과 같습니다.
키 값이 여러개 들어왔을때 이를 제거하는 로직을 구현하는 중입니다.
이때 키 값 각각을 하나의 트랜잭션으로 처리해야합니다.
이를 한 클래스 내에서 트랜잭션을 처리하려고 하니 제대로 된 트랜잭션 처리가 되지 않았습니다.
제가 처음에 구현한 로직의 구조
처음에는 Controller에서 여러 키를 한 번에 받아 Service로 전달하는 방식으로 시작했습니다. Service에서 제거할 키 리스트를 받으면 반복문을 사용하여 removeKey(String key) 메서드로 각 키를 순차적으로 제거했습니다. removeKey 메서드는 제거할 키 값과 관련된 모든 데이터를 삭제해야 했기 때문에 여러 테이블에 접근해 데이터를 일괄적으로 삭제해야 해야 했습니다. 따라서 이 과정을 하나의 트랜잭션으로 묶는 것이 필요했습니다. 이를 위해 @Transactional 어노테이션을 사용했으나, 예상과 달리 이 과정이 한 트랜잭션으로 묶이지 않는 문제가 발생했습니다.
아 내가 @Transactional 어노테이션을 잘 모르는구나...
공부해보자!
@Transactional에 대해 공부하기 전, 서버와 DB간의 연결은 어떻게 되어 있는지를 간단하게 확인해보겠습니다.
서버 & DB 연결 구조
서버와 DB는 이렇게 위와 같은 그림으로 연결되어 있습니다. 서버와 DB가 연결되어있고 사용자의 요청에 따라 서버에서 DB관련 로직을 처리하려고 한다면, 서버의 DBCP(Database Connection Pool)에서 미리 생성되어있던 커넥션 객체를 가져옵니다. 그 후 데이터베이스는 서버 내부에 세션이라는 것을 만들고 커넥션과 연결되고 커넥션을 통해 전달된 SQL 요청 등이 이를 처리하는 식으로 동작합니다.
그렇다면 지금부터 예시 상황에 따라 Spring서버에서는 어떻게 트랜잭션을 구현할 수 있으며 이게 어떻게 발전되었는지를 차근차근 살펴보도록 하겠습니다.
예시 상황
- 유저를 한번에 DB에 저장해야한다.
- 저장하다가 한명이라도 저장이 안되면 이전에 저장했던 유저들을 다 삭제해야한다.
※ JDBC & JPA를 사용한 초기 트랜잭션 구현 방법
- 첫번째로 DBConfig 클래스에서 DB 관련 환경 설정을 진행하고 이를 가지고 Connection 객체를 정의합니다.
- 이후 트랜잭션이 필요한 로직 처리시 해당 객체를 사용해서 커넥션을 가져옵니다.
- 커넥션을 가져온 후, 기본적으로 자동 커밋 모드가 기본값으로 설정되어 있는 경우가 많기 때문에 이를 수동 커밋 모드로 설정하는 것이 시작입니다.
용어 | 설명 |
자동 커밋 모드 | 쿼리 실행 후 자동으로 커밋을 하기 때문에 쿼리 실행 후 바로 트랜잭션이 종료된다. |
수동 커밋 모드 | 쿼리 실행 후 커밋 또는 롤백을 직접 호출할 수 있고 마지막에 직접 트랜잭션을 종료해야한다. |
- 두번째로 해당 커넥션을 가지고 DB관련 로직을 처리합니다. 보시는 바와 같이 connection 객체를 매개변수로 넘겨 DAO에서 이 커넥션을 가지고 처리하는 것을 알 수 있습니다.
- 해당 커넥션을 가지고 원하는 작업을 try catch 문을 통해 실행합니다.
- try 안에 있는 로직이 제대로 다 실행이 되면 commit을 하고 에러가 발생하면 rollback을 하는 식으로 동작합니다.
- 마지막으로 이전에 임의로 설정한 수동 커밋 모드를 자동 커밋 모드로 변경합니다. 그 후 커넥션을 종료합니다.
- 이 로직은 에러 발생과는 상관없이 무조건 처리되어야 하는 로직이기 때문에 finally문 안에 처리했습니다.
그렇다면 이제부터 JPA를 사용하여 간단하게 트랜잭션을 구현해보도록 하겠습니다. JDBC을 사용한 것과 비슷한 부분이 많기 때문에 간단하게만 설명하도록 하겠습니다.
- JDBC와는 다르게 EntityManager를 가져와 EntityManager에서 EntityTransaction을 가져옵니다.
- 따른 설정 클래스 파일이 필요 없고 properties 파일에서 DB연결만 해주면 되기 때문에 Connection 객체를 정의할 필요가 없습니다. 이것만 봐도 확실히 JDBC보다는 더 코드가 준 것을 확인할 수 있습니다.
- 이후, 해당 Connection 객체를 통해 JDBC와 동일하게 트랜잭션 시작, 커밋, 에러 발생시 롤백, 처리가 다 끝나면 해당 트랜잭션을 닫는 것까지 진행합니다.
JDBC와 JPA만을 사용한 트랜잭션 사용하는 것을 도식화해보면,
이렇게 도식화할 수 있습니다. 명시적으로 트랜잭션 시작 및 종료를 선언하고 중간에 DB관련 처리 로직을 구현합니다. 이런 식의 구조로 트랜잭션을 관리했습니다.
※ JDBC와 JPA만을 사용한 트랜잭션 처리의 한계점
이러한 방식의 한계는 다음과 같습니다.
- JDBC를 사용하는 경우 JDBC API의 Connection 객체를 사용하기 하기 때문에 JDBC API와 의존성이 강해진다. (데이터 엑세스 기술에 의존성이 강제된다.)
- 트랜잭션 관리를 직접 해야하기 때문에(트랜잭션 Open/Close) 해야하기 때문에 번거롭다.
- Service코드에 비즈니스 로직 외의 코드가 추가되어 관리가 힘들다.(Try Catch 등~)
- 데이터 접근 기술에 따라 코드의 내용이 변경된다. ( JDBC -> JPA 등)
이러한 문제를 스프링은 깔끔하게 해결했습니다.
※ 스프링 트랜잭션 핵심 기술
스프링에서는 위와 같이 트랜잭션 동기화 & 추상화 & 선언적 트랜잭션 기술을 사용해서 위와 같은 문제를 해결했습니다. 그렇다면 이 용어들을 하나하나 살펴보도록 하겠습니다.
[ PART 1 ] 트랜잭션 동기화 (Transaction Synchronization)
- 앞에서 봤던 것과 같이 JDBC를 이용한 방법은 여러개의 작업을 하나의 트랜잭션으로 관리하려면 Connection 메서드 매개변수로 전달하여 커넥션 객체를 공유하는 등의 불편한 작업이 동반된다.
- 이러한 문제를 해결하기 위해 Spring은 트랜잭션 동기화(Transaction Synchrnoization) 기술을 제공한다.
- 트랜잭션 동기화란 트랜잭션을 시작하기 위한 Connection 객체를 특별한 저장소인 트랜잭션 동기화 매니저에 저장하고 필요할때마다 꺼내 쓸 수 있도록 하는 기술이다.
- 트랜잭션 동기화 매니저는 작업 쓰레드마다 Connection 객체를 독립적으로 관리하기 때문에, 멀티 쓰레드 환경에서도 충돌이 발생하지 않는다. (현재 실행중인 트랜잭션과 관련된 정보를 쓰레드 로컬에 저장)
이를 적용한 로직을 코드로 살펴보도록 하겠습니다.
- 첫번째로 트랜잭션 동기화 매니저를 초기화하고 커넥션을 가져옵니다. DataSource는 기본적으로 제공하기 때문에 의존성 주입을 통해 사용하면됩니다. 저는 JDBC를 사용했기 때문에 DataSourceUtils 클래스를 사용해 Connection을 가져왔습니다.
- DataSourceUtils.getConnection() 메서드 커넥션이 존재할 경우 가져다 쓰고 존재하지 않을 경우 새로 등록해서 사용합니다. (Connection Holder에서 확인)
- 이것 또한 동일하게 수동 커밋 모드로 변경합니다.
- DB관련 로직을 처리합니다. 보시는 바와 같이 connection 객체를 매개변수로 넘겨 DAO에서 처리하는 것이 아닌 트랜잭션 동기화 매니져에서 커넥션 객체를 가져와 처리합니다.
- 해당 커넥션을 가지고 원하는 작업을 try catch 문을 통해 실행합니다.
- try 안에 있는 로직이 제대로 다 실행이 되면 commit을 하고 에러가 발생하면 rollback을 하는 식으로 동작합니다.
- 마지막으로 이전에 임의로 설정한 수동 커밋 모드를 자동 커밋 모드로 변경합니다.
- 그 후, 사용한 커넥션을 release하고 트랜잭션 동기화 매니져의 바인딩과 동기화 작업을 종료 & 정리합니다.
- 이 로직은 에러 발생과는 상관없이 무조건 처리되어야 하는 로직이기 때문에 finally문 안에 처리했습니다.
트랜잭션 동기화 처리를 도식화해보면,
이런식으로 동작합니다. 달라진 점은 커넥션을 가져오고 종료하는 방식과 insert문에 Connection 객체를 전달하지 않는다는 점입니다. 따로 이러한 커넥션 객체를 관리해주기 때문에 매개변수로 전달하는 등의 불필요한 로직을 줄일 수 있습니다.
※ 트랜잭션 동기화만을 사용해 트랜잭션을 처리할때의 한계점
이러한 방식의 한계는 다음과 같습니다.
- DataSourceUtils 클래스를 사용해서 Connection을 가져오거나 생성하기 때문에 아직 데이터 접근 기술에 의존적입니다.
- 트랜잭션을 사용하는 코드는 데이터 접근기술 마다 다릅니다. ( 데이터베이스와의 연결 방법이 다르기 때문에 )
- 이로 인해, 데이터 접근 기술이 바뀌면 서비스 계층의 코드도 바뀌는 문제가 있습니다.
- 아직까지도 Service코드에 비즈니스 로직 외의 코드가 추가되어 관리가 힘들다.(Try Catch 등~)
- 아래는 데이터 접근 기술마다 Connection을 가져올때 쓰는 클래스를 보여주는 그림입니다.
그런데 이렇게 트랜잭션 처리 작업을 놓고 보니, 공통점을 발견했습니다.
1. 트랜잭션을 가져오거나 생성합니다.
2. DB 관련 작업을 진행합니다.
3. 작업이 정상적으로 끝나면 마지막에 커밋을 하고 트랜잭션을 종료합니다.
4. 만약 문제가 생기면 중간에 작업을 멈추고 롤백합니다.
이러한 문제를 해결하기 위해 마지막으로 트랜잭션 추상화라는 개념이 등장했습니다.
[ PART 2 ] 트랜잭션 추상화 (Transaction Abstraction)
- 트랜잭션 매니저의 기능을 인터페이스로 정의하고 JDBC, JPA 등의 데이터 접근 기술에 따른 구현체를 사용하는 것을 말합니다.
- 이를 통해 서비스 계층은 구현체가 아닌 인터페이스에 의존할 수 있습니다.
- 따라서 만약 JDBC -> JPA로 변경해도 서비스 코드가 변하지 않습니다.
이를 적용한 로직을 코드로 살펴보도록 하겠습니다. 먼저 트랜잭션 Template를 사용해서 트랜잭션을 구현하는 방법을 살펴보도록 하겠습니다.
- 보시는 바와 같이 TransactionManager를 통해 트랜잭션을 관리합니다.
- TransactionManagerfmf TransactionTemplate의 매개변수로 넣어 이를 가지고 DB 로직을 처리합니다.
- 따라서 데이터베이스 커넥션을 직접 열고 닫을 필요가 없습니다.
슬슬 코드가 비슷해지기 시작합니다!!
이번에는 이를 추상화해 처리해보도록 하겠습니다. PlatfromTransactionManager를 사용해서 이를 구현합니다.
이제보니 결국 둘다 PlatfromTransactionManager을 사용해서 트랜잭션을 가져오는군요!!
저 PlatfromTransactionManager만 스프링에서 처리해준다면,
이젠 데이터 접근기술에 따라 코드가 변경되지 않겠네요!!
한번 중간 점검으로 어떤식으로 저 PlatfromTransactionManager를 사용해서 추상화를 진행할 수 있는지 도식으로 정리해보도록 하겠습니다.
- 각각의 DB에 맞는 TransactionManager는 PlatfromTransactionManager의 구현체로 정의하도록 로직을 구현합니다.
- 서비스는 내부적으로 각각의 DB에 맞는 TransactionManager를 의존하는 것이 아닌 PlatfromTransactionManager 인터페이스를 의존합니다.
- 스프링에서 자동으로 개발자가 사용하는 DB 기술에 맞게 구현체를 연결해 DB 기술간의 의존성을 제거합니다.
그렇다면, 위의 내용을 라이브러리를 확인하여 분석해보도록 하겠습니다.
- 보시는 바와 같이 PlatfromtransactionManager에서 getTransaction() 메서드로 트랜잭션을 가져옵니다.
- getTransaction()에서 doGetTransaction()이라는 메서드로 TransactionManager를 가져옵니다. 이는 DB 접근 기술에 따라 다른 구현체를 가지고 있습니다.
그렇다면, 대표적인 DB 접근 기술 중 하나인 JDBC, JPA, Hibernate의 doGetTransaction() 메서드가 어떻게 구현되어있는지 확인해보도록 하겠습니다.
이렇게 구현되어 있는 것을 확인할 수 있었습니다. 그렇다면 최종적으로 트랜잭션 추상화가 어떻게 구현되는지 코드를 통해 확인해보도록 하겠습니다.
- DI를 통해 PlatfromTransactioinManger를 주입받아 사용합니다.
- 기본 트랜잭션 속성으로 트랜잭션을 생성합니다.
- 이로인해 완전히 데이터 접근 기술과 의존하지 않아 데이터 접근 기술을 바꿀때, 코드가 바뀌지 않습니다.
※ 트랜잭션 추상화의 한계점
이러한 방식의 한계는 다음과 같습니다.
- 아직까지도 비즈니스 로직 외의 다른 작업을 추가로 서비스 로직 내에서 구현해야합니다.
- 트랜잭션을 열고, 커밋하고, 롤백하는 일련의 작업들을 서비스 로직 내에서 수행하고 있어 코드의 줄이 길어진다는 단점이 있습니다.
이러한 문제를 해결하기 위해 마지막으로 선언적 트랜잭션이라는 개념이 등장했습니다.
[ PART 3 ] 선언적 트랜잭션 (Declarative Transaction)
- 스프링은 선언적 트랜잭션 기술을 통해 트랜잭션 생성 및 종료 등의 관련 내용을 비즈니스 로직과 분리했습니다.
- 스프링 AOP를 통해 프록시 객체를 만들고 해당 객체에서 트랜잭션 관련 로직을 처리합니다.
- 그 후, 트랜잭션을 시작하고 실제 서비스를 대신 호출해 서비스 코드에 구현되어 있는 비즈니스 로직을 처리합니다.
- 해당 로직이 문제가 생기면 rollback을, 문제가 없으면 commit 후 트랜잭션을 종료하는 일련의 처리를 해당 프록시 객체에서 다 처리합니다.
- 크게 XML 파일을 이용한 설정(tx namespace 이용) or 어노테이션을 이용한 설정 2가지 방식을 사용합니다.
저는 이 2가지 방법 중 어노테이션 방식을 설명드리도록 하겠습니다. (@Transactional)
※ 해당 클래스가 트랜잭션 처리를 할 필요가 없는 클래스인 경우 (@Transactional X)
- 보시는 바와 같이 디버깅을 통해 testService 객체 주소를 출력해보면 실제 객체에 접근하는 것을 알 수 있습니다.
※ 해당 클래스가 트랜잭션 처리를 한 클래스인 경우 (@Transactional O)
- 보시는 바와 같이 디버깅을 통해 testService 객체 주소를 출력해보면 뭔가 실제 객체를 참조하는게 아닌거 같습니다. 실제 객체명 뒤에 $$EnhancerBySpringCGLIB... 같은 내용이 추가된 것을 확인할 수 있습니다.
이렇게 이름이 바뀌는 이유는,
스프링 프레임워크에서 @Transactional 어노테이션이 적용된 메서드는
트랜잭션 처리를 위해 프록시 패턴을 이용하여 동작하기 때문입니다.
※ 프록시 패턴이란?
- 프록시 패턴은 어떤 객체에 대한 접근을 제어하는 용도로 대리인이나 대변인에 해당하는 객체를 제공하는 패턴입니다.
- 따라서 프록시 객체는 원래 객체를 감싸고 있는 객체입니다.
- 스프링 AOP는 프록시 기반의 AOP 구현체이며, 스프링 Bean에만 AOP 적용 가능합니다.
- 이 프록시 객체의 종류는 크게 JDK Dynamic Proxy와 CGLIB(Code Generator Libray)가 있습니다.
- 프록시는 클라이언트로부터 요청을 받으면 타겟 클래스의 메소드로 위임하고, 경우에 따라 부가 작업을 추가합니다.
즉 프록시는 클라이언트가 타겟 클래스를 호출하는 과정에만 동작합니다. 타겟 클래스가 자기 자신의 메소드를 호출할 때는 AOP가 적용되지 않고 대상 객체를 직접 호출하게 되어 프록시 객체를 타지 않습니다.
간단하게 선언적 트랜잭션을 적용한 코드를 살펴보면,
- 확실히 코드가 깔끔해진 것을 확인할 수 있습니다.
- 이제 서비스 로직에 불필요한 로직이 추가되지 않습니다.
그럼 어떻게 이렇게 구현되는지 도식화를 통해 확인해보도록 하겠습니다.
- @Transactional 어노테이션이 붙은 메서드 로직을 처리하려고 한다면, Controller에서 Service를 호출할때 실제 Service 객체로 접근하는 것이 아닌 AOP 프록시 객체로 접근합니다.
- AOP 프록시 객체에서는 자동으로 트랜잭션을 시작하고 내부를 try catch를 통해 commit과 rollback로직을 처리합니다.
- 로직이 다 끝나면 트랜잭션을 자동으로 종료하고 응답값을 클라이언트에게 전달합니다.
@ Transactional
- 메서드 , 클래스, 인터페이스 등에 적용할 수 있습니다.
- 디비에 여러번 접근하면서 하나의 작업을 수행하는 서비스 계층 메서드에 주로 사용합니다.
- 세밀한 작업을 간편하게 할 수 있다는 장점이 있습니다.
- propagation, isolation, timeout, readOnly, rollbackFor, noRollbackFor 속성을 지정할 수 있습니다.
이렇게 해서 트랜잭션 처리의 문제점을 해결할 수 있었습니다.
그렇다면, 제 이전 로직에서의 문제점은 무엇이었을까요??
- 객체 간의 이동시 제거할 키 리스트를 받고, Service 클래스 안에서 반복문으로 removeKey() 메서드를 반복적으로 실행해서 로직을 구현했습니다.
- 이러다보니 @Transactional 어노테이션이 붙은 removeKey() 메서드를 호출할때 객체간의 이동이 없습니다.
- 따라서 프록시 객체를 타지 않기 때문에 @Transactional이 무시됩니다.
그렇다면, 해결 방법은?
- 이런식으로 매번 removeKey()메서드를 호출할때마다 객체간의 이동을 할 수 있도록 로직을 구현한다면 @Transactional 어노테이션으로 인해 프록시 객체를 제대로 타게 되어 문제를 해결할 수 있습니다.
- 굳이 Controller에서 처리하지 않더라도, 중간에 다른 클래스를 하나 둬서 처리할 수도 있습니다.
이렇게 @Transactional 어노테이션을 공부하고 나니 제가 확실히 @Transactional이라는 어노테이션을 잘 이해하지 못하고 사용했다는 것을 알게 되었습니다. 이러한 문제를 빠르게 확인해서 다행이였지 만약 이를 나중에 확인했다면, 코드의 문제를 찾고 이를 수정하는 것이 정말 어려웠을 것이라고 생각합니다.
이번 기회에 저는
어떤 기술을 사용할때는 확실하게 해당 기술을 알고 사용하자!!
는 교훈을 배우게 되었습니다. 감사합니다.
'Study > Spring' 카테고리의 다른 글
JVM Memory의 3가지 메트릭 지표 - Committed Memory란? (0) | 2024.04.02 |
---|---|
조회 성능 향상을 위한 캐시 처리 With Redis (1) | 2024.01.29 |
외부 API 사용 :: 마지막까지 깔끔하게 (2) | 2023.12.30 |
Spring Mail 인증 비동기 처리 & 비동기 retry 정책 적용 (1) | 2023.12.17 |
DB에 저장하는 데이터는 개발자도 모르게!! (2) | 2023.12.13 |