Tag
Infra
4 posts
아니, 요즘도 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건으로 다시 배웠다.
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/)
내 우분투 서버에 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에게 말하면 서버가 실제로 바뀐다는 점이다. 이제 내 서버는 그냥 접속해서 관리하는 서버가 아니라, 대화하면서 고쳐가는 작업 환경에 가까워지고 있다. 아직 거칠지만, 이 방향은 꽤 마음에 든다.
홈 서버에 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