본문 바로가기
/ 학습 / 안내서 / 데이터 처리
L2 중급 · 13분 읽기

출동 통계, CSV 한 장으로 월간 집계

2026.07.12 갱신 출동 데이터를 직접 다뤄보려는 실무자
#pandas#통계#groupby#검산
검토 상태: 전문 검토 전 위험도 미분류

이 안내서는 공식 절차·법령·현장 판단을 대체하지 않으며, 적용 전 소속 기관 기준과 담당자 검토를 확인하세요.

매달 출동 현황을 손으로 집계하는 일은 반복적이고 실수가 잦습니다. 이 안내서는 출동 기록 CSV 한 장으로 월별·유형별·요일×시간대·지역별 집계와 차트 4종, 보고용 엑셀까지 만드는 방법입니다. 외부 AI API를 쓰지 않는 순수 집계라 로그인·키가 필요 없습니다.

🎯 이 장의 목표
  • 출동 CSV의 시간·유형·지역 열을 집계 가능한 기준으로 정규화할 수 있습니다.
  • groupby, value_counts, pivot_table의 쓰임을 구분할 수 있습니다.
  • 집계 합과 전체 행 수를 대조해 누락 여부를 검산할 수 있습니다.
  • 집계표가 말하는 사실과 별도 근거가 필요한 해석을 구분할 수 있습니다.

필요한 것 — 표준 컬럼 4개

출동 기록에서 네 개의 컬럼만 있으면 됩니다.

  • reported_at — 신고/출동 일시
  • incident_type — 출동 유형
  • district — 지역 단위(행정동 등)
  • incident_id — 중복 제거·검산용 고유값

부서마다 컬럼명이 다르면 매핑 한 줄로 맞춥니다.

PYTHON
COLUMN_MAP = {
    "신고일시": "reported_at",
    "사고유형": "incident_type",
    "행정동": "district",
}

동작 원리

월간 집계는 원본 표를 바로 요약하는 일이 아니라, 기준 열을 만들고 → 같은 기준끼리 묶고 → 각 묶음을 세고 → 전체와 대조하는 네 단계입니다. 먼저 일시 문자열을 날짜형으로 바꾸면 월·요일·시간 같은 파생 열을 같은 규칙으로 만들 수 있습니다. 그 다음 유형이나 지역처럼 이미 있는 범주 열과 파생 열을 묶음 기준으로 사용합니다.

groupby는 원본 행을 없애는 명령이 아닙니다. 지정한 기준값이 같은 행의 위치를 묶어 두었다가 size, count, sum 같은 집계 연산을 적용합니다. 여기서 size()는 묶음 안의 행 수를 세고, 특정 열의 count()는 그 열에서 비어 있지 않은 값만 셉니다. 같은 표에서도 둘의 합이 다를 수 있으므로 "출동 건수"가 행 수인지, 고유 사건 수인지 먼저 정해야 합니다.

마지막 검산은 집계 과정의 반대 방향입니다. 잘게 나눈 묶음의 수를 다시 더해 입력 전체와 비교합니다. 다만 중복 사건이 원본에 두 줄이면 양쪽 모두 두 줄로 세기 때문에 합은 맞습니다. 따라서 행 수 검산은 집계 누락을 찾는 검사이고, 고유값·결측·분류 정확성 검사는 별도로 남습니다.

groupby가 하는 일 — 우편물 분류

집계 코드를 보기 전에 groupby가 무엇을 하는지 그림을 잡아 두면 나머지가 쉽습니다. 원리는 우체국의 우편물 분류와 같습니다. 우체국은 편지를 우편번호별 칸에 나눠 넣고, 칸마다 편지 수를 세고, 그 결과를 한 장의 표로 모읍니다. df.groupby("월").size() 도 똑같습니다 — 줄을 월별 칸에 나눠 담고, 칸마다 줄 수를 세어, 월별 건수표로 돌려줍니다.

value_counts() 는 이 과정을 한 열에 대해 자동으로 해주는 지름길입니다. df["incident_type"].value_counts() 는 "유형별 칸에 나눠 담고 세기"를 한 줄로 합친 것입니다.

주의할 점이 하나 있습니다. groupby는 글자가 정확히 같은 값끼리만 한 칸에 넣습니다. "강서구"와 "강서"는 서로 다른 칸으로 갑니다. 그래서 집계 전에 표기가 통일돼 있어야 하고, 결측·중복도 미리 정리돼 있어야 합니다. 더러운 표를 집계하면 깔끔한 코드도 틀린 답을 냅니다. 표기·결측·중복을 먼저 다듬는 방법은 지저분한 출동 데이터 깔끔하게 정제하기에 있습니다.

선택지와 트레이드오프

도구 이름보다 질문의 모양을 먼저 봅니다. 같은 데이터를 세더라도 결과 표의 형태와 결측 처리 방식이 달라집니다.

선택지맞는 질문장점주의할 점
value_counts()한 열의 값별 빈도를 빠르게 볼 때짧고 빈도순 결과를 바로 얻음기본 결측 처리와 정렬 방식을 확인해야 함
groupby(...).size()한 개 이상의 기준으로 행 수를 셀 때여러 기준을 조합하고 후속 집계를 붙이기 쉬움원본 표기가 흔들리면 묶음도 갈라짐
pivot_table()요일×시간대처럼 두 축의 격자가 필요할 때보고용 교차표를 바로 만듦빈 조합을 0으로 채울지 비워 둘지 명시해야 함
고유 incident_id 집계한 사건이 여러 행일 수 있을 때중복 행과 사건 건수를 구분할 수 있음ID 누락·재사용 여부를 먼저 검사해야 함

집계 4종

날짜에서 월·요일·시간대를 뽑고, 네 가지로 집계합니다.

PYTHON
df["일시"] = pd.to_datetime(df["reported_at"])
df["월"] = df["일시"].dt.month
df["요일"] = df["일시"].dt.dayofweek.map(dict(enumerate("월화수목금토일")))
df["시간대"] = df["일시"].dt.hour
monthly = df.groupby("월").size().rename("건수")
by_type = df["incident_type"].value_counts().rename("건수")
heat = (df.pivot_table(index="요일", columns="시간대", values="incident_id",
                        aggfunc="count", fill_value=0).reindex(list("월화수목금토일")))
top_dong = df["district"].value_counts().head(10).rename("건수")

이렇게 월별 추이, 유형별 구성, 요일×시간대 히트맵, 지역 TOP10 — 네 가지 표와 차트가 나옵니다.

다만 top_dong은 보고용 일부 보기입니다. value_counts()는 기본적으로 빈 지역명(NaN)을 제외하고, head(10)은 상위 10개 밖 지역을 숨깁니다. 보고 전에는 df["district"].isna().sum()으로 결측 건수를 따로 세고 df["district"].value_counts(dropna=False)의 전체 합을 입력 행 수와 대조합니다. 이 검사를 통과한 뒤에만 TOP10을 축약 표로 사용합니다.

각 집계가 실제로 어떤 모양인지 보면 이해가 빠릅니다. 아래는 합성 데이터를 넣었을 때 나오는 예시입니다.

monthly(월별 건수)는 이런 표입니다.

TEXT
월   건수
1   412
2   388
3   501

by_type(유형별 건수)는 많은 유형부터 정렬돼 나옵니다.

TEXT
incident_type   건수
구급            2,140
화재             612
구조             498

monthly는 '월'이라는 한 열로 나눈 것이고, by_type은 '유형'으로 나눈 것입니다 — 나누는 기준만 바뀌었을 뿐 같은 분류 작업입니다. 히트맵(heat)은 나누는 기준이 둘(요일과 시간대)이라 표가 격자 모양이 됩니다.

가상 사례: 상황실 담당자의 교대 시간대 비교

다음은 설명을 위한 가상 사례입니다. 상황실 담당자가 승인된 출동 CSV에서 교대 전후 시간대의 접수 건수 분포를 확인하려 합니다. 입력에는 일시가 비어 있는 행과 형식이 다른 문자열이 섞여 있고, 목적은 인력 배치를 단정하는 것이 아니라 원자료 정비가 필요한 구간을 찾는 것입니다.

현재 예제의 pd.to_datetime(df["reported_at"])는 읽을 수 없는 날짜 문자열을 만나면 기본 설정에서 변환을 중단합니다. 별도 실패 목록을 자동으로 만들거나 성공한 행만 계속 집계하지 않습니다. 담당자는 오류가 가리킨 원본 행을 확인하고, 값 수정 또는 제외가 업무상 타당한지 승인받은 뒤 입력을 정비해 날짜 변환부터 다시 실행합니다. 시간대별 건수가 높아도 곧바로 "인력이 부족하다"고 쓰지 않고, 해당 시간대 입력 품질과 사건 중복을 먼저 확인합니다.

실패 가능성은 오류를 피하려고 날짜를 임의로 채우거나 문제 행을 근거 없이 삭제한 뒤 재실행하는 것입니다. 사람은 원본 값과 정비 사유를 확인하고, 수정·제외가 승인된 입력으로 전체 집계를 다시 만든 뒤 행 수와 집계 합을 검산합니다. 운영 판단에는 별도 근거와 승인 절차가 필요하다는 문장도 남깁니다.

검산: 집계 합 = 전체 행 수

보고서 숫자가 틀리면 신뢰를 잃습니다. 그래서 집계가 끝나면 합이 전체 행 수와 같은지 확인하고, 다르면 멈춥니다.

원리는 은행 창구의 마감 시재 확인과 같습니다. 창구 직원은 퇴근 전에 금고의 현금과 장부의 합계가 1원까지 맞는지 셉니다. 안 맞으면 어딘가 옮겨 적다 틀린 것이니, 맞을 때까지 퇴근하지 못합니다. assert 한 줄이 그 역할입니다 — 같은 데이터를 월별로도 나눠 세고 유형별로도 나눠 셌으니, 두 합은 서로 같고 전체 행 수와도 같아야 합니다. 다르면 집계 어딘가가 틀렸다는 신호입니다.

PYTHON
# 검산: 집계의 합 = 전체 행 수 (다르면 멈추고 사람이 본다)
assert monthly.sum() == len(df) == by_type.sum(), "검산 실패: 집계 합이 전체 행 수와 다릅니다"
print(f"[검산] 월별 합 {monthly.sum():,} = 유형 합 {by_type.sum():,} = 전체 {len(df):,} ✓")

이 검산이 잡는 것은 "옮겨적기·집계 누락"입니다. 반대로 원자료 자체가 틀린 것은 못 잡습니다 — 입력에 중복이 남아 있거나 유형이 잘못 분류돼 있으면, 합은 맞아도 결론은 틀립니다. 그래서 검산은 앞 절의 정제와 짝을 이룹니다. 깨끗한 입력과 검산이 함께 있어야 숫자를 믿을 수 있습니다.

▸ 소방 활용 포인트
이 한 줄(assert)이 월말 집계의 "옮겨적기 오류"를 자동으로 잡습니다. 합성 데이터와 실데이터를 보고서에 함께 쓸 때는 어느 쪽인지 반드시 표시하세요.

가상 사례: 교육 담당자의 합성 집계 실습

이 사례도 전부 가상이며 입력값은 설명을 위한 합성 자료입니다. 교육 담당자는 신규 직원에게 월별 집계와 검산을 가르치기 위해, 일부 행에 중복 ID와 빈 지역명을 넣은 연습 CSV를 준비합니다. 앞 사례보다 데이터는 작지만, 정답을 미리 알고 있어 각 검사가 무엇을 잡는지 비교할 수 있습니다.

학습자는 먼저 행 수 기준 월별 합을 맞춘 뒤 "검산 성공"이라고 판단합니다. 그러나 고유 incident_id 수를 비교하면 중복 사건이 드러납니다. TOP10 코드(value_counts().head(10))만 실행하면:

  • 빈 지역명(NaN)은 자동으로 제외됩니다 (누락이 아니라 보고 축약)
  • 상위 10개 밖의 지역도 숨겨집니다

따라서 실제 누락과 이상을 찾으려면 코드 87행의 검산처럼 결측 수를 따로 세고 dropna=False 전체 분포를 보아야 합니다. TOP10이 보여주지 않는 "하위 지역"과 "결측"이 검토 대상이 됩니다. 이 차이를 통해 assert monthly.sum() == len(df)가 모든 품질 문제를 해결하는 것이 아님을 확인합니다.

실패 가능성은 합성 자료의 숫자를 실제 현황처럼 캡처해 재사용하는 것입니다. 교육 담당자는 파일명과 결과 화면에 합성 표기를 남기고, 학습자가 행 수 검산·고유 ID 검산·결측 검사를 각각 설명한 뒤에만 실습 완료로 확인합니다.

숫자를 문장으로 옮길 때 — 단정 금지

가상의 상황을 하나 들어 보겠습니다. 집계표를 보니 3월 건수가 유난히 높습니다. 보고서에 한 줄 쓰려는데 "3월에 위험이 늘었다"고 적고 싶은 유혹이 듭니다. 하지만 표가 말해주는 것은 "3월에 건수가 많았다"까지입니다. 왜 많았는지(계절 요인인지, 입력 방식이 바뀐 건지, 특정 사건 때문인지)는 표 밖의 이야기입니다. 표에 없는 것을 문장에 넣는 순간, 집계는 근거 없는 주장으로 바뀝니다.

집계 결과를 문장으로 옮길 때, AI에게 표에 없는 원인·평가를 만들지 말라고 못박습니다.

TEXT
아래 표는 합성 또는 승인된 집계표입니다.
표에 없는 원인, 평가, 책임 소재를 만들지 않도록 요청합니다.
각 문장 끝에 검산할 숫자 또는 확인할 원문 항목을 붙여 달라고 요청합니다.
공식 판단처럼 보이는 표현은 "추가 확인 필요"로 바꿔 달라고 요청합니다.

경계는 분명합니다. "합성 데이터 기준 3월 건수가 다른 월보다 많습니다"는 괜찮지만 "3월에 실제 위험이 증가했습니다"는 안 됩니다. "해당 집계 단위에서 건수가 높게 나타납니다"는 괜찮지만 "위험지역입니다"는 안 됩니다.

허용되는 표현과 넘어선 표현을 나란히 두면 경계가 또렷해집니다(아래 문장은 합성 데이터 기준 예시입니다).

표가 말하는 것(허용)표를 넘어선 것(금지)
합성 데이터 기준 3월 건수가 다른 월보다 많습니다3월에 실제 위험이 증가했습니다
해당 집계 단위에서 건수가 높게 나타납니다이 지역은 위험지역입니다
야간 시간대 건수가 주간보다 많습니다야간 근무 인력이 부족합니다

왼쪽은 표를 그대로 읽은 것이고, 오른쪽은 원인·평가·대책을 덧붙인 것입니다. 오른쪽을 쓰려면 표가 아니라 별도의 근거가 있어야 합니다. 이 경계를 제출 직전에 한 번 더 점검하는 절차는 AI 결과, 제출 전 1분 점검에 한 장으로 정리돼 있습니다.

사람 검토 체크리스트

TEXT
- [ ] 개인정보 열을 제거한 뒤 실행했습니다.
- [ ] 검산 출력이 입력 행 수와 일치합니다.
- [ ] 튀는 수치는 원자료에서 다시 확인했습니다.
- [ ] 문장으로 옮길 때 표에 없는 원인·평가를 넣지 않았습니다.
- [ ] 보고서에 넣을 때 합성 데이터와 실데이터를 혼동하지 않도록 표시했습니다.
⚠️ 흔한 실수
동봉 예제 데이터는 합성 데이터입니다 — 현실 통계가 아닙니다. 실제 데이터로 돌릴 때는 개인정보 열을 먼저 제거하세요.

문제 해결

증상가능한 원인처방확인
날짜 변환에서 실행이 중단됨기본 변환이 읽을 수 없는 날짜 문자열을 만남오류가 가리킨 원본 행을 확인하고 수정·제외 승인 후 처음부터 재실행정비 사유를 기록하고 재실행한 전체 행 수와 집계 합을 확인
월별 합은 맞는데 유형별 합이 작음유형 열의 결측을 count()로 세었음행 수 질문이면 size()를 쓰고 결측 유형을 별도 표시결측 포함 여부를 맞춘 두 합을 다시 비교
같은 지역이 여러 줄로 갈라짐공백·약칭·표기 차이승인된 매핑표로 정규화하고 원본 열을 보존정규화 전후 고유값 목록과 행 수를 대조
사건 수가 예상보다 많음incident_id가 여러 행에 존재중복이 오류인지 세부 기록 구조인지 먼저 확인행 수와 고유 ID 수를 함께 보고 차이 사유 기록
지역 TOP10 합이 전체보다 작음결측이 기본 제외되고 10위 밖 지역이 잘림결측 수와 dropna=False 전체 분포를 먼저 산출전체 분포 합이 입력 행 수와 같은지 확인

자주 묻는 질문

Q. 검산 합이 맞으면 보고서 숫자를 믿어도 되나요?

집계 과정에서 행이 빠지지 않았다는 근거는 됩니다. 원자료의 중복·오분류·잘못된 일시까지 맞다는 뜻은 아니므로 고유 ID, 결측, 이상값 검사가 함께 필요합니다.

Q. size()count() 중 무엇을 써야 하나요?

행 자체를 건수로 정의하면 size()가 직접적입니다. 특정 값이 기록된 행만 세려면 count()를 쓸 수 있지만, 결측을 제외한다는 점을 결과 설명에 남겨야 합니다.

Q. 지역 TOP10만 보고해도 되나요?

상위 목록은 나머지 지역을 감춥니다. 전체 지역 수와 제외된 결측 건수를 함께 제시하고, TOP10이 전체를 대표한다는 표현은 별도 근거 없이 쓰지 않습니다.

Q. 집계표를 AI에 바로 붙여넣어 문장을 만들어도 되나요?

승인되지 않은 원자료나 개인정보 열은 넣지 않습니다. 승인된 집계표라도 표에 없는 원인·책임·대책을 만들지 않도록 제한하고 사람이 원표와 대조합니다.

핵심 정리

  • 집계 전에 날짜형·범주 표기·고유 ID의 의미를 먼저 고정합니다.
  • 질문의 축과 결측 처리에 따라 value_counts, groupby, pivot_table을 선택합니다.
  • 행 수 합 검산과 중복·결측·오분류 검사는 서로 다른 품질 관문입니다.
  • 집계표가 보여 주는 차이와 원인·운영 판단을 분리해 보고합니다.

다음 단계