OpenAPI spec은 유효한 요청이 어떤 모습인지, 유효한 응답이 어떤 모습인지 알려줍니다. 하지만 POST /orders를 POST /auth 이전에 호출할 수 있는지, DELETE /invoice/{id} 이후에 GET /invoice/{id}를 호출하면 어떤 일이 일어나는지는 알려주지 않습니다. 그 정보는 protocol spec에 있으며, OpenAPI는 protocol spec이 아닙니다.
이것이 바로 간극입니다. OpenAPI 문서에서 시스템의 모든 메시지 타입을 생성할 수는 있습니다. 하지만 그 메시지들이 언제 전송될 수 있는지에 대한 규칙은 생성할 수 없습니다. 그 규칙들이 바로 session type이며, 이는 추상화의 다른 계층에 존재합니다.
OpenAPI가 포착하는 것과 무시하는 것
OpenAPI는 HTTP surface area에 대한 contract입니다. 경로, 메서드, 쿼리 파라미터, 요청 본문, 응답 코드, JSON schema를 정의합니다. 이는 가치 있는 일입니다. 하지만 근본적으로 정적입니다. 각 endpoint는 고립되어 기술됩니다. endpoint 간의 관계, 이들이 트리거하는 상태 전이, 그리고 허용되는 순서는 구현자가 알아서 채워야 하는 부분으로 남겨둡니다.
Session type은 그 정반대입니다. Session type은 통신 protocol의 형식적 기술입니다. 메시지가 반드시 전송되고 수신되어야 하는 순서, 누가 무엇을 보내는지, 그리고 메시지 내용에 따라 protocol이 어떻게 분기하는지를 명시합니다. 이진 session type은 이렇게 말할 수 있습니다: 클라이언트가 Login 메시지를 보내고, 서버는 Success로 응답하여 protocol을 계속하거나, Failure로 응답하여 protocol을 종료합니다.
그 순서를 OpenAPI spec에서 유도할 수 없는 이유는, 그 순서가 기록된 적이 없기 때문입니다. OpenAPI 문서는 endpoint 목록을 나열할 뿐입니다. /auth가 /orders보다 먼저 와야 한다고 명시하지는 않습니다. 그 제약 조건은 문서, 코드, 또는 API를 설계한 엔지니어의 머릿속에 존재합니다.
추출할 수 있는 것: 메시지 계층
OpenAPI가 제공하는 것은 정확하고 기계가 읽을 수 있는 메시지 정의입니다. 모든 요청 본문 schema, 모든 응답 schema, 모든 enum과 discriminator는 JSON Schema로 명시됩니다. 이는 session type specification에서 메시지 타입을 만드는 원자재입니다.
구체적인 예를 살펴겠습니다. 다음 OpenAPI 조각을 보세요:
paths:
/auth:
post:
requestBody:
content:
application/json:
schema:
type: object
properties:
username: { type: string }
password: { type: string }
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
token: { type: string }
'401':
description: Unauthorized
이것으로부터 두 가지 메시지 타입을 추출할 수 있습니다: username과 password를 담은 AuthRequest, 그리고 token을 가진 Success나 Failure인 AuthResponse. Session type은 이러한 타입들을 필요로 합니다. 또한 클라이언트가 요청을 보내고 서버가 응답을 본낸다는 사실도 알아야 하는데, 이는 OpenAPI가 HTTP semantics를 통해 암시합니다.
이 추출 과정을 자동화할 수 있습니다. 다음은 OpenAPI JSON spec을 파싱하여 Rust 스타일의 메시지 정의를 생성하는 Python 스크립트입니다:
import json
from dataclasses import dataclass
@dataclass
class Field:
name: str
type: str
@dataclass
class Message:
name: str
fields: list[Field]
TYPE_MAP = {"string": "String", "integer": "i64", "boolean": "bool"}
def sanitize_name(path: str, method: str, suffix: str) -> str:
cleaned = path.replace("/", "").replace("{", "").replace("}", "")
return f"{method.upper()}{cleaned}{suffix}"
def extract_messages(openapi_path: str) -> list[Message]:
with open(openapi_path) as f:
spec = json.load(f)
messages = []
for path, methods in spec.get("paths", {}).items():
for method, operation in methods.items():
if not isinstance(operation, dict):
continue
req = operation.get("requestBody", {})
schema = req.get("content", {}).get("application/json", {}).get("schema", {})
if schema:
fields = [
Field(name=k, type=TYPE_MAP.get(v.get("type"), "serde_json::Value"))
for k, v in schema.get("properties", {}).items()
]
messages.append(Message(name=sanitize_name(path, method, "Request"), fields=fields))
for code, resp in operation.get("responses", {}).items():
schema = resp.get("content", {}).get("application/json", {}).get("schema", {})
if schema:
fields = [
Field(name=k, type=TYPE_MAP.get(v.get("type"), "serde_json::Value"))
for k, v in schema.get("properties", {}).items()
]
messages.append(Message(name=sanitize_name(path, method, f"Response{code}"), fields=fields))
return messages
if __name__ == "__main__":
for m in extract_messages("api.json"):
print(f"struct {m.name} {{")
for f in m.fields:
print(f" {f.name}: {f.type},")
print("}")
이것은 기계적이지만 동작합니다. OpenAPI 문서를 session type 구현체가 참조할 수 있는 struct로 변환합니다. 타입은 정확합니다. 이름은 문자 그대로입니다. 관계는 빠져 있습니다.
순서 정보가 존재하는 곳
protocol을 얻으려면 스테이트 머신을 알아야 합니다. 일부 API는 이를 암묵적으로 인코딩합니다. POST /orders는 주문 ID를 반환하고, 이후의 GET /orders/{id} 호출은 이를 참조합니다. protocol 상태에는 ‘주문이 생성되었다’가 포함됩니다. OpenAPI spec은 이러한 의존성을 모델링하지 않습니다.
이 간극을 메우려는 신규 표준들도 있습니다. AsyncAPI는 채널 semantics를 사용하여 이벤트 중심 protocol을 다루지만, 여전히 전역 session type을 제공하지는 않습니다. Smithy는 연산과 trait을 정의하고, 커스텀 trait을 통해 유한 스테이트 머신을 모델링할 수 있지만, 명시적인 주석이 필요합니다. JSON Hyper-Schema는 리소스 연결을 시도했지만 널리 채택되지는 못했습니다.
현재로서는 실용적인 접근법은 protocol을 별도의 산출물로 다루는 것입니다. OpenAPI에서 메시지 타입을 생성한 다음, 그 위에 session type을 직접 작성합니다.
실용적인 하이브리드: 생성된 타입과 수동으로 작성한 protocol
실제로 이것이 어떤 모습인지 Rust와 session_types crate를 사용하여 보겠습니다. 먼저, 위의 스크립트나 typify와 같은 도구를 사용하여 OpenAPI에서 메시지 타입을 생성합니다. 그런 다음 session type을 명시적으로 정의합니다:
use session_types::*;
// Generated from OpenAPI
struct AuthRequest { username: String, password: String }
struct AuthSuccess { token: String }
struct AuthFailure { reason: String }
struct OrderRequest { item_id: u64, quantity: u64 }
struct OrderConfirmation { order_id: u64 }
// The protocol: authenticate, then optionally place an order
type ServerProto = Receive<AuthRequest, Offer<
Send<AuthSuccess, Receive<OrderRequest, Send<OrderConfirmation, Eps>>>,
Send<AuthFailure, Eps>
>>;
fn server(c: Chan<(), ServerProto>) {
let (c, req) = c.recv();
if authenticate(&req) {
let c = c.sel1().send(AuthSuccess { token: "abc".into() });
let (c, order) = c.recv();
let c = c.send(OrderConfirmation { order_id: 42 });
c.close();
} else {
let c = c.sel2().send(AuthFailure { reason: "bad creds".into() });
c.close();
}
}
Session type ServerProto는 서버가 정확히 무엇을 하는지 명시합니다. AuthRequest를 수신합니다. 그리고 선택을 제공합니다: AuthSuccess를 보내고 OrderRequest를 계속 수신하거나, AuthFailure를 보내고 종료합니다. OpenAPI spec은 struct를 제공했습니다. Session type은 문법을 제공했습니다.
이것이 바로 업무 분담입니다. OpenAPI는 메시지 형태를 다룹니다. Session type은 메시지 순서를 다룹니다. 하나는 생성되고, 다른 하나는 설계됩니다.
알아야 할 트레이드오프
OpenAPI에서의 자동화된 추출은 날카로운 모서리가 있습니다. 첫 번째는 다형성입니다. OpenAPI는 union에 oneOf와 anyOf를 사용하지만, discriminator 정보가 session type 분기에 깔끔하게 매핑되지는 않습니다. 응답 schema의 oneOf는 두 가지 다른 성공 형태를 나타낼 수도 있고, protocol 분기를 나타낼 수도 있습니다. 어떤 것인지 알려면 spec을 읽어야 합니다.
두 번째는 HTTP에 특화된 semantics입니다. Session type은 transport에 독립적입니다. OpenAPI는 HTTP 메서드, 상태 코드, 헤더와 깊이 결합되어 있습니다. 메시지를 추출할 때, 메시지 이름에 인코딩하지 않는 한 메서드와 경로 정보를 잃게 됩니다. 이것이 위의 Python 스크립트가 POSTAuthResponse200과 같은 이름을 생성하는 이유입니다. 보기 흉하지만 출처는 보존합니다.
세 번째는 불완전한 spec입니다. 많은 OpenAPI 문서가 코드에서 생성되며 모든 오류 응답을 포함하지는 않습니다. session type이 모든 가능한 분기를 처리해야 한다면, 불완전한 OpenAPI spec은 불완전한 session type을 만들 것입니다. type checker는 소스 문서에 없던 분기를 놓칠 것입니다.
codebase에 구현하는 방법
메시지부터 시작하세요. openapi-typescript, typify, 또는 커스텀 스크립트를 사용하여 OpenAPI schema에서 언어별 타입을 생성하세요. 이 단계에서 protocol을 생성하려 하지 마세요. struct만 얻으세요.
다음으로, 시스템에서 가장 중요한 흐름에 대한 session type을 작성하세요. 순서 버그가 가장 비싼 대가를 치를 흐름을 선택하세요. 인증과 결제는 좋은 후보입니다. 선택한 언어로 session type을 정의하세요. Rust에는 session_types가 있습니다. TypeScript, OCaml, Scala용 실험적 라이브러리도 있습니다. 사용 중인 언어에 session type 라이브러리가 없다면, 유효한 순서를 검증하는 스테이트 머신 테스트를 작성하세요. 동일한 보장은 아니지만, 동일한 종류의 버그를 잡아냅니다.
마지막으로, CI를 통해 두 산출물을 동기화 상태로 유지하세요. OpenAPI spec이 변경되면 메시지 타입을 재생성하세요. 새로운 필수 필드가 추가되면 session type 코드는 컴파일에 실패할 것입니다. 그것이 당신이 얻는 안전성입니다.
FAQ
WebSocket이나 gRPC API에도 적용됩니까?
직접적으로는 아닙니다. OpenAPI는 HTTP를 기술합니다. gRPC의 경우 protobuf schema가 있어 메시지 타입은 제공하지만 여전히 순서는 제공하지 않습니다. WebSocket의 경우 AsyncAPI가 더 가깝지만, 동일한 제약이 적용됩니다: 채널은 기술하지만 전역 session protocol은 기술하지 않습니다.
대신 AsyncAPI에서 session type을 생성할 수 있나요?
AsyncAPI는 채널 semantics와 메시지 라우팅을 추가하여 더 가까워집니다. 하지만 여전히 전역 protocol 스테이트 머신을 명시하지는 않습니다. AsyncAPI에서 메시지 타입을 추출한 다음, OpenAPI와 마찬가지로 session type을 직접 작성해야 합니다.
API에 endpoint가 50개라면 어떻게 해야 하나요?
50개의 endpoint는 50개의 메시지 타입을 의미하며, 50단계를 가진 하나의 session type이 아닙니다. 실제 protocol은 더 작은 session으로 분해됩니다. 전자상거래 API는 인증, 결제, 재고를 위한 별도의 session을 가질 수 있습니다. 이들을 조합하세요.
이 모든 것을 자동으로 해주는 도구가 있나요?
아직은 없습니다. 관련 연구는 존재합니다. REST API와 choreographies에서 session type을 추출하는 논문도 있습니다. 하지만 프로덕션 준비가 된 도구는 없습니다. 현재로서는 하이브리드 접근법이 실용적인 길입니다.
OpenAPI spec은 사전과 같습니다. 단어의 의미를 알려줍니다. Session type은 문법과 같습니다. 어떤 문장이 유효한지 알려줍니다. 어휘는 OpenAPI에서 생성하세요. 문법은 직접 작성하세요.