아제르바이잔에서 자동차 중개업을 운영하며 미국에서 사고 차량(salvage vehicles)을 수입할 때, 여러분이 겪는 소프트웨어 문제는 실리콘밸리 스타트업의 문제와는 다릅니다. 수백만 명의 동시 접속자를 위해 최적화하는 것이 아닙니다. 명확성, 가동 시간(uptime), 그리고 12시간 시차가 나는 경매장과 조율하며 자정에 스스로 문제를 해결할 수 있는 능력을 위해 최적화하는 것입니다. 그것이 바로 제가 AutoMakler를 만들었을 때 처했던 상황입니다. 이 플랫폼은 실시간 경매 스크래핑과 Carfax 조회부터 배송 예상 시간 및 결제 처리까지 모든 것을 처리합니다. 이는 실제 고객에게 서비스를 제공하는 실제 프로덕션 시스템이며, 대부분의 개발자가 '공격적으로 지루한 스택'이라고 부를 만한 기술로 운영됩니다.

아무도 소개하고 싶어 하지 않는 스택

React도 없습니다. Vue도 없습니다. Redis, Celery, WebSocket 서버도 없습니다. 백엔드는 순수 Python을 사용하는 FastAPI입니다. 데이터베이스는 PostgreSQL입니다. 프론트엔드는 Jinja2 템플릿, Bootstrap, 그리고 약간의 vanilla JavaScript를 사용한 서버 렌더링 HTML입니다. 스크래핑에는 Playwright를 사용합니다. 모든 것은 HTML을 직접 제공하는 단일 Python 프로세스로 실행됩니다.

빌드 단계도 없습니다. 감사할 node_modules 폴더도, 설정할 트랜스파일러도, 따라잡아야 할 프론트엔드 프레임워크의 변화도 없습니다. 배포할 때 저는 번들러 파이프라인을 조율하는 것이 아니라, Python 파일과 템플릿을 옮길 뿐입니다. 그 단순함은 타협이 아닙니다. 그것이 바로 핵심입니다.

메시지 브로커 없이 작업 큐를 구성하는 방법

실시간 자동차 경매 스크래핑은 동기적으로 처리될 수 없습니다. Playwright가 페이지를 로드하고, JavaScript를 실행하고, 데이터를 추출하는 동안 단 한 번의 스크래핑에도 몇 초가 소요될 수 있습니다. 이 작업이 진행되는 동안 사용자를 차단하는 것은 선택지에 없습니다. 정석적인 방법은 Redis를 설치하고, Celery를 설정하고, 워커 풀(worker pool)을 가동하는 것입니다. 저는 이 모든 과정을 건너뛰었습니다.

대신, AutoMakler는 Postgres를 자체 작업 큐로 사용합니다. 사용자가 스크래핑을 트리거하면, 애플리케이션은 tasks 테이블에 상태가 pending인 새로운 행을 작성합니다. asyncio 백그라운드 태스크가 해당 행을 가져와 브라우저 스크래핑을 시작합니다. 그동안 브라우저는 상태를 확인하기 위해 3초마다 가벼운 엔드포인트를 폴링(polling)합니다. 행의 상태가 completed로 업데이트되면 페이지가 새로고침되어 결과를 표시합니다.

이 패턴이 작동하는 이유는 폴링 간격이 응답성을 느끼기에 충분히 짧으면서도, 서버에 무리를 주지 않을 만큼 충분히 길기 때문입니다. 3초는 컴퓨터에게는 영겁의 시간과 같지만, 외부 경매 사이트를 기다리는 사람에게는 거의 느껴지지 않는 시간입니다. 데이터베이스는 네이티브하게 동시성을 처리하며, 작업이 단순히 Postgres의 행일 뿐이므로 Celery 로그나 Redis 키를 뒤지는 대신 간단한 SQL 쿼리로 큐를 조사할 수 있습니다.

워커 풀 없이 서버를 안정적으로 유지하는 방법

브라우저 자동화는 메모리를 많이 사용합니다. Playwright 인스턴스를 한꺼번에 너무 많이 실행하면 서버가 무너질 것입니다. 관습적인 해결책은 동시성 제한이 있는 관리형 워커 풀을 사용하는 것이며, 대개 앞서 언급한 Redis와 Celery 조합을 사용합니다. 저는 Python 코드 한 줄을 사용합니다: asyncio.Semaphore입니다.

세마포어는 동시에 실행될 수 있는 브라우저 인스턴스의 수를 제한합니다. 새로운 스크래핑 요청이 들어오면, 즉시 슬롯을 확보하거나 슬롯이 비워질 때까지 기다립니다. 이 모든 것은 동일한 프로세스 내부에서 일어납니다. 실패할 외부 오케스트레이터도, 조용히 죽어버릴 워커 프로세스도, 모니터링해야 할 추가 인프라도 없습니다. 메모리 사용량은 예측 가능한 상태를 유지하며, 서버를 보호하는 코드는 배포 매니페스트에 숨겨져 있는 것이 아니라 서버를 사용하는 코드 바로 옆에 위치합니다.

하나의 콜백 URL로 결제 경로 지정하기

결제 처리는 제가 바꿀 수 없는 제약 조건을 가져왔습니다. 제가 사용하는 결제 게이트웨이는 가맹점 계정당 정확히 하나의 콜백 URL만 허용하지만, 저는 하나의 계정을 통해 두 개의 별도 프로젝트에 대한 트랜잭션을 처리해야 했습니다. 두 번째 가맹점 프로필을 만드는 것은 작은 중개업체가 감당하기 어려운 추가 수수료, 추가 컴플라이언스, 그리고 추가 서류 작업을 의미했습니다.

해결책은 고객을 게이트웨이로 보내기 전에 주문 ID 문자열에 프로젝트 이름을 직접 인코딩하는 것이었습니다. 콜백이 서버에 도달하면, AutoMakler는 해당 ID를 디코딩하여 결제가 어느 프로젝트에 속하는지 식별하고, 알림을 올바른 내부 핸들러로 라우팅합니다. 기존 로직은 그대로 유지되었습니다. 이것은 애디티브 디자인(additive design)입니다. 결제 흐름을 다시 작성한 것이 아니라, 식별자가 조금 더 많은 컨텍스트를 담도록 만들었을 뿐입니다. 이는 지나고 나면 당연해 보이지만, 설계상의 복잡한 기교를 피하며 수 시간을 아껴주는 종류의 해킹입니다.

WebSocket 없이 작동하는 채팅

고객 지원 채팅은 보통 엔지니어들이 타협하여 WebSocket을 추가하게 되는 지점입니다. 저는 인앱 메시징 기능이 필요했지만, 인프라 규모를 아주 작게 유지해야 했습니다. 그래서 경매 스크래핑을 구동하는 것과 동일한 폴링(polling) 전략을 재사용했습니다.

메시지는 Postgres에 저장됩니다. 사용자가 메시지를 보내면 테이블에 기록됩니다. 클라이언트는 업데이트를 위해 폴링을 수행하며, UI는 새로운 메시지와 읽음 확인을 거의 실시간으로 반영합니다. 대화 테이블이 커지더라도 속도를 유지하기 위해, 활성 대화의 읽지 않은 메시지만 포함하는 Postgres 부분 인덱스(partial index)를 추가했습니다. 데이터베이스는 오래된 기록을 스캔하는 데 자원을 낭비하지 않으며, 쿼리 플래너는 좁은 인덱스 범위 스캔을 통해 대부분의 채팅 조회 요청을 처리할 수 있습니다.

몇 초 정도의 지연 시간이 허용되는 고객 지원 채팅의 경우, 이 방식은 매우 적절합니다. 사용자는 필요한 피드백을 받을 수 있고, 저는 끊긴 WebSocket 연결을 디버깅하거나 별도의 소켓 서버를 관리할 필요가 전혀 없었습니다.

솔직한 단점들

이 아키텍처에는 실제적인 트레이드오프가 존재하며, 그렇지 않은 척하는 것은 정직하지 못한 일일 것입니다. 폴링은 통신 빈도가 높습니다. 3초마다 모든 활성 클라이언트가 서버에 요청을 보냅니다. 대역폭과 쿼리 부하가 지속적인 소켓 연결이 요구하는 수준보다 높습니다. 만약 Python 프로세스가 재시작되면, 진행 중인 백그라운드 작업은 외부 워커가 이를 다시 이어받을 수 없기 때문에 즉시 중단됩니다. 하지만 작업 단위가 작고 재시도 비용이 낮기 때문에 저는 이를 수용합니다. 브라우저 스크래핑이 실패하면 사용자가 단순히 다시 실행하면 됩니다.

이 방식에는 한계도 있습니다. 만약 AutoMakler가 수천 개의 동시 스크래핑을 처리해야 하는 상황이 온다면, 폴링을 사용하는 단일 프로세스 모델은 무리가 올 것입니다. 하지만 제가 하는 사업은 그런 방향이 아닙니다. 저에게 필요한 것은 수천 명이 아닌 수십 명의 동시 접속 사용자를 위한 신뢰성이지,