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

Postgres 하나로 Durable Workflow 짜기 — DBOS 방식이 실무에서 먹히는 이유

오늘 HN 목록 보다가 "Building durable workflows on Postgres"(DBOS) 글이 292점 받고 올라와 있길래 골랐다. AI 관련 글이 절반인 와중에 이건 인프라/백엔드 하는 사람한테 훨씬 실용적인 주제다. 워크플로우 오케스트레이션이라고 하면 보통 Temporal, Airflow, Step Functions 떠올리는데, "그거 그냥 Postgres로 다 되는 거 아니냐"는 주장이라 한번 정리해볼 만하다.

왜 지금 이게 화제인가

현업에서 결제, 주문, 정산 같은 워크플로우 짜본 사람은 다 안다. "여러 단계로 이뤄진 작업이 중간에 죽으면 어디부터 다시 해야 하나" 이게 진짜 골칫거리다.

예를 들어 결제 플로우가 이렇다고 치자.

1. 재고 차감
2. 결제 승인 (외부 PG 호출)
3. 포인트 적립
4. 알림 발송

2번에서 PG 호출하다가 서버 프로세스가 죽었다. 결제는 됐는데 포인트는 안 붙었다. 재배포로 인스턴스가 내려갔을 수도 있고, OOM으로 죽었을 수도 있다. 이 "중간에 죽어도 정확히 멈춘 지점부터 다시 이어서 한다"는 게 durable execution(내구성 있는 실행)의 핵심이다.

지금까지 이걸 제대로 하려면 Temporal 같은 별도 워크플로우 엔진을 띄워야 했다. 근데 그게 운영 부담이 만만치 않다. DBOS의 주장은 "이미 너희가 쓰는 Postgres 트랜잭션이면 충분하다"는 거다.

핵심: 어떻게 Postgres만으로 되나

아이디어 자체는 의외로 단순하다. 각 단계(step)의 실행 결과를 Postgres 테이블에 기록해두고, 워크플로우가 재시작되면 이미 완료된 단계는 건너뛰고 결과만 읽어온다. 이게 흔히 말하는 메모이제이션(memoization)이다.

DBOS 라이브러리 기준으로 쓰면 대충 이런 모양이다.

import { DBOS } from "@dbos-inc/dbos-sdk";

class Checkout {
  @DBOS.step()
  static async chargePayment(orderId: string) {
    // 외부 PG 호출
    return await pg.charge(orderId);
  }

  @DBOS.step()
  static async addPoints(userId: string, amount: number) {
    return await pointService.add(userId, amount);
  }

  @DBOS.workflow()
  static async run(orderId: string, userId: string) {
    const result = await Checkout.chargePayment(orderId);
    await Checkout.addPoints(userId, result.amount);
    return result;
  }
}

여기서 마법은 @DBOS.step()에 있다. 이 데코레이터가 붙은 함수는 실행되면 결과가 Postgres에 저장된다. 만약 addPoints 직전에 프로세스가 죽었다가 워크플로우가 재시작되면, chargePayment는 다시 호출되지 않고 저장된 결과를 그대로 반환한다. 이미 결제가 된 걸 또 긁는 사고를 막아주는 구조다.

비유하자면 게임 세이브 포인트다. 보스 잡고 세이브 찍었으면, 다음 구간에서 죽어도 보스부터 다시 잡진 않는다. 워크플로우 상태와 각 스텝 결과가 전부 DB 트랜잭션 안에서 원자적으로 커밋되니까, "절반만 저장된" 어정쩡한 상태가 안 생긴다. 이게 Postgres의 ACID를 그대로 빌려쓰는 부분이다.

그리고 워크플로우 실행 코드 자체가 애플리케이션 프로세스 안에서 돌아간다는 점이 Temporal과 가장 다르다. Temporal은 워커-서버 구조로 별도 클러스터가 필요한데, DBOS는 라이브러리 임베드 방식이라 "내 앱 + Postgres" 조합이면 끝난다.

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

1) 스텝은 멱등(idempotent)하게 짜야 한다. 이건 durable execution 도구를 뭘 쓰든 공통이다. DBOS가 완료된 스텝을 건너뛴다고 해도, "정확히 한 번 실행됐는지" 보장은 스텝이 커밋되기 직전에 죽는 경계 케이스에서 까다롭다. 외부 PG 호출 같은 건 idempotency key를 같이 넘겨서 PG 쪽에서도 중복 승인을 막아야 안전하다. 라이브러리만 믿고 멱등성 설계를 생략하면 언젠가 중복 결제로 사고 난다.

2) Postgres가 단일 장애 지점이자 병목이 된다. 모든 워크플로우 상태가 한 DB로 몰린다. 워크플로우 처리량이 높아지면 스텝마다 INSERT/UPDATE가 발생하니 DB write 부하가 커진다. 트래픽 큰 서비스라면 워크플로우 전용 DB를 분리하거나, 어차피 쓰는 Postgres라도 커넥션 풀과 vacuum 전략을 미리 생각해둬야 한다. "Postgres is all you need"는 규모가 적당할 때 맞는 말이지, 무한정 확장된다는 뜻은 아니다.

3) 스텝 시그니처를 함부로 못 바꾼다. 진행 중인 워크플로우가 DB에 남아있는데 코드에서 스텝 순서를 바꾸거나 추가하면, 재시작 시 저장된 상태와 코드가 안 맞아 깨질 수 있다. 이건 Temporal의 versioning 이슈와 같은 종류의 함정이다. 워크플로우 코드 변경은 배포 전략을 따로 신경 써야 한다(공식 문서에서 버전 관리 정책 확인 필요).

대안과 비교

  • Temporal: 기능과 생태계는 가장 성숙. 대신 별도 클러스터 운영 부담. 규모 크고 워크플로우가 복잡하면 여전히 강력한 선택.
  • Airflow: 배치/데이터 파이프라인용. 실시간 트랜잭션성 워크플로우엔 결이 안 맞는다.
  • Step Functions: AWS 락인. 매니지드라 편하지만 로컬 개발/디버깅이 불편하고 상태 전이마다 비용이 붙는다.
  • 직접 구현: outbox 패턴 + 상태 테이블로 비슷하게 만들 수 있다. DBOS는 이 패턴을 라이브러리로 추상화해준 거라고 보면 된다.

개인적으로는 이미 Postgres 박혀 있는 모놀리식~중소 규모 서비스에서 "결제/정산 같은 단계형 작업의 신뢰성"만 올리고 싶을 때 가성비가 가장 좋아 보인다. 운영 컴포넌트를 안 늘려도 되는 게 제일 큰 메리트다.

정리

한 줄 요약: 이미 쓰는 Postgres 트랜잭션을 세이브 포인트 삼아, 중간에 죽어도 이어서 실행되는 워크플로우를 별도 인프라 없이 만든다.

누가 언제 쓰면 좋냐면 — 결제/주문/정산처럼 "중간에 끊기면 곤란한" 다단계 작업이 있고, Temporal 클러스터까지 띄우기는 부담스러운 팀. 반대로 초당 수천 건 워크플로우가 도는 대규모거나, 이미 워크플로우 엔진 운영 노하우가 있다면 굳이 갈아탈 이유는 없다. 도입하더라도 스텝 멱등성 설계와 DB 부하 모니터링은 처음부터 깔고 가야 나중에 안 운다.

참고 자료

※ 라이브러리 API와 버전 관리 정책은 빠르게 바뀔 수 있으니 도입 전 공식 문서로 최신 내용 확인 필요.

사진: Surface / Unsplash

728x90

Firebase웹에서 컬렉션과 샘플 데이터까지 만들었다고 가정할 시 

맥에서 flutter를 사용하여 Firebase와 연동하려고 하는 방법을 설명한다 

 

제일 처음 터미널을 켜서 아래와 같이 입력한다 

curl -sL https://firebase.tools | bash

 

설치 도중 기기의 암호를 입력하는 단계가 있으므로 잊지말고 암호를 입력하자

그러면 설치가 완료 되는데, 

 

완료 이후  아래와 같은 명령어로 firebase에 로그인을 해준다

firebase login

 

명령어를 실행하면 웹에서 연동된 구글 계정으로 로그인 할 수 있다. 

로그인한 이후 

 

다시 터미널로 돌아가서 flutter와 firebase간 연동을 쉽게해주는 dart 패키지를 받을건데  명령어는 다음과 같다

dart pub global activate flutterfire_cli

 

설치가 완료되면 다음 사진과 같이 화면이 뜰텐데

경고의 내용은 심플하게 경로에있는 내용을 현재 쉘rc 파일에 환경설정으로 추가하란 말이다.  

 

경고를 따라서 다음과같이 명령어를 입력하고 내용을 복사하여 ~/.zshrc에 환경변수를 선언했다

vim ~/.zshrc

 

환경변수를 선언하고 vscode terminal에서 다음과 같이 명령어를 치면 사진과 같이 있는 프로젝트를 선택할 수 있는데 

거기서 맞는 프로젝트를 선택하면 된다. 

flutterfire configure

 

프로젝트를 선택했으면 다음으로 어떤 플랫폼에 연동할건지 선택을 할 수 있는데

이번 프로젝트는 앱으로만 만들예정이므로 android 및 ios만 선택하고 나머지는 space키로 선택해제 한다

 

다음으로는 android가 선택이 되어있으므로 패키지 명을 입력해줘야 하는데

입력은 com.example.flutter_Firebase_blog_app으로 해당 이름은 root/android/app/build.gradle에서 찾을 수 있다. 

 

아래 사진에서처럼 namespace를 확인해주면 된다. 

 

하지만 진행하다가 다음과 같은 에러를 마주했는데 찾아보니 ruby version이 너무 낮아서 생기는 문제였다 

(https://totally-developer.tistory.com/176 를 참조하여 ruby 버전 올리고 진행함)

 

 

해결하면 다음과 같이 완료된 모습을 볼 수 있다

 

 

아래명령어를 통해 flutter 패키지에 firebase core 플러그인과 firestore관련 플러그인을 추가해준다 

flutter pub add firebase_core
flutter pub add cloud_firestore

 

에러가 많았다..

 

하지만 결국 해냈고 

 

앱이 실행되는게 확인 되면(빌드하는데 4분걸렸다..) 

main함수에 다음과같이 runApp 위에 다음과 같이 추가해준다 

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(const ProviderScope(child: MyApp()));
}
728x90

+ Recent posts