
이번에 항해 3주차를 마치면서 클린 아키텍쳐 원칙을 적용하여 선착순 쿠폰 발급 기능을 구현한 과정을 공유하고자 한다.
프로젝트 구조는 레이어드 아키텍처를 기반으로 하되, 각 레이어의 책임을 명확히 분리하고 테스트 용이성을 높이기 위해 Clean Architecture의 아이디어를 접목했다.
최종적으로 아래와 같은 계층을 갖는 구조로 설계했다.
각 계층은 애플리케이션의 한 부분을 담당하며, 서로 명확한 경계를 갖는다. 역할을 분리함으로써 코드의 응집도(cohesion)는 높이고 결합도(coupling)는 낮추어, 변경에 유연하고 테스트하기 쉬운 구조를 얻는다. 계층별 책임은 다음과 같다:
@RequestBody용 클래스인데 외부 세계의 데이터 표현을 내부로 들여오는 역할을 하고, Validation Annotation 등을 활용해 1차적인 유효성 검증을 수행할 수 있다.Repository 활용)하고 도메인 로직을 실행한 뒤 결과를 반환한다. 서비스 레이어에서는 도메인 객체와 외부 세계(예: DB)를 중개하지만, 구체적인 입출력 형식(JSON 등)이나 UI에 대해서는 모른다.CouponRepository와 같은 저장소 인터페이스가 도메인에 속한다.이제 선착순 쿠폰 발급 기능을 예시로 각 레이어가 어떻게 협력하는지 단계별로 알아보자. 이 기능은 "특정 쿠폰 이벤트에 대해 선착순으로 제한된 수량의 쿠폰을 사용자에게 발급한다"는 시나리오다.
사용자가 한정 수량 쿠폰 발급 API를 호출하면, Controller가 요청을 수신한다.
이 프로젝트에서는 API 명세(@Operation, @PostMapping)는 CouponAPI라는 인터페이스에 정의되어 있고, 실제 로직은 이를 구현한 CouponController에서 처리한다.
@Tag(name = "Coupon", description = "쿠폰 발급 및 조회 API")
@RequestMapping("/api/v1/coupons")
public interface CouponAPI {
@Operation(
summary = "한정 수량 쿠폰 발급",
description = """
한정 수량 쿠폰을 사용자가 발급받는 API입니다.
- 한정 수량 초과 시 `422 UNPROCESSABLE_ENTITY` 반환
- 이미 발급받은 사용자는 `409 CONFLICT` 반환
- 쿠폰이 만료되었거나 존재하지 않으면 `404 NOT_FOUND` 반환
""",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
required = true,
description = "발급받을 쿠폰 정보",
content = @Content(schema = @Schema(implementation = CouponResponse.class))
),
responses = {
@ApiResponse(responseCode = "200", description = "쿠폰 발급 성공",
content = @Content(schema = @Schema(implementation = CouponResponse.class))),
@ApiResponse(responseCode = "404", description = "쿠폰이 존재하지 않거나 만료됨"),
@ApiResponse(responseCode = "409", description = "이미 발급받은 쿠폰"),
@ApiResponse(responseCode = "422", description = "발급 가능한 수량 초과")
}
)
@PostMapping("/limited-issue")
ResponseEntity<CustomApiResponse<CouponResponse>> limitedIssueCoupon(
@Valid @RequestBody CouponRequest request
);
}
위와 같이 명세는 인터페이스에서 정의되며, 실제 처리는 다음과 같이 CouponController에서 수행된다:
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/coupons")
public class CouponController implements CouponAPI {
private final CouponUseCase couponUseCase;
@Override
public ResponseEntity<CustomApiResponse<CouponResponse>> limitedIssueCoupon(
@Valid @RequestBody CouponRequest request
) {
// 1) Request DTO → Command 변환
IssueLimitedCouponCommand command = request.toCommand();
// 2) UseCase 실행 (Service 호출)
CouponResult result = couponUseCase.issueLimitedCoupon(command);
// 3) 결과 → Response DTO 변환 → 공통 응답 포맷으로 래핑
return ResponseEntity.ok(CustomApiResponse.success(
new CouponResponse(
result.userCouponId(),
result.userId(),
result.couponType(),
result.discountRate(),
result.issuedAt(),
result.expiryDate()
)
));
}
}
CouponRequest (요청 DTO)
클라이언트에서 전달된 JSON 요청은 CouponRequest 객체로 매핑된다. @Valid를 통해 기본적인 검증도 함께 수행된다.
Command 객체 변환
CouponRequest는 toCommand() 메서드를 통해 IssueLimitedCouponCommand로 변환된다. 이 객체는 애플리케이션 계층에서 사용하는 유즈케이스 입력 모델로, 불필요한 외부 의존을 제거한 순수 데이터 캡슐화 객체이다.
java
CopyEdit
public record IssueLimitedCouponCommand(Long userId, String couponCode) { }
UseCase 실행
CouponUseCase는 issueLimitedCoupon() 메서드를 통해 도메인 계층을 호출하고 핵심 로직을 수행한다.
이 과정에서 쿠폰 존재 여부, 재고, 중복 발급 여부, 만료 여부 등을 도메인 계층에서 검증하게 된다.
결과 변환 및 응답
UseCase에서 반환된 결과는 CouponResult라는 응용 계층 DTO로 매핑되며, 다시 CouponResponse로 가공된다. 마지막으로 CustomApiResponse.success()로 감싸져 통일된 API 응답 형태로 반환된다.
이 구조의 장점은 다음과 같다:
이번 구조에서는 Facade를 따로 두지 않고, CouponController가 직접 CouponUseCase를 호출한다.
CouponUseCase는 실제 비즈니스 흐름을 처리하는 응용 계층의 유즈케이스 인터페이스이고, 그 구현체인 CouponService가 주요 로직을 담당한다.
@Service
@RequiredArgsConstructor
public class CouponService implements CouponUseCase {
private final CouponReader couponReader;
private final CouponIssueWriter couponIssueWriter;
private final CouponIssueReader couponIssueReader;
@Override
public CouponResult issueLimitedCoupon(IssueLimitedCouponCommand command) {
// 1) 쿠폰 조회
Coupon coupon = couponReader.findByCode(command.couponCode());
// 2) 쿠폰 유효성 확인 (만료, 재고 등)
coupon.validateUsable();
// 3) 중복 발급 여부 확인
if (couponIssueWriter.hasIssued(command.userId(), coupon.getId())) {
throw new CouponException.AlreadyIssuedException(command.userId(), command.couponCode());
}
// 4) 쿠폰 발급 이력 저장
CouponIssue issue = couponIssueWriter.save(command.userId(), coupon);
// 5) 응답용 Result DTO 반환
return CouponResult.from(issue);
}
}
CouponReader: 쿠폰 조회 전용 포트CouponIssueWriter: 발급 처리 및 중복 체크CouponIssueReader: 발급 여부 확인 (apply 시 사용)Coupon 엔티티에서 책임지고, 서비스는 흐름만 조율CouponIssue를 CouponResult 응용 DTO로 변환하여 반환현재는 유즈케이스 하나만 단순하게 실행하면 되므로 추가 조율(Facade)이 필요 없는 구조다.
알림, 포인트 차감, 이벤트 발행 등과 같은 부가 처리가 없는 경우,
불필요하게 Facade 레이어를 도입하지 않고 간단하고 명확한 흐름을 유지하는 것이 더 낫다.
도메인 계층은 시스템의 핵심 비즈니스 규칙과 상태를 표현하는 영역이다.
이번 구조에서는 Coupon 엔티티가 선착순 발급 방식의 핵심 개념과 제약을 담고 있으며, 모든 비즈니스 유효성 검사와 상태 변화는 이 객체 내부에서 수행된다.
@Entity
@Table(name = "coupon")
@Getter
@NoArgsConstructor
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String code;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private CouponType type;
@Column(nullable = false)
private Integer discountRate;
@Column(nullable = false)
private Integer totalQuantity;
@Column(nullable = false)
private Integer remainingQuantity;
@Column(nullable = false)
private LocalDateTime validFrom;
@Column(nullable = false)
private LocalDateTime validUntil;
// 생성자 및 팩토리 메서드 생략...
/**
* 쿠폰 사용 가능 여부 검증 (만료 or 재고 소진 여부)
*/
public void validateUsable() {
if (isExpired()) {
throw new CouponException.ExpiredException();
}
if (isExhausted()) {
throw new CouponException.AlreadyExhaustedException();
}
}
/**
* 재고 차감 로직
*/
public void decreaseQuantity() {
validateUsable(); // 보호 로직 내장
this.remainingQuantity -= 1;
}
/**
* 할인 금액 계산 (정액/정률)
*/
public Money calculateDiscount(Money orderAmount) {
return switch (this.type) {
case FIXED -> Money.wons(this.discountRate);
case PERCENTAGE -> orderAmount.multiplyPercent(this.discountRate);
};
}
private boolean isExpired() {
return LocalDateTime.now().isAfter(this.validUntil);
}
private boolean isExhausted() {
return this.remainingQuantity <= 0;
}
}
validateUsable() 메서드를 통해 쿠폰의 만료 여부와 발급 가능 수량을 확인한다. 유효하지 않은 경우에는 도메인 전용 예외를 발생시켜 잘못된 사용을 방지한다.decreaseQuantity()는 정상적으로 발급이 가능할 때, 남은 발급 수량을 1 줄이는 역할을 한다. 단순한 수학 연산이 아니라, 쿠폰 발급의 핵심 상태 변경을 도메인 객체가 스스로 책임지고 수행하는 구조다.FIXED : 정액 할인PERCENTAGE : 주문 금액의 일정 비율 할인이전 구조에서는 issuedUserIds 같은 메모리 내 Set으로 중복 여부를 관리했지만, 지금은 DB 조회 방식(couponIssueWriter.hasIssued(...))을 통해 처리하고 있다.
또한 도메인 예외는 흩어진 개별 클래스가 아닌, CouponException이라는 공통 예외 클래스의 정적 내부 클래스 형태로 통일되었다.
public class CouponException extends RuntimeException {
public static class AlreadyIssuedException extends CouponException {
public AlreadyIssuedException(Long userId, String couponCode) {
super("이미 발급된 쿠폰입니다: userId=" + userId + ", couponCode=" + couponCode);
}
}
public static class AlreadyExhaustedException extends CouponException {
public AlreadyExhaustedException() {
super("쿠폰 재고가 모두 소진되었습니다.");
}
}
public static class ExpiredException extends CouponException {
public ExpiredException() {
super("쿠폰이 만료되었습니다.");
}
}
public CouponException(String message) {
super(message);
}
}
validateUsable(), decreaseQuantity()는 내부 로직 외부 노출 없이 캡슐화Service에서 처리된 결과는 컨트롤러로 반환되며,
컨트롤러는 이를 클라이언트에 전달할 응답 DTO(Response)로 변환한다.
이번 구조에서는 도메인 계층의 처리 결과를 CouponIssue 엔티티로 받은 뒤,
이를 CouponResult라는 응용 계층의 DTO로 변환하고,
마지막으로 CouponResponse라는 프레젠테이션 계층의 DTO로 가공해 API 응답을 생성한다.
Domain(CouponIssue)
→ CouponResult (Application DTO)
→ CouponResponse (Response DTO)
이처럼 입력과 출력 모두에서 별도의 DTO를 사용하면,
API 스펙이 변경되더라도 내부 도메인 로직에는 영향을 거의 주지 않기 때문에
유지보수성과 확장성 면에서 매우 유리하다.
Controller
→ Request DTO (CouponRequest)
→ Command (IssueLimitedCouponCommand)
→ UseCase (CouponService)
→ Domain (Coupon, CouponIssue)
→ Result DTO (CouponResult)
→ Response DTO (CouponResponse)
→ HTTP 응답 (CustomApiResponse)
| 구성요소 | 책임 |
|---|---|
| Controller + Request/Response DTO | 외부와의 인터페이스, 입력/출력 명세 관리 |
| Command + UseCase | 유즈케이스 실행 흐름 제어 |
| Domain (Coupon) | 비즈니스 규칙, 상태 변경 로직 캡슐화 |
| Result DTO | 도메인 결과를 애플리케이션 계층에서 응답용으로 가공 |
| Response DTO | 클라이언트에 전달할 명확한 데이터 구조 표현 |
API의 입력/출력과 내부 도메인 모델을 명확히 분리함으로써,
도메인은 외부 변화로부터 보호되고, 컨트롤러는 오직 입출력 처리에만 집중할 수 있다.
이러한 설계 방식은 변화에 유연하고, 계층 간 책임이 명확하게 구분된 클린 아키텍처 구조를 완성시켜 준다.
이번 프로젝트에서는 기능별 모듈(Context)을 중심으로 패키지를 나누고,
각 모듈 내부를 계층(Application / Domain / Interfaces / Infrastructure) 기준으로 구분하는 방식을 사용했다.
예를 들어, 쿠폰 기능의 전체 패키지 구조는 다음과 같다:
kr.hhplus.be.server
├── application
│ └── coupon # [Application Layer - UseCase, Command, Result]
│ ├── ApplyCouponCommand.java
│ ├── ApplyCouponResult.java
│ ├── CouponResult.java
│ ├── CouponService.java
│ ├── CouponUseCase.java
│ └── IssueLimitedCouponCommand.java
│
├── domain
│ └── coupon # [Domain Layer - Entity, Repository Interface, Exception]
│ ├── Coupon.java
│ ├── CouponException.java
│ ├── CouponIssue.java
│ ├── CouponIssueReader.java
│ ├── CouponIssueWriter.java
│ ├── CouponReader.java
│ └── CouponType.java
│
├── infrastructure
│ └── coupon # [Infrastructure Layer - Repository 구현체]
│ ├── CouponIssueReaderImpl.java
│ ├── CouponIssueWriterImpl.java
│ └── CouponReaderImpl.java
│
└── interfaces
└── coupon # [Interface Layer - Controller, DTO]
├── CouponAPI.java
├── CouponController.java
├── CouponRequest.java
└── CouponResponse.java
coupon, order, payment, product 등 도메인 기능 단위로 폴더를 나누고, 각 폴더 안에서 계층별로 구조화한다.application: 유즈케이스 구현, Command/Result DTOdomain: 엔티티, 도메인 서비스, 예외, Repository 인터페이스interfaces: API 엔드포인트, Request/Response DTOinfrastructure: 실제 DB 연동 구현체 (JPA 등)application은 domain에만 의존하고, interfaces, infrastructure는 application 또는 domain에 의존하지만, 그 반대는 절대 없다. → 계층 간 의존성은 항상 안쪽에서 바깥쪽으로 향하지 않는다.이 구조의 장점은 다음과 같다:
계층을 명확히 분리한 클린 아키텍처의 또 다른 큰 장점은 테스트 작성이 매우 쉬워진다는 점이다.
각 레이어는 자기 책임만을 갖도록 설계되어 있기 때문에, 작은 단위로 테스트를 격리하고 집중해서 작성할 수 있다.
이번 쿠폰 발급 기능 구현에서는 도메인부터 서비스 레이어까지 다음과 같은 전략으로 테스트를 진행했다.
도메인 계층에서는 Coupon 엔티티가 갖고 있는 비즈니스 로직 중심의 메서드들을 단독으로 테스트했다.
예를 들어, 다음과 같은 사항들을 검증했다:
CouponException.ExpiredException을 던지는지CouponException.AlreadyExhaustedException을 던지는지calculateDiscount()가 정해진 할인 정책에 따라 정확히 계산되는지이러한 테스트는 스프링 컨텍스트나 DB 없이 순수 자바 객체 수준에서 빠르게 실행되며,
비즈니스 규칙이 제대로 동작하는지를 단순하고 명확하게 검증할 수 있었다.
서비스 레이어에서는 외부 의존성을 가진 CouponReader, CouponIssueWriter, CouponIssueReader 등을 Mockito로 Mock 처리하여 테스트했다.
이렇게 하면 DB 없이도 각 포트의 동작을 자유롭게 시뮬레이션하고, 로직만을 격리하여 검증할 수 있다.
예를 들어, 다음과 같은 테스트 케이스를 작성했다:
@ExtendWith(MockitoExtension.class)
class CouponServiceTest {
@Mock
private CouponReader couponReader;
@Mock
private CouponIssueWriter couponIssueWriter;
@Mock
private CouponIssueReader couponIssueReader;
@InjectMocks
private CouponService couponService;
private final String couponCode = "TEST10";
private final long userId = 1L;
@Test
@DisplayName("쿠폰 정상 발급 성공")
void issueCoupon_success() {
// given
Coupon coupon = createValidCoupon();
CouponIssue issued = new CouponIssue(userId, coupon);
IssueLimitedCouponCommand command = new IssueLimitedCouponCommand(userId, couponCode);
given(couponReader.findByCode(couponCode)).willReturn(coupon);
given(couponIssueWriter.hasIssued(userId, coupon.getId())).willReturn(false);
given(couponIssueWriter.save(userId, coupon)).willReturn(issued);
// when
CouponResult result = couponService.issueLimitedCoupon(command);
// then
assertThat(result).isNotNull();
assertThat(result.userId()).isEqualTo(userId);
verify(couponReader).findByCode(couponCode);
verify(couponIssueWriter).save(userId, coupon);
}
이 테스트에서는 쿠폰이 정상적으로 발급되었는지,
그리고 save() 메서드가 실제로 호출되었는지를 검증한다.
이외에도 다음과 같은 다양한 상황별 테스트를 작성했다:
또한 테스트 코드 내에서 반복되는 쿠폰 생성 로직은 아래와 같은 픽스처 메서드로 공통화하여 재사용성을 높였다:
private Coupon createValidCoupon() {
return Coupon.create(
couponCode,
CouponType.PERCENTAGE,
10,
100,
LocalDateTime.now().minusDays(1),
LocalDateTime.now().plusDays(1)
);
}
이렇게 함으로써 테스트 코드가 깔끔하고 읽기 쉬워졌으며, 새로운 테스트 케이스를 추가할 때도 부담 없이 확장할 수 있었다.
이번 설계와 구현을 진행하면서 클린 아키텍처가 가지는 진정한 가치에 대해 체감할 수 있었다.
처음에는 단순히 Controller → Service → Repository 구조만으로 충분해 보였지만,
유즈케이스 중심의 구조 분리, Command/Result 객체의 도입, 응답 DTO 계층 분리를 적용하면서
각 계층의 책임이 더욱 명확해지고 유지보수가 쉬워졌다.
특히 다음과 같은 점에서 큰 만족감을 느꼈다:
이번 주 클린 아키텍처 기반의 설계와 구현을 진행하면서 다시 한 번 느낀 것은, 헥사고날 아키텍처든 클린 아키텍처든 결국에는 SOLID 원칙을 얼마나 잘 지키는가가 핵심이라는 점이었다. 요구사항은 언제든지 변할 수 있기 때문에, 변화에 유연하게 대응할 수 있는 구조를 만드는 것이 설계의 시작이자 목적이라는 걸 체감할 수 있었다.
이러한 이유로 수많은 디자인 패턴과 아키텍처가 등장했고, 이들과 함께 빠른 검증을 가능하게 하는 테스터블한 코드 구조가 결합되어 왔다. 처음에는 이러한 구조가 다소 번거롭고 생산성이 떨어지는 것처럼 느껴질 수 있지만, 시간이 지남에 따라 유지보수성과 확장성이 좋아지고 결과적으로 생산성이 더욱 향상되는 구조라는 것을 직접 경험하게 되었다.
그리고 곰곰이 생각해보면 단순히 코드 구조나 패턴을 아는 것을 넘어서, 왜 이렇게 설계해야 하는지, 그 구조가 장기적으로 어떤 이점을 주는지에 대한 고민이 훨씬 더 중요하다는 생각이 든다. 정말 공부할 것도 많고, 갈수록 더 재밌어진다