Skip to content

[EPIC] 실용적 Rich Domain Model 리팩토링 v2 - 복잡도 기반 선택적 적용 #146

@softmoca

Description

@softmoca

[EPIC] 실용적 DDD 리팩토링 v2 - 복잡도 기반 선택적 적용

🔗 Related Issues & PRs

Phase Issue PR Status
0 #147 - [DOCS] 현재 코드베이스 분석 및 복잡도 분류 #148
1 #149 - [DOCS] 테스트 인프라 표준화 및 작성 가이드 문서화 #150
2 #151 - [REFACTOR] 패키지 구조 1차 정리 #152
3-1 #153 - [REFACTOR] Notification 도메인 단순화 (파일럿) #154
3-2 #155 - [REFACTOR] CoreValue 도메인 단순화 #156
3-3 #157 - [REFACTOR] FAQ 도메인 단순화 #158
3-4 #159 - [REFACTOR] Generation 도메인 단순화 #160
3-5 #161 - [REFACTOR] Member 도메인 단순화 #162
3-6 #163 - [REFACTOR] Part 도메인 단순화 #164
3-7 #165 - [REFACTOR] Recruitment 도메인 단순화 #166
3-8 #167 - [REFACTOR] News 도메인 단순화 #168
4 Review, SoptStory 테스트 보완 - ✅ (기존 완료)
5 Homepage/Admin 조합 서비스 정리 - 🔄 진행중

🎯 개요

배경: 왜 v2인가?

v1 리팩토링의 문제점

❌ 모든 도메인에 동일한 Full DDD 패턴을 적용하려 함
❌ 단순한 필드(이메일, 기수)에도 전용 VO + 예외 + 에러코드 생성
❌ 과잉 엔지니어링으로 오히려 복잡성 증가
❌ "왜 이렇게까지 했지?"라는 의문을 후임자가 가질 수 있음
❌ 결론적으로 오히려 인수인계 및 유지보수 측면에서 안좋아짐

구체적 문제 예시 (Notification 도메인):

// Before: v1 - 과잉 엔지니어링
@Entity
public class Notification {
    @Embedded
    private Email email;        // VO - 단순 형식 검증만 하는데 전용 클래스
    
    @Embedded  
    private Generation generation;  // VO - 양수 검증만 하는데 전용 클래스
}

@Embeddable
public class Email {
    private String value;
    
    public Email(String value) {
        if (!EMAIL_PATTERN.matcher(value).matches()) {
            throw NotificationDomainException.emailInvalidFormat(value);
            // ↑ 전용 예외 + 에러코드까지...
        }
        this.value = value;
    }
}

// 파일 수: 12개 (Entity, VO 2개, Repository 2개, Service 2개, 
//              Exception 2개, Controller, DTO 3개...)

v2 리팩토링의 방향

✅ 비즈니스 규칙 복잡도에 따라 선택적 적용
✅ 복잡한 규칙이 있는 도메인 → Full DDD 유지 (Review, SoptStory)
✅ 단순 CRUD 도메인 → Light 패턴으로 단순화 (Notification 등 9개)
✅ 핵심 목표: 인수인계에 도움이 되는 살아있는 테스트 코드

개선된 코드 (Notification 도메인):

// After: v2 - 실용적 접근
@Entity
@Table(name = "\"Notification\"")
public class Notification {

    @Email(message = "유효한 이메일 형식이 아닙니다")
    @NotBlank(message = "이메일은 필수입니다")
    @Column(nullable = false)
    private String email;  // ✅ @Valid 어노테이션으로 충분

    @Min(value = 1, message = "기수는 1 이상이어야 합니다")
    @NotNull(message = "기수는 필수입니다")
    @Column(nullable = false)
    private Integer generation;  // ✅ @Valid 어노테이션으로 충분
    
    public static Notification of(String email, Integer generation) {
        Notification notification = new Notification();
        notification.email = email;
        notification.generation = generation;
        return notification;
    }
}

// 파일 수: 7개 (42% 감소!)

최종 목표

┌─────────────────────────────────────────────────────────────────┐
│           6개월마다 팀원이 바뀌는 환경에서                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1️⃣  테스트 코드가 "살아있는 인수인계 문서" 역할                    │
│      → 테스트만 읽으면 비즈니스 규칙 이해 가능                      │
│                                                                 │
│  2️⃣  새 팀원 온보딩 시간 단축                                     │
│      → 코드 구조가 직관적이고 일관성 있음                          │
│                                                                 │
│  3️⃣  리팩토링/기능 추가 시 회귀 버그 방지                          │
│      → 통합 테스트가 안전망 역할                                  │
│                                                                 │
│  4️⃣  과잉 엔지니어링 제거                                         │
│      → 유지보수 부담 감소, 코드 이해도 향상                        │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

적용 전략

Full vs Light 판단 기준

질문 Yes → Full 적용 예시
조건부 검증이 있나? "A일 때 B가 필수" 같은 규칙 Review: 전체활동 → 세부활동 필수
상태 변화 로직이 있나? 좋아요 증감, 상태 전이 SoptStory: LikeCount 증감
여러 필드 간 관계가 있나? 카테고리에 따라 세부항목 결정 Review: category ↔ subjects
계산 로직이 있나? 날짜 계산, 금액 계산 등 -

2개 이상 충족 시 Full DDD, 그 외 Light 패턴

최종 분류 결과

┌─────────────────────────────────────────────────────────────────────────┐
│                         최종 분류 결과                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  🟢 Full DDD 유지 (2개)                                                  │
│  ├── Review       : 카테고리-세부주제 조건부 검증                         │
│  │   └── "전체활동이면 세부활동 필수", "서류/면접이면 세부유형 필수"         │
│  └── SoptStory    : 좋아요 증감 규칙, IP 중복 체크                        │
│      └── LikeCount 불변성, 음수 불가, IP 기반 중복 방지                   │
│                                                                         │
│  🟡 Light 단순화 (9개)                                                   │
│  ├── Notification : VO 제거, @Valid 전환                                │
│  ├── CoreValue    : Command/Query 통합                                  │
│  ├── FAQ          : Command/Query 통합 (QuestionAnswer JSON 유지)       │
│  ├── Generation   : Command/Query 통합 (BrandingColor, MainButton 유지) │
│  ├── Member       : Command/Query 통합 (MemberRole, SnsLinks 유지)      │
│  ├── Part         : Command/Query 통합 (VO 없음)                        │
│  ├── Recruitment  : Command/Query 통합 (Schedule, RecruitType 유지)     │
│  ├── RecruitPartIntroduction : 별도 패키지 분리 (PartIntroduction 유지)  │
│  └── News         : 레거시 정리, Light로 전환                           │
│                                                                         │
│  🔵 조합 서비스 (2개)                                                    │
│  ├── Homepage     : 여러 도메인 Query 조합 → application/ 이동           │
│  └── Admin        : 여러 도메인 Command 조합 → application/ 이동         │
│                                                                         │
│  ⚪ 외부 연동 (리팩토링 범위 외)                                          │
│  ├── Project      : Playground API 호출 위주                            │
│  └── infrastructure/external : auth, crew, playground, scrap           │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

도메인별 테스트 전략

도메인 유형 단위 테스트 통합 테스트 이유
🟢 Full DDD (Review, SoptStory) ✅ 필수 ✅ 필수 복잡한 비즈니스 규칙 검증 필요
🟡 Light (나머지 9개) ❌ 불필요 ✅ 필수 단순 CRUD, 통합 테스트로 충분
🔵 조합 서비스 (Homepage, Admin) ❌ 불필요 ✅ 필수 여러 도메인 조합 검증

📋 목표 (What)

  • 비즈니스 규칙 복잡도에 따른 선택적 DDD 적용
  • 모든 도메인에 통합 테스트 작성 (인수인계 목적)
  • 과잉 엔지니어링 제거 (불필요한 VO, 에러코드 정리)
  • 패키지 구조 표준화 (global, infrastructure, application, domain)
  • 테스트 작성 가이드 문서화
  • 조합 서비스 통합 테스트 작성
  • DB 스키마 정리 및 Flyway 적용

📊 진행 상황

Phase 0: 분석 및 계획 ✅

산출물: docs/analysis-v2.md

분석 항목 결과
전체 도메인 수 13개
Full DDD 대상 2개 (Review, SoptStory)
Light 대상 9개
조합 서비스 2개 (Homepage, Admin)
제거 대상 VO 2개 (Email, Generation)
유지 대상 VO 8개 (의미있는 필드 묶음)

Phase 1: 테스트 인프라 구축 ✅

산출물: docs/testing-guide.md

테스트 피라미드:

                    ┌───────────┐
                    │   E2E     │  ← 최소한 (CI/CD에서 API 호출)
                   ─┼───────────┼─
                  / │  통합     │ \  ← 핵심 (모든 도메인)
                 /  │  테스트   │  \
               ─┼───┼───────────┼───┼─
              / │   │  단위     │   │ \  ← Full DDD 도메인만
             /  │   │  테스트   │   │  \
            ────┴───┴───────────┴───┴────

테스트 명명 규칙:

이모지 용도 예시
정상/성공 케이스 @DisplayName("✅ 정상: 알림 등록 성공")
실패/예외 케이스 @DisplayName("❌ 실패: 중복 등록 불가")
🔍 조회/검색 케이스 @DisplayName("🔍 조회: 기수별 필터링")
성능/경계값 테스트 @DisplayName("⚡ 대량 데이터 조회")

Phase 2: 패키지 구조 정리 ✅

산출물: docs/phase2-package-structure.md

Before → After:

Before:                              After:
sopt.org.homepage/                   sopt.org.homepage/
├── admin/                           │
├── aws/                             ├── global/                    🌐 전역 공통
│   └── s3/                          │   ├── common/
├── cache/                           │   │   ├── constants/
├── common/                          │   │   ├── dto/
│   ├── constants/                   │   │   ├── filter/
│   ├── dto/                         │   │   ├── type/
│   ├── filter/                      │   │   └── util/
│   ├── type/                        │   ├── config/
│   └── util/                        │   └── exception/
├── config/                          │
├── exception/                       ├── infrastructure/            🔧 인프라 계층
├── homepage/                        │   ├── aws/s3/
├── internal/                        │   ├── cache/
│   ├── auth/                        │   └── external/
│   ├── crew/                        │       ├── auth/
│   └── playground/                  │       ├── crew/
├── notification/                    │       ├── playground/
├── review/                          │       └── scrap/
├── soptstory/                       │
├── corevalue/                       ├── application/               📱 응용 서비스
├── member/                          │   ├── admin/
├── part/                            │   ├── homepage/
├── generation/                      │   └── visitor/
├── recruitment/                     │
├── faq/                             └── [도메인들]/                 🎯 도메인 계층
├── news/                                ├── notification/
├── project/                             ├── corevalue/
├── scrap/                               ├── faq/
└── visitor/                             ├── generation/
                                         ├── member/
                                         ├── part/
                                         ├── recruitment/
                                         ├── recruitpartintroduction/
                                         ├── news/
                                         ├── project/
                                         ├── review/          (Full DDD)
                                         └── soptstory/       (Full DDD)

트러블슈팅:

// FeignClient 빈 인식 실패 해결
// Before
@EnableFeignClients(basePackages = "sopt.org.homepage.internal")

// After
@EnableFeignClients(basePackages = "sopt.org.homepage.infrastructure.external")

Phase 3: Light 도메인 단순화 ✅

3-1. Notification (파일럿) ⭐

항목 Before After 개선
파일 수 12개 7개 42% 감소
VO 클래스 2개 0개 100% 제거
Service 클래스 2개 1개 50% 감소
Repository 2개 1개 50% 감소
예외 클래스 2개 1개 50% 감소

핵심 변경:

// Before: 전용 VO
@Embedded private Email email;
@Embedded private Generation generation;

// After: Bean Validation
@Email @NotBlank private String email;
@Min(1) @NotNull private Integer generation;

3-2. CoreValue

항목 Before After 개선
파일 수 10개 7개 30% 감소
QueryDSL 구현체 1개 0개 제거

3-3. FAQ

항목 Before After 개선
파일 수 10개 7개 30% 감소
VO 유지 QuestionAnswer JSON 저장

3-4. Generation

항목 Before After 개선
파일 수 11개 8개 27% 감소
VO 유지 BrandingColor, MainButton 4개/3개 필드 묶음

특이사항: PK가 Integer이고 자동 생성 아닌 기수 번호 직접 사용 (35, 36...)

3-5. Member

항목 Before After 개선
파일 수 13개 10개 23% 감소
VO 유지 MemberRole, SnsLinks Enum + 4개 SNS 링크

레거시 호환:

// Admin에서 문자열 role로 요청
MemberRole.fromLegacyRole("회장") // → MemberRole.PRESIDENT

3-6. Part

항목 Before After 개선
파일 수 12개 9개 25% 감소
VO 없음 - 가장 단순

특이사항: curriculums는 JSON으로 저장 (List<String>)

3-7. Recruitment + RecruitPartIntroduction

항목 Before After 개선
파일 수 24개 16개 33% 감소
패키지 1개 2개 관심사 분리
VO 유지 Schedule, RecruitType, PartIntroduction

패키지 분리:

recruitment/                    # 모집 일정
├── Recruitment.java
├── RecruitmentRepository.java
├── RecruitmentService.java
└── vo/
    ├── Schedule.java           # 6개 일정 필드 묶음
    └── RecruitType.java        # OB/YB Enum

recruitpartintroduction/        # 파트별 모집 소개 (분리)
├── RecruitPartIntroduction.java
├── RecruitPartIntroductionRepository.java
├── RecruitPartIntroductionService.java
└── vo/
    └── PartIntroduction.java   # content + preference

3-8. News

항목 Before After 개선
파일 수 15개 12개 20% 감소
Entity 이름 MainNewsEntity News 정리

특이사항:

  • Admin DTO 6개를 application/admin/dto/로 이동
  • Presigned URL V2 API 유지 (Lambda 10MB 제한 우회)

Phase 4: Full 도메인 검증 ✅

Full DDD 도메인은 v1에서 이미 적절하게 구현되어 있어 테스트 보완만 진행.

Review 도메인

비즈니스 규칙:

// 카테고리-세부주제 조건부 검증
public void validateForCategory(ReviewCategory category) {
    if (category.requiresSubActivities() && isEmpty()) {
        throw new InvalidReviewSubjectException(
            "전체활동 카테고리는 세부 활동이 필수입니다."
        );
    }
    if (category.isRecruitingCategory() && isEmpty()) {
        throw new InvalidReviewSubjectException(
            "서류/면접 카테고리는 세부 유형이 필수입니다."
        );
    }
}

테스트 현황:

테스트 유형 파일 상태
단위 테스트 ReviewTest, ReviewSubjectsTest, ReviewCategoryTest, ReviewContentTest, ReviewAuthorTest, ReviewUrlTest
통합 테스트 ReviewCommandServiceTest, ReviewQueryServiceTest

SoptStory 도메인

비즈니스 규칙:

// 좋아요 증감 규칙 (불변 VO)
public LikeCount increment() {
    if (value >= MAX_COUNT) {
        throw new IllegalStateException("좋아요 개수가 최대값에 도달했습니다.");
    }
    return new LikeCount(this.value + 1);
}

public LikeCount decrement() {
    if (value <= MIN_COUNT) {
        throw new IllegalStateException("좋아요 개수는 음수가 될 수 없습니다.");
    }
    return new LikeCount(this.value - 1);
}

테스트 현황:

테스트 유형 파일 상태
단위 테스트 SoptStoryTest, SoptStoryLikeTest, LikeCountTest, SoptStoryContentTest, SoptStoryUrlTest, IpAddressTest
통합 테스트 SoptStoryCommandServiceTest, SoptStoryQueryServiceTest

Phase 5: 조합 서비스 정리 🔄

패키지 이동은 Phase 2에서 완료. 통합 테스트 작성 진행 중.

현재 구조

application/
├── admin/                                    ✅ 패키지 이동 완료
│   ├── AdminController.java
│   ├── service/
│   │   ├── AdminService.java
│   │   └── AdminServiceImpl.java
│   └── dto/
│       ├── request/
│       └── response/
│
├── homepage/                                 ✅ 패키지 이동 완료
│   ├── controller/
│   │   └── HomepageController.java
│   ├── service/
│   │   └── HomepageQueryService.java
│   └── dto/
│       ├── MainPageResponse.java
│       ├── AboutPageResponse.java
│       └── RecruitPageResponse.java
│
└── visitor/
    ├── VisitorController.java
    └── VisitorService.java

HomepageQueryService 분석

메서드 조합 도메인 외부 API 복잡도
getMainPageData() Generation, Part, News, Recruitment
getAboutPageData() Generation, CoreValue, Part, Member ✅ Playground, Crew, Auth
getRecruitPageData() Generation, Recruitment, RecruitPartIntroduction, FAQ

AdminServiceImpl 분석

메서드 조합 도메인 인프라 복잡도
addMainData() - S3, Cache
addMainDataConfirm() Generation, CoreValue, Member, Part, Recruitment, RecruitPartIntroduction, FAQ S3, Cache 매우 높음
getMain() Generation, CoreValue, Member, Part, Recruitment, RecruitPartIntroduction, FAQ, News -

작업 현황

  • application/homepage/ 패키지로 이동
  • application/admin/ 패키지로 이동
  • HomepageQueryServiceTest 작성
    • GET /homepage (Main)
    • GET /homepage/about
    • GET /homepage/recruit
  • AdminServiceTest 작성
    • POST /admin (벌크 생성)
    • GET /admin (조회)

Phase 6: DB 스키마 정리 (예정)

  • [ANALYSIS] 불필요한 DB 스키마 분석
  • [DB] Flyway 마이그레이션 적용

현재 Flyway 현황:

src/main/resources/db/migration/
└── V1__init_notification_table.sql  # Notification 테이블만 있음

주요 테이블:

테이블 도메인 상태
Notification Notification
Review Review
SoptStory SoptStory
SoptStoryLike SoptStory
Generation Generation
CoreValue CoreValue
Member Member
Part Part
Recruitment Recruitment
RecruitPartIntroduction RecruitPartIntroduction
FAQ FAQ
MainNews News

Phase 7: 문서화 및 마무리 (예정)

  • [DOCS] 아키텍처 문서화
  • [DOCS] 인수인계 문서 정리

📈 성과 요약

정량적 성과

항목 Before After 개선
Light 도메인 평균 파일 수 ~12개 ~8개 33% 감소
불필요한 VO 2개 0개 100% 제거
유지된 VO (의미있는) 8개 8개 유지
Application 계층 분리 명확한 책임 분리
테스트 커버리지 도메인 3개 11개 267% 증가
테스트 완료율 - 10/11 91%

도메인별 테스트 현황

도메인 테스트 파일 케이스 수 상태
Notification NotificationServiceTest 5개
CoreValue CoreValueServiceTest 3개
FAQ FAQServiceTest 7개
Generation GenerationServiceTest 7개
Member MemberServiceTest 4개
Part PartServiceTest 4개
Recruitment - - 누락
RecruitPartIntroduction RecruitPartIntroductionServiceTest 5개
News NewsServiceTest 6개
Review 단위 6개 + 통합 2개 다수
SoptStory 단위 6개 + 통합 2개 다수

VO 처리 결과

처리 대상 이유
❌ 제거 Email, Generation @Valid 어노테이션으로 충분
✅ 유지 BrandingColor 4개 컬러 필드 묶음 (main, high, low, point)
✅ 유지 MainButton 3개 필드 묶음 (text, keyColor, subColor)
✅ 유지 MemberRole Enum (회장, 부회장, 총무...)
✅ 유지 SnsLinks 4개 SNS 링크 묶음
✅ 유지 Schedule 6개 일정 필드 묶음
✅ 유지 RecruitType Enum (OB, YB)
✅ 유지 PartIntroduction content + preference 묶음
✅ 유지 QuestionAnswer question + answer 묶음 (JSON)

🚨 리스크 관리

DB 변경 안전 원칙

❌ 코드 정리 중간에 DB 스키마 변경 금지
❌ 여러 테이블을 한 번에 변경 금지
✅ Phase 5 완료 후에만 DB 작업 시작
✅ 각 마이그레이션마다 롤백 스크립트 준비

체크포인트

Phase 체크포인트 상태
2 전체 테스트 통과 확인
3 (Notification) 패턴 리뷰 및 확정
5 코드 레벨 리팩토링 완료 🔄
6 시작 전 백업 + 롤백 계획 수립

📚 참고 자료

프로젝트 문서

문서 설명 위치
분석 결과 전체 도메인 분석 및 복잡도 분류 docs/analysis-v2.md
패키지 구조 목표 패키지 구조 정의 docs/phase2-package-structure.md
Notification 파일럿 Light 패턴 가이드 docs/phase3-notification-light.md
테스트 가이드 테스트 작성 기준 docs/testing-guide.md
v1 리팩토링 기존 리팩토링 문서 docs/refactoring-v1.md

외부 참고


🔜 다음 단계

즉시 작업 필요

우선순위 작업 예상 시간 필수 여부
1 RecruitmentServiceTest 작성 20분 ⭐ 필수
2 HomepageQueryServiceTest 작성 40분 권장
3 AdminServiceTest 작성 60분 권장

📝 회고 및 교훈

잘한 점

  1. 파일럿 접근 - Notification으로 패턴 확립 후 나머지 적용
  2. 단계적 진행 - Phase별 체크포인트로 안정성 확보
  3. VO 선별적 유지 - 의미있는 VO는 유지하여 도메인 표현력 보존
  4. 문서화 병행 - 각 Phase마다 문서 업데이트

개선점

  1. 테스트 누락 - RecruitmentServiceTest 빠짐
  2. 조합 서비스 테스트 - 복잡도 높아 우선순위 밀림

향후 적용 시 권장사항

1️⃣ 분석 먼저: 무조건 Full DDD 적용하지 말고 복잡도 분석부터
2️⃣ 파일럿 필수: 가장 단순한 도메인으로 패턴 확립
3️⃣ VO 기준 명확히: "여러 필드 묶음"인가? → 유지, "단순 검증"인가? → 제거
4️⃣ 테스트 = 문서: 비즈니스 규칙을 테스트로 표현
5️⃣ 점진적 개선: 한 번에 다 하려고 하지 말 것

Metadata

Metadata

Assignees

Labels

🧼 refactor코드의 효율/가독성을 위해 수정한 경우

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions