Theory
고급 2-3시간 이론 + 실습
Spring 22: 실전 프로젝트 구조
프로덕션 레벨 프로젝트 설계
Clean ArchitectureDDD모듈화Best Practice
1. 프로젝트 구조 설계
1.1 멀티 모듈 프로젝트
실전 프로젝트에서는 기능별로 모듈을 분리하여 관리합니다. 각 모듈은 독립적인 책임을 가지며 재사용 가능한 구조로 설계합니다.
// 멀티 모듈 구조 ecommerce-project/ ├── ecommerce-core/ // 공통 도메인, 유틸리티 ├── ecommerce-api/ // REST API 모듈 ├── ecommerce-batch/ // 배치 처리 모듈 ├── ecommerce-admin/ // 관리자 모듈 └── ecommerce-common/ // 공통 설정, 예외처리 // settings.gradle rootProject.name = 'ecommerce-project' include 'ecommerce-core' include 'ecommerce-api' include 'ecommerce-batch' include 'ecommerce-admin' include 'ecommerce-common'
모듈 분리 원칙:
- - 단일 책임 원칙
- - 의존성 방향 관리
- - 재사용성 고려
- - 배포 단위 분리
2. 레이어드 아키텍처
2.1 계층별 패키지 구조
각 계층의 역할을 명확히 분리하여 유지보수성과 테스트 용이성을 높입니다.
// 패키지 구조
com.example.ecommerce/
├── controller/ // 프레젠테이션 계층
│ ├── UserController.java
│ ├── OrderController.java
│ └── dto/
│ ├── UserRequest.java
│ └── UserResponse.java
├── service/ // 비즈니스 계층
│ ├── UserService.java
│ ├── OrderService.java
│ └── impl/
│ ├── UserServiceImpl.java
│ └── OrderServiceImpl.java
├── repository/ // 데이터 접근 계층
│ ├── UserRepository.java
│ └── OrderRepository.java
├── domain/ // 도메인 모델
│ ├── User.java
│ ├── Order.java
│ └── OrderItem.java
└── config/ // 설정
├── DatabaseConfig.java
└── SecurityConfig.java// Controller 계층
@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody UserRequest request) {
UserResponse response = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{userId}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long userId) {
UserResponse response = userService.getUser(userId);
return ResponseEntity.ok(response);
}
}3. 예외 처리 전략
3.1 글로벌 예외 처리
일관된 예외 처리를 위해 @ControllerAdvice를 사용하여 전역 예외 핸들러를 구현합니다.
// 커스텀 예외 클래스
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
public enum ErrorCode {
USER_NOT_FOUND("U001", "사용자를 찾을 수 없습니다."),
INVALID_PASSWORD("U002", "비밀번호가 올바르지 않습니다."),
ORDER_NOT_FOUND("O001", "주문을 찾을 수 없습니다."),
INSUFFICIENT_STOCK("P001", "재고가 부족합니다.");
private final String code;
private final String message;
ErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
}@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.warn("Business exception occurred: {}", e.getMessage());
ErrorResponse response = ErrorResponse.of(e.getErrorCode());
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException e) {
log.warn("Validation exception occurred: {}", e.getMessage());
ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT,
e.getBindingResult());
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("Unexpected exception occurred", e);
ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}4. 설정 관리
4.1 프로파일별 설정
환경별로 다른 설정을 관리하기 위해 Spring Profile을 활용합니다.
# application.yml (공통 설정)
spring:
application:
name: ecommerce-api
profiles:
active: local
jpa:
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.SnakeCasePhysicalNamingStrategy
show-sql: false
properties:
hibernate:
format_sql: true
logging:
level:
com.example.ecommerce: INFO
org.springframework.security: DEBUG
---
# application-local.yml
spring:
config:
activate:
on-profile: local
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
---
# application-dev.yml
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:mysql://dev-db:3306/ecommerce
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate@Configuration
@Profile("!test")
public class DatabaseConfig {
@Bean
@ConfigurationProperties("spring.datasource")
public DataSource dataSource() {
return DataSourceBuilder.create().build();
}
}
@Configuration
@Profile("local")
public class LocalConfig {
@Bean
public CommandLineRunner initData(UserRepository userRepository) {
return args -> {
// 로컬 환경 초기 데이터 설정
if (userRepository.count() == 0) {
User admin = User.builder()
.email("admin@example.com")
.name("관리자")
.role(UserRole.ADMIN)
.build();
userRepository.save(admin);
}
};
}
}5. 테스트 전략
5.1 계층별 테스트
각 계층별로 적절한 테스트 전략을 수립하여 안정적인 애플리케이션을 구축합니다.
// Controller 테스트
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void createUser_Success() throws Exception {
// given
UserRequest request = new UserRequest("test@example.com", "테스트");
UserResponse response = new UserResponse(1L, "test@example.com", "테스트");
when(userService.createUser(any(UserRequest.class))).thenReturn(response);
// when & then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.email").value("test@example.com"));
}
}// Service 테스트
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserServiceImpl userService;
@Test
void createUser_Success() {
// given
UserRequest request = new UserRequest("test@example.com", "테스트");
User savedUser = User.builder()
.id(1L)
.email("test@example.com")
.name("테스트")
.build();
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// when
UserResponse response = userService.createUser(request);
// then
assertThat(response.getId()).isEqualTo(1L);
assertThat(response.getEmail()).isEqualTo("test@example.com");
}
}// Repository 테스트
@DataJpaTest
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void findByEmail_Success() {
// given
User user = User.builder()
.email("test@example.com")
.name("테스트")
.build();
entityManager.persistAndFlush(user);
// when
Optional<User> found = userRepository.findByEmail("test@example.com");
// then
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("테스트");
}
}