설문조사가 끝나면 흔히 엑셀 파일을 열어 평균부터 계산한다.
그러나 설문 원자료에는 다음과 같은 문제가 자주 섞여 있다.
- 응답자 번호가 비어 있다.
- 같은 사람이 설문을 두 번 제출했다.
- 나이가
29,29세,미응답처럼 입력되어 있다. - 성별이
여,여성,F로 나뉘어 있다. - 만족도 1~5점 문항에 6이 입력되어 있다.
미응답과해당 없음이 같은 결측값으로 처리되어 있다.- 복수응답 문항이
웹;앱,웹 / 앱,웹, 앱으로 저장되어 있다. - 변수명이 길고 공백과 특수문자를 포함한다.
이 상태에서 평균과 빈도를 바로 계산하면 R 코드는 실행되더라도 분석단위, 분모와 범주가 잘못될 수 있다.
스프레드시트는 자료를 입력하고 확인하는 데 편리하지만, 분석과 변경이력 관리는 별도의 재현 가능한 코드에서 수행하는 편이 안전하다(Broman & Woo, 2018). 데이터 정제도 잘못된 값을 임의로 수정하는 작업이 아니라 문제를 탐지하고, 원인을 진단하고, 명시적인 규칙으로 편집하는 과정이다(Van den Broeck et al., 2005).
이번 글에서는 가상의 설문 엑셀 파일을 직접 만들고 다음 과정을 R로 수행한다.
\[
\text{엑셀 원자료}
\rightarrow
\text{안전한 불러오기}
\rightarrow
\text{오류 탐지}
\rightarrow
\text{표준화}
\rightarrow
\text{중복 처리}
\rightarrow
\text{복수응답 분리}
\rightarrow
\text{검증}
\rightarrow
\text{분석용 자료 저장}
\]
핵심은 다음과 같다.
설문 원자료를 정리한다는 것은 보기 좋게 고치는 일이 아니라, 한 행과 한 열과 한 값이 무엇을 의미하는지 명확하게 만드는 일입니다.
분석 가능한 설문자료는 어떤 구조여야 할까
일반적인 응답자 단위 설문자료에서는 다음 구조가 적절하다.
\[
\text{한 행}
=
\text{응답자 한 명}
\]
\[
\text{한 열}
=
\text{변수 한 개}
\]
\[
\text{한 셀}
=
\text{값 한 개}
\]
예를 들면 다음과 같다.
| respondent_id | age | gender | satisfaction |
|---|---|---|---|
| R001 | 34 | 여성 | 5 |
| R002 | 29 | 남성 | 4 |
| R003 | 41 | 남성 | 3 |
이러한 구조는 tidy data의 기본 원칙과 연결된다(Wickham, 2014).
그러나 복수응답 문항은 한 셀에 여러 값이 들어갈 수 있다.
| respondent_id | used_channels |
|---|---|
| R001 | 웹;앱 |
| R002 | 앱;매장 |
이 값을 그대로 두면 채널별 응답자 수를 계산하기 어렵다.
복수응답은 별도의 긴 형식 자료로 분리하는 편이 좋다.
| respondent_id | channel |
|---|---|
| R001 | 웹 |
| R001 | 앱 |
| R002 | 앱 |
| R002 | 매장 |
따라서 설문 프로젝트에서는 최소한 다음 두 자료를 구분할 수 있다.
- 응답자 자료: 응답자 한 명당 한 행
- 복수응답 자료: 응답자와 선택항목의 조합당 한 행
두 자료를 무리하게 하나의 표로 만들면 응답자가 중복되어 평균과 빈도가 부풀 수 있다.
재현 가능한 가상 엑셀 파일 만들기
다음 예제는 실제 조사자료가 아니라 정제과정을 설명하기 위해 만든 가상자료다.
필요한 패키지를 설치하지 않았다면 최초 한 번만 설치한다.
# install.packages(
# c(
# "dplyr",
# "tidyr",
# "stringr",
# "readr",
# "readxl",
# "writexl",
# "janitor",
# "lubridate",
# "tibble",
# "here"
# )
# )R패키지를 불러온다.
library(
dplyr
)
library(
tidyr
)
library(
stringr
)
library(
readr
)
library(
readxl
)
library(
writexl
)
library(
janitor
)
library(
lubridate
)
library(
tibble
)
library(
here
)R가상 설문자료를 만든다.
survey_demo <- tibble::tribble(
~`Respondent ID`,
~`Submitted At`,
~`Age (years)`,
~`Gender`,
~`Region`,
~`Satisfaction (1-5)`,
~`Recommend?`,
~`Used Channels (multi)`,
~`Open Comment`,
"R001",
"2026-07-01 09:10",
"34",
"여성",
"서울",
"5 매우만족",
"예",
"웹;앱",
"배송이 빨랐어요",
"R002",
"2026-07-01 09:14",
"29세",
"M",
"서울시",
"4",
"Y",
"앱, 매장",
" 만족합니다 ",
"R003",
"2026-07-01 09:20",
"41",
"남성",
"부산광역시",
"3 보통",
"아니오",
"웹 / 전화",
NA_character_,
"R004",
"2026-07-01 09:25",
"미응답",
"F",
"부산",
"2",
"N",
"해당 없음",
" ",
"R005",
"2026-07-01 09:31",
"132",
"여",
"경기도",
"6",
"모름",
"앱",
"문항이 어려웠음",
"R006",
"2026-07-01 10:00",
"38",
"남",
"대전",
"4",
"예",
"웹",
"첫 제출",
"R006",
"2026-07-01 10:12",
"38",
"남성",
"대전시",
"5",
"네",
"웹;앱",
"수정 제출",
NA_character_,
"2026-07-01 10:20",
"52",
"여성",
"서울",
"4",
"예",
"매장",
"응답자 번호 누락",
"R007",
"2026-07-01 10:28",
"17",
"여성",
"서울",
"5",
"예",
"앱",
NA_character_,
"R008",
"2026-07-01 10:35",
"46",
"기타",
"경기",
"1 매우불만족",
"잘 모르겠다",
"전화",
NA_character_,
"R009",
"2026-07-01 10:42",
"31",
"응답하지 않음",
"서울",
"미응답",
"예",
"웹|앱",
NA_character_,
"R010",
"2026-07-01 10:48",
"27",
"male",
"부산",
"3",
"아니오",
"웹;매장",
NA_character_,
"R011",
"2026-07-01 10:53",
"59",
"female",
"대전",
"4",
"예",
"앱",
NA_character_,
"R012",
"2026-07-01 11:01",
"39",
"알수없음",
"서울",
"2",
"모름",
"기타채널",
NA_character_
)R자료사전도 만든다.
codebook_demo <- tibble::tribble(
~variable,
~description,
~allowed_values,
"Respondent ID",
"응답자 고유번호",
"비어 있지 않은 고유 문자열",
"Submitted At",
"응답 제출시각",
"YYYY-MM-DD HH:MM",
"Age (years)",
"만 나이",
"18~100 또는 미응답",
"Gender",
"성별",
"남성, 여성, 기타, 응답하지 않음",
"Region",
"거주지역",
"서울, 경기, 부산, 대전",
"Satisfaction (1-5)",
"전반적 만족도",
"1~5 또는 미응답",
"Recommend?",
"추천 의향",
"예, 아니오, 잘 모르겠다",
"Used Channels (multi)",
"이용 경험 채널",
"웹, 앱, 매장, 전화, 기타, 해당 없음",
"Open Comment",
"자유의견",
"자유 텍스트"
)R원자료 폴더를 만든다.
raw_directory <- here::here(
"data",
"raw"
)
dir.create(
raw_directory,
recursive = TRUE,
showWarnings = FALSE
)R가상 엑셀 파일을 저장한다.
survey_path <- here::here(
"data",
"raw",
"survey_demo.xlsx"
)
if (!file.exists(
survey_path
)) {
writexl::write_xlsx(
list(
survey_raw =
survey_demo,
codebook =
codebook_demo
),
path =
survey_path
)
}R실제 프로젝트에서는 전달받은 원자료를 코드로 덮어쓰지 않아야 한다. 위 코드는 예제파일을 재현하기 위해 파일이 없을 때만 생성한다.
엑셀 원자료는 먼저 보존해서 불러오세요
통합문서의 시트 이름을 확인한다.
readxl::excel_sheets(
survey_path
)R이번 통합문서에는 다음 두 시트가 있다.
- survey_raw
- codebook
설문 원자료를 불러온다.
survey_raw <- readxl::read_excel(
path =
survey_path,
sheet =
"survey_raw",
col_types =
"text",
na = c(
"",
"NA",
"N/A"
),
trim_ws =
TRUE
) |>
janitor::clean_names() |>
dplyr::mutate(
raw_row_number =
dplyr::row_number() +
1L
)
survey_rawR이번 예제에서는 혼합된 입력을 최대한 보존하기 위해 모든 열을 문자형으로 불러왔다.
예를 들어 나이 열에 숫자, 29세, 미응답이 함께 들어 있다. R이 일부 값을 자동으로 숫자로 변환하게 두기보다 원문을 보존한 뒤 명시적인 규칙으로 변환하는 방식이다.
자료형이 안정적으로 관리되는 실제 파일에서는 다음처럼 열별 자료형을 직접 지정할 수 있다.
# readxl::read_excel(
# survey_path,
# col_types = c(
# "text",
# "date",
# "numeric",
# "text",
# "text",
# "numeric",
# "text",
# "text",
# "text"
# )
# )R자동 자료형 추론은 편리하지만 앞부분만 보고 열의 형식을 판단할 수 있다. 뒤쪽에 다른 형식이 등장하면 값이 결측으로 바뀌거나 예상하지 못한 자료형이 만들어질 수 있다.
원자료 객체는 수정하지 않고 survey_raw로 보존한다.
정제된 값은 별도의 객체에 만든다.
\[
\text{원자료}
\neq
\text{정제자료}
\]
정제규칙이 바뀌더라도 원자료부터 다시 실행할 수 있어야 한다(Sandve et al., 2013; Wilson et al., 2017).
문자, 숫자와 날짜를 명시적인 규칙으로 정리하기
먼저 공백을 정리하는 함수를 만든다.
normalize_text <- function(
x
) {
x |>
stringr::str_replace_all(
"\u00A0",
" "
) |>
stringr::str_squish() |>
dplyr::na_if(
""
)
}R이 함수는 일반 공백과 줄어들지 않는 공백을 정리하고, 빈 문자열은 결측값으로 바꾼다.
원자료를 파싱하고 범주를 표준화한다.
survey_parsed <- survey_raw |>
dplyr::mutate(
dplyr::across(
c(
respondent_id,
submitted_at,
age_years,
gender,
region,
satisfaction_1_5,
recommend,
used_channels_multi,
open_comment
),
normalize_text
),
submitted_at_value =
lubridate::ymd_hm(
submitted_at,
quiet = TRUE,
tz = "Asia/Seoul"
),
age_value =
readr::parse_number(
age_years,
na = c(
"미응답",
"무응답"
)
),
satisfaction_value =
readr::parse_number(
satisfaction_1_5,
na = c(
"미응답",
"무응답"
)
),
gender_key =
stringr::str_to_lower(
gender
),
region_key =
stringr::str_to_lower(
region
),
recommend_key =
stringr::str_to_lower(
recommend
),
gender_standard = dplyr::case_when(
gender_key %in%
c(
"여",
"여성",
"f",
"female"
) ~
"여성",
gender_key %in%
c(
"남",
"남성",
"m",
"male"
) ~
"남성",
gender_key ==
"기타" ~
"기타",
gender_key %in%
c(
"응답하지 않음",
"무응답"
) ~
"응답하지 않음",
.default =
NA_character_
),
region_standard = dplyr::case_when(
region_key %in%
c(
"서울",
"서울시",
"서울특별시"
) ~
"서울",
region_key %in%
c(
"경기",
"경기도"
) ~
"경기",
region_key %in%
c(
"부산",
"부산시",
"부산광역시"
) ~
"부산",
region_key %in%
c(
"대전",
"대전시",
"대전광역시"
) ~
"대전",
.default =
NA_character_
),
recommend_standard =
dplyr::case_when(
recommend_key %in%
c(
"예",
"네",
"y",
"yes"
) ~
"예",
recommend_key %in%
c(
"아니오",
"아니요",
"n",
"no"
) ~
"아니오",
recommend_key %in%
c(
"모름",
"잘 모르겠다"
) ~
"잘 모르겠다",
.default =
NA_character_
)
)R나이와 만족도의 허용범위를 검사한다.
survey_parsed <- survey_parsed |>
dplyr::mutate(
age_invalid = (
!is.na(
age_value
) &
(
age_value <
18 |
age_value >
100
)
),
satisfaction_invalid = (
!is.na(
satisfaction_value
) &
!satisfaction_value %in%
1:5
),
submitted_at_invalid =
is.na(
submitted_at_value
),
gender_unrecognized = (
!is.na(
gender
) &
is.na(
gender_standard
)
),
region_unrecognized = (
!is.na(
region
) &
is.na(
region_standard
)
),
recommend_unrecognized = (
!is.na(
recommend
) &
is.na(
recommend_standard
)
)
)R중요한 점은 잘못된 값을 즉시 삭제하지 않았다는 것이다.
- 원래 값
- 표준화한 값
- 오류 여부
세 정보를 함께 보존한다.
중복, 결측과 범위 오류를 먼저 기록하세요
데이터 정제는 오류를 숨기는 작업이 아니다.
무엇이 문제였는지 별도의 감사자료로 남겨야 한다.
응답자 번호 결측을 기록한다.
missing_id_issues <- survey_parsed |>
dplyr::filter(
is.na(
respondent_id
)
) |>
dplyr::transmute(
raw_row_number,
respondent_id,
issue =
"응답자 번호 결측",
original_value =
NA_character_
)R중복 응답자 번호를 기록한다.
duplicate_id_issues <- survey_parsed |>
dplyr::add_count(
respondent_id,
name =
"response_count"
) |>
dplyr::filter(
!is.na(
respondent_id
),
response_count >
1L
) |>
dplyr::transmute(
raw_row_number,
respondent_id,
issue =
"응답자 번호 중복",
original_value =
paste0(
"응답 수: ",
response_count
)
)R범위와 범주 오류를 기록한다.
value_issues <- dplyr::bind_rows(
survey_parsed |>
dplyr::filter(
age_invalid
) |>
dplyr::transmute(
raw_row_number,
respondent_id,
issue =
"나이 허용범위 이탈",
original_value =
age_years
),
survey_parsed |>
dplyr::filter(
satisfaction_invalid
) |>
dplyr::transmute(
raw_row_number,
respondent_id,
issue =
"만족도 허용범위 이탈",
original_value =
satisfaction_1_5
),
survey_parsed |>
dplyr::filter(
submitted_at_invalid
) |>
dplyr::transmute(
raw_row_number,
respondent_id,
issue =
"제출시각 누락 또는 변환 실패",
original_value =
submitted_at
),
survey_parsed |>
dplyr::filter(
gender_unrecognized
) |>
dplyr::transmute(
raw_row_number,
respondent_id,
issue =
"미등록 성별 범주",
original_value =
gender
),
survey_parsed |>
dplyr::filter(
region_unrecognized
) |>
dplyr::transmute(
raw_row_number,
respondent_id,
issue =
"미등록 지역 범주",
original_value =
region
),
survey_parsed |>
dplyr::filter(
recommend_unrecognized
) |>
dplyr::transmute(
raw_row_number,
respondent_id,
issue =
"미등록 추천의향 범주",
original_value =
recommend
)
)R전체 오류목록을 만든다.
quality_issues <- dplyr::bind_rows(
missing_id_issues,
duplicate_id_issues,
value_issues
) |>
dplyr::arrange(
raw_row_number,
issue
)
quality_issuesR오류목록에는 같은 행이 여러 번 나타날 수 있다.
한 응답에서 나이와 만족도가 모두 잘못되었다면 두 개의 문제로 기록되는 것이 정상이다.
자료 정제에서는 다음 세 상태를 구분해야 한다.
| 상태 | 사례 | 처리 |
|---|---|---|
| 유효한 값 | 만족도 4 | 그대로 사용 |
| 응답 결측 | 미응답 | NA로 보존 |
| 유효하지 않은 값 | 만족도 6 | 오류 기록 후 NA 처리 |
미응답을 0점으로 바꾸면 안 된다.
\[
\text{미응답}
\neq
0점
\]
해당 없음도 단순 결측과 다르다.
예를 들어 이용 채널이 해당 없음인 고객은 문항을 빠뜨린 것이 아니라 어떤 채널도 이용하지 않았다고 응답한 것이다. 결측 메커니즘과 구조를 구분해야 한다(Tierney & Cook, 2023).
중복응답은 distinct로 지우지 마세요
다음 코드는 중복 응답을 간단히 제거한다.
# survey_raw |>
# distinct(
# respondent_id,
# .keep_all = TRUE
# )R그러나 어떤 응답이 남았는지 알기 어렵다.
첫 번째 응답을 사용할지, 마지막 응답을 사용할지, 가장 완전한 응답을 사용할지는 조사 운영규칙으로 결정해야 한다.
이번 가상 프로젝트에서는 다음 규칙을 사용한다.
동일한 응답자 번호가 여러 번 제출되었다면 가장 최근 제출본을 사용한다.
이 규칙을 적용하기 전에 제출시각을 확인한다.
if (any(
survey_parsed$submitted_at_invalid &
!is.na(
survey_parsed$respondent_id
)
)) {
stop(
"제출시각이 없는 응답은 중복 처리 전에 검토해야 합니다."
)
}R잘못된 숫자는 오류기록 후 결측으로 바꾼다.
survey_standardized <- survey_parsed |>
dplyr::mutate(
age = dplyr::if_else(
age_invalid,
NA_real_,
age_value
),
satisfaction_score =
dplyr::if_else(
satisfaction_invalid,
NA_real_,
satisfaction_value
)
)R중복된 원자료는 별도로 보존한다.
duplicate_review <- survey_standardized |>
dplyr::add_count(
respondent_id,
name =
"response_count"
) |>
dplyr::filter(
!is.na(
respondent_id
),
response_count >
1L
) |>
dplyr::arrange(
respondent_id,
submitted_at_value,
raw_row_number
)
duplicate_reviewR응답자 번호가 없는 행은 분석용 자료에서 제외하되 감사자료에는 남긴다.
excluded_missing_id <- survey_standardized |>
dplyr::filter(
is.na(
respondent_id
)
)R가장 최근 제출본을 선택한다.
survey_latest <- survey_standardized |>
dplyr::filter(
!is.na(
respondent_id
)
) |>
dplyr::arrange(
respondent_id,
submitted_at_value,
raw_row_number
) |>
dplyr::group_by(
respondent_id
) |>
dplyr::slice_tail(
n = 1
) |>
dplyr::ungroup()R최종 응답자 자료를 만든다.
survey_clean <- survey_latest |>
dplyr::transmute(
respondent_id,
submitted_at =
submitted_at_value,
source_excel_row =
raw_row_number,
age,
gender = factor(
gender_standard,
levels = c(
"여성",
"남성",
"기타",
"응답하지 않음"
)
),
region = factor(
region_standard,
levels = c(
"서울",
"경기",
"부산",
"대전"
)
),
satisfaction_score =
as.integer(
satisfaction_score
),
satisfaction_label =
factor(
satisfaction_score,
levels = 1:5,
labels = c(
"매우 불만족",
"불만족",
"보통",
"만족",
"매우 만족"
),
ordered = TRUE
),
recommend = factor(
recommend_standard,
levels = c(
"예",
"아니오",
"잘 모르겠다"
)
),
used_channels_raw =
used_channels_multi,
no_channel = dplyr::coalesce(
used_channels_multi ==
"해당 없음",
FALSE
),
open_comment
)
survey_cleanRsource_excel_row를 남긴 이유는 정제자료의 각 응답이 원래 엑셀의 어느 행에서 왔는지 추적하기 위해서다.
복수응답은 별도의 긴 자료로 분리하세요
이용 채널 문항에는 여러 구분자가 섞여 있다.
웹;앱앱, 매장웹 / 전화웹|앱
먼저 구분자를 세미콜론으로 통일한다.
channel_long <- survey_clean |>
dplyr::transmute(
respondent_id,
channel_text =
used_channels_raw
) |>
dplyr::mutate(
channel_text =
stringr::str_replace_all(
channel_text,
"[,;/|]+",
";"
)
) |>
tidyr::separate_longer_delim(
channel_text,
delim = ";"
) |>
dplyr::mutate(
channel_text =
normalize_text(
channel_text
),
channel_key =
stringr::str_to_lower(
channel_text
),
channel = dplyr::case_when(
channel_key %in%
c(
"웹",
"웹사이트"
) ~
"웹",
channel_key %in%
c(
"앱",
"모바일 앱"
) ~
"앱",
channel_key %in%
c(
"매장",
"오프라인 매장"
) ~
"매장",
channel_key %in%
c(
"전화",
"콜센터"
) ~
"전화",
channel_key %in%
c(
"기타",
"기타채널"
) ~
"기타",
channel_key ==
"해당 없음" ~
NA_character_,
.default =
channel_text
)
) |>
dplyr::filter(
!is.na(
channel
)
) |>
dplyr::distinct(
respondent_id,
channel
)
channel_longR채널별 응답자 수를 계산한다.
channel_summary <- channel_long |>
dplyr::count(
channel,
name =
"respondent_count",
sort =
TRUE
)
channel_summaryR복수응답 비율의 분모는 분석목적에 따라 달라진다.
전체 응답자 대비 선택률
\[
\frac{
\text{해당 채널 선택 응답자 수}
}{
\text{전체 유효 응답자 수}
}
\]
채널 이용 경험자 대비 선택률
\[
\frac{
\text{해당 채널 선택 응답자 수}
}{
\text{하나 이상의 채널을 선택한 응답자 수}
}
\]
복수응답에서는 채널별 비율의 합이 100%를 넘을 수 있다.
한 사람이 여러 채널을 선택할 수 있기 때문이다.
리커트 점수와 범주 라벨을 함께 보존하세요
만족도는 숫자형 점수와 순서형 범주라는 두 가지 역할을 가진다.
이번 정제자료에는 두 변수를 함께 만들었다.
- satisfaction_score: 1~5의 정수
- satisfaction_label: 순서가 있는 범주형 변수
빈도표를 만들 때는 라벨이 편리하다.
satisfaction_frequency <- survey_clean |>
dplyr::count(
satisfaction_label,
.drop = FALSE,
name =
"respondent_count"
)
satisfaction_frequencyR평균을 계산할 때는 점수변수를 사용한다.
satisfaction_summary <- survey_clean |>
dplyr::summarise(
valid_count = sum(
!is.na(
satisfaction_score
)
),
missing_count = sum(
is.na(
satisfaction_score
)
),
mean_score = mean(
satisfaction_score,
na.rm = TRUE
),
median_score = median(
satisfaction_score,
na.rm = TRUE
)
)
satisfaction_summaryRordered factor를 바로 숫자로 변환하면 내부 범주번호가 반환될 수 있다.
다음 코드는 원래 설문점수와 우연히 같아 보일 수 있지만 안전한 분석관행은 아니다.
# as.numeric(
# survey_clean$satisfaction_label
# )R분석에는 원래 숫자형 점수변수를 사용하고, 그래프와 표에는 라벨변수를 사용하는 편이 명확하다.
또한 만족도 평균의 통계적 의미와 리커트 척도의 측정수준 문제는 데이터 정제와 별도의 Statistics 주제다. DataScience 단계에서는 값의 범위, 결측과 범주순서를 정확하게 구현하는 데 집중한다.
정제 전후의 행 수와 값 범위를 검증하세요
응답자 번호의 고유성을 확인한다.
stopifnot(
anyDuplicated(
survey_clean$respondent_id
) ==
0L
)R유효한 응답자 번호 수와 최종 행 수를 비교한다.
expected_respondent_count <-
dplyr::n_distinct(
survey_standardized$respondent_id,
na.rm = TRUE
)
stopifnot(
nrow(
survey_clean
) ==
expected_respondent_count
)R나이범위를 확인한다.
stopifnot(
all(
is.na(
survey_clean$age
) |
(
survey_clean$age >=
18 &
survey_clean$age <=
100
)
)
)R만족도 범위를 확인한다.
stopifnot(
all(
is.na(
survey_clean$satisfaction_score
) |
survey_clean$satisfaction_score %in%
1:5
)
)R복수응답의 응답자와 채널 조합이 고유한지 확인한다.
stopifnot(
anyDuplicated(
channel_long[
c(
"respondent_id",
"channel"
)
]
) ==
0L
)R복수응답 자료의 모든 응답자가 주 자료에 존재하는지 확인한다.
stopifnot(
all(
channel_long$respondent_id %in%
survey_clean$respondent_id
)
)R중복으로 제거된 추가 행 수를 계산한다.
duplicate_extra_rows <-
survey_standardized |>
dplyr::filter(
!is.na(
respondent_id
)
) |>
dplyr::count(
respondent_id
) |>
dplyr::summarise(
extra_rows = sum(
pmax(
n -
1L,
0L
)
)
) |>
dplyr::pull(
extra_rows
)R정제 요약표를 만든다.
quality_summary <- tibble::tibble(
check = c(
"불러온 원자료 행 수",
"응답자 번호 결측 행 수",
"중복으로 제외된 추가 행 수",
"유효범위 밖 나이",
"유효범위 밖 만족도",
"미등록 성별 범주",
"미등록 지역 범주",
"최종 분석 응답자 수"
),
count = c(
nrow(
survey_raw
),
sum(
is.na(
survey_parsed$respondent_id
)
),
duplicate_extra_rows,
sum(
survey_parsed$age_invalid
),
sum(
survey_parsed$satisfaction_invalid
),
sum(
survey_parsed$gender_unrecognized
),
sum(
survey_parsed$region_unrecognized
),
nrow(
survey_clean
)
)
)
quality_summaryR좋은 정제보고서는 최종 응답자 수만 제시하지 않는다.
\[
\text{원자료 행 수}
–
\text{응답자 번호 누락}
–
\text{중복 추가 제출}
=
\text{최종 응답자 수}
\]
이 흐름이 설명되어야 한다.
분석자료와 오류기록을 분리해서 저장하세요
처리자료와 감사자료 폴더를 만든다.
processed_directory <- here::here(
"data",
"processed"
)
audit_directory <- here::here(
"output",
"audit"
)
dir.create(
processed_directory,
recursive = TRUE,
showWarnings = FALSE
)
dir.create(
audit_directory,
recursive = TRUE,
showWarnings = FALSE
)R응답자 자료를 CSV로 저장한다.
survey_csv <- here::here(
"data",
"processed",
"survey_clean.csv"
)
readr::write_csv(
survey_clean,
survey_csv
)R복수응답 자료를 저장한다.
channel_csv <- here::here(
"data",
"processed",
"survey_channel_long.csv"
)
readr::write_csv(
channel_long,
channel_csv
)RR의 자료형을 보존하는 RDS 파일도 저장한다.
survey_rds <- here::here(
"data",
"processed",
"survey_clean.rds"
)
saveRDS(
survey_clean,
survey_rds
)RCSV는 다른 프로그램과 공유하기 쉽다.
RDS는 다음 자료형을 보존한다.
- 날짜와 시간
- factor
- ordered factor
- 객체 속성
오류목록과 정제 요약표를 저장한다.
readr::write_csv(
quality_issues,
here::here(
"output",
"audit",
"survey_quality_issues.csv"
)
)
readr::write_csv(
quality_summary,
here::here(
"output",
"audit",
"survey_quality_summary.csv"
)
)R중복 검토자료도 저장한다.
readr::write_csv(
duplicate_review,
here::here(
"output",
"audit",
"survey_duplicate_review.csv"
)
)R실행환경을 기록한다.
writeLines(
capture.output(
sessionInfo()
),
here::here(
"output",
"audit",
"session_info.txt"
)
)R최종 출력파일이 생성되었는지 확인한다.
expected_files <- c(
survey_csv,
channel_csv,
survey_rds,
here::here(
"output",
"audit",
"survey_quality_issues.csv"
),
here::here(
"output",
"audit",
"survey_quality_summary.csv"
)
)
stopifnot(
all(
file.exists(
expected_files
)
)
)R이 구조에서는 최종 분석자료뿐 아니라 어떤 행이 왜 제외되거나 수정되었는지도 추적할 수 있다. 코드, 자료사전, 정제규칙과 실행환경을 함께 보존하는 것이 재현성에 중요하다(Marwick et al., 2018; Krafczyk et al., 2021).
설문 엑셀 정리에서 자주 발생하는 오류
엑셀 파일을 직접 수정한 뒤 원본에 덮어쓴다
어떤 값이 언제 바뀌었는지 확인할 수 없다. 원자료는 그대로 보존하고 정제는 코드로 수행해야 한다.
응답자 번호 없이 행 번호를 고객번호처럼 사용한다
정렬과 행 삭제 후 번호가 바뀔 수 있다. 안정적인 고유 식별자가 필요하다.
중복응답을 distinct로 무조건 제거한다
첫 번째와 마지막 응답 가운데 어떤 값이 남았는지 알 수 없다.
미응답을 0으로 바꾼다
0이 실제 척도값이라면 결측과 실제 응답을 구분할 수 없다.
해당 없음을 결측과 동일하게 처리한다
문항을 건너뛴 것과 적용대상이 아닌 상태는 다르다.
만족도 6을 그대로 평균에 포함한다
R은 숫자범위를 자동으로 알지 못한다. 자료사전에 따른 검증이 필요하다.
성별과 지역의 표기 차이를 서로 다른 범주로 집계한다
여, 여성, F가 별도 범주가 될 수 있다.
복수응답을 한 셀에 둔 채 부분문자열로 집계한다
예상하지 못한 문자열이 포함되거나 중복선택이 계산될 수 있다.
복수응답 자료를 주 자료와 결합해 응답자를 중복시킨다
한 응답자가 선택한 항목 수만큼 행이 늘어 평균과 빈도가 부풀 수 있다.
정제된 파일만 보존한다
어떤 원자료와 규칙으로 만들었는지 검증하기 어렵다.
오류값을 NA로 바꾸고 기록하지 않는다
실제 미응답과 데이터 오류가 같은 NA로 합쳐진다.
설문 원자료를 분석하기 전에는 다음을 확인해야 한다.
- 한 행은 무엇을 의미하는가?
- 응답자 고유번호가 있는가?
- 중복응답 처리규칙이 있는가?
- 각 변수의 허용값이 자료사전에 정의되어 있는가?
- 미응답과 해당 없음을 구분했는가?
- 숫자와 날짜 변환 실패를 확인했는가?
- 범주 표기를 표준화했는가?
- 복수응답을 별도 자료로 분리했는가?
- 정제 전후의 행 수를 비교했는가?
- 수정과 제외내역을 별도 파일에 기록했는가?
- 원자료를 덮어쓰지 않았는가?
- 새 R 세션에서 처음부터 다시 실행되는가?
연습문제
첫째, 학력 변수에 대졸, 대학교 졸업, 4년제 졸업이 함께 들어 있다고 가정하고 표준화 규칙을 작성해 본다.
둘째, 응답자별 결측 문항 수를 계산하고 결측 문항이 절반을 넘는 응답자를 별도 검토자료로 분리한다.
셋째, 중복응답에서 가장 최근 제출본 대신 결측이 가장 적은 제출본을 선택하는 규칙을 구현한다. 동률일 때 어떤 응답을 선택할지도 명시한다.
넷째, 원자료 시트와 코드북 시트의 변수명을 비교해 코드북에 없는 변수를 자동으로 찾는 검증코드를 작성한다.
다섯째, 정제된 만족도 자료를 이용해 지역별 유효 응답자 수, 결측률, 중앙값과 응답분포를 계산한다.
설문 정제는 분석 이전의 부수작업이 아닙니다
설문자료를 정리하는 과정은 시간이 많이 들지만 분석과 분리된 단순 작업이 아니다.
어떤 값을 동일한 범주로 볼 것인지, 중복응답 가운데 무엇을 사용할 것인지, 미응답과 해당 없음을 어떻게 구분할 것인지에 따라 분석결과가 달라진다.
비전공자는 다음 다섯 문장으로 기억할 수 있다.
설문 원자료는 직접 수정하지 말고 별도로 보존해야 한다.
응답자 한 명당 한 행이라는 분석단위를 먼저 지켜야 한다.
범주 통합과 숫자변환은 자료사전에 따른 명시적인 코드로 수행해야 한다.
복수응답은 응답자와 선택항목의 긴 형식 자료로 분리해야 한다.
제외·수정된 값과 정제 전후의 행 수를 감사자료로 남겨야 한다.
좋은 설문 정제는 모든 오류를 조용히 없애는 과정이 아니다.
무엇이 잘못되었고, 어떤 규칙으로 바꾸었으며, 최종 분석자료가 원자료의 어느 행에서 왔는지 설명할 수 있게 만드는 과정이다.
\[
\text{문제 탐지}
\rightarrow
\text{원인 확인}
\rightarrow
\text{규칙 적용}
\rightarrow
\text{결과 검증}
\rightarrow
\text{변경 기록}
\]
분석 가능한 설문자료는 깨끗해 보이는 엑셀 파일이 아니라 원자료에서 최종 결과까지의 변환과정을 다시 실행하고 검증할 수 있는 데이터입니다.
참고문헌
Batini, C., & Scannapieco, M. (2016). Data and information quality: Dimensions, principles and techniques. Springer. https://doi.org/10.1007/978-3-319-24106-7
Broman, K. W., & Woo, K. H. (2018). Data organization in spreadsheets. The American Statistician, 72(1), 2–10. https://doi.org/10.1080/00031305.2017.1375989
Firke, S. (n.d.). janitor: Simple tools for examining and cleaning dirty data. https://sfirke.github.io/janitor/
Krafczyk, M. S., Shi, A., Bhaskar, A., Marinov, D., & Stodden, V. (2021). Learning from reproducing computational results: Introducing three principles and the Reproduction Package. Philosophical Transactions of the Royal Society A, 379(2197), 20200069. https://doi.org/10.1098/rsta.2020.0069
Lowndes, J. S. S., Best, B. D., Scarborough, C., Afflerbach, J. C., Frazier, M. R., O’Hara, C. C., Jiang, N., & Halpern, B. S. (2017). Our path to better science in less time using open data science tools. Nature Ecology & Evolution, 1, Article 0160. https://doi.org/10.1038/s41559-017-0160
Marwick, B., Boettiger, C., & Mullen, L. (2018). Packaging data analytical work reproducibly using R and friends. The American Statistician, 72(1), 80–88. https://doi.org/10.1080/00031305.2017.1375986
Müller, K. (n.d.). here: A simpler way to find your files. https://here.r-lib.org/
Ooms, J. (n.d.). writexl: Export data frames to Excel xlsx format. https://docs.ropensci.org/writexl/
Sandve, G. K., Nekrutenko, A., Taylor, J., & Hovig, E. (2013). Ten simple rules for reproducible computational research. PLOS Computational Biology, 9(10), e1003285. https://doi.org/10.1371/journal.pcbi.1003285
Tierney, N. J., & Cook, D. H. (2023). Expanding tidy data principles to facilitate missing data exploration, visualization and assessment of imputations. Journal of Statistical Software, 105(7), 1–31. https://doi.org/10.18637/jss.v105.i07
Van den Broeck, J., Cunningham, S. A., Eeckels, R., & Herbst, K. (2005). Data cleaning: Detecting, diagnosing, and editing data abnormalities. PLOS Medicine, 2(10), e267. https://doi.org/10.1371/journal.pmed.0020267
Wickham, H. (2014). Tidy data. Journal of Statistical Software, 59(10), 1–23. https://doi.org/10.18637/jss.v059.i10
Wickham, H., & Bryan, J. (n.d.). readxl: Read Excel files. https://readxl.tidyverse.org/
Wickham, H., Çetinkaya-Rundel, M., & Grolemund, G. (2023). R for data science: Import, tidy, transform, visualize, and model data (2nd ed.). O’Reilly Media. https://r4ds.hadley.nz/
Wilson, G., Bryan, J., Cranston, K., Kitzes, J., Nederbragt, L., & Teal, T. K. (2017). Good enough practices in scientific computing. PLOS Computational Biology, 13(6), e1005510. https://doi.org/10.1371/journal.pcbi.1005510









