
AI 에이전트의 ReAct 루프는 단순했다. 정말 어려웠던 것은 그 주변이었다
요약
LLM이 Office 파일을 직접 편집하는 AI 에이전트 개발 과정에서 LangGraph의 prebuilt 기능 대신 ReAct 루프를 직접 구현하게 된 배경을 다룹니다. 프레임워크 표준 기능만으로는 해결하기 어려운 스트리밍 제어와 에러 복구 등 예외 상황 대응의 중요성을 강조합니다.
핵심 포인트
- 단순 ReAct 루프보다 LLM의 비정상적 응답 처리가 구현의 핵심임
- 스트리밍 중 예외 발생 시 사용자 UI 중복 방지 로직 필요
- 모델/프로바이더별로 상이한 응답 형식을 통합 관리해야 함
- 재시도 가능한 에러와 불가능한 에러를 구분하는 정교한 설계 필요
1. 서론
현재 채팅(텍스트/음성)을 통해 LLM이 Word, Excel, PowerPoint 파일을 직접 생성 및 편집하는 AI 에이전트를 개발하고 있습니다. 주요 특징은 다음과 같습니다.
- Office 파일을 직접 편집함 (python-docx/openpyxl/python-pptx 기반의 도구군을 통해 LLM이 파일을 직접 읽고 쓰며, Office 자체를 백그라운드에서 조작하는 방식은 아닙니다)
- 로컬 LLM (Ollama)을 기본으로 하되, OpenAI/Gemini 등 복수의 프로바이더(Provider)에 대응
- 한 번의 요청으로 여러 단계(읽기 → 편집하기 → 확인하기 등)를 자율적으로 반복하는 ReAct 방식
단순히 문장만 쓰는 챗봇이 아니라, 표의 집계, 그래프 삽입, PowerPoint 레이아웃 깨짐 감지 및 수정까지 LLM이 도구(Tool)를 사용하여 자율적으로 수행할 수 있도록 하는 점이 특징입니다.

채팅창의 도구 호출(파일 목록 → Excel 읽기 2단계 → Python 실행 → 셀 쓰기)과 그에 연동되어 업데이트된 Excel 프리뷰가 나란히 보이는 화면
이 핵심에는 ReAct 루프 구현이 있으며, 처음에는 LangGraph의 prebuilt (create_react_agent와 같은 메커니즘)를 사용하여 프로토타입을 만들었습니다. 몇 줄의 코드로 동작하기 때문에 구성 면에서는 솔직히 합리적인 선택이었다고 생각합니다. 하지만 로컬 LLM을 포함한 복수의 프로바이더에 대응하여 구동해 보니, 프레임워크의 표준 기능만으로는 커버할 수 없는 상황이 계속해서 발생했습니다. 응답이 무음 상태로 멈추거나, 토큰을 중간까지 출력했는데 예외(Exception)가 발생하며 종료되거나, 도구 호출(Tool Call)의 이름이 깨진 형태로 전달되는 등——이러한 다루기 힘든 응답에 대한 대처가 prebuilt 외곽에 조금씩 쌓여갔습니다.
결국 ReAct 루프 자체를 직접 다시 작성하기로 했습니다. 본 기사에서는 그 판단에 이르게 된 경위와, 직접 구현하면서 실제로 부딪혔던 포인트들을 가능한 한 일반화할 수 있는 형태로 정리해 보겠습니다. 결론부터 말씀드리면, 루프의 골격 자체는 단순하며, 구현의 대부분은 "LLM의 예상치 못한 출력을 어떻게 다룰 것인가"에 시간을 할애하는 형태가 되었습니다. 제목에서 말하는 "그 주변"이란 구체적으로 4장의 스트리밍(Streaming) 제어와 5장의 에러 복구(Error Recovery)를 의미합니다.
전체 구현은 아래 리포지토리(Repository)에 공개되어 있습니다. 본 기사의 코드 예제는 이 리포지토리의 backend/app/agent/loop.py를 단순화 및 발췌한 것입니다 (실제 코드는 후술할 내용처럼 훨씬 길고 분기(Branch)도 많습니다).
2. 왜 prebuilt가 아니라 직접 만든 루프를 선택했는가
LangGraph의 prebuilt는 "LLM이 원활하게 도구를 호출하고 종료한다"는 통상적으로 예상되는 흐름을 최단 거리로 구축할 수 있도록 되어 있습니다. 반면, 그 외의——예를 들어 아래와 같은——케이스의 제어는 프레임워크의 표준 기능 외부에 자신만의 방식으로 덧붙이게 되기 쉽다고 느꼈습니다.
- 스트리밍 중에 부분적으로 토큰을 출력한 후, 도중에 예외가 발생했을 경우의 처리 (그대로 재시도(Retry)하면 사용자 화면에 응답이 중복으로 표시될 수 있음)
- 모델 및 프로바이더마다 다른 응답 형식의 흡수 (예: 문자열로 반환되는 것과 콘텐츠 블록(Content Block) 리스트로 반환되는 것이 혼재함)
- "재시도해야 할 에러"와 "재시도해도 소용없는 에러"의 구분
- 도구 실행 전후로 무엇이 변했는지를 UI에 순차적으로 통지하는 세밀한 이벤트 설계
이러한 제어 로직을 프레임워크 계층을 넘나들며 써 내려가는 것보다, 처음부터 순수한 루프로 작성하는 것이 가독성이 더 좋을 것이라고 판단했습니다. 어디까지나 기준이지만, 다음 중 하나에 해당한다면 직접 만든 루프를 검토해 볼 가치가 있을지도 모릅니다.
- 로컬 LLM/복수 프로바이더 대응이 필수적이며, 응답 형식이 안정적으로 돌아온다고 보장할 수 없는 경우
- 스트리밍 UI에 도구 실행이나 진행 상황을 세밀하게 이벤트로 전송해야 하는 경우
- 형식이 깨진 응답(무효한 도구 호출, 타입이 맞지 않는 인자 등)에 대한 복구 로직이 많은 경우
반대로 말하면, 대응 프로바이더가 1~2개로 한정되어 있고 프레임워크의 추상화를 전적으로 신뢰할 수 있다면 prebuilt로 충분한 케이스도 많을 것입니다 (이 점은 6장에서 다시 다루겠습니다).
3. 루프의 골격과 전체 구성
직접 만든 루프라고 하면 복잡하게 들릴 수도 있지만, 골격 자체는 매우 단순합니다.
for _step in range(MAX_AGENT_STEPS):
ai_msg = await call_llm_streaming(messages) # 스트리밍으로 LLM을 호출
messages.append(ai_msg)
...
"LLM을 호출한다 → 툴 호출이 있으면 실행하고 결과를 쌓는다 → 없으면 종료한다"를
MAX_AGENT_STEPS
회만큼 반복할 뿐입니다. 다만, 이 10줄의 골격이 그대로
구현되어 있는 것은 아닙니다. 5장에서 설명하듯이, 실제 loop.py는
시스템 프롬프트(System Prompt)의 문구를 제외하더라도 380줄 전후이며, 그 대부분은 "LLM을 호출한다"와 "툴을 실행한다" 사이에 끼어드는 복구 처리입니다. 골격이 단순한 것과 구현이 단순한 것은 별개의 문제라는 점을 미리 말씀드려 둡니다.
이 단순한 루프를 축으로, 실제 리포지토리(Repository)에서는 다음과 같은 파일 구성으로 되어 있습니다 (일부 발췌).
backend/app/
├── main.py WebSocket 엔드포인트(/ws). emit()으로 이벤트를 순차적으로 송신
├── agent/
...
호출 흐름을 그림으로 나타내면 다음과 같습니다. 이것은 실제 호출 관계를 나타내고 있으므로, Mermaid 플로우차트로 작성했습니다.
본 기사에서 다루는 것은 이 중의 agent/loop.py —— 특히 스트리밍 제어와 에러 복구 부분뿐입니다. 각 툴(word_tools.py 등)의 구현이나 프리뷰 생성(services/preview.py)은 분량 관계상 생략하지만, 리포지토리에는 포함되어 있습니다.
현 시점에서 backend/app 하위는 28개 파일, 약 4,700줄이며, Word/Excel/PowerPoint/파일 조작/변경 이력 등을 합친 툴 전체로 44종류가 있습니다 (그중 Word/Excel/PowerPoint용만 해도 30종류). 이 수치를 언급하는 이유는, "이만큼 조작 대상이 많기 때문에, 프레임워크의 얇은 추상화보다 직접 만든 루프가 나에게는 다루기 쉬웠다"라는 2장과 6장의 내용과 연결하고 싶었기 때문입니다. 본 기사에서 다루는 것은 어디까지나 이 중의 agent/loop.py라는 일부분이라는 전제도 함께 적어 둡니다.
순수 루프(Raw loop)라는 점의 부차적인 이점으로, 시스템 프롬프트의 구성을 자유롭게 조건 분기할 수 있다는 점도 있습니다. 예를 들어 이미지를 읽을 수 있는 모델일 때만 "작업 후에 이미지로 외관을 확인한다"라는 규칙을 추가하거나, 특정 의뢰(프레젠테이션 자료 작성 등)일 때만 전용 가이드라인을 추가하기도 합니다. 프레임워크의 프롬프트 템플릿(Prompt Template) 메커니즘을 거치지 않고, 솔직한 if문으로 작성할 수 있다는 점은 사소하지만 서서히 효과를 발휘하는 부분이라고 느끼고 있습니다.
4. 스트리밍 제어에서 막혔던 부분
타임아웃을 한 종류로 두지 않기
처음에는 "응답이 일정 시간 오지 않으면 중단한다"라는 단순한 타임아웃만 넣었지만, 이것으로는 부족했습니다. 로컬 LLM은 다음 두 가지 패턴에서 막힐 수 있습니다.
- 청크(Chunk) 자체가 오지 않고 무언으로 멈춤 (idle)
- 출력은 계속되고 있지만, 전체적으로 언제까지나 끝나지 않음 (step 전체가 너무 김)
이 두 가지는 감시하는 방식이 다르기 때문에, "다음 청크를 기다리는 타임아웃"과 "스텝 전체의 데드라인(Deadline)"을 별도로 갖게 했습니다.
deadline = now + LLM_STEP_TIMEOUT
while True:
remaining = deadline - now()
...
나아가, "부분적으로 토큰을 출력한 후에 예외가 발생한 경우에는 재시도(Retry)하지 않는다"라는 판단도 넣었습니다. 여기서 재시도하면 사용자 화면에 응답이 이중으로 흘러가 버리기 때문입니다.
💡 idle과 스텝 전체의 데드라인을 나누어 가질 것, 재시도 가능 여부를 "아직 아무것도 출력하지 않았는가"로 나눌 것. 이 두 가지는 스트리밍 UI를 가진 에이전트라면 대체로 어디서든 유용할 것이라고 생각합니다.
프로바이더(Provider)별 "흔히 발생하는 문제"
여러 프로바이더에 대응시키다 보면, 문서에 적혀 있지 않은 동작 차이를 자주 마주하게 됩니다. 대표적인 것을 두 가지만 들겠습니다.
- Ollama: 클라우드(Cloud) 버전은 일시적인 500 에러가 파도처럼 연속되며, 지수 백오프(Exponential Backoff)로 간격을 띄우지 않으면 복구되지 않습니다. 또한 무료 플랜에서 유료 전용 모델을 지정하면...
403 에러가 반환되며, 이는 재시도(Retry)를 해도 소용이 없으므로 사용자에게 직접 플랜 변경을 권장합니다. 또한, 툴 호출(Tool role) 메시지에는 이미지를 포함할 수 없기 때문에, 이미지는 HumanMessage로서 별도로 주입해야 했습니다.
Gemini 3계열: content가 단순한 문자열이 아니라, 텍스트 블록과 사고 블록(Thinking block, signature 포함)이 혼재된 리스트 형태로 전달됩니다. 이를 단순한 문자열로 변환하여 이력(History)에 쌓으면 최종 답변이 누락되거나, 여러 턴에 걸친 툴 호출(Tool calling)이 제대로 작동하지 않을 수 있기 때문에 리스트 형식 그대로 이력에 유지하고 있습니다.
이 외에도 프로바이더(Provider)별 차이를 흡수하는 처리가 더 있지만, 본 기사에서는 대표적인 사례만 소개했습니다. 문서를 읽는 것만으로는 알 수 없으며, 실제로 구동하며 에러를 마주하고 나서야 처음 깨닫게 된 것들이 대부분입니다. 여러 프로바이더를 지원하려면, 이러한 차이를 완전히 없애는 것은 어렵다는 전제하에 작업량을 산정하는 것이 좋지 않을까 생각합니다.
<think> 블록을 스트리밍 도중에 제거하기
추론 과정을 <think>...</think>로 감싸서 반환하는 모델이 있는데, 이를 그대로 화면에 흘려보내면 사용자에게는 불필요한 노이즈가 됩니다. 까다로운 점은 태그가 청크(Chunk)의 경계를 넘어 전달될 수 있다는 것인데, 단순한 문자열 치환으로는 이를 놓치게 됩니다.
그래서 상태(태그 안에 있는지 여부)와 버퍼(태그 경계 검출을 위해 끝부분 몇 글자만 유지)를 갖춘 작은 필터를 구현했습니다.
class ThinkFilter:
def __init__(self):
self._in_think = False
...
태그 경계 검출의 핵심은 "끝부분의 몇 글자가 시작/종료 태그의 접두사(Prefix)와 일치할 가능성이 있다면, 출력하지 않고 보류한다"는 점 하나입니다. <think>는 7글자이므로, 끝부분 최대 6글자만 매번 체크하면 충분하다는 것을 알 수 있습니다(태그 중에는 </think>의 8글자 분량을 유지). 이 판정 로직만 잡아두면 대상 태그가 바뀌더라도 동일한 구조로 작성할 수 있을 것입니다.
💡 상태(태그 안인지 밖인지)와 경계 검출용 버퍼. 이 두 가지만 갖추고 있다면 대부분의 스트리밍 제거 처리는 작성할 수 있을 것이라 생각합니다. <think> 이외의 구분자에도 그대로 유용할 것입니다.
5. 에러 복구와 툴 실행 주변의 설계 (가장 효과적이었던 부분)
직접 구현하기를 잘했다고 느끼는 부분이 가장 많은 곳이 바로 이 장입니다. 여기서 소개하는 것은 사고방식과 최소한의 코드이며, 실제 agent/loop.py는 시스템 프롬프트(System Prompt) 문구를 제외하고도 380행 전후이며, 그 대부분이 여기서 설명하는 분기(Branch)와 재시도(Retry) 구현에 할애되어 있습니다. "모델이 존재하지 않음", "플랜 제한", "일시적인 서버 에러", "인자 유효성 검사(Validation) 에러", "형식이 깨진 툴 호출" —— 각각에 대해 전용 판정 함수와 복구 방식이 있으며, 도착 지점이 다른 분기들이 한곳에 여러 갈래로 나열되어 있습니다. 마지막 절은 에러 복구 그 자체는 아니지만, 동일한 "프레임워크에 의존하지 않고 스스로 원하는 입도(Granularity)로 처리를 끼워 넣는다"라는 발상을 툴 실행과 UI 연동에 응용한 이야기입니다.
재시도해야 할 에러와, 하지 말아야 할 에러를 구분하기
LLM 호출 예외는 한데 묶어 "실패"로 취급하기 쉽지만, 실제로는 성격이 완전히 다릅니다.
- 모델이 존재하지 않음 (404) / 이용 플랜에서 허용되지 않음 (403)
→ 몇 번을 재시도해도 해결되지 않습니다. 즉시 사용자에게 "모델을 다시 선택해 주세요"라는 알기 쉬운 메시지를 보냅니다. - 일시적인 서버 에러 (5xx 등)
→ 지수 백오프(Exponential Backoff)를 적용하여 재시도합니다. 시도 횟수의 상한은 5회(LLM_MAX_ATTEMPTS), 백오프 간격은2 * 2^attempt초를 30초(LLM_RETRY_BACKOFF_CAP)로 제한하고 있습니다.
for attempt in range(LLM_MAX_ATTEMPTS): # 기본 5회
try:
... # LLM 호출
...
에러의 종류를 판별한 후에야 비로소 "재시도할지/말지", "사용자에게 무엇을 전달할지"를 결정할 수 있습니다. 이곳을 일률적인 try/except로 묶어서 처리해 버리면, 해결되지 않는 에러에도 계속 재시도하게 되어 사용자에게 불필요한 대기 시간을 강요하게 될 수 있습니다. emitted는 4장에서 설명한 "아직 아무것도 출력하지 않은"
「아직 아무것도 출력하지 않은」」의 플래그 그 자체입니다. 에러 종류의 판정과 이 플래그가 갖춰져야 비로소,
재시도 가능 여부를 안전하게 결정할 수 있습니다.
💡 「상한 있음」이라고만 정하지 말고, 가능한 한 구체적인 수치를 정해두는 것이
좋을 것 같습니다. 이 부분을 모호하게 둔 채 구현하면, 운영 환경에서 무한 재시도에
가까운 동작을 하고 있다는 사실을 알아차리기 어려워집니다.
에러 메시지도 「LLM을 위한 입력」입니다
도구의 인자 유효성 검사 (pydantic)에 실패했을 때, 그대로
영어 에러를 반환해도 LLM은 스스로 수정하기 어렵습니다. 그래서 에러 종류를
일본어의 구체적인 지시문으로 변환하여 반환하도록 했습니다.
_TYPE_HINTS = {
"bool": "true 또는 false",
"int": "정수",
...
에이전트에게 자기 수정을 시키는 설계에서는, 에러 메시지는 사용자용
UI 문구인 동시에, 다음 LLM 호출을 위한 입력 (프롬프트)이기도 합니다.
「인간에게 읽히게 할 것인가」 「모델에게 읽히게 할 것인가」를 의식하여 에러 문구를 설계해야
한다는 것이 여기서의 배움입니다. 타입명 → 일본어 지시문으로의 변환
테이블을 이 정도의 입도로 가지고 있는 것만으로도, 에이전트의 자기 수정 성공률은
체감상 상당히 달라진 것처럼 느껴집니다.
모델의 출력은 신뢰하지 않는다
로컬 LLM에서는 도구 호출(tool call) 안에 식별자로서 부적절한 이름
(예: "..."와 같이 형식이 성립되지 않은 문자열)이 정상적인 호출에
섞여서 전달되는 경우가 있었습니다. 이를 정규 표현식으로 필터링하여, 정상적인 호출만
남기는 가드(guard)를 넣고 있습니다.
_TOOL_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _drop_broken_tool_calls(tool_calls):
kept = [tc for tc in tool_calls if _TOOL_NAME_RE.match(tc.get("name") or "")]
...
프레임워크를 통하면 도구 호출의 타당성은 라이브러리 측의 구현에
맡겨지기 쉽지만, 자체 루프라면 「모델의 출력은 끝까지 신뢰하지 않는다」는
전제하에 가드를 원하는 위치에 끼워 넣을 수 있습니다. 다만 필터가 너무 강력하게 작동하여
호출 건수가 0건이 되면, 루프가 무반응 상태로 멈춰 원인을 파악하기
어려워지므로, 전멸했을 때는 원래 상태 그대로 반환하여 통상적인 에러 응답에 맡기도록 하고 있습니다.
도구 실행과 UI로의 이벤트 전송
도구 실행 전후로 tool_start / tool_end를 WebSocket으로 흘려보내,
채팅 UI에 「지금 무엇을 하고 있는지」를 실시간으로 표시하고 있습니다
(main.py의 WebSocket 핸들러가 전달하는 emit 콜백을, agent/loop.py 측에서 호출하는 형태입니다). 여기서 고안한 점은, 파일 변경
감지를 도구 측의 구현에 맡기지 않고, 실행 전후의 워크스페이스 상태를 통째로
스냅샷(snapshot)하여 차이(diff)를 구하는 방식으로 한 것입니다.
before = snapshot_workspace()
result = run_tool(name, args)
changed, deleted = diff_snapshots(before, snapshot_workspace())
...
이렇게 하면 새로운 도구를 추가할 때마다 「변경 통지를 발행한다」는 처리를
개별 도구에 작성할 필요가 없습니다. 도구의 구현 (agent/tools/*.py, 현재
10개 파일)과 변경 감지 로직 (services/workspace_watch.py)을 느슨한 결합 (loose coupling)으로
만들 수 있는 것은, 자체 루프이기 때문에 원하는 입도로 끼워 넣을 수 있는 설계라고 생각합니다. 부작용(side effect)의
통지 전반에 사용할 수 있는 발상이기에, 도구를 늘릴 때마다 발생하는 구현 비용도 작게 유지되고 있습니다.
6. 트레이드오프 · 맞지 않는 케이스
지금까지 기술한 회복 로직은 당연하게도 모두 직접 테스트하고 유지보수해야
합니다. 프레임워크가 담당하던 「번거롭지만 검증된 부분」을
직접 떠맡는다는 트레이드오프에 대해서는 솔직하게 적어두고자 합니다.
- 분기가 많은 그래프 구조나, 체크포인트 / 영속화(persistence), 인간의 개입
(human-in-the-loop)이 필요한 경우에는 LangGraph의 그래프 기능이
오히려 강력하며, 순수 루프보다 적합하다고 생각합니다.
- 대응 프로바이더가 1~2개로 좁혀지고, 프레임워크의 추상화를
전적으로 신뢰할 수 있다면, prebuilt(기구축된 기능)만으로도 충분한 경우가 많을 것입니다.
이번 판단은 "로컬 LLM을 포함한 여러 프로바이더(Provider)에 대응하고, 스트리밍 UI로 세밀하게 이벤트를 전달하며, 예상치 못한 응답에 대한 회복이 빈번하게 필요하다"라는 조건이 겹쳤기 때문에 내린 결정이며, 항상 자체 구현이 정답이라는 뜻은 아닙니다.
7. 요약
이 프로젝트를 만들며 가장 크게 느낀 점은, "ReAct 루프를 작성하는 것"보다
**"현실의 LLM은 생각보다 훨씬 더 예상치 못한 응답을 내놓는다"**는 것이었습니다.
본 기사 안에서도 막혔던 패턴은 적어도 다음 6가지 종류가 있었습니다.
- 청크(Chunk)가 더 이상 오지 않고 무언으로 멈춤 (idle)
- 출력은 계속되고 있지만, 끝이 나지 않음
- 부분 출력 도중에 예외(Exception)가 발생함
<think>태그가 청크 경계에 걸쳐서 전달됨- 도구 호출(Tool Call)의 이름이나 인자(Argument)가 깨진 형태로 전달됨
- 프로바이더마다 응답 형식이나 에러 코드의 의미가 다름
이것들은 단 하나도 "LLM이 얼마나 똑똑한가"와는 관계가 없으며, 모두 "LLM의 출력을 프로그램으로서 어떻게 안전하게 받아낼 것인가"라는, 미미하지만 피할 수 없는 계층의 이야기라고 느낍니다. 구현의 대부분은 에이전트를 똑똑하게 만드는 것이 아니라, 이 계층—예상치 못한 출력으로부터 어떻게 회복할 것인가—에 소비되고 있다는 것이 실감 납니다. 요점을 정리하면 다음과 같습니다.
- ReAct 루프의 골격 자체는 단순하며, 복잡성은 그 주변의 회복 처리(Recovery process)에 집중됩니다.
- 재시도(Retry) 가능 여부는 에러의 성격에 따라 구분하며, 에러 메시지는 "인간용"과 "LLM 입력용" 양쪽을 모두 의식하여 설계합니다.
- 변경 감지(Change detection)와 같은 크로스 커팅(Cross-cutting) 처리는 개별 도구에 부여하지 않고, 스냅샷 차이(Snapshot diff)와 같은 형태로 느슨한 결합(Loose coupling)을 유지할 수 있습니다.
- 반면, 그래프 구조가 복잡하거나 대응 프로바이더가 적은 경우에는 프레임워크의 prebuilt 기능만으로도 충분한 경우가 많습니다.
본 기사에서는 agent/loop.py의 스트리밍 제어와 에러 회복만을 다루었지만, 실제 리포지토리에는 Word, Excel, PowerPoint를 편집하는 도구군, 여러 LLM 프로바이더의 추상화, 변경 이력으로부터의 복원, 파일 미리보기 생성 등을 포함하여, 채팅에서 직접 Office 파일을 조작할 수 있는 하나의 AI 에이전트로 공개하고 있습니다.
구현 전체에 관심이 있다면, README의 설정 절차를 따라 Docker로 실행해 보거나, 설계를 따라가며 코드를 읽어주시면 감사하겠습니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기