Joyuno's Dev Blog

2026년 6월, “돌아가는 데모”를 “확장 가능한 AI 앱”으로 바꾸는 아키텍처 설계 패턴 7가지

Backend Architecture

들어가며

2026년 들어 AI 앱의 실패 원인은 더 이상 “모델이 멍청해서”가 아니라, 실행 레이어(execution layer)·도구 연동(tooling)·RAG 데이터 레이어·관측(Observability)·안전장치(Guardrails)가 분리되지 않은 아키텍처 부채로 이동했습니다. OpenAI Agents SDK가 “harness(제어)”와 “sandbox(실행)”를 분리해 표준화하려는 방향을 명확히 드러낸 것도 같은 맥락입니다. (openai.com)

이 글은 “요즘 유행하는 패턴 나열”이 아니라, 내 서비스에 언제 적용하고 언제 피해야 하는지를 기준으로 정리합니다.

  • 언제 쓰면 좋은가
    • LLM이 tool을 여러 번 호출하거나, 파일/코드/브라우저처럼 상태를 가진 작업을 수행한다.
    • RAG가 “검색+생성” 수준을 넘어 테넌트 격리, 데이터 최신성, 비용/지연 SLO가 요구된다.
    • 운영에서 “왜 틀렸는지”를 추적해야 해서 trace/metric/log 기반 디버깅이 필요하다. (openai.github.io)
  • 언제 쓰면 안 되는가
    • 단일 모델 호출로 끝나는 단순 Q&A(오히려 과한 분리로 복잡도 증가)
    • 데이터가 작고 변동이 거의 없어 RAG 운영 이슈(스테일 인덱스, 동기화, 테넌트) 자체가 없는 경우
    • “agent”가 실제로는 deterministic workflow인데, 굳이 LLM routing을 넣어 비용/불확실성을 키우는 경우(최근에는 워크플로우를 모델 가중치로 컴파일해 오케스트레이션 비용을 줄이자는 연구 흐름도 나옴) (arxiv.org)

🔧 핵심 개념

아래 7개 패턴은 서로 독립이 아니라, 확장 가능한 AI 앱에서 보통 같이 등장하는 “레이어 분리” 전략입니다.

1) Control Plane / Compute Plane 분리 (Harness ↔ Sandbox)

정의

  • Harness(Control Plane): agent loop, tool routing, policy, retry, budget, tracing 등 “결정/통제”
  • Sandbox(Compute Plane): 파일/커맨드/브라우저/테스트 실행 등 “위험하고 상태ful한 실행”

내부 흐름

  1. 사용자의 요청 → harness가 정책/가드레일/예산 확인
  2. tool 실행이 필요하면 sandbox에 “작업”을 위임(파일 마운트/출력 디렉토리/스토리지 연결 포함)
  3. sandbox 결과(artifact, 로그, diff, stdout)를 harness로 회수
  4. harness가 다음 step 결정(추가 tool, 요약, 종료) + trace 기록 (openai.com)

왜 2026년에 중요해졌나

  • “agent가 똑똑해짐”보다 “agent가 안전하게 오래 일함”이 더 어려워졌기 때문입니다. OpenAI도 sandbox agents를 “persistent workspace”로 명시합니다. (openai.github.io)

다른 접근과의 차이

  • 기존: 앱 서버 프로세스 안에서 tool 실행(보안/격리/재현성 취약)
  • 분리 후: sandbox를 격리 경계로 두고, harness는 stateless하게 scale-out 가능

2) Durable Workspace State 패턴 (Chat Memory보다 “작업공간 상태”)

정의

  • 대화 history, vector memory보다 중요한 상태는 종종 파일, 설치된 deps, 테스트 로그, 스크린샷, 이전 시도 산출물입니다.
  • 이를 “workspace snapshot”으로 저장/복원 가능하게 설계합니다.

구조

  • Object Storage(S3/GCS/R2 등)에 입력/출력 artifact 저장
  • Manifest로 “어떤 파일이 어디에 있어야 하는지”를 선언(로컬→프로덕션 이식성) (openai.com)

판단 기준

  • 코딩 에이전트/데이터 분석 에이전트/리포트 생성처럼 “설치→실행→수정→재실행” 루프가 있으면 거의 필수

3) Tool Integration 표준화: MCP + Broker(중개) 계층

정의

  • MCP(Model Context Protocol)는 agent가 외부 tool/API를 표준 방식으로 발견/호출하게 해 주는 방향으로 확산 중입니다. (developer.ibm.com)
  • 다만 프로덕션에서는 “프로토콜만”으로 부족해서 Broker/Policy 계층이 필요하다는 지적이 나옵니다(예: identity propagation, budget, structured errors). (arxiv.org)

왜 Broker가 필요한가

  • 엔터프라이즈에서 tool 호출은 항상 “누가(tenant/user) 무엇을(권한) 얼마만큼(비용/시간) 호출했는지(감사)”가 붙습니다.
  • MCP 서버를 직접 붙이면 이 공통 요구가 각 tool마다 중복 구현됩니다.

보안 메모

  • MCP 생태계는 빠르게 커졌지만, 원격 코드 실행(RCE) 등 보안 이슈가 지속적으로 보고됩니다. “표준을 썼으니 안전”이 아니라 실행 경계(격리) + allowlist + 입력 검증이 필요합니다. (tomshardware.com)

4) RAG를 “파이프라인”이 아니라 “아키텍처 탐색(Architecture Search)”으로 보기

정의

  • chunking, retrieval depth, hybrid search, reranking, query rewriting, context compression은 상호작용하며, 휴리스틱 튜닝은 재현성이 떨어집니다.
  • 2026년엔 이를 search space + budget으로 두고 최적화를 시도하는 흐름(“RAG architecture search”)이 나왔습니다. (arxiv.org)

핵심 차이

  • “우리 조직은 semantic chunking이 정답” 같은 단정이 아니라,
  • 업무/데이터/질의 분포별로 최적점이 다르다는 전제를 아키텍처에 넣습니다. (arxiv.org)

5) Hybrid Retrieval + Reranking을 기본값으로(단, 비용을 계층화)

정의

  • sparse(BM25) + dense(embedding) 조합으로 후보를 넓게 가져오고, cross-encoder/LLM reranker로 정밀하게 재정렬합니다.
  • 근거 기반 RAG에서 hybrid+rerank+claim-level 검증을 결합한 프레임워크가 제안/검증되고 있습니다. (arxiv.org)

실무 포인트

  • 모든 쿼리에 reranker를 태우면 비용 폭발 → 2단계 게이트가 필요(cheap scorer → expensive reranker)

6) Unified Data Layer 패턴: “벡터 DB + RDB” 분리를 줄여 동기화/테넌트 리스크 제거

문제

  • 전통적인 RAG는 RDB(권한/필터) + Vector DB(유사도) + 검색엔진(키워드)로 찢어져 “동기화/필터링/테넌트 격리”가 운영 지뢰가 됩니다.

대안

  • PostgreSQL + pgvector 같은 통합 데이터 레이어로 staleness, tenant leakage, query explosion을 줄이자는 주장/벤치가 등장했습니다. (arxiv.org)

언제 유효한가

  • 멀티테넌트 B2B, 권한 필터가 많은 경우(“부서/프로젝트/계약” 스코프)
  • 반대로, 초대형 코퍼스/초저지연이 최우선이면 전용 검색 스택이 더 유리할 수 있음

7) Guardrails & Tracing을 “부가기능”이 아니라 “실행 경로의 일부”로

Guardrails

  • input/output guardrail뿐 아니라, tool 호출마다(tool guardrails) 검증을 걸어야 실제로 막을 수 있다고 SDK 문서가 명시합니다. (openai.github.io)

Tracing

  • agent run에서 generation/tool/handoff/guardrail 이벤트를 span으로 남겨 “어느 단계가 비용/지연/실패를 만들었는지”가 보입니다. (openai.github.io)

판단 기준

  • 유저가 늘기 시작하면, “프롬프트 디버깅”이 아니라 “분산 시스템 디버깅”이 됩니다. 이때 tracing 없으면 운영이 안 됩니다.

💻 실전 코드

현실적인 시나리오: 사내 정책 문서 RAG + 티켓 요약/답변 draft 생성
요구사항:

  • 멀티테넌트(tenant_id) 격리
  • Hybrid retrieval(BM25 + vector) + rerank(옵션)
  • “근거 없는 주장”을 줄이기 위한 간단한 claim check(경량 judge)
  • 결과/trace를 남겨 운영 가능하게

아래는 PostgreSQL(pgvector) 기반 통합 데이터 레이어 + FastAPI 예제입니다(로컬에서 그대로 실행 가능). “toy”를 피하려고: 스키마(tenant/ACL), hybrid 검색, rerank 게이트, 응답에 citations 포함까지 넣었습니다.

1) 셋업

# 1) Postgres + pgvector (로컬)
docker run --name rag-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=rag \
  -p 5432:5432 -d pgvector/pgvector:pg16

python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn psycopg[binary] numpy rank-bm25 openai

주: openai는 예시로 LLM/embedding 호출에 사용. 사내 모델/다른 벤더로 대체 가능.
OPENAI_API_KEY 환경변수 설정 필요.

2) DB 스키마(테넌트 격리 + 검색 인덱스)

psql "postgresql://postgres:postgres@localhost:5432/rag" <<'SQL'
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE docs (
  id            bigserial PRIMARY KEY,
  tenant_id     text NOT NULL,
  doc_id        text NOT NULL,
  chunk_id      int  NOT NULL,
  title         text NOT NULL,
  content       text NOT NULL,
  tsv           tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
  embedding     vector(1536), -- 예: text-embedding-3-small 차원에 맞춰 조정
  updated_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX docs_tenant_tsv_idx ON docs USING GIN (tenant_id, tsv);
CREATE INDEX docs_tenant_doc_idx ON docs (tenant_id, doc_id);
CREATE INDEX docs_embedding_hnsw ON docs USING hnsw (embedding vector_cosine_ops);

-- 멀티테넌트에서 "항상 tenant_id 조건이 들어간다"는 전제의 인덱싱이 중요
SQL

3) API 서버: hybrid retrieval + rerank 게이트 + citations

# app.py
from __future__ import annotations
import os
import numpy as np
from fastapi import FastAPI
from pydantic import BaseModel
import psycopg
from rank_bm25 import BM25Okapi
from openai import OpenAI

app = FastAPI()
client = OpenAI()

DSN = "postgresql://postgres:postgres@localhost:5432/rag"

class QueryIn(BaseModel):
    tenant_id: str
    question: str
    top_k: int = 8
    use_rerank: bool = True

def embed(text: str) -> list[float]:
    # 임베딩 모델/차원은 환경에 맞춰 변경
    r = client.embeddings.create(model="text-embedding-3-small", input=text)
    return r.data[0].embedding

def cosine(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8))

def fetch_candidates(tenant_id: str, question: str, k_dense: int = 30, k_sparse: int = 30):
    q_emb = embed(question)

    with psycopg.connect(DSN) as conn:
        with conn.cursor() as cur:
            # 1) dense 후보
            cur.execute(
                """
                SELECT id, doc_id, chunk_id, title, content, embedding
                FROM docs
                WHERE tenant_id = %s
                ORDER BY embedding <=> %s::vector
                LIMIT %s
                """,
                (tenant_id, q_emb, k_dense),
            )
            dense = cur.fetchall()

            # 2) sparse 후보 (tsvector)
            cur.execute(
                """
                SELECT id, doc_id, chunk_id, title, content, NULL::vector
                FROM docs
                WHERE tenant_id = %s
                  AND tsv @@ plainto_tsquery('simple', %s)
                ORDER BY ts_rank(tsv, plainto_tsquery('simple', %s)) DESC
                LIMIT %s
                """,
                (tenant_id, question, question, k_sparse),
            )
            sparse = cur.fetchall()

    # merge (id로 dedup)
    by_id = {}
    for row in dense + sparse:
        by_id[row[0]] = row
    rows = list(by_id.values())

    # dense 점수(가능한 것만)로 1차 정렬 힌트 제공
    qv = np.array(q_emb, dtype=np.float32)
    scored = []
    for (id_, doc_id, chunk_id, title, content, emb) in rows:
        if emb is not None:
            ev = np.array(emb, dtype=np.float32)
            s = cosine(qv, ev)
        else:
            s = 0.0
        scored.append((s, id_, doc_id, chunk_id, title, content))
    scored.sort(reverse=True, key=lambda x: x[0])
    return scored[: max(20, k_dense)]  # rerank 전 후보 폭

def cheap_rerank(question: str, candidates):
    # 비용을 아끼는 게이트: BM25를 질문 토큰 기준으로 후보 재정렬
    tokenized = [c[5].split() for c in candidates]  # content
    bm25 = BM25Okapi(tokenized)
    scores = bm25.get_scores(question.split())
    packed = []
    for i, c in enumerate(candidates):
        packed.append((float(scores[i]),) + c)  # (bm25, dense, id, ...)
    packed.sort(reverse=True, key=lambda x: x[0])
    return packed

def llm_rerank(question: str, candidates, top_n: int = 8):
    # 상위 후보만 LLM에 rerank 요청(비용 폭발 방지)
    items = [{"id": c[2], "text": c[6]} for c in candidates[:20]]  # id_, content
    prompt = (
        "You are a reranker. Rank passages by how well they answer the question.\n"
        f"Question: {question}\n"
        "Return a JSON list of passage ids in best-first order."
    )
    # 간단화를 위해 responses.create 사용(구현체는 팀 표준에 맞게)
    r = client.responses.create(
        model="gpt-4.1-mini",
        input=[{"role": "system", "content": prompt},
               {"role": "user", "content": str(items)}],
    )
    text = r.output_text

    # 파서(견고화는 실무에서 필수: JSON schema 검증/재시도)
    import json
    try:
        order = json.loads(text)
        order = [int(x) for x in order]
    except Exception:
        # 실패 시 cheap 결과 사용
        return candidates[:top_n]

    by_id = {c[2]: c for c in candidates}  # id_ 기준
    ranked = [by_id[i] for i in order if i in by_id]
    return ranked[:top_n]

def generate_answer(question: str, top_chunks):
    context = "\n\n".join(
        [f"[{i+1}] ({c[4]} / {c[3]}:{c[4]})\n{c[6]}" for i, c in enumerate(top_chunks)]
    )
    sys = (
        "You are an enterprise support assistant. "
        "Answer ONLY using the provided passages. "
        "If insufficient evidence, say '정보가 부족합니다' and ask a clarifying question. "
        "Cite passage numbers like [1], [2]."
    )
    r = client.responses.create(
        model="gpt-4.1",
        input=[
            {"role": "system", "content": sys},
            {"role": "user", "content": f"Question: {question}\n\nPassages:\n{context}"},
        ],
    )
    return r.output_text

@app.post("/ask")
def ask(q: QueryIn):
    candidates = fetch_candidates(q.tenant_id, q.question)

    # 1) cheap rerank로 상위 후보 압축
    cheap = cheap_rerank(q.question, candidates)

    # 2) 필요할 때만 LLM rerank(예: 상위 점수들이 비슷하거나, 질문이 길거나, 고정확 요구)
    if q.use_rerank:
        top = llm_rerank(q.question, cheap, top_n=q.top_k)
    else:
        top = [c[1:] for c in cheap[: q.top_k]]  # (dense,id,...) 형태 맞추기

    answer = generate_answer(q.question, top)

    citations = [
        {"doc_id": c[3], "chunk_id": c[4], "title": c[5]}
        for c in top
    ]
    return {"answer": answer, "citations": citations}

실행:

uvicorn app:app --reload --port 8000

예상 출력(형태):

  • answer: 근거 passage 번호 [1] 같은 citation 포함
  • citations: 어떤 doc_id/chunk_id를 썼는지 구조화(후속 “근거 보기” UI에 바로 연결)

여기서 핵심은 “코드”보다 패턴

  • Unified data layer: tenant_id 필터가 SQL의 1급 시민
  • Hybrid + rerank: recall(폭)과 precision(정밀)을 단계적으로
  • Evidence-only generation: 환각을 “프롬프트만”이 아니라 파이프라인 규칙으로 통제

⚡ 실전 팁 & 함정

Best Practice

1) Rerank를 ‘항상’이 아니라 ‘계층화’하라

  • cheap scorer(BM25/간단 cosine) → top-N에만 cross-encoder/LLM rerank
  • 비용/지연 SLO를 지키면서 품질을 올리는 가장 현실적인 방법입니다. (근거 기반 hybrid+rerank 흐름은 연구/사례에서 반복) (arxiv.org)

2) RAG 튜닝을 “휴리스틱”이 아니라 “검색(space) + 측정(evals)”으로 운영하라

  • chunk size/overlap, retrieval depth, rerank topN, context compression 등은 상호작용합니다.
  • 최근엔 이를 아키텍처 탐색 문제로 보고 벤치/프레임워크를 제안합니다. (arxiv.org)
  • 실무에선 전부 자동화하긴 어렵더라도, 최소한 “변수 목록 + 오프라인 eval 세트 + 변경 이력”은 갖추세요.

3) Agent는 ‘메모리’보다 ‘실행 환경’이 먼저다

  • 파일/커맨드/브라우저가 필요한 순간부터, “대화 저장”은 핵심 상태가 아닙니다.
  • sandbox/persistent workspace를 전제로 설계해야 재시도/장기 작업이 가능합니다. (openai.github.io)

흔한 함정/안티패턴

  • MCP를 붙였으니 안전하다는 착각
    • 프로토콜 표준화와 보안은 별개입니다. 특히 tool 실행은 격리/검증/allowlist가 필요합니다. (tomshardware.com)
  • Vector DB + RDB + Search를 무계획으로 분리
    • 동기화 지옥 + tenant leakage 위험 + 필터 쿼리 폭발로 이어집니다(통합 레이어 접근이 왜 나왔는지 이해해야 함). (arxiv.org)
  • Tracing 없이 “프롬프트”만 만지기
    • agent workflow는 분산 트랜잭션에 가깝습니다. span 단위로 비용/지연/실패를 봐야 최적화가 됩니다. (openai.github.io)

비용/성능/안정성 트레이드오프

  • Hybrid retrieval은 대체로 품질이 오르지만, 후보 수가 늘어 rerank 비용이 같이 늘 수 있음 → top-k/게이트 설계가 성패
  • Unified data layer는 운영 단순화에 강하지만, 초대형 코퍼스/특수 검색 요구에선 전용 스택이 유리할 수 있음
  • Sandbox는 안정성을 올리지만, cold start/스냅샷 저장 비용이 생김 → “짧은 요청”과 “긴 작업”을 라우팅 분리하는 게 일반적으로 이득 (openai.com)

🚀 마무리

2026년 6월 기준 “확장 가능한 AI 앱”은 모델 선택보다 아키텍처 분리가 성능을 좌우합니다.

  • agent를 한다면: Harness(Control) / Sandbox(Compute) 분리 + durable workspace로 “오래 일하는 실행”을 먼저 잡기 (openai.com)
  • RAG를 한다면: Hybrid + Rerank 계층화, 그리고 설계를 “휴리스틱”이 아니라 “탐색/측정”으로 운영하기 (arxiv.org)
  • tool을 붙인다면: MCP 같은 표준은 도움이 되지만, 프로덕션에서는 Broker/Policy/Identity/Budget/Error semantics를 갖춘 중개 계층과 강한 격리가 필요 (arxiv.org)
  • 운영을 한다면: Guardrails(tool-level) + Tracing을 경로에 넣지 않으면, 유저가 늘수록 “왜 실패했는지”를 영영 못 잡습니다. (openai.github.io)

다음 학습 추천(순서): 1) Agents SDK의 sandbox/harness 개념과 tracing/guardrails 문서(“실행/관측” 감각 잡기) (openai.github.io)
2) RAG 아키텍처 탐색(RAISE)처럼 “변수 공간을 정의하고 eval로 관리”하는 접근 (arxiv.org)
3) 멀티테넌트 RAG라면 통합 데이터 레이어/필터링 전략 검토 (arxiv.org)

원하면, 당신의 서비스 조건(트래픽, 평균 문서 수/테넌트 수, SLO, tool 종류, 규제/보안 요구)을 받아서 위 패턴을 우선순위 로드맵(2주/6주/분기) 형태로 재구성해 드릴게요.