컨트롤 플레인과 데이터 플레인: 시스템을 두 층으로 나누는 이유

컨트롤 플레인과 데이터 플레인: 시스템을 두 층으로 나누는 이유

시스템 설계를 다루는 글을 읽다 보면 컨트롤 플레인(control plane)과 데이터 플레인(data plane)이라는 말이 늘 짝을 지어 나옵니다. 시스템을 둘로 나눈다는 이야기인 건 알겠는데, 정확히 무엇을 기준으로 나누는 건지 물으면 선뜻 답하기 어렵죠.

특히 웹 서비스를 만들며 시스템을 프론트엔드와 백엔드로만 나눠 보던 분이라면 더 낯설게 느껴질 겁니다. 그런데 이 구분은 그 익숙한 나누기와 전혀 다른 방향으로 시스템을 자르는 칼이에요. 한 번 익혀 두면 매일 만지는 백엔드 코드도 다르게 보이기 시작합니다.

이번 글에서는 컨트롤 플레인과 데이터 플레인이 무엇이고, 시스템을 왜 굳이 이렇게 두 층으로 나누는지 살펴보겠습니다. 익숙한 웹 서비스로 감을 잡은 다음 실제 인프라 도구에서 두 층을 찾아보고, 마지막에는 Bun으로 아주 작은 게이트웨이를 만들어 두 층이 어떻게 맞물리는지 직접 확인해 볼게요.

라우터에서 시작된 말

이 용어는 네트워크 장비에서 왔습니다. 플레인(plane)은 원래 평면이라는 뜻인데, 여기서는 한 시스템 안에 종류가 다른 일이 층처럼 나뉘어 있다는 의미로 쓰여요. 라우터 한 대가 하는 일을 쪼개 보면 성격이 전혀 다른 두 가지가 섞여 있어요.

하나는 들어온 패킷을 보고 어느 포트로 내보낼지 정해 실제로 내보내는 일입니다. 초당 수백만 개의 패킷을 처리해야 하니 아주 빨라야 하고, 그래서 고급 장비에서는 전용 칩(ASIC)이 이 일을 맡습니다. 패킷 하나를 처리할 때마다 하는 일은 포워딩 테이블(forwarding table)에서 목적지를 찾아보는 것뿐이에요.

다른 하나는 그 포워딩 테이블을 만드는 일입니다. 이웃 라우터와 OSPF나 BGP 같은 라우팅 프로토콜로 경로 정보를 주고받고, 어떤 경로가 가장 좋은지 계산해서 테이블을 갱신합니다. 이 일은 패킷마다 일어나지 않고 네트워크 상태가 바뀔 때만 일어나니, 범용 CPU에서 소프트웨어로 처리해도 충분합니다.

앞의 것이 데이터 플레인이고 뒤의 것이 컨트롤 플레인입니다. 여기에 SSH나 SNMP로 운영자가 장비 설정을 바꾸는 관리 플레인(management plane)을 더해 세 개로 나누기도 하는데, 요즘 소프트웨어 시스템에서는 관리 플레인을 컨트롤 플레인에 합쳐서 부르는 경우가 많습니다.

한 문장으로 구분하기

라우터 밖으로 나와 일반적인 시스템에 적용하면 이렇게 정리할 수 있습니다. 데이터 플레인은 요청 하나하나를 실제로 처리하는 경로이고, 컨트롤 플레인은 그 처리 방식을 결정해서 데이터 플레인에 내려보내는 쪽입니다.

비유를 들자면 고속도로와 교통 관제 센터 같은 관계예요. 차들은 고속도로 위를 달리지 관제 센터를 지나가지 않습니다. 관제 센터는 차선을 막거나 전광판 안내를 바꾸는 식으로 도로가 어떻게 쓰일지를 정할 뿐이죠. 관제 센터 전산이 잠깐 멈춰도 차들은 마지막으로 켜진 신호대로 계속 달립니다.

flowchart TB
    accTitle: 컨트롤 플레인과 데이터 플레인의 관계
    accDescr: 운영자가 컨트롤 플레인에 원하는 상태를 선언하면 컨트롤 플레인이 설정을 만들어 여러 데이터 플레인 인스턴스에 내려보낸다. 사용자 요청은 컨트롤 플레인을 거치지 않고 데이터 플레인을 지나 백엔드로 간다.

    operator["운영자"] --> control

    subgraph control["컨트롤 플레인"]
        direction LR
        api["설정 API"] --> store[("원하는 상태")]
    end

    control -. "설정 전달" .-> data

    user["사용자 요청"] --> data

    subgraph data["데이터 플레인"]
        direction LR
        p1["프록시 1"] ~~~ p2["프록시 2"] ~~~ p3["프록시 3"]
    end

    data --> backend["백엔드 서비스"]

그림에서 사용자 요청이 컨트롤 플레인을 한 번도 지나지 않는다는 점을 눈여겨보세요. 운영자는 위에서 설정을 내려보내고, 사용자 요청은 옆에서 들어와 데이터 플레인만 지나갑니다. 이 선이 지켜져야 뒤에서 살펴볼 장점들이 모두 성립합니다.

두 층의 성격을 나란히 놓고 보면 차이가 더 분명해집니다.

구분데이터 플레인컨트롤 플레인
처리 대상요청, 패킷, 메시지 하나하나설정, 정책, 상태 변경
호출 빈도초당 수천에서 수백만 번분이나 시간 단위
중요한 성질낮은 지연과 높은 처리량일관성과 정확성
확장 방식트래픽에 맞춰 인스턴스를 늘림보통 소수의 복제본으로 충분
멈췄을 때 영향사용자 요청이 바로 실패새 변경이 반영되지 않음

마지막 줄이 특히 중요합니다. 이 차이가 두 층을 나누는 가장 큰 이유이기 때문이에요.

이 구분이 가장 쓸모 있는 순간은 장애가 났을 때입니다. 서비스에 문제가 생기면 먼저 이렇게 물어보세요. “새로 만들거나 바꾸는 일이 안 되는가, 아니면 이미 돌고 있는 서비스가 느려지거나 실패하는가?” 배포 버튼이 먹히지 않고 설정 변경이 반영되지 않는다면 컨트롤 플레인 쪽 문제입니다. 사용자 요청의 오류율이나 응답 시간이 튄다면 데이터 플레인 쪽 문제고요.

앞의 경우는 운영자가 불편한 정도로 끝날 때가 많지만, 뒤의 경우는 사용자가 바로 체감합니다. 그래서 장애 공지를 쓸 때도 “새 리소스 생성이 지연되고 있습니다”와 “요청 처리에 오류가 발생하고 있습니다”를 나눠 쓰면 사용자가 자기에게 미치는 영향을 정확히 알 수 있어요.

왜 굳이 나눌까?

가장 큰 이유는 장애를 가두기 위해서입니다. 컨트롤 플레인은 변경이 잦고 로직이 복잡해서 버그가 숨어들기 쉬운 쪽인데요. 두 층이 깔끔하게 분리되어 있으면 컨트롤 플레인이 멈추더라도 데이터 플레인은 마지막으로 받은 설정을 들고 계속 일할 수 있습니다. AWS는 이 성질을 정적 안정성(static stability)이라고 부르며 서비스 설계의 원칙으로 삼고 있어요. 예를 들어 EC2에서 인스턴스를 새로 만드는 API가 컨트롤 플레인이고 이미 떠 있는 인스턴스가 데이터 플레인인데, API에 장애가 나도 돌고 있는 인스턴스는 영향을 받지 않도록 설계합니다.

또한 두 층은 확장하는 방식이 다릅니다. 트래픽이 열 배로 늘면 데이터 플레인은 그만큼 늘려야 하지만, 컨트롤 플레인은 설정을 내려보낼 대상이 조금 늘어날 뿐 거의 그대로 둬도 됩니다. 한 프로세스에 둘이 섞여 있으면 필요 없는 부분까지 같이 늘려야 하죠.

그리고 변경 주기와 보안 경계도 다릅니다. 데이터 플레인은 성능이 검증된 코드를 오래 안정적으로 돌리는 게 미덕이고, 컨트롤 플레인은 새 기능이나 정책을 자주 붙이는 곳입니다. 접근 권한도 다르게 줘야 해요. 데이터 플레인은 인터넷에 열려 있어도 되지만, 설정을 바꾸는 컨트롤 플레인은 운영자나 내부 시스템만 닿을 수 있어야 합니다.

프론트엔드와 백엔드로만 나눠 왔다면

우리가 가장 익숙한 나누기는 프론트엔드와 백엔드입니다. 이 기준이 묻는 질문은 “코드가 어디서 실행되는가?”예요. 브라우저에서 돌면 프론트엔드, 서버에서 돌면 백엔드죠. 반면 컨트롤 플레인과 데이터 플레인은 “이 코드는 결정하는가, 처리하는가?”를 묻습니다. 질문이 다르니 같은 시스템을 잘라도 선이 전혀 다른 곳에 그어져요.

익숙한 URL 단축 서비스로 확인해 볼까요? 사용자가 대시보드에서 긴 URL을 입력하면 짧은 링크가 만들어지고, 누군가 그 짧은 링크를 열면 원래 주소로 리디렉션되는 서비스입니다. 구성 요소를 두 기준으로 나란히 분류하면 이렇게 됩니다.

구성 요소실행 위치플레인
링크 관리 대시보드프론트엔드컨트롤 플레인
링크 생성, 수정 API백엔드컨트롤 플레인
짧은 링크 리디렉션 핸들러백엔드데이터 플레인

프론트엔드와 백엔드로만 보면 아래 두 줄은 같은 백엔드 서버 안의 API 엔드포인트일 뿐입니다. 하지만 플레인으로 보면 성격이 완전히 달라요. 링크 생성 API는 하루에 몇 번 불리고 조금 느려도 괜찮습니다. 리디렉션 핸들러는 링크가 퍼질 때마다 초당 수천 번 불리고, 느려지면 링크를 누른 모든 사람이 기다려야 하죠.

이 차이를 알아보면 설계 질문이 새로 생깁니다. 링크 생성 API가 쓰는 데이터베이스가 점검 중이라면 이미 퍼진 링크도 같이 죽어야 할까요? 그럴 이유가 없습니다. 리디렉션 핸들러가 단축 코드와 원래 주소의 매핑을 캐시나 읽기 전용 복제본에서 읽게 해 두면, 쓰기 쪽이 멈춰도 링크는 계속 열려요. 트래픽이 몰릴 때도 리디렉션 핸들러만 따로 늘리면 되고요. 같은 서버에 두더라도 코드와 의존성을 두 플레인으로 나눠 두는 습관만으로 장애 범위가 확 줄어듭니다.

거꾸로 컨트롤 플레인 안에도 프론트엔드와 백엔드가 모두 있다는 점을 눈여겨보세요. 대시보드 화면이 프론트엔드고 링크 생성 API가 백엔드입니다. 그러니 “백엔드니까 데이터 플레인”이라고 짐작하지 말고, 이 코드가 사용자 요청을 직접 처리하는 경로에 있는지부터 확인해야 합니다.

마지막으로 하나 더 구분해 둘 축이 있습니다. 멀티 테넌트(multi-tenant)와 단일 테넌트(single-tenant)는 “이 자원을 고객 몇 명이 함께 쓰는가?”를 묻는 기준인데, 플레인과는 독립적이라 층마다 다르게 고를 수 있어요. 관리형 게이트웨이 서비스라면 설정 API와 대시보드 같은 컨트롤 플레인은 모든 고객이 함께 쓰고, 트래픽을 처리하는 프록시는 고객마다 따로 띄우는 구성이 흔합니다. 컨트롤 플레인은 부하가 작아 함께 써도 괜찮지만, 데이터 플레인에서는 한 고객의 트래픽 폭증이 다른 고객에게 번지면 곤란하기 때문이에요.

사례 1: Tailscale

Tailscale은 이 구분을 가장 직관적으로 보여 주는 예입니다. 전통적인 VPN은 중앙 서버가 모든 트래픽을 중계하니 제어와 데이터가 한 곳에 몰려 있어요. Tailscale은 이 둘을 떼어 냈습니다.

코디네이션 서버(coordination server)가 기기들의 공개키를 나눠 주고 접근 정책을 관리하는 컨트롤 플레인 역할을 합니다. 실제 데이터는 기기와 기기가 WireGuard로 직접 주고받는 메시 네트워크, 즉 데이터 플레인으로 흐르고요. 컨트롤 플레인은 중앙에 하나 있지만 데이터 플레인은 기기 수만큼 흩어져 있는 구조입니다.

그래서 코디네이션 서버에 잠깐 문제가 생겨도 이미 연결된 기기끼리는 계속 통신할 수 있습니다. 대신 새 기기를 추가하거나 접근 정책을 바꾸는 일은 서버가 돌아올 때까지 반영되지 않죠. 앞의 표에서 본 “멈췄을 때 영향”이 그대로 드러나는 장면입니다.

사례 2: 쿠버네티스

쿠버네티스는 클러스터를 아예 두 종류의 노드로 나눕니다. 예전에는 마스터 노드라고 불렀던 쪽을 지금은 공식 문서에서 컨트롤 플레인이라고 부릅니다.

컨트롤 플레인에는 모든 요청을 받는 kube-apiserver, 클러스터 상태를 저장하는 etcd, 파드를 어느 노드에 둘지 정하는 kube-scheduler, 원하는 상태와 실제 상태를 맞추는 kube-controller-manager가 있습니다. 워커 노드 쪽에는 컨테이너를 실제로 띄우는 kubelet과 컨테이너 런타임, 서비스 트래픽을 파드로 보내는 kube-proxy가 있고요. 사용자의 HTTP 요청은 워커 노드의 파드가 처리하지 kube-apiserver를 지나가지 않습니다.

그래서 kube-apiserver가 죽어도 이미 떠 있는 파드는 요청을 계속 받습니다. 새 배포나 스케일링이 안 될 뿐이죠. AWS의 EKS 같은 관리형 쿠버네티스가 바로 이 경계를 따라 책임을 나눕니다. 운영하기 까다로운 컨트롤 플레인은 클라우드 업체가 맡고, 워커 노드는 사용자가 원하는 대로 구성하는 식이에요.

사례 3: Envoy와 AI 게이트웨이

리버스 프록시나 로드 밸런서는 원래 설정 파일 하나를 읽어 혼자 일하는 프로그램이었습니다. 그런데 서비스가 수백 개로 늘고 인스턴스가 수시로 생겼다 사라지면 설정 파일을 손으로 고쳐 재시작하는 방식이 버티지 못합니다.

Envoy는 이 문제를 xDS라는 API로 풀었습니다. Envoy 자신은 트래픽만 나르는 데이터 플레인이고, 리스너와 라우트, 클러스터 설정은 xDS를 구현한 별도의 컨트롤 플레인에게 받아 옵니다. 재시작 없이 설정을 바꿀 수 있게 된 거죠. 서비스 사이의 통신을 프록시로 감싸 관리하는 서비스 메시(service mesh) Istio가 대표적인 예예요. istiod가 컨트롤 플레인이 되어, 각 파드 옆에 하나씩 붙어 트래픽을 대신 받는 Envoy 사이드카(sidecar)에 설정을 내려보냅니다.

이 구분은 AI 에이전트 트래픽을 다루는 게이트웨이에서도 그대로 보입니다. Agent Router는 README 첫 줄부터 “Agent Router controls. Envoy carries.”라고 선언하는데요. 트래픽은 검증된 Envoy가 나르고, Agent Router는 그 위에서 LLM과 MCP 라우팅 규칙을 Envoy 설정으로 바꿔 주는 컨트롤 플레인 역할에 집중합니다. 반대로 agentgateway는 Rust로 데이터 플레인 자체를 새로 짠 프로젝트예요. 같은 문제를 풀면서도 어느 층에 힘을 줬는지가 정반대라, 두 글을 나란히 읽어 보면 이 개념이 훨씬 손에 잡힙니다.

사례 4: 느슨하게 쓰이는 경우

모든 문서가 이 용어를 엄밀하게 쓰지는 않습니다. OpenClaw는 핵심 구성 요소인 Gateway를 컨트롤 플레인이라고 소개하는데, 메신저에서 들어온 메시지도 이 Gateway를 거쳐 에이전트로 전달됩니다. 엄밀하게 따지면 데이터가 지나가는 경로이기도 한 셈이죠.

이럴 때의 컨트롤 플레인은 “전체를 조율하는 중심”이라는 넓은 의미에 가깝습니다. 세션을 관리하고 어떤 도구를 실행할지 정하는 역할을 강조한 표현이에요. 문서에서 이 단어를 만나면 “요청이 이 구성 요소를 지나가는가?”를 한 번 따져 보면 좋습니다. 지나가지 않는다면 좁은 의미의 컨트롤 플레인이고, 지나간다면 비유적인 의미로 쓴 경우입니다. 그리고 후자라면 그 구성 요소가 멈췄을 때 요청 처리도 같이 멈춘다고 봐야 합니다.

Bun으로 두 층 나눠 보기

개념을 코드로 옮겨 보겠습니다. 경로 접두어를 보고 요청을 백엔드로 넘기는 아주 작은 게이트웨이인데, 포트를 두 개 엽니다. 3000번은 모든 사용자 요청이 지나가는 데이터 플레인이고, 9000번은 라우팅 테이블을 바꾸는 컨트롤 플레인이에요.

gateway.ts
// 라우팅 테이블: 컨트롤 플레인이 쓰고, 데이터 플레인이 읽는다
let routes: Record<string, string> = {
  "/users": "http://localhost:4001",
};

// 데이터 플레인: 모든 요청이 지나가는 경로
Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    const prefix = Object.keys(routes).find((p) => url.pathname.startsWith(p));
    if (!prefix) return new Response("no route\n", { status: 404 });

    return fetch(routes[prefix] + url.pathname + url.search);
  },
});

// 컨트롤 플레인: 운영자만 접근하는 설정 API
Bun.serve({
  port: 9000,
  hostname: "127.0.0.1",
  async fetch(req) {
    if (req.method === "PUT") {
      routes = await req.json();
      return Response.json({ applied: Object.keys(routes) });
    }
    return Response.json(routes);
  },
});

데이터 플레인의 fetch 핸들러가 하는 일은 메모리에 있는 routes를 찾아보는 것뿐입니다. 요청마다 데이터베이스를 조회하거나 다른 서버에 물어보지 않아요. 라우터가 포워딩 테이블만 들여다보고 패킷을 내보내는 것과 같은 모양입니다. 컨트롤 플레인은 hostname을 127.0.0.1로 묶어 바깥에서 닿지 않게 했습니다.

백엔드 역할을 할 서버 두 개도 띄워 둡니다.

backends.ts
Bun.serve({
  port: 4001,
  fetch: (req) => new Response(`users v1: ${new URL(req.url).pathname}\n`),
});
Bun.serve({
  port: 4002,
  fetch: (req) => new Response(`users v2: ${new URL(req.url).pathname}\n`),
});

두 파일을 각각 실행합니다.

bun backends.ts
bun gateway.ts

처음에는 /users만 등록되어 있으니 /orders는 404가 납니다.

curl localhost:3000/users/42
curl localhost:3000/orders/1
결과
users v1: /users/42
no route

이제 컨트롤 플레인에 새 라우팅 테이블을 보냅니다. /users를 v2 백엔드로 옮기고 /orders를 추가해 볼게요.

curl -X PUT localhost:9000 \
  -d '{"/users":"http://localhost:4002","/orders":"http://localhost:4001"}'
curl localhost:3000/users/42
결과
{"applied":["/users","/orders"]}
users v2: /users/42

게이트웨이를 재시작하지 않았는데 다음 요청부터 바로 새 경로로 갑니다. Envoy가 xDS로 설정을 받아 바꾸는 것도 규모만 다를 뿐 같은 원리예요.

실제 시스템이라면 여기서 한 걸음 더 나아갑니다. 컨트롤 플레인을 별도 프로세스로 떼어 내고, 데이터 플레인이 받은 설정을 디스크에도 저장해 두는 거죠. 그러면 컨트롤 플레인이 내려가도, 심지어 데이터 플레인이 재시작되어도 마지막 설정으로 다시 일어날 수 있습니다. 앞에서 말한 정적 안정성이 바로 이런 모습입니다.

또 하나, 위 예제는 받은 라우팅 테이블을 검사 없이 바로 적용합니다. 오타 하나만 있어도 모든 요청이 엉뚱한 곳으로 가겠죠. 그래서 실제 컨트롤 플레인 안을 들여다보면 보통 세 단계로 나뉩니다. 원하는 상태를 저장하고, 그 상태가 올바른지 검증하고, 통과한 것만 데이터 플레인에 전달하는 거죠. 코드가 저장소에 올라가 CI에서 검증되고 CD로 배포되는 흐름과 똑같은 모양이에요. 설정 변경이 곧 데이터 플레인의 동작을 바꾸는 배포이기 때문에, 코드 배포만큼 신중하게 검증하고 조금씩 내보내야 합니다.

흔히 하는 실수

두 층을 나눠 놓고도 무심코 섞어 버리는 경우가 꽤 있습니다. 대표적인 게 요청마다 컨트롤 플레인에 물어보는 설계예요. 예를 들어 게이트웨이가 요청이 들어올 때마다 정책 서버에 “이 요청 통과시켜도 돼?”라고 묻는다면, 정책 서버는 이름만 컨트롤 플레인일 뿐 사실상 데이터 경로에 들어와 있는 겁니다. 정책 서버가 느려지면 모든 요청이 느려지고, 죽으면 모든 요청이 실패하죠. 정책을 미리 받아 데이터 플레인 안에서 판단하거나, 최소한 캐시와 기본 동작을 정해 둬야 합니다.

또 하나는 컨트롤 플레인 API를 데이터 플레인과 같은 문으로 열어 두는 실수입니다. 위 예제에서 9000번 포트를 0.0.0.0으로 열면 누구나 라우팅 테이블을 바꿀 수 있게 되죠. Caddy가 관리 API를 기본으로 localhost:2019에만 여는 것도 같은 이유입니다.

마치며

컨트롤 플레인은 “어떻게 처리할지”를 정하고, 데이터 플레인은 “실제로 처리”합니다. 사용자 요청이 컨트롤 플레인을 지나가지 않게 선을 그어 두면, 두 층을 따로 확장할 수 있고 컨트롤 플레인이 멈춰도 서비스는 마지막 설정으로 계속 돌아갑니다. 라우터에서 출발한 이 생각이 쿠버네티스, 서비스 메시, VPN, AI 게이트웨이까지 거의 모든 인프라에 같은 모양으로 들어가 있다는 게 흥미롭지 않나요?

앞으로 새로운 인프라 도구를 만나면 “이 중에 어느 쪽이 컨트롤 플레인이고, 그게 멈추면 무슨 일이 생기지?”라고 먼저 물어보세요. 아키텍처 그림이 훨씬 빨리 읽힐 겁니다. 😊

정적 안정성을 더 깊이 살펴보고 싶다면 AWS Builders’ Library의 Static stability using Availability Zones를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord