회사에서는 현재 master DB (원천), 멀티테넌트 구조로 운영되는 main DB (메인), common 스키마 하나에 모든 데이터들이 들어있는 Integrate DB (이하 Itg DB)가 있다. 세 db 모두 Postgresql을 사용한다.
내가 작업하는 레포에서는 하나의 서버 애플리케이션에서 3가지의 DB를 모두 접근해야하기에, db별로 JpaRepositoryConfig이름으로 Configuration파일을 다르게 설정해 두었다.
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = {ITG_JPA_REPOSITORY_BASE_PACKAGE},
entityManagerFactoryRef = ITG_ENTITY_MANAGER_FACTORY,
transactionManagerRef = ITG_JPA_TRANSACTION_MANAGER
)
public class ItgJpaRepositoryConfig {
/**
* 트랜잭션 매니저 설정(jpa)
*/
@Bean
@Qualifier(ITG_JPA_TRANSACTION_MANAGER)
public PlatformTransactionManager ItgJpaTransactionManager(
@Qualifier(ITG_ENTITY_MANAGER_FACTORY) LocalContainerEntityManagerFactoryBean factoryBean) {
JpaTransactionManager transactionManager = new JpaTransactionManager(
Objects.requireNonNull(factoryBean.getObject()));
transactionManager.setDefaultTimeout(transactionTimeoutSeconds);
return transactionManager;
}
}
해당 config가 MASTER, MAIN, ITG 3종류로 나뉘어 존재한다.
Spring의 트랜잭션은 기본적으로 "트랜잭션 매니저 단위"로 동작한다.
서로 다른 PlatformTransactionManager는 서로의 상태를 전혀 공유하지 않는다.
따라서 세 DB에 접근하기 위해서는 서로 다른 트랜잭션 매니저를 호출해야 한다.
하나의 비즈니스 로직 내에서 서로 다른 세 트랜잭션을 열기에, 트랜잭션 상태 관리가 매우 중요하다.
서로 다른 세 DB에 접근하는 비즈니스 로직 내부에서의 트랜잭션 흐름을 알아보기 위해 Codex의 도움을 받아 테스트 코드를 아래와 같이 간단하게 작성해 보았다.
/**
* Rollback verification for four cross-db scenarios:
* 1) itg -> master, master error
* 2) itg -> master, itg error
* 3) master -> itg, itg error
* 4) master -> itg, master error
*/
@SpringJUnitConfig(ItgMasterTransactionRollbackTest.TestConfig.class)
class ItgMasterTransactionRollbackTest {
/**
* itg 트랜잭션내에서 master 트랜잭션 호출, master에서 exception 터짐
*/
@Test
void itgToMaster_masterError_shouldRollbackBoth() {
assertThatThrownBy(() -> itgOuterService.callMasterAndMasterFails())
.isInstanceOf(IllegalStateException.class)
.hasMessage("master fail");
assertTxCount(itgTransactionManager, 1, 0, 1);
assertTxCount(masterTransactionManager, 1, 0, 1);
}
/**
* itg 트랜잭션내에서 master 트랜잭션 호출, itg에서 exception 터짐
*/
@Test
void itgToMaster_itgError_shouldRollbackOnlyItg() {
assertThatThrownBy(() -> itgOuterService.callMasterThenItgFails())
.isInstanceOf(IllegalStateException.class)
.hasMessage("itg fail");
assertTxCount(itgTransactionManager, 1, 0, 1);
assertTxCount(masterTransactionManager, 1, 1, 0);
}
private void assertTxCount(RecordingTransactionManager manager, int begin, int commit, int rollback) {
assertThat(manager.getBeginCount()).isEqualTo(begin);
assertThat(manager.getCommitCount()).isEqualTo(commit);
assertThat(manager.getRollbackCount()).isEqualTo(rollback);
}
}
https://github.com/jay91537/Transaction-Test
GitHub - jay91537/Transaction-Test
Contribute to jay91537/Transaction-Test development by creating an account on GitHub.
github.com
트랜잭션 기본 설정, 전체 테스트코드는 해당 깃헙링크에서 확인 가능하다.
여기서는 롤백을 테스트하는 중요한 로직만 가져와보았다.
1. itg 트랜잭션에서 master 트랜잭션 호출, master에서 Exception 터짐
2. itg 트랜잭션에서 master 트랜잭션 호출, itg에서 Exception터짐
(master에서 itg를 호출하는 경우도 이와 같은 흐름이기에 생략했다)
assertTxCount()는 테스트코드 메서드 내에서 해당 트랜잭션의 begin, commit, rollback 횟수를 나타낸다.
1번의 결과는 프로그램적으로나 사용자가 느끼기에 모두 자연스러운 결과이다.
호출당한 트랜잭션(master)에서 Exception이 던져지고, 호출한 트랜잭션(itg) 쪽으로도 전파돼 두 트랜잭션 모두 rollback 된다.
2번의 결과는 프로그램적으로는 자연스럽지만, 사용자가 느끼기에는 자연스럽지 못한 결과이다.
(예를 들어 회원 가입 비즈니스 로직이 실패했다면, 사용자는 어떤 DB에서 수행된 작업이든 모두 취소되기를 기대하기 때문이다.)
호출당한 트랜잭션(master)은 commit 되어 자연스럽게 닫히지만, 이후 호출 트랜잭션(itg)에서 Exception이 터지면서 itg만 rollback 되어, 사용자가 기대하는 흐름(master와 itg가 모두 rollback 됨)에서 벗어나게 된다.
== 문제 ==
여기서 2번의 결과가 문제가 된다. 아래는 실서비스에서 사용 중인 회원가입 비즈니스 로직을 추상화한 예제이다. (정말 매우 단순하게 표현했다)
@Slf4j
@Service
@RequiredArgsConstructor
public class RegisterFacade {
private final MasterService masterService;
private final MainService mainService;
// itg트랜잭션으로 시작
@Transactional(value = ITG_JPA_TRANSACTION_MANAGER)
public void register(Req req) {
// master 등록 (masterService내부에서 master트랜잭션 새로 열림)
User user = masterService.postUser(req);
// itgService 내부에서 기존 itg트랜잭션 적용
itgService.addAA();
itgService.addBB();
...
// mainService 로직 (mainService 내부에서 main트랜잭션 새로 열림)
mainService.update();
// 추가 로직
itgService.addC();
itgService.sendMail();
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class MasterService {
private final MasterRepository masterRepository;
// master트랜잭션
@Transactional(value = MASTER_JPA_TRANSACTION_MANAGER)
public User postUser(req) {
return masterRepository.save(req.toEntity());
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class MainService {
private final MainRepository mainRepository;
// main트랜잭션
@Transactional(value = MAIN_JPA_TRANSACTION_MANAGER)
public void update() {
mainRepository.update();
}
}
register 메서드에서 masterService.postUser(req); 메서드 실행 이후의 로직에서 Exception이 터지면 itg 혹은 main 트랜잭션 내의 작업은 롤백되지만, masterService 메서드의 작업내역은 롤백되지 못한다.
mainService.update()도 같은 이유로 롤백되지 못하는 이슈가 존재한다.
=== 해결 ===
이를 해결하기 위해 보상트랜잭션을 적용했고, 가장 간단한 방법인 try catch를 이용해 보았다.
// itg트랜잭션으로 시작
@Transactional(value = ITG_JPA_TRANSACTION_MANAGER)
public void register(Req req) {
// master 등록 (masterService내부에서 master트랜잭션 새로 열림)
User user = masterService.postUser(req);
try {
// itgService 내부에서 기존 itg트랜잭션 적용
itgService.addAA();
itgService.addBB();
itgService.addC();
...
masterService.update();
// mainService 내부에서 main트랜잭션 새로 열림
mainService.update();
itgService.sendMail();
} catch(Exception e) {
log.error();
// 역순으로 보상
mainService.compensateUpdate();
masterService.deleteUser(user);
throw e;
}
}
}
이를 통해 분리된 여러 트랜잭션을 보상하는 로직을 구현했지만, 문제가 몇 가지 있다.
1. 과연 해당 로직은 원자성(Atomicity)을 보장하는가
원자성이라는 것은 "전체가 한 번에 성공하거나, 아니면 전부 취소되어야 한다"를 뜻한다.
그러나 master 혹은 main 트랜잭션이 일단 커밋된 순간 원자성은 깨지게 되고, 결국 보상트랜잭션도 이를 수습하기 위한 사후처리일 뿐이다.
2. 메서드 실행 중 데이터 불일치 시점 발생
master트랜잭션이 커밋되고, main 혹은 itg 트랜잭션이 실패할 경우 catch로 넘어가서 보상 로직이 실행되기 전까지는 master DB에 user가 커밋된 상태로 그대로 남아있게 돼 데이터 불일치가 발생하는 시점이 존재한다.
특히 메서드의 크기가 크고 보상로직이 길어질수록 불일치 기간은 더 길어질 것이다.
3. 보상 트랜잭션 자체가 실패하는 경우
가장 위험한 부분이라고 생각한다.
1, 2번의 경우는 사용자의 행동과 UX에 크게 영향을 미치지 않을 수 있다.
그러나 보상 자체가 실패할 경우, 잘못되었음을 감지한 사용자가 다시 한번 같은 행동을 취하면 이미 커밋된 데이터들로 인해 또다시 Exception을 뱉어낼 것이다.
(2번도 마찬가지로 데이터 불일치 기간에 사용자가 같은 행동을 취할 경우 Exception을 뱉겠지만, 결국 원복 된다는 점에서 3번보다는 덜 위험하다고 생각한다.)
원복이 되지 않기 때문에 결국 사용자는 회원가입을 절대 다시는 할 수 없다.
=== 개선 작업 ===
관련해서 찾아보던 중, 강한 일관성(Strong Consistency)과 최종적 일관성(Eventual Consistency)이라는 것을 알게 되었다.
앞서 내가 설명한 보상트랜잭션은 최종적 일관성이라는 말 아래로 정리가 된다.
그래서 해당 회원가입 로직이 강한 일관성과 최종적 일관성 중 어느 것을 보장해야 하는지 생각해 보았다.
회원가입 로직(화면)은 우리 서비스를 사용해야겠다는 마음을 굳게 먹은 잠재고객과 처음 마주하는 인터페이스(?)인데, 이 일련의 과정이 실패했을 때 복구까지 시간이 오래 걸린다면 고객은 바로 서비스를 떠나버릴 것이라는 결론이 나왔다.
따라서 강한 일관성을 보장하는 것이 맞지만, 현재의 보상로직이 복잡하지 않고, 다수의 DB에 접근하는 로직이 많지 않기에 보상과정에서 최종적 일관성을 보장하는 방향으로 작업하기로 했다.
방법을 생각해보던 중, 회원가입 API 호출 시 동작하는 여러 작업들이 서로 다른 두 가지 성격을 지니고 있는 것을 파악했다.
이를 회원가입 시 동기적(예외 발생 시 회원가입 불가)으로 진행되어야 하는 작업과, 비동기적(예외 발생해도 회원가입 가능)으로 진행될 수 있는 작업으로 분리했다.
동기 작업의 경우에는 회원가입 즉시 반영되지 않으면 사용자가 서비스를 정상적으로 사용할 수 없게 되는 로직들로, 예외 발생 시 회원가입 불가로 처리하기로 했다.
비동기 작업의 경우에는 회원가입 즉시 반영되지 않아도 사용자의 서비스 이용에 영향을 주지 않는 로직들로, 예외가 발생해도 회원가입이 가능하도록 처리하기로 했다.
비동기 작업의 예시: 이용약관 동의 여부(단순 boolean컬럼 업데이트), 가입 완료 메일 발송 등
그리고 앞서 중요하게 생각했던 보상 실패를 보장하는 작업도 당연히 비동기 작업으로 두기로 결정했다.
비동기로 분리한 작업은 별도로 DB에서 테이블로 관리되도록 했고, 주기적으로 스케줄러가 비동기 task를 DB에서 읽어 이를 실행시키는 방식으로 작업했다.
비동기 작업 유형 (AsyncTaskType) 의 종류는 아래와 같이 지정했다.
회원 가입 시 진행되어야 하는 비동기 작업 - REGISTER_POST_PROCESS
보상트랜잭션 비동기 작업 - MASTER_CLEANUP
회사 코드라, 주석으로 흐름만 설명해두었다.
@Component
@RequiredArgsConstructor
public class AsyncTaskWorker {
private final AsyncTaskService asyncTaskService;
@Scheduled(fixedDelayString = "${async.task.fixed-delay-ms:30000}")
public void processPendingTasks() {
asyncTaskService.processPendingTasks();
}
}
비동기 작업을 주기적으로 스케줄링하는 워커
30초에 한 번 주기적으로 DB에서 polling해온다.
@Slf4j
@Service
@RequiredArgsConstructor
public class AsyncTaskService {
public void processPendingTasks() {
// 실행되어야 하는 비동기 작업 가져오기
// 비동기 작업 실행
for (AsyncTask task : tasks) {
processTask(task);
}
}
private void processTask(AsyncTask task) {
try {
// 비동기 작업 실행 및 db에 완료 상태로 업데이트
} catch (Exception e) {
log.error("비동기 작업 실패");
// 실패 상태로 업데이트
}
}
private void updateRetry(AsyncTask task, Exception exception) {
// 실패 상태 업데이트
// 3회 실패 시 슬랙 알림 전송
if (reachMaxRetry) {
slackNotifier.sendAlarm();
}
}
}
비동기 작업 worker가 호출하는 AsyncTaskService
실행되어야 하는 비동기 작업을 가져온 후 해당 작업이 성공하면 완료 상태로 업데이트, 실패하면 실패 상태로 업데이트하고
3회 실패된 작업은 슬랙 알림으로 전송한다.
@Transactional(value = ITG_JPA_TRANSACTION_MANAGER)
public void register(Req req) {
// master 등록 (masterService내부에서 master트랜잭션 새로 열림)
User user = masterService.postUser(req);
try {
// 회원가입과 동시에 세팅이 필요한 데이터 삽입 (세팅 안되면 회원가입 불가)
initializeRegisterDefaults();
// 비동기 작업 메타데이터 생성
RegisterTaskPayload taskPayload = RegisterTaskPayload.of();
// 비동기 작업 메타데이터 DB에 저장
asyncTaskService.save(AsyncTaskType.REGISTER_POST_PROCESS, taskPayload);
} catch (Exception e) {
// 보상로직 수행
handleRegisterFailure(corp.getCorpSbtId(), e);
throw new Exception("회원가입 실패", e);
}
}
// 회원가입과 동시에 세팅이 필요한 데이터 삽입 (세팅 안되면 회원가입 불가)
private void initializeRegisterDefaults() {
itgService.addAA();
itgService.addBB();
itgService.addC();
}
// 보상로직
private void handleRegisterFailure(Exception registerException) {
log.error(registerException.getMessage(), registerException);
try {
// 보상작업 진행
masterService.deleteUser();
} catch (Exception cleanupException) {
log.error("회원가입 실패 보상 작업 중 에러 발생");
try {
// 보상 실패 시 이를 비동기 task로 DB에 저장
} catch (Exception enqueueException) {
// 비동기 task 저장마저 실패 시 개발자에게 슬랙알림 (비상상황)
slackAlert.send();
log.error("가입 실패 보상 작업 저장 실패", enqueueException);
}
}
}
수정된 회원가입 로직
1. 회원가입 성공 흐름:
회원가입 API 호출 -> 필수 데이터 세팅 -> 비동기 task 저장 -> 회원가입 성공 -> 스케줄러가 비동기 작업 실행 -> 실패 시 계속 재시도 가능 (max 3회) -> 3회 실패 시 개발자에게 슬랙 알림
2. 회원가입 실패 후 보상 흐름:
회원가입 API 호출 -> 필수 데이터 세팅 중 Exception 발생 -> 회원가입 실패 -> 보상 성공 -> 3 DB 모두 데이터 원복 완료 -> 사용자는 다시 회원가입 가능
3. 회원가입 실패 후 보상 실패 흐름:
회원가입 API 호출 -> 필수 데이터 세팅 중 Exception 발생 -> 회원가입 실패 -> 보상 실패 -> 보상 작업을 비동기 task로 저장 -> 스케줄러가 비동기 작업 실행 -> 성공 시 사용자는 다시 회원가입 가능 -> 실패 시 계속 재시도 (max 3회) -> 3회 실패 시 개발자에게 슬랙 알림
4. 회원가입 실패 -> 보상 실패 -> 보상 작업을 비동기 task로 저장하는 작업마저 실패하는 흐름:
회원가입 API 호출 -> 필수 데이터 세팅 중 Exception 발생 -> 회원가입 실패 -> 보상 실패 -> 보상 작업을 비동기 task로 저장실패 -> 개발자에게 슬랙 알림 -> 개발자가 수동 대응 -> 사용자는 다시 회원가입 가능
수정한 로직에도 위에서 볼 수 있듯이 막을 수 없는 크리티걸한 상황이 두 가지 존재하는데,
1. 회원가입 실패 -> 보상 실패 -> 비동기 task 저장 마저 실패할 경우
2. 비동기 task가 계속 실패해서 MAX_RETRY_COUNT(3회)를 넘어갈 경우
해당 허점은 슬랙 알림을 통해 개발자가 즉시 인지하고 조치를 취할 수 있도록 작업했다.
'개발' 카테고리의 다른 글
| [Java, Spring, Aop] SoftDelete 적용기 (2) (2) | 2026.02.14 |
|---|---|
| [Java, Spring, Aop] SoftDelete 적용기 (2) | 2026.01.01 |
| 벤처투자 ERP 개발을 위한 도메인 공부 (1) (1) | 2025.09.20 |
| 이미지(파일) 업로드 개선 (6) | 2025.06.07 |
| 배포 전략 (2) | 2025.05.05 |