Posts

Posts

17 posts

AIDevelop

Hermes로 만든 AI 웨딩 플래너 김실장: 생각보다 너무 좋다

2026.08.12

요약: 김실장은 결혼 준비를 대신 결정하는 AI가 아니다. 두 사람이 놓치기 쉬운 일정·예산·업체·결정 이유를 같은 상태로 보게 만드는, Hermes 기반의 개인용 운영 시스템이다. 결혼 준비를 시작하면 정보는 넘친다. 검색하면 후기와 견적이 쏟아지고, 웨딩 앱에는 체크리스트가 있고, 상담을 다녀오면 카카오톡과 사진첩에도 기록이 쌓인다. 문제는 정보량이 아니라 상태가 흩어진다는 것이었다. 어떤 업체를 검토 중인지, 계약 전에 무엇을 확인해야 하는지, 비용을 넣으면 전체 예산이 어떻게 달라지는지, 일정 전에 준비할 것은 무엇인지. 둘이 이야기를 나눌 때는 분명히 알고 있었는데 며칠만 지나면 다시 처음부터 찾아보게 됐다. 그래서 두 사람이 함께 쓰는 AI 웨딩 플래너 김실장을 만들었다. Hermes와 대화하는 창이 먼저 보이지만, 실제 중심은 채팅이 아니다. 중요한 건 대화가 지나간 뒤에도 남는 기록과 상태다. 생성형 이미지로 표현한 김실장의 사용 장면. 실제 사람·일정·업체 화면은 포함하지 않았다. 처음에는 “드레스샵 비교해줘”, “이번 주에 무엇을 해야 하지?”처럼 질문에 답하는 AI로 시작했다. 그런데 결혼 준비처럼 기간이 길고 이해관계가 많은 일은 답변을 잘 받는 것만으로 해결되지 않았다. 한 가지 선택에도 같이 남겨야 할 것이 많다. - 지금은 제안인지, 확인 중인지, 확정인지 - 누가 어떤 이유로 이 선택을 꺼냈는지 - 다음으로 확인할 조건은 무엇인지 - 연결된 예산과 일정은 무엇인지 - 나중에 다시 봐야 할 원문은 어디에 있는지 김실장은 이 정보를 가능한 한 같은 흐름으로 다룬다. Hermes는 대화를 바탕으로 자료와 확인할 조건을 정리하고, 다음 행동을 제안하는 인터페이스다. 하지만 결정 자체를 채팅 기록 속에 묻어두지는 않는다. 결혼 준비를 대화 문제가 아니라 공유해야 하는 상태의 문제로 보기 시작한 뒤부터 구조가 훨씬 단단해졌다. 김실장의 기준 문서는 로컬 Markdown이다. 체크리스트, 예산, 업체 비교, 의사결정 기록, 상담 메모처럼 길게 맥락을 남겨야 하는 정보는 파일로 관리한다. 특정 서비스의 화면이나 한 번의 대화에만 의존하지 않기 위해서다. 이 방식이 좋은 이유는 단순하다. - 사람이 읽기 쉽다. - 변경 이유를 문장으로 남길 수 있다. - 필요한 도구를 바꿔도 원본 기록은 남는다. - Hermes도 같은 문서를 읽고, 이미 정리된 맥락을 바탕으로 다음 작업을 이어갈 수 있다. 특히 의사결정 기록은 “무엇을 골랐는가”보다 “왜 그렇게 골랐는가”를 남기는 데 유용하다. 시간이 지나면 최종 선택은 기억나도 비교했던 후보와 망설였던 조건은 사라진다. 그래서 제안, 확인, 확정 같은 상태를 구분하고 결정 이유와 후속 확인 항목을 같이 적는다. Markdown이 원본 기록이라면, Notion은 현재 진행 중인 일을 연결해서 보는 운영 레이어다. 김실장에서는 업체·할 일·예산·상담 메모를 관계형 데이터로 관리한다. 업체를 볼 때 연결된 확인 항목·비용·상담 기록·다음 행동을 함께 볼 수 있도록 운영한다. 아직 특정 업체로 분류하지 않은 항목은 별도 분류로 관리한다. 여기서 중요한 건 같은 내용을 모두 한곳에 몰아넣지 않는 것이다. 비교 근거와 긴 메모는 Markdown에 남기고, 현재 운영에 필요한 관계와 상태는 Notion에서 본다. 입력이 겹치지 않도록 역할을 나누는 원칙으로 운영한다. 로컬 원본 기록, 관계형 운영 데이터, 일정, 계산 자동화가 역할별로 나뉘는 모습을 개념적으로 표현했다. 이 그림은 자동 동기화 흐름을 뜻하지 않는다. 전체 구조는 다음처럼 역할을 나눠 둔 상태에 가깝다. 둘의 대화·상담 자료·조사 결과 ├─ Hermes: 자료·확인 항목 정리와 다음 행동 제안 ├─ 로컬 Markdown: 긴 기록과 결정 근거 ├─ Notion 관계형 운영 DB: 관계와 상태 관리 └─ Google Calendar: 시간 제약이 있는 일정 관리 규칙 기반 자동화: Notion 예비비 계산·갱신 레이어 사이의 반영이 모두 자동으로 이뤄진다는 뜻은 아니다. 현재 확인된 규칙 기반 자동화는 Notion 예비비 계산이며, 그 밖의 레이어 간 반영을 모두 자동화로 단정하지 않는다. 가능한 한 각 레이어의 역할을 나누는 게 핵심이다. Markdown은 기억과 근거를 보존하고, Notion은 관계를 보여주고, Calendar는 시간 제약이 있는 일을 실행 가능한 일정으로 만든다. “상담 전 확인할 조건 정리하기”는 할 일이다. 반면 “특정 시각의 상담”이나 “가봉 일정”은 시간이 묶인 실행 항목이다. 둘을 같은 목록에 쌓아두면 중요한 시간 약속이 체크리스트에 파묻힌다. 그래서 시간 제약이 있는 항목은 전용 Google Calendar에 분리해 관리한다. 등록한 일정은 재조회로 반영 여부를 확인하는 방식으로 운영해 왔다. 아직 정확한 소요 시간이 확인되지 않은 일정은 필요에 따라 임시 블록으로 잡고, 상담이나 안내문을 확인한 뒤 수정한다. 이렇게 하면 일정도 “등록했으니 아마 됐겠지”가 아니라, 확인 가능한 운영 상태가 된다. 결혼 준비에서 예산은 자주 바뀐다. 어떤 항목은 계약하면서 금액이 달라지고, 어떤 비용은 아직 미확정이고, 비교안은 남겨두되 합계에는 중복으로 넣으면 안 된다. 김실장에는 updatenotioncontingency.py라는 작은 자동화 스크립트가 있다. 이 스크립트는 Notion의 계획 예산을 읽어 정해 둔 계산 규칙에 따라 예비비를 다시 계산한다. 흐름은 복잡하지 않다. 1. Notion의 계획 예산을 읽는다. 2. 예식장 분야에 연결된 항목과 예비비 자체는 합계에서 뺀다. 3. 목표 예산과의 차이로 남은 예비비를 계산한다. 4. 예산이 목표를 넘으면 예비비를 0으로 두고 초과 상태를 기록한다. 5. 값이 달라졌거나 초과 상태일 때 예비비 항목의 금액과 비고를 갱신한다. 이 스크립트는 별도 Hermes 스케줄 작업으로 현재 30분마다 실행된다. 여기서 자동화가 하는 일은 결정을 내리는 것이 아니라, 이미 입력한 계획이 지금 어떤 결과를 만드는지 투명하게 보여주는 것이다. 예를 들어 새로운 견적을 반영했을 때 “이걸 진행해도 되는가”를 AI가 답으로 확정하는 게 아니다. 대신 예산 상태가 어떻게 변했는지, 무엇을 다시 봐야 하는지 빠르게 드러낸다. 자동화는 계산과 확인을 맡고, 계약·결제·취향처럼 중요한 결정은 두 사람이 검토하는 경계를 표현한 이미지다. 운영하며 Hermes에 맡기는 일은 정리와 추적이다. - 상담 내용을 바탕으로 확인할 조건 정리하기 - 업체·할 일·예산·일정의 연결 관계 보기 - 미확정 상태를 구분해 보기 - 반복 계산을 같은 규칙으로 처리하기 - 이전 결정의 근거를 다시 찾기 반대로 자동으로 확정하지 않는 일도 명확히 둔다. - 계약과 결제 - 취소·환불·변경 조건의 최종 확인 - 예산 초과를 받아들일지 여부 - 둘의 취향이 갈리는 선택 - 개인정보가 들어가는 외부 공유 특히 계약 조건이나 실제 금액은 대화 요약이 아니라 안내문·견적서·계약서처럼 원문으로 다시 확인한다. AI가 정리한 내용은 다음 질문을 잘 만들기 위한 보조 자료이며, 계약 판단의 근거로 단독 사용하지 않는다. 이 경계를 두니까 오히려 편하게 쓸 수 있었다. AI에게 일을 많이 시키는 것보다, 잘못하면 곤란한 일을 자동으로 넘기지 않는 편이 훨씬 중요했다. 김실장을 혼자 썼다면 아마 괜찮은 개인 정리 도구 정도였을 것이다. 두 사람이 함께 쓰면서 더 좋아졌다. 한 사람이 모든 정보를 머릿속에 들고 있는 구조가 줄었다. 누가 마지막으로 어디까지 알아봤는지, 왜 그 선택을 보류했는지, 다음 상담 전에 무엇을 물어봐야 하는지 같은 정보가 특정 사람의 기억에만 남지 않는다. 그리고 두 사람이 대화할 때도 조금 달라졌다. “이거 할까?”에서 끝나는 대신, 현재 예산·남은 확인 항목·일정상 마감·이전 결정의 맥락을 같이 보고 이야기하게 된다. AI가 결혼 준비를 대신해줘서 좋은 건 아니다. 우리가 같은 상태를 보고, 빠뜨리지 않고 같이 준비할 수 있게 해줘서 좋다. 김실장은 웨딩 앱을 대체하려고 만든 서비스가 아니다. 두 사람이 진행하는 큰 프로젝트를 대화와 캡처에 흩어두지 않고, 기록·관계·일정·계산으로 나눠 운영해보려는 실험에 가깝다. 결혼 준비가 끝난 뒤에도 이 구조는 남을 것 같다. 집을 구하거나, 여행을 계획하거나, 큰 지출을 관리할 때도 결국 비슷한 문제가 생긴다. 정보는 흩어지고, 결정은 쌓이고, 일정과 예산은 연결되고, 누군가는 전체 맥락을 기억해야 한다. 김실장이 지금 해주는 가장 좋은 일은 답을 대신 내리는 게 아니다. 우리가 결정하기 전에 필요한 맥락을 다시 꺼내주고, 결정한 뒤에는 이유와 다음 행동을 잃어버리지 않게 해주는 일이다.

DevelopInfra

아니, 요즘도 SQL 인젝션이 있네: 실서비스 공격 대응기

2026.07.31

내가 직접 개발하고 운영하는 웹 서비스에서 겪은 공격 대응 기록이다. 서비스 이름과 업종, 도메인, 저장소, 고객 정보처럼 신원을 추정할 수 있는 내용은 생략하거나 일반화했다. 공격 문자열도 패턴을 알아보는 데 필요한 형태만 남기고 축약했다. 관리 화면에서 평소와 다른 데이터가 눈에 들어왔다. 사용자가 이름을 입력해야 하는 칸에 정상적인 이름과 함께 이런 문자열이 연달아 저장돼 있었다. CASE WHEN (...) THEN ... ELSE ... END SELECT ... FROM DUAL UNION SELECT ... JSON(CHAR(...)) ' OR '1'='1' -- 한두 건이 아니었다. 같은 연락처를 사용한 요청이 짧은 시간 동안 반복됐고, 데이터베이스 종류를 가리지 않고 반응을 확인하려는 문법이 섞여 있었다. Oracle, MySQL 등 여러 환경을 차례로 찔러보는 자동화 도구의 전형적인 탐색 패턴에 가까웠다. 화면을 보자마자 든 생각은 솔직히 이것이었다. “아니, 요즘도 SQL 인젝션이 있네.” SQL 인젝션은 워낙 오래된 공격이라 교과서에서나 보는 문제처럼 느껴질 때가 있다. ORM과 SDK를 쓰고 서버리스 환경에 배포한 뒤 관리형 데이터베이스를 연결하면 자연스럽게 안전할 것 같기도 하다. 하지만 공격자는 서비스의 기술 수준부터 평가하지 않는다. 입력할 수 있는 폼이 보이면 일단 찔러본다. 오래된 공격이 사라진 게 아니라 자동화되면서 더 싸고 흔해졌다. 이번 탐색 공격의 대상은 연습용 프로젝트가 아니었다. 내가 직접 개발했고 지금도 실제 사용자가 쓰는 운영 서비스였다. 화면, 서버 로직, 데이터베이스, 배포 환경을 모두 관리하고 있었기에 발견부터 차단, 코드 수정, 데이터 정리, 운영 검증까지 직접 해야 했다. SQL처럼 생긴 문자열이 데이터베이스에 들어갔다고 해서 곧바로 SQL 인젝션에 성공한 것은 아니다. 둘은 구분해야 한다. 1. 공격자가 SQL 문법을 입력했다. 2. 그 문자열이 데이터베이스에 일반 문자열로 저장됐다. 3. 애플리케이션이 그 값을 SQL 구문의 일부로 실행했다. 실제 SQL 인젝션은 세 번째 단계에서 발생한다. 예를 들어 서버가 입력값을 문자열로 이어 붙여 raw SQL을 만든다면 위험하다. // 하면 안 되는 예 const query = INSERT INTO requests (name, phone) VALUES ('${name}', '${phone}') 반면 파라미터 바인딩을 사용하는 SDK나 안전하게 구현된 RPC를 거쳤다면 공격 문장은 실행 코드가 아니라 데이터로 저장될 가능성이 높다. // 개념적인 안전한 예 await database .from("requests") .insert({ name, phone }) 내 서비스도 관리형 데이터베이스 SDK를 사용하고 있었다. 조사 결과, 공격 문자열이 SQL로 실행됐다는 증거는 찾지 못했다. 데이터 유출이나 임의 변경 정황도 확인되지 않았다. 하지만 이것으로 안심할 수는 없었다. - 공격 문자열이 서버 입력 검증을 통과했다. - 비정상 요청이 짧은 시간 동안 대량 저장됐다. - 운영 관리 화면에 악성 문자열이 그대로 노출됐다. - 폼에서 데이터베이스까지 이어지는 경계가 충분히 단단하지 않았다. - 관리자용 API의 인증 범위도 함께 점검할 필요가 있었다. 이번 사건은 “DB가 뚫렸다”가 아니라 공개 입력 경계가 공격자에게 너무 관대했다는 문제였다. 눈에 띈 몇 건만 보고 판단하지 않았다. 추측 대신 운영 데이터를 기준으로 범위를 확인했다. 확인 결과 악성 패턴이 포함된 요청은 총 165건이었다. 반복 요청에는 다음과 같은 특징이 있었다. - 동일한 연락처가 반복 사용됐다. - 이름처럼 짧아야 하는 필드에 긴 문자열이 들어갔다. - 조건문, 따옴표, 주석, UNION SELECT 계열 문법이 섞여 있었다. - 여러 데이터베이스에서 통할 법한 문법을 바꿔가며 시도했다. - 정상적인 서비스 이용 목적과 관계없는 자동화 탐색이었다. 여기서 원칙을 하나 세웠다. 공격 데이터를 먼저 지우고 끝내지 않는다. 삭제부터 하면 공격 규모와 패턴을 확인할 근거가 사라진다. 우선 어떤 입력 경로로 들어왔는지, 몇 건인지, 실제 SQL 실행 가능성이 있었는지, 다른 API까지 노출됐는지를 조사했다. 필요한 사실을 확인한 뒤에야 악성 레코드를 제거했다. 민감한 운영 데이터는 로컬에 오래 남기지 않았다. 조사 과정에서 만든 임시 로그와 백업은 검증이 끝난 뒤 삭제했다. 공격 대응 자료가 새로운 개인정보 유출 경로가 되어서는 안 되기 때문이다. 클라이언트 폼에는 이미 입력 제한이 있었다. 하지만 브라우저의 maxlength, 입력 타입, JavaScript 검증은 보안 경계가 아니다. 공격자는 화면을 거치지 않고 API를 직접 호출할 수 있다. 실제 저장 직전에 서버가 입력을 다시 검사하도록 바꿨다. 검증 대상은 대략 다음과 같았다. - 필수 필드 존재 여부 - 문자열 타입 여부 - 앞뒤 공백 정리 - 이름과 제목의 최대 길이 - 전화번호 형식과 정규화 - 허용되지 않은 제어 문자 - 선택값이 서버의 허용 목록에 포함되는지 - 예상하지 못한 추가 필드 처리 개념적으로는 다음과 같은 구조다. function validateSubmission(input: unknown) { if (!input || typeof input !== "object") { throw new Error("Invalid request") } const name = normalizeText(input.name) const phone = normalizePhone(input.phone) if (name.length < 2 || name.length MAXNAMELENGTH) { throw new Error("Invalid name") } if (!PHONEPATTERN.test(phone)) { throw new Error("Invalid phone") } return { name, phone } } 여기서 특정 SQL 키워드를 블랙리스트로 막는 방식은 사용하지 않았다. // 좋은 방어가 아니다 if (name.includes("SELECT") || name.includes("UNION")) { throw new Error("Blocked") } 이런 필터는 쉽게 우회된다. 정상 입력에도 해당 단어가 포함될 수 있고, 대소문자·주석·인코딩·문자 분할로 변형할 방법도 많다. SQL 인젝션 방어의 중심은 입력값을 SQL 코드와 분리하는 것이다. 길이와 형식 검증은 비정상 데이터를 줄이는 보조 방어이고, 실제 쿼리는 파라미터 바인딩이나 제한된 RPC를 사용해야 한다. 공개 폼만 보고 끝내지 않고 관리자 API도 전부 다시 조사했다. 이 과정에서 일부 데이터 조회 경로의 인증 경계가 기대보다 약하다는 사실을 확인했다. 운영 서비스에서는 입력 공격과 데이터 노출을 따로 생각하면 안 된다. 공격자가 폼으로 악성 데이터를 넣는 것보다, 인증 없이 관리자 데이터를 읽을 수 있는 상황이 훨씬 심각할 수 있다. 조치 후 관리자용 API는 서버에서 세션을 확인한 다음에만 데이터를 반환하도록 통일했다. export async function GET(request: Request) { const session = await requireAdminSession(request) if (!session) { return new Response("Unauthorized", { status: 401 }) } return loadProtectedData() } 배포 후에는 코드만 보고 끝내지 않았다. 실제 운영 주소에 인증 없이 요청을 보내 401 Unauthorized가 반환되는지 확인했다. 보안 변경은 “구현했다”보다 운영 경계에서 정말 거부되는가가 중요하다. 입력 검증만으로는 자동화 요청의 속도를 낮추기 어렵다. 그래서 공개 요청 폼에 Cloudflare Turnstile을 추가했다. Turnstile을 붙이면서 가장 신경 쓴 부분은 클라이언트의 성공 표시를 믿지 않는 것이었다. 브라우저에서 체크 표시가 나왔다고 해서 서버가 요청을 받아들이면 안 된다. 처리 흐름은 이렇게 구성했다. 사용자 입력 → Turnstile 토큰 발급 → 서버로 입력값과 토큰 전달 → 서버가 Cloudflare Siteverify 호출 → 검증 성공 시에만 데이터베이스 작업 서버에서는 단순히 success만 보지 않았다. - HTTP 응답이 정상인지 - JSON 응답을 정상적으로 읽었는지 - success가 true인지 - 토큰을 발급한 hostname이 허용 목록과 일치하는지 - 위젯에 지정한 action이 서버의 예상값과 같은지 - 만료되거나 재사용된 토큰이 아닌지 const response = await fetch( "https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ secret: process.env.TURNSTILESECRETKEY!, response: token, }), }, ) if (!response.ok) { throw new Error("Verification unavailable") } const result = await response.json() if ( result.success !== true || result.action !== EXPECTEDACTION ) { throw new Error("Verification failed") } Cloudflare 응답이 실패하거나 네트워크 오류가 나면 요청을 허용하지 않는 fail closed 방식으로 처리했다. 외부 검증 서버가 잠시 불안정할 때 요청을 통과시키면 편하겠지만, 검증 장애가 그대로 우회 경로가 된다. 또한 Turnstile 토큰은 제출을 시작하는 순간 클라이언트 상태에서 소비하도록 했다. 실패 후 같은 토큰을 다시 사용하는 흐름을 막고, 만료·오류 시 위젯을 재설정했다. 처음에는 대표 폼 몇 곳만 고치면 될 줄 알았다. 코드를 전체 검색해보니 같은 서버 작업을 호출하는 폼이 9개였다. 랜딩 페이지, 상세 페이지, 플로팅 UI, 별도 문의 화면처럼 모양과 위치는 달랐지만 최종적으로 같은 데이터 저장 경로를 사용했다. 눈에 보이는 대표 화면만 고쳤다면 나머지 폼이 우회 통로로 남았을 것이다. 공용 Turnstile 컴포넌트를 만들고 모든 호출 지점을 확인했다. 각 폼에서 다음 상태를 일관되게 처리했다. - 토큰 발급 전 제출 금지 - 토큰 만료 처리 - 위젯 오류 처리 - 제출 시작 시 토큰 소비 - 실패 후 재검증 - 중복 제출 방지 보안 기능을 적용할 때 세어야 하는 것은 화면이 아니라 데이터가 들어오는 모든 경로였다. Turnstile은 봇을 줄여주지만 모든 자동화를 막는 절대적인 방어 수단은 아니다. 사람이 직접 토큰을 풀 수도 있고, 정상 브라우저를 자동화할 수도 있다. 그래서 동일한 정규화 연락처로 일정 시간 안에 반복되는 요청은 데이터베이스에 다시 저장하지 않도록 했다. 적용한 제한 시간은 10분이었다. 가장 단순한 방법은 최근 데이터를 먼저 조회하고, 없으면 저장하는 것이다. const exists = await hasRecentSubmission(phone) if (!exists) { await insertSubmission(data) } 하지만 이 방식에는 경쟁 조건이 있다. 두 요청이 거의 동시에 들어오면 둘 다 exists = false를 확인한 뒤 나란히 저장할 수 있다. 부하 테스트나 자동화 공격처럼 요청 간격이 짧을 때 실제로 발생할 수 있는 문제다. 그래서 PostgreSQL RPC 안에서 transaction advisory lock과 조회·저장을 하나로 묶었다. PERFORM pgadvisoryxactlock( hashtextextended(pnormalizedphone, 0) ); IF EXISTS ( SELECT 1 FROM public.requests WHERE normalizedphone = pnormalizedphone AND createdat = now() - interval '10 minutes' ) THEN RETURN QUERY SELECT false; RETURN; END IF; INSERT INTO public.requests (...) VALUES (...); RETURN QUERY SELECT true; 동일한 연락처를 사용하는 요청만 같은 잠금을 놓고 순서대로 처리된다. 서로 다른 정상 사용자의 요청은 불필요하게 막지 않는다. IP 주소만으로 강하게 제한하지 않은 데도 이유가 있다. 이동통신사, 회사, 학교, 공용 네트워크에서는 여러 사람이 하나의 공인 IP를 공유할 수 있다. IP를 유일한 기준으로 삼으면 정상 사용자를 함께 차단할 가능성이 높다. IP는 로그와 보조 신호로 활용하되, 서비스 흐름에 맞는 정규화 식별값을 중복 제한의 중심으로 사용했다. 중복 요청이 들어왔을 때 사용자에게는 기존과 같은 완료 화면을 보여줬다. “이미 제출했다”는 구체적인 응답은 특정 연락처의 접수 여부를 외부에 알려주는 정보가 될 수 있고, 봇에게 제한 규칙을 친절하게 설명하는 결과도 낳는다. 데이터베이스에는 다시 저장하지 않되 외부 응답은 일반화했다. 관리형 데이터베이스를 쓰면 브라우저용 공개 키와 Row Level Security(RLS)를 함께 사용하는 경우가 많다. 여기서 흔히 생기는 오해가 있다. RLS를 활성화했다고 모든 접근이 자동으로 안전해지는 것은 아니다. 정책, 테이블 권한, 함수 실행 권한을 함께 봐야 한다. 특히 SECURITY DEFINER 함수나 RPC가 있다면 누가 실행할 수 있는지 명시적으로 확인해야 한다. 이번에는 다음과 같이 권한을 정리했다. - 대상 테이블의 RLS 활성화 - 브라우저 역할의 직접 테이블 권한 회수 - 공개·일반 인증 역할의 RPC 실행 권한 회수 - 서버 전용 역할에만 RPC 실행 권한 부여 - RPC의 searchpath 고정 - 중복 조회에 필요한 인덱스 추가 개념적인 권한 정책은 다음과 같다. ALTER TABLE public.requests ENABLE ROW LEVEL SECURITY; REVOKE ALL ON TABLE public.requests FROM anon, authenticated; REVOKE ALL ON FUNCTION public.submitrequest(...) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.submitrequest(...) TO servicerole; 적용 후에는 실제 권한을 다시 조회했다. anonymous role: RPC 실행 불가 authenticated role: RPC 실행 불가 server role: RPC 실행 가능 애플리케이션 코드가 안전해도 공개 키로 데이터베이스나 RPC를 직접 호출할 수 있으면 서버 검증을 우회할 수 있다. 서버에서 Turnstile과 입력값을 검사해놓고 브라우저가 DB에 직접 쓸 수 있게 두면 앞단의 방어가 아무 의미가 없다. 처음 만든 마이그레이션을 곧바로 운영 DB에 실행하지 않았다. 로컬 코드가 기대하는 구조와 운영 데이터베이스의 실제 구조가 다를 수 있기 때문이다. CLI로 읽기 전용 메타데이터를 조회해 다음 항목을 확인했다. - 컬럼과 타입 - 제약조건 - 인덱스 - RLS 활성 상태 - 정책 - 역할별 권한 - 함수와 실행 권한 - 트리거 개인정보 행은 조회하지 않았다. 구조를 확인하는 데 실제 고객 데이터는 필요하지 않았다. 조사 결과에 맞춰 마이그레이션을 보완한 뒤 운영 DB에 적용했다. 적용 후 같은 메타데이터 조회를 다시 실행해 RLS, 권한, 함수, 인덱스가 의도한 상태인지 검증했다. 이 작업에는 GUI SQL Editor보다 CLI가 훨씬 안정적이었다. 실행한 쿼리와 종료 상태를 남길 수 있고, 읽기 전용 조사와 변경 작업을 분리하기도 쉬웠다. 보안 코드는 작성자의 의도대로만 읽으면 놓치는 부분이 생긴다. 별도 리뷰에서 실제로 세 가지 문제가 발견됐다. 1. Siteverify가 비정상 HTTP 상태를 반환할 때 명확하게 차단해야 했다. 2. 제출을 시작할 때 Turnstile 토큰을 즉시 소비해야 했다. 3. 운영 스키마와 마이그레이션의 컬럼 구성이 정확히 맞는지 보완이 필요했다. 첫 리뷰는 승인되지 않았다. 지적된 내용을 수정하고 회귀 테스트를 추가한 뒤 다시 검토해 최종 승인을 받았다. 검증 범위는 다음과 같았다. - 보안 관련 자동 테스트 11개 - TypeScript 타입 검사 - 변경 파일 ESLint - Production build - 정적 페이지 63개 생성 확인 - git diff --check - 운영 배포 상태 - 주요 운영 URL의 HTTPS 200 응답 - 무인증 관리자 API의 401 응답 - 일반 브라우저에서 Turnstile과 실제 제출 동작 - DB 역할별 RPC 실행 권한 전체 lint에서는 이번 변경과 관계없는 기존 파일의 오류가 남아 있었다. 이를 보안 작업이 모두 통과한 것처럼 숨기지 않고, 변경 파일 검사 결과와 기존 오류를 분리해 기록했다. 방어 코드와 운영 권한을 적용한 뒤 공격 데이터 165건을 삭제했다. 삭제 후 같은 조건으로 다시 조회해 잔존 데이터가 0건인지 확인했다. 함께 정리한 것은 다음과 같다. - 조사용 임시 로그 - 로컬에 만든 공격 데이터 백업 - 임시 스크립트 - 개인정보가 들어갈 수 있는 중간 파일 - 작업용 브랜치와 임시 작업 공간 소스 코드와 테스트, 재사용 가능한 마이그레이션은 남겼지만 운영 개인정보와 공격 원문을 불필요하게 보존하지 않았다. 최종 요청 흐름은 다음과 같다. 공개 폼 ↓ 클라이언트 기본 검증 ↓ Turnstile 토큰 발급 ↓ 서버 입력값 정규화·검증 ↓ 서버가 Cloudflare Siteverify 호출 ├─ 실패: DB 클라이언트 생성 전 거부 └─ 성공 ↓ 서버 전용 역할로 제한된 PostgreSQL RPC 호출 ↓ 동일 연락처 advisory lock ↓ 최근 10분 중복 확인 ├─ 중복: INSERT 없이 일반 완료 응답 └─ 신규: INSERT 데이터베이스 쪽 경계는 별도로 닫았다. 브라우저 공개 역할 ├─ 테이블 직접 접근 불가 └─ 제출 RPC 실행 불가 서버 전용 역할 └─ 검증 완료 후 제출 RPC 실행 가능 어느 한 기능도 단독으로 완전한 해결책은 아니다. - 입력 검증은 비정상 데이터를 줄이지만 봇 자체를 막지는 못한다. - Turnstile은 자동화를 줄이지만 우회 가능성을 0으로 만들지는 못한다. - 중복 제한은 대량 저장을 막지만 잘못 구현하면 경쟁 조건이 생긴다. - RLS는 중요하지만 테이블과 함수 권한이 열려 있으면 충분하지 않다. - 관리자 API 인증은 데이터 노출을 막지만 공개 폼의 남용까지 해결하지 않는다. 각 방어가 다른 방어의 실패를 보완하도록 겹쳐 놓는 것이 중요했다. SQL 인젝션은 낡은 공격이라기보다 비용이 거의 들지 않는 기본 탐색 항목에 가깝다. 공개 폼이 있으면 공격자는 서비스가 어떤 스택으로 만들어졌는지 모르는 상태에서도 일단 문자열을 던진다. “요즘 누가 이런 걸 하지?”가 아니라 “자동화 도구라면 당연히 이것부터 넣겠지”라고 생각해야 한다. 관리형 SDK와 파라미터 바인딩 덕분에 실제 SQL 실행은 피했을 가능성이 높았다. 그래도 비정상 데이터 165건이 운영 DB에 들어왔고 관리자 화면까지 도달했다. 쿼리 실행 안전성과 운영 서비스의 전체 보안은 같은 문제가 아니다. 클라이언트 검증은 정상 사용자가 실수했을 때 빠르게 알려주는 장치다. 보안 판단은 서버에서 다시 해야 한다. 브라우저를 건너뛴 요청에도 같은 규칙이 적용돼야 한다. 폼에 Turnstile을 붙이는 것만으로는 부족하다. 서버 작업, RPC, 테이블 권한까지 따라가야 한다. 공격자가 앞단을 우회해 DB를 직접 호출할 수 있다면 위젯은 장식에 불과하다. 빌드가 통과하고 배포 상태가 Ready여도 실제 운영 API가 원하는 상태로 거부하는지는 별도 문제다. 무인증 요청의 401, 일반 브라우저의 실제 제출, 역할별 DB 권한을 각각 확인해야 했다. 이번 대응에는 코드 수정 외에도 공격 범위 조사, 운영 데이터 정리, 비밀키 교체, 환경변수 분리, DB 마이그레이션, 권한 검증, 배포 확인, 임시 개인정보 파일 삭제가 포함됐다. 개발 환경에서 테스트가 통과한 시점은 중간 단계였다. 실제 사용자가 쓰는 서비스에서 방어가 작동하고, 잘못된 접근이 거부되며, 정상 요청은 계속 처리되는 것을 확인해야 작업이 끝난다. 처음 화면에서 SQL 문법이 가득한 데이터를 봤을 때는 “아니, 요즘도 SQL 인젝션이 있네”라는 생각부터 들었다. 조사하고 나니 질문을 조금 바꿔야 했다. SQL 인젝션이 아직도 있느냐가 문제가 아니었다. 공개 입력창이 있는 한 이런 탐색은 계속 들어온다. 중요한 것은 공격 문자열 하나를 필터링하는 일이 아니라, 입력부터 데이터베이스까지 어느 경계가 실패해도 다음 경계가 막아주는 구조를 갖추는 것이다. 이번 공격에서 실제 SQL 실행이나 데이터 유출 증거는 발견되지 않았다. 그렇다고 아무 일도 아니었던 것은 아니다. 서버 입력 검증이 약했고, 자동화 요청이 운영 DB에 쌓였으며, 관리자 API와 데이터베이스 권한까지 다시 점검해야 했다. 대응을 마친 뒤 서비스의 경계는 이전보다 훨씬 단단해졌다. - 비정상 입력은 서버에서 거부된다. - 자동화 요청은 Turnstile로 한 번 더 걸러진다. - 검증 장애는 우회가 아니라 차단으로 처리된다. - 동시 반복 요청은 데이터베이스 트랜잭션 안에서 제한된다. - 브라우저 역할은 테이블과 제출 RPC에 직접 접근할 수 없다. - 관리자 데이터는 인증 없이 조회할 수 없다. - 운영 배포 뒤 실제 거부와 정상 제출을 모두 확인한다. 실서비스를 직접 개발하고 운영하는 일은 기능을 만드는 데서 끝나지 않는다. 공개 입력창 하나가 어디까지 이어지는지 알아야 하고, 문제가 생기면 그 경로 전체를 닫을 수 있어야 한다. 이번에는 그 사실을 운영 데이터 165건으로 다시 배웠다.

Biz

플랫폼의 세 가지 조건으로 보는 OOO 분석

2026.07.28

이 글은 과거에 직접 다뤘던 서비스의 데이터를 바탕으로 작성했다. 서비스가 특정되는 것을 막기 위해 이름은 OOO으로 표기하고, 일부 맥락은 일반화했다. 회원 수가 빠르게 늘면 플랫폼이 잘되고 있다고 말할 수 있을까? 예전에는 나도 회원가입률과 시장점유율을 먼저 봤다. OOO은 후발주자였지만 시작 시기와 비교하면 빠르게 회원을 모았고, 시장점유율도 35%까지 올라왔다. 숫자만 놓고 보면 분명 인상적인 성장이다. 그런데 매출을 함께 놓고 보니 이야기가 달라졌다. 홈페이지 배너 광고와 업체 광고 영상은 하나도 없었고, 올라온 콘텐츠 대부분을 회사 비용으로 직접 제작하고 있었다. 사용자는 모였지만 돈을 내는 쪽은 거의 작동하지 않았다. OOO은 정말 성공한 플랫폼이었을까? 이 질문에 답하기 위해 플랫폼의 구조부터 다시 살펴봤다. 온라인에서 서비스를 제공한다고 모두 플랫폼은 아니다. 일반적인 서비스는 기업이 만든 상품을 고객에게 판매한다. 플랫폼은 서로 다른 참여자 집단을 연결하고, 그들 사이의 거래나 상호작용을 중개한다. 배달 플랫폼을 떠올리면 쉽다. 소비자가 많아지면 음식점이 입점할 이유가 커지고, 음식점이 많아지면 소비자가 서비스를 이용할 이유도 커진다. 한쪽의 참여가 다른 쪽의 가치를 높인다. 이를 교차 네트워크 효과라고 한다. 경제학의 외부성 개념과 맞닿아 있지만, 플랫폼에서는 서로 다른 참여자 집단 사이에서 효과가 발생한다는 점이 중요하다. 내가 참고한 글인 [「3가지만 있으면 플랫폼이 만들어진다?!」](https://maily.so/platform/posts/01852e)는 플랫폼의 기본 구조를 다음 세 요소로 정리한다. 1. 양면시장 2. 교차보조도구 3. 가격전략 이 글에서는 이 틀을 빌려 OOO을 분석했다. 다만 ‘플랫폼인가 아닌가’를 단순히 판정하기보다, 세 요소가 실제로 얼마나 강하게 연결돼 있는지에 초점을 맞췄다. 양면시장은 서로를 필요로 하는 둘 이상의 참여자 집단을 플랫폼이 연결하는 구조다. OOO에는 크게 네 집단이 있었다. - 무료 콘텐츠를 보는 사용자 - 유료 콘텐츠를 구매하는 사용자 - 강의를 올리는 업체 또는 공급자 - 광고를 집행하려는 업체 구조만 보면 OOO은 분명 플랫폼의 형태를 갖췄다. 콘텐츠를 찾는 사용자와 콘텐츠를 공급하는 업체가 있고, 광고주는 이용자의 관심과 트래픽을 구매할 수 있다. 문제는 집단이 ‘존재한다’는 사실만으로 양면시장이 작동하지 않는다는 점이다. 양쪽이 서로에게 충분한 가치를 제공해야 한다. OOO에서는 무료 사용자가 빠르게 늘었지만 다음 집단은 충분히 커지지 못했다. 1. 광고비를 지불할 업체 2. 콘텐츠를 올리고 판매할 업체 3. 유료 강의를 결제할 사용자 당시 OOO의 시장점유율은 35%였지만, 전체 사용자 수는 주요 경쟁 서비스의 절반에도 미치지 못했다. 회원 수와 비교해 월간 활성 사용자 수(MAU)도 낮았다. 가입자는 확보했지만 반복 방문과 구매로 이어지는 밀도가 부족했다. 광고주 입장에서는 광고를 집행할 만큼 도달 가능한 이용자가 부족했고, 강의 공급자 입장에서는 제작비와 운영비를 들여 콘텐츠를 올릴 만큼 기대 매출이 크지 않았다. 좋은 콘텐츠가 부족해지면 사용자의 재방문도 줄어든다. 결국 다음과 같은 악순환이 생긴다. 낮은 활성 사용자 수 → 낮은 공급자 수익 기대 → 고품질 콘텐츠 감소 → 사용자의 재방문 감소 → 더 낮은 활성 사용자 수 OOO의 약점은 단순히 회원 수가 적다는 데 있지 않았다. 한쪽의 성장이 다른 쪽의 참여를 촉진하는 교차 네트워크 효과가 약했다. 참여자를 모으려면 서비스를 처음 쓰게 만드는 분명한 계기가 필요하다. 참고 글에서는 이를 교차보조도구라고 표현한다. 사용자가 체감하는 무료 혜택, 새로운 경험, 비용 절감 같은 유인책이다. OOO의 가장 강한 교차보조도구는 무료 강의였다. - 경쟁사보다 무료 강의 수가 많았다. - 사용자가 결제 전에 콘텐츠를 충분히 경험할 수 있었다. - 서비스에 가입해야 할 이유를 직관적으로 전달했다. 이 전략은 초기 회원 확보에 효과적이었다. 시작 시기를 고려하면 OOO의 회원가입률이 높았던 이유도 여기에 있다. 사용자는 ‘무료로 볼 것이 많다’는 이유만으로 일단 가입할 수 있었다. 하지만 무료 콘텐츠는 유입 장치이지, 그 자체로 완성된 플랫폼 구조는 아니다. 무료 이용자가 늘어난 뒤 공급자와 광고주의 가치까지 함께 커져야 한다. OOO에서는 무료 콘텐츠가 사용자를 모으는 데 성공했지만 그 트래픽이 유료 구매, 광고 성과, 공급자 수익으로 충분히 전환되지 않았다. 게다가 콘텐츠 대부분을 회사 비용으로 직접 만들었다. 공급자가 자발적으로 콘텐츠를 늘리는 구조가 아니라 회사가 비용을 투입해 무료 혜택을 유지하는 구조에 가까웠다. 이 차이는 크다. 플랫폼의 핵심은 참여자가 늘수록 참여자 스스로 새로운 가치를 만드는 데 있다. 회사가 계속 비용을 써야만 콘텐츠가 유지된다면 회원 수가 늘어날수록 운영 부담도 함께 커질 수 있다. 플랫폼의 가격전략은 단순히 가격이 싸다는 뜻이 아니다. 어느 쪽에는 무료 또는 낮은 가격을 제공하고, 어느 쪽에서 수익을 확보할지 정하는 가격 구조에 가깝다. 플랫폼은 모든 참여자에게 같은 방식으로 과금할 필요가 없다. 이용자를 무료로 모은 뒤 광고주에게 과금할 수도 있고, 공급자에게 판매 수수료를 받을 수도 있다. 소비자에게 구독료를 받고 공급자의 입점 장벽을 낮추는 방식도 가능하다. OOO의 가격 경쟁력은 강했다. - 무료 강의가 많았다. - 유료 강의도 경쟁사보다 저렴했다. 사용자 입장에서는 진입 장벽이 낮았다. 문제는 낮은 가격이 어떤 수익 구조로 연결되는지가 불분명했다는 점이다. OOO이 기대할 수 있는 수익원은 세 가지였다. | 수익원 | 돈을 내는 참여자 | 당시 상태 | | --- | --- | --- | | 광고 | 광고를 원하는 업체 | 홈페이지 배너와 업체 광고 영상이 사실상 없음 | | 콘텐츠 공급 | 강의를 올리는 업체 | 외부 공급보다 회사 제작 콘텐츠의 비중이 큼 | | 유료 판매 | 유료 강의를 구매하는 사용자 | 무료 이용자에 비해 결제 사용자가 적음 | 무료와 저가 전략은 유입을 만들었지만 수익을 부담할 집단을 충분히 키우지 못했다. 가격이 싸다는 사실과 좋은 가격전략을 가졌다는 평가는 구분해야 한다. 좋은 가격전략은 다음 세 조건을 함께 충족해야 한다. - 사용자가 받아들일 수 있는 가격이어야 한다. - 공급자가 계속 참여할 유인이 있어야 한다. - 플랫폼이 서비스를 유지할 수 있는 수익이 남아야 한다. OOO은 첫 번째 조건에는 강했지만 두 번째와 세 번째 조건은 약했다. 결론부터 말하면 OOO은 플랫폼의 형태를 갖췄지만, 플랫폼의 선순환은 충분히 만들지 못한 서비스에 가까웠다. 서로 다른 참여자 집단을 연결했고 무료 콘텐츠라는 강력한 유입 장치도 있었다. 이 점에서는 플랫폼이라고 부를 수 있다. 반면 실제 거래와 수익을 만드는 쪽의 참여가 매우 약했고, 콘텐츠 생산도 플랫폼 참여자보다 회사의 직접 투자에 의존했다. 플랫폼 여부를 이름으로 판단할 필요는 없다. 아래 흐름이 실제로 작동하는지를 봐야 한다. 사용자 증가 → 공급자 참여 증가 → 콘텐츠의 양과 질 향상 → 사용자 재방문과 결제 증가 → 광고·판매 수익 증가 OOO에서는 첫 단계인 사용자 유입은 강했지만 다음 단계로 넘어가는 연결고리가 약했다. OOO이 잘된 이유는 명확하다. 무료 콘텐츠가 많았고 유료 콘텐츠도 저렴했다. 사용자가 서비스를 처음 경험할 이유가 경쟁사보다 강했다. 그 결과 후발주자임에도 높은 회원가입률과 35%의 시장점유율을 만들었다. 반대로 매출이 따라오지 못한 이유도 명확하다. - 회원 수에 비해 MAU가 낮았다. - 무료 사용자가 유료 구매자로 충분히 전환되지 않았다. - 광고주가 비용을 지불할 만큼 활성 트래픽이 크지 않았다. - 외부 공급자가 콘텐츠를 자발적으로 늘릴 경제적 유인이 약했다. - 콘텐츠를 회사 비용으로 직접 제작해 네트워크 효과보다 운영비 의존도가 높았다. 무료 콘텐츠와 저렴한 가격은 성장의 출발점이었지만, 그 자체가 플랫폼의 완성은 아니었다. 예전에 이 분석을 처음 썼을 때는 “OOO은 플랫폼인가?”라는 질문에 답하려 했다. 지금 다시 보면 더 중요한 질문은 따로 있다. OOO의 한쪽 참여자가 늘어날수록 다른 쪽 참여자가 얻는 가치도 실제로 커지고 있는가? OOO은 사용자를 모으는 데 성공했다. 하지만 무료 사용자의 증가가 공급자의 수익, 광고주의 성과, 유료 콘텐츠의 확대까지 이어지지는 못했다. 돈을 내는 참여자 집단이 0에 가까운 상태라면 서비스는 성장해도 플랫폼의 선순환은 만들어지지 않는다. 플랫폼은 단순히 두 집단을 한곳에 모아놓은 서비스가 아니다. 누구를 연결할지 정하고, 참여할 이유를 만들고, 그 가치가 지속 가능한 수익으로 이어지게 설계한 구조다. OOO의 사례는 회원 수와 시장점유율만으로 플랫폼의 성공을 판단하면 안 된다는 점을 보여준다. 유입이 아니라 거래와 재방문, 그리고 양쪽 참여자가 서로의 가치를 키우는 구조까지 봐야 한다.

DevelopInfra

Obsidian 동기화 서버를 직접 만들었다: Self-hosted LiveSync와 CouchDB

2026.07.24

Obsidian을 쓰다 보면 결국 동기화 문제를 만난다. Mac 한 대에서만 쓸 때는 별일이 없다. 볼트 폴더 안의 Markdown 파일을 Obsidian이 읽어 주는 단순한 구조니까. 하지만 iPhone과 iPad가 추가되고, 월 구독 없이 여러 기기를 연결하고 싶어지면 이야기가 달라진다. 나도 처음에는 iCloud Drive를 사용했다. Apple 기기끼리는 가장 자연스러운 선택이었다. 별도 플러그인도 필요 없고, Obsidian에서 iCloud 볼트를 만들면 Mac과 iPhone에서 바로 사용할 수 있다. 문제는 내가 운영하는 Ubuntu 서버였다. 서버를 개인 서비스와 자동화의 중심으로 쓰고 있는데, Linux에서 iCloud Drive를 안정적인 상시 동기화 저장소처럼 다루기는 어렵다. Syncthing은 Mac과 Linux에서는 훌륭하지만 iOS가 걸렸다. Git은 기록과 복구에는 강하지만 실시간 동기화 도구로 쓰기엔 손이 많이 간다. 공식 Obsidian Sync는 가장 편한 답이지만 매달 비용이 든다. 결국 내 조건은 이랬다. - MacBook, iPhone, iPad에서 같은 볼트를 쓰고 싶다. - 특정 회사의 클라우드 저장소에 묶이고 싶지 않다. - 이미 운영 중인 Ubuntu 서버와 Docker 환경을 활용하고 싶다. - 동기화 서버를 내가 직접 운영하고 싶다. - 가능하면 추가 구독료 없이 구성하고 싶다. 이 조건을 가장 잘 맞춘 조합이 Self-hosted LiveSync와 CouchDB였다. 이 글은 Ubuntu 서버에 CouchDB를 Docker로 실행하고, Cloudflare Tunnel을 거쳐 Mac·iPhone·iPad의 Obsidian을 연결한 실제 구성 과정을 정리한 글이다. Self-hosted LiveSync를 이해하려면 Dropbox나 iCloud와 같은 파일 동기화 서비스부터 잠시 잊는 편이 좋다. 일반적인 파일 동기화는 완성된 파일을 기기 사이에서 복사한다. 반면 Self-hosted LiveSync는 Obsidian 플러그인이 각 기기의 변경 내용을 감지하고, 이를 CouchDB 문서와 청크 형태로 저장·복제한다. MacBook Obsidian ─┐ │ iPhone Obsidian ──┼── HTTPS ── Cloudflare Tunnel ── CouchDB │ iPad Obsidian ────┘ 각 기기에는 평소처럼 실제 Markdown 볼트가 있다. 사용자는 로컬 파일을 열고 수정한다. Self-hosted LiveSync 플러그인이 변경 사항을 CouchDB로 보내고, 다른 기기들은 CouchDB에서 새 변경 사항을 받아 자기 로컬 볼트에 반영한다. CouchDB가 이 구조에 잘 맞는 이유도 여기에 있다. CouchDB는 JSON 문서 저장뿐 아니라 문서 버전과 변경 이력, 복제를 핵심 기능으로 제공한다. 네트워크가 잠시 끊겼다가 다시 연결되는 모바일 기기와 궁합이 좋다. 이 부분은 꼭 짚고 넘어가야 한다. CouchDB의 Docker 볼륨을 열어도 내 노트.md 같은 파일은 보이지 않는다. 서버에는 CouchDB 데이터 파일이 저장될 뿐이다. Markdown 원본은 각 Obsidian 클라이언트의 로컬 볼트에 있다. 각 기기: 실제 .md 파일 Ubuntu 서버: CouchDB 문서와 첨부파일 청크 따라서 서버의 CouchDB 데이터 디렉터리를 직접 수정하면 안 된다. Ubuntu에서 Markdown 파일을 직접 읽고 수정해야 한다면 별도의 Obsidian 클라이언트나 LiveSync 호환 브리지가 필요하다. 이번 구성에서 Ubuntu는 동기화 중계 서버 역할만 맡는다. 처음부터 Self-hosted LiveSync만 본 것은 아니다. 후보마다 장단점이 뚜렷했다. | 방식 | 장점 | 내가 포기한 이유 | |---|---|---| | iCloud Drive | Apple 기기에서 가장 간단함 | Ubuntu/Linux 연동이 애매하고 이중 동기화 위험이 있음 | | Obsidian Sync | 공식 지원, 설정과 복구가 편함 | 계속 발생하는 구독 비용 | | Syncthing | 무료, 빠름, Mac·Linux에서 안정적 | iOS 백그라운드 동기화와 앱 제약 | | Git | 변경 이력과 복구에 강함 | 실시간 동기화가 아니며 모바일 충돌 처리가 번거로움 | | Remotely Save | S3·WebDAV 등 선택지가 많음 | 즉시 복제보다는 주기적 동기화에 가깝고 별도 저장소가 필요함 | | Self-hosted LiveSync | 무료, 여러 플랫폼 지원, 거의 실시간, 서버 소유권 확보 | 설치와 장애 복구를 직접 책임져야 함 | 공식 Obsidian Sync가 나쁜 선택이라는 뜻은 아니다. 오히려 서버 운영에 시간을 쓰고 싶지 않다면 가장 합리적이다. 공식 서비스 비용에는 동기화 서버, 버전 기록, 장애 대응, 보안 업데이트, 고객 지원이 포함된다. 나는 이미 24시간 돌아가는 Ubuntu 서버와 Docker, Cloudflare Tunnel을 운영하고 있었다. 새 서버 비용이 들지 않았고, 장애가 생겼을 때 직접 확인할 수도 있었다. 그래서 편의성을 조금 포기하고 운영권을 가져오는 쪽을 골랐다. 내가 구성한 전체 경로는 다음과 같다. ┌──────────────────┐ │ MacBook Obsidian │ ← 최초 업로드를 담당하는 기준 기기 │ + LiveSync │ └────────┬─────────┘ │ ┌────────▼─────────┐ │ iPhone / iPad │ ← 기존 원격 DB에 참가하는 보조 기기 │ Obsidian │ │ + LiveSync │ └────────┬─────────┘ │ HTTPS ▼ ┌──────────────────────────────┐ │ Cloudflare │ │ Public Hostname │ │ obsidian-sync.example.com │ └──────────────┬───────────────┘ │ Named Tunnel ▼ ┌──────────────────────────────┐ │ Ubuntu │ │ cloudflared │ │ ↓ │ │ 127.0.0.1:5984 │ │ ↓ │ │ CouchDB 3.5 Docker Container │ │ ↓ │ │ Docker Named Volumes │ └──────────────────────────────┘ 여기서 중요한 보안 경계는 127.0.0.1:5984다. CouchDB 포트를 인터넷이나 내부 네트워크 전체에 직접 열지 않았다. 서버 로컬에서만 접속할 수 있게 묶고, 외부 기기는 Cloudflare Tunnel의 HTTPS 주소를 사용한다. 인터넷 → Cloudflare HTTPS → 암호화된 Tunnel → localhost:5984 공유기에서 5984 포트를 포워딩할 필요도 없고, 서버 방화벽에 CouchDB 포트를 공개할 이유도 없다. 구성을 시작하기 전에 아래 항목을 준비했다. - 24시간 실행되는 Ubuntu 서버 - Docker Engine과 Docker Compose 플러그인 - 사용할 도메인 - Cloudflare에서 관리 중인 DNS - 서버에서 실행 중인 Named Tunnel - MacBook, iPhone, iPad의 Obsidian - 각 기기에 설치할 Self-hosted LiveSync 커뮤니티 플러그인 - 비밀번호 관리자 CouchDB 계정 비밀번호와 LiveSync의 E2EE 암호는 서로 다른 값이다. 둘 다 반드시 비밀번호 관리자에 보관해야 한다. 서버의 Docker 앱은 한곳에서 관리하는 편이 좋다. 나는 다음 경로를 사용했다. mkdir -p ~/docker/obsidian-livesync cd ~/docker/obsidian-livesync 최종 파일 구조는 단순하다. ~/docker/obsidian-livesync/ ├── docker-compose.yml ├── .env └── couchdb-init.sh .env에는 비밀값이 들어가므로 Git에 커밋하지 않는다. 무작위 비밀번호를 만든다. python3 -c 'import secrets; print(secrets.tokenurlsafe(48))' 생성된 값을 이용해 .env를 작성한다. COUCHDBUSER=obsidianadmin COUCHDBPASSWORD=충분히긴무작위비밀번호 권한도 제한한다. chmod 600 .env 블로그나 저장소에 실제 .env를 올리면 안 된다. LiveSync 설정 URI 역시 CouchDB 주소와 인증 정보가 들어갈 수 있으므로 공개하면 안 된다. 실제로 사용한 구성은 아래와 같다. services: couchdb: image: couchdb:3.5 containername: obsidian-livesync-couchdb restart: unless-stopped environment: COUCHDBUSER: ${COUCHDBUSER} COUCHDBPASSWORD: ${COUCHDBPASSWORD} volumes: - couchdb-data:/opt/couchdb/data - couchdb-config:/opt/couchdb/etc/local.d ports: - "127.0.0.1:5984:5984" healthcheck: test: - CMD-SHELL - - curl -fsS -u "$$COUCHDBUSER:$$COUCHDBPASSWORD" http://127.0.0.1:5984/up || exit 1 interval: 10s timeout: 5s retries: 10 startperiod: 20s volumes: couchdb-data: couchdb-config: 서버 애플리케이션에서 latest는 편하지만 예측하기 어렵다. 어느 날 컨테이너를 다시 만들었는데 메이저 버전까지 바뀔 수 있다. 최소한 메이저·마이너 계열은 고정해 두는 편이 낫다. - couchdb-data: 실제 데이터베이스 데이터 - couchdb-config: LiveSync 초기화 스크립트가 적용한 CouchDB 설정 컨테이너를 삭제하고 새 이미지로 다시 만들어도 두 볼륨이 남아 있으면 데이터와 설정을 이어서 사용할 수 있다. 물론 볼륨이 백업을 대신해 주지는 않는다. Compose 파일에서 $COUCHDBUSER라고 쓰면 호스트의 Compose가 먼저 변수 치환을 시도한다. $$COUCHDBUSER라고 적어야 컨테이너 안에서 실제 환경 변수를 읽는다. 이 차이는 뒤에서 꽤 중요한 문제를 막아 줬다. cd ~/docker/obsidian-livesync docker compose up -d 상태를 확인한다. docker compose ps 정상이라면 healthy가 표시된다. 로컬 API도 확인한다. set -a source .env set +a curl -fsS \ -u "$COUCHDBUSER:$COUCHDBPASSWORD" \ http://127.0.0.1:5984/up 예상 응답은 다음과 같다. {"status":"ok"} 버전도 확인할 수 있다. curl -fsS \ -u "$COUCHDBUSER:$COUCHDBPASSWORD" \ http://127.0.0.1:5984/ 내가 글을 쓸 때 실제 서버에서 실행 중인 버전은 CouchDB 3.5.2였다. CouchDB 컨테이너를 띄우는 것만으로는 끝나지 않는다. Self-hosted LiveSync가 브라우저 기반 Obsidian 앱과 통신하려면 인증과 CORS 설정이 필요하다. 프로젝트에서 제공하는 초기화 스크립트를 받는다. curl -fsSLO \ https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh chmod 700 couchdb-init.sh 인터넷에서 받은 스크립트는 바로 실행하기 전에 내용을 확인하는 습관이 좋다. less couchdb-init.sh 그다음 .env 값을 읽어 초기화한다. set -a source .env set +a hostname=http://127.0.0.1:5984 \ username="$COUCHDBUSER" \ password="$COUCHDBPASSWORD" \ bash ./couchdb-init.sh 초기화 스크립트는 LiveSync에 필요한 CORS, 인증, 데이터베이스 동작 관련 설정을 적용한다. 확인할 핵심은 다음과 같다. - 인증하지 않은 사용자는 접근할 수 없어야 한다. - enablecors가 활성화되어야 한다. - Obsidian 데스크톱과 모바일 앱의 origin이 허용되어야 한다. - 인증된 /up 요청은 계속 성공해야 한다. 처음에는 health check를 익명 요청으로 작성하기 쉽다. healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:5984/up"] CouchDB를 막 띄운 직후에는 이 요청이 성공할 수 있다. 그런데 LiveSync 초기화가 requirevaliduser를 활성화하면 익명 /up 요청은 HTTP 401을 반환한다. CouchDB는 멀쩡한데 Docker는 컨테이너를 unhealthy로 표시한다. 해결 방법은 health check에도 인증 정보를 넣는 것이다. healthcheck: test: - CMD-SHELL - - curl -fsS -u "$$COUCHDBUSER:$$COUCHDBPASSWORD" http://127.0.0.1:5984/up || exit 1 수정 후 컨테이너를 다시 만든다. docker compose up -d --force-recreate CouchDB 장애가 아니라 보안 설정이 강화되면서 기존 점검 방식이 틀어진 것이었다. 원인을 모르고 볼륨까지 지웠다면 데이터만 잃을 뻔했다. ss -ltnp | grep 5984 원하는 결과는 아래처럼 127.0.0.1에만 바인딩된 상태다. 127.0.0.1:5984 아래처럼 나오면 외부 인터페이스에도 열려 있다는 뜻이다. 0.0.0.0:5984 [::]:5984 이 경우 Compose의 포트 매핑을 다시 확인해야 한다. 모바일 기기는 집 밖에서도 동기화해야 한다. Tailscale처럼 각 기기에 VPN을 연결하는 대신, 기존에 운영하던 Cloudflare Named Tunnel에 전용 Public Hostname을 추가했다. Cloudflare Zero Trust에서 다음 경로로 이동한다. Networks → Tunnels → 사용 중인 Tunnel → Public Hostnames → Add a public hostname 예시는 다음과 같다. Subdomain: obsidian-sync Domain: example.com Type: HTTP URL: localhost:5984 완성 주소는 다음과 같은 HTTPS URL이 된다. https://obsidian-sync.example.com cloudflared tunnel run --token ... 방식으로 실행 중인 remotely managed tunnel이라면 로컬 config.yml이 라우팅의 기준이 아니다. Cloudflare 대시보드나 API에서 Public Hostname을 관리해야 한다. 브라우저용 관리 페이지라면 Cloudflare Access가 매우 유용하다. 하지만 LiveSync 클라이언트 앞에 브라우저 로그인 페이지가 끼면 CouchDB 요청이 리디렉션되어 동기화가 실패할 수 있다. 이 구성에서는 다음 보안 계층을 사용했다. 1. Cloudflare Tunnel로 원본 서버 포트 비공개 2. HTTPS 전송 암호화 3. CouchDB 사용자 인증 4. LiveSync E2EE 5. 필요하면 경로와 속성 이름 난독화 Access를 반드시 사용하고 싶다면 서비스 토큰 방식까지 포함해 실제 클라이언트 호환성을 먼저 검증해야 한다. Tunnel을 만들었다고 끝난 것이 아니다. 서버 외부에서 실제 HTTPS 요청이 성공해야 한다. curl -fsS \ -u 'obsidianadmin:비밀번호' \ https://obsidian-sync.example.com/up CORS preflight도 확인하면 좋다. curl -i -X OPTIONS \ -H 'Origin: app://obsidian.md' \ -H 'Access-Control-Request-Method: GET' \ https://obsidian-sync.example.com/ 실제 주소와 인증 정보는 셸 기록에 남을 수 있다. 운영 환경에서는 비밀번호를 명령행에 직접 적기보다 임시 환경 변수나 안전한 자격 증명 파일을 사용하는 편이 낫다. 여러 기기를 한꺼번에 붙이면 문제가 생겼을 때 원인을 찾기 어렵다. 기존 노트가 있는 MacBook을 기준 기기로 정하고, 여기서 서버의 첫 상태를 만든 뒤 모바일 기기를 하나씩 추가했다. 시작하기 전 볼트 전체를 반드시 백업한다. 기존 Mac 볼트 → 별도 폴더나 외장 저장소에 전체 복사 → 백업본은 동기화 대상에서 제외 그리고 iCloud와 LiveSync가 같은 볼트를 동시에 동기화하지 않도록 한다. 하나의 볼트에 동기화 엔진 두 개를 겹치면 삭제, 이름 변경, 설정 파일 변경이 서로 전파되며 예측하기 어려운 충돌이 생길 수 있다. Obsidian에서 다음 순서로 설치한다. Settings → Community plugins → Browse → Self-hosted LiveSync 검색 → Install → Enable 플러그인 설정에서 다음 값을 입력한다. URI: https://obsidian-sync.example.com Username: CouchDB 사용자명 Password: CouchDB 비밀번호 Database name: 영문 소문자 데이터베이스명 데이터베이스 이름은 소문자로 단순하게 정하는 편이 좋다. my-vault 첫 동기화 전에 플러그인의 Detect and Fix CouchDB Issues를 실행한다. 보통 다음 권장값을 확인하게 된다. - case-sensitive filenames: false - per-file saved customisation sync: true - CouchDB v3 chunk size enhancement: 60 새로 만든 빈 데이터베이스에서는 원격 설정이 아직 없으므로 Fetch Remote Configuration Failed가 나타날 수 있다. 정말 빈 DB를 처음 구성하는 상황이라면 Skip and proceed로 넘어가도 된다. 이미 사용 중인 DB에서 같은 오류가 난다면 네트워크나 인증 문제부터 확인해야 한다. 서버 DB가 비어 있고 Mac의 볼트가 유일한 원본이라는 사실을 다시 확인한 뒤, 새 서버를 설정하는 흐름을 선택한다. Setting up a new server / Reset existing server → Overwrite Server with This Device's Files 이 버튼은 평소에 누르는 동기화 버튼이 아니다. 서버를 이 기기의 상태로 덮어쓰는 일회성 작업이다. 다른 기기가 이미 연결된 뒤에는 함부로 사용하면 안 된다. Do you want to send all chunks before replication? 질문에는 일반적인 초기 설정이라면 기본값인 No를 선택했다. 마지막으로 Setup → Enable LiveSync를 눌러야 실제 복제가 시작된다. 서버 탐지와 DB 생성이 성공했더라도 아래 상태라면 아직 동기화가 꺼져 있다. No replicator has been activated or has not been initialized yet ↑0 ↓0 Self-hosted LiveSync는 동기화 데이터를 클라이언트에서 암호화한 뒤 CouchDB에 저장하는 E2EE를 지원한다. 여기에는 세 종류의 비밀값이 등장한다. | 값 | 용도 | |---|---| | CouchDB 비밀번호 | 서버 API 인증 | | LiveSync E2EE 암호 | 노트 내용을 암호화·복호화 | | Setup URI 암호 | 설정 URI를 다른 기기로 전달할 때 보호 | 서로 같은 것이 아니다. 특히 E2EE 암호는 서버의 .env에 저장되지 않는다. 잃어버리면 새 기기에서 CouchDB 데이터를 받아도 내용을 복호화할 수 없다. 나는 E2EE 암호를 비밀번호 관리자에 따로 보관했다. 선택적으로 경로와 속성 이름 난독화도 활성화할 수 있지만, 모든 기기에서 같은 설정을 사용해야 한다. 모바일에서는 새 빈 볼트를 만든다. LiveSync를 사용할 볼트에 iCloud의 Store in iCloud를 함께 켜지 않는다. 모바일 기기는 서버를 덮어쓰는 기준 기기가 아니다. 반드시 기존 원격 서버에 참가하는 흐름을 선택한다. Existing configuration / Join this device Mac과 동일한 값을 입력한다. - CouchDB HTTPS URL - 데이터베이스 이름 - CouchDB 사용자명과 비밀번호 - LiveSync E2EE 암호 - 경로·속성 난독화 사용 여부 빈 모바일 볼트라면 원격 서버가 정답이다. Overwrite all with remote files → Delete local files if not on remote 이 선택은 모바일의 빈 로컬 볼트를 원격 상태로 맞추는 작업이다. 서버를 덮어쓰는 것이 아니다. 첫 동기화 동안에는 Obsidian을 화면에 열어 두는 편이 좋다. iOS는 앱의 백그라운드 실행 시간을 제한하므로 대용량 볼트를 처음 받을 때 앱을 닫으면 작업이 중단될 수 있다. Wi-Fi와 충전 환경에서 진행하면 안정적이다. P2P를 사용하지 않는다면 P2P Status의 Disconnected는 문제가 아니다. 이번 구성의 전송 방식은 CouchDB replication이다. 노트와 첨부파일만 동기화하면 끝이라고 생각했지만, 실제 사용에서는 테마·플러그인·설정도 중요했다. Self-hosted LiveSync에는 Customisation Sync가 있다. 여기서 가장 큰 함정은 LiveSync 플러그인 자신의 설정까지 다른 기기로 덮어쓸 수 있다는 점이다. Mac의 장치 이름과 연결 상태를 iPhone에 그대로 적용하면 모바일 기기의 고유 설정이 깨질 수 있다. 나는 다음 원칙으로 구성했다. 1. Mac과 iPhone/iPad에 서로 다른 장치 이름을 지정한다. 2. 기준 Mac에서 Customisation Sync를 활성화한다. 3. Beta3 관리 화면에서 변경 사항을 스캔한다. 4. Self-hosted LiveSync의 MAIN과 DATA를 Ignore/Paused로 설정한다. 5. Mac에서 Sync once로 설정을 게시한다. 6. 모바일에서 Sync once → Refresh를 실행한다. 7. 원하는 테마와 플러그인 설정만 선택해 적용한다. Select All Shiny와 Apply All Selected를 무조건 누르는 방식은 피했다. 데스크톱 전용 플러그인이나 workspace.json을 모바일에 적용하면 화면 배치와 플러그인 상태가 망가질 수 있다. workspace-mobile.json 역시 모바일 쪽에 남겨 두는 편이 안전하다. Customisation Sync와 일반 노트 복제는 별개의 기능이다. 일반 LiveSync → 노트와 첨부파일 Customisation Sync → 테마, 플러그인, Obsidian 설정 LiveSync MAIN/DATA → 각 장치의 연결·상태 정보 이 세 가지를 한 덩어리로 생각하면 복구가 어려워진다. 설정을 다시 만들거나 서버 DB를 재구축하면 보조 기기에서 다음과 비슷한 경고를 볼 수 있다. The remote database has been rebuilt 이 잠금은 고장이 아니라 안전장치다. 원격 DB의 정체성이 바뀌었는데 기존 로컬 상태로 계속 복제하면 대량 삭제나 중복이 발생할 수 있으므로 플러그인이 동기화를 멈춘다. 보조 기기에 동기화되지 않은 고유 노트가 없고 원격 서버가 기준 상태라면 다음 순서로 복구한다. Fetch everything again from remote → Overwrite all with remote files → Delete local files if not on remote 단순히 Unlock remote database만 누르는 것은 권하지 않는다. 로컬과 원격이 정말 같은 상태라는 확신이 없다면 전체 재수신이 안전하다. Customisation Sync만 초기화하려는 경우에는 일반 LiveSync 연결까지 지우면 안 된다. Hatch → Reset → Delete all customization sync data Back to non-configured는 LiveSync 연결 자체를 초기화하므로 목적이 다르다. cd ~/docker/obsidian-livesync docker compose ps docker logs --tail 100 obsidian-livesync-couchdb set -a source .env set +a curl -fsS \ -u "$COUCHDBUSER:$COUCHDBPASSWORD" \ http://127.0.0.1:5984/up docker volume ls | grep obsidian-livesync ss -ltnp | grep 5984 바로 재생성하기 전에 백업과 변경 사항을 확인한다. cd ~/docker/obsidian-livesync docker compose pull docker compose up -d 업데이트 후에는 반드시 docker compose ps, /up, 외부 HTTPS 주소를 다시 확인한다. Named Volume은 컨테이너와 데이터를 분리할 뿐 백업이 아니다. 서버 디스크가 망가지면 볼륨도 함께 사라진다. 최소한 두 종류의 백업이 필요하다. 1. 기준 기기의 Obsidian 볼트 백업 2. CouchDB 데이터 또는 데이터베이스 복제 백업 가장 중요한 것은 기준 기기의 실제 Markdown 볼트다. E2EE를 사용하는 CouchDB 데이터만 백업해 두고 암호를 잃어버리면 복구할 수 없다. 운영 환경에서는 CouchDB의 replication 기능으로 별도 CouchDB 인스턴스에 복제하거나, 컨테이너를 안전하게 멈춘 뒤 볼륨을 스냅샷하는 방식을 고려할 수 있다. 어떤 방식을 택하든 복원 테스트를 해 보지 않은 백업은 절반짜리다. Self-hosted LiveSync는 꽤 만족스럽지만 공짜 Obsidian Sync 복제품이라고만 생각하면 실망할 수 있다. 내가 직접 책임져야 하는 것이 늘어난다. - Ubuntu와 Docker 업데이트 - CouchDB 보안 업데이트 - Cloudflare Tunnel 장애 - 인증 정보와 E2EE 암호 보관 - 볼륨 및 볼트 백업 - 기기 추가와 충돌 복구 - 커뮤니티 플러그인 업데이트 확인 반대로 얻는 것도 분명하다. - 동기화 서버의 위치와 운영 방식을 내가 결정한다. - 이미 가진 서버를 활용하면 추가 비용이 거의 없다. - Mac, iPhone, iPad 사이에서 거의 실시간으로 동기화된다. - 각 기기의 볼트는 여전히 로컬 Markdown 파일이다. - CouchDB 인증과 LiveSync E2EE를 함께 사용할 수 있다. 서버를 운영하고 싶지 않다면 공식 Obsidian Sync가 더 싸게 먹힐 수 있다. 시간을 비용으로 계산하면 특히 그렇다. 하지만 이미 홈서버를 운영하고 있고, 문제를 직접 추적하는 과정까지 즐긴다면 Self-hosted LiveSync는 좋은 선택이다. 이번 작업에서 가장 어려웠던 부분은 CouchDB 컨테이너를 띄우는 일이 아니었다. docker compose up -d는 몇 분이면 끝난다. 진짜 중요한 일은 어느 기기를 원본으로 삼을지 정하고, 동기화 엔진을 겹치지 않으며, 모바일 기기를 하나씩 안전하게 참가시키는 것이었다. 그리고 세 가지를 분리해서 이해해야 했다. 노트 동기화 설정 동기화 LiveSync 플러그인 자체의 장치별 설정 이 구분만 지켜도 대부분의 사고를 피할 수 있다. 지금 내 서버에서는 CouchDB 3.5가 Docker로 실행되고 있다. 포트는 127.0.0.1:5984에만 열려 있고, 외부에서는 Cloudflare Tunnel의 HTTPS 주소로 접근한다. MacBook이 기준 기기이고 iPhone과 iPad는 같은 원격 DB에 참가한다. LiveSync의 MAIN과 DATA는 설정 동기화 대상에서 제외했다. 결국 내가 원했던 것은 단순히 무료 동기화가 아니었다. 내 노트는 각 기기의 로컬 Markdown으로 남기고, 그 사이를 연결하는 서버도 내가 운영하는 구조였다. 약간의 수고를 감수한 대신, 데이터가 어디를 지나고 어떻게 저장되는지 설명할 수 있게 됐다. - [Self-hosted LiveSync GitHub](https://github.com/vrtmrz/obsidian-livesync) - [Self-hosted LiveSync CouchDB 초기화 스크립트](https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh) - [Apache CouchDB Docker 설치 문서](https://docs.couchdb.org/en/stable/install/docker.html) - [Apache CouchDB Replication 문서](https://docs.couchdb.org/en/stable/replication/intro.html) - [Cloudflare Tunnel 문서](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/)

AIDevelopInfra

내 우분투 서버에 Hermes로 내부 대시보드 만들고 운영하기

2026.07.11

요약: 중요한 건 Docker로 띄웠다는 사실이 아니라, 내 우분투 서버 위에서 Hermes Agent를 실행기로 삼아 내부 운영 대시보드를 직접 만들고 계속 고쳐가며 쓰고 있다는 점이다. 내 우분투 서버에는 이것저것 많이 올라가 있다. Hermes Agent, n8n, Docker 서비스, Cloudflare Tunnel, Telegram 알림, 내부 프로젝트들. 처음엔 각각 따로 확인했다. docker ps systemctl status something journalctl -u something curl localhost:3000 이런 식으로. 물론 가능은 하다. 근데 자주 쓰다 보면 금방 귀찮아진다. 서비스가 죽었는지, 포트가 열려 있는지, n8n은 정상인지, Hermes gateway는 살아 있는지 매번 터미널에서 확인하는 게 은근히 번거롭다. 그래서 생각했다. 내가 쓰는 서버 상태를 한 화면에서 보면 되지 않을까? 처음부터 거창한 운영툴을 만들 생각은 아니었다. Datadog이나 Grafana 같은 걸 붙이고 싶은 것도 아니었다. 그냥 내가 매일 보는 정보만, 내 방식대로 정리된 작은 내부 대시보드가 필요했다. 이 대시보드는 Docker로 배포돼 있다. 하지만 이 글에서 중요한 건 Docker가 아니다. Docker는 그냥 실행 방식이다. 컨테이너로 띄우면 재시작하기 쉽고, 환경이 고정되고, 서버에서 관리하기 편하니까 쓴다. 그 이상도 이하도 아니다. 진짜 중요한 건 이 대시보드를 내가 Hermes와 대화하면서 만들고 운영한다는 점이다. 예전 같으면 대시보드를 고치려면 이렇게 해야 했다. 1. 서버 접속 2. 프로젝트 폴더 이동 3. 코드 수정 4. 빌드 5. Docker 재빌드 6. 브라우저 확인 7. 문제 있으면 다시 수정 지금은 흐름이 다르다. 나: Hermes 페이지에서 토큰 사용량 카드 빼줘. Hermes: 코드 수정 → 빌드 → Docker 재시작 → 브라우저 확인 나: 프로젝트별 Vercel 링크가 공통 대시보드로 가는데 고쳐줘. Hermes: Vercel API 확인 → 동적 URL 생성 → 빌드 → 실제 링크 검증 나: Penpot 완전히 삭제해. 대시보드에서도 빼고. Hermes: Docker 스택 삭제 → 볼륨 확인 → 대시보드 코드 제거 → 404 검증 대시보드는 사람이 직접 보는 화면이지만, 운영 방식은 거의 AI 에이전트 중심으로 바뀌었다. 내 우분투 서버의 주요 상태, Hermes, n8n, Vercel 프로젝트를 한 화면에서 보는 내부 대시보드. 처음에는 욕심이 많았다. 서버 상태, 토큰 사용량, 프로젝트 카드, n8n 상태, Hermes 상태, 배포 링크, 설정값까지 다 보여주고 싶었다. 근데 막상 써보니 자주 보는 정보는 몇 개 안 됐다. - 서버가 살아 있는가 - Docker 컨테이너가 정상인가 - n8n이 떠 있는가 - Hermes gateway가 살아 있는가 - 주요 프로젝트 링크가 맞는 곳으로 가는가 - 문제가 생겼을 때 어디부터 봐야 하는가 그래서 대시보드도 점점 줄이는 방향으로 갔다. 필요 없는 카드는 빼고, 자주 누르는 링크는 앞으로 가져오고, 설정 화면도 실제로 쓰는 값만 남겼다. 이게 개인 내부 도구의 장점이다. 일반 사용자를 설득할 필요가 없다. 내 사용 패턴에 안 맞으면 바로 지우면 된다. 처음 화면은 조금 산만했다. 페이지마다 정보는 있는데, 어디에 뭐가 있는지 한눈에 안 들어왔다. 그래서 좌측 메뉴를 만들었다. 참고한 건 Linear다. 어두운 배경, 작은 메뉴, 튀지 않는 구분선, 현재 위치만 조용히 강조되는 구조. 내부 도구는 예쁘게 꾸미는 것보다 계속 보기 편한 것이 중요하다. 너무 화려하면 금방 질린다. 반대로 너무 밋밋하면 어디를 눌러야 할지 모르겠다. 그래서 지금 구조는 대충 이렇다. Overview Server Hermes n8n Settings 많지 않다. 이 정도면 충분하다. Hermes Agent 상세 화면. Gateway, Cron, Memory, 세션 상태를 따로 확인할 수 있게 만들었다. 내가 Hermes를 서버에 올려두고 가장 크게 체감한 건 이 부분이다. 단순히 코드를 써주는 게 아니다. 서버 안에서 실제로 실행하고 확인한다. 예를 들면 이런 작업 흐름이다. 1. 코드 수정 2. npm run lint 3. npm run build 4. docker compose build 5. docker compose up -d 6. 브라우저에서 실제 페이지 확인 7. DOM이나 API 응답까지 검증 이 과정이 중요하다. 내부 대시보드는 말만 그럴듯하면 안 된다. 실제 서버에서 떠야 하고, 내가 브라우저로 봤을 때 맞아야 한다. 특히 예전에 Vercel 링크를 고칠 때 이 차이가 컸다. 처음엔 링크가 전부 https://vercel.com/dashboard로 가고 있었다. 보기엔 Vercel 버튼이 있으니 괜찮아 보였지만, 실제로 누르면 각 프로젝트 Overview가 아니라 공통 대시보드로 이동했다. Hermes는 이걸 하드코딩으로 때우지 않고, Vercel API에서 팀 slug와 프로젝트명을 받아와서 동적으로 URL을 만들게 고쳤다. 그리고 브라우저에서 실제 링크 href까지 확인했다. 이런 게 사람이 직접 하면 귀찮은데, 에이전트가 하면 딱 맞는 작업이다. 개인 도구를 만들 때 의외로 중요한 건 추가보다 삭제다. Penpot도 한동안 서버에 올려뒀지만 결국 지웠다. 안 쓰는 서비스는 서버 리소스만 먹고, 대시보드에도 쓸데없는 카드가 남는다. 그래서 이렇게 요청했다. Penpot 완전히 삭제하려고. 내 대시보드에서도 삭제하고. Hermes가 한 일은 단순히 카드 하나 지운 게 아니었다. - Penpot Docker 컨테이너 삭제 - 관련 볼륨 삭제 - /home/duncan/docker/penpot 디렉토리 삭제 - 대시보드의 Penpot 카드 제거 - Settings의 Penpot URL 설정 제거 - /server/penpot 페이지 삭제 - 실제로 404가 나는지 확인 - 메인 페이지 HTML에 penpot 문자열이 남았는지 확인 이런 식으로 서비스 제거도 하나의 운영 작업이 된다. 그냥 화면에서 안 보이게 숨기는 게 아니라, 서버와 코드 양쪽에서 실제로 정리하는 것이다. 이 대시보드는 아직 대단한 제품이 아니다. 남에게 팔 수 있는 SaaS도 아니고, 범용 모니터링 도구도 아니다. 하지만 나한테는 의미가 크다. 서버에 뭔가 문제가 생기면 예전에는 내가 직접 들어가서 봐야 했다. 지금은 먼저 물어본다. 예지야, 메모리 왜 올라갔어? 그러면 Hermes가 free, ps, docker stats, systemctl을 확인해서 원인을 찾아준다. 실제로 VS Code Remote 서버가 맥북에서는 꺼졌는데 우분투에 남아 메모리를 쓰고 있던 것도 이렇게 잡았다. 예지야, 이 프로세스 뭐야? 그러면 dockerd인지, Codex app-server인지, n8n node 프로세스인지 확인하고 설명해준다. 대시보드는 이 구조의 시각적 입구다. 내가 직접 볼 수도 있고, Hermes가 수정할 수도 있고, 필요 없는 건 지울 수도 있다. 내가 만든 건 단순한 서버 상태판이 아니다. 조금 더 정확히 말하면: 내 우분투 서버를 AI 에이전트가 이해하고 조작할 수 있게 만드는 운영 인터페이스 에 가깝다. 브라우저 화면은 사람을 위한 인터페이스고, Hermes는 실행을 위한 인터페이스다. 둘이 같은 서버 위에 있으니 작업 흐름이 짧아진다. 사람이 본다 → 문제를 말한다 → Hermes가 고친다 → 서버에서 검증한다 → 대시보드에 반영된다 이 순환이 생긴 게 제일 크다. 아직 하고 싶은 건 많다. - Docker 컨테이너별 메모리/CPU 추이 - n8n 워크플로우 실패 알림 요약 - Hermes gateway 상태와 최근 에러 로그 - GitHub Actions / Vercel 배포 상태 - Linear 이슈 자동화 상태 - 내 서버에서 돌고 있는 장기 프로세스 목록 - “정리해도 되는 서비스” 추천 하지만 전부 한 번에 붙일 생각은 없다. 내부 도구는 많이 넣는 순간 다시 안 쓰게 된다. 필요가 생길 때 하나씩 붙이고, 안 쓰는 건 바로 빼는 게 맞다. 이 대시보드를 만들면서 느낀 건 하나다. 개인 서버 운영에서 중요한 건 도구를 많이 붙이는 게 아니다. 내가 실제로 운영하는 방식에 맞게, 서버와 AI 에이전트와 화면을 하나의 흐름으로 묶는 것이다. Docker는 그중 실행 방식일 뿐이다. 핵심은 내 우분투 서버 위에 Hermes가 있고, 내가 그 Hermes에게 말하면 서버가 실제로 바뀐다는 점이다. 이제 내 서버는 그냥 접속해서 관리하는 서버가 아니라, 대화하면서 고쳐가는 작업 환경에 가까워지고 있다. 아직 거칠지만, 이 방향은 꽤 마음에 든다.

AIDevelop

헤르메스 에이전트(Hermes Agent)와 n8n 조합 개꿀

2026.07.07

요약: n8n은 공개 이벤트를 받고 최소 payload만 넘기는 브릿지로 두고, Hermes Agent가 Linear MCP로 이슈 본문과 댓글을 직접 읽어 작업·검증·완료 처리까지 맡게 했다. n8n으로 [사내 챗봇](/posts/n8ngwa-nosyeoneuro-gucughaneun-rag-giban-sanae-ai-caesbos-gaideu)도 만들어봤고, 캘린더 비서도 만들어봤다. 트리거 받아서 API 몇 개 호출하고 조건 분기하는 자동화는 n8n이 정말 잘한다. 근데 어느 순간 벽에 부딪혔다. 판단이 필요한 작업, 예를 들어 "이 PR 리뷰 코멘트 보고 실제로 코드 고쳐라", "이 Linear 이슈 보고 관련 파일 찾아서 초안 작성해라" 같은 건 n8n 노드 조합으로는 안 된다. AI Agent 노드를 넣어봐도 결국 프롬프트 하나 던지고 텍스트 응답 받는 수준이지, 파일 시스템을 만지거나 여러 단계를 거쳐 뭔가를 실제로 완성시키는 건 완전히 다른 이야기였다. 그래서 최근에 [홈 서버에 올려둔 Hermes Agent](/posts/hom-seobeoe-hermes-agent-olryeobwassda-sabjil-girog)를 n8n 뒤에 실행기로 붙이는 구조를 시험해봤는데, 써보니 이 조합 진짜 개꿀이다. 아이디어 자체는 별거 없다. n8n은 바깥 세상과 접촉하는 레이어, Hermes는 안쪽에서 실제로 일하는 레이어로 나누는 거다. n8n (공개/트리거 레이어) Linear webhook, GitHub webhook, Slack 메시지, 스케줄 트리거를 받는다. 외부 서비스(Notion, Slack, Google 등)와의 인증/연동을 관리한다. 들어온 이벤트를 정제해서 "이 작업을 해줘"라는 형태로 다듬는다. 결과를 다시 Slack이나 Linear 코멘트로 되돌려주는 것도 n8n 몫이다. Hermes Agent (신뢰된 내부 실행기) 실제 판단이 필요한 작업, 즉 코드 수정, 파일 탐색, 여러 단계 추론이 필요한 작업을 맡는다. 세션 간 메모리를 유지하니까 "저번에 하던 그 작업 이어서 해줘" 같은 것도 가능하다. 내부적으로 Claude Code를 오케스트레이션해서 실제 리포지토리를 건드리는 작업(브랜치 생성, 커밋, PR 초안)까지 위임한다. 서버 로컬 파일 시스템, 내부 DB, 사내 API에 직접 접근할 수 있는 유일한 레이어다. 결국 n8n이 못 하는 "진짜 일"은 전부 Hermes로 넘기고, n8n은 그 앞단에서 트래픽을 정리하고 결과를 사람이 보는 채널로 되돌리는 배달원 역할만 하는 셈이다. 여기서 제일 신경 쓴 부분은 Hermes를 절대 인터넷에 직접 노출하지 않는다는 거다. 내 서버 구조는 이렇다. [외부 웹훅/사용자] │ (HTTPS, 공개) ▼ ┌────────┐ │ n8n │ ← 공개 진입점. Cloudflare/Nginx 리버스 프록시 뒤 └───┬────┘ │ (Docker bridge network, 내부 전용) ▼ ┌──────────────┐ │ hermes-agent │ ← 외부 포트 없음. n8n 컨테이너에서만 접근 가능 └──────────────┘ │ ▼ Claude Code / 로컬 파일시스템 / 내부 DB 중요한 건 세 가지다. 1. n8n만 공개 도메인/포트를 가진다. Hermes gateway는 Docker bridge 네트워크 내부 IP로만 열려 있고, 호스트에 포트를 바인딩하지 않는다. 2. 맥북에서 관리용으로 붙을 때는 Tailscale을 쓴다. 이건 나만 접근하는 관리 채널이고, n8n → Hermes 트래픽과는 완전히 다른 경로다. 3. 인증 경계 자체가 다르다. n8n은 webhook URL + secret 정도로 외부 요청을 받지만, Hermes 쪽 엔드포인트는 n8n 컨테이너의 내부 네트워크 접근 자체가 인증이다. 외부에서 Hermes를 직접 때릴 방법이 물리적으로 없다. 이렇게 나누면 "공개된 표면적"과 "실제로 시스템을 조작할 수 있는 권한"이 분리된다. n8n 웹훅이 뚫려도 공격자가 할 수 있는 건 n8n 워크플로우 안에서 노출한 것뿐이고, Hermes가 가진 파일 시스템/Claude Code 실행 권한까지는 못 건드린다. 여기가 이번에 제일 많이 갈아엎은 부분이다. 처음엔 n8n이 이슈 본문까지 통째로 읽어서 정리한 다음 Hermes한테 던지는 구조로 짰는데, 쓰다 보니 이건 방향이 틀렸다는 걸 깨달았다. n8n이 이슈 내용을 파싱하고 가공하기 시작하면, 결국 "판단"의 일부를 n8n이 떠안게 된다. 그러면 Hermes를 붙인 의미가 없어진다. 그래서 최종적으로는 n8n을 얇은 공개 브릿지로만 쓰기로 했다. n8n은 Linear 이벤트를 받아서, "이슈가 예지(Yeji)한테 새로 할당/위임됐는지"만 필터링한다. 본문이나 코멘트 내용은 절대 읽지도, 가공하지도 않는다. 이슈 ID, 팀, 액션 타입 정도만 들어간 최소 payload를 만든다. 여기에 요청 ID와 내부 secret을 붙여서 Hermes webhook(linear-yeji-agent-session)으로 그대로 던진다. 실제 이슈 본문/코멘트/컨텍스트는 Hermes가 Linear MCP로 직접 가져온다. getissue, listcomments로 전체 맥락을 읽고, 어떤 리포지토리 작업인지 매핑한 다음 Claude Code를 오케스트레이션해서 실제로 작업하고, 결과를 검증한 뒤 savecomment로 Linear에 코멘트를 남기고 saveissue로 상태를 Done으로 바꾼다. Linear 이벤트 → n8n: 예지 위임 여부 필터 → n8n: 최소 payload 생성 (issue id, request id, secret) → POST → Hermes webhook (linear-yeji-agent-session) → Hermes: Linear MCP로 getissue / listcomments → Hermes: 리포지토리 매핑 → Claude Code 오케스트레이션 → Hermes: 결과 검증 → savecomment → Hermes: saveissue (status: Done) n8n은 최소한의 이벤트 정보만 정리해서 호출하고 끝이다. 처음엔 중간에 별도 워커 하나를 두고 n8n이 그 워커 상태를 폴링하는 과도기 구조도 써봤는데, 정리하고 보니 n8n → Hermes webhook → Linear MCP로 바로 이어지는 쪽이 훨씬 깔끔했다. 그 워커는 어디까지나 전환기용 런처였고, 이 조합의 핵심은 아니다. GitHub PR에 리뷰 코멘트가 달리면 n8n이 받아서 Hermes에 넘긴다. Hermes가 Claude Code로 실제 코드를 고치고 커밋 푸시까지 하면, n8n이 PR에 "반영 완료" 코멘트를 남긴다. 물론 이건 신뢰도가 높은 저장소/브랜치에서만, 그리고 항상 PR 형태로 남겨서 최종 머지는 사람이 하도록 걸어뒀다. 배포 파이프라인이 실패하면 n8n이 로그를 모아서 Hermes에 "이 로그 보고 원인 추정해줘"라고 요청하고, Hermes가 분석 결과를 정리해서 Slack으로 알림을 보낸다. 이건 판단만 하고 실행은 안 하는 케이스라 권한 경계가 특히 느슨해도 되는 예시다. 이건 최종 구조는 아니고, 중간에 잠깐 곁길로 샜던 실험이다. n8n과 Hermes를 같은 서버에 올려두면, Hermes가 n8n 워크플로우 파일이나 API에 직접 접근해서 워크플로우 자체를 만들어줄 수도 있지 않을까 싶어서 시험해본 적이 있다. 실제로 프롬프트 한 번으로 대충 동작하는 워크플로우가 나오긴 했다. 근데 이건 결국 메인 구조로 채택하지 않았다. Hermes가 n8n 워크플로우 정의까지 손대기 시작하면 경계가 다시 흐려진다 — n8n은 얇은 공개 브릿지로 남아있어야 하는데, 그 브릿지 자체를 내부 실행기가 마음대로 고쳐 쓸 수 있으면 "공개 표면적 = n8n만"이라는 전제가 깨진다. 그래서 최종적으로는 3번(Linear 예지 위임)에서 정리한 것처럼, Hermes는 Linear MCP로 이슈를 읽고 쓰는 것까지만 맡고, n8n 워크플로우 자체는 사람이 관리하는 쪽으로 정리했다. 재미있는 실험이었지만 핵심 얘기는 아니다. 쓰기 권한은 항상 브랜치/PR 단위로 제한한다. Hermes가 main에 직접 푸시하는 경로는 아예 만들지 않았다. n8n → Hermes 요청에도 내부 전용 토큰을 넣는다. 네트워크가 분리돼 있어도 컨테이너 하나가 뚫렸을 때를 대비한 이중 방어다. Hermes가 접근 가능한 리포지토리/디렉토리를 명시적으로 화이트리스트해뒀다. 아무 경로나 건드릴 수 있게 열어두지 않는다. 실행 결과는 항상 사람이 보는 채널(Slack, Linear 코멘트)로 되돌아오게 해서, 뭔가 이상하게 돌아가면 바로 눈에 띄게 만들었다. n8n이 이슈 본문을 읽고 가공하던 초기 구조. 처음엔 n8n이 이슈 제목/본문/코멘트를 다 정리해서 Hermes한테 넘기는 식으로 짰다. 근데 이러면 n8n이 "무슨 내용인지 이해하고 정리하는" 역할까지 떠안게 되고, Hermes는 그냥 실행만 하는 처지가 된다. 결국 n8n은 필터링과 최소 payload 조립까지만 하고, 본문/코멘트는 Hermes가 Linear MCP로 직접 읽어오는 구조로 정리했다. 중간에 잠깐 거쳐간 별도 워커. n8n과 Hermes 사이에 상태를 관리하는 중간 워커를 하나 두고 폴링하는 과도기 구조를 써본 적이 있다. 하지만 최종 방향은 n8n → Hermes webhook → Linear MCP로 바로 잇는 쪽이다. 책임이 줄고, 장애 지점도 줄고, 설명도 쉬워졌다. Docker bridge 네트워크 이름 충돌. n8n과 Hermes를 각각 다른 docker-compose로 올렸더니 기본 네트워크가 분리돼서 서로 못 봤다. 명시적으로 external network를 하나 만들어서 두 컴포즈 파일이 같은 네트워크를 참조하게 고쳐서 해결했다. 권한을 너무 넓게 줬던 것. 처음엔 편의를 위해 Hermes에 파일 시스템 접근을 넓게 줬다가, 이건 아니다 싶어서 필요한 만큼만 남기고 다시 좁혔다. 자동화가 편해질수록 권한 경계는 오히려 더 타이트하게 잡아야 한다는 걸 이번에 체감했다. 처음 이 조합을 시작한다면 이 정도 구조부터 권한다. 1. Docker Compose로 n8n + Hermes를 같은 internal network에 올린다. Hermes는 호스트 포트 바인딩 없이, n8n 컨테이너에서만 접근 가능하게. 2. n8n은 리버스 프록시 뒤에서 공개 webhook만 받는다. HTTPS + secret 검증은 필수. 3. n8n은 이벤트 필터링과 최소 payload 조립까지만 한다. 이슈 본문/코멘트 같은 실제 컨텍스트는 Hermes가 Linear MCP로 직접 가져오게 하고, n8n은 절대 그 내용을 읽거나 실행하지 않는다. 4. Hermes의 쓰기 권한은 브랜치/디렉토리 화이트리스트로 제한하고, 결과는 항상 사람이 확인하는 채널(Linear 코멘트 등)로 되돌린다. 5. 관리자 접근(나)은 Tailscale로만, n8n → Hermes 트래픽과 완전히 분리된 경로를 유지한다. 이 다섯 가지만 지켜도 "n8n은 얼굴, Hermes는 손발" 구조를 안전하게 돌릴 수 있다. n8n만으로는 트리거와 연동은 되는데 진짜 판단이나 실행은 안 됐고, Hermes만 쓰자니 외부 서비스 연동과 트리거 관리가 번거로웠다. 둘을 역할별로 나눠 붙이니까 각자 잘하는 것만 하게 됐다. 처음엔 n8n이 이슈 내용을 정리해서 넘겨주는 구조로 짰다가, 그게 결국 n8n이 판단의 일부를 떠안는 거라는 걸 깨닫고 방향을 바꿨다. 지금은 n8n이 필터링과 최소 payload 전달까지만 하고, Hermes가 Linear MCP로 직접 컨텍스트를 읽고 작업하고 코멘트 남기고 이슈를 Done으로 바꾸는 데까지 전부 맡는 구조로 정리됐다. n8n: 공개 트리거, 이벤트 필터링, 최소 payload 전달 Hermes: 신뢰된 내부 실행기, Linear MCP로 컨텍스트 조회/코멘트/상태 변경, Claude Code 오케스트레이션 경계: Docker internal network + 화이트리스트 권한 + Tailscale 관리 채널 아직 실전 검증이 더 필요한 구간이 남아있지만, 방향 자체는 확실히 맞다고 본다. 작업 환경: Ubuntu 미니PC, n8n (Docker), Hermes Agent (Nous Research), Claude Code, Tailscale

AITools

폰으로 서버를 제어한다 — Claude Code Remote Control 세팅기

2026.06.22

폰으로 홈 서버에 채팅을 걸면, 서버 위의 Claude가 실행하고 결과를 돌려준다. MacBook 없이 10분 세팅으로 된다. 나는 Intel N100 미니PC에 Ubuntu 24를 올려서 홈 서버(duncan-ubuntu)를 운영하고 있다. n8n 자동화를 비롯해 여러 도커 컨테이너들이 거기서 돌아간다. 문제는 외부에서 서버를 제어하려면 MacBook을 꺼내야 했다는 거다. Tailscale이 깔려 있어서 어디서든 SSH는 가능하다. 그런데 SSH로 터미널을 여는 건 폰에서 하기엔 번거롭다. 코드도 입력해야 하고, 작은 화면에 터미널 UX는 그렇게 좋지 않다. 내가 원했던 건 이거였다: 이동 중에 폰으로 서버 상태를 물어보고 싶다 "n8n 컨테이너 재시작해줘"처럼 자연어로 작업을 시키고 싶다 결과를 텍스트로 받아보고 싶다 그러니까 터미널 에뮬레이터가 아니라, 서버 위의 AI 에이전트를 폰에서 채팅으로 제어하는 것. Claude Code Remote Control이 그 역할을 한다. Claude Code는 CLI 기반 AI 코딩 에이전트다. 터미널에서 실행하면 파일을 읽고, bash를 실행하고, 코드를 수정하는 걸 Claude가 알아서 한다. Remote Control은 여기에 붙는 기능이다. 서버에서 실행 중인 Claude Code 세션을 외부 기기(폰, 다른 맥)에서 채팅 UI로 연결할 수 있다. Claude 앱을 열면 "Code" 탭에 연결된 세션이 나타나고, 거기서 채팅하면 실제로 서버 위에서 명령이 실행된다. 구조를 단순하게 그리면 이렇다: [폰 Claude 앱] ←→ [Anthropic 릴레이] ←→ [duncan-ubuntu의 claude 프로세스] 서버를 인터넷에 직접 노출하지 않고, Anthropic 릴레이를 통해 연결된다. Tailscale 없이도 된다. Claude Pro 또는 Max 플랜 (API 키 방식 불가, claude.ai OAuth 로그인 필수) 서버: Ubuntu 등 Linux 계열 (macOS도 됨) 폰: Claude 앱 설치 (iOS / Android) 서버에 SSH로 접속한 뒤: curl -fsSL https://claude.ai/install.sh | bash 설치 확인: claude --version claude auth login claude.ai 계정으로 OAuth 로그인. API 키 방식으로 연결된 환경이라면 먼저 해제해야 한다: unset ANTHROPICAPIKEY tmux 없이도 Remote Control은 된다. 그러나 SSH 연결이 끊기면 프로세스도 같이 죽는다. 상시 연결을 원한다면 tmux가 필수다. sudo apt install tmux -y tmux new -s claude tmux 세션 안에서: claude Claude Code가 실행되면, 프롬프트에서: /remote-control duncan-ubuntu 세션 이름은 원하는 이름으로 지으면 된다. 실행하면 연결 URL과 QR 코드가 출력된다. Claude 앱 → Code 탭 → 좌측 사이드바에 방금 만든 세션이 보인다. 탭하면 연결 완료. 혹은 서버에서 출력된 QR을 폰 카메라로 스캔해도 된다. 연결되면 채팅창에 타이핑한 내용이 서버의 Claude Code로 전달된다. Claude는 필요하면 bash를 실행하고, 파일을 읽고, 결과를 돌려준다. 실제로 쓰는 것들: 서버 상태 확인 docker ps 해줘 컨테이너 목록, 상태, 업타임을 정리해서 보여준다. 컨테이너 재시작 n8n 재시작해줘 docker restart n8n-n8n-1을 실행하고 결과를 알려준다. 로그 확인 n8n 최근 에러 로그 보여줘 docker logs 결과에서 에러만 필터링해서 가져온다. 디스크/메모리 상태 디스크 남은 용량 확인해줘 간단한 파일 작업 /etc/hosts에 뭐가 들어있어? 요약하면, 터미널에서 SSH로 하던 작업 대부분을 자연어 채팅으로 할 수 있다. 푸시 알림은 안 된다. Routines(클라우드 스케줄 에이전트)는 Remote Control 세션과 분리된 환경에서 실행되기 때문에, 작업 완료 알림이 폰으로 오지 않는다. 정기 알림이 필요하면 n8n cron → Slack으로 별도 구성하는 게 낫다. 서버 재시작 후 세션이 죽는다. tmux 세션이 살아있어도 서버가 재부팅되면 다시 접속해서 claude → /remote-control을 실행해야 한다. systemd 서비스로 자동 시작을 구성할 수 있지만 아직 직접 해보진 않았다. OAuth 로그인 필수. Hermes처럼 API 키로 Anthropic API를 쓰는 에이전트 시스템과는 별개다. Remote Control은 claude.ai 계정 기반이라 구독 플랜이 필요하다. 복잡한 셋업 없이 10분 안에 끝난다. Tailscale로 이미 서버에 붙을 수 있는 환경이라면 추가 네트워크 설정은 전혀 필요 없다. 이동 중에 서버에 뭔가 물어보거나, 간단한 작업을 시켜야 할 때 MacBook을 꺼낼 필요가 없어졌다는 게 체감상 제일 크다. 코딩 에이전트라는 포지셔닝 때문에 개발 용도로만 생각하기 쉬운데, 서버 운영·관리 용도로도 충분히 유용하다. 설치 환경: Ubuntu 24.04, Claude Code v2.1.183+, Claude Pro/Max 플랜

AIDevelopInfra

홈 서버에 Hermes Agent 올려봤다 — 삽질 기록

2026.06.09

요약: Ubuntu 미니PC에 AI 에이전트를 상시 실행 서비스로 올리는 게 이렇게 간단할 줄 몰랐다. 그리고 이렇게 삽질할 줄도 몰랐다. AI 에이전트 툴을 한 번 제대로 써보고 싶다는 생각은 오래 전부터 있었다. 조건은 하나였다. 에이전트는 서버에서 돌고, 나는 맥북에서 앱으로 붙는 구조. SSH 열고 터미널 띄우는 게 아니라, 앱 켜면 바로 연결되는 리모트 환경. 코딩할 때 잠깐 켜는 도구가 아니라, 서버에서 상시 돌면서 슬랙으로 명령 받고 결과 뱉는 구조도 함께 원했다. 후보는 여러 개였는데 최종적으로 둘을 비교했다. OpenClaw — GitHub 스타 수가 어마어마하고 생태계가 넓다. 심지어 Hermes에는 hermes claw migrate라는 마이그레이션 명령이 있을 정도로 인지도 있는 도구다. 근데 코드베이스가 거대하고, 내가 필요한 건 그 1%도 안 될 것 같았다. Hermes Agent — Nous Research 작. 세션 간 메모리 지속, 자가 스킬 생성, 멀티 메시징 플랫폼 지원. MIT 라이선스, 데이터는 내 머신에만. 결국 Hermes를 선택한 이유는 세 가지였다. 첫째, 맥북 전용 데스크탑 앱이 있고 리모트 서버에 붙는 구조를 공식 지원한다. Tailscale IP만 입력하면 앱에서 바로 연결된다. 둘째, Slack 연동이 네이티브로 된다. 셋째, LLM 프로바이더를 고정하지 않는다 — OpenRouter, Nous Portal, Anthropic, 자체 엔드포인트까지 hermes model 한 줄로 전환 가능하다. 집에서 운영 중인 Ubuntu 미니PC(duncan-ubuntu, Intel N100, RAM 16GB)에 올렸다. 이미 Ghost CMS, n8n이 각자 전용 유저로 격리돼서 돌고 있는 구조라, Hermes도 똑같은 패턴으로 붙이기로 했다. 접근은 Tailscale로 한다. 포트 직접 열지 않아도 된다. sudo useradd -m -s /bin/bash hermes sudo su - hermes curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash 끝이다. 진짜로. 설치 스크립트가 Python 3.11 가상환경, Node.js, 의존성 전부 알아서 세팅해준다. 2분도 안 걸렸다. 설치 후 LLM 프로바이더와 Slack 연동 설정은 두 가지 방법 중 하나로 한다. hermes setup nano ~/.hermes/.env 나는 .env를 직접 편집했다. 프로바이더 API 키와 Slack 봇 토큰을 여기에 넣으면 된다. Slack 연동 설정하고 테스트 메시지 보냈더니 바로 에러가 떴다. slacksdk.errors.SlackApiError: missingscope 원인은 Slack Bot Token 스코프 설정 누락이었다. 처음에 기본 권한만 주고 넘어갔는데, 채널/그룹 목록을 읽으려면 추가 스코프가 필요했다. Slack App 설정 → OAuth & Permissions 에서 아래 스코프를 추가하고 Bot Token 재발급 후 ~/.hermes/.env 업데이트. channels:read groups:read mpim:read im:read 이거 하나 때문에 30분 썼다. 에러 메시지는 명확했는데 어디서 추가하는지 처음엔 헷갈렸다. 처음엔 서비스가 하나인 줄 알았다. 근데 구조를 보니 둘이었다. | 서비스 | 역할 | 없으면? | | --- | --- | --- | | hermes-gateway | Slack/Telegram 등 연동, 실제 에이전트 루프 | 메시징 명령 안 먹힘 | | hermes-dashboard | 맥북 데스크탑 앱 연결용 웹소켓 백엔드 | 앱 접속 불가, Slack은 정상 | Slack만 쓸 거면 gateway만 올려도 된다. 나는 맥북 데스크탑 앱도 쓰고 싶어서 둘 다 systemd로 등록했다. [Unit] Description=Hermes Agent Gateway After=network-online.target Wants=network-online.target [Service] User=hermes Group=hermes ExecStart=/home/hermes/.hermes/hermes-agent/venv/bin/python -m hermescli.main gateway run WorkingDirectory=/home/hermes/.hermes EnvironmentFile=/home/hermes/.hermes/.env Restart=always RestartSec=5 [Install] WantedBy=multi-user.target [Unit] Description=Hermes Agent Dashboard After=network.target hermes-gateway.service [Service] User=hermes EnvironmentFile=/home/hermes/.hermes/.env ExecStart=/home/hermes/.local/bin/hermes dashboard --host 100.x.x.x Restart=always RestartSec=5 [Install] WantedBy=multi-user.target --host에는 Tailscale IP를 넣는다. systemctl enable 해두면 서버 재부팅 후 자동 실행된다. 한 가지 더 — gateway 서비스의 ExecStart는 반드시 venv 절대경로로 써야 한다. hermes gateway처럼 CLI 명령만 쓰면 PATH 해석이 꼬이는 경우가 있었다. 데스크탑 앱에서 Gateway 설정에 Tailscale IP와 대시보드 포트(기본 9119)를 입력하면 끝. VPN(Tailscale) 없이 외부 포트 열 필요 없다는 게 제일 마음에 드는 부분이다. 보안 신경 쓸 게 크게 줄었다. 서버에 이미 Betterstack Heartbeat 크론이 돌고 있어서, Hermes gateway용 항목을 하나 추가했다. /5 systemctl is-active --quiet hermes-gateway && curl -s https://uptime.betterstack.com/api/v1/heartbeat/[TOKEN] /dev/null systemctl is-active --quiet 조건을 걸어서, 서비스가 죽어 있으면 핑을 안 보내게 했다. Betterstack이 핑 끊기면 알람 날려주는 구조. 솔직히 말하면 아직 업무에 깊게 붙이진 못했다. 설치하고 Slack 연동되는 걸 확인하는 것까지가 이번 라운드였다. 다만 구조를 보면서 느낀 건, 이게 단순한 챗봇 래퍼가 아니라는 점이다. 세션이 끊겨도 프로젝트 맥락을 기억하고, 문제 해결 패턴을 스킬로 저장한다. 이미 내 서버에서 돌면서 스스로 스킬을 쌓아가고 있다. 지금 n8n으로 돌리는 자동화 파이프라인들을 점진적으로 이쪽으로 옮기거나 연계하는 방향을 고민 중이다. 아직 검증이 더 필요하지만, 일단 인프라 세팅 허들은 생각보다 낮았다는 건 확실히 말할 수 있다. 설치: curl 한 줄, 체감 난이도 낮음. 인스톨러가 venv·Node까지 알아서 처리 인증: LLM 프로바이더 API 키 방식 (.env 또는 hermes setup 마법사) 삽질 구간: Slack 스코프 설정, gateway/dashboard 역할 구분, systemd ExecStart 경로 운영 구조: systemd 서비스 + Tailscale + Betterstack 조합 현재 상태: 세팅 완료, 실사용 검증 중 다음엔 실제로 업무 자동화에 붙여본 결과를 써볼 예정이다. 설치 환경: Ubuntu 24.04, Hermes Agent (Nous Research), Slack Socket Mode

AIDevelop

Text-to-SQL: "매출 지난달이랑 비교해줘" 한 마디로 BigQuery의 데이터 분석 및 리포트 작성

2026.06.04

TL;DR — SQL을 몰라도 된다. 자연어로 분석을 요청하면 AI가 SQL을 자동 생성하고, BigQuery에 직접 쿼리를 날려 결과를 돌려주는 Text-to-SQL 분석 시스템을 구축했다. 핵심은 Claude Projects + BigQuery MCP + 커스텀 Analytics Skill의 조합이다. 데이터가 쌓이면 질문이 생긴다. "이번 달 채널별 매출 비중이 어떻게 돼?" "지난 3개월 평균이랑 비교하면 어때?" "해외 환자 유입이 전년 대비 얼마나 늘었어?" 이 질문들의 공통점은 답이 데이터베이스 안에 있다는 것이다. 그런데 문제가 있었다. 데이터를 꺼내려면 SQL을 알아야 한다. 우리팀은 분석 요청이 생길 때마다 업무 리소스가 발생했다. 요청 → 전달 → 쿼리 작성 → 결과 전달의 사이클이 반복됐고, 특히 복잡한 분석을 요청하는 경우 쿼리 작성 시간이 길어졌다. ​우리팀은 더 게을러지기 위해 이 Claude Project를 만들었다. Text-to-SQL은 자연어(Natural Language) 질의를 SQL로 자동 변환하는 기술이다. NL2SQL이라고도 부른다. 개념 자체는 오래됐지만, LLM(대형 언어 모델)의 등장으로 실용적인 수준에 도달했다. 특히 테이블 스키마와 비즈니스 컨텍스트를 함께 제공하면, 모델이 도메인에 맞는 정확한 SQL을 생성할 수 있게 됐다. 이 방식을 선택한 이유는 단순하다. 별도 UI 개발 없이 구현 가능 기존 BigQuery 인프라를 그대로 활용 비개발자도 즉시 사용 가능 질문의 형태와 깊이에 제한이 없음 세 가지 컴포넌트가 핵심이다. MCP(Model Context Protocol)는 AI 모델이 외부 서비스와 직접 통신할 수 있게 해주는 프로토콜이다. BigQuery MCP를 연결하면 Claude가 SQL을 생성하는 것에 그치지 않고, BigQuery에 직접 쿼리를 실행하고 결과를 받아올 수 있다. 기존 Text-to-SQL의 한계는 "SQL을 생성해주면 사람이 직접 실행해야 한다"는 것이었다. MCP는 이 단계를 없앤다. 자연어 질의부터 분석 결과까지 중간에 사람이 개입하지 않아도 된다. Claude의 Skill은 특정 도메인에 대한 지식과 규칙을 패키징한 컨텍스트 파일이다. tu-analytics Skill에는 다음이 담겨 있다. 스키마 정보 (schema.md) 테이블 구조, 컬럼 설명, 데이터 타입. Claude가 올바른 SQL을 생성하려면 테이블이 어떻게 생겼는지 알아야 한다. 지표 정의 (tables.md / 분석 지침) 단순한 컬럼 설명을 넘어, 비즈니스 지표의 정확한 계산식을 정의한다. 예를 들어 "객단가"는 단순 평균이 아니라 특정 필터 조건을 만족하는 행만 포함해야 한다. 이런 규칙이 없으면 SQL은 실행되지만 결과가 틀린다. 쿼리 패턴 (querypatterns.md) 자주 사용하는 분석 패턴을 템플릿화했다. 영업일 기준 동기 비교, 국내/해외 분리 집계, 누적 영업일 필터링 등 반복되는 로직을 표준화해두면 매번 처음부터 만들지 않아도 된다. 특수 케이스 (specialcases.md) 데이터의 예외 상황과 주의사항. 예를 들어 특정 컬럼에 NULL이 들어오는 경우 어떻게 처리해야 하는지, 중복 제거는 어떤 기준으로 하는지 등이 명시돼 있다. 이 Skill이 없으면 Claude는 일반적인 SQL은 만들 수 있지만, 이 데이터베이스의 맥락에 맞는 정확한 SQL은 만들기 어렵다. Claude Projects는 특정 프로젝트에 대한 지식과 설정을 지속적으로 유지할 수 있는 환경이다. tu-analytics Skill을 프로젝트에 연결해두면, 매번 컨텍스트를 다시 설명하지 않아도 된다. 대화가 길어지거나 세션이 바뀌어도 테이블 구조, 지표 정의, 분석 원칙이 유지된다. 질의 예시: "5월 국내/해외 매출 전월 동기 대비 비교해줘" 내부 처리 과정: 1. Claude가 Skill을 참조해 관련 테이블과 컬럼을 파악 2. "전월 동기"의 의미를 영업일 기준으로 해석 (달력 날짜 기준이 아님) 3. 국내/해외 분리 집계 쿼리 생성 (nationalitytype 기준) 4. BigQuery MCP로 쿼리 실행 5. 결과 반환 → 증감 계산 + 시각화 + 인사이트 해석 핵심은 3번과 4번 사이에 사람이 없다는 것이다. 결과 리포트 예시 "매출이 얼마야?"라는 질문은 단순해 보이지만, 어떤 컬럼을 쓰느냐에 따라 숫자가 달라진다. 고객납부액인지, 매출귀속액인지, 세금 포함/제외인지. 이 정의가 Skill에 명확하게 박혀 있어야 일관된 결과가 나온다. 수식 하나하나를 명문화하는 작업이 시스템 구축보다 더 시간이 걸렸다. "전월 대비"를 단순 날짜 수로 비교하면 안 된다. 영업일 수가 다른 달을 날짜 기준으로 비교하면 오해를 부른다. 이 시스템은 영업일 기준 동기 비교를 표준으로 정의하고, 모든 기간 비교에 이 원칙을 적용한다. 당월 / 전월 동기 / 직전 3개월 평균 / 전년 동기 — 네 가지 비교 기준을 항상 함께 제공하는 것도 규칙으로 정해뒀다. 분석 결과가 매번 다른 형태로 나오면 보는 사람이 불편하다. 스코어카드 → 차트 → 표 요약 → 인사이트의 순서를 표준화하고, 증감 표기 방식(▲/▼, 색상 코드)도 규칙으로 정의했다. 완벽하지 않다. LLM 기반 SQL 생성은 복잡한 조건이 중첩될수록 오류 가능성이 높아진다. 이 때문에 중요한 분석은 결과를 검증하는 습관이 필요하다. 컨텍스트 품질이 전부다. Skill에 정의된 스키마와 지표가 부정확하면 SQL도 틀린다. "쓰레기가 들어가면 쓰레기가 나온다(GIGO)"는 여기서도 그대로 적용된다. 초기 Skill 작성과 지속적인 업데이트가 시스템 품질을 결정한다. MCP는 현재 사용자 단위 인증이다. BigQuery MCP는 개인 인증 기반으로 동작한다. 조직 전체가 동일한 연결을 공유하는 구조가 아니므로, 팀 단위 배포 시에는 이 점을 고려해야 한다. SQL을 모르는 팀원도 분석 질문을 직접 던질 수 있게 됐다. 분석 요청 → 결과 확인의 사이클이 대폭 줄었고, "물어보기 애매해서 참던" 질문들이 올라오기 시작했다. 데이터 민주화라는 말이 있다. 데이터를 다루는 기술이 없어도 데이터에서 인사이트를 얻을 수 있게 되는 것. Text-to-SQL이 그 방향으로 한 걸음 가까워지게 해준 기술이라고 생각한다. 사용 기술: Claude Projects, BigQuery MCP, Model Context Protocol (MCP), Google BigQuery 관련 키워드: Text-to-SQL, NL2SQL, Conversational Analytics, AI Analytics, Data Democratization

AIDevelop

회사 캘린더 비서 만들기 (feat. n8n, Gemini API)

2026.05.12

잔디에 말 걸면 알아서 회의실 예약해주는 AI 비서, 직접 만들어봤습니다. 우리 회사는 회의실 예약을 Google Calendar로 관리한다. 문제는 예약할 때마다 캘린더 앱을 열고, 시간대를 확인하고, 이벤트를 직접 만들어야 한다는 것. 팀원들이 회의실 예약하러 캘린더 들어가는 걸 귀찮아하는 걸 보고 생각했다. "잔디에서 그냥 말로 하면 안 되나?" 그래서 만들었다. 잔디 채팅창에 "내일 오후 2시에 대회의실 1시간 잡아줘" 라고 보내면, AI가 알아서 캘린더에 예약을 넣어주는 시스템. \[이미지: n8n 워크플로우 전체 화면\] 사용한 스택은 단순하다. n8n — 워크플로우 자동화 허브 Google Gemini — 자연어 해석 + 도구 호출 판단 Google Calendar API — 실제 예약 실행 잔디(Jandi) — 사용자 인터페이스 (채팅) 흐름은 이렇다. 잔디 메시지 → n8n Webhook → AI Agent (Gemini) → Google Calendar → 잔디 응답 AI Agent가 중심이고, 상황에 따라 4가지 Calendar 도구 중 하나를 선택해서 실행한다. | 도구 | 기능 | | --- | --- | | createconferenceroomevent | 새 회의실 예약 생성 | | updateconferenceroomevent | 기존 예약 수정 | | deleteconferenceroomevent | 예약 삭제 | | searchconferenceroom | 예약 현황 조회 | 잔디는 특정 채널에 메시지가 오면 외부 URL로 POST 요청을 보내는 Incoming/Outgoing Webhook을 지원한다. 잔디 팀 설정에서 Outgoing Webhook을 추가한다. 채널: 봇이 응답할 채널 선택 트리거 단어: 설정하지 않으면 모든 메시지에 반응 (봇 전용 채널 추천) URL: n8n Webhook URL 입력 \[이미지: 잔디 Outgoing Webhook 설정 화면\] 잔디가 보내는 POST body 구조는 다음과 같다. json { "token": "...", "teamName": "tugether", "roomName": "회의실봇", "writerName": "홍길동", "text": "내일 오후 2시에 대회의실 1시간 잡아줘", "writer": "user@company.com" } n8n에서 잔디로 응답을 보낼 때는 잔디의 Incoming Webhook URL을 사용한다. 잔디 채널 설정 → Incoming Webhook 추가 → URL 복사. 나중에 n8n에서 HTTP Request 노드로 이 URL에 POST하면 된다. json { "body": "✅ 예약 완료! 내일 오후 2~3시 대회의실을 예약했습니다." } n8n에서 새 워크플로우를 만들고 Webhook 노드를 추가한다. HTTP Method: POST Path: 원하는 경로 (예: calendar-bot) Response Mode: Respond to Webhook 노드로 분리 팁: Response Mode를 "Last Node"로 하면 AI 처리가 끝날 때까지 HTTP 연결을 물고 있어야 한다. Gemini 응답이 느릴 수 있으니 "Respond to Webhook" 노드를 별도로 두고 먼저 잔디에 응답하는 구조를 추천한다. Webhook URL을 복사해서 잔디 Outgoing Webhook에 붙여넣으면 1단계 연결 완료. n8n에서 Google Calendar 노드를 쓰려면 OAuth2 인증이 필요하다. 1. [Google Cloud Console](https://console.cloud.google.com/?ref=haas.kr) → API & Services → Credentials 2. OAuth 2.0 클라이언트 ID 생성 (웹 애플리케이션) 3. 승인된 리디렉션 URI에 n8n OAuth 콜백 URL 추가 형식: https://[n8n도메인]/rest/oauth2-credential/callback 4. 클라이언트 ID / Secret 복사 n8n → Credentials → Google Calendar OAuth2 API → 위 값 입력 후 연결. AI Agent의 Tool 포트에 Google Calendar 노드 4개를 연결한다. 각 노드 설정: create\conference\room\event Operation: Create Calendar: 회의실 캘린더 선택 나머지 필드: AI가 채워줌 (Expression으로 설정) update\conference\room\event Operation: Update Event ID: AI가 검색해서 넘겨줌 delete\conference\room\event Operation: Delete search\conference\room Operation: Get All 시간 범위: AI가 판단해서 설정 각 노드의 이름을 도구 이름과 동일하게 지정해야 AI Agent가 올바르게 인식한다. 워크플로우의 핵심. AI Agent 노드를 추가하고 설정한다. AI Agent의 Chat Model 포트에 Google Gemini Chat Model 노드를 연결한다. Model: gemini-2.0-flash (속도/비용 균형) API Key: Google AI Studio에서 발급한 Gemini API 키 AI Agent의 System Prompt가 전체 품질을 결정한다. 아래는 실제 사용 중인 프롬프트 구조다. 너는 00(test.ai)의 전문 회의실 예약 비서야. 사용자의 입력({{ $json.body.data }})과 작성자 정보({{ $json.body.writer.email }})를 바탕으로 [예약/수정/삭제] 업무를 수행해. 1. 의도 분류: 사용자의 요청이 '신규 예약', '시간/제목 수정', '예약 취소(삭제)' 중 무엇인지 판단해. 2. 시간 계산: 현재 시간({{ $now }})을 기준으로 시작/종료 시간을 ISO8601 형식으로 변환해. (타임존: Asia/Seoul) - 별도 언급 없으면 종료 시간은 시작 시간 1시간 뒤로 설정. '2시간 동안' 등 기간 언급 시 그에 맞게 계산. 3. 제목 생성: 제목 언급이 없으면 "{{ $json.body.writer.name }}님의 회의"로 자동 생성. 4. 회의실 ID 매핑: (언급 없으면 '10층 대회의실' 기본값) - 10층 대회의실/큰방: '~~8757c93@group.calendar.google.com' - 10층 소회의실/작은방: '~~b2d83@group.calendar.google.com' - 8층 대회의실/미팅룸: '~~549@group.calendar.google.com' 5. 참석자: 본문의 <@이메일 멘션을 추출해 'getmemberemails'로 이메일을 확보하고 작성자({{ $json.body.writer.email }})를 포함해. - 모든 액션 전 'searchroomschedules' 도구를 호출해 해당 날짜의 전체 일정을 가져와. - [예약/수정 시]: 요청한 시간대에 겹치는 일정이 있는지 확인해. (수정의 경우 본인 기존 일정은 제외) - [수정/삭제 시]: 기존 일정 중 사용자가 말한 시간/제목과 일치하는 'Event ID'를 찾아내. 1. 신규 예약: 중복이 없을 때만 'createroomevent' 호출. 2. 일정 수정: 'Event ID'가 확인되고 변경 시간대에 중복이 없을 때 'updateroomevent' 호출. 3. 일정 삭제: 'Event ID'가 확인되면 'deleteroomevent' 호출. 모든 응답은 친절한 한국어로 하며 아래 형식을 지켜줘. ✅ 성공 시: [예약 성공 / 수정 완료 / 삭제 완료] 되었습니다. - 회의실: [회의실 이름] - 일시: [시작시간] ~ [종료시간] - 제목: [회의 제목] - 참석자: [이름/이메일 리스트] ❌ 중복/실패 시: 요청하신 [회의실 이름]은 이미 아래 일정이 잡혀 있어 처리가 불가능합니다. [해당 날짜 전체 일정 리스트] - [시작~종료] : [제목] (이후 "다른 시간으로 도와드릴까요?"라고 마무리) AI Agent의 User Message에는 잔디에서 받은 텍스트를 넘긴다. {{ $json.body.text }} 잔디 Outgoing Webhook이 text 필드에 메시지를 담아서 보내기 때문에 이렇게 받는다. AI Agent 처리가 끝나면 Respond to Webhook 노드로 잔디에 응답을 돌려준다. Response Body: AI Agent의 출력값 {{ $json.output }} 동시에 잔디 Incoming Webhook으로도 별도 POST를 보내면 채널에 메시지가 표시된다. 잔디에서 예약 요청 메시지 보내는 화면 잔디에서 AI 응답이 돌아오는 화면 실제로 이런 요청들이 동작한다. "내일 오후 3시에 소회의실 2시간 잡아줘" → 중복 확인 후 예약 생성 "이번 주 금요일 대회의실 예약 취소해줘" → 검색 후 삭제 "다음 주 월요일 오전에 어느 회의실 비어 있어?" → 가용 시간 조회 "아까 예약한 거 4시로 바꿔줘" → 이벤트 찾아서 업데이트 잔디에서 Webhook이 안 불릴 때 잔디 Outgoing Webhook은 Public URL이 필요하다. 로컬 n8n은 Cloudflare Tunnel이나 ngrok으로 외부 노출이 필요하다. Gemini가 도구를 안 쓸 때 시스템 프롬프트에 "반드시 도구를 사용하라"는 지시를 명시적으로 추가한다. Gemini는 도구 사용 여부를 자체 판단하므로 프롬프트로 유도해야 한다. 날짜 계산이 틀릴 때 시스템 프롬프트에 {{ $now }}를 포함해서 현재 시각을 명시적으로 알려줘야 한다. 이게 없으면 Gemini가 날짜를 잘못 계산하는 경우가 생긴다. 이중 예약 문제 create 전에 반드시 search로 확인하도록 프롬프트에 강제하는 게 핵심이다. Freebusy API를 별도로 연동하면 더 정확하게 처리할 수 있다. n8n + Gemini 조합으로 생각보다 빠르게 실용적인 AI 비서를 만들 수 있었다. 코드 한 줄 없이 노드 연결만으로 자연어 처리부터 Calendar API 호출까지 완성된다는 게 n8n의 강점이다. 비슷한 구조로 HR 문의 봇, 공지사항 자동 발송, 리포트 조회 봇 등으로 확장 가능하다. 잔디 대신 Slack이나 카카오워크를 Webhook으로 연결하면 그대로 이식된다. 전체 워크플로우 JSON은 추후 공유할 예정이다.

Biz

CAC와 LTV: PM이 반드시 알아야 할 단위 경제학

2026.05.10

《7가지 코드》(구글·마이크로소프트·메타 빅테크 PM 3인 공저) 中 '단위 경제학' 챕터를 읽고 정리한 내용입니다. "고객이 늘고 있는데 왜 회사는 돈을 못 버는 거지?" 이 질문에 답할 수 있는 지표가 바로 CAC와 LTV다. 단순히 사용자 수를 보는 것만으로는 비즈니스의 수익성을 제대로 파악하기 어렵다. 1달러짜리를 90센트에 팔면 누구나 많은 고객을 얻을 수 있다. 《7가지 코드》는 바로 이 함정을 피하기 위해 단위 경제학(Unit Economics)의 핵심 지표인 CAC와 LTV를 이해해야 한다고 강조한다. [Brunch](https://brunch.co.kr/@bluemarble/47?ref=haas.kr) 신규 고객 1명을 확보하는 데 드는 총비용이다. CAC = (마케팅비 + 영업비) / 신규 고객 수 예를 들어, 1억 원짜리 마케팅 캠페인으로 신규 고객 1만 명을 확보했다면 CAC = 10,000원. 신규 고객을 유치하는 데에는 마케팅비와 영업비만 드는 게 아니다. 제품을 만드는 사람에게 급여를 지급해야 하고, 제품을 실행하는 데 필요한 서버 비용을 부담해야 하며, 경우에 따라 고객지원팀도 고용해야 한다. [Brunch](https://brunch.co.kr/@bluemarble/47?ref=haas.kr) 분자뿐 아니라 분모도 마찬가지다. 분모에 어떤 고객을 포함시키느냐에 따라 CAC는 완전히 달라진다. 무료 사용자를 포함할 것인가? 1주일 만에 이탈한 고객은? 유료 전환 고객만 카운트할 것인가? CAC = (마케팅비 + 영업비 + 도구/인프라 비용) / 신규 유료 고객 수 ← 모두 동일한 기간(X개월) 기준 → 기간을 명시하고, 관련된 모든 비용을 포함시켜야 비로소 의미 있는 숫자가 된다. 한 고객이 서비스를 이용하는 동안 벌어들일 것으로 예상되는 이익의 총액이다. 여기서 핵심은 수익(Revenue)이 아닌 이익(Profit)이라는 점이다. LTV = (연간 ARPU × 매출총이익률) / 이탈률(Churn Rate) ARPU: Average Revenue Per User, 사용자당 평균 수익 매출총이익률: 매출에서 직접비용(서버비, 인건비 등)을 뺀 비율 이탈률: 연간 또는 월간 서비스 이용을 중단한 사용자 비율 연간 이탈률이 50%라면, 일반 사용자의 경우 2년간 고객으로 남아있을 것이다. 즉, 이탈률이 낮을수록 LTV는 올라간다. [Brunch](https://brunch.co.kr/@bluemarble/47?ref=haas.kr) LTV는 고객당 '한계수익'이고, CAC는 '한계비용'이며, 두 값의 차는 한계이익이다. 기업은 이 한계이익이 0보다 큰 상태를 유지해야 한다. [Brunch](https://brunch.co.kr/@bluemarble/47?ref=haas.kr) | LTV : CAC | 상태 | 해석 | | --- | --- | --- | | 1 : 1 | 위험 | 겉보기엔 손익분기 같지만, CAC에 모든 비용을 포함하지 않았다면 사실상 손실 중 | | 1.25 : 1 | 경고 | LTV가 CAC보다 높아졌지만 마진이 너무 얇음. 즉각적인 개선 필요 | | 3 : 1 | 황금 비율 | 실리콘밸리의 마법의 숫자. 수익성과 성장성 균형 | | 5+ : 1 | 과잉 절약 | 마케팅에 더 투자해야 함. 성장 기회를 놓치고 있을 가능성 | LTV가 CAC보다 3배 이상 더 큰 값이 되기를 기대한다. 종종 마법의 숫자로 불리기도 하는 이 3:1 비율은 월가와 실리콘밸리 사람들의 마음속 깊이 자리하고 있다. [Brunch](https://brunch.co.kr/@bluemarble/47?ref=haas.kr) 반대로 이론적으로 5대 1의 LTV/CAC 비율은 훌륭해 보일 수 있지만, 영업과 마케팅에 자원을 최대로 활용하지 않고 있다는 것을 나타낼 수 있다. CAC에 더 많은 자금을 투자하지 않으면 큰 성장 기회를 놓칠 수 있다. [ZUZU](https://zuzu.network/resource/blog/ratio-of-ltv-cvc/?ref=haas.kr) LTV보다 CAC가 높은 상황에서는 팔면 팔수록 손해를 보게 되므로, LTV보다 높은 마케팅/세일즈 예산을 책정해서는 안 된다. [Velog](https://velog.io/@yooseungkim/Finance-Unit-Economics-%ED%95%9C-%EB%AA%85%EC%9D%98-%EA%B3%A0%EA%B0%9D%EC%9D%84-%EB%B3%B4%EB%A9%B4-%EC%A0%84%EC%B2%B4%EA%B0%80-%EB%B3%B4%EC%9D%B8%EB%8B%A4?ref=haas.kr) 목표 LTV/CAC 비율을 3:1로 설정했다면 역산이 가능하다. LTV = 100만 원 → CAC 상한선 = 약 33만 원 HubSpot은 MRR Churn이 3.5%에서 1.5%로 줄어들면서 LTV:CAC 비율이 1.7에서 4.7까지 드라마틱하게 상승했다. LTV를 높이는 가장 직접적인 레버는 바로 이탈률 감소다. [Velog](https://velog.io/@yooseungkim/Finance-Unit-Economics-%ED%95%9C-%EB%AA%85%EC%9D%98-%EA%B3%A0%EA%B0%9D%EC%9D%84-%EB%B3%B4%EB%A9%B4-%EC%A0%84%EC%B2%B4%EA%B0%80-%EB%B3%B4%EC%9D%B8%EB%8B%A4?ref=haas.kr) 업력이 짧은 스타트업은 고객의 라이프타임이 얼마나 긴지 알기 어렵기 때문에 LTV를 계산하기 어렵다. 그럴 때는 LTV 대신 투자 회수 기간(Payback Period) 개념을 활용하면 된다. [Yozm](https://yozm.wishket.com/magazine/detail/2102/?ref=haas.kr) 현금 흐름을 최적화하고자 하는 회사의 경우 12개월 이내에 CAC를 복구할 것을 권장한다. [For Entrepreneurs](https://www.forentrepreneurs.com/ko/ltv/?ref=haas.kr) 《7가지 코드》를 읽으면서 PM이 단순히 기능을 기획하는 사람이 아니라는 걸 다시 한번 느꼈다. LTV와 CAC를 제대로 이해할 때 창의적인 비즈니스 모델을 만들 수 있다. 높은 CAC를 갖고 있더라도 더 높은 LTV를 실현할 수 있는 사업 전략만 있다면 별문제가 없다. [Brunch](https://brunch.co.kr/@bluemarble/47?ref=haas.kr) 결국 PM은 제품의 수익 구조를 숫자로 이해하고, 그 숫자를 움직이는 전략을 설계하는 사람이어야 한다. CAC를 낮추고, LTV를 높이고, 그 비율을 3:1 황금 비율에 가깝게 유지하는 것. 이것이 지속 가능한 제품을 만드는 단위 경제학의 핵심이다. 참고: 《7가지 코드 — 구글·마이크로소프트·메타 빅테크 PM은 이렇게 일한다》 中 '단위 경제학' 챕터

Tools

NotePlan이란 무엇인가-2주사용 후기

2026.05.01

나의 Second Brain을 만들기 위한 수만은 검색과 공부의 결과로 결정하여 사용 중인 [NotePlan - Tasks, Notes, and Calendar](https://noteplan.co/?ref=haas.kr)이라는 앱을 소개한다. 이번 포스트에서는 대략적인 기능들만 간단히 소개하고, 추 후에 기능 하나하나 소개하는 포스트도 올리겠다. 1. 마크다운을 기반으로 해야한다. 2. [Obsidian](https://obsidian.md/?ref=haas.kr) 처럼 로컬에 .md 파일로 저장되어 관리가 되어야 한다. 3. 내가 주로 사용하는 Mac, iPad, iPhone에 연동이 되어야 한다. 4. 애플캘린더와 연동이 되어야 한다. 5. 손글씨(애플팬슬)가 되어야 한다. → 설교 노트 작성... 1. [Obsidian](https://obsidian.md/?ref=haas.kr) 노트안에서 손글씨가 불가능 캘린더 연동이 불편함 2. [Reflect Notes](https://reflect.app/?ref=haas.kr) 노트안에서 손글씨가 불가능 로컬에 .md 파일로 저장되어 관리가 안됨(only 클라우드) 3. [ProNotes](https://www.pronotes.app/?ref=haas.kr) 최후까지 고려함 캘린더 연동 기능이 없어 탈락 내가 원하는 노트 조건을 모두 충족함 NotePlan은 할 일, 노트, 캘린더를 하나로 통합하고 있는 앱이다. → 애플/구글 캘린더 + 애플 리마인더 연동 독일 개발자가 만들고 있으며, 사용자 요청을 적극 반영하고 있다. Markdown 기반으로 빠르고 간편하게 작업하고 있다. macOS, iPhone, iPad + Web에서 모두 연동하여 사용 할 수 있다. 개인적으로는 로컬에 파일을 저장 하고 이미지 업로드같은 것도 아주 편하게 알아서 설정 해준다. 예를 들면 1. 위와 같이 ~attachments라는 폴더로 같은 경로에 자동으로 생성이 된다. 2. 내가 해당 .md 파일을 이동하면 저 이미지 폴더는 자동으로 따라온다. 👏 파일 경로로 가본다 이미지를 붙여넣는다(노션에 붙여 넣는 것 처럼 편하게) 1. 어마어마한 캘린더 연동 기능 [https://youtu.be/4j9-2O44g3w](https://youtu.be/4j9-2O44g3w?ref=haas.kr) 영상에서 처럼 실시간으로 NotePlan에서 Task와 일정을 등록 하면 애플캘린더에 연동되며, 반대로 애플캘린더에 등록한 일정은 바로 NotePlan에 보여진다. 완전한 실시간 연동을 보여준다. 2. Obsidian 보다 편한 로컬 파일 관리 시스템 내가 정말 감탄한 것은 로컬 파일(.md) 관리 시스템 방식이다. ~기존에 코드에디터로 마크다운 파일을 관리하는 포멧에서 거의 벗어나지 않게 관리한다.~ 하지만 App 내에서의 사용 편의성은 너무 편하게 만들었다. 위와 같은 장점으로 GitHub Blog의 포스트를 쓰기 편하다. 3. 손글씨 가능 나는 교회에서의 설교노트는 모두 손글씨로 메모하고 아카이빙한다. NotePlan에서는 손글씨를 아주 편하게 작성 할 수 있고, 아이패드와 맥의 연동성도 최고다.(하기 \예시) 심지어 AI를 통해 손글씨를 텍스트화 할 수 있다. 4. 매우 편하고 쉬운 Templates 기능 나는 데일리노트, 설교노트, QT노트. 심지어 미팅록까지 템플릿으로 만들어 사용 중인데, 정말 편리하다 5. 노트 외부 웹 공유 노션과 같이 노트를 외부 URL로 공유 가능 하다 [NotePlan Publish](https://noteplan.co/n/7EE1A78F-6D19-4C52-B776-ED6A703AFCAE?ref=haas.kr) 6. PARA 정리법에 대한 상세한 가이드와 세팅 PARA에 대한 가이드가 상세히 정리 되어 있고, 이를 활용하여 누구나 쉽게 나의 Second Brain을 만들 수있게 해주었다. \예시 1. Web버전에서의 연동 불안성 공식적으로는 Web버전이 지원되나, 사용해봤을때 저장이 안되었던 경험이 좀 있었다. 그 이후로 불안해서 사용을 못하는데, 테스트 단계라서 그런 것 같다 2. 한글 미지원 공식적으로 한글을 지원하지 않는다. 내가 열심히 커뮤니티에 말하고 있다 3. 한국 커뮤니티 부재 한국 유저가 많이 없는 것 같고, 한국 유저의 커뮤니티가 많이 없다. 정말 괜찮은 앱이다. 아직 2주밖에 사용하지 않았지만, 사용하면서 큰 불편함이나 이슈가 없었고, 내가 사용 하는 프로그램을 줄이고 있다. NotePlan 하나로 내가 원하는 것을 다 하고 있다. 기능들을 상세하게 정리하면서 한국에 전파하겠다!

AIDevelop

n8n과 노션으로 구축하는 RAG 기반 사내 AI 챗봇 가이드

2026.03.18

자사에서는 업계 특성상 임직원의 입/퇴사가 잦은 편이고, 이로 인한 자세한 온보딩 여력이 되지 않는다. 그로인한 다양한 임직원의 문의는 인사팀의 업무 리소스로 이어지며, 이를 해결하기 위해 사내 AI 봇을 기획하고 만들게 되었다. [Make](https://www.make.com/en?ref=haas.kr) 와 [Zapier](https://zapier.com/?ref=haas.kr) 같이 다양한 애플리케이션 간의 자동화를 no code로 도와주는 서비스 이다. 하지만, 위 툴들과 다르게 [n8n](https://n8n.io/?ref=haas.kr)이 요즘 각광을 받는 이유는 한 가지다. 바로 '셀프호스팅'을 제공한다. 즉, 내가 DevOps 지식만 있다면, 거의 무료로 자동화 시스템을 구축 할 수 있다는 말이다. 처음에 기획 했을때는 RAG가 아닌 단순히 AI Agent 노드로 제작 하였다. 노션에 올라가 있는 데이터가 많지도 않고, 따라서 성능이슈는 없을 것으로 예상했다. AI Agent 독립사용 워크플로우 이미지 1. 노션 페이지의 블록 누락 이슈 이번에 정확히 알게된 사실인데, 노션 데이터를 json으로 받게 되면, 페이지에 그냥 있는 text의 depth와 콜아웃, 토글, 표 등의 블록의 depth가 달라 데이터를 읽어오는 과정에서 이를 모두 고려해야 한다. 그래서 위 이미지를 보면 "데이터베이스 Get - 각각 페이지 ID 추출 - 각 페이지 Get - 각 페이지에서 블록 추출" 하는 로직이 필요하다. 이 과정에서 난 모든 블록을 고려하는 것은 포기하고 표와 텍스트만 가져오기로 했다. 위 문제는 Notion Node를 이용하면 비교적 간단하게 해결이 될 수도 있지만 일일히 설정 해야 하는 것은 변함이 없다. 2. 성능 이슈(속도) 노션에서 데이터를 조회하는 노드가 4개가 있고, 이렇게 하여도 사용자가 채팅을 입력할 때마다 전체 노션 데이터를 실시간으로 조회해야 한다. 데이터가 적을 때는 큰 문제가 없었지만, 페이지가 늘어날수록 응답 지연이 눈에 띄게 증가했다. 매 요청마다 Notion API를 여러 번 호출하는 구조 자체가 근본적인 병목이었다. 위 두 가지 문제를 해결하기 위해 아키텍처를 완전히 재설계했다. 노션 데이터를 매 요청마다 가져오는 대신, 주기적으로 벡터 DB에 임베딩해두고 AI Agent가 이를 Tool로 검색하는 방식으로 전환했다. 전체 플로우 워크플로우는 크게 두 개의 독립적인 플로우로 나뉜다. Schedule Trigger → Get many database pages (DB1) → Get many database pages1 (DB2) → Delete Collection (Qdrant 컬렉션 초기화) → Merge (append) → Filter → Loop Over Items → HTTP Request (Notion 페이지 본문 조회) → Code in JavaScript (텍스트 가공) → Qdrant Vector Store (임베딩 저장) ← Embeddings Google Gemini ← Default Data Loader 주요 포인트: Schedule Trigger로 주기적(예: 매일 새벽)으로 실행된다. 실행 시작 시 Delete Collection으로 기존 벡터 데이터를 전부 삭제 후 재적재한다. 이렇게 하면 노션에서 수정/삭제된 내용이 자동으로 반영된다. 여러 노션 DB를 Merge (append)로 합쳐 단일 파이프라인으로 처리한다. Filter로 불필요한 페이지(미완성, 비공개 등)를 걸러낸다. Loop Over Items로 각 페이지를 순회하며 Notion API(HTTP Request)로 본문을 가져온다. Code in JavaScript로 블록 타입별 텍스트 추출 및 포맷팅을 처리한다. 최종적으로 Qdrant Vector Store에 Google Gemini 임베딩과 함께 저장한다. When chat message received → AI Agent ← Google Gemini Chat Model ← Simple Memory ← Qdrant Vector Store2 (Tool) ← Embeddings Google Gemini1 주요 포인트: 사용자 메시지를 받으면 AI Agent가 동작한다. Qdrant Vector Store를 Tool로 연결하여, Agent가 필요할 때만 벡터 검색을 수행한다. Simple Memory로 대화 히스토리를 유지해 멀티턴 대화가 가능하다. Notion API 호출이 전혀 없으므로 응답 속도가 획기적으로 빨라진다. | 항목 | 개선 전 (AI Agent 단독) | 개선 후 (RAG + Qdrant) | | --- | --- | --- | | 데이터 조회 시점 | 매 요청마다 실시간 | 주기적 사전 적재 | | 응답 속도 | 느림 (Notion API 다중 호출) | 빠름 (벡터 검색) | | 블록 누락 | 있음 | HTTP Request로 직접 조회하여 감소 | | 최신 데이터 반영 | 즉시 | 스케줄 주기에 따라 반영 | | 비용 | Notion API 호출 많음 | 임베딩 비용 발생 (저렴) | Notion Node 대신 HTTP Request를 사용한 이유는 유연성 때문이다. Notion API의 /blocks/{blockid}/children 엔드포인트를 직접 호출하면 블록 타입을 코드로 직접 핸들링할 수 있다. // 블록 타입별 텍스트 추출 예시 const items = $input.all(); const results = []; for (const item of items) { // HTTP Response body 추출 const body = item.json.body ?? item.json; // pageId와 pageTitle은 Loop의 현재 아이템에서 가져오기 // (Loop Over Items가 넘겨준 원본 페이지 정보) const pageId = item.json.pageId ?? item.json.id ?? body?.results?.[0]?.parent?.pageid ?? ""; const pageTitle = item.json.pageTitle ?? item.json.title ?? "Untitled"; const lastEdited = item.json.lasteditedtime ?? ""; // block results 배열 추출 const blocks = body?.results ?? []; // 각 블록에서 텍스트 추출 const textParts = []; for (const block of blocks) { const type = block.type; const blockData = block[type]; if (!blockData) continue; // richtext 배열이 있는 블록 타입들 처리 const richText = blockData.richtext ?? []; const blockText = richText .map(rt = rt.plaintext ?? rt.text?.content ?? "") .join(""); if (blockText.trim()) { // 헤딩 타입은 앞에 마크다운 표시 if (type === "heading1") textParts.push(# ${blockText}); else if (type === "heading2") textParts.push(## ${blockText}); else if (type === "heading3") textParts.push(### ${blockText}); else if (type === "bulletedlistitem") textParts.push(• ${blockText}); else if (type === "numberedlistitem") textParts.push(- ${blockText}); else textParts.push(blockText); } } const bodyText = textParts.join("\n"); results.push({ json: { text: 제목: ${pageTitle}\n\n[본문]\n${bodyText}, metadata: { pageId, pageTitle, chunkIndex: 0, source: "notion", lastEdited, } } }); } return results; 매 실행마다 컬렉션을 삭제 후 재생성하는 방식은 단순하지만 효과적이다. 업데이트/삭제 추적 로직 없이도 항상 노션과 동기화된 상태를 유지할 수 있다. 데이터 양이 많아지면 upsert 방식으로 전환을 고려할 수 있다. n8n + Notion + Qdrant + Google Gemini 조합으로 사내 AI 챗봇을 구축한 결과, 인사팀의 반복 문의 대응 부담을 크게 줄일 수 있었다. 특히 RAG 아키텍처 도입 후 응답 품질과 속도 모두 만족스러운 수준으로 개선되었다. 셀프호스팅 n8n의 가장 큰 장점은 API 키와 데이터가 외부로 나가지 않는다는 점이다. 사내 민감 정보를 다루는 HR 봇에는 이 점이 특히 중요했다.

AIDevelop

Claude로 법무법인 마케팅 콘텐츠 자동 검토 파이프라인 만들기

2026.03.11

법무법인 마케팅을 대행하는 지인의 요청으로 콘텐츠 자동 검토 파이프라인을 만들어봤다. 목표는 단순했다. 초안 하나를 넣으면 문맥, 법적 리스크, 마케팅 관점까지 순서대로 검토하고 최종 수정본을 뽑아내는 구조다. 법무법인 마케팅 콘텐츠는 일반 블로그 글처럼 "잘 읽히면 끝"이 아니다. 최소한 세 가지 기준을 동시에 통과해야 한다. | 기준 | 확인할 내용 | | --- | --- | | 가독성 | 잠재 의뢰인이 상황과 해결 방향을 쉽게 이해할 수 있는가 | | 법적 안정성 | 변호사법, 광고 규정에 어긋날 만한 표현은 없는가 | | 마케팅 효과 | CTA, 검색 키워드, Pain Point가 충분히 살아 있는가 | 문제는 이 세 가지를 한 사람이 동시에 잘 보기 어렵다는 점이다. 카피에디터는 법률 리스크를 놓치기 쉽고, 변호사는 마케팅 표현을 지나치게 보수적으로 다듬을 수 있다. 마케터는 전환율을 높이려다가 법적으로 위험한 표현을 쓸 수 있다. 그래서 검토 관점을 하나의 프롬프트에 모두 넣는 대신, 역할별 Agent로 나누기로 했다. 파이프라인은 4단계다. input/original.md - STEP 1: Context Reviewer - STEP 2: Legal Reviewer - STEP 3: Marketing Reviewer - STEP 4: Final Editor - output/step4final/final.md 각 Agent는 이전 단계의 draft.md를 입력으로 받고, 자기 역할에 해당하는 부분만 검토한다. 중요한 원칙은 하나다. 자기 역할이 아닌 영역은 건드리지 않는다. 이 원칙을 넣지 않으면 모든 Agent가 비슷한 방향으로 글을 다시 쓰기 시작한다. 그러면 단계가 나뉘어 있어도 결과는 단일 프롬프트와 크게 다르지 않다. 첫 번째 Agent는 10년 차 카피에디터 역할이다. 법률적으로 맞는지, 마케팅 성과가 좋은지는 판단하지 않는다. 오직 글의 흐름과 읽기 편한지만 본다. 주요 검토 항목은 다음과 같다. 도입, 사례, 법적 쟁점, 해결 방향, CTA의 흐름이 자연스러운가 한 문장이 지나치게 길지 않은가 문단 간 전환이 끊기지 않는가 같은 내용을 반복하고 있지 않은가 원본의 핵심 사례나 사실관계를 훼손하지 않았는가 출력은 두 파일로 나눴다. output/step1context/ ├── feedback.md └── draft.md feedback.md에는 문제점과 수정 방향을 남기고, draft.md에는 실제 수정된 본문을 저장한다. 두 번째 Agent는 법률 광고 리스크를 보는 역할이다. STEP 1에서 정리된 흐름은 최대한 유지하고, 위험한 표현만 잡는다. | 유형 | 위험한 표현 | 대체 방향 | | --- | --- | --- | | 결과 보장 | "반드시 승소", "100% 해결" | "유리한 결과를 이끌어낸 경험" | | 최상급 표현 | "국내 최고", "업계 1위" | "풍부한 경험", "다수의 유사 사건 처리" | | 비용 오해 | "무조건 전액 환급" | "사안에 따라 비용 상담 가능" | 여기서 중요한 건 모든 문장을 무조건 약하게 만들지 않는 것이다. 법적으로 문제가 될 수 있는 표현은 줄이되, 허용되는 범위 안에서는 설득력을 최대한 유지하도록 지시했다. 세 번째 Agent는 디지털 마케터 역할이다. STEP 2에서 설정된 법적 한계선을 넘지 않는 조건으로 전환율을 높이는 방향을 찾는다. 주요 검토 항목은 다음과 같다. 잠재 의뢰인의 Pain Point가 충분히 드러나는가 CTA가 구체적이고 부담스럽지 않은가 SEO 키워드가 자연스럽게 들어가 있는가 블로그, SNS, 웹사이트 중 어느 채널에 적합한 톤인가 제목과 소제목이 검색 의도에 맞는가 법리 검토 이후에는 표현이 다소 밋밋해질 수 있다. 이 단계에서는 다시 마케팅 관점에서 힘을 실어준다. 단, 결과 보장처럼 법적으로 민감한 표현은 다시 살리지 않는다. 마지막 Agent는 앞선 세 단계의 결과를 조율한다. 역할은 단순한 문장 교정이 아니다. 문맥, 법리, 마케팅 의견이 충돌할 때 최종 판단을 내리는 편집자다. 우선순위는 이렇게 정했다. | 충돌 상황 | 판단 기준 | | --- | --- | | 마케팅 효과 vs 법적 제약 | 법적 제약을 우선하고, 허용 범위 안에서 마케팅 효과를 최대화 | | 문맥 흐름 vs 마케팅 강조 | 독자 경험을 우선 | | SEO 키워드 vs 자연스러운 문장 | 자연스러운 문장을 우선 | 최종 결과물에는 본문뿐 아니라 운영에 필요한 정보도 같이 넣었다. 최종 제목 제목 후보 2~3개 단계별 주요 변경사항 추천 게재 채널 SEO 키워드 후속 콘텐츠 아이디어 Claude Code에서 바로 돌릴 수 있도록 파일 구조를 단순하게 만들었다. project/ ├── CLAUDE.md ├── agents/ │ ├── agent1context.md │ ├── agent2legal.md │ ├── agent3marketing.md │ └── agent4final.md ├── input/ │ └── original.md └── output/ ├── step1context/ │ ├── feedback.md │ └── draft.md ├── step2legal/ │ ├── feedback.md │ └── draft.md ├── step3marketing/ │ ├── feedback.md │ └── draft.md └── step4final/ └── final.md 핵심은 CLAUDE.md다. 여기에 전체 실행 순서, 각 Agent의 역할, 입출력 경로, 금지 행동을 적어둔다. Claude Code는 프로젝트 디렉토리의 CLAUDE.md를 먼저 읽기 때문에, 실행할 때마다 같은 기준으로 파이프라인을 돌릴 수 있다. 실행 요청은 이렇게 단순하게 만들었다. input/original.md 파일을 읽고 CLAUDE.md의 파이프라인대로 STEP 1부터 STEP 4까지 순차적으로 실행해줘. 각 단계마다 결과 파일을 저장하고 완료 여부를 알려줘. 이 파이프라인은 병렬 실행하면 안 된다. STEP 2는 STEP 1의 draft.md를 기준으로 검토해야 하고, STEP 3은 STEP 2에서 정리한 법적 한계선을 지켜야 한다. STEP 4는 앞선 세 단계의 결과를 모두 읽어야 한다. 겉으로는 네 명의 전문가가 독립적으로 검토하는 구조처럼 보이지만, 실제로는 순차 편집 파이프라인에 가깝다. 그래서 CLAUDE.md에 다음 원칙을 명시했다. 각 단계는 반드시 순차 실행한다. 이전 단계의 draft.md가 생성되기 전에는 다음 단계를 시작하지 않는다. 병렬 실행하지 않는다. 처음에는 하나의 프롬프트로 문맥, 법리, 마케팅 검토를 모두 시켰다. 결과는 애매했다. 법리 검토는 얕고, 마케팅 표현은 약하고, 문맥 수정은 거의 없었다. 역할을 나누니 결과가 훨씬 안정적이었다. | 항목 | 단일 프롬프트 | 4단계 파이프라인 | | --- | --- | --- | | 문맥 정리 | 부분 수정에 그침 | 글 전체 흐름 기준으로 정리 | | 법리 검토 | 위험 표현 일부만 탐지 | 표현 유형별로 체계적 검토 | | 마케팅 보강 | 일반적인 조언 위주 | CTA, SEO, Pain Point를 분리해 개선 | | 충돌 조율 | 기준이 불명확 | Final Editor가 우선순위에 따라 판단 | | 재현성 | 실행할 때마다 편차 큼 | 같은 구조로 반복 실행 가능 | 멀티에이전트 구조의 핵심은 Agent 수를 늘리는 게 아니었다. 중요한 건 역할과 금지 행동을 명확히 나누는 것이다. 특히 법무법인 마케팅처럼 표현의 강도와 리스크가 계속 충돌하는 콘텐츠는 한 번에 다 고치려 하면 결과가 흐려진다. 문맥, 법리, 마케팅, 최종 조율을 분리하니 각 단계의 판단 근거가 더 선명해졌다. Claude Code와 로컬 파일 구조만으로도 이런 검토 파이프라인은 충분히 만들 수 있다. 외부 SaaS를 붙이지 않아도 되고, 단계별 결과가 파일로 남기 때문에 나중에 어떤 판단으로 문장이 바뀌었는지도 추적하기 쉽다.

Thought

문제를 해결한다는 것에 대한 나의 고찰

2026.02.23

'문제해결' 이라는 것에 대한 내 생각을 정리한다. 인생을 살면서 많은 문제를 맞닥뜨린다. 하지만 생각보다 많은 사람들은 문제를 해결하지 못하고 포기한다는 사람들이 있고, 어려워 하는 사람들도 있다. 물론 나또한 어려운 문제들이 있고, 결국 해결하지 못하는 경우들이 많다. 하지만 내가 이 글을 쓰는 이유는 많은 사람이 문제를 해결하겠다면서 진짜 문제가 뭔지도 모르고 쉬운 해결방법을 두고 어렵게 해결하려는 사람들이 있어 이 글을 쓴다. 22년도 퇴사 후 SW사관학교정글에서 공부를 하고 있을때, 하루에 해결해야 할 문제들이 10개는 넘었었다. 왜 지금 코드에서 오류가 나는지, 왜 이렇게 시스템이 동작 하는지, 무슨 알고리즘을 요기에 써야 하는지 등등... 어느날 우리는 가상메모리 공부를 하면서 사람 3명이서 동일한 문제를 맞닥뜨리고 고민하고 있었다. 그때 때마침 코치님께서 오셨고, 우린 차분하게 문제를 설명해 드리며 도움을 구했다. 하지만 코치님은 우리의 질문에는 답하지 않으시고, 우리가 갖고 있는 문제들에 대해서 되묻기만 하셨다. 왜 이것을 문제라고 생각했나? 그렇게 생각한 이유는 무엇인가? 그게 문제라고 생각 했다면 그 문제가 발생한 이유가 무엇인가? 그 문제가 발생한 이유가 어떤 것이라고 생각 하나? 이런 대화를 하면서 우리는 계속해서 그 질문에 답하기 위해 생각해야 했다. 계단을 하나씩 하나씩 올라가면서 조금씩 멀리있는 건물을 보게 되는 것 같았다. 💡 이렇게 대답을 해가면서 결국 그 문제의 해답은 우리의 입에서 나왔다. 정말로 우리는 문제를 알고 있었을까? 가끔 지인들이나, 친구, 동생들에게 고민이 있다고 말하며 상담을 요청 하는 경우들이 있다. "정말 답이 없는 문제에요." "1주일은 생각해 봤는데, 해결 방법이 없어요" 하는 고민들을 들어보면, 90%는 정말 문제가 뭔지를 파악하지 못하는 경우들이다. 진짜 문제는 다른곳에 있는데, 엉뚱한 곳에서 해결점을 찾으려고 하니 해결을 못하는 것이 당연한 것 아닌가? 나는 SW사관학교정글에서의 경험으로 내린 생각은. 💡 가장 중요하고 또한 어려운 것은 "진짜 문제를 찾고 그 원인을 찾는것 이라고 생각하며, 해결하는 방법은 너무나도 쉽다." 이다. 내가 경험했던 SW사관학교정글에서의 경험을 다시 생각해보자 코치님은 우리의 문제를 해결하는 가장 간단한 방법으로 "답을 바로 말해 주는것" 이였을 것이다. 하지만 코치님은 그렇게 하지 않으시고, 우리가 스스로 답을 찾을 수 있도록 도와 주셨다. 질문을 하셨다. 내가 주목하는 점은 이것이다. 나의 문제에서 궁금한 점이 나오지 않을 때까지 질문 하는 것이다. 1. 지금 내가 생각하는 문제에게 질문한다. 2. 질문은 내가 더이상 질문을 할 수 없을 때까지 질문한다. 3. 더이상 질문을 할 수 없다면, 각각의 질문들에 대한 해결방법을 다시 고민 한다. 4. 운이 좋다면, 가장 마지막에 한 질문의 문제만 해결 되었을때, 모든 문제가 해결되는 마법을 경험한다. 이것이 내가 생각하는 문제 찾고 해결에 가까워 지는 방법이다. 어떤 회사의 엘레베이터가 너무 느려서 직원들의 불만이 사장에게 까지 닿게 됐다. 사장은 두 직원들에게 당장 이 문제를 해결할 수 있는 방안을 들고 오라고 하였다. A직원은 몇천만원을 들여 엘레베이터를 업그레이드 하여 속도를 높히는 작업을 하겠다고 하며, 엘레베이터 업체 리스트와 견적서, 업체 비교분석까지 완벽하게 해와서 사장에게 제출했다. B직원의 사장에게 1줄짜리 보고서만 제출했다. 그 보고서에서 "엘레베이터 안밖으로 거울과 광고 게시판을 설치하면 됩니다." 였다. 이처럼 "엘레베이터가 느리다" 라는 문제를 해결하는 방법은 단순히 "엘레베이터의 속도를 높힌다"로 생각 하기 쉽지만, 사장은 당연히 B직원의 아이디어로 문제를 해결 할 것이다. 문제를 해결 할 수 있는 방법은 수만가지다. 중요한건 명확한 페인포인트를 해결하면서 효율을 생각하는 것. 그것이 PM의 역할이다. 직장에서나 인생에서나 우가 풀어야 하는 문제들은 절대 단순하지 않다. 하지만 숲이 불타고 있는데, 지금 내눈 앞에 나무 한그루의 불만을 끄기 위해서 노력한다면, 너무 바보같지 않은가? 내가 위에 말한 나의 문제 해결 방법은 "숲을 보는 방법"이다. 아무리 복잡해 보이는 문제여도 하나하나 의문을 갖고 계단을 올라가다 보면, 결국은 원인을 발견 하고 해결 할 수 있다고 나는 감히 확신한다.

Tools

Obsidian vs NotePlan vs Amplenote

2026.01.05

디지털 노트 앱 시장은 이제 단순 기록을 넘어 '지능형 워크플로우'의 시대로 접어들었습니다. 로컬 기반의 자유도 끝판왕 Obsidian, 캘린더 중심의 완벽한 플래너 NotePlan, 그리고 태스크 실행에 최적화된 Amplenote를 전격 비교합니다. | 구분 | Obsidian (옵시디언) | NotePlan (노트플랜) | Amplenote (앰플노트) | | --- | --- | --- | --- | | 철학 | 개인 지식 베이스 (PKM) 구축 | 데일리 플래닝 + 할 일 관리 | 아이디어 실행 깔때기 (Capture to Schedule) | | 데이터 저장 | 로컬 기반 (Markdown 파일) | 로컬 + iCloud (애플 특화) | 클라우드 기반 (E2E 암호화) | | 핵심 기능 | 그래프 뷰, 캔버스, 무한 플러그인 | 데일리 노트-캘린더-태스크 통합 | Task Score(우선순위), Rich Footnotes | | 2026 이슈 | 'Bases' 기능으로 데이터베이스 강화 | v3.12 이후 손글씨 및 AI 인식 강화 | AI 자동 요약 및 캘린더 양방향 동기화 고도화 | 무용 범위: 개인용은 완전 무료. 노트 개수 제한 없음. 플러그인, 코어 기능 모두 사용 가능. 유료 범위: \ Sync ($4~5/월): 기기 간 암호화 동기화. Publish ($8~10/월): 노트를 웹사이트로 발행. Commercial ($0): 2025년부터 상업용 라이선스가 대부분 무료화되어 기업에서도 비용 부담 없이 사용 가능. 특징: 비용보다는 '구축 시간'이 비용인 앱입니다. 무료 범위: 공식적인 무료 플랜 없음 (7일~14일 무료 트라이얼 제공). 유료 범위: \ Personal (연 약 $99 / 월 $12): 모든 기기(Mac, iOS) 사용 및 동기화. 특징: 구독형 모델이지만, 애플 생태계(iCloud, Reminders)와의 완벽한 연동으로 비용 지불 가치가 높다는 평이 많습니다. 무료 범위: Personal 플랜 무료. 무제한 기기 동기화, 기본 4개 모드(Jots, Notes, Tasks, Calendar) 제공. 유료 범위: \ Pro ($5.84/월): 외부 캘린더(Google, Outlook) 양방향 동기화. Unlimited ($10/월): 보안 금고(Vault), 노트 웹 발행. 특징: '개인 사용'에 가장 너그러운 무료 범위를 제공하며, 외부 서비스 연동 시 비용이 발생합니다. Obsidian: 기본 기능: 내장 캘린더 뷰가 없으며, 'Daily Notes' 코어 플러그인만 제공. 확장성: 'Calendar', 'Full Calendar', 'Prisma Calendar' 플러그인을 설치해야 함. 최근 'Bases' 기능 업데이트로 날짜 기반의 데이터베이스 뷰(Calendar View)를 실험적으로 도입 중. NotePlan: 독보적 강점: 앱 자체가 캘린더입니다. 우측에 항상 캘린더가 위치하며, 노트를 날짜에 바로 연결합니다. 타임 블로킹: 할 일을 캘린더로 드래그하여 바로 일정화하는 기능이 매우 강력하며, Apple/Google 캘린더 일정과 내 할 일을 한 화면에서 겹쳐 볼 수 있습니다. Amplenote: 드래그 앤 드롭: 'Tasks' 모드에 쌓인 할 일을 'Calendar' 모드로 끌어다 놓는 방식. 양방향 동기화: 유료 플랜 시 외부 캘린더와 실시간 양방향 동기화가 되어, Amplenote에서 시간을 조정하면 Google 캘린더에도 즉시 반영됩니다. Obsidian: 네이티브 지원 부족: 기본적으로 텍스트 중심입니다. 플러그인 의존: Excalidraw 플러그인을 통해 손글씨를 그리거나, Handwritten Notes 플러그인으로 PDF 위에 필기하는 방식을 사용합니다. 개인적으로 난 [Ink](https://github.com/daledesilva/obsidianink?ref=haas.kr) 라는 플러그인을 아주 잘 사용 하고 있다. NotePlan: 최고의 손글씨 지원: v3.12 업데이트로 Apple Pencil을 활용한 네이티브 드로잉 기능을 탑재했습니다. AI 변환: 손으로 그린 동그라미를 체크박스로 인식하거나, 손글씨 텍스트를 AI가 즉시 디지털 텍스트로 변환(OCR)해 줍니다. 아이패드 사용자에게 가장 추천하는 앱입니다. Amplenote: 부분 지원: 아이패드 앱에서 기본 드로잉 기능을 지원하며, Excalidraw가 내장되어 화이트보드 형태의 작업이 가능합니다. 다만 NotePlan만큼의 유연한 필기-텍스트 혼합 능력은 다소 부족합니다. 1. "나는 내 지식 세계를 무한히 확장하고 싶다" → Obsidian (무료, 커스터마이징, 로컬 보안) 2. "나는 아이패드와 아이폰을 쓰며 오늘 하루 일정을 완벽히 관리하고 싶다" → NotePlan (애플 연동, 최고의 손글씨 지원, 캘린더 일체형) 3. "나는 아이디어를 메모하고 우선순위에 따라 바로 실행하는 게 중요하다" → Amplenote (태스크 스코어링, 외부 캘린더 양방향 연동)

Tools

Notion Mail(노션메일) 사용 해보기

2025.03.27

하기 링크를 통해 신청이 가능하다. 나는 한 1개월 정도 걸렸던 것 같다. [Notion Mail - Join the waitlist](https://www.notion.com/ko/product/mail?ref=haas.kr) 이제 그냥 사용 가능 하다 👍 아래와 같이 메일이 오고, Get Notion Mail free 버튼을 누르면 시착 페이지로 간다. 내용을 보면, 1. 메일 필터를 내 입맛에 맞게 설정하여 보고 2. 노션 AI와 노션 글 서식을 사용 할 수 있고 AI가 메일을 자동 라벨링 해준다 라고 자랑을 하고 있다. 사용해 보자 기본적으로는 Gmail를 연동해서 사용해야 한다. 연동 시 구글 계정을 통해 Gmail, Calendar를 연동 하라고 한다. 난 둘다 연동 했다. 추가로 노션 캘린더 처럼 별도의 App으로 만들었다. 1. Views 1. 인박스와 라벨 구분으로 되어 있다. 2. 카테고리도 있는데, 메일의 라벨 기능을 이용한 기능이다. 2. Mail 1. 기본적은 메일 보관함과 보낸 메일함이 있다. 3. 세팅 1. 기본적인 세팅 메뉴가 있다.(언어 변경은 없다) 4. 탬플릿 1. 탬플릿은 Views에 대한 탬플릿이 있다. 2. 다양한 필터값을 세팅해 두었다 5. 푸터 1. 노션, 노션캘린더로 바로가는 버튼이 있다. 라벨, 필터 기타 구분값을 설정 할 수 있는 메뉴가 있다. 재밌는 건 Group by 에 Priority 라는 구분 항목이 있고, 이를 사용 하면 사이드 바에 하기와 같이 나타난다. 뷰 화면에서는 다음과 같이 보인다 이처럼 메일의 상태값을 분리 하여 관리 할 수 있다. 신기한건, Gmail로 가보면, 아래와 같이 라벨이 만들어져 있다. 메일의 라벨 시스템을 극한까지 활용한 모습이다. 메일 에디터에서 노션 문법을 거의 그대로 사용 할 수 있다. (TODO는 아직 구현이 안된 것 같다...) 캘린더도 연동을 하는데, 이부분은 나중에 디테일하게 활용 해보고 정리하겠다. 캘린더에서 메일까지 만든거 보면, 노션의 방향성이 대략적으로 보이는 것 같다. 한가지 좀 아쉬웠던건 메일이 구글 메일에만 제약되어 사용이 되는 것 같아서 아쉬웠다. 아마 장기적으로는 노션 자체 서버를 활용한 이메일이 나올 것 같은데, 그러면 좀더 재밌겠다. 우선 난 회사메일을 Gmail에 연동 하고, 다시 Gmail을 노션 메일에 연동했는데, 진정한 아웃룩(?)처럼 사용 하려면, 노션메일 자체적으로 POP을 지원해야 할 것 같다. 노션 화이팅.

Posts