이번에 개발하게 된 Rest 통신 기반의 CRUD Transaction 처리 AOP 를 정리하려고
오랜만에 블로그를 켜보았다.
이게 무슨 처리냐 하면,
기본적인 데이터베이스들은 굉장히 좋은 라이브러리들이 많이 있기 때문에
메소드 레벨에 기능을 담당하는 Annotation 하나만 달면
개발자가 따로 신경쓰지 않아도 Transaction 처리가 아주 잘 된다.
하지만 현재 프로젝트는 Keycloak 이라는 오픈소스를 사용 중이고,
내가 개발중인 서비스에서 keycloak 서비스에 각종 데이터들을 CRUD 할때
Keycloak Rest API 기반 통신 라이브러리를 사용하여 호출하고 있다.
그럼 Keycloak 서비스에서는 본인이 사용하는 데이터베이스에 데이터 CRUD 처리를 한다.
이때 내서비스와 Keycloak 서비스 간 CRUD 통신 기능들이 Transaction 처리가 전혀 되지 않기 때문에
데이터 동기화 이슈가 있어
이것을 간편한 방식으로 처리하고자 개발을 진행하게 되었다!
기능설계는 Spring Boot 라이브러리인 JPA 에서 아이디어를 많이 받아왔고,
JPA 처럼 만들면 참 좋겠지만 개발 일정도 문제고 사용되는 규모에 비해 개발 규모가 너무 커질 것 같아서
각 통신 서비스에 의존도가 존재하게 구성되었다.
서비스 간 Transaction 처리의 큰 구성도만 잘 정리해 놓으면 비슷한 업무를 할 때 빠르게 진행할 수 있을 거라고 믿는다.

한번 살펴볼까?
큰 기술로는 AOP와 Generic을 사용했다.
중요한 Class 두 개를 첨부했고, 소스코드에 전체를 가져오지는 못했다.
/**
* Keycloak 관련 비지니스 로직에 대하여 Transaction 을 관리한다.
* @KeycloakTransactional annotation 을 method 레벨에 달면 된다.
*/
@RequiredArgsConstructor
@Slf4j
@Aspect
@Order(1)
@Component
public class KeycloakTransactionManager<T> {
public static Map<Long, List<KeycloakRestRequestInfo>> beforeData = new ConcurrentHashMap<>();
public static Map<Long, Boolean> transaction = new ConcurrentHashMap<>();
private final Keycloak keycloak;
private final KeycloakProperties keycloakProperties;
@Before(("@annotation(com.global.module.keycloak.KeycloakTransactional)"))
public void createTransactional() {
Long threadId = Thread.currentThread().getId();
transaction.put(threadId, true);
}
@After(("@annotation(com.global.module.keycloak.KeycloakTransactional)"))
public void deleteTransactional() {
Long threadId = Thread.currentThread().getId();
transaction.remove(threadId);
beforeData.remove(threadId);
}
@AfterReturning("@annotation(com.global.module.keycloak.KeycloakTransactional)")
public void transactionalReturning() { // update, delete
log.info("keycloak returning request transaction committed ..");
Long threadId = Thread.currentThread().getId();
List<KeycloakRestRequestInfo> committedDatas = beforeData.get(threadId);
for (KeycloakRestRequestInfo committedData : committedDatas) {
switch (committedData.getMethod()) {
case UPDATE:
committedUpdateTransactional(committedData);
break;
case DELETE:
committedDeleteTransactional(committedData);
break;
}
}
}
@AfterThrowing("@annotation(com.module.keycloak.KeycloakTransactional)")
public void transactionalThrowing() { // create
log.info("keycloak throwing request transaction committed ..");
Long threadId = Thread.currentThread().getId();
List<KeycloakRestRequestInfo> committedDatas = beforeData.get(threadId);
for (KeycloakRestRequestInfo committedData : committedDatas) {
switch (committedData.getMethod()) {
case CREATE: // create 경우에는 uuid 가 필요하기 때문에 exception 시 delete 하도록
rollbackCreateTransactional(committedData);
break;
}
}
}
private void rollbackCreateTransactional(KeycloakRestRequestInfo keycloakTransaction) {
committedDeleteTransactional(keycloakTransaction);
}
private void committedUpdateTransactional(KeycloakRestRequestInfo keycloakTransaction) {
switch (keycloakTransaction.getResource()) {
case USER:
UserRepresentation userRepresentation = (UserRepresentation) keycloakTransaction.getData();
updateUserCommitted(userRepresentation);
break;
case TENANT:
GroupRepresentation groupRepresentation = (GroupRepresentation) keycloakTransaction.getData();
updateTenantCommitted(groupRepresentation);
break;
}
}
private void committedDeleteTransactional(KeycloakRestRequestInfo keycloakTransaction) {
String uuid = (String) keycloakTransaction.getData();
switch (keycloakTransaction.getResource()) {
case USER:
deleteUserCommitted(uuid);
break;
case TENANT:
deleteTenantCommitted(uuid);
break;
}
}
public T create(Map<Enum, String> fields, T obj) {
if (obj instanceof UserRepresentation) {
return (T) createUser(fields);
}
if (obj instanceof GroupRepresentation) {
return (T) createTenant(fields);
}
}
public T add(T obj) {
if (obj instanceof UserRepresentation) {
UserRepresentation userRepresentation = (UserRepresentation) obj;
return (T) addUser(userRepresentation);
}
if (obj instanceof GroupRepresentation) {
GroupRepresentation groupRepresentation = (GroupRepresentation) obj;
return (T) addTenant(groupRepresentation);
}
}
public T get(T obj) {
if (obj instanceof UserRepresentation) {
UserRepresentation userRepresentation = (UserRepresentation) obj;
return (T) getUser(userRepresentation.getId());
}
if (obj instanceof GroupRepresentation) {
GroupRepresentation groupRepresentation = (GroupRepresentation) obj;
return (T) getTenant(groupRepresentation.getId());
}
}
public void update(T obj) {
if (obj instanceof UserRepresentation) {
UserRepresentation userRepresentation = (UserRepresentation) obj;
updateUser(userRepresentation.getId(), userRepresentation);
return;
}
if (obj instanceof GroupRepresentation) {
GroupRepresentation groupRepresentation = (GroupRepresentation) obj;
updateTenant(groupRepresentation.getId(), groupRepresentation);
return;
}
}
public void delete(T obj) {
if (obj instanceof UserRepresentation) {
UserRepresentation userRepresentation = (UserRepresentation) obj;
deleteUser(userRepresentation.getId());
return;
}
if (obj instanceof GroupRepresentation) {
GroupRepresentation groupRepresentation = (GroupRepresentation) obj;
deleteTenant(groupRepresentation.getId());
return;
}
}
}
/**
* Keycloak 에 Rest 기반으로 각종 리소스를 CRUD 한다.
* 모든 CRUD 로직은 리소스 객체를 기반으로 진행되며, 필요한 객체를 필드명 (Fields Enum) 을 활용하여 생성한다.
* CRUD 별로 필수 구성 값들이 있다.
* C : ID 제외 필드
* R : ID
* U : ID 포함 필드
* D : ID
*/
@RequiredArgsConstructor
@Slf4j
@Service
public class KeycloakService {
private final KeycloakTransactionManager<UserRepresentation> userKeycloakTransactionManager;
private final KeycloakTransactionManager<GroupRepresentation> tenantKeycloakTransactionManager;
public UserRepresentation addNewUser(String email, String name, String password) {
Map<Enum, String> fields = new HashMap<>();
fields.put(EMAIL, email);
fields.put(NAME, name);
fields.put(PASSWORD, password);
UserRepresentation userRepresentation =
userKeycloakTransactionManager.create(fields, new UserRepresentation());
return userKeycloakTransactionManager.add(userRepresentation);
}
public UserRepresentation getUserById(String uuid) {
Map<Enum, String> fields = new HashMap<>();
fields.put(ID, uuid);
UserRepresentation userRepresentation =
userKeycloakTransactionManager.create(fields, new UserRepresentation());
return userKeycloakTransactionManager.get(userRepresentation);
}
public void updateUserById(String uuid, String email, String name, String password) {
Map<Enum, String> fields = new HashMap<>();
fields.put(ID, uuid);
fields.put(EMAIL, email);
fields.put(NAME, name);
fields.put(PASSWORD, password);
UserRepresentation userRepresentation =
userKeycloakTransactionManager.create(fields, new UserRepresentation());
userKeycloakTransactionManager.update(userRepresentation);
}
public void deleteUserById(String uuid) {
Map<Enum, String> fields = new HashMap<>();
fields.put(ID, uuid);
UserRepresentation userRepresentation =
userKeycloakTransactionManager.create(fields, new UserRepresentation());
userKeycloakTransactionManager.delete(userRepresentation);
}
}
그럼 첫 번째 클래스부터 차근차근 살펴보자.
public static Map<Long, List<KeycloakRestRequestInfo>> beforeData = new ConcurrentHashMap<>();
public static Map<Long, Boolean> transaction = new ConcurrentHashMap<>();
@Before(("@annotation(com.global.module.keycloak.KeycloakTransactional)"))
public void createTransactional() {
Long threadId = Thread.currentThread().getId();
transaction.put(threadId, true);
}
@After(("@annotation(com.global.module.keycloak.KeycloakTransactional)"))
public void deleteTransactional() {
Long threadId = Thread.currentThread().getId();
transaction.remove(threadId);
beforeData.remove(threadId);
}
@AfterReturning("@annotation(com.global.module.keycloak.KeycloakTransactional)")
public void transactionalReturning() { // update, delete
log.info("keycloak returning request transaction committed ..");
Long threadId = Thread.currentThread().getId();
List<KeycloakRestRequestInfo> committedDatas = beforeData.get(threadId);
for (KeycloakRestRequestInfo committedData : committedDatas) {
switch (committedData.getMethod()) {
case UPDATE:
committedUpdateTransactional(committedData);
break;
case DELETE:
committedDeleteTransactional(committedData);
break;
}
}
}
@AfterThrowing("@annotation(com.module.keycloak.KeycloakTransactional)")
public void transactionalThrowing() { // create
log.info("keycloak throwing request transaction committed ..");
Long threadId = Thread.currentThread().getId();
List<KeycloakRestRequestInfo> committedDatas = beforeData.get(threadId);
for (KeycloakRestRequestInfo committedData : committedDatas) {
switch (committedData.getMethod()) {
case CREATE: // create 경우에는 uuid 가 필요하기 때문에 exception 시 delete 하도록
rollbackCreateTransactional(committedData);
break;
}
}
}
먼저, Transaction 처리를 해주는 AOP 메서드들이다.
@KeycloakTransactional 이라는 Annotation 을 달면 위의 4가지의 AOP 들이 상황에 맞춰서 진행된다.
1. @Before
@KeycloakTransactional Annotataion 이 달린 메서드가 실행되기 전에 발생한다.
Annotation 이 달렸다면 메소드가 실행되기 전에 실행중인 Thread 를 Transaction 처리 할 건지 상태 값을 static 데이터(trasaction)에 Thread Id 를 키 값으로 저장한다.
참고로 이 데이터는 static 메모리에 저장되기 때문에 모든 Thread 에서 접근할 수 있기 때문에 동시성 이슈를 방지하기 위해서 ConcurrnetHashMap 으로 만들었다.
2. @After
@KeycloakTransactional Annotataion 이 달린 메서드가 실행된 후에 발생한다.
Annotataion 달린 메소드가 종료되었다면 Transaction 처리를 위해서 종료된 Thread Id 키 값으로 저장했던 각종 static 데이터들모두 지워준다.
3. @AfterReturning
@KeycloakTransactional Annotataion 이 달린 메소드가 정상적으로 실행이 된 후 발생한다.
참고로 @AfterReturning -> @After 순으로 실행된다!
메소드가 정상적으로 실행이 됐다면 실행된 Thread Id로 저장된 Transaction 처리가 필요한 데이터들을 commit (여기선 Rest 통신 처리) 한후 최종 반영한다.
4. @AfterThrwoing
@KeycloakTransactional Annotataion 이 달린 메서드가 실행 중에 예외가 생기면 발생한다.
이 부분은 기능 설계상의 이유로 다음과 같이 개발이 되었고
CREATE 요청에 한해서만 CREATE 를 한 후, 예외가 생기면 DELETE 하는 방식으로 Transaction 처리를 한다.
그렇다면 언제 Transaction 관련한 static 데이터들을 세팅할까?
UPDATE 를 예시로 정리해보자
public void update(T obj) {
if (obj instanceof UserRepresentation) {
UserRepresentation userRepresentation = (UserRepresentation) obj;
updateUser(userRepresentation.getId(), userRepresentation);
return;
}
if (obj instanceof GroupRepresentation) {
GroupRepresentation groupRepresentation = (GroupRepresentation) obj;
updateTenant(groupRepresentation.getId(), groupRepresentation);
return;
}
}
private void updateTenant(String uuid, GroupRepresentation groupRepresentation) {
groupRepresentation.setId(uuid);
Long threadId = Thread.currentThread().getId();
if (transaction.get(threadId) != null && transaction.get(threadId)) {
createKeycloakRestRequestInfo(UPDATE, TENANT, groupRepresentation);
} else {
updateTenantCommitted(groupRepresentation);
}
}
Generic 을 기반으로 CRUD 요청이 오게 되면 instanceof 를 활용하여 entity mapping 작업을 한다.
JPA 처럼 다이내믹하게 entity 를 mapping 하기엔 일반 데이터베이스들 처럼 정해진 쿼리문법이 있는게 아니고
entity 별로 CRUD 처리를 하는 라이브러리 사용 방식이 다르기 때문에 위처럼 구성했다.
entity 를 mapping 한 후 실제로 update 처리를 진행한다.
진행 직전에 현재 실행 중인 Thread 가 Transaction 처리를 설정한 Thread 라면 바로 commit 처리하지 않고
적절한 Enum 변수를 세팅하여 static 메모리에 넣어준다.
그 후 @KeycloakTransactional Annotation 이 달린 메서드가 정상적으로 마무리가 되면
@AfterReturning AOP 가 실행되며 실제 static 메모리에 데이터를 기반으로 commit 처리를 한다!
두 번째 클래스를 살펴보자.
private final KeycloakTransactionManager<UserRepresentation> userKeycloakTransactionManager;
public void updateUserById(String uuid, String email, String name, String password) {
Map<Enum, String> fields = new HashMap<>();
fields.put(ID, uuid);
fields.put(EMAIL, email);
fields.put(NAME, name);
fields.put(PASSWORD, password);
UserRepresentation userRepresentation =
userKeycloakTransactionManager.create(fields, new UserRepresentation());
userKeycloakTransactionManager.update(userRepresentation);
}
KeycloakTransactionManager 는 Generic 기반으로 사용하기 때문에 원하는 Entity Object 를 담아서 할당 후 사용한다.
이거는 JPA 의 Repository 방식을 분석하면서 아이디어를 떠올렸다.
그 후, 비즈니스 로직 설계에 따라서 updateUserById() 메서드 제일 아랫줄에 적힌 내용처럼
원하는 Entity Generic 이 반영된 TransactioManager 를 활용해서
add()
delete()
update()
get()
메서드를 호출해주면 끝난다!
즉, 외부 패키지에서 keycloak 에 데이터를 CRUD 할 일이 있다면 위 클래스만 DI 하여 호출하고
Transaction 처리가 필요하다면 @KeycloakTransactional Annotation 만 달아주면 된다!
코드를 모두 업로드할 수 없어 복잡하다고 생각 드실 수 있지만, 되게 간단하다.
1. @KeycloakTrasactional Annotation 이 달려있다면 실행 중인 Thread 를 Transaction 설정해준다.
2. 기능 설계에 맞춰 비즈니스 로직 내에 KeycloakService 에 CUD 요청을 호출한다.
3. add() delete() update() 요청이 들어오면 요청을 한 Thread 가 Transaction 이 설정된 Thread 인지 확인한다.
3. Transaction 을 설정하지 않았으면 바로 commit 처리한다.
4. Transaction 을 설정했다면 static 메모리에 잠시 보관한다.
5. @KeycloakTrasactional Annotation 달린 메서드가 정상적으로 끝나면 @AfterReturning AOP 에서 commit 처리를 한다.
이것저것 테스트를 해본다고 해봤는데, 버그 없는 기능은 없다고 하지 않던가..
하지만... 정말 버그가 없었으면 좋겠다.
모쪼록 지금 개발 중인 서비스에서 간편하게 Transaction 처리를 할 수 있게 되어 굉장히 기쁘다
그럼 안녕!
'Programing > Spring' 카테고리의 다른 글
WAS (Spring Boot) - DB 성능 개선과 최적화 (3) - RestClient (0) | 2022.06.02 |
---|---|
Spring 양방향 Dependency Injection (0) | 2022.05.29 |
WAS (Spring Boot) - DB 성능 개선과 최적화 (2) - RestTemplate (0) | 2022.05.14 |
WAS (Spring Boot) - DB 성능 개선과 최적화 (1) - JDBC (0) | 2022.05.14 |
Spring Security Provider Customizing 하기 (2) (0) | 2022.04.01 |