애플리케이션 서버와 DB의 역할 분담¶
한 줄 요약: DB는 묻는 말에만 답하는 창고다. 묻고, 조립하고, 권한을 확인하는 일은 앱 서버가 한다. DB끼리는 서로 부르지 않는다. 시스템 그림을 그릴 때 "누가 누구를 부르나"를 바로 보려면 이것부터 알아야 한다.
📍 만난 상황¶
- 2026-11-09 — 재난 대시보드 구조를 정하던 중이었다. 5번 물음 → 그날 일지
- 11:05 "정규화 표를 중간 DB로 가져와 거른다"는 계획을 듣고, "중간 DB가 있으면 왜 필터가 가능한지" 물었다. 11:47 "원래 DB는 필터 인자를 못 받아서 안 된다며?"라고 되물었다.
- 11:49, 13:32 "그럼 ClickHouse에 SQL을 바로 날리면 되잖아?"를 두 번 물었다.
- 14:41 작업 순서를 듣고 "대시보드 ↔ 중계 서버 ↔ postgres ↔ clickhouse 이 구조가 맞아?"라고 물었다. 실제로는 중계 서버에서 두 갈래로 나뉘는 구조였다.
🧩 쉽게 말하면¶
식당을 떠올리면 된다. 손님(브라우저)은 주방에 들어가지 못한다. 웨이터(앱 서버)가 주문을 받는다.
웨이터는 레시피 노트(PostgreSQL)를 보고, 식료품 창고(ClickHouse)에 가서 재료를 꺼낸다. 레시피 노트가 창고에 전화하는 일은 없다.
웨이터가 없으면 손님이 주방에 들어가 아무 재료나 꺼낸다. 누가 무엇을 가져갔는지 확인할 사람도 없다.
📖 제대로 알기¶
등장인물¶
- 앱 서버(애플리케이션 서버): 요청을 받아 규칙대로 처리하는 프로그램이다. 로그인 확인, 권한 확인, SQL 조립, 결과 합치기를 한다. 그날 구조에서는 Next.js 서버가 이 일을 맡는다.
- DB(DBMS: 데이터를 보관하고 SQL 질문에 답하는 프로그램): 스스로 먼저 움직이지 않는다. 질문이 와야 답하고, 써 달라고 해야 쓴다.
- API(프로그램이 받아 주는 요청의 형식): 어떤 인자를 받는지가 정해져 있다. 받지 않는 인자는 보내도 소용없다.
- 3계층 구조: 화면(프레젠테이션) → 처리(애플리케이션) → 저장(데이터)으로 나누는 가장 흔한 구조다.
필터는 누가 하나¶
필터는 데이터의 성질이 아니라, 데이터를 들고 있는 프로그램이 하는 일이다. 같은 데이터라도 어느 문으로 꺼내느냐에 따라 거를 수 있기도 하고 없기도 하다.
| 문 | 받는 인자 | 결과 |
|---|---|---|
| 매니저 다운로드 API | 형식, 줄 수, 버전 | 통째로만 줌 |
| SQL 조회 창구 | SQL 문장 | 걸러서 줌(최대 1,000줄) |
그 뒤의 ClickHouse 자체는 거를 능력이 있다. "필터가 안 된다"는 말은 첫 번째 문 이야기였다.
직접 접속과 공식 입구¶
DB에 직접 붙으면 빠르고 단순해 보인다. 하지만 세 가지 문제가 있다. 1. 닿지 않는다: 내부 DB는 보통 외부에 주소를 열어 두지 않는다. 2. 권한 검사를 건너뛴다: 플랫폼은 "누가 어느 데이터셋의 어떤 행·열을 보나"를 따로 관리한다. 직접 붙으면 이 검사가 빠진다. 3. 관리자 계정이 필요하다: 그 계정을 앱에 넣으면 보안 구멍이 된다. 플랫폼도 이런 옛 경로를 보안 문제로 보고 막은 기록이 있다.
그래서 SQL은 공식 입구(SQL 창구)로 보낸다. 보내는 문장은 ClickHouse 문법 그대로다. 중간에 로그인 확인과 권한 검사가 더해질 뿐이다.
⚙️ 어떻게 동작하나¶
"침수 · 2022년 8월 · 강남구" 필터 한 번에 일어나는 일이다.
- 브라우저가 앱 서버에 조건만 보낸다. 표 이름이나 SQL은 보내지 않는다.
- 앱 서버가 PostgreSQL에 묻는다. "침수 태그가 붙은 데이터셋의 표 이름과 거르는 방법은?"
- 앱 서버가 받은 값으로 SQL을 글자로 조립한다.
- 앱 서버가 SQL에 토큰을 붙여 dhub2-manager의 SQL 창구로 보낸다.
- 매니저가 권한을 검사하고 ClickHouse에서 실행한다. 걸러진 줄만 돌아온다.
- 앱 서버가 두 결과를 합쳐(예: 즐겨찾기 여부 표시) 브라우저에 돌려준다.
flowchart LR
B["브라우저"] -->|"① 조건"| S["앱 서버<br/>(허브)"]
S -->|"② 메타데이터 읽기"| P[("PostgreSQL<br/>장부")]
S -->|"④ SQL + 토큰"| M["dhub2-manager<br/>SQL 창구"]
M -->|"⑤ 권한 검사 후 실행"| C[("ClickHouse<br/>창고")]
화살표는 모두 앱 서버에서 나간다. PostgreSQL과 ClickHouse는 서로를 모른다. ClickHouse는 매니저 뒤에 숨어 있어서, 앱 서버는 그 주소도 모른다.
왜 이렇게 나눌까. 비밀값과 규칙을 한 곳에 모으기 위해서다. 토큰, DB 비밀번호, 권한 규칙이 브라우저나 DB 여기저기 흩어지면 관리할 수 없다.
🧪 예시¶
-
예시 1 · 일상에 빗대어: 웨이터에게 "강남 지점 8월 냉장 재료 몇 개?"를 물었다. 웨이터는 레시피 노트에서 "냉장 = 3번 창고, 강남 = 11680"을 찾는다. 창고 관리인에게 출입증(토큰)을 보여 주고 "11680번의 8월분만 세어 달라"고 한다. 관리인은 출입증 권한을 확인하고, 43,112개 중 679개를 세어 준다. 노트와 창고는 한 번도 서로 말하지 않았다.
-
예시 2 · 실제로는: 앱 서버 코드의 뼈대다.
// ① PostgreSQL에서 읽기 — 평범한 SELECT
const ds = await pg.query(
"SELECT table_name, time_kind FROM app.dataset WHERE id = ANY($1)", [ids]);
const sgg = await pg.query(
"SELECT code FROM app.region_code WHERE name = $1", ["강남구"]);
// ② 읽은 값으로 ClickHouse용 문장 조립(표 이름은 허용 목록으로 검사)
for (const d of ds.rows) {
const sql = buildCountSql(d, sgg.rows[0].code, "2022-08");
// ③ 플랫폼 SQL 창구로 전송. 토큰은 서버 안에만 있다
const n = await platform.query(sql, { token });
}
pg는 PostgreSQL만 상대한다. $1은 값을 안전하게 끼우는 자리다.
② 두 DB를 잇는 것은 이 글자 조립뿐이다. DB끼리 연결된 선은 없다.
③ platform은 매니저의 SQL 창구만 상대한다. ClickHouse에 직접 붙지 않는다.
⚖️ 비교 · 흔한 오해¶
내가 그린 그림 → 실제 그림
flowchart LR
X1["❌ 대시보드"] --> X2["중계 서버"]
X2 --> X3[("PostgreSQL")]
X3 --> X4[("ClickHouse")]
이 일렬 그림이라면 PostgreSQL이 ClickHouse에 데이터를 받아 오거나 넘겨줘야 한다. 실제는 위 ⚙️의 그림처럼 앱 서버에서 두 갈래로 나뉜다.
| DB에 직접 접속 | 공식 입구 경유 | |
|---|---|---|
| 권한 검사 | 건너뜀 | 요청마다 함 |
| 필요한 계정 | 내부 관리자 계정 | 사용자·서비스 토큰 |
| 외부에서 | 보통 닿지 않음 | 열린 입구로 접근 |
오해 1. "중간 DB가 있으면 필터가 된다." 정확히는 조건을 받는 프로그램이 있으면 된다. 중간 DB는 그런 프로그램 중 하나일 뿐이다. 플랫폼의 SQL 창구가 열려 있으면 중간 DB 없이도 거를 수 있다.
오해 2. "DB끼리 이어져 데이터가 흐른다." 처음 들은 "중간 DB"라는 이름이 데이터 사이에 끼인 관을 떠올리게 한다. 그래서 일렬 그림이 그려지기 쉽다. 실제로 DB는 관이 아니라 창고다. 창고에서는 화살표가 나가지 않는다.
오해 3. "ClickHouse에 바로 SQL을 보내면 되지 않나." 방향은 맞다. 문장도 ClickHouse 문법 그대로다. 다만 보내는 길이 달라야 한다. 권한 검사를 거치는 공식 입구로 보낸다.
🛠️ 실무에서는¶
- 새 구조를 보면 "누가 누구를 부르나" 화살표부터 그린다. DB에서 시작하는 화살표가 있으면 다시 의심한다.
- 비밀값(토큰, DB 비밀번호, client secret)은 서버에만 둔다. 브라우저 코드에 넣지 않는다.
- 브라우저는 "강남구, 2022-08, 침수" 같은 조건만 보내게 한다. 표 이름·열 이름이 화면 코드에 드러나지 않는다.
- 플랫폼에 문이 여러 개면 문마다 받는 인자와 한도(예: 1,000줄)를 먼저 표로 정리한다.
문제가 생기면 먼저 볼 것 - 403(권한 없음): 토큰의 주인에게 그 데이터셋 읽기 권한이 있나. - 연결 자체가 안 됨: 앱 서버가 그 입구에 네트워크로 닿는 위치(같은 내부망·클러스터)에 있나. - 결과가 잘림: 창구의 줄 수 한도에 걸린 것은 아닌가. 나눠 받기(OFFSET)가 필요하다.
❓ 자주 묻는 질문¶
- Q. 백엔드가 dhub2-manager인데 서버를 또 만들어야 하나? A. 얇은 중계는 필요하다. 토큰을 숨기고, 브라우저 요청이 막히는 문제(CORS)를 피하려면 서버끼리 통신해야 한다. 거르기·권한 판정은 매니저가 그대로 한다.
- Q. 앱 서버가 PostgreSQL과 ClickHouse를 한 SQL로 JOIN할 수 있나? A. 못 한다. 서로 다른 프로그램이라 각각 물어보고 앱 서버가 결과를 합친다.
- Q. 내부망이면 토큰 없이 통하나? A. 아니다. 내부망은 "길이 있다"는 뜻일 뿐이다. 매니저는 여전히 토큰과 권한을 검사한다.
🔭 더 깊이 보기¶
- HTTP 요청과 응답, 상태 코드(401·403)는 어떻게 오가나? — 앱 서버가 하는 모든 일이 요청 하나를 받아 응답 하나를 돌려주는 것이라서.
- 프록시(중계)와 CORS는 무엇인가? — 브라우저가 매니저를 바로 부를 수 없었던 이유라서.
- 커넥션 풀은 왜 쓰나? — 앱 서버가 DB를 부르는 비용을 줄이는 기본 기법이라서.
- 관련 주제: 메타데이터 카탈로그 기반 조회 · PostgreSQL 데이터베이스와 스키마 · ERD 관계와 카디널리티(1:N)
📚 참고 자료¶
- N 계층 아키텍처 스타일 (Microsoft Learn 한국어, Azure 아키텍처 센터) — 화면·처리·저장 계층을 나누는 이유와 계층 간 호출 방향 · 링크
- HTTP 개요 (MDN 한국어) — 브라우저와 서버가 요청·응답을 주고받는 기본 흐름 · 링크
- SQL Injection Prevention Cheat Sheet (OWASP, 영문) — 앱 서버가 SQL을 조립할 때 지켜야 할 것 · 링크
🗓️ 이력¶
- 2026-11-09: 필터는 누가 하나, DB에 직접 접속하면 안 되나, DB가 일렬로 이어진 구조인가를 5번 물음 → 일지