번외 - ② : 현재 구조에서 Transaction에 대한 고찰 - 두 번째
목차
- 시작하며
- 문제점 1 - 관심사 밖의 코드 반복
- 문제점 2 - Connection 공유
- 마치며
시작하며
이번 포스팅은 저번 포스팅에 이어서 Transaction을 어떻게 적용해야 할까 하는 취지의 두 번째 글이다. 저번 포스팅에서는 트랜잭션을 왜 써야 했고, 또 정말 트랜잭션이 생성되는지 직접 터미널에서 확인해봤었는데, 이걸 적용하다 보니 문제점이 발생해서 글로 남겨두려 한다.
추가적으로 해당 포스팅에 존재하는 소스코드들은 전부 현재 프로젝트 내부 코드이다.
문제점 1 - 관심사 밖의 코드의 반복
첫 번째 문제점은 Service 객체에서 DB 접속과 관련된 코드가 항상 반복되어야 한다는 것이다.
이해를 돕기 위해 아래 코드를 보자.
public void createPost(Profile profile, Post post) {
Connection connection = null;
try {
connection = DBUtil.getBasicDataSource().getConnection();
connection.setAutoCommit(false);
new PostDao(connection).createPost(profile, post);
new PostLogDao(connection).writePostActivityLog(profile.getAccountNo(), post.getPostNo(), "게시");
connection.commit();
} catch (SQLException e) {
try {
connection.rollback();
e.printStackTrace();
} catch (SQLException ex) {
ex.printStackTrace();
}
}
}
이 코드를 보면 DB에 접속하기 위한 객체인 Connection을 사용하는 모습을 볼 수 있다. 만약 다른 트랜잭션 메서드를 만들게 된다면, 항상 connection.setAutoCommit(false)라던가 connection.commit(), connection.rollback()과 같은 메서드가 반복되어야 한다.
또한 try~catch문을 반복해서 사용해야 하거나 SQLException을 항상 던져주는 작업의 반복성이 생긴다.
나는 관심사 밖의 코드가 반복되는 현상을 없애고자 스프링 프레임워크(Spring Framework)의 @Transactional 어노테이션을 벤치마킹하기로 했다.
어떻게 했냐면, 일단 아래처럼 @Transaction이라는 어노테이션부터 만들었다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Transaction {
}
그리고 이렇게 생성한 커스텀 어노테이션을 사용하여 AOP를 구현하려고 한다.
AOP란 Aspect Oriented Programming의 약어로 한국말로는 관점 지향 프로그래밍이라고 한다.
어쨌든 이번에는 AOP를 구현하기 위해 CG 라이브러리를 사용했다. CG 라이브러리는 AOP를 구현할 때 인터페이스를 굳이 만들지 않아도 되고, 리플렉션을 사용하지 않고 바이트 코드를 조작하여 프록시 객체를 만들어주기 때문에 성능이 좋다고 알려져 있다.
단점이라면 상속(extends)을 이용해서 aop를 구현하는 만큼 final이나 private과 같이 상속된 객체에 오버라이딩을 지원하지 않는 경우 해당 메서드에 대한 Aspect를 적용할 수 없다.
CG 라이브러리를 적용한 후 이를 사용하여 프록시 객체를 만들어줄 수 있는 유틸리티 성 클래스인 ProxyUtil Class를 만들었다.
public class ProxyUtil {
public ProxyUtil() {
}
public static Object getProxyInstance(Class aClass, MethodInterceptor methodInterceptor){
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(aClass);
enhancer.setCallback(methodInterceptor);
return enhancer.create();
}
}
위 클래스를 사용해서 프록시 객체를 만들고 싶은 경우 프록시 객체를 만들려는 클래스와 MethodInterceptor만 있으면 프록시 객체를 얻을 수 있도록 했다.
여기서 MethodInterceptor Class란 CG 라이브러리에서 지원하는 콜백 함수 중 하나로 cglib의 공식 깃허브에서는 단순히 이렇게 설명하고 있다.
callback which provides for "around advice"
아무튼 이제 Service 객체를 프록시로 생성하여 JDBC에서 트랜잭션을 사용하기위해서 필수로 사용해야 했던 반복성 코드들을 없애줘야 한다.
그러기 위해 유틸리티성 클래스인 TransactionManager를 만들었다.
public class TransactionManager {
private TransactionManager() {
}
public static Object getInstance(Class aClass){
MethodInterceptor methodInterceptor = new MethodInterceptor() {
@Override
public Object intercept(Object object, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
int annotationCount = (int) Arrays.stream(method.getAnnotations())
.filter(annotation -> annotation instanceof Transaction).count();
if (annotationCount < 1)
return methodProxy.invokeSuper(object, args);
Connection connection = null;
try {
Field[] fields = method.getDeclaringClass().getDeclaredFields();
Field connectionField = Arrays.stream(fields)
.filter(field -> field.getType() == Connection.class)
.findFirst().get();
connectionField.setAccessible(true);
connectionField.set(object, DBUtil.getConnection());
connection = (Connection) connectionField.get(object);
connection.setAutoCommit(false);
Object returnValue = methodProxy.invokeSuper(object, args);
connection.commit();
connection.close();
return returnValue;
} catch (Exception e) {
e.printStackTrace();
try {
connection.rollback();
connection.close();
} catch (Exception ex) {
ex.printStackTrace();
}
return methodProxy.invokeSuper(object, args);
}
}
};
return ProxyUtil.getProxyInstance(aClass, methodInterceptor);
}
}
이 코드는 TransactionManager.getInstance() 를 통해 프록시 객체를 반환받기 위한 코드로 MethodInterceptor를 새로 만들어서 ProxyUtil.getProxyInstance()에 인자 값으로 넘겨 진짜 프록시 객체를 반환받은 후 해당 프록시 객체를 반환해주도록 만든 코드이다.
여기서 MethodInterceptor를 만들어줄 때 강제로 오버라이딩 해야하는 intercept()에 대한 설명을 cglib의 공식 깃허브에서는 다음과 같이 이야기한다.
All generated proxied methods call this method instead of the original method.The original method may either be invoked by normal reflection using the Method object, or by using the MethodProxy (faster).
이를 한글로 직역하면 이렇다.
생성된 모든 프록시 메서드는 원래 메서드 대신 이 메서드를 호출합니다.
원래의 메서드는 Method 객체를 사용하여 일반 반사에 의해 호출되거나 MethodProxy(더 빠른)를 사용하여 호출될 수 있다.
즉, CG 라이브러리를 사용해 만들어진 프록시 객체의 각 메서드를 실행한다면, 본래의 메서드가 아닌 intercept()가 실행된다는 것을 알았다!
이를 감안하고 코드를 위에서 아래로 주요 부분만 설명하자면,
- 본래 실행하려던 메서드가 가지고 있는 어노테이션을 확인하여 @Transaction의 개수를stream으로 센다.
- @Transaction의 개수가 1 보다 작다면 없는 것이므로 바로 해당 메서드를 실행하고, @Transaction의 개수가 1과 같거나 크다면 트랜잭션을 수행해야 하는 메서드로 인식한다.
- 프록시를 만들 객체의 멤버 변수에서 Connection 타입의 변수를 가져온다. (만약 없다면 아래의 catch문을 통과하게 된다.)
- Connection 객체에 커넥션 풀에서 받아온 커넥션을 값으로 넣어준다.
- 트랜잭션을 위해 setAutoCommit(false)를 실행 후 실제 메서드를 실행한다.
- commit(), rollback()를 적절하게 배치해준다.
이렇게 관심 외의 코드가 반복되는 일을 없앴다! 이제 실제 사용되는 모습을 보자.
public PostController (){
postService = (PostService) TransactionManager.getInstance(PostService.class);
}
이렇게 Service 객체로 캐스팅해주면서 프록시 객체를 생성하고,
@Transaction
public void createPost(Profile profile, Post post) {
new PostDao(this.conection).createPost(profile, post);
new PostLogDao(this.conection).writePostActivityLog(profile.getAccountNo(), post.getPostNo(), "게시");
}
Dao를 선언하면서 Connection을 주입하여 사용한다.
이렇게 관심 외 코드의 반복성을 줄이기 위해 어노테이션으로 AOP를 구현하여 반복을 줄일 수 있었다!
문제점 2 - Connection 공유
두 번째 문제점은 Service 객체의 메서드 내부에서 트랜잭션을 위해 작성된 각 Dao 끼리 Connection이 공유되어야 한다는 것이다.
이전에 AOP로 인해 반복성이 줄어들었던 바로 그 코드를 보자.
private Connection connection;
@Transaction
public void createPost(Profile profile, Post post) {
new PostDao(this.conection).createPost(profile, post);
new PostLogDao(this.conection).writePostActivityLog(profile.getAccountNo(), post.getPostNo(), "게시");
}
해당 메서드에서는 내부적으로 PostDao와 PostLogDao 클래스가 사용되었다.
그리고 Dao 간 커넥션 공유를 위해 Service 객체의 내부에 Connection 타입의 멤버를 생성해주고, 이를 주입해주고 있는 모습을 볼 수 있다.
Dao는 Connection을 주입받은 후 해당 Connection을 사용해서 각 메서드에서로직 처리를 수행하게 되는데, 이해를 돕기 위해 아래의 코드를 보자.
public boolean createPost(Profile profile, Post post) {
String sql = "insert into post (post_no, title, content, posted_date, comment_count, like_count, account_no, picture_no, is_use_anonymous_name, is_use_anonymous_city, nicname, city) values (null , ?, ?, now(), 0, 0, ?, ?, ?, ?, ?, ?)";
try (PreparedStatement preparedStatement = this.connection.prepareStatement(sql)) {
// prepareStatement에서 sql에 변수 값 세팅...
preparedStatement.execute();
return true;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
이 부분을 문제라고 생각하는 이유는 Service 클래스가 DB의 트랜잭션 기능을 돕기 위해 Connection을 멤버로 가져야 한다는 것이다.
정확히 말하면 Service 객체는 Dao를 사용하는 입장임에도 불구하고 DB의 기능 수행을 위해 DB에서 관리해야 할 변수를 가지고 있는 것이 문제라고 생각한다.
이런 식으로 소스 작성이 지속된다면, DB 구성이 바뀌거나 또 다른 기능을 추가해야 하는 경우에 connection과 마찬가지로 추가적인 변수 생성이 필요해야 할 수도 있기 때문이다.
아직까지는 connection 객체를 멤버로 두는 방식 외의 방식에 대해서는 더 생각해봐야 하는 문제이다...
마치며
이번에 트랜잭션에 대해 많은 공부를 하게되었지만, 그만큼 전체 진척도를 신경쓰지 않아 진척률이 별로다... 열심히 해야겠다!!
감사합니다!!