Files
llm-wiki/vault/40-publish/blog/ca-tmpl-streaming-response-support-2026-07-02.md
T

9.7 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
title source_type status confidence tags related_projects last_reviewed canonical_sources audience target_publish status_label
Streaming Response를 지원하지 않는 결정도 계약이다 blog verified high
blog
ca-tmpl
streaming
archunit
api-design
ca-tmpl
2026-07-03
wiki/projects/ca-tmpl/streaming-response-support
backend-engineer ready

Streaming Response를 지원하지 않는 결정도 계약이다

Parent / 부모 (필수)

타깃 독자 / Target reader

  • 독자 profile: template skeleton에서 streaming/SSE/WebSocket response를 언제 열지 고민하는 백엔드 엔지니어.
  • 이미 안다고 가정하는 것: SseEmitter, ResponseBodyEmitter, WebSocket, StreamingResponseBody.
  • 처음 듣는다고 가정하는 것: “지원하지 않음”도 문서 문장이 아니라 build-time rule로 고정할 수 있다는 관점.

도입 / Hook

기술 선택은 보통 “무엇을 지원할 것인가”로 기록됩니다. 그런데 skeleton에서는 “지금은 열지 않을 것”도 중요한 결정입니다. SSE나 WebSocket을 한 번 열면 응답 envelope, timeout, heartbeat, reconnect, observability, connection cap, reverse proxy 설정까지 같이 따라옵니다. use case가 없는데 surface만 열면 템플릿은 빨리 무거워집니다.

ca-tmpl은 streaming response를 지원하지 않는다는 결정을 그냥 README에 쓰지 않았습니다. production code가 SseEmitter, ResponseBodyEmitter, Spring/Jakarta WebSocket surface를 import하면 ArchUnit rule이 잡도록 했습니다. 단, 대용량 다운로드용 StreamingResponseBody는 차단하지 않았습니다. 이 글은 “미지원도 계약이 될 수 있다”는 관점과, 어디까지가 실제 구현인지 정리합니다.

본문 outline / Body outline

  1. 미지원도 설계 결정이다.
  2. streaming이 깨뜨리는 기존 request-response baseline.
  3. 차단 대상과 제외 대상 - SseEmitter/WebSocket은 막고 StreamingResponseBody는 막지 않는다.
  4. ArchUnit rule과 violations-as-data fixture로 검증한다.
  5. 나중에 streaming을 열려면 필요한 선행 계약.

본문 / Body

Streaming은 매력적인 기능입니다. LLM token streaming, 실시간 알림, export 진행률처럼 server가 client에게 계속 event를 보내야 하는 use case가 생기면 request-response만으로는 답답합니다. 하지만 skeleton의 default surface로 넣기에는 비용이 큽니다. event envelope을 어떻게 만들지, error를 mid-stream에서 어떻게 표현할지, trace id는 connection 단위인지 event 단위인지, proxy timeout과 heartbeat는 어떻게 둘지 정해야 합니다.

ca-tmpl은 현재 sample fixture에 server-push use case가 없기 때문에 streaming을 기본 지원하지 않기로 했습니다. 여기서 핵심은 “아직 안 만들었다”가 아니라 “지금은 열지 않는다는 결정을 build-time rule로 고정했다”입니다. production code가 SseEmitter를 import하거나, ResponseBodyEmitter를 쓰거나, Spring/Jakarta WebSocket package에 의존하면 ArchUnit rule이 실패합니다.

차단 대상은 이벤트/server-push streaming입니다. SseEmitter는 Server-Sent Events surface이고, ResponseBodyEmitter는 incremental object emit surface이며, WebSocket은 full-duplex connection model입니다. 이 셋은 request-response API baseline과 다른 운영 계약을 요구합니다. ca-tmpl은 이 표면을 기본 skeleton에 열지 않았습니다.

반대로 StreamingResponseBody는 차단하지 않습니다. 이름은 비슷하지만, ca-tmpl project canonical은 이를 대용량 파일 다운로드나 chunked body처럼 request-response 모델을 유지하는 관심사로 봅니다. 이벤트를 계속 push하는 계약과, 하나의 요청에 대해 body를 stream으로 쓰는 계약은 다릅니다. 그래서 over-block guard fixture가 있습니다. StreamingResponseBodyAllowedFixture는 streaming ban rule이 이 허용 케이스를 잡지 않아야 통과합니다.

이 구조가 좋은 이유는 “금지 rule이 진짜로 작동하는가”까지 테스트한다는 점입니다. ArchitectureViolationFixtureTestSseEmitterUsingFixture, ResponseBodyEmitterUsingFixture, Spring WebSocket fixture, Jakarta WebSocket fixture를 의도적 위반 데이터로 둡니다. 각 rule이 이 fixture를 잡는지 확인하고, WebSocket은 Spring glob과 Jakarta glob을 따로 가져와 vacuous pass를 줄입니다. 동시에 StreamingResponseBody는 잡지 않는지 확인합니다.

나중에 streaming을 열 수 없는 것은 아닙니다. 다만 그때는 단순히 controller return type을 바꾸는 일이 아닙니다. SSE인지 WebSocket인지, event envelope을 기존 { success, data, meta }와 어떻게 맞출지, per-event trace를 만들지, reconnect와 timeout, connection cap, reverse proxy 설정을 어떻게 둘지 결정해야 합니다. ca-tmpl 문서는 이 지원 계약을 planned/open 범위로 남겨 두고 있습니다.

따라서 이 글의 결론은 “streaming은 나쁘다”가 아닙니다. ca-tmpl의 결론은 더 좁습니다. 현재 skeleton의 sync request-response baseline에서는 server-push streaming을 기본 surface로 열지 않고, 그 미지원 상태가 우연히 깨지지 않도록 ArchUnit으로 막습니다. 운영 streaming endpoint, connection load, SSE/WebSocket 장애 대응은 이 글에서 말할 수 있는 범위가 아닙니다.

코드 예제 / Code samples (있다면)

// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule NO_SSE_EMITTER =
    noClasses()
        .that()
        .resideInAPackage("dev.caskeleton..")
        .should()
        .dependOnClassesThat()
        .haveFullyQualifiedName("org.springframework.web.servlet.mvc.method.annotation.SseEmitter");
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule NO_WEBSOCKET_HANDLER =
    noClasses()
        .that()
        .resideInAPackage("dev.caskeleton..")
        .should()
        .dependOnClassesThat()
        .resideInAnyPackage("org.springframework.web.socket..", "jakarta.websocket..");
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
// 실제 파일: app-bootstrap/.../ArchitectureViolationFixtureTest.java, ca-tmpl @f6fbd4e196b4
void noWebsocketHandlerCatchesJakartaWebsocketFixture() {
  EvaluationResult result =
      CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(JAKARTA_WEBSOCKET_FIXTURE_ONLY);

  assertThat(result.hasViolation()).isTrue();
}
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
// 실제 파일: app-bootstrap/.../StreamingResponseBodyAllowedFixture.java, ca-tmpl @f6fbd4e196b4
public class StreamingResponseBodyAllowedFixture {
  public StreamingResponseBody allowed() {
    return outputStream -> outputStream.write("data".getBytes());
  }
}

Sources / 근거 (canonical 인용 필수, derived layer 의무)

사실 vs 의견 / Fact vs opinion 구분

  • 사실: ca-tmpl에는 NO_SSE_EMITTER, NO_RESPONSE_BODY_EMITTER, NO_WEBSOCKET_HANDLER ArchUnit rule과 streaming violation fixtures, StreamingResponseBody over-block guard fixture가 존재한다. 근거: wiki/projects/ca-tmpl/streaming-response-support
  • 사실: 구현된 것은 streaming 지원이 아니라 streaming 미지원을 강제하는 build-time guard다. 근거: wiki/projects/ca-tmpl/streaming-response-support
  • 사실: 실제 SSE/WebSocket endpoint, connection load test, production streaming metric은 없다. 근거: wiki/projects/ca-tmpl/streaming-response-support
  • 의견: skeleton 초기 surface에서는 real-time use case가 나타나기 전까지 server-push streaming을 닫아두는 편이 계약을 단순하게 유지한다.
  • 알지 못하는 것: 실제 streaming workload 요구사항, proxy timeout tuning, per-event tracing 운영 효과.

답할 수 있는 범위 / Answer boundary

  • 자신 있게 답할 수 있는 후속 질문:
    • 왜 streaming을 지금 열지 않았는가?
    • 어떤 Spring/Jakarta streaming surface를 ArchUnit으로 막았는가?
    • StreamingResponseBody는 차단하지 않았는가?
    • violations-as-data fixture가 vacuous pass를 어떻게 줄이는가?
  • 다음 글로 넘길 부분:
    • SSE/WebSocket을 실제로 열 때 필요한 API envelope와 observability 계약.
    • connection cap, heartbeat, reconnect, proxy timeout 설계.
    • production streaming endpoint 검증.

게시 체크리스트 / Publish checklist

  • 모든 사실 주장에 canonical 링크 있음
  • 사실 vs 의견 분리 명시됨
  • 금지 마케팅 표현 없음
  • 코드 예제 출처 명시
  • 타깃 독자 가정과 톤 일치
  • /lint 통과
  • 게시 URL 기록 (게시 후):