pg_durable: PostgreSQL 안에서 돌아가는 durable execution, 실무에서 쓸만한가

Microsoft가 pg_durable이라는 PostgreSQL 확장을 오픈소스로 풀었다. Hacker News에서 410점, 한국 커뮤니티(GeekNews)에도 바로 올라온 걸 보면 관심도가 꽤 높다. 한 줄로 요약하면 "Temporal이나 AWS Step Functions 같은 durable execution을 외부 인프라 없이 Postgres 안에서 처리한다"는 물건이다.

나처럼 인프라를 굴려본 사람 입장에서 이게 왜 흥미로운지, 그리고 어디서 발목을 잡힐지 실무 관점에서 정리해본다.

왜 지금 이게 화제인가

durable execution이라는 개념 자체는 새롭지 않다. 결제 처리, 회원가입 후 이메일 발송, 외부 API 여러 개를 순서대로 호출하는 작업 같은 걸 생각해보자. 중간에 워커가 죽거나 네트워크가 끊기면 어디까지 처리됐는지 알 수가 없다. 그래서 우리는 보통 이런 걸 만든다.

  • 작업 상태를 DB 테이블에 기록 (pending → processing → done)
  • Redis나 RabbitMQ로 큐를 깔고 워커를 띄움
  • 실패하면 재시도하는 로직, 중복 실행 막는 idempotency 키
  • 스케줄링 필요하면 cron이나 별도 스케줄러

이걸 제대로 하려면 Temporal, AWS Step Functions, Inngest 같은 솔루션을 도입하거나, 직접 상태머신을 짜야 한다. 문제는 이게 다 외부 인프라를 하나 더 추가한다는 거다. 운영 포인트가 늘고, 모니터링 대상이 늘고, 장애 지점이 늘어난다.

pg_durable의 핵심 주장은 이거다. "이미 Postgres 쓰고 있잖아? 컨테이너도 외부 서비스도 필요 없이 Postgres와 백그라운드 워커만으로 처리하자." 트랜잭션, 상태 저장, 재시도가 전부 DB 안에서 끝난다는 게 매력 포인트다.

동작 원리 — 체크포인트가 핵심이다

공식 저장소 설명에 따르면 작은 SQL DSL로 재시도, 스케줄링, 병렬 fan-out, 조건 분기를 표현한다. 그리고 가장 중요한 건 모든 단계가 PostgreSQL에 상태를 체크포인트로 저장한다는 점이다.

비유하자면 게임 세이브 포인트다. 보스전 직전에 세이브해두면, 죽어도 처음부터가 아니라 세이브 지점부터 다시 시작한다. durable execution도 똑같다. 단계마다 "여기까지 했다"를 DB에 기록하니까, 워커가 죽고 다시 살아나도 이미 끝낸 단계는 건너뛰고 다음부터 이어간다.

일반적인 durable function의 흐름은 대략 이런 모양이다. (정확한 문법은 저장소 문서 확인 필요)

-- durable function 정의 (개념적 예시)
-- step 1: 결제 호출
-- step 2: 재고 차감
-- step 3: 알림 발송
-- 각 step 결과가 체크포인트로 저장됨

SELECT durable.call('process_order', jsonb_build_object('order_id', 1234));

여기서 step 2를 처리하다가 워커가 죽었다고 하자. 재시작하면 엔진은 "step 1은 이미 완료(체크포인트 있음), step 2부터 다시"라고 판단한다. step 1의 결제 호출이 두 번 일어나지 않는다는 게 핵심 가치다.

외부 큐를 안 쓰는 대신 백그라운드 워커가 Postgres를 폴링하거나 알림(LISTEN/NOTIFY 추정)으로 깨어나서 다음 step을 진행하는 구조로 보인다. 이 부분 구현 방식은 직접 소스 확인이 필요하다.

실무 관점 — 좋은데, 만능은 아니다

매력적인 그림이지만 실무에 넣기 전에 따져봐야 할 게 있다.

1. DB가 워크로드 처리기까지 겸하게 된다

이게 가장 큰 트레이드오프다. 평소에 Postgres는 데이터 저장소다. 그런데 pg_durable을 쓰면 비즈니스 워크플로우 실행, 재시도, 스케줄링까지 DB가 떠안는다. 백그라운드 워커가 폴링을 돌리면 그만큼 DB 부하가 생긴다.

트래픽 작은 서비스에선 문제없다. 그런데 워크플로우가 초당 수천 건씩 돌아가는 규모라면, DB가 병목이 되는 순간 스토리지와 워크플로우 엔진이 동시에 죽는다. 관심사를 분리해놨으면 하나만 죽었을 일이다. 이건 "DB에 뭐든 넣자" 류 확장(pg_cron, pgmq 등)이 공통으로 갖는 숙명이다.

2. 운영 단순함 vs 확장성의 교환

스타트업 초기나 내부 도구, 트래픽 예측 가능한 백오피스 작업에는 정말 좋다. Temporal 클러스터 운영해본 사람은 알겠지만, 그거 띄우고 유지하는 것 자체가 일이다. 반면 pg_durable은 "extension 깔고 워커 띄우면 끝"에 가깝다. 인프라 한 덩어리가 통째로 사라진다.

하지만 트래픽이 커지고 워크플로우가 복잡해지면, 결국 전용 durable execution 플랫폼으로 갈아타야 하는 순간이 온다. 그때 마이그레이션 비용이 든다. 처음부터 "이 서비스는 절대 커질 일 없다" 또는 "커지면 그때 갈아탄다"는 판단이 서야 도입할 만하다.

3. 흔한 함정 — idempotency를 공짜로 주는 게 아니다

durable execution이 step 재실행을 건너뛰어준다고 해도, 외부 부수효과(side effect)의 멱등성은 여전히 네 책임이다. 예를 들어 step에서 외부 결제 API를 호출했는데 응답 받기 직전에 워커가 죽었다고 하자. 엔진 입장에선 "이 step 완료 기록이 없다 → 재시도"인데, 실제로는 결제가 이미 됐을 수 있다.

이런 케이스는 어떤 durable execution 솔루션을 써도 똑같이 발생한다. 외부 호출 쪽에 idempotency key를 같이 보내거나, 결제 상태를 먼저 조회하는 식으로 직접 방어해야 한다. "체크포인트 있으니 중복 안 일어나겠지"라고 믿으면 사고 난다.

4. 대안과 비교

  • Temporal / Cadence: 대규모·복잡한 워크플로우의 정석. 무겁고 운영 부담 큼.
  • AWS Step Functions: 매니지드라 운영 편하지만 AWS 락인, 비용, 디버깅 불편.
  • pgmq + 직접 구현: Postgres 기반 큐만 쓰고 워크플로우 로직은 직접. 더 가볍지만 손이 많이 감.
  • Inngest, Trigger.dev: 코드 중심 durable execution, DX 좋음. 외부 SaaS 의존.
  • pg_durable: 이미 Postgres 쓰고, 추가 인프라 없이 적당한 규모를 처리하고 싶을 때.

참고로 아직 초기 단계 프로젝트로 보이니, 프로덕션에 바로 넣기보단 사이드 워크로드에서 먼저 검증하길 권한다. 버전 안정성, 마이그레이션 호환성은 직접 확인이 필요하다.

정리

한 줄 요약: pg_durable은 외부 워크플로우 인프라 없이 Postgres만으로 durable execution을 처리하는 확장이고, 운영 단순함을 얻는 대신 DB에 부하를 몰아주는 트레이드오프가 있다.

  • 이럴 때 써라: 이미 Postgres 중심 스택이고, 트래픽 규모가 크지 않으며, Temporal까지 도입하긴 과한 백오피스/내부 작업/중소 서비스의 비동기 워크플로우.
  • 피해라: 초당 수천 건 워크플로우, DB가 이미 병목인 환경, 워크플로우 엔진 장애와 DB 장애를 분리해야 하는 미션 크리티컬 시스템.

개인적으론 방향성이 좋다고 본다. "또 인프라 하나 추가"에 지친 팀에겐 충분히 매력적이다. 다만 멱등성은 여전히 네가 챙겨야 하고, 규모가 커질 때의 출구 전략은 미리 그려두고 들어가는 게 안전하다.

참고 자료

사진: Microsoft Copilot / Unsplash

728x90

Java 25 가상 스레드, 진짜로 1만 개를 띄워봤다 - 실무 도입 전 알아야 할 것들

오늘 올라온 목록 중에 "I Started 10,000 Java Threads. My Laptop Barely Noticed."라는 글이 눈에 띄었다. 제목만 보면 어그로 같은데, 사실 Java 21에서 정식 도입되고 25에서 더 다듬어진 가상 스레드(Virtual Threads, 프로젝트 Loom)를 두고 하는 얘기다. 인프라/백엔드 쪽에서 일하다 보면 "스레드 풀 사이즈 얼마로 잡아야 하냐"는 질문을 한 달에 한 번은 받는데, 이 기능은 그 질문 자체를 바꿔버린다. 한번 제대로 정리해보자.

왜 지금 가상 스레드인가

전통적인 자바 스레드는 OS 스레드와 1:1로 매핑된다. OS 스레드 하나가 보통 1MB 안팎의 스택 메모리를 잡고, 컨텍스트 스위칭도 커널이 관리한다. 그래서 수천 개만 띄워도 메모리가 터지거나 스케줄링 오버헤드로 시스템이 버벅인다. 우리가 톰캣 스레드 풀을 200, 400 이런 식으로 제한해온 이유가 바로 이거다.

문제는 웹 서버 워크로드 대부분이 CPU를 빡세게 쓰는 게 아니라 I/O 대기라는 점이다. DB 쿼리 날리고 응답 기다리고, 외부 API 호출하고 기다리고. 이 기다리는 동안 OS 스레드는 그냥 블로킹된 채 놀고 있다. 비싼 자원을 깔고 앉아서 아무것도 안 하는 거다.

가상 스레드는 이 구조를 뒤집는다. 수많은 가상 스레드를 소수의 OS 스레드(캐리어 스레드) 위에서 돌리고, 블로킹이 발생하면 가상 스레드를 캐리어에서 떼어내(unmount) 다른 가상 스레드를 올린다. 결과적으로 "스레드를 아끼지 말고 요청당 하나씩 그냥 만들어라"가 가능해진다.

동작 원리, 코드로 보면

일단 1만 개 띄우는 건 진짜 별거 아니다.

// Java 21+ 가상 스레드 1만 개
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1)); // I/O 대기 흉내
            return i;
        });
    });
} // close에서 모든 작업 완료까지 대기

플랫폼 스레드로 같은 걸 하면 메모리 부족으로 죽거나 한참 버벅인다. 가상 스레드는 스택을 힙에 저장하고, 필요할 때만 캐리어 스레드에 올린다. 핵심은 Thread.sleep이나 소켓 read 같은 블로킹 지점에서 JVM이 알아서 마운트/언마운트를 처리한다는 거다.

비유하자면 이렇다. 기존 방식은 손님(요청)마다 전담 직원(OS 스레드)을 한 명씩 붙여놓고, 손님이 화장실 갈 때(I/O 대기)도 직원이 문 앞에서 멍하니 기다리는 식이다. 가상 스레드는 직원 몇 명이 여러 손님을 돌아가며 응대하다가, 손님이 자리를 비우면 그 직원은 바로 다른 손님한테 간다. 직원(캐리어 스레드)은 적게 두고도 손님을 훨씬 많이 받는다.

// Spring Boot 3.2+ 에서는 설정 한 줄
spring.threads.virtual.enabled=true

톰캣 요청 처리를 가상 스레드로 돌리는 것도 이 정도로 간단하다. 다만 "켰으니 끝"은 절대 아니다. 여기서부터가 실무다.

실무에서 만나는 함정들

1. synchronized 안에서 블로킹하면 핀(pinning) 발생. 가장 악명 높은 함정이다. synchronized 블록 안에서 블로킹 I/O를 하면 가상 스레드가 캐리어에 "고정(pin)"되어 언마운트가 안 된다. 이러면 캐리어 스레드가 묶여서 가상 스레드의 장점이 사라진다. 레거시 코드나 오래된 라이브러리에 synchronized로 감싼 DB 접근 같은 게 숨어 있으면 성능이 기대만큼 안 나온다. 해결책은 ReentrantLock으로 바꾸는 것. 참고로 JDK 24부터는 이 핀 문제가 상당히 완화된 것으로 보이니, 정확한 동작은 사용하는 JDK 버전 릴리스 노트를 확인하는 게 좋다.

2. 스레드 풀로 가상 스레드를 재사용하려 하지 마라. 가상 스레드는 값싸게 만들고 버리는 게 전제다. 기존 습관대로 newFixedThreadPool 같은 데 넣고 풀링하면 의미가 없다. 풀링은 "비싼 자원을 재사용"하는 패턴인데, 가상 스레드는 안 비싸다. 작업당 하나씩 만들고 끝나면 버려라.

3. ThreadLocal 남용 주의. 가상 스레드가 수만 개 떠 있는데 각각 무거운 ThreadLocal 객체를 들고 있으면 메모리가 그만큼 곱해진다. 기존엔 스레드가 수백 개라 신경 안 썼던 부분이 터질 수 있다. Java는 대안으로 ScopedValue를 밀고 있다.

4. CPU 바운드 작업엔 효과 없다. 가상 스레드는 I/O 대기를 효율화하는 거지 연산을 빠르게 하는 게 아니다. 암호화, 이미지 처리, 빡센 계산 위주라면 그냥 코어 수만큼의 플랫폼 스레드가 맞다. 이걸 착각하고 도입하면 "왜 안 빨라지냐"는 소리가 나온다.

5. 다운스트림 폭격. 요청을 무한정 받아들일 수 있게 되면서, 그 뒤에 있는 DB 커넥션 풀이나 외부 API가 병목이 된다. 예전엔 톰캣 스레드 200개가 자연스러운 백프레셔 역할을 했는데, 가상 스레드를 켜면 그 방어막이 사라진다. 가상 스레드는 무제한이지만 DB 커넥션 풀은 여전히 유한하다는 걸 잊으면, 커넥션 대기 큐가 폭발하거나 DB가 먼저 쓰러진다. 세마포어 등으로 동시성 제한을 따로 거는 설계가 필요하다.

정리

한 줄 요약: 가상 스레드는 I/O 대기가 많은 워크로드에서 "스레드 풀 사이즈 고민"을 없애주는 도구지, 만능 성능 향상 스위치가 아니다.

외부 API 호출이 많거나 DB 의존도가 높은 백엔드 서버라면 도입 효과가 크다. 다만 켜기 전에 (1) 코드와 라이브러리에 synchronized 블로킹이 없는지, (2) DB 커넥션 풀 같은 다운스트림 한계에 대한 백프레셔가 있는지부터 점검하자. 반대로 CPU 바운드 배치 작업이라면 굳이 손댈 이유가 없다. 새 프로젝트라면 Spring Boot 설정 한 줄로 켜고 부하 테스트로 검증해보는 걸 추천한다. 핀 발생 여부는 -Djdk.tracePinnedThreads=full 옵션으로 잡아낼 수 있으니 도입 초기에 꼭 확인하길.

참고 자료

사진: Microsoft Copilot / Unsplash

728x90

Elixir 1.20, 점진적 타입 시스템이 들어왔다 — 동적 언어에 타입을 붙인다는 것의 실체

오늘 HN 상단에 Elixir 1.20 릴리스 소식이 올라왔다. 헤드라인은 "이제 점진적 타입 언어가 됐다(gradually typed)"인데, 동적 언어 진영에서 타입 이야기가 나오면 보통 두 가지 반응이 갈린다. "드디어 컴파일 타임에 버그 잡겠네"와 "또 타입 어노테이션 노가다 시작이군". 결론부터 말하면 Elixir의 접근은 둘 다 아니다. 좀 독특해서 정리해둘 만하다.

왜 지금 화제인가

Elixir는 BEAM(Erlang VM) 위에서 도는 동적 타입 함수형 언어다. Phoenix LiveView 덕분에 실시간 웹이나 메시징 백엔드 쪽에서 꾸준히 쓰여왔는데, 동적 언어가 늘 그렇듯 "런타임에서야 터지는 타입 버그"가 고질병이었다.

핵심은 Elixir 코어팀이 2022년경부터 José Valim 주도로 추진해온 set-theoretic type system(집합론적 타입 시스템)이 단계적으로 들어오고 있다는 점이다. 1.17, 1.18을 거치며 패턴 매칭과 함수 헤드 추론이 들어왔고, 1.20에서 "gradually typed language"라고 공식적으로 부를 만큼 성숙했다는 발표로 보인다. 정확한 1.20 신규 범위는 릴리스 노트 확인이 필요하다.

여기서 중요한 건 타입 어노테이션을 거의 안 쓴 기존 코드도 컴파일러가 알아서 추론해서 검사한다는 점이다. 즉 "타입 붙이는 노가다"가 진입 조건이 아니다.

핵심: 집합론적 타입이 뭐가 다른가

TypeScript나 Java식 타입을 떠올리면 헷갈린다. Elixir의 타입은 "값들의 집합"으로 다룬다. 예를 들어 어떤 함수가 :ok 아니면 :error만 반환한다면, 그 반환 타입은 말 그대로 :ok or :error라는 합집합으로 표현된다.

Elixir에서 가장 흔한 함수 패턴이 이거다.

def fetch_user(id) do
  case Repo.get(User, id) do
    nil -> {:error, :not_found}
    user -> {:ok, user}
  end
end

이 함수의 반환 타입은 {:ok, %User{}} or {:error, :not_found}라는 합집합으로 추론된다. 그리고 이걸 호출하는 쪽에서 :ok 케이스만 처리하고 :error를 빼먹으면, 컴파일러가 "이 패턴은 매칭 안 되는 경우가 있다"고 경고할 수 있는 기반이 깔린다.

비유하자면 기존 Dialyzer(Erlang/Elixir의 기존 정적 분석 도구)는 "코드 다 짜고 나서 따로 돌리는 외부 검사기"였다. 느리고, success typing 기반이라 "확실히 틀린 것만" 잡았다. 반면 새 타입 시스템은 컴파일러 안에 통합되어 있어서 그냥 mix compile 할 때 같이 돈다. 별도 설정 없이 경고가 뜬다.

패턴 매칭이 핵심이라 이런 것도 잡힌다.

# map에 :name 키가 없는 구조체를 넘기면
def greet(%{name: name}), do: "Hello #{name}"

greet(%{title: "Mr"})  # 컴파일 타임에 잡힐 수 있는 케이스

동적 언어에서 이게 컴파일 단계에 걸린다는 게 체감상 가장 큰 변화다.

실무 관점: 도입할 때 따져봐야 할 것들

1. 기존 코드는 안 건드려도 된다. 이게 "gradual"의 핵심이다. 타입을 명시하지 않은 부분은 dynamic()이라는 동적 타입으로 취급되어 검사를 통과한다. 그래서 1.20으로 올린다고 기존 프로젝트가 빨간 에러로 도배되진 않는다. 다만 추론된 타입과 명백히 모순되는 코드에선 경고가 새로 뜰 수 있으니, CI에서 warnings-as-errors 옵션을 쓰는 팀은 업그레이드 직후 빌드가 깨질 수 있다. 이게 첫 번째 함정이다.

2. Dialyzer를 당장 버릴 수 있는 건 아니다. 새 타입 시스템이 커버하는 범위가 아직 전체가 아니다. 가드, 패턴 매칭, 일부 기본 타입 위주로 추론하는 단계로 보이고, 함수 시그니처 전반의 spec 검증은 여전히 Dialyzer/dialyxir 영역이 남아있다. 그러니 "이제 PLT 빌드 안 해도 되겠지" 하고 성급하게 걷어내지 말고, 두 도구를 한동안 병행하면서 새 시스템이 잡는 범위를 직접 확인하는 게 안전하다.

3. 타입 명시는 선택이지만, 명시하면 검사가 강해진다. Elixir에는 원래 @spec으로 함수 타입을 적는 관례가 있었다. 새 시스템은 이걸 점점 더 진지하게 활용하는 방향으로 가고 있다. 라이브러리 코드나 팀 공용 모듈처럼 인터페이스가 명확해야 하는 곳부터 spec을 붙이면 효과가 크고, 내부 한 번 쓰고 버리는 헬퍼는 추론에 맡기는 식의 전략이 현실적이다.

@spec fetch_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id), do: ...

4. 흔한 오해 — "이제 타입 안전하다"는 과신. 점진적 타입의 본질상 dynamic() 경계를 넘는 값은 런타임에서 여전히 터질 수 있다. 외부에서 들어오는 JSON, DB 결과, 메시지 큐 페이로드 같은 건 어차피 동적이다. 타입 시스템이 들어왔다고 입력 검증(Ecto changeset, 패턴 매칭 가드)을 소홀히 하면 그게 진짜 사고 지점이 된다.

대안 비교 관점. "타입 때문에 동적 언어를 굳이?"라면 처음부터 Gleam(BEAM 위의 정적 타입 언어)이나 다른 정적 언어를 쓰는 선택지도 있다. 다만 Phoenix 생태계와 기존 Elixir 자산을 그대로 들고 가면서 점진적으로 안전성을 올리고 싶다면, 이번 방식이 가장 마찰이 적다. 기존 코드를 다시 안 짜도 된다는 게 결정적이다.

정리

한 줄 요약: Elixir 1.20의 점진적 타입은 "어노테이션 강제 없이, 컴파일러가 패턴 매칭 기반으로 추론해 런타임 버그를 미리 잡아주는" 방향이고, 기존 코드를 깨지 않으면서 점진적으로 도입하도록 설계됐다.

  • 이미 Elixir/Phoenix 운영 중인 팀: 업그레이드해서 새로 뜨는 경고를 디버깅 힌트로 활용하면 이득. 단 CI warnings-as-errors는 한 번 점검.
  • Dialyzer 쓰던 팀: 당장 걷어내지 말고 병행하며 커버리지 비교.
  • 신규 프로젝트: 라이브러리·도메인 핵심 모듈부터 @spec 붙이는 습관 들이면 타입 검사 효과 극대화.

동적 언어에서 "런타임에서야 알게 되는 nil 사고"에 데여본 사람이라면, 이번 변화는 충분히 깔고 갈 만하다.

참고 자료

※ 1.20에 정확히 포함된 타입 기능 범위와 Dialyzer 대체 가능 여부는 위 릴리스 노트에서 직접 확인하길 권한다.

사진: Chris Ried / Unsplash

728x90

github.dev에서 한 번 클릭으로 GitHub 토큰이 털린다 - VSCode 웹뷰 버그 뜯어보기

왜 지금 이게 화제인가

오늘 HN 상위에 "1-Click GitHub Token Stealing via a VSCode Bug"가 올라왔고, 한국 쪽 GeekNews(hada.io)에도 번역 요약이 같이 떴다. 점수도 479점으로 꽤 높다. 보안 버그 글이 이렇게 빠르게 화제가 되는 건 보통 두 가지 이유다. (1) 공격 조건이 비현실적이지 않고, (2) 영향받는 대상이 우리 다수라는 것. 이번 건은 둘 다 해당된다.

핵심은 이거다. github.dev(저장소 보면서 . 키 누르면 뜨는 브라우저 VSCode)는 사용자의 GitHub OAuth 토큰을 들고 동작한다. 그런데 이 토큰이 특정 저장소로 스코프가 제한돼 있지 않아서, 사용자가 접근 가능한 모든 저장소(프라이빗 포함)를 읽고 쓸 수 있다. 여기에 VSCode 웹뷰(webview)의 격리가 깨지는 버그가 결합되면, 악성 저장소를 github.dev로 한 번 열어보는 것만으로 토큰이 새어나갈 수 있다는 게 요지다.

우리 입장에서 왜 무서우냐면, github.dev는 "그냥 코드 잠깐 볼 때" 무심코 쓰는 도구라서다. PR 리뷰하다가, 남이 보낸 링크 따라가다가, 점 키 한 번 누르는 행동에 보안 의식이 거의 안 들어간다.

동작 원리 - 어디서 격리가 깨지나

먼저 정상 구조를 보자. github.dev의 VSCode는 확장(extension)이나 렌더러가 만든 HTML을 그냥 메인 페이지에 박지 않는다. vscode-webview:// 스킴을 쓰는 샌드박스 iframe 안에 격리해서 띄운다. 이 iframe은 출처(origin)가 달라서, 정상적이라면 부모 페이지(github.dev)의 토큰이나 쿠키, 메시지에 마음대로 접근하지 못한다.

[github.dev 메인 origin]  ← 여기 OAuth 토큰이 산다
        │
        │ postMessage 등 제한된 통로
        ▼
[vscode-webview:// iframe]  ← 확장/프리뷰 렌더링용 (격리되어야 정상)

문제는 이 격리가 완벽하게 강제되지 않는 경로가 있었다는 것이다. 마크다운 프리뷰나 특정 렌더링 기능을 통해 공격자가 제어하는 콘텐츠가 웹뷰 안에서 실행되고, 거기서 부모 컨텍스트로 메시지를 보내거나 권한 경계를 우회하는 식으로 토큰에 도달하는 흐름으로 보인다. (정확한 익스플로잇 체인은 원문 PoC 확인 필요)

비유하자면 이렇다. 회사 건물에 외부인 면회실(웹뷰 iframe)을 따로 뒀다. 면회실에서는 사무실(메인 origin)로 못 들어가게 돼 있어야 하는데, 면회실 벽에 점검용 쪽문이 하나 열려 있던 거다. 면회 온 척하면서 그 문으로 들어가 책상 위 출입카드(토큰)를 집어 나오는 그림.

그리고 결정타가 토큰 스코프다. 이 출입카드가 "회의실 한 곳"만 여는 카드였으면 피해가 제한적인데, 건물 전체 마스터키였던 게 문제다. github.dev 토큰이 단일 저장소로 제한되지 않으니, 한 번 새면 그 사람이 접근 가능한 사내 프라이빗 저장소 전부가 위험해진다.

# 토큰 스코프를 확인하는 가장 단순한 방법 (PAT 기준)
curl -sI -H "Authorization: Bearer $TOKEN" https://api.github.com/user \
  | grep -i x-oauth-scopes
# x-oauth-scopes: repo, read:org ...
# repo 가 통째로 들어가 있으면 그 계정의 모든 repo에 read/write 권한

실무 관점 - 우리가 뭘 해야 하나

이 버그 자체는 GitHub/MS가 패치하는 영역이라 우리가 코드로 막을 건 아니다. 다만 이걸 계기로 점검할 게 명확하다.

1. "토큰 스코프 최소화"가 왜 진짜 중요한지 보여주는 사례

이번 사건의 피해 규모는 결국 토큰이 너무 넓었기 때문에 커졌다. 실무에 그대로 적용된다. 자동화나 CI에서 토큰 쓸 때 습관처럼 repo 풀 스코프 PAT를 박아두는 경우가 많은데, 이게 정확히 같은 종류의 위험이다. 하나 새면 전부 털린다.

  • 가능하면 Fine-grained PAT를 써서 저장소별로 권한을 쪼개라. classic PAT의 repo는 사실상 전 저장소 read/write다.
  • CI에서는 PAT 대신 GitHub App / OIDC 기반 단기 토큰을 우선 검토. 만료가 짧으면 유출돼도 창이 짧다.
  • 조직 차원에서 SSO 강제 + PAT 만료 정책을 걸어둬라.

2. github.dev / 브라우저 IDE를 신뢰 경계 안에서 다뤄라

"그냥 코드 보는 뷰어"라고 생각하지만, 실제로는 내 계정 권한을 들고 임의 저장소 콘텐츠를 렌더링하는 환경이다. 즉 신뢰하지 않는 저장소를 github.dev로 여는 행위 = 신뢰하지 않는 코드를 내 권한 컨텍스트에서 처리하는 것에 가깝다. 모르는 사람이 보낸 repo 링크에 점 키 누르는 건, 모르는 첨부파일 여는 거랑 보안 등급이 비슷하다고 생각하는 게 안전하다.

3. 흔한 함정

  • "프라이빗이라 괜찮겠지"는 함정이다. 이번 케이스는 내 토큰으로 내 프라이빗 저장소까지 읽히는 구조라 프라이빗 여부가 방어가 안 된다.
  • 로컬 VSCode도 안심 금지. 데스크톱 VSCode도 웹뷰를 쓰고, 마크다운 프리뷰·확장 프로그램이 신뢰 안 되는 콘텐츠를 렌더링하는 구조는 같다. 출처 불명 repo를 클론해서 프리뷰 여는 패턴 자체를 경계할 것.
  • 패치만 믿고 끝내지 말 것. 토큰이 이미 유출됐다고 가정하고 사후 점검(이상 로그인, 신규 PAT 발급 이력)을 하는 게 인시던트 대응의 기본이다.

4. 지금 당장 할 점검 리스트

# 내 계정에 발급된 OAuth 앱/토큰 권한 확인
GitHub → Settings → Applications → Authorized OAuth Apps

# 발급해둔 PAT 점검 (오래되고 넓은 스코프부터 폐기)
GitHub → Settings → Developer settings → Personal access tokens

# 조직 관리자라면
Org Settings → Third-party access / PAT policies 검토
감사 로그(Audit log)에서 비정상 repo 접근 패턴 확인

정리

한 줄 요약: github.dev가 들고 있는 GitHub 토큰이 전 저장소 권한이라, 웹뷰 격리 버그 한 방에 계정 전체가 노출될 수 있었다. 버그 자체는 패치 대상이지만, 교훈은 "토큰 스코프 최소화"와 "브라우저 IDE도 신뢰 경계로 취급하라"로 명확하다.

특히 신경 써야 할 사람: 조직 GitHub를 관리하는 인프라/플랫폼 팀, CI에 PAT를 박아 운영 중인 팀, 외부 오픈소스 저장소를 github.dev로 자주 들여다보는 사람. 지금 PAT 목록 한 번 열어서 풀 스코프짜리 오래된 토큰부터 정리하는 게 이 글을 읽고 할 수 있는 가장 효율 좋은 액션이다.

(상세 익스플로잇 체인과 패치 상태는 빠르게 바뀔 수 있으니 원문과 GitHub 보안 공지 확인을 권한다.)

참고 자료

사진: Microsoft Copilot / Unsplash

728x90

인스타그램 계정 탈취 사고로 다시 보는 OAuth/소셜 로그인의 함정

오늘 Hacker News에서 1800점을 넘기며 압도적으로 올라온 글이 하나 있다. 제목은 "The newest Instagram exploit is the goofiest I've seen". 직역하면 "내가 본 인스타그램 익스플로잇 중 제일 어이없는 거"다. 보안 관련 글이 HN 1등을 먹는 경우는 흔하지만, 점수가 이 정도로 쏠린다는 건 보통 "허무할 만큼 단순한 실수"일 때다.

나는 인프라/DevOps를 주로 하지만, 사내 서비스에 소셜 로그인 붙이고 OAuth 연동 디버깅하는 건 결국 우리 몫으로 떨어진다. 그래서 이런 종류의 사고는 남의 일이 아니다. 이 글에서는 해당 사례를 빌미로, 소셜 로그인과 계정 연동(account linking)에서 한국 서비스도 똑같이 밟는 함정들을 정리해보겠다.

왜 지금 화제인가

원문 글의 핵심 주장은 "Meta 쪽 계정 연동 흐름에서 어이없을 정도로 단순한 검증 누락으로 계정 탈취가 가능했다"는 것이다. 정확한 기술적 디테일은 원문(공식 CVE나 Meta의 공식 패치 노트가 아니라 개인 리서처 블로그)을 직접 확인하길 권한다. 다만 이런 류의 사고가 반복되는 패턴은 거의 정해져 있다.

핵심은 이거다. 소셜 로그인은 "로그인"이 아니라 "신원 증명을 외부에 위임하는 것"인데, 많은 서비스가 이걸 단순 로그인처럼 다룬다. 그리고 계정을 기존 계정에 "연결(link)"하는 순간, 검증 한 줄 빼먹으면 그대로 남의 계정에 내 소셜 계정을 붙여버릴 수 있다.

핵심: 계정 연동이 왜 위험한가

가장 흔한 시나리오를 보자. 우리 서비스가 이메일/비번 가입도 받고, 구글/카카오 소셜 로그인도 받는다고 하자. 사용자가 둘 다 쓸 수 있게 하려면 "같은 이메일이면 같은 계정으로 묶는다"는 로직이 들어간다. 바로 여기가 사고의 진원지다.

# 위험한 의사코드
def social_callback(provider_profile):
    email = provider_profile["email"]
    user = User.find_by(email=email)
    if user:
        # 이미 그 이메일로 가입한 계정이 있으니 그냥 묶어버린다
        link_social_account(user, provider_profile)
        return login(user)
    ...

문제는 provider_profile["email"]이 "검증된 이메일"이라는 보장이 없다는 점이다. OIDC를 쓴다면 ID 토큰에 email_verified 클레임이 있는데, 이걸 안 보고 그냥 email만 믿으면 끝장이다. 공격자가 피해자의 이메일을 자기 소셜 계정에 등록(미검증 상태로)해두고 그 계정으로 콜백을 태우면, 우리 서버는 "아 이 이메일 주인이네" 하고 피해자의 기존 계정에 공격자의 소셜 로그인을 붙여준다.

비유하자면 이렇다. 호텔 프런트에서 "302호 손님이세요?"라고 물었더니 "네"라고만 하면 카드키를 내주는 거다. 신분증(검증된 이메일, 토큰 서명)을 안 본다. 인스타그램 사례의 "어이없음"도 결국 이 결의 검증 누락으로 보인다.

제대로 하려면 최소한 이렇게 가야 한다.

def social_callback(provider_profile):
    # 1. 토큰 서명/aud/iss/exp 먼저 검증 (라이브러리에 맡기되 검증 켜졌는지 확인)
    # 2. 이메일이 provider 측에서 검증된 건지 확인
    if not provider_profile.get("email_verified"):
        # 미검증 이메일은 자동 연동 금지 → 별도 확인 절차로
        return require_manual_verification()

    email = provider_profile["email"]
    user = User.find_by(email=email)
    if user:
        # 기존 계정에 묶을 때는 "그 계정 소유자의 명시적 동의"를 받는다
        if not user_confirmed_linking(user):
            return ask_for_login_then_link()
        link_social_account(user, provider_profile)
    ...

두 가지가 핵심이다. (1) provider가 이메일을 검증했는지 확인, (2) 기존 계정에 붙일 때는 그 계정 주인이 직접 로그인/동의하게 만들기. 둘 중 하나만 빠져도 탈취 경로가 열린다.

실무 관점: 어디서 자주 터지나

1. "이메일로 매칭"이라는 편의 기능이 제일 위험하다. UX 팀은 "같은 이메일이면 자동으로 묶어주세요, 사용자 헷갈려요"라고 요구한다. 기분은 이해하지만, 자동 연동은 반드시 검증된 이메일 + 명시적 동의 조건이 붙어야 한다. 나는 사내 리뷰에서 "auto-link" PR이 올라오면 무조건 이 두 가지를 묻는다.

2. 라이브러리가 다 해줄 거라는 착각. NextAuth(Auth.js), Passport, Spring Security 같은 라이브러리들이 OAuth 흐름을 많이 추상화해준다. 그런데 account linking 정책은 라이브러리마다 기본값이 다르다. 예를 들어 NextAuth는 과거에 동일 이메일 자동 연동을 막아두는 게 기본이었고(allowDangerousEmailAccountLinking 같은 옵션명에서 위험성을 짐작할 수 있다), 이걸 편하다고 켜는 순간 위 시나리오에 노출된다. 옵션명에 dangerous가 붙어 있으면 이유가 있다.

3. ID 토큰 검증을 "라이브러리가 알아서"라고 믿는 것. aud(audience), iss(issuer), exp(만료), nonce 검증이 실제로 켜져 있는지는 직접 확인해야 한다. 특히 access token을 ID token처럼 쓰거나, userinfo 엔드포인트 응답을 검증 없이 신뢰하는 코드가 의외로 많다. access token은 "이 사람이 누구다"를 증명하는 용도가 아니라 "이 리소스에 접근해도 된다"는 용도다. 신원 증명은 ID 토큰(OIDC)으로 해야 한다.

4. 한국 특화 함정 - 카카오/네이버. 국내 소셜 로그인은 표준 OIDC를 완전히 따르지 않거나, 이메일을 선택 동의 항목으로 받게 되어 있는 경우가 많다. 그래서 이메일이 null로 오거나, 사용자가 동의를 안 해서 이메일이 없는 케이스를 처리해야 한다. 이메일 기반 매칭 전략 자체가 위태로워지는 거다. 이럴 땐 provider별 고유 ID(예: 카카오 회원번호)를 기준으로 연동 관리하고, 이메일은 보조 정보로만 쓰는 게 안전하다. 공식 문서에서 각 동의 항목의 보장 여부를 반드시 확인하자.

대안/완화책. 자동 연동을 아예 막고, 계정 통합은 "이미 로그인한 상태에서 마이페이지 - 소셜 계정 연결" 흐름으로만 허용하는 게 가장 안전하다. UX는 조금 불편해지지만, 탈취 경로 하나를 통째로 닫는다. 그리고 계정 연동/해제 같은 민감 이벤트는 반드시 감사 로그를 남기고, 가능하면 이메일 알림까지 보내자. 사고가 나도 빨리 탐지된다.

정리

한 줄 요약: 소셜 로그인은 인증 위임이지 인증 그 자체가 아니다. 이메일만 믿고 자동으로 계정을 묶는 순간 탈취 경로가 열린다.

  • 소셜/이메일 로그인을 같이 쓰는 서비스를 운영한다면 → 지금 당장 자동 연동 로직과 email_verified 검증 여부를 확인하라.
  • NextAuth/Passport/Spring Security 같은 라이브러리를 쓴다면 → account linking 기본값과 토큰 검증 옵션이 켜졌는지 직접 확인하라.
  • 국내 소셜(카카오/네이버)을 붙인다면 → 이메일 매칭에 의존하지 말고 provider 고유 ID 기준으로 가라.

이번 인스타그램 사례의 정확한 기술 디테일은 개인 리서처 블로그 기반이라 단정하긴 어렵다. 다만 "어이없을 만큼 단순한 검증 누락"이라는 평가는, 위에서 본 패턴 중 하나일 가능성이 높아 보인다. 우리 코드에 같은 구멍이 없는지 점검하는 계기로 삼으면 충분히 값어치 있다.

참고 자료

사진: Microsoft Copilot / Unsplash

728x90

Cloudflare Turnstile가 WebGL 핑거프린팅을 요구하기 시작했다 — 봇 차단의 대가는 누가 치르나

왜 지금 이게 화제인가

Cloudflare Turnstile은 reCAPTCHA 대안으로 꽤 빠르게 자리잡은 봇 차단 위젯이다. "사용자에게 그림 퍼즐 안 풀리고, 체크박스 하나로 끝난다"는 점 때문에 도입한 곳이 많다. 우리 팀도 로그인/회원가입 폼에 깔아봤고, 외부에서 들어오는 무차별 대입 시도를 꽤 줄였다.

그런데 이번에 올라온 글(hacktivis.me)의 요지는 이거다. Turnstile이 챌린지를 통과시키기 위해 WebGL 렌더링 결과를 요구하기 시작했고, 이 WebGL 결과가 사실상 브라우저/기기를 식별 가능한 핑거프린트로 쓰일 수 있다는 것이다. WebGL을 끄거나 제한한 환경(프라이버시 강화 브라우저, 일부 리눅스 구성, headless 환경)에서는 챌린지가 무한 루프에 빠지거나 통과 자체가 막힌다는 보고다.

"봇 막으려고 깐 게, 실사용자의 정상 브라우저를 못 통과시킨다"는 게 핵심 문제다. DevOps/인프라 입장에서는 단순히 프라이버시 이슈가 아니라 가용성(availability) 문제로 직결된다.

핵심: WebGL 핑거프린팅이 왜 봇 판별에 쓰이나

봇과 사람을 구별하는 가장 확실한 신호 중 하나는 "이 클라이언트가 진짜 GPU 가속 브라우저에서 돌고 있는가"다. WebGL은 GPU를 통해 그래픽을 렌더링하는 API인데, 같은 도형을 그려도 GPU 모델, 드라이버 버전, OS, 안티앨리어싱 처리 방식에 따라 픽셀 단위 결과가 미묘하게 달라진다.

이걸 해시로 뽑으면 기기마다 거의 고유한 값이 나온다. 개념적으로 이런 식이다.

// 개념 예시 (실제 Turnstile 코드 아님)
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');

// 1) 렌더러/벤더 정보 직접 조회
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
console.log(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL));
// 예: "ANGLE (NVIDIA GeForce RTX 3060 ...)"

// 2) 특정 도형을 렌더링한 뒤 픽셀을 읽어 해시
const pixels = new Uint8Array(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const fingerprint = hash(pixels); // 기기별로 미세하게 다른 값

봇 탐지 입장의 논리는 이렇다.

  • 진짜 GPU가 없는 headless 환경은 WebGL 결과가 비거나, 소프트웨어 렌더러(SwiftShader 등) 시그니처가 그대로 드러난다.
  • 대량 자동화 환경은 핑거프린트가 똑같이 찍히거나 비정상적으로 균일해서 패턴이 잡힌다.
  • 정상 사용자는 충분히 다양하고 "그럴듯한" 분포를 보인다.

문제는 이 신호가 봇만 거르는 게 아니라, WebGL을 의도적으로/구조적으로 제한한 정상 사용자도 같이 걸러버린다는 점이다. 프라이버시 보호를 위해 WebGL 핑거프린팅을 차단하는 건 정당한 사용자 행동인데, 그게 봇 신호와 구분이 안 된다.

실무 관점: 깔기 전에 따져야 할 것들

1) 이건 "프라이버시 vs 보안" 트레이드오프다

Turnstile을 마케팅 그대로 "프라이버시 친화적 CAPTCHA"라고 받아들이면 안 된다. 봇 판별 정확도를 높이려면 결국 클라이언트에 대한 신호를 더 많이 수집해야 하고, WebGL 핑거프린팅은 그 신호 중 하나로 보인다. 마케팅 문구와 실제 동작 사이의 간극을 인지하고 도입해야 한다. (정확히 어떤 조건에서 WebGL을 강제하는지는 공식 문서에 명시돼 있지 않으니 직접 확인 필요)

2) 가용성 리스크: 정상 사용자 차단

실무에서 가장 아픈 건 CS 티켓이다. "로그인이 안 돼요"인데 재현이 안 되는 케이스. Turnstile 무한 루프는 다음 환경에서 잘 터진다.

  • WebGL 비활성/제한한 브라우저(프라이버시 확장, Tor, 일부 모바일 보안 브라우저)
  • GPU 드라이버 문제 또는 가속 비활성 환경
  • 일부 리눅스 + 특정 그래픽 스택 조합
  • 오래된 저사양 기기

이 사용자들은 "봇이 아닌데 봇 취급"당한다. 글로벌 서비스나 공공성 있는 서비스라면 접근성/차별 이슈로 번질 수 있다.

3) 도입 시 실제로 해야 할 것

그냥 깔고 끝내지 말고, 실패율을 모니터링하라. Turnstile 토큰 검증은 서버에서 siteverify로 한다.

curl -s https://challenges.cloudflare.com/turnstile/v0/siteverify \
  -d "secret=$TURNSTILE_SECRET" \
  -d "response=$CLIENT_TOKEN" \
  -d "remoteip=$CLIENT_IP"

여기서 응답의 성공/실패율, 그리고 클라이언트단에서 챌린지가 로딩만 되고 토큰 발급이 안 되는 비율을 분리해서 봐야 한다. 후자가 바로 "정상 사용자 차단" 신호다. 클라이언트 콜백을 잡아 실패 이벤트를 로깅하라.

turnstile.render('#widget', {
  sitekey: '...',
  callback: (token) => submitForm(token),
  'error-callback': () => logMetric('turnstile_error'),
  'timeout-callback': () => logMetric('turnstile_timeout'),
});

이 메트릭이 특정 OS/브라우저 군에서만 튀면 WebGL 강제 같은 환경 의존 이슈를 의심할 수 있다.

4) 흔한 실수

  • Turnstile을 유일한 방어선으로 쓰는 것. CAPTCHA류는 첫 관문일 뿐이다. 서버단 rate limiting, IP 평판, 행동 기반 탐지를 같이 깔아야 한다. Turnstile 하나에 가용성을 베팅하지 마라.
  • fail-closed로만 설계. 챌린지 인프라가 흔들리거나 특정 사용자군이 통과 못 할 때 서비스 전체가 막히는 구조면 위험하다. 핵심 경로(로그인 등)는 대체 인증 경로(이메일 OTP 등)를 둬서 fail-soft를 고려하라.
  • 모니터링 없이 깔기. 봇이 줄었다는 건 보이는데 정상 사용자가 얼마나 막혔는지는 안 보인다. 후자를 안 재면 손해를 평생 모른다.

5) 대안

  • hCaptcha: 비슷한 위젯형. 핑거프린팅 의존도는 직접 검증 필요.
  • 서버사이드 위주 방어: rate limit + WAF 규칙 + IP 평판으로 상당수 막고, CAPTCHA는 의심스러운 트래픽에만 노출(adaptive challenge).
  • PoW(Proof of Work) 방식: 클라이언트에 연산 비용을 부과하는 방식. 핑거프린팅 없이 봇의 대량 시도 비용을 올린다. 다만 모바일/저사양 기기 배터리·체감 이슈가 있다.

핵심은 "한 가지에 올인하지 말고, 의심 트래픽에만 강한 챌린지를 점진 적용"하는 것이다.

정리

한 줄 요약: Turnstile은 봇 차단 효과는 있지만, WebGL 핑거프린팅에 기대는 부분이 있어 프라이버시 강화 환경의 정상 사용자를 같이 막을 수 있으니, 실패율 모니터링과 대체 인증 경로를 반드시 같이 설계하라.

누가 언제 쓰나:

  • 봇 무차별 시도가 실제로 문제이고, 사용자 대부분이 평범한 데스크톱/모바일 브라우저인 일반 서비스라면 도입 가치 있다.
  • 단, 글로벌·공공성·접근성이 중요한 서비스나 프라이버시 민감 사용자가 많은 서비스라면, Turnstile을 첫 관문으로만 쓰고 fail-soft 경로를 반드시 둬라.
  • "프라이버시 친화적"이라는 문구만 믿고 컴플라이언스 근거로 쓰는 건 위험하다. 실제 데이터 수집 동작은 직접 확인이 필요하다.

참고 자료

※ 이 글의 WebGL 강제 조건·핑거프린팅 동작에 대한 서술은 출처 아티클의 주장과 일반적 핑거프린팅 원리에 기반한 것으로, Cloudflare가 공식적으로 명시한 동작과는 다를 수 있다. 도입 전 직접 환경별 테스트를 권한다.

사진: engin akyurt / Unsplash

728x90

rsync는 인프라 하는 사람이라면 거의 무의식적으로 손이 가는 도구다. 백업, 배포, 서버 간 파일 동기화, 심지어 S3 마이그레이션 전 단계까지. 그런데 오늘 HN 상단(426점)과 GeekNews에 동시에 openrsync가 올라왔다. "또 rsync 클론인가" 싶겠지만, 이건 OpenBSD 팀이 base에 통합해서 굴리고 있는 물건이라 결이 다르다. 실무에서 한 번쯤 짚고 넘어갈 가치가 있어서 정리한다.

왜 지금 openrsync인가

기존 rsync(우리가 흔히 쓰는 그것, tridge가 만든)는 오래되고 검증됐지만 GPLv3 라이선스다. 코드 베이스도 20년 넘게 쌓이면서 복잡해졌고, 과거 여러 CVE도 있었다. 파일 동기화 프로토콜을 다루는 도구가 setuid나 데몬 모드로 돌아갈 때, 코드 복잡성은 그대로 공격 표면이 된다.

openrsync는 OpenBSD 팀이 이 프로토콜(버전 27 기반)을 ISC 라이선스로 새로 구현한 것이다. ISC는 BSD 계열 퍼미시브 라이선스라, GPL 의무가 부담스러운 상용 제품이나 임베디드 펌웨어에 그냥 끼워 넣을 수 있다. 실제로 Apple이 macOS에서 GPLv3를 피하려고 GPLv2 시절 도구들을 그대로 쓰거나 BSD 대체재로 갈아탄 전력이 있는데, 같은 맥락이다.

정리하면 openrsync가 푸는 문제는 두 가지로 보인다. 첫째 라이선스 자유도, 둘째 작고 감사 가능한(auditable) 코드 베이스. 기능적으로 더 빠르거나 더 많은 걸 하려는 프로젝트가 아니다.

핵심: 무엇이 같고 무엇이 다른가

프로토콜 호환이 핵심이다. openrsync는 rsync 프로토콜 27을 지원하므로, 반대편이 평범한 rsync(테스트엔 3.1.3이 쓰였다고 함)여도 통신이 된다. 즉 한쪽만 openrsync여도 동작할 수 있다.

쓰는 법은 우리가 아는 그대로다.

# 로컬 → 원격 (SSH 경유)
openrsync -av ./build/ user@server:/var/www/app/

# 원격 → 로컬, 삭제 동기화
openrsync -av --delete user@server:/data/ ./backup/

비유하자면 rsync가 풀옵션 스위스 군용칼이라면, openrsync는 자주 쓰는 날 몇 개만 남기고 깔끔하게 다시 만든 칼이다. 델타 전송(바뀐 블록만 보내기), -a 아카이브 모드, --delete, SSH 터널링 같은 일상 옵션은 대부분 커버한다.

대신 rsync의 변두리 옵션들 — 예를 들어 --fuzzy, 정교한 필터 룰, 일부 데몬 모드 세부 기능 — 까지 1:1로 다 구현됐다고 보장하긴 어렵다. 이 부분은 반드시 자기 워크플로의 옵션으로 직접 테스트하고 man 페이지로 확인해야 한다.

실무 관점: 도입 시 따져볼 것들

1. 어디서 돌릴 건가. openrsync는 OpenBSD base에 들어있어서 OpenBSD 환경이라면 이미 있거나 기본에 가깝다. 문제는 우리 대부분이 굴리는 리눅스 서버다. 리눅스용 포팅이 진행되긴 했지만 배포판 기본 패키지로 어디까지 들어와 있는지는 환경마다 다르니, apt/dnf로 바로 잡힐 거란 기대는 접고 직접 확인하는 게 맞다. 일부러 빌드해서까지 깔 이유가 있는지 먼저 자문해야 한다.

2. "그냥 rsync 쓰면 되는데"라는 함정. 솔직히 말해 일반적인 백업/배포 파이프라인에서 굳이 rsync를 openrsync로 갈아탈 강한 이유는 없다. 기존 rsync는 잘 동작하고 생태계도 두텁다. openrsync가 빛나는 건 다음 같은 상황이다.

  • 제품에 rsync 기능을 번들로 넣어야 하는데 GPLv3가 법무 검토에 걸릴 때
  • BSD 계열 시스템이나 임베디드/어플라이언스에서 의존성을 최소화하고 싶을 때
  • 코드 감사가 필요한 보안 민감 환경에서 작은 코드 베이스를 선호할 때

3. 옵션 비호환에서 오는 사일런트 실패. 가장 무서운 시나리오. 자동화 스크립트에서 rsync 옵션을 그대로 openrsync에 던졌는데 해당 옵션이 무시되거나 다르게 동작하면, 명령은 성공으로 끝나지만 결과 파일 상태가 의도와 다를 수 있다. 특히 --delete와 필터 규칙이 얽히면 데이터 누락으로 이어진다. 운영 투입 전엔 dry-run으로 검증하는 습관이 필수다.

# 무엇이 전송/삭제될지 먼저 확인
openrsync -avn --delete src/ dst/

4. 대안 비교. 단순히 "더 가볍고 빠른 동기화"가 목적이라면 openrsync보다 다른 선택지가 더 맞을 수도 있다. 다수 서버 fan-out 배포라면 설정 관리 도구(Ansible 등)나 오브젝트 스토리지 기반 배포, 대용량 클라우드 동기화라면 rclone이 더 현실적이다. openrsync는 "rsync 프로토콜을 퍼미시브 라이선스로 쓰고 싶다"는 구체적 요구가 있을 때의 답이지, 만능 업그레이드가 아니다.

정리

한 줄 요약: openrsync는 더 빠른 rsync가 아니라, ISC 라이선스로 깔끔하게 다시 쓴 작고 감사 가능한 rsync다.

일상 백업/배포에 이미 rsync를 잘 쓰고 있다면 굳이 바꿀 이유는 없다. 다만 (1) 상용 제품에 rsync를 번들해야 하는데 GPL이 걸리거나, (2) BSD/임베디드 환경에서 의존성과 공격 표면을 줄이고 싶거나, (3) 보안 감사 대상 시스템이라면 진지하게 검토할 만하다. 도입할 땐 반드시 자기 워크플로의 옵션을 dry-run으로 검증하고, 비호환 옵션이 조용히 무시되지 않는지 확인하라.

참고 자료

728x90

오늘 HN에서 "SQLite is all you need for durable workflows"가 377점 받고 올라왔다. 제목만 보면 또 흔한 "SQLite가 최고다" 류 떡밥인가 싶지만, durable workflow라는 키워드가 붙으면 얘기가 좀 다르다. 실무에서 Temporal이나 AWS Step Functions 같은 워크플로 엔진을 한 번이라도 운영해본 사람이면 "이걸 SQLite로?" 하는 의문이 바로 들 거다. 정리해보자.

왜 지금 이게 화제인가

먼저 durable workflow가 뭔지부터. 결제 처리, 주문 파이프라인, 이메일 발송 후 N일 뒤 리마인드 보내기 같은 여러 단계로 이루어지고, 중간에 프로세스가 죽어도 멈춘 지점부터 다시 이어가야 하는 작업을 말한다.

이걸 직접 짜본 사람은 안다. 그냥 함수 호출 이어붙이면 서버가 3번째 스텝에서 OOM으로 죽는 순간 끝장이다. 1, 2번 스텝은 이미 실행됐는데(예: 카드 승인 완료), 재시작하면 처음부터 다시 돌아 중복 결제가 난다. 그래서 나온 게 Temporal, Cadence, AWS Step Functions, Azure Durable Functions 같은 솔루션이다.

문제는 이 엔진들이 하나같이 인프라가 무겁다는 거다. Temporal만 해도 자체적으로 Cassandra나 PostgreSQL, Elasticsearch를 깔아야 하고, 워커 클러스터에 frontend/history/matching 서비스까지 띄워야 한다. 작은 팀이 "주문 처리 단계 5개를 안정적으로 굴리고 싶다"는 이유로 도입하기엔 운영 부담이 너무 크다. 여기서 "그냥 SQLite 하나로 안 되나?"라는 발상이 나온 거다.

핵심: durable execution의 원리와 SQLite의 역할

durable workflow의 핵심은 단순하다. 각 스텝의 실행 결과를 영속 저장소에 기록(append)하고, 재시작 시 그 기록을 리플레이해서 이미 끝난 스텝은 건너뛴다. 이걸 event sourcing 혹은 journaling이라 부른다.

의사코드로 보면 이렇다.

async def order_workflow(ctx, order_id):
    # ctx.step()은 "이미 실행했으면 저장된 결과를 반환,
    # 아니면 실행하고 결과를 DB에 기록"한다
    payment = await ctx.step("charge", lambda: charge_card(order_id))
    await ctx.step("reserve", lambda: reserve_stock(order_id))
    await ctx.sleep("wait", timedelta(days=1))   # 1일 대기도 durable하게
    await ctx.step("notify", lambda: send_email(order_id))

여기서 ctx.step이 마법의 핵심이다. 동작을 풀어보면:

  1. charge 스텝을 실행하기 전, DB에 "charge 시도 시작" 기록
  2. 실제 charge_card 실행
  3. 결과를 DB에 커밋. 여기까지가 하나의 트랜잭션
  4. 만약 2번과 3번 사이에 프로세스가 죽으면? 재시작 시 "charge가 완료 안 됨"으로 보고 재시도
  5. 이미 charge가 완료됐으면 저장된 결과를 그대로 반환하고 다음으로 넘어감

여기서 SQLite가 빛난다. 워크플로 상태와 스텝 실행 로그를 저장하는 게 결국 트랜잭션 보장되는 작은 KV/로그 저장소면 충분한데, SQLite가 정확히 그거다. WAL 모드 켜면 동시 읽기도 잘 버티고, 단일 파일이라 운영할 게 없다. 별도 DB 프로세스도, 네트워크 홉도 없다.

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;  -- durability vs 성능 트레이드오프, 뒤에서 설명

CREATE TABLE workflow_steps (
  run_id   TEXT NOT NULL,
  step_id  TEXT NOT NULL,
  status   TEXT NOT NULL,        -- pending / completed / failed
  result   BLOB,
  created_at INTEGER,
  PRIMARY KEY (run_id, step_id)
);

비유하자면 Temporal이 "전기, 수도, 가스 다 끌어와야 하는 대형 공장"이라면, SQLite 기반은 "전기 콘센트 하나로 돌아가는 작업대"다. 처리량과 규모가 작업대 수준이면 공장을 지을 이유가 없다.

실무 관점: 도입 전에 반드시 따져야 할 것들

1. 워크플로 함수는 반드시 deterministic해야 한다

이건 SQLite든 Temporal이든 durable execution 모델의 공통 함정인데, 처음 쓰는 사람이 100% 밟는 지뢰다. 리플레이로 상태를 복원하기 때문에, 워크플로 함수 본문에서 datetime.now(), random(), 외부 API 직접 호출 같은 비결정적 코드를 쓰면 안 된다. 리플레이할 때마다 값이 달라져서 분기가 꼬인다.

현재 시각이 필요하면 ctx.now()처럼 엔진이 제공하는, 결과가 기록되는 함수를 써야 한다. 외부 호출은 전부 ctx.step()으로 감싸야 한다. 이 규칙을 모르고 짜면 "평소엔 잘 되는데 재시작하면 가끔 이상하게 동작하는" 최악의 디버깅 지옥에 빠진다.

2. SQLite의 동시성 한계 = 곧 처리량 천장

SQLite는 WAL 모드여도 쓰기는 한 번에 하나만 된다(single writer). durable workflow는 스텝마다 쓰기가 발생하므로, 초당 수천 건 이상의 워크플로 전이가 필요하면 SQLite는 병목이 된다. 단일 노드에서 초당 수백~수천 트랜잭션 수준이 현실적인 상한선으로 보인다(워크로드·디스크에 따라 크게 다름, 직접 벤치 필요).

반대로 말하면, 워크플로 전이가 초당 수십~수백 건 수준이고 단일 노드로 충분한 규모면 SQLite가 오히려 더 빠르고 단순하다. 네트워크 왕복이 없으니까.

3. 단일 노드 = SPOF, 그리고 수평 확장 불가

가장 큰 트레이드오프다. SQLite 파일은 한 머신에 묶여 있다. 그 노드가 죽으면 워크플로 처리가 멈춘다. Temporal이 무거운 이유 중 하나가 바로 이 HA(고가용성)와 수평 확장을 기본 제공하기 때문이다.

대응책으로는:

  • EBS 같은 영속 볼륨 + 빠른 재기동으로 RTO를 줄인다 (active-passive)
  • Litestream으로 S3에 실시간 백업해서 데이터 유실만 막는다 (복구는 수동)
  • 정말 HA가 필요하면 이 패턴 자체가 부적합하다. 솔직하게 인정하고 다른 걸 써야 한다

4. synchronous 설정과 durability 트레이드오프

위 스니펫에서 synchronous = NORMAL로 했는데, 이건 OS 크래시(전원 차단 등) 시 마지막 트랜잭션 일부가 날아갈 수 있다. "durable"을 표방하면서 데이터 유실 가능성을 열어두는 건 모순이다. 금전 거래처럼 한 건도 잃으면 안 되는 워크로드면 synchronous = FULL로 가야 하고, 그만큼 쓰기 지연이 늘어난다. 무엇을 보장하고 싶은지부터 정하고 설정해야 한다.

대안 비교

  • Temporal: 규모 크고 HA 필수, 전담 인프라 인력 있을 때. 오버스펙 주의.
  • DBOS (PostgreSQL 기반): durable workflow를 Postgres 위에서. 이미 Postgres 쓰는 팀이면 SQLite보다 자연스러운 선택지일 수 있다.
  • SQLite 기반: 단일 서비스, 소규모 팀, 운영 단순함이 최우선일 때.
  • 그냥 메시지 큐 + 멱등 처리: 워크플로가 단순한 fan-out 수준이면 굳이 엔진 안 써도 된다.

정리

한 줄 요약: durable workflow의 본질은 "트랜잭션 가능한 스텝 로그 + 리플레이"이고, 규모가 단일 노드로 충분하면 그 저장소를 SQLite로 두는 게 운영상 가장 단순하다.

이런 사람에게 추천한다 — 스타트업/소규모 팀에서 결제·주문·예약 같은 다단계 작업을 안정적으로 굴려야 하는데, Temporal 클러스터 운영할 여력은 없는 경우. 반대로 초당 수천 건 이상 처리하거나 HA가 계약 조건인 환경이면 처음부터 무거운 엔진을 쓰는 게 맞다. "SQLite로 충분한가"는 결국 처리량과 가용성 요구사항이 단일 노드 안에 들어오느냐로 결정된다. 멋져 보여서가 아니라, 본인 워크로드 숫자를 먼저 재보고 고르자.

참고 자료

사진: Surface / Unsplash

728x90

+ Recent posts