멀티모듈과 DDD로 캡슐화와 응집도 높이기

0. 서론

사이드 프로젝트인 MyCodingTest의 유지보수성과 OOP를 보다 완전히 적용해보기 위해 리팩토링을 진행했다. 특히 멀티모듈을 도입하여 기존의 public, private 접근 제어자만으로는 한계가 있었던 캡슐화를 더 확실히 하고, 계층 간의 책임을 명확히 분리했던 과정을 정리해본다.

1. 배경 및 동기

프로젝트 간략 소개

MyCodingTest는 백준 문제 풀이 기록을 자동 수집하고 복습을 도와주는 서비스

리팩토링 결심 이유

25년 1월 말 즈음에 서비스를 배포하고 운영하고 있었다. 이후 같은 해 4월 취업을 하고 정신이 없기도 했고, AWS 무료 티어가 만료되어 발생하는 비용 부담 때문에 운영을 잠시 중단했었다. 최근 다시 이 프로젝트를 꺼내 보았는데, 그동안 성장을 해서인지 기존 코드에서 객체지향적 설계가 무너진 부분들이 너무나 선명하게 보였다.

무엇보다 큰 문제는 비즈니스 로직의 분산이었다. 비즈니스 로직이 도메인 객체 내부에 응집되어 있지 않고 서비스 계층과 JPA 엔티티 여기저기에 파편화되어 있었다. 그러다 보니 특정 기능을 수정하거나 추가하려고 할 때, 어떤 서비스의 어떤 메서드를 건드려야 할지 한눈에 파악하기가 어려웠다.

결국 데이터와 로직이 분리되어 캡슐화가 깨졌고, 객체는 스스로 메시지를 받아서 수행하는게 아닌 지시를 단순히 수행하는 수동적인 존재가 되어 있었다. 기능을 확장하려 해도 어디로 가야 할지 모르는 막막함이 있었고 수정을 하더라도 이게 부작용이 생길까하는 불안감이 있었다. 이런 불안함을 해결하기 위해 전보다 더 확실히 OOP를 적용을 위한 리팩토링이 절실하다고 느꼈다.

2. 기존 구조 문제점

패키지 구조(리패토링 전)

기존에는 나름 도메인별로 패키지화 했었고 그 내부에 controller, service, dto, entity, repository 등 전형적인 구조를 사용하고 있었다.

src/main/java/com/mycodingtest/
├── authorization/           
├── common/          
├── config/               
├── judgmentresult/            
├── review/          
     ├── dto/
     ├── Review
     ├── ReviewMaaper
     ├── ReviewController
     ├── ReviewRepository
     └── ReviewService
├── security/         
├── solvedproblem/        
├── solvedproblemtag/          
├── storage/            
└── user/             

문제점 1: JPA에 의존하는 도메인 모델

// Before: JPA 어노테이션이 도메인에 직접 침투
@Entity
public class JudgmentResult {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    private User user;
    
    @ManyToOne
    private SolvedProblem solvedProblem;
    
    // ... 수동 getter 코드
    public String getBaekjoonId() { return baekjoonId; }
    public int getCodeLength() { return codeLength; }
    // ...
}

기존에는 도메인 객체가 JPA @Entity 어노테이션을 직접 가지고 있었다.

이는 도메인 모델의 비즈니스 로직에 관한 책임, JPA 관련 설정 책임, 다른 도메인과의 연관관계 명시 책임이 모두 한 클래스에 모여 있는 상태였다.

명백히 SRP(단일 책임 원칙)를 준수하지 않았고, 서비스가 고도화될수록 복잡해질 것이 뻔했다. 도메인 객체가 JPA에 종속되면서 다른 영속성 기술로 교체하기 어려워졌고, @ManyToOne 같은 매핑이 도메인 로직을 불필요하게 복잡하게 만들었다.

문제:

  • 도메인 객체가 JPA에 종속되어 다른 영속성 기술로 교체 어려움
  • @ManyToOne 같은 관계 매핑이 도메인 로직을 복잡하게 만듬
  • Lombok 미사용으로 보일러플레이트 코드 과다

문제점 2: Repository의 프레임워크 직접 의존

// Before: 도메인 레이어가 Spring Data JPA에 직접 의존
public interface JudgmentResultRepository extends JpaRepository<JudgmentResult, Long> {
    
    @Query("SELECT jr FROM JudgmentResult jr " +
           "JOIN FETCH jr.solvedProblem sp " +
           "JOIN FETCH jr.user u " +
           "WHERE sp.id = :solvedProblemId AND u.id = :userId")
    List<JudgmentResult> findJudgmentResultsWithUserBySolvedProblemId...(...);
}

JudgmentResultRepository는 인터페이스임에도 불구하고 구체적인 JPQL 쿼리가 명시되어 있는 해괴한 상황이었다. 도메인 레이어에 속해야 할 Repository가 구체적인 구현 기술인 JPA와 JPQL에 깊게 의존하고 있었다.

이는 도메인 계층이 인프라스트럭처에 의존하게 만들어 DIP(의존성 역전 원칙)를 위반하는 결과를 초래했다.

문제:

  • 도메인 계층이 인프라스트럭처(JPA)에 의존 → 의존성 역전 원칙(DIP) 위반
  • JPQL 쿼리가 Repository 인터페이스에 노출

문제점 3: 서비스 계층의 과도한 책임

// Before: 서비스에서 엔티티 직접 생성, 연관관계 설정, 저장까지 모두 처리
@Transactional
public void saveJudgmentResult(JudgmentResultSaveRequest request, Long userId) {
    User user = userRepository.findById(userId)
            .orElseThrow(NotOurUserException::new);
    SolvedProblem solvedProblem = solvedProblemRepository
            .findByUserIdAndProblemNumber(userId, request.problemNumber())
            .orElse(new SolvedProblem(request.problemNumber(), 
                    request.problemTitle(), user, new Review(user)));
    solvedProblem.setRecentSubmitAt(request.submittedAt());
    solvedProblem.setRecentResultText(request.resultText());
    solvedProblemRepository.save(solvedProblem);
    // 생성자 직접 호출
    judgmentResultRepository.save(new JudgmentResult(
        request.baekjoonId(), request.codeLength(), request.language(), 
        request.memory(), request.problemNumber(), request.resultText(), 
        request.submissionId(), request.submittedAt(), request.time(), 
        user, solvedProblem));
}

기존 서비스 코드는 비즈니스 로직의 중심이 되어야 할 도메인 모델을 대신해 모든 제어 흐름을 직접 담당하고 있었다. 엔티티를 조회한 뒤 의미가 불분명한 Setter를 통해 상태를 하나하나 변경하고, 10개가 넘는 파라미터를 직접 나열하며 생성자를 호출하는 방식이 바로 위 코드다.

이는 객체가 스스로의 상태를 관리하지 못하고 서비스 계층에 의해 수동적으로 조작되는 결과를 초래했다.

로직이 도메인 엔티티 내부에 응집되지 못하고 서비스 계층에 파편화되어 있다 보니, 새로운 기능을 추가하거나 기존 로직을 수정하려 할 때 "어떤 클래스의 어느 메서드를 건드려야 하는지" 직관적으로 알기 어려웠다. 결국 캡슐화는 무너졌고, 서비스 코드는 비즈니스 흐름을 설명하기보다 단순한 데이터 전달 스크립트에 가까워져버렸다.

문제:

  • 의미없는 Setter 남발(비즈니스 용어를 사용한 메서드 필요)
  • 엔티티 생성 로직이 서비스에 분산 (정적 팩토리 메서드 도입 필요)
  • 필수 값 검증 로직 부재
  • 10개가 넘는 파라미터 생성자 → 가독성 및 유지보수성 저하

3. 목표: DDD Layered Architecture

여러 자료를 공부하며 Presentation, Application, Domain, Infrastructure 레이어로 나뉘는 구조를 설계했다.

의존성 규칙은 상위에서 하위로만 흐르게 했으며, 특히 module-domain은 어떤 외부 모듈에도 의존하지 않는 순수성을 유지하도록 했다.

module-infra-rdb는 Domain의 Repository 인터페이스만을 구현하도록 하여 기술 스택의 변경이 도메인에 영향을 주지 않도록 설계했다.

┌─────────────────────────────────────────────────────────┐
│                    Presentation Layer                    │
│                     (module-api)                         │
│          REST API, Controller, Request/Response DTO      │
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│                    Application Layer                     │
│                  (module-application)                    │
│           UseCase, Service, ApplicationService           │
│                트랜잭션 경계 + 도메인 객체 조합                 │
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│                     Domain Layer                         │
│                    (module-domain)                       │
│      Entity, Value Object, Repository Interface          │
│               순수 비즈니스 로직, 프레임워크 독립                │
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│                  Infrastructure Layer                    │
│                   (module-infra-rdb)                     │
│               JPA Entity, Repository 구현체               │
└─────────────────────────────────────────────────────────┘

4. 리팩토링 단계별 진행

1단계: 멀티모듈 구조 분리

Gradle을 활용하여 module-api, module-application, module-domain, module-infra-rdb, module-security로 모듈을 쪼갰다. 처음 해보는 작업이라 빌드 설정에서 꽤 오랜 시간이 걸렸지만, 모듈 간 의존성을 제한하며 캡슐화를 강화할 수 있었다.

MyCodingTest_BACKEND/
├── module-api/           # Presentation Layer
├── module-application/   # Application Layer
├── module-domain/        # Domain Layer (순수)
├── module-infra-rdb/     # Infrastructure Layer
├── module-security/      # Security 관심사 분리
└── build.gradle          # 루트 빌드 설정

2단계: 도메인 모델 재설계

DB 중심적으로 설계되었던 모델을 순수한 자바 객체로 분리했다. @ManyToOne같은 강한 결합 대신 각 도메인의 ID를 참조하는 방식을 도입하여 도메인 간의 결합도를 낮추었다.

또한 도메인 또한 쪼개는 작업을 했다. OCP를 의도하고 플랫폼별 MetaData를 도입하여 기종의 JudgmentResult 도메인 모델에 있던걸 분리하는 등의 작업을 했다.

기존의 도메인 모델은 사실상 DB 테이블이나 마찬가지였다. 테이블 구조가 바뀌면 도메인 로직이 흔들리고, 반대로 도메인 로직을 수정하려 해도 DB 제약 사항을 먼저 고민해야 하는 주객전도된 상황이었다.

이를 해결하기 위해 DB 중심의 설계를 벗어나 순수한 자바 객체 중심의 도메인 모델로 재설계를 진행했다.

이 과정에서 가장 신경 쓴 부분은 '객체 간의 관계'를 정의하는 방식이었다. 이전에는 @ManyToOne을 통한 객체 그래프 참조를 당연하게 사용했지만, 이는 모듈 간의 강한 결합을 유도하고 불필요한 엔티티 로딩 문제를 야기했다. 이를 개선하기 위해 다음과 같은 설계 원칙을 적용했다.

  • ID를 통한 느슨한 결합 - 엔티티 간의 직접적인 참조 대신 userId, problemId처럼 식별자(ID)만 들고 있게 변경
  • 플랫폼별 메타데이터 분리(OCP 준수) - 기존에는 JudgmentResult에 백준 전용 필드들이 섞여 있어 다른 플랫폼(예: 프로그래머스)을 추가하려면 모델 자체를 수정해야 했다. 이를 플랫폼별 특화 정보인 MetaData로 분리하고 JSON 형태로 관리하도록 하여, 기존 코드를 수정하지 않고도 새로운 플랫폼을 확장할 수 있는 구조를 수정
@Getter
@Builder
public class Judgment {
    /**
     * 고유 식별자
     */
    private Long id;
    
    ...생략...
    
    /**
     * 플랫폼별 특화 메타데이터
     * <p>
     * 플랫폼마다 상이한 채점 정보(메모리, 시간, 언어 버전 등)를 유연하게 저장하기 위해 반정규화된 JSON 형태로 관리합니다.
     * </p>
     */
    private MetaData metaData;
    
    ...생략...
}
 

3단계: Repository 인터페이스/구현체 분리

도메인 모듈에는 순수한 인터페이스만 남기고, 실제 구현은 module-infra-rdb 모듈에서 담당하게 했다. 이 과정에서 도메인이 인프라 기술을 몰라도 되도록 DIP가 자연스럽게 준수되었다.

// module-domain: 인터페이스만 정의
public interface JudgmentRepository {
    Judgment save(Judgment judgment);
    List<Judgment> findByProblemIdAndUserId(Long problemId, Long userId);
    boolean existsBySubmissionId(Long submissionId, Platform platform);
}
 
// module-infra-rdb: 구현체
@Repository
public class JudgmentRepositoryImpl implements JudgmentRepository {
    private final JpaJudgmentRepository jpaRepository;
    
    @Override
    public Judgment save(Judgment judgment) {
        JudgmentEntity entity = JudgmentEntity.from(judgment);
        JudgmentEntity saved = jpaRepository.save(entity);
        return saved.toDomain();
    }
}
 

4단계: 정적 팩토리 메서드 도입

엔티티 생성 시 필수 값을 검증하고 객체 생성의 의도를 명확히 하기 위해 from 같은 정적 팩토리 메서드를 도입했다.

이렇게 해서 서비스 계층의 비대했던 생성 로직을 도메인 내부로 캡슐화했다.

// 도메인 엔티티에 팩토리 메서드 추가
public class Judgment {
    ...생략...
    public static Judgment from(Long problemId, Long userId, Long submissionId,
            JudgmentStatus status, Platform platform, 
            MetaData metaData, String sourceCode) {
        // 필수 값 검증
        if (problemId == null) {
            throw new IllegalArgumentException("문제 ID는 필수입니다");
        }
        if (userId == null) {
            throw new IllegalArgumentException("사용자 ID는 필수입니다");
        }
        // 객체 생성
        ...생략...
    }
}
 

5단계: Javadoc, Swagger 문서화

미래의 나와의 협업을 위해 모든 도메인 클래스에 한글 Javadoc을 추가했다. 각 엔티티의 역할과 관계를 명확히 기술하여 코드 자체의 설명력을 높였다.

/**
 * 채점 결과를 나타내는 엔티티입니다.
 * <p>
 * 사용자가 외부 플랫폼(백준 등)에서 제출한 코드의 채점 결과를 저장합니다.
 * </p>
 *
 * @see JudgmentStatus
 * @see MetaData
 */
public class Judgment { ... }

5. 배운 점 & 이후 계획

배운 점

이번 리팩토링을 통해 얻은 가장 큰 수확은 '관심사의 분리'가 주는 깔끔함이었다.

처음에는 엔티티와 도메인 객체를 변환하는 매퍼 코드를 작성하는 것이 번거롭게 느껴지기도 했다. 하지만 막상 분리하고 나니 영속성 계층의 변경이 도메인 로직에 아무런 영향을 주지 않는다는 점에서 오는 안정감이 컸다.

또한 멀티모듈을 통해 의존성 제어를 더 철저히 하여 클래스 접근 제어자로 부족했던 캡슐화를 더 단단히 실현할 수 있었다.

이후 계획

  • 누락된 리팩토링 요소 파악
  • 단위 테스트 더 꼼꼼히 추가
  • 서비스 재배포
  • 부하 테스트를 통한 병목 구간파악
  • 병목 구간의 최적화

6. 참고자료