프로젝트를 진행하면서 사용자 승인 프로세스에서 흥미로운 과제를 마주했습니다.
DB와 AWS Cognito라는 외부 서비스 간의 데이터 일관성을 유지해야 하는 상황이었는데, 특히 Cognito 작업이 실패할 경우 이미 수행된 DB 작업을 롤백해야 하는 요구사항이 있었습니다.
이를 어떻게 안전하고 깔끔하게 구현할 수 있을지 고민이 필요했습니다.
기존의 사용자 승인 프로세스는 컨트롤러에서 직접 처리되고 있었습니다.
@PostMapping("/{userEmail}/approve/student")
public ResponseEntity<Void> approveStudent(
@PathVariable("userEmail") String email,
@RequestBody StudentApprovalRequestDTO request) {
// 1. DB 작업
String courseName = adminUserService.approveStudent(email, request);
// 2. Cognito 그룹 변경
cognitoService.changeUserGroup(request.getUsername(), "STUDENT");
// 3. 이메일 발송
NotificationEvent event = NotificationEvent.of(...)
eventPublisher.publishEvent(event);
return ResponseEntity.ok().build();
}
이 코드에는 아래와 같은 문제점이 있었습니다.
처음에는 다양한 해결 방안을 검토했습니다.
Saga 패턴
이벤트 기반 아키텍처
보상 트랜잭션 (Compensating Transaction)
먼저 Spring의 트랜잭션 관리 기능만을 활용하는 방식을 시도했습니다.
@Transactional
public String approveStudent(String email, StudentApprovalRequestDTO request) {
try {
String courseName = adminUserService.approveStudent(email, request);
cognitoService.changeUserGroup(request.getUsername(), STUDENT_GROUP);
return courseName;
} catch (Exception e) {
log.error("Failed to approve student: {}", email, e);
throw new ApprovalException(ErrorCode.APPROVAL_FAILED, e);
}
}
하지만 이 방식은 롤백 처리의 명시성이 부족했기에 다른 방안을 선택하기로 했습니다.
신중한 검토 끝에 보상 트랜잭션 패턴을 적용하기로 결정했습니다. 이 패턴은 다음과 같은 장점이 있는 패턴입니다.
비즈니스 로직을 관리할 UserApprovalManager 클래스를 구현했습니다.
@Service
@RequiredArgsConstructor
@Slf4j
public class UserApprovalManager {
private static final String STUDENT_GROUP = "STUDENT";
private static final String INSTRUCTOR_GROUP = "INSTRUCTOR";
private final AdminUserService adminUserService;
private final CognitoService cognitoService;
@Transactional
public String approveStudent(String email, StudentApprovalRequestDTO request) {
try {
return processStudentApproval(email, request);
} catch (Exception e) {
log.error("Failed to approve student: {}", email, e);
throw new ApprovalException(ErrorCode.APPROVAL_FAILED);
}
}
private String processStudentApproval(String email, StudentApprovalRequestDTO request) {
String courseName = adminUserService.approveStudent(email, request);
try {
cognitoService.changeUserGroup(request.getUsername(), STUDENT_GROUP);
return courseName;
} catch (CognitoException e) {
// 명시적 보상 트랜잭션 수행
adminUserService.rollbackStudentApproval(email);
throw e;
}
}
}
실패 시 롤백을 처리할 메서드들을 구현했습니다.
@Service
@RequiredArgsConstructor
public class AdminUserService {
@Transactional
public void rollbackStudentApproval(String email) {
studentRepository.findByEmail(email)
.ifPresent(student -> {
courseStudentRepository.deleteByStudent(student);
studentRepository.delete(student);
});
}
}
비즈니스 로직을 매니저 계층으로 이동하고 컨트롤러를 단순화했습니다:
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
@Slf4j
public class AdminUserManagementController {
private final UserApprovalManager userApprovalManager;
private final ApplicationEventPublisher eventPublisher;
@PostMapping("/{userEmail}/approve/student")
public ResponseEntity<Void> approveStudent(
@PathVariable String email,
@RequestBody StudentApprovalRequestDTO request) {
String courseName = userApprovalManager.approveStudent(email, request);
publishApprovalEvent(email, request.getName(), courseName);
return ResponseEntity.ok().build();
}
}
현재 구현에도 여전히 개선의 여지가 있습니다.
이번 리팩토링을 통해 분산 트랜잭션 환경에서의 데이터 일관성 유지가 얼마나 중요하고도 까다로운 문제인지 다시 한 번 깨달았습니다.
완벽한 해결책은 없겠지만, 현재 상황에서 최선의 방법을 찾아 적용하고 지속적으로 개선해나가는 것이 중요하다는 것을 배웠습니다.