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("테스트");
    }
}