맥북을 개발 머신으로 쓰는 백엔드/인프라 엔지니어라면 한 번쯤 고민해봤을 거다. "Docker Desktop 라이선스도 신경 쓰이고, 리소스도 많이 먹고… 대안이 뭐가 있나." Lima, Colima, OrbStack로 갈아탄 사람도 많을 거다. 그런데 작년부터 Apple이 직접 apple/container 프로젝트를 GitHub에 올리면서 판이 좀 달라졌다. 이번 글에서는 그중에서도 최근 Hacker News에서 화제가 된 Container Machine 문서를 실무자 시선으로 뜯어본다.

먼저 분명히 해두자. 이 글은 공식 문서(container-machine.md)를 기준으로 쓴다. 문서에 없는 벤치마크 수치나 버전별 동작은 함부로 단정하지 않고, 확인이 필요한 부분은 그렇게 표시한다.

1. 왜 지금 화제이고 어떤 문제를 푸는가

우리가 흔히 쓰는 컨테이너는 "애플리케이션 하나"를 모델로 한다. nginx 이미지, postgres 이미지처럼 프로세스 하나 띄우고 끝나는 구조다. 그런데 실무에서 의외로 자주 필요한 건 그게 아니다. "리눅스 환경 자체"가 필요할 때가 있다.

  • 로컬은 맥인데, 빌드와 실행은 리눅스에서 검증하고 싶을 때
  • systemd 기반으로 돌아가는 서비스(예: postgresql, redis를 시스템 서비스로)를 통째로 테스트하고 싶을 때
  • alpine, ubuntu, debian 등 타겟 배포판별로 동작을 확인해야 할 때

Container Machine은 바로 이 지점을 노린다. 공식 문서의 표현을 빌리면 "A container machine is modeled after a Linux environment". 즉 앱 하나가 아니라 init 시스템까지 포함한 리눅스 환경 전체를 가볍고 영구적인(persistent) 형태로 맥 위에서 돌리는 게 목표다.

Docker Desktop이나 Lima와의 가장 큰 차이는 호스트 통합이다. 문서가 강조하는 핵심 컨셉이 "Edit on the Mac, build inside"인데, 풀어보면 이렇다.

  • 내 맥 홈 디렉토리($HOME)에 있는 레포가 컨테이너 머신 안에서도 그대로 보인다.
  • 맥 계정 username과 home 디렉토리가 자동으로 리눅스 환경에 매핑된다. whoami를 쳐도 root가 아니라 내 호스트 계정 이름이 나온다.
  • 맥에서 VS Code로 코드를 짜고, 빌드/실행은 리눅스 안에서, 그리고 결과물은 다시 맥 네이티브 도구(브라우저, 프로파일러 등)로 들여다본다. 복사 단계가 없다.

OrbStack을 써본 사람이라면 "어? 그거 OrbStack도 비슷하게 해주는데?"라고 할 거다. 맞다. 컨셉은 OrbStack의 머신 기능과 상당히 겹친다. 차이라면 이건 Apple이 직접 관리하는 오픈소스이고, 애초에 Apple Silicon + Virtualization.framework 위에서 돌도록 설계됐다는 점이다.

2. 동작 원리: 명령어로 직접 보기

백문이 불여일견. Quickstart부터 따라가 보자. 문서 기준 가장 기본 흐름이다.

# alpine 기반 컨테이너 머신을 'dev'라는 이름으로 생성
container machine create alpine:latest --name dev

# 컨테이너 머신 안에서 whoami 실행
container machine run -n dev whoami

여기서 핵심은 출력이다. 일반 컨테이너에서 whoami를 치면 보통 root가 나오는데, 컨테이너 머신은 다르다.

$ container machine run -n dev whoami
your-mac-username      # root가 아니라 호스트 맥 계정 이름

$ container machine run -n dev pwd
/home/your-mac-username   # 맥 홈 디렉토리가 마운트된 위치

이게 바로 "username과 home 디렉토리 자동 매핑"이 동작하는 모습이다. 인자 없이 container machine run -n dev만 치면 인터랙티브 셸로 들어가고, 이 안에서 내 레포 디렉토리로 cd하면 맥에서 작업하던 파일이 그대로 보인다.

명령 단위로 한 번만 실행하고 빠지고 싶으면 이렇게 쓴다. -- 뒤에 붙이는 형태도 지원한다.

$ container machine run -n dev uname -a
Linux dev 6.x.x ... aarch64 GNU/Linux

$ container machine run -n dev -- cat /proc/cpuinfo

매번 -n dev를 붙이기 귀찮으면 기본 머신을 지정해두면 된다. 그리고 container machinem이라는 짧은 별칭이 있어서 m ls, m run처럼 쓸 수 있다.

container machine set-default dev
container machine run        # 이제 -n 없이도 dev에서 동작

# 별칭 사용
m ls          # 모든 컨테이너 머신 목록
m inspect dev # dev의 JSON 상세 정보
m stop dev    # 중지
m rm dev      # 영구 스토리지까지 포함해 삭제

격리 모델은 어떻게 되나

여기서부터는 문서에 명시된 범위와 합리적 추론을 구분해서 말하겠다. Apple의 container 프로젝트는 일반적으로 각 컨테이너를 경량 VM 위에서 격리하는 구조로 알려져 있고, 이는 Virtualization.framework를 기반으로 한다. 컨테이너 머신 역시 init 시스템(/sbin/init)을 직접 부팅한다는 점에서 단순 namespace 격리가 아니라 VM 경계를 가진 풀 리눅스 환경에 가깝다.

다만 "컨테이너마다 별도 VM인지, 머신마다 하나의 VM인지"의 구체적인 구현 디테일은 이 container-machine.md 문서만으로는 단정하기 어렵다. 실제 격리 단위와 커널 공유 여부는 프로젝트 상위 문서 확인이 필요하다. 다만 실무 체감상 중요한 건, init/systemd를 띄울 수 있다는 사실 자체다. 문서에도 이렇게 적혀 있다.

Run a database or whatever your stack needs as a system service — systemctl start postgresql works on images with systemd installed.

일반 컨테이너에서 systemd 띄우려고 --privileged에 cgroup 마운트 삽질해본 사람이라면, 이게 기본으로 된다는 게 얼마나 편한지 알 거다.

3. 실무 관점: 고려사항, 트레이드오프, 흔한 함정

리소스 설정 — 메모리 기본값이 호스트의 절반이다

이거 모르고 쓰면 맥 전체가 버벅인다. 문서에 명시되어 있다. "Memory defaults to half of host memory." 16GB 맥북이면 컨테이너 머신 하나가 기본 8GB를 잡아간다는 얘기다. 여러 머신을 동시에 띄우는 시나리오라면 반드시 조정하자.

# CPU 4개, 메모리 8G로 변경
container machine set -n dev cpus=4 memory=8G

# 설정은 재시작 후 적용된다 — 이 순서가 중요
container machine stop dev
container machine run -n dev -- nproc

흔한 함정 하나: set으로 바꿔놓고 바로 적용 안 됐다고 당황하는 경우. 문서가 분명히 못 박는다. "Changes take effect after the next stop and start." 즉 실행 중인 머신에 즉시 반영되지 않는다. stoprun(또는 start)을 거쳐야 한다. CI 스크립트에서 동적으로 리소스 바꾸려다 이걸로 한참 헤매기 좋다.

볼륨/홈 마운트 모드 — ro로 묶다가 빌드가 깨진다

홈 마운트는 세 가지 모드가 있다. rw(기본), ro, none. 보안이나 실수 방지 목적으로 ro로 걸어두는 건 좋은데, 빌드 산출물을 홈 디렉토리 하위에 쓰는 툴체인을 쓰면 권한 에러로 빌드가 깨진다. 리눅스 안에서 흔히 이런 메시지를 보게 된다.

Read-only file system
error: failed to write to /home/your-username/project/target/...:
Read-only file system (os error 30)

이건 마운트 모드가 ro일 때 전형적으로 만나는 패턴이다. 빌드 캐시나 산출물 디렉토리를 홈 밖(예: 머신 내부 임시 경로)으로 빼거나, 마운트를 rw로 두는 식으로 우회해야 한다.

BYO 이미지 — systemd 없으면 그냥 컨테이너다

문서가 명확히 한 조건이 있다. "Any Linux image that includes /sbin/init works as a container machine." 즉 init이 없는 minimal 이미지를 그대로 쓰면 컨테이너 머신의 장점(서비스 supervisor, systemctl)을 못 쓴다. systemd 깔린 Ubuntu 머신을 직접 빌드하려면 문서의 Dockerfile을 거의 그대로 따라가야 한다.

FROM ubuntu:24.04
ENV container container
RUN apt-get update \
    && apt-get install -y \
       dbus systemd openssh-server net-tools iproute2 \
       iputils-ping curl wget vim-tiny man sudo \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* \
    && yes | unminimize
RUN systemctl set-default multi-user.target
# (이하 hugepages.mount 등 불필요 유닛 mask/disable 생략)
# 이미지 빌드 후 머신으로 생성
container build -t local/ubuntu-machine:latest .
container machine create local/ubuntu-machine:latest --name ubuntu

여기서 알아둘 디테일 하나. 첫 부팅 시 Apple이 기본 setup 스크립트를 돌려서 호스트 계정 매핑 유저를 만든다. 이걸 직접 제어하고 싶으면 이미지에 /etc/machine/create-user.sh 실행 스크립트를 넣으면 된다. root로, 첫 부팅에 한 번만 실행되며 CONTAINER_UID, CONTAINER_GID, CONTAINER_HOME, CONTAINER_USER, CONTAINER_MACHINE_ID 환경변수가 주어진다. 사내 표준 유저 설정(특정 그룹, sudoers 등)을 강제해야 하는 팀이라면 이 훅이 꽤 유용하다.

네트워킹 — 문서 범위 밖이라 단정 금지

개요에서 "네트워킹 구조 분석"을 기대했을 텐데, 솔직하게 말하면 container-machine.md 문서 자체에는 컨테이너 간 통신이나 호스트 브리지 동작에 대한 구체적 서술이 없다. 빌드한 Ubuntu 이미지에 net-tools, iproute2, iputils-ping을 넣는 걸로 보아 네트워크 도구가 동작하는 환경인 건 분명하지만, 구체적인 브리지/포트포워딩 동작 원리는 별도 네트워킹 문서로 확인이 필요하다. 여기서 추측으로 채우면 그게 바로 검색 유입자에게 잘못된 정보를 주는 길이라, 확인 전엔 단정하지 않겠다.

성능 비교 — 실측 수치는 직접 재라

마찬가지로 Docker Desktop / Lima / OrbStack 대비 정량 벤치마크는 이 문서에 없다. 다만 설계상 추론할 수 있는 트레이드오프는 있다.

  • 장점 방향: Apple Silicon + Virtualization.framework 네이티브 설계라 ARM 리눅스 환경에서의 오버헤드가 작을 것으로 기대된다. 홈 디렉토리 직접 마운트라 파일 동기화 복사 비용이 없다.
  • 비용 방향: 머신마다 init/systemd 풀 부팅 + 기본 메모리 절반 할당이라, "앱 컨테이너 하나만 가볍게"라는 용도엔 과하다.

실제 도입 검토라면 본인 워크로드(예: cargo build, go build, DB 부하 테스트)로 Docker Desktop/OrbStack과 직접 시간을 재서 비교하는 걸 권한다. 일반론으로 "더 빠르다"고 말하는 게 가장 위험하다.

4. 정리: 한 줄 요약과 도입 판단

한 줄 요약: Container Machine은 "앱 컨테이너"가 아니라 "내 맥 홈이 그대로 들어간 영구 리눅스 환경"을 Apple Silicon 위에서 네이티브로 굴리는 도구다.

이런 사람한테 맞다:

  • 맥에서 코드 짜고 리눅스에서 빌드/테스트하는 워크플로우가 일상인 백엔드/시스템 개발자
  • systemd 기반 서비스(DB 포함)를 로컬에서 통째로 띄워 검증해야 하는 경우
  • alpine/ubuntu/debian 멀티 배포판 호환성을 자주 확인하는 라이브러리/CLI 메인테이너
  • Docker Desktop 라이선스/리소스 부담에서 벗어날 오픈소스 대안을 찾는 팀

아직 신중해야 할 경우:

  • 프로덕션 CI의 핵심 빌드 인프라로 바로 올리기엔, 네트워킹·성능 특성이 공개 문서로 충분히 검증되지 않았다. 로컬 개발 보조부터 시작하는 게 안전하다.
  • Intel 맥 사용자. 이 프로젝트는 Apple Silicon 전제다.
  • "앱 하나만 가볍게" 돌리는 용도라면 풀 init 부팅과 메모리 절반 기본값이 오히려 부담이다 — 이 경우는 기존 컨테이너 런타임이 낫다.

개인적으론 OrbStack 머신 기능을 잘 쓰던 사람이라면 컨셉 이해가 빠를 거고, "Apple 공식 + 오픈소스"라는 점에서 장기적으로 지켜볼 가치가 충분하다고 본다. 다만 지금 시점에선 메인 개발 환경을 통째로 갈아엎기보다, 별도 머신 하나 만들어서 멀티 배포판 테스트나 systemd 서비스 검증 용도로 먼저 굴려보길 권한다.

참고 자료

728x90

Cloudflare로 공인 IP 없이 사내 서버를 외부에 노출하기: Private Origins 라우팅 뜯어보기

오늘 올라온 목록을 쭉 보면 AI 얘기가 절반이지만, 인프라 굴리는 입장에서 진짜 손에 잡히는 건 Cloudflare의 Application Services for Private Origins 발표다. "공인 IP 없이 사내 사설망 애플리케이션을 외부 트래픽으로 연결한다"는 한 줄이 꽤 묵직해서, 이걸 실무 맥락에서 풀어보려 한다. (참고: 현재 closed beta 단계라 GA 이후 동작이 달라질 수 있으니 도입 전 공식 문서 재확인 필요.)

왜 지금 이게 화제인가

사내에서 돌리는 내부 API, 어드민 페이지, 레거시 백오피스를 외부(혹은 다른 리전, 파트너사)에 열어줘야 하는 상황은 생각보다 자주 온다. 그동안 우리가 쓰던 방법은 대략 이랬다.

  • 퍼블릭 IP 붙이고 NLB/ALB 앞에 두고 WAF 거는 방식 — 노출면이 늘고 방화벽 관리가 빡세진다.
  • VPN으로 통째로 묶는 방식 — 클라이언트 배포·인증서 갱신·MTU 이슈가 끝없이 따라온다.
  • 커넥터 데몬(예: cloudflared) 깔아서 아웃바운드 터널로 빼는 방식 — 서버마다 에이전트 깔아야 하고 버전 관리가 일이다.

이번 발표의 핵심은 "이미 깔려 있는 IPsec / GRE / CNI / Cloudflare Mesh 경로를 그대로 재활용해서, 공인 hostname을 사설 IP origin으로 라우팅한다"는 점이다. 즉 별도 커넥터 소프트웨어를 origin마다 새로 깔 필요가 없다는 게 차별점이다. 이미 Cloudflare로 사내망을 끌어다 쓰고 있는(Magic WAN 등) 조직이라면 추가 설치 없이 hostname만 매핑하면 된다는 그림이다.

동작 원리: 비유로 풀면

기존 Cloudflare Tunnel을 "건물마다 직원이 밖에서 안으로 케이블을 끌어다 꽂는 방식"이라고 하면, 이번 건 "이미 회사와 통신사 사이에 깔려 있는 전용 회선(IPsec/GRE)에 주소록만 한 줄 추가하는 방식"에 가깝다. 새 케이블(커넥터)을 까는 게 아니라 라우팅 테이블에 매핑을 얹는 개념이다.

흐름을 단순화하면 이렇다.

외부 사용자
   │  HTTPS (app.example.com)
   ▼
Cloudflare Edge  ── WAF / Access(인증) ──┐
   │                                     │
   │  기존 IPsec/GRE/CNI 터널 재사용      │
   ▼                                     │
사설 origin (10.0.x.x:8080)  ◄───────────┘

여기서 중요한 건 origin이 사설 IP인 채로 끝난다는 거다. 인터넷에서 직접 도달 가능한 경로가 없고, 오직 Cloudflare 엣지를 통한 트래픽만 터널을 타고 들어온다. 그래서 origin 쪽에 인바운드 포트를 열 필요가 없고, 노출면이 엣지로 통일된다.

개념상 매핑은 "이 hostname → 이 사설 IP:port, 이 경로(터널)로" 형태가 될 것으로 보인다. 정확한 설정 스키마(대시보드/API/Terraform)는 beta 문서 기준으로 봐야 한다. Terraform 프로바이더로 관리한다면 대략 이런 그림을 예상할 수 있는데, 실제 리소스 이름은 GA 시점에 확인이 필요하다.

# 예시 — 실제 리소스/속성명은 공식 문서 확인 필요
# 의사 구성: hostname을 사설 origin에 매핑
resource "cloudflare_xxx_route" "internal_admin" {
  zone_id  = var.zone_id
  hostname = "admin.internal.example.com"
  origin   = "10.20.0.15:8080"
  path     = "ipsec-tunnel-seoul"   # 기존 터널 경로 재사용
}

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

좋아 보이지만 공짜 점심은 아니다. 실제로 굴린다고 생각하면 걸리는 지점이 몇 개 있다.

1. 락인(lock-in)을 각오해야 한다

이건 Cloudflare 생태계에 깊게 들어가는 결정이다. 이미 Magic WAN, Access, WAF를 쓰고 있다면 자연스러운 확장이지만, 단순히 "사내 서버 하나 열고 싶다" 수준이라면 오버킬이다. 그 경우는 그냥 Cloudflare Tunnel(cloudflared) 한 개 띄우는 게 훨씬 가볍고 빠르다.

2. 트래픽이 전부 엣지를 경유한다

모든 요청이 Cloudflare 엣지를 한 번 찍고 사설 origin으로 들어온다. 한국 리전 사용자가 한국 내부 서버에 붙는데 엣지를 경유하면 RTT가 늘 수 있다. 지연에 민감한 내부 API라면 실측이 필수다. "사내망인데 왜 느려졌지?" 하는 흔한 함정이 여기서 나온다.

3. 인증 레이어를 빼먹지 마라

hostname을 외부에 여는 순간, 라우팅만 했다고 끝이 아니다. 반드시 Cloudflare Access(또는 동등한 인증)를 앞단에 걸어야 한다. "사설 IP라 안전하다"는 착각이 가장 위험하다. 라우팅 매핑을 만든 그 순간부터 그 hostname은 공개 표면이 된다. 인증 미들웨어 없이 어드민 페이지를 노출했다가 사고 나는 패턴, 실무에서 정말 자주 본다.

4. 경로 중복과 IP 충돌

여러 터널(IPsec/GRE)을 동시에 운영하면서 사설 대역이 겹치면 라우팅이 꼬인다. 특히 인수합병이나 멀티 VPC 환경에서 10.0.0.0/8을 양쪽에서 막 쓰던 곳이라면 origin 매핑 시 어느 경로로 나갈지 명확히 잡아줘야 한다. 이건 Cloudflare 문제가 아니라 우리 네트워크 설계 숙제다.

대안 정리

  • 서버 1~2개, 빠르게: Cloudflare Tunnel(cloudflared) 또는 Tailscale Funnel.
  • 이미 Magic WAN/IPsec 운영 중, 커넥터 추가 설치가 싫다: 이번 Private Origins 라우팅이 맞다.
  • 벤더 종속이 싫다: 자체 리버스 프록시(Nginx/Envoy) + WireGuard 조합으로 비슷하게 만들 수 있지만 운영 부담은 우리가 다 진다.

정리

한 줄 요약: 이미 Cloudflare로 사내망을 끌어다 쓰는 조직이, 공인 IP나 커넥터 추가 설치 없이 hostname만 매핑해서 사설 origin을 외부에 안전하게 여는 기능이다.

누가 언제 써야 하나 — Magic WAN/IPsec/GRE를 이미 운영 중이고, origin마다 cloudflared 깔고 버전 관리하는 게 지긋지긋한 팀. 반대로 서버 한두 대 가볍게 열 거라면 굳이 이쪽으로 갈 필요 없다. 그리고 무엇보다, closed beta라는 점과 엣지 경유로 인한 지연·인증 레이어 분리는 꼭 실측·검증하고 들어가자.

참고 자료

===HTML===

사진: Microsoft Copilot / Unsplash

728x90

어려운 방식으로 메일 자체 호스팅하기: /24 IPv4 블록부터 SMTP 평판까지

오늘 올라온 글 중에 1997년부터 운영된 Recoil 메일 인프라를 전용 /24 IPv4 블록 위에서 Postfix, Dovecot, rspamd, Roundcube로 다시 짜는 이야기가 있었다. 솔직히 "메일 서버 직접 운영"은 인프라 엔지니어라면 한 번쯤 호기심에 손댔다가 데인 영역이다. 나도 그랬다. 이 글은 그 데임의 구조를 실무 관점에서 풀어본다.

왜 지금, 무슨 문제를 푸는가

요즘은 메일 보내고 싶으면 그냥 SES, SendGrid, Resend 쓰면 끝이다. 그런데도 굳이 자체 호스팅을 하는 이유는 두 가지다. 하나는 데이터 주권 — 내 메일 전문이 남의 서버를 거치지 않는다. 다른 하나는 학습 가치다. SMTP, IMAP, 스팸 필터링, 그리고 무엇보다 IP 평판(reputation)이 어떻게 돌아가는지 몸으로 알게 된다.

핵심은 원문 제목의 "어려운 방식(the hard way)"이라는 표현이다. 메일 수신/발신 자체는 데몬 몇 개 띄우면 된다. 진짜 어려운 건 다른 메일 서버들이 내 메일을 스팸 처리하지 않게 만드는 것이고, 이게 9할이다. 자체 라우팅 가능한 /24 블록에서 시작한다는 건, 임대 VPS의 오염된 IP 평판을 피하고 IP 통제권을 직접 쥐겠다는 의미로 보인다.

구조와 동작 원리

전형적인 자체 호스팅 스택은 역할이 깔끔하게 나뉜다.

  • Postfix: SMTP. 메일을 받고(MX) 보내는(relay) 본체.
  • Dovecot: IMAP/POP3. 사용자가 메일함을 읽는 부분. SASL 인증도 여기서 위임받는다.
  • rspamd: 스팸 필터링 + DKIM 서명. 들어오는 메일 점수 매기고 나가는 메일에 서명한다.
  • Roundcube: 웹메일 UI.

메일 한 통이 들어올 때 흐름을 비유하자면, Postfix는 건물 1층 로비(외부 연결을 받는 곳), rspamd는 보안 검색대(스팸 점수 채점), Dovecot은 각 호실 우편함(최종 저장과 조회)이다.

발신 쪽이 더 까다롭다. 받는 서버는 내 메일이 진짜인지 세 가지로 검증한다.

# SPF: "이 IP가 우리 도메인 메일을 보내도 되는가"
example.com.  IN TXT "v=spf1 ip4:203.0.113.10 -all"

# DKIM: rspamd가 본문에 서명한 공개키
default._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0..."

# DMARC: SPF/DKIM 실패 시 정책
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc@example.com"

여기에 더해 PTR(역방향 DNS)이 발신 IP에서 도메인으로 정확히 풀려야 한다. 이게 안 맞으면 Gmail 등이 바로 거부하거나 스팸함으로 보낸다. /24 블록을 직접 갖는 이유 중 하나가 이 PTR을 내 마음대로 설정할 수 있다는 점이다 — 일반 VPS는 PTR 설정을 못 하거나 제한적인 경우가 많다.

실무 관점: 트레이드오프와 함정

몇 년간 이쪽을 들여다본 입장에서 현실적인 얘기를 하면.

1. 발신은 평판 게임이다

가장 흔한 실수가 "설정 다 맞췄는데 왜 Gmail이 스팸 처리하지?"다. SPF/DKIM/DMARC 전부 통과해도 IP에 발신 이력이 없으면 신뢰가 0이다. 새 IP는 워밍업(소량부터 점진적 증량)이 필요하고, 25번 포트가 막힌 IP 대역이면 시작조차 못 한다. AWS, 많은 클라우드가 25번 아웃바운드를 기본 차단한다. 자체 /24 블록 + 라우팅 통제는 이 문제를 정면 돌파하려는 선택으로 보인다.

2. 블랙리스트는 한순간

Spamhaus 같은 RBL에 한 번 올라가면 회복이 느리고 피곤하다. 사용자 계정 하나가 털려서 스팸 송신지로 쓰이면 IP 전체 평판이 날아간다. 그래서 발신 레이트 리밋, 인증 강제, fail2ban 연동이 사실상 필수다.

3. 운영 부담이 진짜 부담이다

메일은 24/7 살아있어야 하고, 다운되면 송신측이 재시도하다 결국 바운스시킨다. TLS 인증서 갱신, rspamd 룰 업데이트, 디스크 풀로 인한 메일 유실 등 신경 쓸 게 많다. 이건 "주말 프로젝트"의 탈을 쓴 장기 운영 책임이다.

대안 정리

방식적합한 경우
SES / SendGrid 등 발신 전용앱 알림/트랜잭션 메일만 필요. 평판 관리 위임.
Mailcow / Mail-in-a-Box (도커 올인원)자체 호스팅 원하지만 직접 설정은 부담스러울 때.
이번 글 같은 풀 수동 스택학습 목적이거나 IP·라우팅까지 완전 통제가 필요할 때.

현실적으로 회사 업무 메일은 그냥 Google Workspace나 M365 쓰는 게 맞다. 자체 호스팅은 명확한 이유(주권, 학습, 특수 요건)가 있을 때만 손대자.

정리

한 줄 요약: 메일 자체 호스팅에서 데몬 설정은 쉽고, 발신 IP 평판과 24/7 운영이 어렵다. SMTP/스팸/평판의 작동 원리를 몸으로 배우고 싶거나 IP·라우팅 통제가 꼭 필요한 사람에게는 훌륭한 프로젝트다. 반대로 그냥 메일이 잘 가기만 하면 되는 상황이라면 관리형 서비스가 시간을 아껴준다. SPF/DKIM/DMARC/PTR은 자체 호스팅이든 발신 전용이든 어차피 알아야 하니, 거기서부터 익히는 걸 추천한다.

참고 자료

사진: Microsoft Copilot / Unsplash

728x90

Linear는 왜 그렇게 빠른가 — 로컬 우선 동기화 엔진의 실체

왜 지금 이 주제인가

Linear 써본 사람은 다들 한 번쯤 "어, 이거 왜 이렇게 빨라?"라고 느낀다. 이슈를 만들고 상태를 바꾸고 필터를 거는데 로딩 스피너가 거의 안 보인다. 네트워크 탭을 열어보면 클릭할 때마다 API를 때리는 게 아니라, UI가 먼저 반응하고 서버 통신은 뒤에서 조용히 처리된다.

이게 단순한 "캐싱 잘했네" 수준이 아니다. Linear는 로컬 우선(local-first) 동기화 엔진이라는 아키텍처를 정면으로 채택했다. 클라이언트가 데이터의 1차 소유자처럼 동작하고, 서버는 동기화 허브 역할을 한다. 요즘 사내 툴이나 대시보드 만들 때 "왜 이렇게 굼뜨지"를 고민하는 사람이라면, Linear가 택한 트레이드오프를 이해해 두는 게 큰 도움이 된다.

핵심은 이거다. 대부분의 웹앱이 느린 이유는 네트워크가 사용자 클릭과 화면 갱신 사이에 끼어 있기 때문이다. Linear는 그 네트워크를 임계 경로(critical path)에서 빼버렸다.

핵심: 동작 원리

전통적인 SPA의 데이터 흐름은 대충 이렇다.

클릭 → API 요청 → DB 쓰기 → 응답 → 상태 갱신 → 리렌더
       └────────── 이 구간 동안 사용자는 기다림 ──────────┘

Linear 같은 로컬 우선 모델은 순서가 뒤집힌다.

클릭 → 로컬 스토어 즉시 갱신 → 리렌더 (여기서 사용자 체감 끝)
                              └→ 백그라운드로 서버에 변경분 전송
                                 └→ 서버가 다른 클라이언트에 브로드캐스트

1) 로컬에 전체 모델을 들고 있는다

Linear는 사용자가 접근 가능한 이슈/프로젝트/팀 데이터를 브라우저 로컬(IndexedDB로 보인다)에 적재해 둔다. 그래서 필터링, 정렬, 검색 같은 작업이 서버 왕복 없이 메모리/로컬에서 처리된다. 필터를 바꿀 때 즉각 반응하는 이유가 이거다. 서버에 "이 조건으로 다시 쿼리해줘"라고 묻지 않는다.

2) 옵티미스틱 업데이트 + 동기화 큐

상태를 바꾸면 로컬 모델을 먼저 고치고 화면을 갱신한다. 그 변경(mutation)은 동기화 큐에 쌓여 백그라운드로 전송된다. 네트워크가 끊겨도 큐에 남아 있다가 복구되면 흘려보낸다. 오프라인에서도 작업이 되는 이유다.

// 개념적 의사코드 (실제 구현 아님)
function updateIssueStatus(issueId, status) {
  localStore.apply({ issueId, status });   // 1. 즉시 반영
  rerender();                              // 2. UI 갱신
  syncQueue.push({ type: 'update', issueId, status }); // 3. 큐잉
}
// 동기화 워커가 큐를 비우며 서버로 전송하고,
// 서버 ACK / 충돌 응답을 받아 로컬을 보정

3) 델타 동기화와 실시간 전파

전체를 다시 받지 않고 마지막 동기화 시점 이후의 변경분(델타)만 주고받는다. 다른 사람이 이슈를 바꾸면 WebSocket으로 변경이 흘러와 내 로컬 모델에 머지된다. 그래서 협업 중에도 화면이 자연스럽게 갱신된다.

4) 충돌 처리

여러 클라이언트가 같은 필드를 동시에 고치면 충돌이 난다. Linear는 이 부분을 자체 동기화 엔진으로 다룬다고 알려져 있는데, 구체적으로 LWW(Last-Write-Wins)인지 CRDT 기반인지 필드 단위 머지인지는 공개 정보가 제한적이라 공식 자료 확인이 필요하다. 다만 "필드 단위로 잘게 쪼개 머지하면 충돌 범위가 줄어든다"는 일반 원리는 분명하다.

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

여기가 핵심이다. 로컬 우선이 멋있어 보인다고 아무 프로젝트에 끌어오면 망한다. 내가 사내 툴 몇 개 다뤄보며 느낀 트레이드오프를 정리한다.

이게 잘 맞는 경우

  • 데이터 총량이 클라이언트에 들어갈 수 있을 때. Linear는 "한 사람이 접근하는 이슈 수"가 수만~수십만 단위라 로컬에 올릴 수 있다. 수억 row를 다루는 분석 대시보드엔 이 모델이 안 맞는다.
  • 같은 데이터를 반복해서 읽고 쓰는 워크플로. 이슈 트래커, 노트앱, 칸반처럼 사용자가 한 데이터셋을 계속 만지는 도구.
  • 오프라인/저지연이 진짜 가치인 경우. 그냥 "빠르면 좋지" 정도면 React Query 캐싱으로 충분할 때가 많다.

흔한 함정

  • 권한/필터링을 클라이언트로 미루는 실수. 로컬에 데이터를 올린다고 권한 검사를 클라이언트에서만 하면 보안 사고다. 서버는 여전히 "이 유저가 받을 수 있는 데이터만" 내려줘야 한다. 로컬 우선은 UX 최적화지, 보안 모델이 아니다.
  • 초기 동기화 비용. 첫 로그인 때 데이터를 다 끌어오는 부트스트랩 구간이 무겁다. 이걸 백그라운드 점진 로딩으로 어떻게 자르냐가 체감 품질을 좌우한다.
  • 스키마 마이그레이션 지옥. 서버 DB만 바꾸면 끝이 아니다. 사용자 브라우저마다 옛 버전의 로컬 스키마가 살아 있다. 클라이언트 마이그레이션 전략(버전 태깅, 강제 리싱크)을 처음부터 설계 안 하면 나중에 크게 운다.
  • 충돌 처리를 직접 짜는 비용. 옵티미스틱 업데이트 롤백, 서버 거부 시 UI 복구, 머지 로직… 이거 제대로 만들려면 사람과 시간이 든다. "빨라 보이려고" 시작했다가 동기화 엔진 자체가 제품이 되어버린다.

대안 — 풀 셀프빌드 말고도 길은 있다

  • 가벼운 옵티미스틱 캐싱: TanStack Query(React Query)의 optimistic update + 무효화. 대부분의 CRUD 앱은 이 정도로 충분히 빠르다. 동기화 엔진 직접 안 짜도 된다.
  • 로컬 우선 프레임워크: ElectricSQL, Replicache, Zero, PowerSync, RxDB 같은 솔루션이 동기화/충돌 처리를 대신 해준다. Linear 흉내 내고 싶으면 바닥부터 짜지 말고 이쪽을 먼저 검토하는 게 현실적이다.
  • CRDT 기반: 협업 편집(텍스트, 동시 수정)이 핵심이면 Yjs/Automerge. 단, 이슈 트래커 같은 구조화 데이터엔 과할 수 있다.

한 줄 조언: "Linear처럼 만들자"가 아니라 "우리 데이터 모델이 로컬에 들어가나? 충돌 빈도가 높나?"부터 답하라. 그 답이 애매하면 동기화 엔진은 오버엔지니어링이다.

정리

Linear가 빠른 이유는 마법이 아니라 네트워크를 사용자 클릭의 임계 경로에서 제거했기 때문이다. 로컬에 데이터를 들고, 옵티미스틱하게 먼저 그리고, 동기화는 백그라운드로 밀어버리는 구조다.

  • 써야 할 사람: 사용자가 한정된 데이터셋을 반복적으로 만지는 협업 툴(이슈 트래커, 노트, 칸반)을 만드는 팀.
  • 피해야 할 경우: 데이터가 로컬에 안 들어갈 만큼 크거나, 단순 CRUD라 React Query 캐싱으로 충분한 경우.
  • 현실적 시작: 바닥부터 짜지 말고 Replicache·ElectricSQL·Zero 같은 기성 동기화 엔진부터 검토. 충돌 처리와 스키마 마이그레이션 비용을 처음부터 견적에 넣어라.

참고 자료

주: Linear 내부 구현의 충돌 처리·저장소 세부는 공개 정보가 제한적이므로, 위 공식 블로그와 발표 자료로 교차 확인하길 권한다.

사진: Microsoft Copilot / Unsplash

728x90

SQLite에서 UUID를 기본 키로 쓰면 안 되는 이유: 클러스터드 인덱스와 B-tree 재균형 비용

왜 지금 이 얘기를 하나

마이크로서비스 한다고 PK를 죄다 UUID로 깔아두는 게 요즘 기본값처럼 굳어졌다. ID 충돌 안 나고, 클라이언트에서 미리 생성해도 되고, 분산 환경에서 시퀀스 조율 안 해도 되니까. 그래서 별 생각 없이 SQLite 테이블에도 id TEXT PRIMARY KEY로 UUID4를 박아 넣는다.

그런데 이게 SQLite, 그리고 정도 차이는 있지만 MySIQL/InnoDB 계열에서 의외로 비싼 선택이다. 작은 토이 프로젝트일 땐 안 보이다가, 데이터가 수십만~수백만 행 쌓이고 나서 "삽입이 왜 이렇게 느려졌지?" 하고 뒤늦게 깨닫는다. 오늘 hada.io에 올라온 "SQLite에서 UUID 기본 키의 위험성" 글이 딱 이 지점을 찌른다. 실무에서 자주 밟는 지뢰라 정리해둔다.

핵심: 클러스터드 인덱스에 랜덤 키를 박으면 생기는 일

먼저 SQLite 저장 구조부터. SQLite의 일반 테이블은 내부적으로 rowid라는 64비트 정수를 클러스터드 인덱스 키로 쓴다. 즉 데이터가 rowid 순서대로 B-tree에 물리적으로 정렬되어 저장된다는 뜻이다. INTEGER PRIMARY KEY로 선언하면 그 컬럼이 곧 rowid의 별칭이 된다.

여기서 핵심은 "물리적으로 정렬"이다. 정수 rowid는 보통 autoincrement처럼 단조 증가하니까, 새 행은 항상 B-tree의 맨 오른쪽 끝(가장 큰 키)에 붙는다. 페이지가 가득 차면 새 페이지 하나 만들어 끝에 이어 붙이면 끝이다. 디스크 입장에서도 순차 쓰기에 가깝다.

그런데 UUID4를 PK로 쓰면 어떻게 될까? UUID4는 이름 그대로 랜덤이다. 새로 생성한 값이 기존 키들 사이 어디에 끼어들지 예측이 안 된다. 그래서 삽입할 때마다:

  • B-tree에서 이 키가 들어갈 위치를 찾아 내려가고(랜덤 탐색)
  • 그 중간 페이지에 끼워 넣어야 하니, 페이지가 꽉 찼으면 페이지 분할(split)이 발생하고
  • 트리 균형을 맞추느라 재균형(rebalancing) 비용이 든다

비유하자면 정수 PK는 "맨 뒤에 책 한 권 꽂기"고, 랜덤 UUID는 "책장 중간 아무 데나 책 끼워 넣기"다. 후자는 옆 책들을 계속 밀어내야 한다. 게다가 매번 다른 페이지를 건드리니 페이지 캐시 적중률도 떨어진다.

원문에서 언급하는 수치(정수 rowid 기준 100만 행 삽입 시 대략 초당 100만 건 수준, UUID4는 그보다 현저히 느려짐)는 환경에 따라 달라지므로 그대로 믿기보다 본인 워크로드에서 직접 벤치 떠보는 걸 권한다. 다만 "랜덤 키가 순차 키보다 삽입이 느리다"는 방향성 자체는 B-tree 특성상 분명하다.

흔히 나오는 대안이 WITHOUT ROWID 테이블인데, 이건 오히려 함정이 될 수 있다:

-- rowid 안 쓰고 UUID 자체를 클러스터드 키로
CREATE TABLE events (
    id TEXT PRIMARY KEY,
    payload TEXT
) WITHOUT ROWID;

이렇게 하면 정수 rowid 오버헤드는 없앨 수 있지만, UUID(텍스트)가 그대로 클러스터드 인덱스 키가 되어 데이터 전체가 UUID 순으로 정렬 저장된다. 랜덤 UUID라면 삽입 시 재균형 문제가 더 직접적으로 터진다. 게다가 텍스트 UUID는 36바이트라 키 자체가 뚱뚱해서 페이지당 들어가는 행 수가 줄고 트리가 깊어진다.

실무 관점: 그럼 어떻게 해야 하나

정답은 워크로드에 따라 갈린다. 내가 실무에서 판단하는 기준은 이렇다.

1) 가장 무난한 선택: 정수 PK + UUID는 별도 유니크 컬럼

외부로 노출할 ID는 UUID로 두되, 물리 저장과 조인은 정수 rowid로 처리한다.

CREATE TABLE users (
    id        INTEGER PRIMARY KEY,   -- rowid, 내부 조인/저장용
    public_id TEXT NOT NULL UNIQUE,  -- 외부 노출용 UUID
    name      TEXT
);

삽입 성능은 순차 정수의 이점을 그대로 누리고, UUID는 보조 유니크 인덱스에만 영향을 준다. 다만 이 경우에도 public_id 유니크 인덱스는 랜덤이라 인덱스 갱신 비용은 든다. 그래도 데이터 본체가 흔들리는 것보단 훨씬 싸다.

2) 굳이 UUID를 PK로 써야겠다면: 시간 정렬형 UUID

UUID4 대신 UUIDv7처럼 앞부분에 타임스탬프가 들어간 정렬 가능한 ID를 쓰면 랜덤성이 크게 줄어든다. 시간순으로 거의 단조 증가하니 삽입이 다시 "맨 뒤에 붙기"에 가까워진다. UUID의 분산 친화성은 유지하면서 B-tree 재균형 문제를 완화하는 절충안이다. UUIDv7은 표준(RFC 9562)으로 확정됐고 라이브러리 지원도 늘었으니 신규 설계라면 충분히 검토할 만하다.

3) UUID를 굳이 TEXT로 저장하지 마라

이건 의외로 많이 놓친다. UUID를 TEXT(36자 문자열)로 저장하면 BLOB 16바이트 대비 두 배 이상 공간을 먹고, 비교 연산도 문자열 비교라 느리다. 정 UUID를 키로 쓸 거면 16바이트 BLOB으로 저장하는 걸 고려하라. 가독성은 떨어지지만 인덱스/저장 효율은 확실히 낫다. 디버깅 편의 때문에 TEXT를 고집하다 용량 폭증하는 경우를 봤다.

흔한 실수 정리

  • "UUID는 충돌 안 나니까 무조건 좋다"고 PK 박기 → 삽입 성능과 저장 효율을 같이 봐야 한다
  • 벤치를 작은 데이터로만 돌려보고 OK 판단 → 문제는 데이터가 수십만 행 넘어 페이지 분할이 빈번해질 때 드러난다
  • WITHOUT ROWID가 만능인 줄 알고 적용 → 랜덤 키라면 오히려 독이다. 작고 짧은 정렬형 키일 때 이득
  • UUID를 TEXT로 저장하고 인덱스까지 걸어두고 용량/속도 둘 다 손해

트레이드오프

UUID PK의 진짜 가치는 "분산 환경에서 중앙 조율 없이 ID 생성"이다. 이게 정말 필요한 설계가 아니면(예: 모놀리식 SQLite, 단일 라이터 임베디드 DB) 정수 PK가 거의 항상 유리하다. SQLite는 애초에 임베디드/단일 파일 DB라 분산 ID의 필요성이 약한 경우가 많다는 점도 같이 고려하자.

정리

한 줄 요약: SQLite처럼 클러스터드 인덱스 기반 DB에 랜덤 UUID4를 PK로 박으면 삽입마다 B-tree 재균형이 일어나 느려지고 뚱뚱해진다. 정수 PK를 쓰거나, 꼭 UUID여야 한다면 정렬형 UUIDv7 + BLOB 저장을 검토하라.

  • 정수 PK + UUID 유니크 컬럼: 대부분의 임베디드/단일 라이터 상황의 무난한 정답
  • UUIDv7 PK: 분산 ID가 꼭 필요하면서 삽입 성능도 챙기고 싶을 때
  • UUID4 PK: 정말 필요할 때만, 그리고 BLOB로 저장하고 벤치 떠보고

같은 원리가 InnoDB(MySQL)에도 적용된다. InnoDB도 PK가 클러스터드 인덱스라 랜덤 UUID PK는 똑같이 페이지 분할 지옥을 부른다. SQLite만의 얘기로 들리지만 사실 "클러스터드 인덱스 DB 공통의 함정"으로 이해하는 게 맞다.

참고 자료

사진: Microsoft Copilot / Unsplash

728x90

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

+ Recent posts