배경

프로젝트를 진행하면서 사용자 승인 프로세스에서 흥미로운 과제를 마주했습니다.

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();
}

이 코드에는 아래와 같은 문제점이 있었습니다.

  • 트랜잭션 경계가 불명확
  • DB와 Cognito 작업 간의 원자성 보장 불가
  • 실패 시 롤백 처리 부재

해결 방향 고민

1. 고려했던 접근 방식들

처음에는 다양한 해결 방안을 검토했습니다.

  1. Saga 패턴

    • MSA 환경에서 주로 사용
    • 현재 모놀리식 구조에는 오버엔지니어링이라고 판단
  2. 이벤트 기반 아키텍처

    • 비동기 처리로 인한 일시적 불일치 허용 필요
    • 실시간 피드백이 어려움
  3. 보상 트랜잭션 (Compensating Transaction)

    • 구현이 상대적으로 단순
    • 명시적인 롤백 처리 가능
    • 동기 처리로 즉각적인 피드백 가능

2. Spring 트랜잭션 활용 시도

먼저 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);
    }
}

하지만 이 방식은 롤백 처리의 명시성이 부족했기에 다른 방안을 선택하기로 했습니다.

3. 최종 선택: 보상 트랜잭션 패턴

신중한 검토 끝에 보상 트랜잭션 패턴을 적용하기로 결정했습니다. 이 패턴은 다음과 같은 장점이 있는 패턴입니다.

  • 각 단계별 실패 처리가 명확
  • 롤백 로직에 대한 직접적인 제어 가능

구현 세부사항

1. 매니저 계층 도입

비즈니스 로직을 관리할 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;
        }
    }
}

2. 보상 트랜잭션 구현

실패 시 롤백을 처리할 메서드들을 구현했습니다.

@Service
@RequiredArgsConstructor
public class AdminUserService {
    @Transactional
    public void rollbackStudentApproval(String email) {
        studentRepository.findByEmail(email)
                .ifPresent(student -> {
                    courseStudentRepository.deleteByStudent(student);
                    studentRepository.delete(student);
                });
    }
}

3. 컨트롤러 개선

비즈니스 로직을 매니저 계층으로 이동하고 컨트롤러를 단순화했습니다:

@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();
    }
}

개선 결과

1. 아키텍처 개선

  • 책임 분리를 통한 관심사 분리 달성
  • 계층별 명확한 역할 정의
  • 비즈니스 로직의 응집도 향상

2. 안정성 향상

  • DB 트랜잭션의 원자성 보장
  • 외부 서비스 실패에 대한 견고한 처리
  • 명시적인 롤백 메커니즘

3. 운영 관점의 이점

  • 장애 상황에서의 데이터 정합성 보장

앞으로의 과제

현재 구현에도 여전히 개선의 여지가 있습니다.

장애 복구 메커니즘 강화

  • 롤백 실패 시나리오 대응
  • 재시도 정책 수립

성능 최적화

  • 트랜잭션 범위 최적화

마치며

이번 리팩토링을 통해 분산 트랜잭션 환경에서의 데이터 일관성 유지가 얼마나 중요하고도 까다로운 문제인지 다시 한 번 깨달았습니다.

완벽한 해결책은 없겠지만, 현재 상황에서 최선의 방법을 찾아 적용하고 지속적으로 개선해나가는 것이 중요하다는 것을 배웠습니다.

profile
일관성 있는 개발자

0개의 댓글

Powered by GraphCDN, the GraphQL CDN