지원할 회사, 사람이 늘고 있을까? 국민연금 데이터 58만 행 처음 열어 보기
이 글과 연결된 글 보기
드래그로 이동 · 휠/두 손가락으로 확대 · 방향키로 글 선택 · 0으로 맞춤. 실선: 본문 링크, 점선: 시리즈·주제의 대표 연결.표시된 글 11편 목록
국민연금 가입 사업장 데이터를 열어 보면, 설명에는 "사업자등록번호"라고 적힌 컬럼이 실제로는 전부 6자리입니다. 58만 9천 행에 고유한 번호는 4만 2천 개뿐이고, 번호 하나를 사업장 895곳이 같이 씁니다. 이 번호로 회사를 묶었다면 대시보드는 에러 없이 틀렸을 겁니다. 이 글을 따라 하면 이 데이터를 DuckDB에 넣고, 파이프라인을 만들기 전에 꼭 확인해야 할 함정 다섯 가지를 직접 찾아낼 수 있습니다.
이 글은 이런 분께 필요합니다
- 데이터 엔지니어나 BI 직무를 준비하는데, 포트폴리오에 넣을 "내가 만든 파이프라인"이 없는 분
- 캐글처럼 정리된 데이터만 다뤄 봐서, 지저분한 원본을 처음부터 다뤄 본 적이 없는 분
- SQL과 Python 기초는 배웠고, IDE 터미널에서
pip install정도는 해 본 분
이 시리즈는 데이터 하나로 끝까지 갑니다. 국민연금 사업장 데이터를 받아 쌓고(수집), 웨어하우스에 넣고, 데이터마트로 다듬고, Tableau·Power BI·Superset으로 대시보드를 만들고, 마지막에는 AI가 마트에 질문하게 만듭니다. 다 만들면 "지원할 회사가 사람을 늘리고 있는지, 얼마나 나가는지"를 보는 대시보드가 남습니다. 취업 준비에 직접 쓸 수 있는 도구이면서, 그걸 만든 과정이 그대로 포트폴리오가 됩니다.
저는 쏘카, 타다, 지그재그, 쿠팡에서 8년 동안 데이터 분석과 데이터 엔지니어링을 했습니다. 모빌리티, 이커머스, OTT로 도메인은 달랐지만 새 데이터를 받으면 제일 먼저 하는 일은 같았습니다. 집계 쿼리를 짜기 전에 행 수, 키가 정말 고유한지, 상태값이 서로 맞는지, 중복은 없는지부터 확인하는 일입니다. 파이프라인을 아무리 잘 만들어도 원본을 잘못 이해하면 틀린 숫자를 더 빨리, 더 자주 만들 뿐이기 때문입니다. 이 글의 Step 4는 그 습관을 공공데이터에 그대로 옮긴 것입니다.
왜 국민연금 사업장 데이터인가
국민연금공단은 가입 사업장마다 이번 달 가입자 수, 새로 들어온 사람 수(신규취득자수), 나간 사람 수(상실가입자수)를 매달 공개합니다. 공공데이터포털에서 로그인 없이 CSV로 받을 수 있고, 2019년 등록분부터 과거 파일도 목록에 남아 있습니다.
데이터 엔지니어링 연습용으로 좋은 이유는 세 가지입니다.
- 매달 새 파일이 나옵니다. 한 번 받고 끝이 아니라 계속 쌓아야 해서, 수집과 스케줄링을 연습할 이유가 생깁니다.
- 크기가 적당히 큽니다. 한 달치가 115MB, 58만 행입니다. 엑셀로 열기엔 버겁고, 노트북 한 대로 처리하기엔 충분합니다.
- 함정이 진짜입니다. 아래에서 보듯 키, 중복, 상태값 문제가 실제로 들어 있습니다. 연습용으로 만든 데이터에서는 이런 걸 만나기 어렵습니다.
한 가지 알아둘 점이 있습니다. 국민연금 가입자 수는 직원 수와 정확히 같지 않습니다. 이 데이터로 볼 수 있는 건 "국민연금에 가입된 사람 수의 변화"이고, 시리즈 내내 이 표현을 지키겠습니다.
Step 1. 데이터 받기
공공데이터포털의 국민연금 가입 사업장 내역 페이지에서 CSV 다운로드 버튼을 누릅니다. 2026년 10월 28일에 받은 파일은 국민연금 가입 사업장 내역_20261023.csv, 115MB였습니다.
파일 이름의 날짜와 데이터의 기준월은 다릅니다. 10월 23일자 파일 안에 든 자료생성년월은 2026-09입니다. 지난해 9월 23일에 등록된 파일을 받아 봐도 기준월은 2025-08이었습니다. 파일 날짜로 월을 매기면 모든 데이터가 한 달씩 밀리니, 기준월은 반드시 파일 안의 자료생성년월로 잡습니다. 이 규칙은 2편에서 과거 파일을 쌓을 때 다시 씁니다.
실습 폴더를 하나 만들고 받은 파일을 넣은 뒤, DuckDB를 설치합니다. DuckDB는 설치만 하면 바로 쓰는 분석용 데이터베이스입니다. 서버를 띄울 필요가 없고, 파일 하나가 곧 데이터베이스입니다.
pip install duckdb
Step 2. 원본은 그대로 두고, UTF-8 사본 만들기
처음엔 DuckDB로 바로 읽으려고 했는데 이런 에러가 났습니다.
Invalid Input Error: The CSV Reader does not support the encoding: "CP949"
The currently supported encodings are: utf-16, latin-1, utf-8
이 파일은 한국 공공데이터에서 흔한 CP949 인코딩입니다. DuckDB 기본 CSV 리더는 UTF-8, UTF-16, Latin-1만 읽습니다. 그래서 Python으로 UTF-8 사본을 하나 만들고, 받은 원본 파일은 건드리지 않습니다. 나중에 숫자가 이상할 때 돌아갈 곳은 원본뿐이기 때문입니다.
CSV = "국민연금 가입 사업장 내역_20261023.csv" # 받은 파일 이름
with open(CSV, encoding="cp949") as src, open("nps_utf8.csv", "w", encoding="utf-8") as dst:
for line in src:
dst.write(line)
한 줄씩 읽어 쓰기 때문에 파일이 커도 메모리를 많이 쓰지 않습니다.
Step 3. 모든 컬럼을 문자로 raw 테이블에 넣기
import duckdb
con = duckdb.connect("nps.duckdb")
con.sql("""
CREATE OR REPLACE TABLE raw_nps AS
SELECT * FROM read_csv('nps_utf8.csv', header = true, all_varchar = true)
""")
print(con.sql("SELECT COUNT(*) AS rows, MIN(자료생성년월) AS ym_min, MAX(자료생성년월) AS ym_max FROM raw_nps"))
┌────────┬─────────┬─────────┐
│ rows │ ym_min │ ym_max │
├────────┼─────────┼─────────┤
│ 589413 │ 2026-09 │ 2026-09 │
└────────┴─────────┴─────────┘
all_varchar = true로 모든 컬럼을 문자로 넣은 데는 이유가 있습니다. 같은 파일을 pandas 기본값으로 읽으면 업종코드 020100(임업용 종묘 생산업)이 숫자 20100이 됩니다. DuckDB 자동 추론은 이번에 업종코드를 문자로 읽었지만, 사업자등록번호는 숫자(BIGINT)로 읽었습니다. 타입 추론은 도구마다, 파일마다 달라질 수 있습니다. 그래서 raw 단계에서는 원본을 한 글자도 바꾸지 않고, 타입 변환은 다음 단계에서 의도를 갖고 합니다. 실무 파이프라인에서 raw, staging, mart를 나누는 이유가 여기서 시작됩니다. 이 구조는 5편에서 본격적으로 다룹니다.
M3 맥북 기준으로 Step 2와 Step 3을 합쳐 3초 정도 걸렸습니다.
Step 4. 파이프라인을 만들기 전에 의심할 다섯 가지
데이터를 넣었으면 바로 집계하고 싶어집니다. 그 전에 아래 다섯 개 쿼리를 먼저 돌립니다. 모두 에러 없이 실행되지만, 결과를 보면 이 데이터를 그대로 쓰면 안 되는 이유가 보입니다.
1) 사업자등록번호는 몇 자리인가: 전부 6자리입니다
print(con.sql("""
SELECT LENGTH(사업자등록번호) AS len, COUNT(*) AS rows,
COUNT(DISTINCT 사업자등록번호) AS distinct_bizno
FROM raw_nps GROUP BY 1
"""))
┌───────┬────────┬────────────────┐
│ len │ rows │ distinct_bizno │
├───────┼────────┼────────────────┤
│ 6 │ 589413 │ 42111 │
└───────┴────────┴────────────────┘
사업자등록번호는 원래 10자리인데, 이 데이터에는 앞 6자리만 들어 있습니다. 포털의 컬럼 설명에는 "사업자등록번호입니다"라고만 적혀 있습니다. 58만 행에 고유 번호가 4만 2천 개뿐이라는 건, 이 컬럼이 사업장을 구분하는 키가 될 수 없다는 뜻입니다.
2) 같은 번호를 몇 곳이 쓰나: 최대 895곳
print(con.sql("""
SELECT 사업자등록번호, COUNT(*) AS 사업장수
FROM raw_nps GROUP BY 1 ORDER BY 2 DESC LIMIT 5
"""))
┌────────────────┬──────────┐
│ 사업자등록번호 │ 사업장수 │
├────────────────┼──────────┤
│ 114812 │ 895 │
│ 117810 │ 513 │
│ 215815 │ 417 │
│ 315810 │ 405 │
│ 127830 │ 397 │
└────────────────┴──────────┘
114812 하나를 이름이 다른 사업장 895곳이 같이 씁니다. 이 번호로 다른 표와 JOIN하면 한 회사에 엉뚱한 회사 894곳이 붙습니다. 그래서 사업장을 구분하려면 사업장명, 우편번호, 업종 같은 컬럼을 조합한 키를 따로 설계해야 합니다. 이 키를 어떻게 잡을지가 5편의 핵심 주제입니다.
3) 회사 하나 = 행 하나인가: 쿠팡풀필먼트는 9행입니다
print(con.sql("""
SELECT COUNT(*) AS 사업장수, SUM(CAST(가입자수 AS INT)) AS 가입자합계
FROM raw_nps
WHERE 사업자등록번호 = '837870' AND 사업장명 LIKE '쿠팡풀필먼트%'
"""))
┌──────────┬────────────┐
│ 사업장수 │ 가입자합계 │
├──────────┼────────────┤
│ 9 │ 77006 │
└──────────┴────────────┘
쿠팡풀필먼트서비스 유한회사(47,522명) 외에 인천센터(7,806명), 부천센터(5,345명), 대구센터(4,277명)처럼 센터별 사업장이 따로 있습니다. 게다가 쿠팡풀필먼트서비스 유한회사라는 이름은 같은 우편번호로 두 번 나옵니다. 하나는 47,522명(적용일자 2016-12-01), 다른 하나는 618명(2018-11-01)입니다. 이름과 주소가 같아도 다른 사업장일 수 있다는 뜻입니다. 이 데이터의 한 행은 "회사"가 아니라 "국민연금에 등록된 사업장"입니다. 회사 단위로 보고 싶다면 행을 묶는 규칙을 정해야 하고, 그 규칙이 맞는지도 확인해야 합니다.
4) 상태 코드와 가입자 수가 맞는가: 맞지 않는 행이 840개
print(con.sql("""
SELECT "사업장가입상태코드 1 등록 2 탈퇴" AS 상태,
COUNT(*) AS rows,
SUM(CASE WHEN CAST(가입자수 AS INT) = 0 THEN 1 ELSE 0 END) AS 가입자0,
SUM(CASE WHEN CAST(가입자수 AS INT) > 0 THEN 1 ELSE 0 END) AS 가입자있음
FROM raw_nps GROUP BY 1 ORDER BY 1
"""))
┌─────────┬────────┬─────────┬────────────┐
│ 상태 │ rows │ 가입자0 │ 가입자있음 │
├─────────┼────────┼─────────┼────────────┤
│ 1 │ 553273 │ 548 │ 552725 │
│ 2 │ 36140 │ 35848 │ 292 │
└─────────┴────────┴─────────┴────────────┘
탈퇴(2)한 사업장 36,140곳 중 292곳은 가입자 수가 0이 아닙니다. 반대로 등록(1) 상태인데 가입자가 0명인 곳도 548곳입니다. 어느 쪽이 맞는지는 이 파일만으로 알 수 없습니다. 지금 할 일은 고치는 게 아니라 "이런 행이 있다"는 걸 기록해 두는 것입니다. 집계할 때 탈퇴 사업장을 빼는지, 가입자 수로 거르는지에 따라 합계가 달라지기 때문입니다.
컬럼 이름도 눈여겨볼 만합니다. 사업장가입상태코드 1 등록 2 탈퇴처럼 코드 설명이 컬럼 이름에 들어 있어서, SQL에서는 큰따옴표로 감싸야 합니다. staging 단계에서 status_code 같은 이름으로 바꿔 둘 대상입니다.
5) 완전히 똑같은 행이 있나: 2행
print(con.sql("""
SELECT (SELECT COUNT(*) FROM raw_nps)
- (SELECT COUNT(*) FROM (SELECT DISTINCT * FROM raw_nps)) AS 완전중복
"""))
┌──────────┐
│ 완전중복 │
├──────────┤
│ 2 │
└──────────┘
22개 컬럼이 전부 같은 행이 2개 있습니다. 숫자는 작지만, 공공데이터라고 중복이 없다고 가정하면 안 된다는 증거로는 충분합니다.
Step 5. 회사 하나 골라 보기, 그리고 한계
이제 처음 질문으로 돌아갑니다. 지원할 회사를 하나 골라 봅니다.
print(con.sql("""
SELECT 사업장명, CAST(가입자수 AS INT) AS 가입자수,
CAST(신규취득자수 AS INT) AS 신규, CAST(상실가입자수 AS INT) AS 상실
FROM raw_nps
WHERE 사업장명 IN ('주식회사 카카오', '쿠팡 주식회사', '주식회사 카카오뱅크')
"""))
┌─────────────────────┬──────────┬───────┬───────┐
│ 사업장명 │ 가입자수 │ 신규 │ 상실 │
├─────────────────────┼──────────┼───────┼───────┤
│ 주식회사 카카오 │ 3788 │ 58 │ 55 │
│ 쿠팡 주식회사 │ 10947 │ 322 │ 235 │
│ 주식회사 카카오뱅크 │ 1798 │ 41 │ 14 │
└─────────────────────┴──────────┴───────┴───────┘
2026년 9월 한 달만 보면 세 곳 모두 신규가 상실보다 많습니다. 다만 포털 설명에 따르면 신규취득자수에는 납부를 다시 시작한 사람이 포함되고, 상실가입자수에는 납부 예외자가 포함됩니다. 실제 입사자·퇴사자 수와는 다를 수 있습니다. 그리고 이건 한 달치 스냅샷이라 "사람이 늘고 있다"고 말할 수는 없습니다. 지난달에 크게 줄었다가 이번 달 조금 회복한 것일 수도 있으니까요. 추세를 보려면 지난 파일들을 받아 같은 사업장끼리 이어 붙여야 하고, 그러려면 Step 4에서 본 키 문제를 먼저 풀어야 합니다.
데이터 엔지니어링이 필요한 이유가 여기서 드러납니다. 질문은 한 줄인데, 그 질문에 답할 수 있는 상태로 데이터를 만드는 데는 수집, 키 설계, 중복 처리, 기준월 정리가 전부 필요합니다.
막힐 때
| 증상 | 원인 | 해결 |
|---|---|---|
does not support the encoding: "CP949" | DuckDB 기본 CSV 리더는 CP949를 못 읽음 | Step 2처럼 Python으로 UTF-8 사본을 만든다 |
UnicodeDecodeError: 'utf-8' codec can't decode | 원본을 UTF-8로 열었음 | open(..., encoding="cp949")로 연다 |
업종코드가 20100처럼 5자리로 보임 | 숫자 타입으로 읽어 앞의 0이 사라짐 | all_varchar = true(pandas는 dtype=str)로 문자로 적재한다 |
Referenced column "사업장가입상태코드" not found | 컬럼 이름에 공백과 숫자가 들어 있음 | 이름 전체를 큰따옴표로 감싼다 |
| 기준월이 한 달씩 밀림 | 파일 이름 날짜로 월을 매김 | 파일 안의 자료생성년월을 쓴다 |
재현 자료
이 글의 실습을 그대로 돌리는 노트북을 공개해 두었습니다. 글에서 쓴 파일과 같은 파일을 받도록 고정해 두어서, 다음 달에 새 파일이 올라와도 같은 숫자가 나옵니다.
다음 편
2편에서는 과거 파일을 받아 월별 스냅샷을 쌓습니다. 매달 115MB짜리 파일을 어떻게 보관할지, 다시 받아도 결과가 같게 하려면 어떤 규칙이 필요한지, 그리고 2025년 7월에 제공 범위가 바뀐 구간을 어떻게 표시할지를 다룹니다.

