언어 및 프레임워크/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