언어 및 프레임워크/Spring Boot

Spring Boot 고급 시리즈 2편 – 유지보수 가능한 서비스 아키텍처 설계 전략

B컷개발자 2025. 4. 18. 10:11
728x90

Spring Boot에서 유지보수와 확장성을 고려한 계층 아키텍처 설계 방법을 정리했습니다. 도메인 분리, UseCase 패턴, 책임 분리 전략을 실무 중심으로 설명합니다.


Spring Boot 고급 시리즈 2편 – 유지보수 가능한 서비스 아키텍처 설계 전략

서비스가 커질수록 기존의 Controller-Service-Repository 3계층 구조만으로는 코드가 뒤엉기기 쉽습니다.
이번 글에서는 실제 운영 가능한 서비스 아키텍처를 어떻게 분리하고 조직화할 수 있는지,
그리고 UseCase 기반 계층 분리, Domain 중심 설계(DDD-lite) 전략을 실무 관점에서 소개합니다.


🧱 1. 전통적인 3계층 구조의 한계

기본 구조는 다음과 같습니다:

Controller → Service → Repository → DB

하지만 이런 구조에서는…

  • Service 계층이 비대해지고 모든 책임을 떠맡음
  • 도메인 로직과 유스케이스(기능 흐름)가 혼재됨
  • 테스트와 유지보수가 어려워짐

예: 회원가입 로직, 이메일 인증, 권한 체크가 전부 Service에 뭉쳐 있음


🧩 2. 계층 분리를 위한 핵심 개념 3가지

1️⃣ UseCase 계층 도입 → 비즈니스 유스케이스 단위 분리

Controller
 └─ UseCase (회원가입, 주문, 결제 등)
     └─ DomainService / Entity
         └─ Repository
  • 각 유스케이스는 하나의 명확한 기능 단위를 가짐
  • 비즈니스 규칙이 명확히 분리되어 읽기 쉽고 테스트 용이

2️⃣ Domain 계층 분리 → 비즈니스 규칙을 모델에 담는다

public class User {
    public void register(String email, PasswordEncoder encoder) {
        this.email = email;
        this.password = encoder.encode(password);
    }
}
  • Entity가 도메인 로직을 갖도록 설계 (Anemic 모델 탈피)
  • 비즈니스 정책은 가능한 한 Domain 객체 안에 위치시킨다

3️⃣ DTO ↔ Command/Query 객체 구분

  • Controller → UseCase : Command 또는 Query 전달
  • UseCase → DomainService: 명확한 입력/출력 구조 설계
public record CreateUserCommand(String name, String email, String rawPassword) {}

이로 인해 입력값이 명확하고 변경 추적이 쉬워집니다.


🧠 3. 실무 아키텍처 구조 예시

com.myapp
├── controller
├── user
│   ├── domain
│   │   └── User.java
│   ├── usecase
│   │   └── RegisterUserUseCase.java
│   ├── infra
│   │   └── UserJpaRepository.java
│   └── service
│       └── UserDomainService.java
├── common
│   ├── error
│   └── response

✅ 특징

  • 도메인 단위별로 패키지를 나누어 응집도 ↑
  • UseCase → DomainService → Repository 순으로 흐름 분리
  • test 단위: UseCase 단위로 단위 테스트 가능

🔧 4. 서비스 아키텍처의 실전 적용 전략

전략 설명

유스케이스 중심 한 기능 단위(등록, 조회, 수정 등)를 독립적으로 분리
단방향 흐름 유지 Controller → UseCase → Domain 순, 역참조 금지
테스트 가능한 설계 의존성 분리, 입출력 명확화로 테스트 쉬움
도메인 중심 설계 Entity, VO(Value Object)에 규칙과 책임 부여

 

✅ 예시 : 회원 가입(회원 등록 UseCase) 시나리오 기반

이번 예시는 “회원 가입”이라는 가장 일반적인 비즈니스 로직을 가지고,
계층 분리, UseCase 중심 설계, 도메인 중심 설계를 적용한 구조를 코드 기반으로 설명합니다.


📁 패키지 구조 설계

com.myapp
├── user
│   ├── controller
│   │   └── UserController.java
│   ├── usecase
│   │   └── RegisterUserUseCase.java
│   ├── domain
│   │   ├── User.java
│   │   └── UserRepository.java
│   ├── infra
│   │   └── UserJpaRepository.java
│   └── service
│       └── UserDomainService.java
├── common
│   ├── dto
│   │   └── ApiResponse.java
│   └── exception
│       ├── BusinessException.java
│       ├── ErrorCode.java
│       └── GlobalExceptionHandler.java

1. UserController – API 진입점

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final RegisterUserUseCase registerUserUseCase;

    @PostMapping
    public ResponseEntity<ApiResponse<Void>> register(@RequestBody RegisterUserCommand command) {
        registerUserUseCase.execute(command);
        return ResponseEntity.ok(ApiResponse.success());
    }
}
  • Controller에서는 HTTP 요청을 받고, 유스케이스 실행만 위임합니다.

2. RegisterUserCommand – 명령 객체

public record RegisterUserCommand(String name, String email, String password) {}
  • JSON 요청 데이터를 내부적으로 **구조화된 명령 객체(Command)**로 변환해 전달합니다.
  • 이 방식은 명확한 입력 책임 분리에 유리합니다.

3. RegisterUserUseCase – 유즈케이스 구현

@Component
@RequiredArgsConstructor
public class RegisterUserUseCase {

    private final UserDomainService userDomainService;

    public void execute(RegisterUserCommand command) {
        userDomainService.register(command.name(), command.email(), command.password());
    }
}
  • 기능 단위로 나누어진 유스케이스 (가입, 탈퇴, 수정 등)를 한 기능 하나의 UseCase로 표현
  • 비즈니스 흐름은 DomainService로 위임

4. UserDomainService – 도메인 정책 처리

@Service
@RequiredArgsConstructor
public class UserDomainService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public void register(String name, String email, String rawPassword) {
        if (userRepository.existsByEmail(email)) {
            throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
        }

        User user = User.create(name, email, passwordEncoder.encode(rawPassword));
        userRepository.save(user);
    }
}
  • 중복 이메일 검사 → 도메인 정책
  • 암호화 → 외부 의존성 처리 (협력 객체)
  • User 객체의 생성 책임은 도메인 모델에게 위임

5. User – 도메인 모델

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private String email;
    private String password;

    public static User create(String name, String email, String encodedPassword) {
        User user = new User();
        user.name = name;
        user.email = email;
        user.password = encodedPassword;
        return user;
    }
}
  • 도메인 객체는 자기 자신을 유효한 상태로 유지하는 책임을 가짐
  • setter를 쓰지 않고 생성자를 통한 불변성 확보

6. UserRepository (도메인 인터페이스)

public interface UserRepository {
    boolean existsByEmail(String email);
    User save(User user);
}
  • 비즈니스 계층은 JPA나 DB 기술에 의존하지 않고 인터페이스에만 의존

 

 7. UserJpaRepository (인프라 구현체)

@Repository
public class UserJpaRepository implements UserRepository {

    private final JpaUserRepository jpaRepository;

    public UserJpaRepository(JpaUserRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public boolean existsByEmail(String email) {
        return jpaRepository.existsByEmail(email);
    }

    @Override
    public User save(User user) {
        return jpaRepository.save(user);
    }
}
interface JpaUserRepository extends JpaRepository<User, Long> {
    boolean existsByEmail(String email);
}
  • 인프라 계층은 Spring Data JPA 기반 구현체로 구성
  • 도메인과 구현 분리 → 테스트와 유지보수에 유리

8. 예외 처리: ErrorCode + BusinessException 설계

@Getter
@AllArgsConstructor
public enum ErrorCode {
    DUPLICATE_EMAIL(400, "이미 사용 중인 이메일입니다."),
    INTERNAL_SERVER_ERROR(500, "서버 오류가 발생했습니다.");

    private final int status;
    private final String message;
}
public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;
    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

🧠 요약

항목 적용 내용

Controller HTTP 요청만 담당
UseCase 기능 단위 비즈니스 흐름 정의
Domain 상태와 비즈니스 규칙 담당
Repository 인터페이스로 기술 추상화
Infra JPA 구현체는 기술 세부사항만 담당
예외 공통 에러 코드 체계로 관리

🧪 테스트 전략 요약

  • UseCase 단위 테스트 → Command 객체로 독립 테스트 가능
  • Domain 단위 테스트 → JPA 없이도 핵심 비즈니스 테스트 가능
  • 통합 테스트는 Controller + UseCase 중심으로 최소화

 

✅ 마무리 요약

항목 정리

문제 Service 계층이 지나치게 많은 책임을 가짐
해결 UseCase 단위 분리, Domain 로직 캡슐화
패턴 Command-UseCase-DomainService-Repos 순 구조
장점 유지보수, 협업, 테스트 용이성 ↑, 코드 응집도 ↑
확장 모듈화, MSA 전환 시에도 구조적 이점 확보 가능

📌 다음 편 예고

Spring Boot 고급 시리즈 3편: 커스텀 예외와 오류 코드 설계 – 실무에 강한 API 오류 처리 시스템 만들기

 

728x90