포스트

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

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

들어가며

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
2
3
4
5
6
# 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 스키마(테넌트 격리 + 검색 인덱스)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# 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}

실행:

1
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주/분기) 형태로 재구성해 드릴게요.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.