View

반응형

 

 

🎯 출석, 뱃지, 챌린지… 단순한 기능이 구조를 망치기 시작했다

 

우리가 만들고 있던 건 악기 연습을 꾸준히 이어가도록 도와주는 SNS 플랫폼이었다.
사용자는 하루에 한 번 연습 영상을 업로드하고, 친구들의 피드백을 받으며, 연습 습관을 만들어가는 구조였다.

나에게 구현 임무가 주어진 기능에 대한 기획은 다음과 같이 단순했다.

  • 출석하면 경험치를 주고
  • 챌린지를 성공하면 레벨업하고
  • 특정 조건 달성 시 뱃지를 부여하고
  • 그에 따른 후속 처리를 해야 했다 (예: 경험치/보상 처리 등)

처음엔 “이게 뭐가 어려워?” 싶었다. 그냥 조건 체크하고 if로 때려박으면 끝 아닌가?

 

하지만 그렇게 생각했던 순간이 구조가 무너지기 시작한 첫 단추였다.

 


 

💥 조건 지옥, 로직 중복, 유지보수 지옥

 

챌린지 성공, 출석 완료, 뱃지 획득, 레벨업…
사용자 한 명이 행동 하나만 해도 그 뒤에 따라붙는 처리들이 생각보다 많았다.

예를 들어 챌린지 하나 성공하면, 최소한 아래 작업들이 동시에 발생했다:

  • 경험치 지급
  • 레벨업 조건 검사 → 조건 만족 시 레벨업
  • 신규 뱃지 획득 여부 판단 → 부여
  • 알림 전송 (최소 2~3개)

이 문제를 처음엔 단순하게 접근했다.
"그냥 서비스 레이어에서 다 처리하면 되지 않나?"

challengeService.completeChallenge(userId, challengeId);

// 내부 로직
if (checkLevelUpCondition(user)) {
    user.levelUp();
    notificationService.send("레벨업!");
}
if (checkBadgeCondition(user)) {
    badgeService.grant(...);
    notificationService.send("뱃지 획득!");
}
 

이렇게 시작했지만, 얼마 지나지 않아 시스템은 터지기 시작했다.

 

❌ 조건 처리 지옥

  • 출석은 `7일 연속` 기준으로 경험치 부여
  • 챌린지는 종류별로 뱃지 부여 조건이 다 다름
  • 레벨업은 누적 경험치 기반 + 중간 미션도 존재

이런 조건들이 점점 늘어나면서 코드가 계속해서 복잡해졌고,
기존 조건을 수정할 때마다 여러 곳에 분기 로직이 퍼져 있어서 손대기 무서운 수준이 됐다.

 

❌ 중복 로직 범람

  • 뱃지 부여 로직은 출석, 챌린지, 레벨업 등에서 중복 호출
  • 경험치 부여나 레벨업 로직도 각 서비스마다 비슷하게 반복되고 있었다.
public void completeChallenge(Long userId, Long challengeId) {
    // 챌린지 완료 처리
    challengeRepository.markAsCompleted(userId, challengeId);

    // 경험치 지급 (매번 같은 로직 반복)
    int exp = 1000;
    userService.addExp(userId, exp);

    // 레벨업 조건 검사 → 중복된 곳에서도 또 검사함
    if (userService.isLevelUp(userId)) {
        userService.levelUp(userId);
        userService.addExp(userId, 500); // 보너스 경험치
    }

    // 뱃지 조건 체크 (비슷한 if 구조 반복)
    if (!badgeService.hasBadge(userId, BadgeType.CHALLENGE_ENTHUSIAST)) {
        int count = recordRepository.countByChallengeIdAndUserId(challengeId, userId);
        if (count >= 5) {
            badgeService.grant(userId, BadgeType.CHALLENGE_ENTHUSIAST);
        }
    }

    // 알림 발송 등 다른 후속 로직
}
 

이런 식으로 서비스 로직 + 보상 등 후속 처리 로직이 얽히면서,
테스트가 어려워졌고, 책임 범위도 불분명해졌다.

 

❌ 유지보수 지옥

 

이런 구조를 유지하다 보니 다음과 같은 현상이 발생했다:

  • 신규 보상 조건을 추가하면 → 모든 관련 서비스 코드 손봐야 함
  • 조건 분기 로직이 도처에 퍼지다 보니 → 테스트/수정이 부담으로 다가왔다  
  • 도메인 로직은 점점 '보상 처리기'로 오염되어 감

 

하나의 조건 추가가 코드 전체를 흔드는 구조였다.
이건 명백한 경고였다. “구조가 잘못됐다.”

 

 


 

🧪 AOP, 공통 유틸, 직접 호출… 전부 실패

 

서비스가 복잡해지자, 자연스럽게 "이걸 좀 정리해야겠다"는 생각이 들었다.

가장 먼저 시도한 건 공통 보상 유틸 서비스였다.

 

✅ 1차 시도: 보상 서비스로 묶기

rewardService.giveExp(userId, 10);
rewardService.checkAndGrantBadge(userId, "꾸준왕");

 

일단 로직을 RewardService로 몰아넣으면 깔끔해질 줄 알았다.
그런데 문제는, 조건이 다 달랐다.

  • 출석은 “7일 연속” 조건
  • 챌린지는 “N일 간격” 조건
  • 뱃지는 각 행동에 따라 발동 조건이 다름

결국 RewardService 안에 if문들이 다시 생기기 시작했고,
클래스 이름만 바뀐 조건 분기 지옥 2.0이 만들어졌다.

 

✅ 2차 시도: AOP로 뽑아보기

 

"보상은 부가 로직이니까 AOP로 때리면 되지 않을까?"

@After("@annotation(TrackUserAction)")
public void giveRewards(JoinPoint joinPoint) {
    // 행동 종류 판별 → 보상 처리
}
 

처음엔 좋아 보였다. 깔끔하게 빠졌고, 호출부도 한결 심플해졌다.

그런데 문제가 생겼다.

  • 보상은 단순 트래킹이 아니라 상태 변경이 필요한 로직이었다
    • 경험치 증가 → 레벨업 체크 → 추가 보상
  • 그리고 보상 사이에도 조건 분기가 많아서
    AOP 안에서 처리하려니 로직이 점점 복잡해졌고,
    결국 AOP 안에 또 서비스 호출이 섞이기 시작했다

무엇보다 "이게 트랜잭션 안에서 처리돼야 하나?", "비동기로 빼야 하나?" 같은 고민까지 같이 터졌다.

결론은…

 

"이건 AOP로 해결할 게 아니다."

 

 

✅ 3차 시도: 그냥 직접 호출

이쯤 되니까 “차라리 그냥 서비스에서 다 호출하자”는 회귀가 일어났다.

challengeService.complete(...);
rewardService.giveReward(...);
notificationService.send(...);
 

이건 말 그대로 포기 선언이었다.
보상은 직접 호출하고, 알림도 직접 날리고, 모든 관심사가 한 메서드에 다 붙기 시작했다.

 

 


 

💡 보상은 결과, 행동이 먼저

 

이쯤 되니까 슬슬 이런 말이 나오기 시작했다.

 

“근데 이거… 보상은 ‘결과’잖아?
행동이 일어난 다음에 따라오는 거지, 핵심은 아니지 않냐?”

 

 

그렇다. 놓치고 있던 건 바로 관심사 분리였다.

 

도메인과 비관심사를 나누자

 

핵심 도메인은 "유저가 행동을 했는가"다.
예를 들어:

  • 출석 처리
  • 챌린지 업로드
  • 팔로우/좋아요/가입

이런 것들이 실제 도메인의 “행위”다.
반면에,

  • 보상을 주거나
  • 사용자 상태를 갱신하거나
  • 어떤 결과를 외부에 전달하는 행위는

도메인의 본질이 아니라, 그 결과에 따른 부가적 작업들이다.

 

즉, “보상은 도메인의 관심사가 아니다”
→ 비관심사다.
→ 분리되어야 한다.

 

이벤트 기반 구조로 전환

 

Spring이 이미 ApplicationEventPublisher라는 훌륭한 도구를 제공한다.
우리는 다음과 같은 구조를 갖췄다.

 

  • 도메인 행동 발생 시 → 이벤트 발행
eventPublisher.publishEvent(new ChallengeUploadEvent(userId, challengeId, similarity, ...));

 

  • 리스너에서 후속 처리만 담당
@EventListener
public void handleChallengeUpload(ChallengeUploadEvent event) {
    checkFirstUploadBadge(event);
    checkSimilarityBadge(event);
    
    // 보상 결과 반영, 알림 발송 등
}

 

  • POJO 이벤트 객체로 상태 전달
    • ChallengeUploadEvent, UserEvent, LikeEvent 등
    • 각각의 도메인 행위를 담은 명확한 의미의 이벤트 클래스

 

이 구조의 핵심은?

  • 도메인은 순수해진다
    → 출석 서비스는 출석만 한다
    → 챌린지 업로드는 업로드만 한다
    → 보상은 모른다
  • 보상은 별도 리스너에서 담당
    → 중복 로직 없이 통일된 룰 체크 가능
    → 테스트/수정/추적도 쉬움
  • 확장성 보장
    → 신규 보상 추가? 이벤트 리스너만 새로 등록하면 끝
    → 기존 도메인 코드는 안 건드려도 됨

 

 

이벤트 기반 구조를 도입한 순간,
우리의 코드베이스는 if 지옥에서 벗어나 의미 기반의 흐름 중심 구조로 바뀌었다.

 


 

🏗️ 이벤트 기반 구조로 리팩토링

 

철학이 바뀌면, 구조도 바뀌어야 했다.
우리는 도메인 행동은 이벤트를 발행하고,
보상은 전부 이벤트 리스너에서 처리하는 구조로 방향을 틀었다.

 

구조 설계 – 이벤트 중심으로 책임 재정의

 

핵심 아이디어는 단순했다.

 

"유저가 어떤 행동을 했을 때, 그걸 이벤트로 발행하면
필요한 후속 처리는 리스너들이 알아서 처리한다."

 

// 예: 유저가 챌린지를 업로드했을 때
ChallengeUploadEvent event = new ChallengeUploadEvent(userId, challengeId, similarity, composerId, level);
applicationEventPublisher.publishEvent(event);

 

리스너들이 반응한다 – 관심사 완전 분리

 

이벤트가 발행되면, 여러 리스너가 이걸 받아 처리한다.

@EventListener
@Transactional
public void handleChallengeUpload(ChallengeUploadEvent event) {
    checkFirstUpload(event);
    checkSimilarityBadges(event);
    checkComposerBadge(event);
    checkDifficultyBadge(event);
    checkChallengeCountBadge(event);

    if (!event.getAcquiredBadges().isEmpty()) {
	// 리스너의 후속 작업으로 알림 발송
    }
}
  • 보상 조건들은 각기 checkXXXBadge로 분리
  • 이후 알림 등 비슷한 후속 처리 로직들 처리
  • 실제 도메인 서비스에서는 아무것도 몰라도 됨

 

구조 흐름 요약

  • 한 번의 행동 → 여러 Listener → 각자 책임 처리
  • 도메인은 행동만 담당
  • 리스너는 보상 및 기타 정책을 독립적으로 수행

 

효과: 관심사 분리 & 확장성 확보

  • 새로운 뱃지 조건?
    → BadgeEventListener에 메서드 하나 추가하면 끝
    → 출석, 챌린지, 좋아요 등 이벤트는 그대로 재사용 가능
  • 알림 템플릿 바꾸고 싶다?
    → NotificationService 수정만 하면 됨
  • 도메인 로직은 순수하게 유지
    → 테스트도 간단하고, 유지보수도 안전함

 

비교

항목 리팩토링 전 리팩토링 후
보상 처리 서비스 레이어에서 직접 처리 이벤트 리스너에서 처리
확장성 보상 조건 추가 시 도메인 로직 수정 필요 리스너만 추가하면 됨
테스트 복잡한 의존성 mocking 필요 리스너 단위로 독립 테스트 가능

 

 

 

이 구조 덕분에 보상 로직은 전부 한 곳에 모였고,
도메인 로직은 “내가 할 일만 딱 하는” 순수한 형태로 정리됐다.

 


 

🧱 뱃지 하나 때문에 이 난리를..

 

레벨업, 뱃지, 경험치.  
기능 자체는 별거 없어 보였다.  
하지만 막상 구현하고 보니,  
문제는 “기능의 복잡도”가 아니라 “얽힌 관심사”에 있었다.

처음엔 그냥 if문 몇 개로 처리하면 된다고 생각했다.  
그러나 그 if는 금세 도메인 로직을 물들이기 시작했고,  
결국엔 수정도 어렵고, 테스트도 힘든 구조가 됐다.

그걸 풀 수 있었던 건  
무엇을 언제 처리할 것인가를 구조적으로 분리했기 때문이었다.

이벤트 기반 아키텍처는 절대 거창한 게 아니다.  
단지, “무엇이 본질이고, 무엇이 부가적인가”를 구분할 줄 아는 선택일 뿐이다.

그리고 그 선택이,  
복잡한 보상 조건에도 무너지지 않는 구조를 만들었고  
도메인 로직을 깔끔하게 유지할 수 있는 기반이 되었다.

다음에도 비슷한 요구사항이 생긴다면,  
if문부터 치기 전에 “이건 도메인의 관심사인가?”를 먼저 물어볼 것이다.

 

 

728x90
반응형
Share Link
reply
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31