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문부터 치기 전에 “이건 도메인의 관심사인가?”를 먼저 물어볼 것이다.