온틀은 FastAPI(비동기 파이썬) 하나로 서버 렌더링과 JSON API를 함께 처리하고, MySQL에 데이터를 담되 문자셋을 utf8mb4로 못박고, Cloudflare 터널로 인바운드 포트 없이 외부에 나가며, 와일드카드 DNS로 서브도메인을 코드 배포 없이 열고, systemd가 프로세스를 계속 살려두고, 정적·미디어는 CDN이 나눠 처리하는 구조로 돌아간다. 이 여섯 가지 선택은 "1인 혹은 소규모 팀이 다수의 서브도메인 서비스를 감당할 수 있는가"라는 질문에 대한 각각의 답이다. 이 글은 왜 이 조합인지, 무엇을 얻고 무엇을 감수했는지를 순서대로 짚는다. 그리고 예전 원고에 있던 두 가지 부정확한 설명 — 터널의 보안 효과, MySQL 기본 문자셋 — 을 정확하게 다시 쓴다.
"AI PM이 서비스를 만들어준다"는 말을 들으면 다들 모델 이야기부터 궁금해한다. 하지만 서비스가 몇 달째 죽지 않고, 서브도메인이 수십 개로 늘어도 버티고, 새벽 트래픽이 튀어도 응답하는 이유는 모델이 아니라 그 아래 인프라다. 온틀은 이 인프라 위에서 AI PM이 요구사항을 받아 새 서브도메인을 발행하는 구조인데, 발행이 "즉시" 되는 이유도, 발행된 서비스가 격리되는 방식도 전부 이번 글의 여섯 계층에서 나온다.
비유를 하나 들자. 건물을 지을 때 층마다 문을 만들고 창마다 자물쇠를 채우는 대신, 정문 하나에 경비를 집중하고 나머지 출입구는 없애는 설계를 택할 수 있다. 온틀 인프라가 대체로 이 방식이다. 다만 뒤에서 보겠지만, 정문을 한 곳으로 좁힌다고 해서 "안에서 벌어질 수 있는 일"까지 사라지지는 않는다. 하나씩 열어보자.
FastAPI는 ASGI(비동기 서버 게이트웨이 인터페이스) 위에서 동작하는 파이썬 웹 프레임워크다. 비동기라는 말은, 요청 하나가 데이터베이스나 외부 API 응답을 기다리는 동안 그 자리를 놀리지 않고 다른 요청을 처리한다는 뜻이다. 전통적 동기 구조는 동시 처리량을 늘리려면 스레드나 프로세스를 요청 수만큼 늘려야 하지만, 비동기 구조는 하나의 이벤트 루프가 대기 중인 다수 요청을 번갈아 처리한다.
온틀에서 이게 중요한 이유는, 서브도메인마다 애플리케이션 서버를 따로 띄우는 대신 하나의 FastAPI 프로세스(또는 소수의 워커)가 여러 서비스 요청을 함께 받아내야 하기 때문이다. AI PM이 서비스를 발행할 때마다 프로세스를 새로 띄운다면 서버 자원도 운영 부담도 서비스 개수에 비례해 늘어난다. 비동기 구조는 이 비례 관계를 완만하게 만든다.
FastAPI는 HTML을 서버에서 그려 내려주는 서버사이드 렌더링 라우트와, 클라이언트가 호출하는 JSON API 라우트를 같은 코드베이스, 같은 프로세스에서 다룬다. 렌더링 서버와 API 서버를 굳이 분리하지 않는 이유는 단순하다. 서브도메인마다 요구가 다른데, 서버를 쪼개두면 신규 서비스마다 두 서버 사이의 배포·인증·라우팅을 다시 맞춰야 한다.
약한 예
"서비스마다 별도 서버를 띄워야 안전하고 깔끔하다."
격리를 높여주는 말처럼 들리지만, 실제로는 서비스 하나 늘 때마다 배포 파이프라인·헬스체크·포트 할당·리버스 프록시 설정이 하나씩 늘어난다. 서너 개일 땐 버텨도 열 개, 스무 개가 되면 운영 부담이 서비스 수보다 빠르게 커진다.
강한 예
"하나의 FastAPI 애플리케이션에서 서브도메인별 라우터를 등록하고, 요청 헤더의 호스트값으로 어느 서비스인지 구분한다."
신규 서브도메인 추가가 "라우터 등록 + DB 레코드 추가" 수준으로 끝나고, 프로세스 관리 부담이 서비스 수와 함께 커지지 않는다.
MySQL은 관계형 데이터베이스로, 서비스·사용자·서브도메인·콘텐츠처럼 참조 관계가 뚜렷한 데이터에 적합하다. 온틀에서 "이 서브도메인은 누구 것이고, 어떤 템플릿을 쓰고, 언제 발행됐는지"는 전형적으로 테이블 간 외래키로 표현된다. 문서형 데이터베이스도 선택지지만, 다수 서브도메인이 공통 스키마(발행 상태·소유자·라우팅 경로)를 공유하면서 서비스별 세부만 다른 구조에서는 관계형 모델이 일관성을 지키기 쉽다.
먼저 자주 도는 잘못된 설명부터 정정한다. "MySQL의 기본 문자셋은 한 글자당 3바이트짜리 utf8"이라는 말은 지금은 맞지 않는다. MySQL 8.0부터 서버 기본 문자셋은 utf8mb4이고, 8.4도 동일하다. 과거에 utf8이라 불리던 것은 실은 utf8mb3의 별칭으로, 글자당 최대 3바이트만 지원하며 현재는 폐기 예정(deprecated) 상태다. 즉 4바이트 문자를 못 담는 쪽은 옛 utf8(=utf8mb3)이지, utf8mb4가 아니다.
그렇다면 8.0 이상에서는 아무것도 안 해도 될까. 그렇지 않다. 세 가지 이유로 온틀은 문자셋을 값으로 못박는다.
첫째, 마이그레이션과 구버전 문제다. 예전 서버에서 만들어졌거나 5.7 시절에 넘어온 스키마는 여전히 utf8mb3나 latin1일 수 있다. 서버 버전이 8.x라고 해서 기존 테이블이 자동으로 바뀌지는 않는다.
둘째, 서버 기본값과 커넥션·테이블 설정은 별개다. 서버가 utf8mb4라도 클라이언트 커넥션 문자셋이나 개별 테이블·컬럼이 다를 수 있다. MySQL은 문자셋이 서로 다르면 그 사이에서 변환을 수행하지만, 대상 문자셋이 표현하지 못하는 문자(예: utf8mb3 컬럼에 담으려는 4바이트 이모지)이거나 실제 바이트 인코딩이 선언된 문자셋과 어긋나 있으면 그 지점에서 손실·오류가 생긴다.
셋째, 명시가 안전하다. 버전·환경에 의존하지 않으려면 테이블·컬럼·커넥션·콜레이션을 모두 utf8mb4로 못박는 편이 낫다. 그래서 온틀은 신규 테이블을 이렇게 만든다.
-- 서버 기본값에 의존하지 말고 문자셋·콜레이션을 명시한다
CREATE TABLE ws_domains (
id BIGINT NOT NULL AUTO_INCREMENT,
subdomain VARCHAR(63) NOT NULL,
owner_id BIGINT NOT NULL,
route_path VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_subdomain (subdomain)
) ENGINE=InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_0900_ai_ci; -- 8.0 이상의 기본 콜레이션
커넥션에도 문자셋을 붙인다. 서버가 utf8mb4여도 커넥션 문자셋이 이를 표현하지 못하는 값이거나 실제 데이터의 인코딩과 어긋나 있으면, 왕복 변환 과정에서 손실·오류가 생길 수 있기 때문이다.
# SQLAlchemy(async) + aiomysql — 커넥션 문자셋도 utf8mb4로 명시
engine = create_async_engine(
"mysql+aiomysql://user:[email protected]/ontle_dev?charset=utf8mb4",
pool_pre_ping=True,
)
이러면 한국어는 물론이고 이모지, 확장 한자 같은 4바이트 문자가 서버·커넥션·테이블 어디에서도 안전하게 저장된다. 운영 중에 문자셋을 바꾸려면 전체 테이블 변환이 필요해 비용이 크니, 이 값은 설계 단계에서 정하는 게 맞다.
| 선택 | 얻는 것 | 대신 감수하는 것 |
|---|---|---|
| MySQL(관계형) | 관계 무결성, 익숙한 운영 생태계, 트랜잭션 | 스키마 변경 시 마이그레이션 필요, 수평 확장은 문서형보다 번거로움 |
| utf8mb4 명시 | 서버 버전·커넥션·마이그레이션 상태와 무관하게 4바이트 문자 보장 | utf8mb3 대비 저장 공간·인덱스 키 길이 계산에 소폭 영향 |
서버를 외부에 노출하려면 전통적으로 공유기·방화벽에서 포트를 열어 내부 서버로 연결을 넘기는 포트포워딩을 쓰거나, 격리된 네트워크 구간(DMZ)에 서버를 둔다. 두 방식의 공통점은 인터넷에서 오는 연결을 서버가 직접 받는다는 것이다. 그 포트는 스캔당하고, 취약하면 직접 공격당한다. 포트를 여는 순간 그것은 인터넷 전체를 향해 "여기 서버 있음"이라고 알리는 셈이다.
Cloudflare 터널은 반대로 동작한다. 내부 서버가 Cloudflare로 아웃바운드 연결을 먼저 만들어 유지한다. 외부 사용자가 도메인에 접속하면 요청은 Cloudflare 엣지에 먼저 닿고, Cloudflare가 이미 열려 있는 그 터널을 통해 내부 서버로 요청을 전달한다. 서버 입장에서는 인바운드로 열어둔 포트가 없고, 공유기의 포트포워딩 규칙도 DMZ 구성도 필요 없다.
[기존 방식: 포트포워딩]
인터넷 ---(공개된 인바운드 포트)---> 공유기 ---> 내부 서버
↑ 포트 스캔·직접 공격의 표적
[터널 방식]
내부 서버 ---(아웃바운드 연결, 서버가 먼저 건다)---> Cloudflare 엣지
인터넷 사용자 ---(도메인 접속)---> Cloudflare 엣지 ---(기존 터널)---> 내부 서버
↑ 열린 인바운드 포트는 없지만, 도메인으로 온 요청은 앱까지 도달한다
이 대목이 예전 원고가 틀렸던 부분이다. 예전 글은 "터널 구조에서는 공격자가 접속을 시도할 지점 자체가 없다"고 썼는데, 이는 사실이 아니다. 정확히 구분하자.
줄어드는 것은 네트워크 계층의 공격면이다. 열린 인바운드 포트가 없으니, 서버 IP를 겨냥한 포트 스캔이나 직접 타격, 실수로 노출된 관리 포트(SSH·DB) 탐색 같은 공격이 성립하기 어려워진다. 인터넷에서 서버로 "직접 꽂는" 경로가 사라지는 것이다.
그대로 남는 것은 애플리케이션 계층의 공격면이다. 공개 도메인 abc.ontle.kr로 온 정상 요청은 터널을 타고 애플리케이션까지 그대로 도달한다. 도메인이 곧 접속 지점이고, 그 요청은 앱에 닿는다. 따라서 SQL 인젝션, XSS, 인증 우회, 권한 상승, 비즈니스 로직 악용 같은 웹 취약점은 터널이 있든 없든 유효하다. 터널은 이런 요청을 막아주는 장치가 아니다.
약한 예
"터널을 쓰니 공격할 지점이 없다. 웹 보안은 신경 안 써도 된다."
강한 예
"터널은 네트워크 계층의 노출을 줄인다. 웹 취약점 방어(입력 검증·인증·권한 검사·WAF)는 애플리케이션에서 별도로 해야 한다."
Cloudflare 엣지가 TLS 종료, DDoS 완화, 그리고 설정에 따라 WAF 같은 방어를 대신 맡아 일부 계층을 보강해주는 것은 사실이고 실제 이득이다. 하지만 그건 애플리케이션 자체의 입력 검증·인증·권한 검사를 대체하지 않는다. 정문 비유로 돌아가면, 문을 하나로 좁히고 그 앞에 전문 경비를 세운 것에 가깝다. 벽에 뚫린 구멍은 줄었지만, 정문으로 들여보낸 손님이 안에서 무슨 짓을 하는지는 여전히 안에서 관리해야 한다.
공짜는 아니다. 모든 외부 트래픽이 Cloudflare 엣지를 거치므로, Cloudflare의 장애나 정책 변경에 가용성이 묶인다. 서버가 내부망에 있다는 전제이므로 그 네트워크의 전원·회선 안정성이 곧 서비스 가용성과 직결된다. 엣지를 한 번 더 경유하는 만큼 지연도 생긴다. 그래도 소규모 팀에게는 "포트를 열고 침입 탐지까지 직접 운영하는 부담"보다 "터널 하나에 의존하는 부담"이 대체로 가볍다.
와일드카드 DNS 레코드(*.ontle.kr)는 미리 정하지 않은 모든 서브도메인 요청을 한 곳으로 받는다는 뜻이다. abc.ontle.kr이든 xyz.ontle.kr이든 DNS 단계에서는 같은 곳으로 향한다. 핵심은 DNS가 미리 알 필요가 없다는 것이다. 새 서브도메인마다 레코드를 추가하는 대신, 와일드카드가 이미 모든 경우를 받고 있으니 "어느 서브도메인이 실제로 존재하는가"는 애플리케이션과 데이터베이스가 결정한다.
FastAPI 서버는 들어온 요청의 호스트 헤더를 보고 DB에서 조회해 라우팅을 판단한다. 신규 서비스를 여는 작업이 DNS 변경이나 별도 배포가 아니라 DB 레코드 하나를 추가하는 일로 끝난다.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.middleware("http")
async def route_by_host(request: Request, call_next):
host = request.headers.get("host", "").split(":")[0]
# 와일드카드로 다 들어오지만, 존재 여부는 여기서 DB가 결정한다
ws = await lookup_workspace(host) # 미등록이면 None
if ws is None:
return JSONResponse({"detail": "미개통 서브도메인"}, status_code=404)
request.state.workspace = ws
return await call_next(request)
이게 AI PM이 서브도메인을 발행할 때 별도 인프라 작업 없이 몇 초 안에 서비스가 열리는 근본 이유다.
트레이드오프도 있다. 와일드카드는 존재하지 않는 서브도메인 요청도 일단 서버까지 도달시킨다. 따라서 미등록 요청을 빠르고 정확히 걸러내는 판별 로직이 필요하다. 또 TLS는 와일드카드 인증서로 처리되는데, 인증서 범위를 넘어서는 깊이(예: a.b.ontle.kr)는 별도 고려가 필요하다.
파이썬 프로세스를 터미널에서 그냥 실행하면 세션이 끊기거나 서버가 재부팅될 때 함께 죽는다. systemd는 리눅스의 프로세스 관리 표준 도구이고, 유저 서비스로 등록하면 프로세스가 예기치 않게 죽었을 때 다시 띄우는 정책을 걸 수 있다. 재부팅 후 자동 기동도 설정된다.
전문 오케스트레이션 도구(컨테이너 클러스터 등)는 강력하지만 학습·운영 비용이 든다. 서비스 수가 수백 개, 트래픽이 초당 수천 건 규모가 아니라면, 배포판에 기본 내장된 systemd만으로도 "죽으면 다시 뜬다"는 핵심 요구를 충분히 채운다. 도구를 얹기보다 이미 있는 도구를 정확히 쓰는 편이 복잡도를 낮춘다.
다만 무한 재시작은 근본 원인(코드 버그, 자원 고갈)을 가릴 수 있다. Restart=always만 걸고 끝내면, 서비스가 반복적으로 죽었다 살아나는 상태를 눈치채지 못한 채 방치할 수 있다. 그래서 재시작 정책은 로그·알림과 함께 둔다.
이미지, 영상, CSS, 자바스크립트 같은 정적 파일은 요청마다 내용이 바뀌지 않는다. 이런 파일을 애플리케이션 서버가 매번 직접 읽어 응답하면, 정적 전송에 쓰이는 자원이 정작 동적 로직(DB 조회, 렌더링)에 써야 할 자원을 갉아먹는다. CDN은 정적 자원을 여러 위치에 캐시해두고 사용자와 가까운 곳에서 바로 내려준다. 애플리케이션 서버는 동적 처리에 집중하고, 대역폭이 큰 정적 트래픽은 CDN이 흡수한다. 소규모 팀일수록 이 분리가 서버 한 대의 여유를 크게 바꾼다.
여섯 계층을 관통하는 원리는 하나다. 팀 규모가 작을수록, 각 선택은 "직접 운영해야 할 것의 수"를 줄이는 방향이어야 한다. 다만 그 줄임이 어디까지 유효한지를 정확히 아는 것도 그만큼 중요하다. 터널이 네트워크 노출을 줄여도 웹 취약점 방어는 남고, 문자셋 기본값이 좋아졌어도 명시는 여전히 필요하다.
아닙니다. 터널은 서버의 열린 인바운드 포트를 없애 네트워크 계층의 노출을 줄여줄 뿐입니다. 공개 도메인으로 온 요청은 터널을 통해 애플리케이션에 그대로 도달하므로, SQL 인젝션·XSS·인증 우회 같은 웹 취약점은 여전히 유효합니다. 입력 검증·인증·권한 검사는 애플리케이션에서 반드시 별도로 해야 합니다.
권장합니다. 8.0·8.4의 서버 기본 문자셋은 이미 utf8mb4이지만, 마이그레이션되거나 구버전에서 넘어온 테이블은 utf8mb3(옛 utf8)나 latin1일 수 있습니다. MySQL은 문자셋이 서로 다르면 변환을 수행하지만, 대상 문자셋이 표현하지 못하는 문자이거나 실제 인코딩이 선언된 값과 어긋나면 그 지점에서 손실·오류가 생깁니다. 테이블·컬럼·커넥션·콜레이션을 모두 utf8mb4로 명시하면 버전·환경과 무관하게 4바이트 문자가 안전합니다.
온틀은 하나의 애플리케이션에서 호스트 헤더로 워크스페이스를 구분하고, 요청 컨텍스트에 워크스페이스를 실어 라우팅합니다. 데이터는 외부 연동 수집물·크리덴셜을 프로젝트 전용 DB로 사일로화해 분리합니다. 프로세스를 나누지 않고도 데이터·권한 경계를 애플리케이션 계층에서 세우는 방식입니다.
충분하지 않습니다. 재시작은 "죽으면 다시 뜬다"를 보장하지만, 반복 재시작을 유발하는 근본 원인(버그·자원 고갈)을 가릴 수 있습니다. 재시작 횟수와 실패 로그를 관찰하고 알림을 함께 두어야, 서비스가 조용히 죽었다 살아나는 상태를 방치하지 않습니다.