[JAVA][Spring]클린 아키텍처 기반 구조 설계: 선착순 쿠폰 발급 시스템

JUNYOUNG·2025년 4월 6일
post-thumbnail

이번에 항해 3주차를 마치면서 클린 아키텍쳐 원칙을 적용하여 선착순 쿠폰 발급 기능을 구현한 과정을 공유하고자 한다.
프로젝트 구조는 레이어드 아키텍처를 기반으로 하되, 각 레이어의 책임을 명확히 분리하고 테스트 용이성을 높이기 위해 Clean Architecture의 아이디어를 접목했다.

최종적으로 아래와 같은 계층을 갖는 구조로 설계했다.

아키텍처 계층 구조

각 계층은 애플리케이션의 한 부분을 담당하며, 서로 명확한 경계를 갖는다. 역할을 분리함으로써 코드의 응집도(cohesion)는 높이고 결합도(coupling)는 낮추어, 변경에 유연하고 테스트하기 쉬운 구조를 얻는다. 계층별 책임은 다음과 같다:

  • Request DTO (요청 DTO): 프레젠테이션 계층의 입력 모델이다. 주로 Controller에서 사용하며, 클라이언트로부터 전달받은 요청 파라미터를 담는 간단한 데이터 객체다.
    예를 들어 HTTP 요청 본문(JSON)을 매핑하는 @RequestBody용 클래스인데 외부 세계의 데이터 표현을 내부로 들여오는 역할을 하고, Validation Annotation 등을 활용해 1차적인 유효성 검증을 수행할 수 있다.
  • Command 객체: 애플리케이션 계층에서 사용하는 유즈케이스 입력 모델이다. Request DTO를 도메인에 전달하기 적합한 형태로 변환한 객체라고 볼 수 있다.
    주로 Service에 전달되며, 유즈케이스 수행에 필요한 데이터를 캡슐화한다. Command 객체에는 비즈니스 로직은 없고 순수하게 데이터와 약간의 유효성 체크 정도만 포함한다.
  • Facade (파사드): 선택적인 애플리케이션 계층으로, 복잡한 유즈케이스의 진입점을 단순화하는 역할을 한다. 여러 Service 호출이나 트랜잭션 관리가 필요할 경우 Facade에서 한꺼번에 처리한다.
    Controller와 Service 사이의 완충지대 역할을 하여, 필요에 따라 여러 서비스를 orchestration(조율)하거나 하나의 상위 유즈케이스로 묶어준다. 단순한 경우에는 생략 가능하며, 이 레이어가 없다면 Controller가 직접 Service를 호출하도록 구현한다.
  • Service (서비스): 비즈니스 로직을 담당하는 애플리케이션 계층이다. 하나의 서비스는 하나의 유즈케이스(Use Case)를 구현하며, 주로 도메인 객체를 활용하여 비즈니스 규칙을 처리한다.
    트랜잭션 경계를 정하거나, 필요한 경우 도메인 객체를 생성/조회(Repository 활용)하고 도메인 로직을 실행한 뒤 결과를 반환한다. 서비스 레이어에서는 도메인 객체와 외부 세계(예: DB)를 중개하지만, 구체적인 입출력 형식(JSON 등)이나 UI에 대해서는 모른다.
  • Domain (도메인): 핵심 비즈니스 규칙과 엔티티를 담은 계층이다. 시스템이 제공해야 하는 개념들과 그 불변 조건, 상태 변경 로직 등이 이곳에 있다. 예를 들어 Coupon 엔티티, Coupon과 관련된 도메인 서비스, 그리고 CouponRepository와 같은 저장소 인터페이스가 도메인에 속한다.
    다른 레이어에 전혀 의존하지 않으며, 순수 자바/비즈니스 코드로만 이루어진다. 클린 아키텍처의 핵심인 의존성 규칙(Dependency Rule)에 따라, 외부 계층이 도메인에 의존하고, 도메인은 어떤 것도 의존하지 않도록 구성했다.

유즈케이스 흐름: 컨트롤러부터 도메인까지

이제 선착순 쿠폰 발급 기능을 예시로 각 레이어가 어떻게 협력하는지 단계별로 알아보자. 이 기능은 "특정 쿠폰 이벤트에 대해 선착순으로 제한된 수량의 쿠폰을 사용자에게 발급한다"는 시나리오다.

1. Controller: 요청 수신, Request DTO → Command 변환 및 응답 생성

사용자가 한정 수량 쿠폰 발급 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()
            )
        ));
    }
}

흐름 요약

  1. CouponRequest (요청 DTO)

    클라이언트에서 전달된 JSON 요청은 CouponRequest 객체로 매핑된다. @Valid를 통해 기본적인 검증도 함께 수행된다.

  2. Command 객체 변환

    CouponRequesttoCommand() 메서드를 통해 IssueLimitedCouponCommand로 변환된다. 이 객체는 애플리케이션 계층에서 사용하는 유즈케이스 입력 모델로, 불필요한 외부 의존을 제거한 순수 데이터 캡슐화 객체이다.

    java
    CopyEdit
    public record IssueLimitedCouponCommand(Long userId, String couponCode) { }
    
  3. UseCase 실행

    CouponUseCaseissueLimitedCoupon() 메서드를 통해 도메인 계층을 호출하고 핵심 로직을 수행한다.

    이 과정에서 쿠폰 존재 여부, 재고, 중복 발급 여부, 만료 여부 등을 도메인 계층에서 검증하게 된다.

  4. 결과 변환 및 응답

    UseCase에서 반환된 결과는 CouponResult라는 응용 계층 DTO로 매핑되며, 다시 CouponResponse로 가공된다. 마지막으로 CustomApiResponse.success()로 감싸져 통일된 API 응답 형태로 반환된다.


이 구조의 장점은 다음과 같다:

  • 입력, 도메인, 출력 모델을 명확히 구분하여 변경에 유연함
  • 인터페이스로 API 명세 분리 → Swagger 문서화와 구현 분리
  • 단일 진입점인 UseCase를 통해 비즈니스 흐름이 명확해짐
  • 응답 래핑을 통한 일관된 API 응답 포맷 제공

2. UseCase: 유즈케이스 실행 및 도메인 호출

이번 구조에서는 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);
    }
}

흐름 요약

  • Reader/Writer 분리 구조
    • CouponReader: 쿠폰 조회 전용 포트
    • CouponIssueWriter: 발급 처리 및 중복 체크
    • CouponIssueReader: 발급 여부 확인 (apply 시 사용)
  • 도메인 규칙은 Coupon 엔티티에서 책임지고, 서비스는 흐름만 조율
  • 결과는 도메인 객체인 CouponIssueCouponResult 응용 DTO로 변환하여 반환

Facade는 왜 없는가?

현재는 유즈케이스 하나만 단순하게 실행하면 되므로 추가 조율(Facade)이 필요 없는 구조다.

알림, 포인트 차감, 이벤트 발행 등과 같은 부가 처리가 없는 경우,

불필요하게 Facade 레이어를 도입하지 않고 간단하고 명확한 흐름을 유지하는 것이 더 낫다.

3. Domain: 핵심 비즈니스 로직과 엔티티

도메인 계층은 시스템의 핵심 비즈니스 규칙과 상태를 표현하는 영역이다.

이번 구조에서는 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);
    }
}

도메인 계층의 핵심

  • 불변 조건(invariant)을 보장하는 validateUsable(), decreaseQuantity()내부 로직 외부 노출 없이 캡슐화
  • 외부 레이어(Service 등)는 도메인 객체의 상태를 직접 수정하지 않고, 메서드 호출을 통해 규칙을 위임
  • 예외 상황도 도메인 객체 내에서 직접 판단하고, 적절한 도메인 예외를 던짐

5. 응답 생성 및 반환

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

패키징 전략 설명

  • Context 기반 모듈화 coupon, order, payment, product도메인 기능 단위로 폴더를 나누고, 각 폴더 안에서 계층별로 구조화한다.
  • 계층적 구분 각 모듈 안에서는 기능을 기준으로가 아니라, 클린 아키텍처의 레이어(Application, Domain, Interfaces, Infrastructure)에 따라 파일을 나눈다.
    • application: 유즈케이스 구현, Command/Result DTO
    • domain: 엔티티, 도메인 서비스, 예외, Repository 인터페이스
    • interfaces: API 엔드포인트, Request/Response DTO
    • infrastructure: 실제 DB 연동 구현체 (JPA 등)
  • 의존성 방향 준수 applicationdomain에만 의존하고, interfaces, infrastructureapplication 또는 domain에 의존하지만, 그 반대는 절대 없다. → 계층 간 의존성은 항상 안쪽에서 바깥쪽으로 향하지 않는다.

이 구조의 장점은 다음과 같다:

  • 관심사 분리에 따라 역할이 명확해지고, 유지보수가 쉬워짐
  • 테스트 시 각 계층만 독립적으로 검증할 수 있음
  • 기능별 모듈이 잘 분리되어 있어, 팀 단위 개발/배포에 유리함
  • 추후 도메인 별 CQRS, 이벤트 발행 등도 무리 없이 확장 가능

테스트 전략 및 품질 향상

계층을 명확히 분리한 클린 아키텍처의 또 다른 큰 장점은 테스트 작성이 매우 쉬워진다는 점이다.

각 레이어는 자기 책임만을 갖도록 설계되어 있기 때문에, 작은 단위로 테스트를 격리하고 집중해서 작성할 수 있다.

이번 쿠폰 발급 기능 구현에서는 도메인부터 서비스 레이어까지 다음과 같은 전략으로 테스트를 진행했다.


도메인 단위 테스트

도메인 계층에서는 Coupon 엔티티가 갖고 있는 비즈니스 로직 중심의 메서드들을 단독으로 테스트했다.

예를 들어, 다음과 같은 사항들을 검증했다:

  • 만료된 쿠폰은 CouponException.ExpiredException을 던지는지
  • 발급 수량이 0인 경우 CouponException.AlreadyExhaustedException을 던지는지
  • calculateDiscount()가 정해진 할인 정책에 따라 정확히 계산되는지

이러한 테스트는 스프링 컨텍스트나 DB 없이 순수 자바 객체 수준에서 빠르게 실행되며,

비즈니스 규칙이 제대로 동작하는지를 단순하고 명확하게 검증할 수 있었다.


서비스 레이어 테스트 (Mock 기반)

서비스 레이어에서는 외부 의존성을 가진 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 계층 분리를 적용하면서

각 계층의 책임이 더욱 명확해지고 유지보수가 쉬워졌다.

특히 다음과 같은 점에서 큰 만족감을 느꼈다:

  • 테스트 가능한 구조 도메인과 서비스가 잘 분리되어 있어, 각 계층을 Mock 또는 Stub으로 대체하며 테스트할 수 있었고, 실제 DB 없이도 복잡한 시나리오를 단위 테스트 수준에서 모두 커버할 수 있었다.
  • 유연성과 확장성 예를 들어, 현재는 선착순 발급 방식이지만 추후 추첨 방식으로 변경되어도 Domain 계층의 로직만 수정하면 되고, Controller나 Application 계층에는 영향이 거의 없다. 새로운 검증 로직이 필요해도 도메인에 캡슐화하여 추가할 수 있고, 기존 흐름을 깨지 않고도 유즈케이스를 확장해나갈 수 있는 구조가 갖춰져 있었다.
  • SOLID 원칙과 설계 철학의 체화 단순히 “클린 아키텍처”라는 이름을 따르는 게 아니라, 내부적으로는 의존성 역전(DIP), 단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP) 등의 철학이 자연스럽게 녹아들게 되었다. 이로 인해 요구사항 변경에 유연하고, 테스트와 리팩토링이 쉬운 구조를 실제로 구현할 수 있었다.

아키텍처 설계의 본질적인 가치

이번 주 클린 아키텍처 기반의 설계와 구현을 진행하면서 다시 한 번 느낀 것은, 헥사고날 아키텍처든 클린 아키텍처든 결국에는 SOLID 원칙을 얼마나 잘 지키는가가 핵심이라는 점이었다. 요구사항은 언제든지 변할 수 있기 때문에, 변화에 유연하게 대응할 수 있는 구조를 만드는 것이 설계의 시작이자 목적이라는 걸 체감할 수 있었다.

이러한 이유로 수많은 디자인 패턴과 아키텍처가 등장했고, 이들과 함께 빠른 검증을 가능하게 하는 테스터블한 코드 구조가 결합되어 왔다. 처음에는 이러한 구조가 다소 번거롭고 생산성이 떨어지는 것처럼 느껴질 수 있지만, 시간이 지남에 따라 유지보수성과 확장성이 좋아지고 결과적으로 생산성이 더욱 향상되는 구조라는 것을 직접 경험하게 되었다.

그리고 곰곰이 생각해보면 단순히 코드 구조나 패턴을 아는 것을 넘어서, 왜 이렇게 설계해야 하는지, 그 구조가 장기적으로 어떤 이점을 주는지에 대한 고민이 훨씬 더 중요하다는 생각이 든다. 정말 공부할 것도 많고, 갈수록 더 재밌어진다

profile
Onward, Always Upward - 기록은 성장의 증거

0개의 댓글