문제 상황
RDF 데이터 생성, 수정, 삭제 요청을 처리하는 API에서는 요청 하나에 대해 여러 종류의 검증이 필요했습니다.
단순히 요청 본문에 필요한 필드가 존재하는지 확인하는 것만으로는 충분하지 않았습니다. RDF 데이터는 여러 리소스 간의 관계를 표현하는 그래프 데이터이기 때문에, 요청 값의 타입이 올바르더라도 도메인 규칙이나 RDF 규칙에 맞지 않으면 잘못된 데이터가 유입될 수 있었습니다.
또한 일부 데이터는 외부 시스템의 기준 데이터와 연결되어 있었습니다. 따라서 요청에 포함된 값이 외부 시스템에 실제로 존재하는지, 외부 시스템의 데이터와 정합성이 맞는지도 확인해야 했습니다.
처음에는 이러한 검증을 하나의 흐름 안에서 처리할 수 있다고 생각했습니다. 하지만 검증 로직이 늘어나면서 다음과 같은 문제가 발생했습니다.
첫째, 검증 실패의 성격이 서로 달랐습니다. 요청 형식이 잘못된 경우, 도메인 규칙을 위반한 경우, 외부 API 호출 자체가 실패한 경우는 모두 다른 원인에 의해 발생하는 문제였습니다.
둘째, 모든 실패를 동일한 validation 오류로 처리하면 문제의 원인을 명확히 구분하기 어려웠습니다. 예를 들어 클라이언트가 잘못된 값을 보낸 경우와 외부 시스템 장애로 인해 검증을 수행하지 못한 경우는 서로 다르게 처리되어야 했습니다.
셋째, 에러 응답 형식이 일관되지 않을 가능성이 있었습니다. FastAPI의 request validation 오류, 직접 정의한 논리 검증 오류, 외부 API 호출 실패 오류가 각각 다른 방식으로 응답되면 클라이언트 입장에서는 실패 응답을 일관되게 처리하기 어려웠습니다.
따라서 검증 책임을 단계별로 분리하고, 실패 원인의 성격에 따라 예외 처리 방식도 구분해야 했습니다.
해결 방향
검증 책임을 크게 세 가지로 분리했습니다.
- FastAPI의 request validation
- 외부 API 호출을 통한 참조 확인
- RDF 데이터에 대한 논리적 validation
첫 번째 단계에서는 FastAPI와 Pydantic의 request validation을 활용했습니다. 요청 본문에 필요한 필드가 존재하는지, 타입이 올바른지, 기본적인 스키마를 만족하는지를 프레임워크 레벨에서 검증하도록 했습니다.
두 번째 단계에서는 외부 API를 호출해 요청 데이터가 외부 시스템의 기준 데이터와 정합성이 맞는지 확인했습니다. 다만 이때 외부 API 호출 실패는 일반적인 validation 실패와 다르게 처리했습니다. 외부 시스템의 네트워크 오류, timeout, 5xx 응답 등은 클라이언트 요청 값이 잘못되었다고 보기 어렵기 때문입니다.
세 번째 단계에서는 RDF 데이터에 대한 논리적 validation을 수행했습니다. 요청 형식은 올바르지만, RDF 규칙이나 도메인 규칙상 허용되지 않는 요청인지 확인했습니다. 예를 들어 특정 리소스에 허용되지 않는 관계를 생성하려는 경우, 삭제할 수 없는 리소스를 삭제하려는 경우, 온톨로지 규칙을 위반하는 경우를 검증했습니다.
이렇게 검증 책임을 나누면서 각 단계의 실패를 다음과 같이 구분했습니다.
1. Request validation 실패
- 필수 필드 누락
- 타입 오류
- 요청 스키마 위반
- 클라이언트 요청 오류로 처리
2. 외부 API 호출 실패
- timeout
- 네트워크 오류
- 외부 시스템 5xx 응답
- 외부 시스템 연동 오류로 처리
3. 논리적 validation 실패
- RDF 규칙 위반
- 도메인 규칙 위반
- 허용되지 않은 생성, 수정, 삭제 요청
- 클라이언트 요청 오류로 처리
핵심은 모든 실패를 validation 오류로 묶지 않는 것이었습니다. 요청 자체가 잘못된 경우와 외부 시스템 문제로 인해 검증을 완료하지 못한 경우를 분리해서 처리했습니다.
전체 처리 흐름
전체 흐름은 다음과 같이 구성했습니다.
flowchart TD
A[Client Request] --> B[FastAPI Request Validation]
B -->|Invalid Request| H1[Common Validation Handler]
B -->|Valid Request| C[External API Reference Check]
C -->|External API Call Failed| H2[External API Error Handler]
C -->|Reference Check Passed| D[Logical RDF Validation]
D -->|Logical Validation Failed| H1
D -->|Validation Passed| E[Service Logic]
E --> F[RDF Store / Graph DB]
H1 --> R1[Consistent Validation Error Response]
H2 --> R2[External System Error Response]
FastAPI request validation을 먼저 수행하고, 요청 스키마를 통과한 경우에만 외부 API를 호출했습니다. 외부 API 호출이 정상적으로 완료되면, 이후 RDF 데이터에 대한 논리적 validation을 수행했습니다.
이 흐름에서 request validation 실패와 논리적 validation 실패는 모두 클라이언트 요청 오류에 해당한다고 보고 공통 validation handler에서 처리했습니다.
반면 외부 API 호출 실패는 별도의 external API error handler에서 처리했습니다. 외부 시스템 장애나 네트워크 문제는 클라이언트 요청 값의 문제가 아니기 때문에 validation 오류와 분리하는 것이 적절하다고 판단했습니다.
FastAPI Request Validation 활용
요청 필드의 존재 여부, 타입, 기본적인 형식 검증은 FastAPI와 Pydantic의 request validation을 활용했습니다.
from pydantic import BaseModel
class RdfRequest(BaseModel):
subject: str
predicate: str
object: str
operation: str
FastAPI는 요청 본문이 RdfRequest 모델을 만족하지 않으면 라우터 로직에 진입하기 전에 validation error를 발생시킵니다.
이 구조를 사용하면 직접 필수 필드나 타입을 검사하는 코드를 작성하지 않아도 됩니다. 요청 스키마에 대한 검증 책임은 FastAPI와 Pydantic에 맡기고, 애플리케이션에서는 RDF 데이터의 정합성이나 도메인 규칙 검증에 집중할 수 있었습니다.
외부 API 호출 실패의 분리
외부 API를 호출하는 단계는 RDF 데이터가 외부 기준 시스템과 정합성이 맞는지 확인하기 위한 과정이었습니다.
하지만 외부 API 호출은 네트워크와 외부 시스템 상태에 의존합니다. 따라서 timeout, 연결 실패, 외부 시스템의 5xx 응답 등은 클라이언트 요청 값이 잘못되었다고 볼 수 없었습니다.
이 때문에 외부 API 호출 실패는 validation 실패와 분리했습니다.
class ExternalApiException(Exception):
def __init__(self, message: str, error_code: str):
self.message = message
self.error_code = error_code
외부 API 호출 과정에서 timeout이나 5xx 응답이 발생하면 ExternalApiException을 발생시키도록 했습니다.
class ExternalReferenceClient:
async def validate_reference(self, external_id: str):
try:
response = await self.client.get(f"/external-resources/{external_id}")
except TimeoutError:
raise ExternalApiException(
message="external api timeout",
error_code="EXTERNAL_API_TIMEOUT"
)
if response.status_code >= 500:
raise ExternalApiException(
message="external api server error",
error_code="EXTERNAL_API_SERVER_ERROR"
)
return response
이렇게 분리하면 외부 시스템 문제를 클라이언트 요청 오류처럼 처리하지 않을 수 있었습니다.
예를 들어 외부 API timeout이 발생했을 때 400 응답을 내려주면 클라이언트는 자신의 요청이 잘못되었다고 오해할 수 있습니다. 반대로 별도의 외부 API 오류로 응답하면, 해당 오류가 외부 시스템 연동 과정에서 발생한 문제임을 명확히 표현할 수 있습니다.
논리적 Validation
FastAPI request validation을 통과했다고 해서 RDF 데이터로 저장해도 되는 것은 아니었습니다.
요청의 형식은 올바르지만, RDF 규칙이나 도메인 규칙상 허용되지 않는 요청일 수 있기 때문입니다.
예를 들어 다음과 같은 검증이 필요했습니다.
- 특정 subject에 허용되지 않는 predicate인지 확인
- 생성할 수 없는 관계를 생성하려는 요청인지 확인
- 수정할 수 없는 리소스를 수정하려는 요청인지 확인
- 삭제할 수 없는 리소스를 삭제하려는 요청인지 확인
- 온톨로지 규칙을 위반하는 요청인지 확인
이러한 검증은 프레임워크의 request validation으로는 처리할 수 없었습니다. 따라서 별도의 논리적 validation 계층을 두었습니다.
class RdfLogicalValidationException(Exception):
def __init__(self, message: str, error_code: str):
self.message = message
self.error_code = error_code
class RdfLogicalValidator:
async def validate(self, request: RdfRequest):
if not self.is_allowed_predicate(request.subject, request.predicate):
raise RdfLogicalValidationException(
message="predicate is not allowed for this subject",
error_code="RDF_INVALID_PREDICATE"
)
if request.operation == "DELETE" and self.is_protected_resource(request.subject):
raise RdfLogicalValidationException(
message="protected resource cannot be deleted",
error_code="RDF_PROTECTED_RESOURCE"
)
def is_allowed_predicate(self, subject: str, predicate: str) -> bool:
return True
def is_protected_resource(self, subject: str) -> bool:
return False
논리적 validation은 요청 스키마 검증 이후에 수행했습니다. 이를 통해 기본 형식이 보장된 요청에 대해서만 RDF 규칙과 도메인 규칙을 검증할 수 있었습니다.
공통 Validation Handler
FastAPI의 request validation 실패와 RDF 논리 validation 실패는 발생 위치는 다르지만, 둘 다 클라이언트 요청 오류에 해당했습니다.
따라서 두 오류를 공통 validation handler에서 일관된 응답 형식으로 처리했습니다.
FastAPI request validation에서 발생하는 RequestValidationError와 직접 정의한 RdfLogicalValidationException을 각각 handler에 연결하되, 내부적으로는 동일한 응답 생성 함수를 사용하도록 구성했습니다.
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
def build_validation_error_response(error_code: str, message: str):
return JSONResponse(
status_code=400,
content={
"errorCode": error_code,
"message": message
}
)
@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(
request: Request,
exc: RequestValidationError
):
return build_validation_error_response(
error_code="REQUEST_VALIDATION_ERROR",
message="invalid request format"
)
@app.exception_handler(RdfLogicalValidationException)
async def rdf_logical_validation_exception_handler(
request: Request,
exc: RdfLogicalValidationException
):
return build_validation_error_response(
error_code=exc.error_code,
message=exc.message
)
이렇게 구성하면 FastAPI request validation 실패와 RDF 논리 validation 실패를 동일한 응답 구조로 관리할 수 있었습니다.
클라이언트 입장에서는 요청 형식 오류와 RDF 규칙 위반 오류를 모두 같은 구조로 처리할 수 있고, 서버 입장에서는 validation 오류 응답 형식을 한 곳에서 일관되게 관리할 수 있었습니다.
External API Error Handler
외부 API 호출 실패는 validation handler와 분리했습니다.
외부 API 호출 실패는 요청 값이 잘못된 것이 아니라, 외부 시스템 호출 과정에서 발생한 문제일 수 있기 때문입니다. 따라서 별도의 handler를 두고 다른 응답 코드와 에러 코드를 사용했습니다.
@app.exception_handler(ExternalApiException)
async def external_api_exception_handler(
request: Request,
exc: ExternalApiException
):
return JSONResponse(
status_code=503,
content={
"errorCode": exc.error_code,
"message": exc.message
}
)
이를 통해 클라이언트 요청 오류와 외부 시스템 연동 오류를 명확히 구분할 수 있었습니다.
{
"errorCode": "EXTERNAL_API_TIMEOUT",
"message": "external api timeout"
}
이 구조는 장애 대응 관점에서도 유리했습니다. validation 오류는 클라이언트 요청 수정이 필요한 문제이고, 외부 API 오류는 외부 시스템 상태나 네트워크 상태를 확인해야 하는 문제입니다. 두 오류를 분리함으로써 로그 분석과 장애 원인 파악도 더 쉬워졌습니다.
개선 효과
이 구조를 적용하면서 검증 실패의 성격을 더 명확히 구분할 수 있었습니다.
FastAPI request validation은 요청 스키마 검증을 담당했고, RDF 논리 validation은 도메인 규칙과 RDF 규칙 검증을 담당했습니다. 두 validation 실패는 모두 클라이언트 요청 오류로 보고 공통 handler에서 일관된 응답 형식으로 처리했습니다.
반면 외부 API 호출 실패는 validation 실패와 분리했습니다. 외부 시스템 장애나 네트워크 오류는 클라이언트 요청 값의 문제가 아니기 때문에, 별도의 handler에서 외부 시스템 연동 오류로 응답하도록 했습니다.
결과적으로 다음과 같은 장점을 얻을 수 있었습니다.
- 요청 스키마 검증과 도메인 논리 검증의 책임 분리
- 클라이언트 요청 오류와 외부 시스템 연동 오류의 명확한 구분
- validation 오류 응답 형식의 일관성 확보
- 외부 API 장애 상황에 대한 별도 처리 가능
- 검증 로직과 예외 응답 생성 책임 분리
이전에는 여러 검증 실패를 하나의 흐름에서 처리하면서 오류의 성격이 섞일 수 있었습니다. 하지만 검증 책임과 예외 처리 책임을 분리한 뒤에는 각 실패 상황을 더 명확하게 표현할 수 있었고, 클라이언트와 서버 모두에서 오류를 더 예측 가능하게 처리할 수 있었습니다.
회고
이번 작업을 통해 validation이라는 이름으로 모든 실패를 묶는 것이 항상 적절하지는 않다는 점을 느꼈습니다.
요청 필드가 누락되었거나 타입이 잘못된 경우, RDF 규칙을 위반한 경우, 외부 API 호출이 실패한 경우는 모두 API 처리 과정에서 발생할 수 있는 실패입니다. 하지만 이 실패들의 원인과 대응 방식은 서로 달랐습니다.
요청 형식 오류와 RDF 논리 검증 오류는 클라이언트가 요청을 수정해야 하는 문제였습니다. 반면 외부 API 호출 실패는 외부 시스템 장애나 네트워크 문제일 수 있기 때문에 클라이언트 요청 오류로 처리하는 것은 적절하지 않았습니다.
따라서 FastAPI request validation과 RDF 논리 validation은 공통 validation handler에서 처리하고, 외부 API 호출 실패는 별도의 handler에서 처리하도록 구조를 나누었습니다.
이를 통해 에러 응답의 일관성을 유지하면서도, 실패 원인의 성격에 따라 적절한 응답을 내려줄 수 있었습니다. RDF 데이터처럼 정합성이 중요한 데이터를 다룰 때는 어떤 검증을 수행할지뿐만 아니라, 검증 실패와 시스템 실패를 어떻게 구분할지도 중요한 설계 요소라고 느꼈습니다.