Spring 7기 프로젝트/아웃소싱 팀 프로젝트

[트러블슈팅] 과도한 엔지니어링 : 이벤트 기반 아키텍처 삽질기

JuNo_12 2025. 6. 15. 21:37

"name 컬럼 하나 때문에 이벤트 기반 아키텍처를 도입한 뉴비의 솔직한 후기"

 

들어가며

팀 프로젝트에서 "새로운 아키텍처를 써보고 싶다"는 마음으로 시작한 이벤트 기반 아키텍처 도입기입니다. 하지만 기능구현을 마무리하며 깨달은 충격적인 사실...

"Auth 테이블에 name 컬럼 하나만 추가하면 되는 상황이었다" 

이 글은 제가 저지른 과도한 엔지니어링의 전 과정과, 그 과정에서 배운 값진 교훈들을 솔직하게 기록한 트러블슈팅 후기입니다.


문제 상황: 팀원 간 블로킹 이슈

초기 상황

// Task 도메인에서 User 이름이 필요한 상황
@Service
public class TaskService {
    private final UserRepository userRepository; // 👈 다른 팀원이 구현해야 함
    
    public void createTask(CreateTaskRequest request) {
        // User 이름이 필요하지만... 기다려야 함
        User user = userRepository.findById(request.getUserId());
        Task task = new Task(request.getTitle(), user.getName());
    }
}

 

당시 생각: "도메인 간 의존성이 문제네! 이벤트 기반으로 해결해야지!" 

지금 생각: "Auth 테이블에 name 컬럼만 추가하면 되는데..." 


해결 과정: 삽질의 시작

1단계: 이벤트 기반 아키텍처 설계

복잡한 테이블 구조 설계

-- Auth 테이블 (인증 정보)
CREATE TABLE auth (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(20) NOT NULL,
    email VARCHAR(100) NOT NULL,
    password VARCHAR(255) NOT NULL,
    role VARCHAR(20) NOT NULL
    -- name이 없어서 User 테이블 필요하다고 생각 🤔
);

-- User 테이블 (프로필 정보)
CREATE TABLE users (
    id BIGINT PRIMARY KEY,        -- Auth.id와 같은 값
    name VARCHAR(50) NOT NULL,    -- 👈 이거 하나 때문에...
    email VARCHAR(100) NOT NULL   -- Auth와 중복 데이터
);

 

이벤트 클래스 구현

@Getter
public class UserRegisteredEvent {
    private final Long userId;
    private final String name; 
    private final String email;
    // 생성자, 검증 로직 등... 50줄의 코드
}

 

Auth 서비스에서 이벤트 발행

@Transactional
public SignupResponseDto signup(SignupRequestDto request) {
    Auth savedAuth = authRepository.save(new Auth(...));
    
    // 복잡한 이벤트 발행
    eventPublisher.publishEvent(new UserRegisteredEvent(
        savedAuth.getId(),
        request.getName(),
        savedAuth.getEmail()
    ));
    
    return response;
}

 

User 도메인에서 이벤트 처리

@EventListener
@Transactional
public void handleUserRegistered(UserRegisteredEvent event) {
    User user = new User(event.getUserId(), event.getName(), event.getEmail());
    userRepository.save(user);
}

당시 만족감: "와! 도메인이 완전히 분리됐어! 멋지다!" 


트러블 1: 데이터 일관성 문제 발견

문제 상황

@Transactional
public SignupResponseDto signup(SignupRequestDto request) {
    Auth savedAuth = authRepository.save(auth);     // Auth 저장 ✅
    eventPublisher.publishEvent(new UserRegisteredEvent(...)); // 이벤트 발행
    
    // 만약 여기서 예외 발생하면?
    throw new RuntimeException("뭔가 잘못됨!");
    // Auth는 롤백되지만 User는 이미 생성됨 😱
}

 

해결 시도 1: @TransactionalEventListener 도입

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleUserRegistered(UserRegisteredEvent event) {
    // Auth 커밋 후에만 실행되도록 변경
}

결과: Spring 6 호환성 문제로 에러 발생 

당시 고민: "왜 이렇게 복잡하지? 뭔가 잘못된 건 아닐까?" 


트러블 2: User 생성 실패 시 Auth 롤백 불가

새로운 문제 발견

  • Auth는 성공적으로 커밋됨 ✅
  • User 생성이 실패함 ❌
  • Auth 롤백이 불가능함 😱

 

해결 시도 2: 비동기 + Saga 패턴 도입

비동기 처리 설정

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "eventTaskExecutor")
    public Executor eventTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(10);
        // 복잡한 설정들...
        return executor;
    }
}

 

보상 트랜잭션 구현

@EventListener
@Async("eventTaskExecutor")
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleUserRegistered(UserRegisteredEvent event) {
    try {
        userRepository.save(new User(...));
    } catch (Exception e) {
        // 실패 시 보상 트랜잭션 발행
        eventPublisher.publishEvent(new UserCreationFailedEvent(event.getUserId()));
    }
}

@EventListener
@Transactional
public void handleUserCreationFailed(UserCreationFailedEvent event) {
    // Auth 롤백 처리
    authRepository.deleteById(event.getUserId());
}

당시 생각: "드디어 완벽한 아키텍처다! Saga 패턴까지 적용했어!" 

지금 생각: "이게 정말 필요했나..." 


 

진짜 해결책: 단순함의 발견

현재 복잡한 구조

// 이벤트 기반 회원가입 (200줄+ 코드)
Auth 생성 → UserRegisteredEvent 발행 → 비동기 User 생성 → 실패 시 보상 트랜잭션

 

진짜 필요했던 것

-- 이것만 하면 끝이었음 🤦‍♂️
CREATE TABLE auth (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(20) NOT NULL,
    email VARCHAR(100) NOT NULL,
    password VARCHAR(255) NOT NULL,
    role VARCHAR(20) NOT NULL,
    name VARCHAR(50) NOT NULL  -- 👈 이 한 줄만 추가!
);

-- User 테이블은 아예 불필요했음
// 단순한 회원가입 (10줄 코드)
@Transactional
public SignupResponseDto signup(SignupRequestDto request) {
    Auth auth = new Auth(
        request.getUsername(),
        request.getEmail(),
        encodedPassword,
        UserRole.USER,
        request.getName()  // 👈 name도 Auth에 저장
    );
    Auth savedAuth = authRepository.save(auth);
    return new SignupResponseDto(savedAuth.getUsername(), savedAuth.getEmail());
}

왜 이런 삽질을 했을까?

삽질의 원인들

1. 기술에 대한 욕망

❌ "이벤트 기반 아키텍처를 써보고 싶다"
✅ "문제를 해결하기 위해 적절한 기술을 선택한다"

 

2. 도메인 분리 강박

❌ "인증과 사용자는 반드시 분리되어야 한다"
✅ "비즈니스 복잡도에 따라 분리 여부를 결정한다"

 

3. MSA를 고려한다는 핑계

❌ "나중에 MSA로 분리할 거니까 미리 준비해야지"
✅ "현재 요구사항에 맞는 가장 단순한 해결책부터"

 

4. 프로젝트 규모 오판

// 내가 생각한 User 도메인
class User {
    private String name;
    private String profileImage;
    private String bio;
    private List<SocialLink> socialLinks;
    private UserPreferences preferences;
    // 복잡한 프로필 정보들...
}

// 실제 필요했던 것
class Auth {
    private String name; // 👈 이것만...
}

그래도 배운 것들

기술적 역량 향상

1. 이벤트 기반 아키텡처 이해

// 언제 사용해야 하는지 이제 안다
복잡한 비즈니스 로직이 여러 도메인에 걸쳐 있을 때
도메인 간 결합도를 낮춰야 할 때
비동기 처리가 성능상 필수일 때

❌ name 컬럼 하나 때문에 (내 경우)

 

2. 트랜잭션 이벤트 처리 이해

// @EventListener vs @TransactionalEventListener 완벽 이해
@EventListener              // 즉시 실행
@TransactionalEventListener // 트랜잭션 생명주기 연동

 

3. 비동기 처리와 Saga 패턴 경험

// 실무에서 정말 필요할 때 바로 적용 가능
@Async + 보상 트랜잭션 패턴

 

아키텍처 설계 철학 정립

Before (기술 중심)

"이 기술 멋있어! 써보자!"
"MSA 대비해서 미리 분리하자!"
"복잡한 게 전문가답다!"

 

After (문제 중심)

"이 문제를 해결하는 가장 단순한 방법은?"
"현재 요구사항에 맞는 최소 구현은?"
"복잡도 대비 얻는 가치가 있나?"

 

협업 관점의 깨달음

1. 팀원 배려

// Before: 혼자만 아는 복잡한 구조
"이벤트 기반이니까 UserRegisteredEvent 보세요!"

// After: 모두가 이해하는 단순한 구조  
"Auth 테이블에 name 컬럼 있어요!"

2. 유지보수성

// Before: 디버깅 지옥
Auth 생성 → 이벤트 발행 → 비동기 처리 → 보상 트랜잭션 (어디서 실패?)

// After: 디버깅 천국
Auth 생성 (끝!)

 

교훈과 가이드라인

기술 선택 가이드라인

1. YAGNI 원칙 (You Aren't Gonna Need It)

❌ "나중에 필요할 수도 있으니까 미리 구현해두자"
✅ "지금 당장 필요한가? 아니면 나중에 추가하자"

 

2. 복잡도 vs 가치 평가

// 질문해야 할 것들
1. 이 기술이 해결하는 진짜 문제가 있는가?
2. 단순한 대안은 정말 없는가?
3. 추가되는 복잡도 대비 얻는 가치가 있는가?
4. 팀원들이 이해하고 유지보수할 수 있는가?

 

3. 단계적 발전

// 올바른 진화 순서
1. 가장 단순한 해결책으로 시작
2. 문제가 실제로 발생하면 개선
3. 복잡도가 정당화될 때 고도화

// 잘못된 진화 순서 (내가 한 것)
1. 복잡한 해결책으로 시작
2. 문제를 찾아서 해결
3. 나중에 단순화 고려

 

 

팀 프로젝트에서의 적용

1. 기술 도입 시 팀 합의

# 기술 도입 제안서 (예시)
## 문제 상황
- 구체적인 문제 설명
- 현재 해결책의 한계

## 제안 기술
- 기술 설명 및 장단점
- 복잡도 증가 정도
- 학습 곡선

## 대안 검토
- 단순한 대안들과 비교
- 각 대안의 장단점

## 팀 의견
- 모든 팀원의 의견 수렴
- 합의된 결정과 이유

 

2. 점진적 도입

// ✅ 올바른 접근
1. 단순한 구조로 MVP 완성
2. 실제 문제 발생 시 개선 검토
3. 팀과 합의 후 단계적 도입

// ❌ 잘못된 접근 (내가 한 것)
1. 처음부터 복잡한 구조 도입
2. 팀원들에게 학습 강요
3. 문제 발생 시 더 복잡한 해결책 추가

결론: 삽질도 값진 경험

  • 기술 역량: 이벤트 기반, 비동기, Saga 패턴 마스터
  • 아키텍처 사고: 복잡도 vs 가치 평가 능력
  • 협업 인사이트: 팀 친화적 기술 선택의 중요성
  • 블로그 소재: 솔직한 삽질 경험담 (이것도 가치!)

 

미래를 위한 다짐

1. 문제 우선 사고

// Before: 기술이 먼저
"이벤트 기반 아키텍처를 써보자. 어떤 문제에 적용할까?"

// After: 문제가 먼저  
"이 문제를 해결하려면 어떤 기술이 적합할까?"

 

2. 단순함의 가치 인정

// 복잡한 != 전문적인
// 단순한 != 실력 부족

// 진짜 전문가는
"가장 단순한 해결책을 찾는 사람"

 

3. 팀과 함께 성장

// 혼자만 아는 고급 기술 < 모두가 이해하는 적절한 기술
// 개인의 성장 < 팀 전체의 성장

에필로그: 삽질의 연대기

타임라인

Week 1: "이벤트 기반으로 해보자!" 
Week 2: "데이터 일관성 문제 발견!" 
Week 3: "비동기 + Saga로 해결!" 
Week 4: "팀원: 그냥 name 컬럼 추가하면 안 돼요?" 
Week 5: "...맞네"

 

만약 시간을 되돌릴 수 있다면?

그래도 똑같이 했을 것 같습니다. 

 

왜냐하면:

  • 이론으로만 알던 것을 직접 경험해봤기 때문
  • 실패를 통해 더 깊이 이해하게 되었기 때문
  • 앞으로는 절대 같은 실수 안 할 자신이 있기 때문
  • 무엇보다 정말 재미있었기 때문!