들어가며
최근 TaskFlow 프로젝트에서 인증 시스템을 구현하면서 이벤트 기반 아키텍처를 도입했습니다. 회원가입 시 Auth 엔티티가 저장되면 UserRegisteredEvent를 발생시켜 User 프로필을 생성하는 방식으로 도메인 간 결합도를 낮추려고 했습니다.
하지만 실제 구현 과정에서 여러 복잡한 문제들을 마주하게 되었고, 이를 해결하기 위해 다양한 방법들을 검토해보았습니다. 이 글에서는 이벤트 기반 아키텍처 구현 시 실제로 마주할 수 있는 도전과제들과 각각의 해결책들을 정리해보겠습니다.
현재 구현된 이벤트 시스템
@EventListener
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleUserRegistered(UserRegisteredEvent event) {
try {
User user = new User(event.getUserId(), event.getName(), event.getEmail());
userRepository.save(user);
log.info("사용자 프로필 생성 완료: userId={}", event.getUserId());
} catch (Exception e) {
log.error("사용자 프로필 생성 실패: userId={}", event.getUserId());
throw e;
}
}
이 방식은 간단해 보이지만, 실제 운영 환경에서는 여러 복잡한 문제들이 발생할 수 있습니다.
1. 데이터 일관성 문제
문제 상황
비동기 이벤트 처리에서 가장 큰 문제는 데이터 일관성입니다. Auth 엔티티는 성공적으로 저장되었지만 User 프로필 생성이 실패하는 경우가 발생할 수 있습니다.
해결책 1: Transactional Outbox Pattern
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String aggregateId;
private String eventType;
private String eventData;
private LocalDateTime createdAt;
private boolean processed;
}
@Service
@Transactional
public class AuthService {
public void signup(SignupRequestDto request) {
Auth auth = authRepository.save(new Auth(...));
// 이벤트를 DB에 저장 (같은 트랜잭션)
OutboxEvent event = new OutboxEvent(
auth.getId().toString(),
"UserRegistered",
objectMapper.writeValueAsString(new UserRegisteredEvent(...))
);
outboxEventRepository.save(event);
}
}
@Component
public class OutboxEventProcessor {
@Scheduled(fixedDelay = 5000)
@Transactional
public void processOutboxEvents() {
List<OutboxEvent> unprocessedEvents =
outboxEventRepository.findByProcessedFalse();
for (OutboxEvent event : unprocessedEvents) {
try {
eventPublisher.publishEvent(deserializeEvent(event));
event.markAsProcessed();
} catch (Exception e) {
// 재시도 로직 또는 DLQ 전송
}
}
}
}
해결책 2: Event Status Tracking
@Entity
@Table(name = "event_status")
public class EventStatus {
@Id
private String eventId;
private String status; // PENDING, PROCESSING, COMPLETED, FAILED
private int retryCount;
private LocalDateTime lastRetryAt;
private String errorMessage;
}
@EventListener
@Async
public void handleUserRegistered(UserRegisteredEvent event) {
EventStatus status = new EventStatus(event.getEventId(), "PROCESSING");
eventStatusRepository.save(status);
try {
userRepository.save(new User(...));
status.markAsCompleted();
} catch (Exception e) {
status.markAsFailed(e.getMessage());
if (status.getRetryCount() < 3) {
scheduleRetry(event, status.getRetryCount() + 1);
}
} finally {
eventStatusRepository.save(status);
}
}
해결책 3: Saga Pattern : 제가 개인적으로 가장 적용해보고싶은 패턴입니다.
복잡한 비즈니스 프로세스에서 여러 서비스 간의 분산 트랜잭션을 관리하기 위한 패턴입니다.
Orchestration-based Saga
@Component
public class UserRegistrationSaga {
public void executeUserRegistration(SignupRequestDto request) {
String sagaId = UUID.randomUUID().toString();
SagaTransaction saga = new SagaTransaction(sagaId);
try {
// Step 1: Auth 생성
Long authId = authService.createAuth(request);
saga.addCompensation(() -> authService.deleteAuth(authId));
// Step 2: User 프로필 생성
Long userId = userService.createUserProfile(authId, request);
saga.addCompensation(() -> userService.deleteUserProfile(userId));
// Step 3: 환영 이메일 발송
notificationService.sendWelcomeEmail(request.getEmail());
saga.markAsCompleted();
} catch (Exception e) {
saga.compensate(); // 실패 시 보상 트랜잭션 실행
throw new UserRegistrationFailedException("회원가입 처리 중 오류가 발생했습니다.", e);
}
}
}
@Component
public class SagaTransaction {
private final List<Runnable> compensations = new ArrayList<>();
public void addCompensation(Runnable compensation) {
compensations.add(compensation);
}
public void compensate() {
Collections.reverse(compensations); // 역순으로 보상 작업 실행
for (Runnable compensation : compensations) {
try {
compensation.run();
} catch (Exception e) {
log.error("보상 작업 실패: {}", e.getMessage());
}
}
}
}
Choreography-based Saga
@Service
public class UserService {
@EventListener
@Transactional
public void handleAuthCreated(AuthCreatedEvent event) {
try {
User user = userRepository.save(new User(...));
eventPublisher.publishEvent(new UserProfileCreatedEvent(
user.getId(), event.getEmail(), event.getSagaId()
));
} catch (Exception e) {
// 보상 이벤트 발행 - Auth 삭제 요청
eventPublisher.publishEvent(new CompensateAuthCreationEvent(
event.getAuthId(), event.getSagaId()
));
}
}
}
Saga 상태 관리
@Entity
@Table(name = "saga_instances")
public class SagaInstance {
@Id
private String sagaId;
@Enumerated(EnumType.STRING)
private SagaStatus status; // ACTIVE, COMPLETED, COMPENSATED, FAILED
private String currentStep;
private LocalDateTime createdAt;
private String errorMessage;
}
@Service
public class SagaManager {
// 장시간 실행되는 Saga 감지
@Scheduled(fixedRate = 300000)
public void checkStuckSagas() {
LocalDateTime threshold = LocalDateTime.now().minusHours(1);
List<SagaInstance> stuckSagas = sagaRepository
.findByStatusAndUpdatedAtBefore(SagaStatus.ACTIVE, threshold);
for (SagaInstance saga : stuckSagas) {
log.warn("장시간 실행 중인 Saga 감지: {}", saga.getSagaId());
alertService.sendManualInterventionAlert(saga.getSagaId());
}
}
}
Saga Pattern의 특징:
- 장점: 분산 환경에서 데이터 일관성 보장, 각 서비스의 자율성 유지
- 단점: 구현 복잡도 증가, 보상 로직 설계의 어려움
- 적용 시점: 여러 서비스에 걸친 복잡한 비즈니스 프로세스 처리 시
2. 성능 및 메모리 문제
문제 상황
대량의 이벤트가 발생할 때 스레드 풀 고갈이나 메모리 부족 문제가 발생할 수 있습니다.
해결책 1: Virtual Thread 활용 (Java 21+)
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
해결책 2: Message Queue 활용
@Component
public class EventMessageProducer {
@RabbitListener(queues = "user.events")
public void handleUserEvent(UserRegisteredEvent event) {
// 비동기 처리 로직
}
@EventListener
public void onUserRegistered(UserRegisteredEvent event) {
rabbitTemplate.convertAndSend("user.events", event);
}
}
3. 순환 의존성과 무한 루프
문제 상황
이벤트가 또 다른 이벤트를 발생시키면서 순환 참조나 무한 루프가 발생할 수 있습니다.
해결책 1: Netflix 방식 - Circuit Breaker for Events
@Component
public class EventCircuitBreaker {
private final Map<String, AtomicInteger> eventChainDepth = new ConcurrentHashMap<>();
private final Map<String, Set<String>> eventHistory = new ConcurrentHashMap<>();
public boolean isEventAllowed(String eventType, String contextId) {
// 이벤트 체인 깊이 제한 (최대 3단계)
AtomicInteger depth = eventChainDepth.computeIfAbsent(contextId, k -> new AtomicInteger(0));
if (depth.get() >= 3) {
return false;
}
// 같은 이벤트 타입 재발생 차단
Set<String> history = eventHistory.computeIfAbsent(contextId, k -> new HashSet<>());
if (history.contains(eventType)) {
return false;
}
return true;
}
public void recordEvent(String eventType, String contextId) {
eventChainDepth.get(contextId).incrementAndGet();
eventHistory.get(contextId).add(eventType);
}
}
@EventListener
public void handleEvent(DomainEvent event) {
String contextId = event.getContextId();
String eventType = event.getClass().getSimpleName();
if (!eventCircuitBreaker.isEventAllowed(eventType, contextId)) {
log.warn("이벤트 체인 제한으로 인해 차단됨: {}", eventType);
return;
}
eventCircuitBreaker.recordEvent(eventType, contextId);
// 실제 이벤트 처리 로직
}
해결책 2: Amazon 방식 - Event Sourcing + CQRS
@Entity
@Table(name = "event_store")
public class EventStore {
@Id
private String eventId;
private String aggregateId;
private String eventType;
private String eventData;
private LocalDateTime timestamp;
private String causationId; // 이 이벤트를 발생시킨 원인 이벤트 ID
private String correlationId; // 연관된 이벤트들의 그룹 ID
}
@Service
public class EventChainAnalyzer {
public void analyzeEventChain(String correlationId) {
List<EventStore> events = eventStoreRepository
.findByCorrelationIdOrderByTimestamp(correlationId);
// 이벤트 체인 분석
if (hasCircularDependency(events)) {
alertService.sendAlert("순환 의존성 감지: " + correlationId);
}
}
private boolean hasCircularDependency(List<EventStore> events) {
Set<String> processedEvents = new HashSet<>();
for (EventStore event : events) {
if (processedEvents.contains(event.getEventType())) {
return true; // 같은 타입의 이벤트가 다시 발생
}
processedEvents.add(event.getEventType());
}
return false;
}
}
4. 디버깅과 추적의 복잡성
문제 상황
비동기 이벤트 처리로 인해 문제 발생 시 원인 추적이 어려워집니다.
해결책: Distributed Tracing
@EventListener
@NewSpan("user-profile-creation")
public void handleUserRegistered(UserRegisteredEvent event) {
Span span = Span.current();
span.setTag("user.id", event.getUserId().toString());
span.setTag("event.type", "UserRegistered");
try {
userRepository.save(new User(...));
span.setTag("result", "success");
} catch (Exception e) {
span.setTag("result", "failure");
span.setTag("error.message", e.getMessage());
throw e;
}
}
5. 테스트 코드 복잡성
문제 상황
비동기 이벤트 처리로 인해 테스트 코드 작성이 복잡해집니다.
해결책: Test Event Listener
@TestConfiguration
public class TestEventConfig {
@Bean
@Primary
public ApplicationEventPublisher testEventPublisher() {
return new TestEventPublisher();
}
}
@Component
public class TestEventCapture {
private final List<Object> capturedEvents = new ArrayList<>();
@EventListener
public void captureEvent(Object event) {
capturedEvents.add(event);
}
public <T> List<T> getEventsOfType(Class<T> eventType) {
return capturedEvents.stream()
.filter(eventType::isInstance)
.map(eventType::cast)
.collect(Collectors.toList());
}
public void clear() {
capturedEvents.clear();
}
}
@SpringBootTest
class AuthServiceTest {
@Autowired
private TestEventCapture eventCapture;
@Test
void 회원가입_시_사용자_등록_이벤트_발생() {
// given
SignupRequestDto request = new SignupRequestDto(...);
// when
authService.signup(request);
// then
List<UserRegisteredEvent> events =
eventCapture.getEventsOfType(UserRegisteredEvent.class);
assertThat(events).hasSize(1);
assertThat(events.get(0).getUserId()).isEqualTo(expectedUserId);
}
}
6. 모니터링과 관찰성 문제
해결책: 종합적인 모니터링 시스템
@Component
public class EventMetrics {
private final MeterRegistry meterRegistry;
private final Counter eventPublishedCounter;
private final Counter eventProcessedCounter;
private final Timer eventProcessingTime;
public EventMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.eventPublishedCounter = Counter.builder("events.published")
.register(meterRegistry);
this.eventProcessedCounter = Counter.builder("events.processed")
.register(meterRegistry);
this.eventProcessingTime = Timer.builder("events.processing.time")
.register(meterRegistry);
}
@EventListener
public void onApplicationEvent(ApplicationEvent event) {
eventPublishedCounter.increment(
Tags.of("event.type", event.getClass().getSimpleName())
);
}
public void recordProcessingTime(String eventType, Duration duration) {
eventProcessingTime.record(duration,
Tags.of("event.type", eventType));
}
}
결론
이벤트 기반 아키텍처는 시스템의 결합도를 낮추고 확장성을 높이는 강력한 패턴이지만, 동시에 데이터 일관성, 성능, 순환 의존성, 디버깅 등 여러 복잡한 문제들을 동반합니다.
각 문제에 대한 해결책들을 정리하면 다음과 같습니다.
데이터 일관성: Transactional Outbox Pattern, Event Status Tracking
성능 문제: Virtual Thread, Message Queue 활용
순환 의존성: Circuit Breaker, Event Sourcing 적용
디버깅 복잡성: Distributed Tracing 도입
테스트 복잡성: Test Event Listener 구현
모니터링: 종합적인 메트릭 시스템 구축
실제 프로덕션 환경에서는 이러한 문제들을 사전에 고려하여 적절한 해결책을 선택하고 구현하는 것이 중요하다고 느꼈습니다. 특히 시스템의 규모와 복잡도에 따라 어떤 해결책을 선택할지 신중하게 결정해야 하며, 단계적으로 도입하면서 시스템의 안정성을 확보해나가는 것이 바람직하다고 생각합니다.
'Spring 7기 프로젝트 > 아웃소싱 팀 프로젝트' 카테고리의 다른 글
| 내가 프로젝트에서 사용한 Java & Spring 문법 총 정리 및 개념 재정립 (1) | 2025.06.18 |
|---|---|
| [트러블슈팅] JWT + Spring Security 기반 인증/인가 시스템 구현 후기 (프로젝트 정리본) (0) | 2025.06.18 |
| [트러블슈팅] 나만의 Spring Security + JWT를 활용한 인증 시스템 구조 (0) | 2025.06.17 |
| [트러블슈팅] 과도한 엔지니어링 : 이벤트 기반 아키텍처 삽질기 (0) | 2025.06.15 |
| [트러블슈팅] 대시보드 성능 최적화: GROUP BY 지옥에서 이벤트 기반 통계로 (0) | 2025.06.15 |