스프링 사전과제 가이드 7편: Advanced Patterns

스프링 사전과제 가이드 7편: Advanced Patterns


시리즈 네비게이션

이전현재다음
6편: DevOps7편: Advanced Patterns종합 과제

📚 전체 로드맵: 스프링 사전과제 가이드 로드맵 참고


서론

시리즈의 마지막 편으로, 차별화를 보여줄 수 있는 고급 패턴들을 다룬다. 모든 과제에 적용할 필요는 없지만, 상황에 맞게 활용하면 설계 역량을 보여줄 수 있다.

7편에서 다루는 내용:

  • 이벤트 기반 아키텍처
  • 비동기 처리
  • 파일 처리
  • API 버저닝
  • 아키텍처 패턴
  • 멀티 모듈 프로젝트

목차


이벤트 기반 아키텍처

1. Spring Events 기본

이벤트를 활용하면 도메인 로직과 부가 기능(알림, 로깅 등)을 분리할 수 있다.

// 이벤트 정의
public record OrderCreatedEvent(
    Long orderId,
    Long memberId,
    Integer totalAmount,
    LocalDateTime occurredAt
) {
    public OrderCreatedEvent(Order order) {
        this(order.getId(), order.getMember().getId(),
             order.getTotalAmount(), LocalDateTime.now());
    }
}
// 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Long createOrder(OrderCreateCommand command) {
        Order order = Order.create(command);
        orderRepository.save(order);

        // 이벤트 발행
        eventPublisher.publishEvent(new OrderCreatedEvent(order));

        return order.getId();
    }
}
// 이벤트 리스너
@Component
@RequiredArgsConstructor
public class OrderEventListener {

    private static final Logger log = LoggerFactory.getLogger(OrderEventListener.class);
    private final NotificationService notificationService;

    @EventListener
    public void handleOrderCreated(OrderCreatedEvent event) {
        log.info("Order created: orderId={}, memberId={}",
                 event.orderId(), event.memberId());

        // 알림 발송
        notificationService.sendOrderConfirmation(event.memberId(), event.orderId());
    }
}
Kotlin 버전
// 이벤트 정의
data class OrderCreatedEvent(
    val orderId: Long,
    val memberId: Long,
    val totalAmount: Int,
    val occurredAt: LocalDateTime = LocalDateTime.now()
) {
    constructor(order: Order) : this(
        orderId = order.id!!,
        memberId = order.member.id!!,
        totalAmount = order.totalAmount
    )
}

// 이벤트 발행
@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: ApplicationEventPublisher
) {
    @Transactional
    fun createOrder(command: OrderCreateCommand): Long {
        val order = Order.create(command)
        orderRepository.save(order)

        eventPublisher.publishEvent(OrderCreatedEvent(order))

        return order.id!!
    }
}

// 이벤트 리스너
@Component
class OrderEventListener(
    private val notificationService: NotificationService
) {
    private val log = LoggerFactory.getLogger(javaClass)

    @EventListener
    fun handleOrderCreated(event: OrderCreatedEvent) {
        log.info("Order created: orderId={}, memberId={}", event.orderId, event.memberId)
        notificationService.sendOrderConfirmation(event.memberId, event.orderId)
    }
}

2. @TransactionalEventListener

트랜잭션 상태에 따라 이벤트 처리 시점을 제어할 수 있다.

@Component
@RequiredArgsConstructor
public class OrderEventListener {

    private final NotificationService notificationService;

    /**
     * 트랜잭션 커밋 후 실행
     * - 주문 저장이 확정된 후에만 알림 발송
     */
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCreatedAfterCommit(OrderCreatedEvent event) {
        notificationService.sendOrderConfirmation(event.memberId(), event.orderId());
    }

    /**
     * 트랜잭션 롤백 시 실행
     * - 실패 로깅 등
     */
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleOrderCreatedOnRollback(OrderCreatedEvent event) {
        // 실패 로깅
    }
}
Phase설명사용 시점
AFTER_COMMIT커밋 성공 후알림, 외부 시스템 연동
AFTER_ROLLBACK롤백 후실패 로깅, 보상 처리
AFTER_COMPLETION커밋/롤백 상관없이리소스 정리
BEFORE_COMMIT커밋 직전추가 검증

3. 비동기 이벤트 처리

@Component
@RequiredArgsConstructor
public class OrderEventListener {

    private final NotificationService notificationService;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCreatedAsync(OrderCreatedEvent event) {
        // 비동기로 실행되어 메인 트랜잭션에 영향 없음
        notificationService.sendOrderConfirmation(event.memberId(), event.orderId());
    }
}
💬 이벤트 vs 직접 호출 선택 기준
상황권장 방식이유
핵심 비즈니스 로직직접 호출명확한 흐름, 디버깅 용이
부가 기능 (알림, 로깅)이벤트느슨한 결합, 확장 용이
외부 시스템 연동이벤트 + 비동기실패해도 메인 로직 영향 없음
여러 모듈에서 반응이벤트발행자가 구독자를 몰라도 됨

과제에서 권장: 핵심 로직은 직접 호출, 알림/로깅 등은 이벤트로 분리하면 좋은 설계로 평가받을 수 있다.

⚠️ 이벤트 사용 시 주의점
  1. 트랜잭션 경계 주의

    • @EventListener는 같은 트랜잭션 내에서 실행
    • 리스너에서 예외 발생 시 전체 롤백
  2. 순환 참조 주의

    • A → 이벤트 발행 → B 리스너 → A 호출 → 무한 루프
  3. 테스트 어려움

    • 이벤트 발행/구독 검증 필요
    • @SpyBean이나 테스트용 리스너 활용
  4. 디버깅 어려움

    • 흐름 추적이 어려움
    • 로깅을 충실히 남길 것

비동기 처리

1. @Async 설정

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            Logger log = LoggerFactory.getLogger(method.getDeclaringClass());
            log.error("Async method {} threw exception: {}", method.getName(), ex.getMessage(), ex);
        };
    }
}

2. @Async 사용

@Service
@RequiredArgsConstructor
public class NotificationService {

    private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
    private final EmailSender emailSender;
    private final SmsSender smsSender;

    @Async
    public void sendOrderConfirmation(Long memberId, Long orderId) {
        log.info("Sending order confirmation: memberId={}, orderId={}", memberId, orderId);

        // 이메일 발송 (비동기로 실행)
        emailSender.send(memberId, "주문 확인", "주문이 완료되었습니다.");
    }

    @Async
    public CompletableFuture<Boolean> sendSmsAsync(String phoneNumber, String message) {
        boolean result = smsSender.send(phoneNumber, message);
        return CompletableFuture.completedFuture(result);
    }
}

3. CompletableFuture 활용

@Service
@RequiredArgsConstructor
public class ProductAggregationService {

    private final ProductService productService;
    private final ReviewService reviewService;
    private final InventoryService inventoryService;

    /**
     * 여러 서비스에서 데이터를 병렬로 조회
     */
    public ProductDetailResponse getProductDetail(Long productId) {
        CompletableFuture<Product> productFuture =
            CompletableFuture.supplyAsync(() -> productService.getProduct(productId));

        CompletableFuture<List<Review>> reviewsFuture =
            CompletableFuture.supplyAsync(() -> reviewService.getReviews(productId));

        CompletableFuture<Integer> stockFuture =
            CompletableFuture.supplyAsync(() -> inventoryService.getStock(productId));

        // 모든 비동기 작업 완료 대기
        CompletableFuture.allOf(productFuture, reviewsFuture, stockFuture).join();

        return ProductDetailResponse.of(
            productFuture.join(),
            reviewsFuture.join(),
            stockFuture.join()
        );
    }

    /**
     * 타임아웃 적용
     */
    public ProductDetailResponse getProductDetailWithTimeout(Long productId) {
        try {
            CompletableFuture<ProductDetailResponse> future = CompletableFuture.supplyAsync(() ->
                getProductDetail(productId)
            );

            return future.get(5, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            throw new ServiceTimeoutException("Product detail fetch timeout");
        } catch (Exception e) {
            throw new ServiceException("Failed to fetch product detail", e);
        }
    }
}
💬 동기 vs 비동기 처리 판단
상황권장 방식이유
응답에 결과 필요동기결과를 기다려야 함
응답에 결과 불필요비동기응답 시간 단축
외부 API 호출비동기 (타임아웃)실패/지연에 영향받지 않음
트랜잭션 필수동기트랜잭션 전파 어려움
여러 작업 병렬 실행비동기처리 시간 단축

과제에서: 알림 발송 등 응답에 필요 없는 작업을 비동기로 처리하면 좋은 평가를 받을 수 있다.

⚠️ @Async 사용 시 주의점
  1. 같은 클래스 내 호출 불가

    • 프록시 기반이므로 self-invocation 시 동기로 실행
    • 다른 Bean에서 호출해야 함
  2. 트랜잭션 전파 안됨

    • @Async 메서드는 별도 스레드에서 실행
    • 새로운 트랜잭션 필요 시 @Transactional 추가
  3. 예외 처리

    • void 반환 시 예외가 무시될 수 있음
    • AsyncUncaughtExceptionHandler 설정 필수
  4. 스레드풀 고갈

    • 큐 용량, 최대 스레드 수 적절히 설정
    • 모니터링 필요

파일 처리

1. 파일 업로드

@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
public class FileController {

    private final FileService fileService;

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<FileUploadResponse> uploadFile(
            @RequestParam("file") MultipartFile file) {

        FileUploadResponse response = fileService.upload(file);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @PostMapping(value = "/multiple", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<List<FileUploadResponse>> uploadFiles(
            @RequestParam("files") List<MultipartFile> files) {

        List<FileUploadResponse> responses = fileService.uploadMultiple(files);
        return ResponseEntity.status(HttpStatus.CREATED).body(responses);
    }
}
@Service
@RequiredArgsConstructor
public class FileService {

    private static final Logger log = LoggerFactory.getLogger(FileService.class);
    private static final List<String> ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png", "gif", "pdf");
    private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

    @Value("${file.upload-dir}")
    private String uploadDir;

    public FileUploadResponse upload(MultipartFile file) {
        validateFile(file);

        String originalFilename = file.getOriginalFilename();
        String extension = getExtension(originalFilename);
        String storedFilename = UUID.randomUUID() + "." + extension;
        Path filePath = Paths.get(uploadDir, storedFilename);

        try {
            Files.createDirectories(filePath.getParent());
            file.transferTo(filePath);

            log.info("File uploaded: original={}, stored={}", originalFilename, storedFilename);

            return new FileUploadResponse(storedFilename, originalFilename, file.getSize());
        } catch (IOException e) {
            throw new FileUploadException("Failed to upload file", e);
        }
    }

    private void validateFile(MultipartFile file) {
        if (file.isEmpty()) {
            throw new InvalidFileException("File is empty");
        }

        if (file.getSize() > MAX_FILE_SIZE) {
            throw new InvalidFileException("File size exceeds limit");
        }

        String extension = getExtension(file.getOriginalFilename());
        if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
            throw new InvalidFileException("File type not allowed: " + extension);
        }
    }

    private String getExtension(String filename) {
        return filename.substring(filename.lastIndexOf(".") + 1);
    }
}

2. 파일 다운로드

@GetMapping("/{filename}")
public ResponseEntity<Resource> downloadFile(@PathVariable String filename) {
    Resource resource = fileService.loadAsResource(filename);

    String contentDisposition = ContentDisposition.attachment()
        .filename(filename, StandardCharsets.UTF_8)
        .build()
        .toString();

    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}
public Resource loadAsResource(String filename) {
    try {
        Path filePath = Paths.get(uploadDir).resolve(filename).normalize();
        Resource resource = new UrlResource(filePath.toUri());

        if (resource.exists() && resource.isReadable()) {
            return resource;
        } else {
            throw new FileNotFoundException("File not found: " + filename);
        }
    } catch (MalformedURLException e) {
        throw new FileNotFoundException("File not found: " + filename, e);
    }
}

3. S3 연동 (AWS)

// build.gradle
implementation 'software.amazon.awssdk:s3:2.21.0'
@Configuration
public class S3Config {

    @Value("${aws.region}")
    private String region;

    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
            .region(Region.of(region))
            .build();
    }
}
@Service
@RequiredArgsConstructor
public class S3FileService {

    private final S3Client s3Client;

    @Value("${aws.s3.bucket}")
    private String bucket;

    public String upload(MultipartFile file) {
        String key = "uploads/" + UUID.randomUUID() + "_" + file.getOriginalFilename();

        try {
            PutObjectRequest request = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType(file.getContentType())
                .build();

            s3Client.putObject(request, RequestBody.fromInputStream(
                file.getInputStream(), file.getSize()));

            return key;
        } catch (IOException e) {
            throw new FileUploadException("Failed to upload to S3", e);
        }
    }

    public byte[] download(String key) {
        GetObjectRequest request = GetObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .build();

        try (ResponseInputStream<GetObjectResponse> response = s3Client.getObject(request)) {
            return response.readAllBytes();
        } catch (IOException e) {
            throw new FileDownloadException("Failed to download from S3", e);
        }
    }
}
💬 로컬 파일 vs 클라우드 스토리지
방식장점단점사용 시점
로컬 파일간단, 네트워크 비용 없음서버 확장 시 공유 어려움단일 서버, 개발/테스트
S3/GCS확장성, 내구성, CDN 연계비용, 네트워크 지연프로덕션, 대용량

과제에서 권장:

  • 기본: 로컬 파일 시스템으로 구현
  • 가산점: S3 연동 또는 S3 인터페이스 추상화

API 버저닝

1. URI 버저닝 (가장 일반적)

@RestController
@RequestMapping("/api/v1/products")
public class ProductControllerV1 {

    @GetMapping("/{id}")
    public ProductResponseV1 getProduct(@PathVariable Long id) {
        // V1 응답
    }
}

@RestController
@RequestMapping("/api/v2/products")
public class ProductControllerV2 {

    @GetMapping("/{id}")
    public ProductResponseV2 getProduct(@PathVariable Long id) {
        // V2 응답 (필드 추가 등)
    }
}

2. Header 버저닝

@RestController
@RequestMapping("/api/products")
public class ProductController {

    @GetMapping(value = "/{id}", headers = "X-API-VERSION=1")
    public ProductResponseV1 getProductV1(@PathVariable Long id) {
        // V1 응답
    }

    @GetMapping(value = "/{id}", headers = "X-API-VERSION=2")
    public ProductResponseV2 getProductV2(@PathVariable Long id) {
        // V2 응답
    }
}

3. Accept Header 버저닝

@RestController
@RequestMapping("/api/products")
public class ProductController {

    @GetMapping(value = "/{id}", produces = "application/vnd.myapp.v1+json")
    public ProductResponseV1 getProductV1(@PathVariable Long id) {
        // V1 응답
    }

    @GetMapping(value = "/{id}", produces = "application/vnd.myapp.v2+json")
    public ProductResponseV2 getProductV2(@PathVariable Long id) {
        // V2 응답
    }
}
💬 버저닝 전략 비교
방식장점단점
URI명확, 캐싱 용이, 테스트 쉬움URL 변경 필요
HeaderURL 깔끔테스트/문서화 어려움
AcceptRESTful복잡, 이해하기 어려움
Parameter간단선택적 파라미터와 혼동

과제에서 권장: URI 버저닝 (/api/v1/...)이 가장 명확하고 일반적


아키텍처 패턴

1. 계층형 아키텍처 (기본)

┌─────────────────────────────────────┐
│         Controller (Presentation)    │
├─────────────────────────────────────┤
│            Service (Business)        │
├─────────────────────────────────────┤
│         Repository (Persistence)     │
├─────────────────────────────────────┤
│              Domain (Entity)         │
└─────────────────────────────────────┘

대부분의 과제에서 사용하는 기본 구조다.

2. Hexagonal Architecture (포트와 어댑터)

                    ┌─────────────────┐
    Driving         │                 │        Driven
    Adapters        │    Application  │        Adapters
                    │      Core       │
┌──────────┐       │                 │       ┌──────────┐
│Controller│──────▶│  ┌───────────┐  │──────▶│Repository│
└──────────┘       │  │  Domain   │  │       └──────────┘
                    │  │  Service  │  │
┌──────────┐       │  └───────────┘  │       ┌──────────┐
│  Event   │──────▶│                 │──────▶│ External │
│ Listener │       │                 │       │   API    │
└──────────┘       └─────────────────┘       └──────────┘
src/main/java/com/example/
├── application/              # Application Layer
│   ├── port/
│   │   ├── in/              # Inbound Ports (Use Cases)
│   │   │   └── CreateOrderUseCase.java
│   │   └── out/             # Outbound Ports
│   │       ├── OrderRepository.java
│   │       └── PaymentGateway.java
│   └── service/
│       └── OrderService.java
├── domain/                   # Domain Layer
│   ├── Order.java
│   └── OrderItem.java
└── adapter/                  # Adapter Layer
    ├── in/
    │   └── web/
    │       └── OrderController.java
    └── out/
        ├── persistence/
        │   └── OrderJpaAdapter.java
        └── external/
            └── PaymentGatewayAdapter.java
// Inbound Port (Use Case Interface)
public interface CreateOrderUseCase {
    Long createOrder(CreateOrderCommand command);
}

// Outbound Port
public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(Long id);
}

// Application Service
@Service
@RequiredArgsConstructor
public class OrderService implements CreateOrderUseCase {

    private final OrderRepository orderRepository;  // Port 사용
    private final PaymentGateway paymentGateway;    // Port 사용

    @Override
    @Transactional
    public Long createOrder(CreateOrderCommand command) {
        Order order = Order.create(command);
        orderRepository.save(order);
        paymentGateway.process(order);
        return order.getId();
    }
}

// Outbound Adapter
@Repository
@RequiredArgsConstructor
public class OrderJpaAdapter implements OrderRepository {

    private final OrderJpaRepository jpaRepository;

    @Override
    public Order save(Order order) {
        return jpaRepository.save(order);
    }

    @Override
    public Optional<Order> findById(Long id) {
        return jpaRepository.findById(id);
    }
}

3. CQRS (Command Query Responsibility Segregation)

명령(쓰기)과 조회(읽기)를 분리하는 패턴이다.

src/main/java/com/example/order/
├── command/                  # 명령 (쓰기)
│   ├── CreateOrderCommand.java
│   ├── OrderCommandService.java
│   └── OrderCommandRepository.java
└── query/                    # 조회 (읽기)
    ├── OrderQueryService.java
    ├── OrderQueryRepository.java
    └── OrderDetailResponse.java
// Command Service (쓰기)
@Service
@RequiredArgsConstructor
@Transactional
public class OrderCommandService {

    private final OrderRepository orderRepository;

    public Long createOrder(CreateOrderCommand command) {
        Order order = Order.create(command);
        return orderRepository.save(order).getId();
    }

    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.cancel();
    }
}

// Query Service (읽기)
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderQueryService {

    private final OrderQueryRepository queryRepository;

    public OrderDetailResponse getOrderDetail(Long orderId) {
        return queryRepository.findOrderDetail(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
    }

    public Page<OrderSummaryResponse> getMyOrders(Long memberId, Pageable pageable) {
        return queryRepository.findOrdersByMemberId(memberId, pageable);
    }
}
💬 아키텍처 오버엔지니어링 주의

과제에서의 아키텍처 선택:

과제 규모권장 아키텍처
단순 CRUD계층형 (Controller-Service-Repository)
복잡한 도메인계층형 + DDD 요소 (도메인 서비스, 값 객체)
읽기/쓰기 분리 필요CQRS 부분 적용

주의:

  • 과제는 보통 1~2주 내 완성해야 함
  • 과도한 추상화는 오히려 감점 요인
  • README에 아키텍처 선택 이유를 명시하면 좋음

Hexagonal을 적용하면 좋은 경우:

  • 외부 시스템 연동이 많은 과제
  • 테스트 용이성이 강조된 과제
  • 명시적으로 클린 아키텍처를 요구하는 경우

멀티 모듈 프로젝트

1. 멀티 모듈이란?

하나의 프로젝트를 여러 모듈로 분리하여 관심사를 분리하고 의존성을 명확히 하는 구조다.

marketplace/
├── build.gradle (root)
├── settings.gradle
├── marketplace-api/           # API 모듈 (Controller, 실행)
├── marketplace-domain/        # 도메인 모듈 (Entity, Service)
├── marketplace-infra/         # 인프라 모듈 (Repository, 외부 연동)
└── marketplace-common/        # 공통 모듈 (Utils, Exception)

2. 멀티 모듈 구조 옵션

멀티 모듈 설계에는 두 가지 접근 방식이 있다.

옵션특징Service 위치Repository 처리
Option A (정석)DIP 엄격 적용domain 모듈인터페이스/구현 분리
Option B (간소화)실용적 접근api 모듈JpaRepository 직접 사용
💬 어떤 옵션을 선택할까?

Option A 선택 시점:

  • 클린 아키텍처 요구가 명시된 경우
  • 외부 연동(결제, 알림 등)이 많아 테스트 격리가 중요한 경우
  • 도메인 로직을 인프라 기술과 완전히 분리하고 싶은 경우

Option B 선택 시점:

  • 실용적이고 간단한 구조를 원하는 경우
  • JPA/QueryDSL을 도메인 계층에서 직접 활용하고 싶은 경우
  • Repository 래핑 레이어가 단순 위임만 하는 경우

대부분의 과제에서는 Option B 로도 충분하며, 오버엔지니어링을 피할 수 있다.

settings.gradle

rootProject.name = 'marketplace'

include 'marketplace-api'
include 'marketplace-domain'
include 'marketplace-infra'
include 'marketplace-common'
Kotlin DSL (settings.gradle.kts)
rootProject.name = "marketplace"

include("marketplace-api")
include("marketplace-domain")
include("marketplace-infra")
include("marketplace-common")

Root build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.0'
    id 'io.spring.dependency-management' version '1.1.4'
}

allprojects {
    group = 'com.example'
    version = '1.0.0'

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'io.spring.dependency-management'

    java {
        sourceCompatibility = JavaVersion.VERSION_17
    }

    dependencies {
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
    }

    dependencyManagement {
        imports {
            mavenBom "org.springframework.boot:spring-boot-dependencies:3.2.0"
        }
    }

    test {
        useJUnitPlatform()
    }
}

// Root 프로젝트는 빌드하지 않음
bootJar.enabled = false
jar.enabled = false
Kotlin DSL (build.gradle.kts)
plugins {
    java
    id("org.springframework.boot") version "3.2.0"
    id("io.spring.dependency-management") version "1.1.4"
}

allprojects {
    group = "com.example"
    version = "1.0.0"

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply(plugin = "java")
    apply(plugin = "io.spring.dependency-management")

    configure<JavaPluginExtension> {
        sourceCompatibility = JavaVersion.VERSION_17
    }

    the<io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension>().apply {
        imports {
            mavenBom("org.springframework.boot:spring-boot-dependencies:3.2.0")
        }
    }

    dependencies {
        "compileOnly"("org.projectlombok:lombok")
        "annotationProcessor"("org.projectlombok:lombok")
        "testImplementation"("org.springframework.boot:spring-boot-starter-test")
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }
}

tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
    enabled = false
}

tasks.named<Jar>("jar") {
    enabled = false
}

3. 각 모듈 설정

marketplace-common (공통 모듈)

// marketplace-common/build.gradle
dependencies {
    // 공통 유틸리티만 포함
}
marketplace-common/
└── src/main/java/com/example/common/
    ├── exception/
    │   ├── BusinessException.java
    │   ├── ErrorCode.java
    │   └── ErrorResponse.java
    └── util/
        └── DateUtils.java

marketplace-domain (도메인 모듈)

// marketplace-domain/build.gradle
dependencies {
    implementation project(':marketplace-common')

    // JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // Validation
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}
Option A (정석) - Entity, Service, Repository 인터페이스
marketplace-domain/
└── src/main/java/com/example/domain/
    ├── member/
    │   ├── Member.java
    │   ├── MemberRepository.java (인터페이스)
    │   └── MemberService.java
    ├── product/
    │   ├── Product.java
    │   ├── ProductRepository.java (인터페이스)
    │   └── ProductService.java
    └── order/
        ├── Order.java
        ├── OrderRepository.java (인터페이스)
        └── OrderService.java
Option B (간소화) - Entity만 포함
marketplace-domain/
└── src/main/java/com/example/domain/
    ├── common/
    │   └── BaseEntity.java
    ├── member/
    │   ├── Member.java
    │   └── Role.java
    ├── product/
    │   ├── Product.java
    │   ├── ProductImage.java
    │   └── ProductStatus.java
    ├── order/
    │   ├── Order.java
    │   ├── OrderItem.java
    │   └── OrderStatus.java
    └── category/
        └── Category.java

Service는 api 모듈에 위치하고, Repository는 infra 모듈의 JpaRepository를 직접 사용한다.

marketplace-infra (인프라 모듈)

// marketplace-infra/build.gradle
dependencies {
    implementation project(':marketplace-common')
    implementation project(':marketplace-domain')

    // JPA 구현체
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'com.mysql:mysql-connector-j'

    // QueryDSL (선택)
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'

    // Redis (선택)
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
Option A (정석) - Repository 구현체
marketplace-infra/
└── src/main/java/com/example/infra/
    ├── persistence/
    │   ├── member/
    │   │   ├── MemberJpaRepository.java
    │   │   └── MemberRepositoryImpl.java
    │   ├── product/
    │   │   └── ProductRepositoryImpl.java
    │   └── order/
    │       └── OrderRepositoryImpl.java
    ├── cache/
    │   └── RedisCacheConfig.java
    └── external/
        └── PaymentGatewayClient.java
Option B (간소화) - JpaRepository + QueryDSL 직접 사용
marketplace-infra/
└── src/main/java/com/example/infra/
    ├── member/
    │   └── MemberJpaRepository.java
    ├── product/
    │   ├── ProductJpaRepository.java
    │   ├── ProductJpaRepositoryCustom.java
    │   └── ProductJpaRepositoryImpl.java (QueryDSL)
    ├── order/
    │   ├── OrderJpaRepository.java
    │   ├── OrderJpaRepositoryCustom.java
    │   └── OrderJpaRepositoryImpl.java (QueryDSL)
    └── category/
        └── CategoryJpaRepository.java

QueryDSL Custom Repository 패턴을 사용하면 복잡한 동적 쿼리도 JpaRepository 인터페이스에 통합할 수 있다.

marketplace-api (API 모듈)

// marketplace-api/build.gradle
plugins {
    id 'org.springframework.boot'
}

dependencies {
    implementation project(':marketplace-common')
    implementation project(':marketplace-domain')
    implementation project(':marketplace-infra')

    // Web
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

    // Swagger
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
}

bootJar {
    enabled = true
    archiveFileName = 'marketplace-api.jar'
}
Option A (정석) - Controller, Security만
marketplace-api/
└── src/main/java/com/example/api/
    ├── MarketplaceApplication.java
    ├── config/
    │   ├── SecurityConfig.java
    │   └── SwaggerConfig.java
    ├── controller/
    │   ├── MemberController.java
    │   ├── ProductController.java
    │   └── OrderController.java
    ├── dto/
    │   ├── request/
    │   └── response/
    └── security/
        ├── JwtTokenProvider.java
        └── JwtAuthenticationFilter.java
Option B (간소화) - Controller, Service, Security 포함
marketplace-api/
└── src/main/java/com/example/api/
    ├── MarketplaceApplication.java
    ├── config/
    │   ├── SecurityConfig.java
    │   ├── SwaggerConfig.java
    │   └── DataInitializer.java
    ├── member/
    │   ├── MembersController.java
    │   ├── AuthController.java
    │   ├── AuthService.java
    │   ├── MemberService.java
    │   └── dto/
    ├── product/
    │   ├── ProductController.java
    │   ├── ProductService.java
    │   └── dto/
    ├── order/
    │   ├── OrderController.java
    │   ├── OrderService.java
    │   ├── dto/
    │   └── event/
    ├── category/
    │   ├── CategoryController.java
    │   └── CategoryService.java
    └── security/
        ├── JwtTokenProvider.java
        └── JwtAuthenticationFilter.java

Service가 api 모듈에 있으므로 도메인별 패키지로 구성하여 응집도를 높인다.

4. 모듈 간 의존성 규칙

Option A (정석) - 의존성 역전 적용

┌─────────────────┐
│  marketplace-api │  ← Controller, Security
├─────────────────┤
│     depends on   │
├─────────────────┤
│ marketplace-domain │  ← Entity, Service, Repository 인터페이스
├─────────────────┤
│ (does not depend) │  ← domain은 infra를 의존하지 않음!
├─────────────────┤
│ marketplace-infra │  ← Repository 구현체 (domain 인터페이스 구현)
├─────────────────┤
│     depends on   │
├─────────────────┤
│ marketplace-common │  ← 공통 유틸, 예외
└─────────────────┘

핵심: domain → infra 의존 금지, Repository 인터페이스/구현 분리

Option B (간소화) - 실용적 접근

┌─────────────────┐
│  marketplace-api │  ← Controller, Service, Security
├─────────────────┤
│     depends on   │
├─────────────────┤
│ marketplace-domain │  ← Entity만
│ marketplace-infra │  ← JpaRepository, QueryDSL
├─────────────────┤
│     depends on   │
├─────────────────┤
│ marketplace-common │  ← 공통 유틸, 예외
└─────────────────┘

핵심: api가 domain과 infra를 모두 조합하여 사용. domain은 순수 Entity만 포함

5. Repository 구현 패턴

Option A: 인터페이스/구현 분리 (DIP)

// marketplace-domain/src/.../ProductRepository.java (인터페이스)
public interface ProductRepository {
    Product save(Product product);
    Optional<Product> findById(Long id);
    List<Product> findByCategory(Category category);
    Page<Product> search(ProductSearchCondition condition, Pageable pageable);
}
// marketplace-infra/src/.../ProductRepositoryImpl.java (구현체)
@Repository
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepository {

    private final ProductJpaRepository jpaRepository;
    private final ProductQueryRepository queryRepository;

    @Override
    public Product save(Product product) {
        return jpaRepository.save(product);
    }

    @Override
    public Optional<Product> findById(Long id) {
        return jpaRepository.findById(id);
    }

    @Override
    public List<Product> findByCategory(Category category) {
        return jpaRepository.findByCategory(category);
    }

    @Override
    public Page<Product> search(ProductSearchCondition condition, Pageable pageable) {
        return queryRepository.search(condition, pageable);
    }
}

// JPA Repository (infra 내부에서만 사용)
interface ProductJpaRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategory(Category category);
}

Option B: QueryDSL Custom Repository 패턴

JpaRepository에 QueryDSL을 통합하는 Spring Data 표준 패턴이다.

// marketplace-infra/src/.../ProductJpaRepository.kt
interface ProductJpaRepository : JpaRepository<Product, Long>, ProductJpaRepositoryCustom {
    fun findBySellerId(sellerId: Long, pageable: Pageable): Page<Product>
    fun findByStatusOrderBySalesCountDesc(status: ProductStatus, pageable: Pageable): List<Product>
}
// marketplace-infra/src/.../ProductJpaRepositoryCustom.kt
interface ProductJpaRepositoryCustom {
    fun findByIdWithLock(id: Long): Optional<Product>
    fun search(
        keyword: String?,
        categoryId: Long?,
        minPrice: BigDecimal?,
        maxPrice: BigDecimal?,
        status: ProductStatus?,
        sellerId: Long?,
        pageable: Pageable
    ): Page<Product>
}
// marketplace-infra/src/.../ProductJpaRepositoryImpl.kt
class ProductJpaRepositoryImpl(
    private val queryFactory: JPAQueryFactory
) : ProductJpaRepositoryCustom {

    private val product = QProduct.product

    override fun findByIdWithLock(id: Long): Optional<Product> {
        val result = queryFactory
            .selectFrom(product)
            .where(product.id.eq(id))
            .setLockMode(LockModeType.PESSIMISTIC_WRITE)
            .fetchOne()
        return Optional.ofNullable(result)
    }

    override fun search(
        keyword: String?,
        categoryId: Long?,
        minPrice: BigDecimal?,
        maxPrice: BigDecimal?,
        status: ProductStatus?,
        sellerId: Long?,
        pageable: Pageable
    ): Page<Product> {
        val content = queryFactory
            .selectFrom(product)
            .where(
                keywordContains(keyword),
                categoryIdEq(categoryId),
                priceGoe(minPrice),
                priceLoe(maxPrice),
                statusEq(status),
                sellerIdEq(sellerId),
                notDeleted()
            )
            .offset(pageable.offset)
            .limit(pageable.pageSize.toLong())
            .orderBy(product.createdAt.desc())
            .fetch()

        val countQuery = queryFactory
            .select(product.count())
            .from(product)
            .where(/* 동일 조건 */)

        return PageableExecutionUtils.getPage(content, pageable) {
            countQuery.fetchOne() ?: 0L
        }
    }

    private fun keywordContains(keyword: String?) =
        keyword?.takeIf { it.isNotBlank() }?.let {
            product.name.containsIgnoreCase(it)
                .or(product.description.containsIgnoreCase(it))
        }

    // ... 기타 조건 메서드
}
// Service에서 직접 JpaRepository 사용
@Service
class ProductService(
    private val productJpaRepository: ProductJpaRepository,  // 직접 주입
    private val memberJpaRepository: MemberJpaRepository,
    private val categoryJpaRepository: CategoryJpaRepository
) {
    fun searchProducts(req: ProductSearchRequest, pageable: Pageable): Page<ProductResponse> {
        return productJpaRepository.search(
            keyword = req.keyword,
            categoryId = req.categoryId,
            minPrice = req.minPrice,
            maxPrice = req.maxPrice,
            status = req.status?.let { ProductStatus.valueOf(it) },
            sellerId = req.sellerId,
            pageable = pageable
        ).map { ProductResponse.from(it) }
    }
}
💬 Option A vs Option B 비교
기준Option A (DIP)Option B (QueryDSL Custom)
추상화 수준높음 (완전 분리)중간 (JPA 의존)
코드량많음 (래퍼 필요)적음
테스트 용이성Mock 교체 쉬움Spring Data 테스트 활용
유연성DB 교체 용이JPA 생태계에 최적화
러닝커브높음낮음

권장: 대부분의 과제에서는 Option B 가 적합. Option A는 외부 연동이 많거나 클린 아키텍처가 명시적으로 요구될 때 선택.

6. 빌드 및 실행

# 전체 빌드
./gradlew build

# 특정 모듈만 빌드
./gradlew :marketplace-api:build

# 실행
./gradlew :marketplace-api:bootRun

# JAR 생성
./gradlew :marketplace-api:bootJar
# → marketplace-api/build/libs/marketplace-api.jar

7. Docker 설정 (멀티 모듈)

# Dockerfile
FROM gradle:8.5-jdk17 AS builder

WORKDIR /app

# Gradle 파일 먼저 복사 (캐싱)
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY marketplace-common/build.gradle ./marketplace-common/
COPY marketplace-domain/build.gradle ./marketplace-domain/
COPY marketplace-infra/build.gradle ./marketplace-infra/
COPY marketplace-api/build.gradle ./marketplace-api/

RUN gradle dependencies --no-daemon || true

# 소스 복사 및 빌드
COPY . .
RUN gradle :marketplace-api:bootJar --no-daemon -x test

# Runtime
FROM eclipse-temurin:17-jre-alpine

WORKDIR /app
COPY --from=builder /app/marketplace-api/build/libs/marketplace-api.jar app.jar

RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
💬 싱글 모듈 vs 멀티 모듈
구분싱글 모듈멀티 모듈
복잡도단순초기 설정 복잡
빌드 시간빠름모듈별 캐싱으로 최적화 가능
의존성 관리암묵적명시적, 강제
테스트전체 테스트모듈별 독립 테스트
확장성제한적모듈 추가 용이
팀 협업충돌 가능성모듈별 분업 용이

과제에서의 선택 기준:

상황권장
단순 CRUD, 기한 짧음싱글 모듈
도메인 복잡, 외부 연동 多멀티 모듈
클린 아키텍처 요구멀티 모듈
멀티 모듈 명시적 요구멀티 모듈
💡 멀티 모듈 설계 팁

1. 순환 의존성 방지

// 잘못된 예: A → B → A
marketplace-domain → marketplace-infra (X)
marketplace-infra → marketplace-domain (O)

2. 공통 모듈 비대화 방지

  • common 모듈에 모든 것을 넣지 말 것
  • 정말 공통으로 쓰이는 것만 포함
  • 특정 도메인 로직은 해당 모듈에

3. 모듈 책임 명확화

  • api: HTTP 요청 처리, DTO 변환, 보안
  • domain: 비즈니스 로직, 도메인 규칙
  • infra: 기술 구현 (DB, 캐시, 외부 API)
  • common: 유틸리티, 공통 예외

4. 설정 파일 위치

  • application.yml: api 모듈에 위치
  • 모듈별 설정이 필요하면 @ConfigurationProperties로 분리
⚠️ 멀티 모듈 흔한 실수
  1. 의존성 방향 위반

    • domain이 infra를 의존하면 의미 없음
    • Repository 인터페이스/구현 분리 필수
  2. Entity 위치 오류

    • Entity는 domain 모듈에 위치
    • @Entity 스캔을 위해 api에서 패키지 설정 필요
  3. Component 스캔 누락

    @SpringBootApplication(scanBasePackages = "com.example")
    public class MarketplaceApplication { }
  4. 테스트 설정 누락

    • 각 모듈의 테스트는 해당 모듈 내에서 실행
    • 통합 테스트는 api 모듈에서 실행
  5. 빌드 순서 문제

    • 의존하는 모듈이 먼저 빌드되어야 함
    • Gradle이 자동 처리하지만 순환 의존 시 실패

정리

체크리스트

항목확인
부가 기능(알림, 로깅)이 핵심 로직과 분리되어 있는가?
비동기 처리가 필요한 곳에 @Async가 적용되어 있는가?
파일 업로드 시 검증(크기, 확장자)이 적용되어 있는가?
API 버전 전략이 일관되게 적용되어 있는가?
아키텍처가 과제 복잡도에 맞게 선택되었는가?
멀티 모듈 적용 시 의존성 방향이 올바른가?
멀티 모듈 적용 시 선택한 옵션(DIP vs 간소화)이 일관되게 적용되었는가?

핵심 포인트

  1. 이벤트: 부가 기능 분리, @TransactionalEventListener로 트랜잭션 제어
  2. 비동기: 응답에 불필요한 작업 분리, 스레드풀 설정 필수
  3. 파일 처리: 검증 필수, 저장 경로 보안 고려
  4. API 버저닝: URI 방식이 가장 명확
  5. 아키텍처: 과제 규모에 맞게 선택, 오버엔지니어링 주의
  6. 멀티 모듈: 의존성 역전 원칙, domain → infra 의존 금지
💡 과제 Plus Alpha 팁
  1. 이벤트 활용

    • 주문 완료 → 알림 발송을 이벤트로 분리
    • @TransactionalEventListener(AFTER_COMMIT) 사용
  2. 비동기 처리

    • 이메일/SMS 발송을 @Async로 처리
    • 스레드풀 설정 포함
  3. 인터페이스 추상화

    • 외부 연동(결제, 알림)을 인터페이스로 추상화
    • 테스트 시 Mock 구현체 사용
  4. 멀티 모듈 적용

    • api / domain / infra / common 분리
    • 의존성 역전으로 테스트 용이성 확보
    • README에 모듈 구조 다이어그램 포함
  5. README에 설계 의도 명시

    • 왜 이 아키텍처를 선택했는지
    • 어떤 트레이드오프를 고려했는지
⚠️ 과제에서 흔한 실수
  1. 이벤트 남용

    • 모든 로직을 이벤트로 처리 → 흐름 파악 어려움
    • 핵심 로직은 직접 호출이 명확
  2. 비동기 예외 무시

    • void 반환 + 예외 미처리 → 에러 확인 불가
    • AsyncUncaughtExceptionHandler 필수
  3. 파일 검증 누락

    • 확장자, 크기 검증 없이 저장 → 보안 취약점
    • 악성 파일 업로드 방지 필요
  4. 과도한 아키텍처

    • 간단한 CRUD에 Hexagonal 적용 → 복잡도만 증가
    • 과제 규모에 맞는 적절한 선택 필요
  5. 멀티 모듈 구조 일관성 부족

    • Option A 선택 시: domain이 infra를 의존하면 DIP 위반
    • Option B 선택 시: Service를 domain에 두면 infra 접근 불가
    • 두 옵션을 섞어서 사용하면 혼란 발생
    • Component 스캔 범위 설정 누락

시리즈를 마쳤습니다! 종합 과제 에서 1~7편의 내용을 모두 활용해보세요.

👉 이전: 6편 - DevOps & Deployment 👉 다음: 종합 과제

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.