프레임워크 없이, 고통 없이: Aether Slices 작성

발행: (2026년 2월 19일 오후 07:08 GMT+9)
18 분 소요
원문: Dev.to

I’m happy to translate the article for you, but I’ll need the actual text of the post. Could you please paste the content you’d like translated (or a portion of it) here? I’ll keep the source link, formatting, markdown, and any code blocks exactly as they appear while translating the surrounding prose into Korean.

Source:

여기 전체 배포 가능한 서비스가 있습니다

@Slice
public interface OrderService {
    Promise placeOrder(PlaceOrderRequest request);

    static OrderService orderService(InventoryService inventory,
                                     PricingEngine pricing) {
        return request -> inventory.check(request.items())
                                   .flatMap(pricing::calculate)
                                   .map(OrderResult::placed);
    }
}

그것은 단순화된 예제가 아니라 실제 구현입니다.

  • 애노테이션 프로세서는 @Slice를 보고 팩터리 메서드 시그니처를 읽어들여 다음을 생성합니다:
    • 와이어링 코드,
    • 원격 호출을 위한 프록시,
    • 배포 메타데이터.

당신은 하나의 인터페이스만 작성하면, 스케일링되고 장애 복구가 가능하며 분산 클러스터 전반에 걸쳐 투명하게 라우팅되는 서비스를 얻습니다.

No @Autowired.
No application.yml.
No @Configuration class.
No @Bean method.
No component scan.
No service locator.
No dependency‑injection container at all.

팩터리 메서드 자체가 의존성 주입입니다. 그 매개변수가 선언된 의존성이고, 컴파일러가 이를 검증하며, 애노테이션 프로세서가 와이어링을 수행합니다. 설정할 것도 없고, 잊어버릴 것도 없으며, 새벽 2시에도 디버깅할 것이 없습니다.

팩터리가 반환하는 것을 주목하세요: 람다입니다. 구현 클래스가 없습니다. 인터페이스에 메서드가 하나뿐이므로, 팩터리는 이를 직접 구현하는 람다를 반환합니다—비즈니스 로직이 함수 형태로 제공됩니다. 메서드가 여러 개인 슬라이스의 경우, 프라이빗 record가 의존성을 캡처하고 인터페이스를 구현합니다—여전히 별도의 Impl 클래스가 없고, 유지할 파일도 없으며, 추적할 간접 경로도 없습니다.

모든 슬라이스는 두 가지 규칙을 따릅니다

  1. 팩토리 메서드가 의존성을 선언한다 – 슬라이스가 외부 세계에서 필요로 하는 것이 한 곳, 즉 팩토리 메서드 시그니처에 나타납니다.

    static OrderService orderService(InventoryService inventory,
                                     PricingEngine pricing) {
        return request -> inventory.check(request.items())
                                   .flatMap(pricing::calculate)
                                   .map(OrderResult::placed);
    }

    팩토리를 읽고, 의존성을 파악한다. 설정 파일이 이를 모순할 수 없으며, 컴파일러가 보지 못한 의존성을 런타임에 도입할 수도 없습니다.

  2. Promise 반환 타입 – 모든 메서드는 Promise를 반환합니다. 이는 스타일상의 선택이 아니라 투명한 분산을 가능하게 하는 핵심입니다. 호출이 같은 프로세스 내이든 네트워크를 넘어선 것이든, 호출자는 동일한 타입을 보게 됩니다.

그게 전부—두 규칙뿐입니다. 나머지는 이 규칙들에서 자연스럽게 따라옵니다.

어노테이션 프로세서가 팩터리 파라미터를 분류하는 방법

프로세서가 보는 내용프로세서가 수행하는 작업
@PrimaryDb DatabaseConnector db리소스 – 설정에서 제공
InventoryService inventory외부 슬라이스 – 네트워크 프록시를 생성
OrderValidator validator팩터리와 로컬 인터페이스 – 팩터리를 직접 호출

이를 직접 설정하지 않습니다. 인프라 리소스를 제외하고는 의존성을 @Inject@Qualifier로 어노테이션하지 않습니다. 필요한 것을 나열하기만 하면 프로세서가 어떻게 제공할지 알아냅니다.

인프라스트럭처 리소스

@ResourceQualifier 로 주석이 달린 매개변수는 인프라스트럭처—데이터베이스 연결, HTTP 클라이언트, 메시지 큐—입니다. 프로세서는 이를 구성에서 제공합니다:

@ResourceQualifier(type = DatabaseConnector.class, config = "database.primary")
public @interface PrimaryDb {}

@Slice
public interface OrderRepository {
    Promise findOrder(FindOrderRequest request);

    static OrderRepository orderRepository(@PrimaryDb DatabaseConnector db) {
        return request -> db.query(request.orderId())
                            .map(OrderResult::fromRow);
    }
}

원격 의존성

다른 패키지의 @Slice 인터페이스인 매개변수는 원격 의존성이다. 프로세서는 런타임의 호출 구조에 위임하는 프록시 레코드를 생성한다. 코드에서 inventory.check(request)를 일반 메서드 호출처럼 사용하면, 프록시가 직렬화, 라우팅, 재시도 및 장애 조치를 처리한다.

Local dependencies

정적 팩토리 메서드를 가진 단순 인터페이스인 매개변수는 local입니다. 프로세서는 팩토리를 직접 호출합니다—프록시도 없고, 네트워크도 없으며, 오버헤드도 없습니다.

세 가지 카테고리를 모두 포함

static LoanService loanService(@PrimaryDb DatabaseConnector db,
                               CreditBureau creditBureau,
                               RiskCalculator riskCalculator) {
    return request -> riskCalculator.assess(request)
                                    .flatMap(risk -> creditBureau.check(request.applicant()))
                                    .flatMap(credit -> persistDecision(db, request, credit));
}
  • Database – 설정에서 가져옵니다.
  • Credit bureau – 네트워크 프록시를 통해.
  • Risk calculator – 로컬에서 인스턴스화됩니다.

한 줄로 모두 선언합니다. 프로세서가 나머지를 처리합니다.

Source:

구조화된 오류 처리

프레임워크는 예외를 던지도록 훈련시킵니다. Spring은 이를 HTTP 상태 코드로 변환하고, Jackson은 오류 응답을 직렬화하며, 예외 핸들러는 타입을 메시지에 매핑합니다. 누군가 예상치 못한 예외를 던질 때까지는 잘 작동하지만, 일반적인 500 응답은 호출자에게 아무 정보도 제공하지 않습니다.

Slices는 sealed Cause 계층을 사용합니다:

public sealed interface OrderCause extends Cause {
    OrderCause EMPTY_ORDER = new EmptyOrder("Order must have items");
    OrderCause INSUFFICIENT_STOCK = new InsufficientStock("Insufficient stock");

    static OrderCause insufficientStock(StockStatus stock) {
        return new InsufficientStock("Insufficient stock: " + stock);
    }

    record EmptyOrder(String message) implements OrderCause {}
    record InsufficientStock(String message) implements OrderCause {}
}

모든 실패 모드는 하나의 타입입니다. 컴파일러가 모든 타입을 알고 있습니다. 패턴 매칭을 통해 이를 완전하게 처리합니다. 500 오류로 위장하는 뜻밖의 NullPointerException이 없습니다.

비즈니스 관점에서의 오류 처리

무언가 실패했을 때는 예외를 잡아먹는 대신 유창한(flient) 스타일로 실패한 프라미스를 반환해야 합니다:

return inventory.checkStock(stockRequest)
                .flatMap(this::verifyAvailability);
private Promise verifyAvailability(StockStatus stock) {
    return stock.sufficient()
        ? completeOrder(stock)
        : OrderCause.insufficientStock(stock).promise();
}
  • 런타임은 Cause를 네트워크를 통해 전파합니다.
  • 호출자는 단순 문자열 메시지가 아닌 타입이 지정된 실패를 받습니다.
  • 오류 처리 계약이 API의 일부가 되며, @ControllerAdvice에 뒤늦게 추가되는 것이 아닙니다.

컨테이너 없이 테스트하기

  • 시작할 테스트 컨테이너가 없습니다.
  • 구성할 모크 서버가 없습니다.
  • 방대한 환경을 로드하는 @SpringBootTest가 없습니다.
  • 모킹 프레임워크가 없습니다 – 의존성이 인터페이스이므로 필요한 값을 정확히 반환하는 람다를 전달합니다.
class OrderServiceTest {

    @Test
    void placeOrder_succeeds_whenInventoryAvailable() {
        InventoryService inventory = request -> Promise.success(new StockResult("RES-123", true));
        PricingEngine pricing = request -> Promise.success(new PriceResult("ORD-456", 99.99));

        var service = OrderService.orderService(inventory, pricing);

        service.placeOrder(request)
               .await()
               .onFailure(Assertions::fail)
               .onSuccess(result -> assertEquals("ORD-456", result.orderId()));
    }

    @Test
    void placeOrder_fails_whenInventoryUnavailable() {
        InventoryService inventory = request -> OrderCause.INSUFFICIENT_STOCK.promise();
        PricingEngine pricing = request -> Promise.success(new PriceResult("ORD-456", 99.99));

        var service = OrderService.orderService(inventory, pricing);

        service.placeOrder(request)
               .await()
               .onSuccess(Assertions::fail);
    }
}
  • 의존성은 인터페이스입니다 – 성공 또는 실패를 반환하는 람다를 전달합니다.
  • 팩토리 메서드가 이를 연결합니다.
  • 리플렉션, 클래스‑패스 스캔, 컨텍스트 초기화가 없습니다.
  • Mockito도 없고, when(...).thenReturn(...)도, verify(...)도 없습니다.

테스트 시작이 즉시 이루어집니다. 시작할 것이 없기 때문입니다: 컨테이너도, 프레임워크도, 빈 해석도 없고, 객체가 객체를 호출하는 것뿐입니다.

Source:

슬라이스 기반 아키텍처

단일 Maven 모듈에 의미가 있는 만큼 많은 슬라이스를 포함할 수 있습니다:

commerce/
  src/main/java/org/example/
    order/
      OrderService.java       # @Slice
    payment/
      PaymentService.java    # @Slice
    shipping/
      ShippingService.java   # @Slice
  • @Slice는 자체 팩토리, API 아티팩트, 그리고 배포 메타데이터를 생성합니다.
  • Maven 플러그인은 이를 별도로 패키징합니다.
  • 도메인 타입, 빌드 설정, 저장소를 공유하면서 독립적으로 배포 및 확장됩니다.

세분화된 확장

슬라이스는 단일 메서드만큼 작을 수도 있습니다. 운영 오버헤드가 없기 때문에(컨테이너, 로드 밸런서, 서비스별 모니터링이 없음) 슬라이스를 개별적으로 확장할 수 있습니다:

  • 피크 부하 시 50 인스턴스를 제공하는 슬라이스 하나.
  • 최소 용량으로 대기하는 또 다른 슬라이스.

Aether에서는 이것이 기본 동작입니다.

어노테이션 프로세서가 생성하는 내용

외부 의존성이 하나만 있는 간단한 슬라이스를 생각해 보세요:

@Slice
public interface OrderService {
    Promise placeOrder(PlaceOrderRequest request);

    static OrderService orderService(InventoryService inventory) {
        return request -> inventory.check(request.items())
                                   .map(OrderResult::fromAvailability);
    }
}

프로세서는 InventoryService가 외부 @Slice임을 감지하고 다음을 생성합니다:

  1. 프록시 레코드InventoryService를 구현하고 모든 메서드 호출을 런타임의 SliceInvokerFacade에 위임합니다.
    코드에서 inventory.check(request)를 호출하면, 프록시가 요청을 직렬화하고 InventoryService가 배치된 노드로 라우팅한 뒤, 응답을 역직렬화하여 Promise를 반환합니다.

  2. 팩토리 클래스AspectSliceInvokerFacade를 받아 프록시를 생성하고 모든 연결을 설정합니다.

  3. 배포 메타데이터 (META-INF/slice/에 위치) – 슬라이스 이름, 메서드 목록, 의존성을 포함합니다.
    런타임은 이 메타데이터를 읽어 의존성 그래프를 구성하고 배포 순서를 결정합니다.

이 모든 작업은 컴파일 시점에 생성·검증되며 비즈니스 로직에서는 보이지 않습니다.

기존 Spring 코드를 점진적으로 마이그레이션하기

새 프로젝트를 처음부터 시작할 필요가 없습니다. 기존 Java 코드는 단 한 줄만으로도 slice가 될 수 있습니다.

레거시 Spring 서비스

@Service
public class OrderService {
    @Autowired private InventoryRepository inventory;
    @Autowired private PricingService pricing;

    @Transactional
    public OrderResult processOrder(OrderRequest request) {
        var availability = inventory.checkAvailability(request.getItems());
        if (!availability.isAvailable()) {
            return OrderResult.outOfStock(availability.getMissingItems());
        }
        var quote = pricing.calculateQuote(request.getItems(), request.getCustomerId());
        // ... payment, order creation, notification ...
        return OrderResult.success(order);
    }
}

slice 로 감싸기

@Slice
public interface OrderProcessor {
    Promise processOrder(OrderRequest request);

    static OrderProcessor orderProcessor() {
        var legacyService = createLegacyService();
        return request -> Promise.lift(() -> legacyService.processOrder(request));
    }
}
  • Promise.lift()는 동기 호출을 감싸고 예외를 잡아 타입이 지정된 실패를 반환합니다. 이는 원시 스택 트레이스 대신에 적절한 Promise를 제공합니다.

감싼 slice는 동작하지만 블랙 박스처럼 보입니다. Peeling 패턴을 사용하면 한 번에 한 레이어씩 점진적으로 열어볼 수 있으며, 각 단계마다 동작하는 코드를 유지할 수 있습니다.

외부 구조 Peel하기

불투명한 lift()Sequencer 로 교체하고 각 단계는 여전히 감싸서 사용합니다:

return Promise.lift(() -> legacyCheckInventory(request))
              .flatMap(inv -> Promise.lift(() -> legacyCalculatePricing(inv)))
              .flatMap(quote -> Promise.lift(() -> legacyProcessPayment(quote, request)));

이제 각 단계가 명시적이고 테스트 가능하며 조합할 수 있게 되며, 전체 흐름은 여전히 단일하고 타입‑안전한 promise 체인으로 유지됩니다.

Migration walkthrough

ote)))
    .flatMap(payment -> Promise.lift(() -> legacyCreateOrder(payment)));

Now the pipeline is visible. You can see the steps, test them individually, and reason about the flow.

Peel one step deeper

Take the hottest lift() and expand it:

private Promise checkInventory(OrderRequest request) {
    return Promise.all(
            Promise.lift(() -> legacyCheckWarehouse(request)),
            Promise.lift(() -> legacyCheckSupplier(request))
        )
        .map(this::combineAvailability);
}
  • The outer call is now clean JBCT.
  • The inner calls are still wrapped.
  • Tests pass at every step.

Stop anywhere – mixed JBCT and legacy code works fine. The remaining lift() calls mark exactly where legacy code lives. When they’re all gone, you have a clean slice. There’s no deadline; each peeling step delivers value on its own.

The full migration walkthrough covers the complete path – from initial wrapping through fault tolerance to clean JBCT code.

왜 슬라이스 개발은 다른가

전통적인 마이크로‑서비스 개발은 프레임워크와의 협상이다:

  • 그들의 추상화, 라이프사이클 훅, 설정 DSL, 어노테이션 모델, 오류‑처리 규칙, 그리고 테스트 유틸리티를 배우게 됩니다.
  • 프레임워크가 중심이 되고; 당신의 비즈니스 로직은 그 주위를 돕니다.

슬라이스는 이를 뒤집습니다.

  • 비즈니스 로직이 중심.
  • 인터페이스가 계약을 정의합니다.
  • 팩토리 메서드가 의존성을 선언합니다.
  • 구현은 람다입니다.

그 외 모든 것 – 직렬화, 라우팅, 스케일링, 장애 복구, 설정 – 은 런타임의 문제입니다.

“프레임워크를 배우는 것이 아니라, Java 인터페이스를 작성하고 구현합니다.”

두 가지 규칙. 나머지는 당신의 도메인일 뿐입니다.

프레임워크 없음. 고통도 없음.

리소스

  • Pragmatica Aether – 분산 Java 런타임
  • GitHub Repository – [source code]
  • Slice Development Guide – 전체 참고 자료
0 조회
Back to Blog

관련 글

더 보기 »