R · 기본
데이터로 시작하는 R
종합 실습 - 판매 데이터 분석 보고서
데이터 생성→정리→집계→그래프→간단한 선형 회귀 lm, 결과 해석 문장 쓰기
개발자KR · 원고 갱신
이 장에서 배우는 것
편의점 판매 기록을 분석하는 일은 평균 하나를 계산하는 것으로 끝나지 않는다. 기록이 믿을 만한지 확인하고, 질문에 맞게 표를 묶고, 그래프와 수치를 근거로 설명을 써야 한다. 이 장에서는 앞 장에서 익힌 그래프 저장 방법을 포함해 지금까지 사용한 도구를 하나의 프로그램으로 연결한다. 마지막에는 판매 수량과 판매 금액의 관계를 간단한 선형 회귀로 살펴보고, 분석 결과와 한계를 짧은 보고서로 남긴다.
예제의 판매 기록은 코드 안에서 만든 가상 자료다. 실제 편의점의 경영 상태를 설명하는 자료가 아니라, 분석 과정을 끝까지 실행하기 위한 자료다. 모든 코드를 main.R에 저장하면 터미널에서 한 번의 명령으로 CSV, 집계표, 그래프, 보고서를 만들 수 있다.
- 판매 기록을 생성하고 CSV로 저장한 뒤 다시 읽어 분석을 시작한다.
- 중복 기록과 분석 조건에 맞지 않는 행을 구분해 정리한다.
- 날짜별·분류별 집계표와 파일로 저장되는 그래프를 만든다.
- lm()으로 선형 회귀를 적합하고 계수와 결정계수를 해석한다.
- 수치가 말해 주는 범위 안에서 결과와 한계를 문장으로 쓴다.
문제 상황
동네 편의점 주인이 최근 판매 기록을 정리하려 한다. 엑셀 표에는 날짜, 상품, 분류, 수량, 금액이 들어 있다. 주인이 알고 싶은 것은 세 가지다. 날짜별 판매 금액이 어떻게 달라졌는지, 어느 분류에서 판매 금액이 많이 발생했는지, 많이 판매한 날에는 금액도 함께 커졌는지다.
그런데 표를 붙여 넣는 과정에서 같은 기록이 두 번 들어갔다. 수량에 음수가 입력된 행도 있고, 금액이 비어 있는 행도 있다. 이런 기록을 그대로 더하면 잘못된 합계가 나온다. 그래프를 먼저 그려도 입력 오류가 사라지지는 않는다. 먼저 분석에 사용할 행을 정하고, 그 다음에 합계와 관계를 살펴봐야 한다.
이 예제에서 한 행은 하루 동안 한 상품이 판매된 결과다. 거래 한 건을 뜻하지 않는다. 같은 날짜와 상품의 기록이 여러 개 있으면 원자료의 중복 여부를 확인해야 한다. 실제 거래별 자료라면 날짜와 상품이 같아도 서로 다른 정상 거래일 수 있으므로, 거래 번호 같은 식별 정보가 필요하다.
금액은 원 단위의 판매 금액이다. 매입 비용이나 임대료는 들어 있지 않다. 따라서 이 자료로 판매 금액은 비교할 수 있지만 이익은 계산할 수 없다. 또한 수량은 서로 다른 상품의 개수를 합친 값이다. 상품 하나의 가격이 모두 같다는 뜻은 아니다.
분석 질문과 정리 기준을 먼저 정한다
분석에 앞서 자료의 단위와 제외 기준을 글로 적는다. 기준이 없으면 결과를 보고 마음에 들지 않는 행을 지우기 쉽다. 여기서는 날짜, 상품, 분류, 수량, 금액이 모두 있어야 하며, 수량은 양의 정수이고 금액은 양수여야 한다고 정한다. 반품과 무료 증정은 이번 분석 범위에 포함하지 않는다는 가정이다.
| 열 | 의미 | 사용 조건 |
|---|---|---|
| date | 판매 날짜 | 날짜로 변환할 수 있다 |
| product | 상품 이름 | 비어 있지 않다 |
| category | 상품 분류 | 비어 있지 않다 |
| quantity | 판매 개수 | 유한한 양의 정수다 |
| amount | 판매 금액 | 유한한 양수다 |
유한한 수는 무한대가 아닌 보통의 수를 뜻한다. is.finite()는 결측값과 무한대를 함께 걸러낼 때 사용할 수 있다. complete.cases()는 지정한 열에 결측값이 없는 행을 찾는다. 두 검사를 함께 사용하면 금액이 비어 있는 행과 계산에 쓸 수 없는 수가 들어 있는 행을 제외할 수 있다.
중복과 조건 위반은 따로 센다. 이번 자료에서는 다섯 열이 모두 같은 행을 중복으로 정의한다. 먼저 duplicated()로 중복을 제거한 다음, 남은 행에서 조건 위반을 찾는다. 따라서 같은 행이 여러 번 들어왔을 때 중복 수와 조건 위반 수를 두 번 세지 않는다.
실제 자료에서 음수 수량은 입력 오류일 수도 있지만 반품일 수도 있다. 이번 코드의 제외 기준을 모든 판매 자료에 그대로 적용해서는 안 된다. 반품을 포함한 순판매 금액을 분석하려면 음수 기록의 의미를 확인하고 별도 기준을 세워야 한다. 정리 기준은 자료가 만들어진 업무와 연결되어야 한다.
제외한 행은 rejected_rows.csv에 따로 저장한다. 이 파일은 버린 자료를 숨기기 위한 파일이 아니라, 어떤 기록이 합계에서 빠졌는지 다시 확인하기 위한 파일이다. 중복 행의 개수는 보고서에 남기고, 조건을 위반한 행의 내용은 별도 CSV에 남긴다.
집계표와 그래프를 같은 자료에서 만든다
집계는 여러 행을 정해진 기준으로 묶어 계산하는 일이다. 날짜별 집계에서는 같은 날짜의 수량과 금액을 더한다. 분류별 집계에서는 음료와 과자의 수량과 금액을 각각 더한다. aggregate()의 수식에서 왼쪽은 계산할 열이고 오른쪽은 묶을 기준이다. cbind()를 사용하면 수량과 금액을 한 번에 집계할 수 있다.
날짜별 집계표는 날짜순으로 정렬한다. 선 그래프는 점을 순서대로 연결하므로 정렬되지 않은 표를 쓰면 날짜가 앞뒤로 오가는 선이 생길 수 있다. 분류별 집계표는 금액이 큰 순서로 정렬한다. 분류 이름의 문자 정렬 순서에 기대지 않으므로 같은 자료에서는 같은 순서가 나온다.
합계가 유지되는지도 확인한다. 정리된 원자료의 금액 합계와 날짜별 집계표의 금액 합계가 같아야 한다. 분류별 집계에서도 마찬가지다. stopifnot()은 조건이 참이 아니면 실행을 멈춘다. 여기서는 분석 대상 행 수, 날짜 수, 집계 전후 합계가 예상대로인지 확인하는 데 사용한다.
그래프는 화면에 띄우지 않고 png()로 파일에 저장한다. png()를 호출하면 이후의 그리기 명령이 지정한 파일로 향한다. 그림을 다 그린 뒤 dev.off()를 호출해야 파일 저장이 마무리된다. 완성 코드에서는 이 과정을 두 번 실행해 날짜별 선 그래프와 분류별 막대그래프를 만든다.
PNG의 제목과 축 이름에는 영문을 사용한다. 한글 글꼴 설치 상태가 서로 다른 macOS와 Linux에서 같은 코드를 실행할 때 글꼴 문제를 줄이기 위한 선택이다. 한국어 해석은 텍스트 보고서에 담는다. 숫자와 문장은 난수를 쓰지 않아 재실행할 때 같고, 그림의 글꼴과 렌더링은 운영체제에 따라 조금 달라질 수 있다.
회귀 결과를 설명 문장으로 바꾼다
선형 회귀(linear regression)는 두 수치의 관계를 직선으로 나타내는 방법이다. 여기서는 하루 판매 수량을 설명변수, 하루 판매 금액을 반응변수로 둔다. 설명변수는 관계를 설명하는 데 사용하는 값이고, 반응변수는 그 관계로 설명하려는 값이다. lm(amount ~ quantity, data = daily)는 날짜별 표에서 이 관계를 적합한다. 적합은 관측한 값에 맞도록 직선의 계수를 구하는 과정이다.
직선은 ‘예측 금액 = 절편 + 기울기 × 수량’으로 읽는다. 기울기는 수량이 1개 많아질 때 직선이 나타내는 금액이 얼마나 달라지는지 보여 준다. 절편은 수량이 0일 때 직선이 나타내는 금액이다. 관측한 수량이 10개부터 20개까지라면 0개는 관측 범위 밖이다. 이때 절편을 실제 매장의 판매 금액으로 해석하면 안 된다.
실제 금액과 직선이 나타내는 금액의 차이를 잔차(residual)라고 한다. 잔차가 0이면 그 관측값은 직선 위에 있다. 결정계수(R-squared)는 관측한 반응변수의 변동 가운데 모형이 설명하는 비율을 나타낸다. 절편을 포함한 이번 모형에서는 0에 가까우면 설명하는 비율이 작고, 1에 가까우면 크다. 새 날짜를 얼마나 잘 예측하는지는 별도 자료로 확인해야 한다.
판매 금액은 상품별 수량과 가격을 곱한 값의 합이다. 수량과 금액 사이에 관계가 나타나는 것은 이런 구조와도 연결된다. 서로 다른 상품을 합친 하루 수량으로 회귀하므로 기울기를 상품 하나의 가격이라고 해석할 수는 없다. 음료와 과자의 판매 비중이 달라지면 같은 총수량에서도 금액이 달라질 수 있다.
이번 자료는 날짜가 여섯 개뿐이고 코드로 만든 값이다. 높은 결정계수가 나와도 매장의 앞으로의 판매를 잘 예측한다고 결론 내릴 수 없다. 보고서에는 관측 범위, 관계의 방향과 크기, 자료의 한계를 함께 쓴다. 회귀를 실행했다는 사실보다 독자가 수치를 어떻게 읽어야 하는지 알려 주는 문장이 중요하다.
완성 코드
다음 내용을 UTF-8로 main.R에 저장한다. R은 이 파일을 읽고 실행하므로 별도의 컴파일 명령은 필요하지 않다. 실행 폴더에는 아래 코드가 사용하는 이름의 결과 파일이 생성되며, 같은 이름의 파일이 있으면 덮어쓴다. 파일을 쓸 수 있는 작업 폴더에서 실행한다. macOS와 Linux의 일반적인 R 4.5 이상 설치 환경을 기준으로 한다.
# 1. 판매 기록을 만든다.
dates <- as.Date("2026-09-01") + 0:5
sales <- data.frame(
date = rep(as.character(dates), each = 2),
product = rep(c("water", "cracker"), times = 6),
category = rep(c("beverage", "snack"), times = 6),
quantity = c(4, 6, 6, 6, 5, 9, 9, 7, 8, 10, 10, 10),
stringsAsFactors = FALSE
)
prices <- c(water = 2000, cracker = 1000)
sales$amount <- sales$quantity * unname(prices[sales$product])
negative_row <- sales[2, ]
negative_row$quantity <- -1
negative_row$amount <- -1000
missing_row <- sales[3, ]
missing_row$amount <- NA_real_
raw <- rbind(sales, sales[1, ], negative_row, missing_row)
# 2. 직접 쓴 CSV를 다시 읽는다.
write.csv(raw, "sales_input.csv", row.names = FALSE,
na = "", fileEncoding = "UTF-8")
raw <- read.csv("sales_input.csv", stringsAsFactors = FALSE,
na.strings = "", fileEncoding = "UTF-8")
raw$date <- as.Date(raw$date, format = "%Y-%m-%d")
# 3. 중복과 조건 위반을 구분한다.
input_n <- nrow(raw)
duplicate <- duplicated(raw)
duplicate_n <- sum(duplicate)
unique_rows <- raw[!duplicate, ]
valid <- complete.cases(unique_rows) &
is.finite(unique_rows$quantity) &
is.finite(unique_rows$amount) &
unique_rows$quantity > 0 &
unique_rows$quantity == floor(unique_rows$quantity) &
unique_rows$amount > 0 &
nzchar(trimws(unique_rows$product)) &
nzchar(trimws(unique_rows$category))
valid[is.na(valid)] <- FALSE
rejected <- unique_rows[!valid, ]
clean <- unique_rows[valid, ]
invalid_n <- nrow(rejected)
write.csv(rejected, "rejected_rows.csv", row.names = FALSE,
na = "", fileEncoding = "UTF-8")
# 4. 날짜별·분류별 표를 만든다.
daily <- aggregate(cbind(quantity, amount) ~ date,
data = clean, FUN = sum)
daily <- daily[order(daily$date), ]
category_totals <- aggregate(cbind(quantity, amount) ~ category,
data = clean, FUN = sum)
category_totals <- category_totals[
order(-category_totals$amount, category_totals$category), ]
stopifnot(
nrow(clean) == 12L,
nrow(daily) == 6L,
sum(clean$quantity) == sum(daily$quantity),
sum(clean$amount) == sum(daily$amount),
sum(clean$amount) == sum(category_totals$amount)
)
write.csv(daily, "daily_summary.csv", row.names = FALSE,
fileEncoding = "UTF-8")
write.csv(category_totals, "category_summary.csv",
row.names = FALSE, fileEncoding = "UTF-8")
# 5. 그래프를 파일로 저장한다.
png("daily_amount.png", width = 960, height = 600)
par(mar = c(5, 5, 4, 2))
plot(daily$date, daily$amount, type = "b", pch = 16,
col = "#315675", xlab = "Date", ylab = "Sales (KRW)",
main = "Daily sales", ylim = c(0, 32000))
dev.off_result <- dev.off()
png("category_amount.png", width = 960, height = 600)
par(mar = c(5, 5, 4, 2))
barplot(category_totals$amount,
names.arg = category_totals$category,
col = "#e1edf7", border = "#315675",
xlab = "Category", ylab = "Sales (KRW)",
main = "Sales by category", ylim = c(0, 90000))
dev.off_result <- dev.off()
# 6. 날짜별 수량과 금액의 관계를 적합한다.
fit <- lm(amount ~ quantity, data = daily)
slope <- unname(coef(fit)["quantity"])
intercept <- unname(coef(fit)["(Intercept)"])
r_squared <- summary(fit)$r.squared
# 7. 수치와 해석을 보고서에 남긴다.
report <- c(
"판매 분석 보고서",
sprintf("입력: %d행 / 중복 제외: %d행 / 조건 위반 제외: %d행",
input_n, duplicate_n, invalid_n),
sprintf("분석: %d행, %d일", nrow(clean), nrow(daily)),
sprintf("총수량: %.0f개 / 총금액: %.0f원",
sum(clean$quantity), sum(clean$amount)),
sprintf("하루 평균 금액: %.0f원", mean(daily$amount)),
sprintf("분류 합계: beverage %.0f원 / snack %.0f원",
category_totals$amount[
category_totals$category == "beverage"],
category_totals$amount[
category_totals$category == "snack"]),
sprintf("회귀식: 금액 = %.2f + %.2f * 수량",
intercept, slope),
sprintf("결정계수: %.3f", r_squared),
sprintf(paste0("해석: 관측한 6일에서 하루 수량이 1개 많을 때 ",
"적합한 금액은 약 %.0f원 높았다."), slope),
"범위: 관측 수량은 하루 10~20개이며, 절편은 실제 판매로 해석하지 않는다.",
"한계: 가상 자료 6일의 관계이며, 인과관계나 미래 예측력을 입증하지 않는다.",
"CSV: sales_input.csv, rejected_rows.csv, daily_summary.csv, category_summary.csv",
"그래프: daily_amount.png, category_amount.png",
"보고서: report.txt"
)
writeLines(enc2utf8(report), "report.txt", useBytes = TRUE)
cat(paste(report, collapse = "\n"), "\n", sep = "")
줄별 해설
첫 부분의 dates는 시작 날짜에 0부터 5까지를 더해 연속된 여섯 날짜를 만든다. rep()의 each = 2는 각 날짜를 두 번씩 배치한다. 상품과 분류는 두 값의 묶음을 여섯 번 반복한다. 따라서 각 날짜에는 생수와 과자 기록이 한 행씩 들어간다. quantity의 값은 코드에 직접 정했으므로 난수를 생성하지 않으며 set.seed()도 필요하지 않다.
prices는 상품 이름을 이름표로 가진 벡터다. prices[sales$product]는 각 행의 상품에 맞는 가격을 가져온다. 여기에 수량을 곱해 금액을 만든다. unname()은 계산에 필요하지 않은 이름표를 제거한다. 이 자료에서 생수는 2,000원, 과자는 1,000원이다.
negative_row와 missing_row는 정리 과정을 확인하기 위해 추가한 행이다. 첫째는 수량과 금액이 음수이고, 둘째는 금액이 결측값이다. rbind()는 정상 기록 12행에 정상 기록의 첫 행을 한 번 더 붙이고, 두 조건 위반 행을 붙인다. 입력 행 수는 15가 된다.
write.csv()의 row.names = FALSE는 R의 행 이름이 별도 열로 저장되는 것을 막는다. na = ""는 결측값을 빈 칸으로 저장한다. 이어지는 read.csv()는 빈 칸을 결측값으로 읽는다. CSV에는 날짜 자료형 자체가 저장되지 않으므로 읽은 뒤 as.Date()로 날짜 열을 다시 변환한다.
duplicate는 중복 행의 위치를 표시하는 논리 벡터다. raw[!duplicate, ]는 중복이 아닌 행만 고른다. valid는 여러 조건을 &로 연결한 논리 벡터다. quantity == floor(quantity)는 소수 부분이 없는지 검사한다. trimws()는 양끝 공백을 제거하고, nzchar()는 남은 문자열에 글자가 있는지 확인한다. 조건 계산에 NA가 남으면 FALSE로 바꿔 분석 대상에서 제외한다.
rejected에는 조건 위반 행이, clean에는 분석에 사용할 행이 들어간다. 원자료 raw를 수정하지 않고 별도 객체를 만들기 때문에 입력과 결과를 비교하기 쉽다. 분석 대상이 확정된 뒤에야 aggregate()로 합계를 계산한다. 이 순서를 지키면 결측 행이 집계 과정에서 조용히 빠지는 일을 정리 단계에서 확인할 수 있다.
daily의 각 행은 하루를 나타내고, category_totals의 각 행은 분류 하나를 나타낸다. order()는 원하는 순서의 행 위치를 구한다. 금액 앞의 음수 부호는 큰 금액부터 정렬하라는 뜻이다. 금액이 같으면 분류 이름을 두 번째 정렬 기준으로 사용한다.
stopifnot()의 12행과 6일 조건은 이 가상 자료가 예상대로 정리되었는지 확인하는 검사다. 다른 기간의 실제 자료에 적용할 때는 이 숫자를 그대로 두지 않는다. 집계 전후 합계 비교는 자료 크기와 무관하게 유지할 수 있는 검사다.
그래프 저장 부분은 파일 열기, 그래프 그리기, 파일 닫기의 순서다. dev.off()의 반환값을 변수에 넣어 콘솔에 불필요한 장치 번호가 출력되지 않게 한다. 이 변수는 분석에 쓰지 않는다. PNG 파일명은 마지막 report에 포함되어 실행 결과에서 확인할 수 있다.
lm()의 수식은 금액을 수량으로 설명한다는 뜻이다. coef()는 적합한 계수를 가져오고, summary(fit)$r.squared는 결정계수를 가져온다. sprintf()는 숫자의 표시 자릿수를 정한다. %.2f는 소수 둘째 자리까지, %.3f는 소수 셋째 자리까지 표시한다. 계산에 사용하는 값은 반올림하지 않고 보고서에 표시할 때만 자릿수를 줄인다.
report는 보고서의 각 줄을 담은 문자 벡터다. writeLines()는 이를 파일에 저장한다. cat()은 같은 내용을 터미널에 출력한다. 파일과 화면의 문장을 따로 작성하지 않으므로 두 결과의 내용이 서로 달라질 가능성을 줄인다.
실행 결과
main.R이 있는 폴더로 이동한 뒤 다음 명령을 실행한다. 별도의 패키지 설치나 외부 자료 다운로드는 필요하지 않다.
Rscript main.R
예상 표준 출력은 다음과 같다. 회귀 계수는 지정한 자릿수로 표시된다.
판매 분석 보고서
입력: 15행 / 중복 제외: 1행 / 조건 위반 제외: 2행
분석: 12행, 6일
총수량: 90개 / 총금액: 132000원
하루 평균 금액: 22000원
분류 합계: beverage 84000원 / snack 48000원
회귀식: 금액 = -1571.43 + 1571.43 * 수량
결정계수: 0.971
해석: 관측한 6일에서 하루 수량이 1개 많을 때 적합한 금액은 약 1571원 높았다.
범위: 관측 수량은 하루 10~20개이며, 절편은 실제 판매로 해석하지 않는다.
한계: 가상 자료 6일의 관계이며, 인과관계나 미래 예측력을 입증하지 않는다.
CSV: sales_input.csv, rejected_rows.csv, daily_summary.csv, category_summary.csv
그래프: daily_amount.png, category_amount.png
보고서: report.txt
| 날짜 | 수량 | 금액 |
|---|---|---|
| 2026-09-01 | 10 | 14,000원 |
| 2026-09-02 | 12 | 18,000원 |
| 2026-09-03 | 14 | 19,000원 |
| 2026-09-04 | 16 | 25,000원 |
| 2026-09-05 | 18 | 26,000원 |
| 2026-09-06 | 20 | 30,000원 |
날짜별 그래프에서는 판매 금액이 날짜에 따라 증가하는 모습이 나타난다. 분류별 그래프에서는 음료의 합계가 과자의 합계보다 크다. 다만 음료는 상품 한 개의 가격도 더 높다. 분류별 금액 차이를 판매 개수의 차이로만 설명할 수 없다. 실제로 음료의 총수량은 42개이고 과자의 총수량은 48개다.
결정계수 0.971은 이 여섯 날짜에서 금액 변동의 약 97.1%를 직선 모형이 설명한다는 뜻이다. 판매 금액의 97.1%가 수량 때문에 발생했다는 뜻도 아니고, 앞으로의 예측이 97.1% 정확하다는 뜻도 아니다. 보고서의 수치는 그 수치가 계산된 자료와 질문 안에서 읽어야 한다.
실무에서 자주 틀리는 것
결측값을 0으로 바꾸고 합계만 계산한다
빈 금액은 판매 금액이 0원이라는 기록과 다르다. 아래 코드는 금액을 모르는 행을 0원 판매로 바꾼다. 이번 기준에서는 결측 행을 따로 보관하고 제외해야 한다.
# 틀린 코드
raw$amount[is.na(raw$amount)] <- 0
total <- sum(raw$amount)
# 고친 코드: 완성 코드의 정리 결과를 사용한다.
total <- sum(clean$amount)
write.csv(rejected, "rejected_rows.csv", row.names = FALSE,
na = "", fileEncoding = "UTF-8")
sum(raw$amount, na.rm = TRUE)도 결측값을 제외한 합계를 구할 뿐, 제외 이유를 기록해 주지는 않는다. 결측 처리 방침과 제외 개수는 계산 함수 밖에서 명시한다.
행 금액의 평균을 하루 평균이라고 부른다
clean의 한 행은 하루 전체가 아니라 날짜와 상품의 조합이다. 행 금액의 평균은 상품 기록 한 행당 평균 금액이다. 하루 평균을 구하려면 날짜별 합계에 평균을 적용한다.
# 틀린 코드
daily_mean <- mean(clean$amount)
# 고친 코드
daily_mean <- mean(daily$amount)
기록이 없는 날짜도 주의한다. 현재 자료에는 여섯 날짜 모두 기록이 있다. 실제 자료에서 빠진 날짜가 휴무인지, 판매 0원인지, 자료 누락인지 확인하지 않고 0원을 채우면 평균의 의미가 달라진다.
원자료 행으로 회귀하고 하루 관계라고 해석한다
원자료 행으로 적합하면 상품별 기록의 수량과 금액 관계를 분석한다. 하루 총수량과 하루 총금액의 관계를 묻는 질문과 관측 단위가 다르다.
# 틀린 코드: 하루 합계의 관계를 묻고 있다.
fit <- lm(amount ~ quantity, data = clean)
# 고친 코드
fit <- lm(amount ~ quantity, data = daily)
회귀의 행 수는 정리된 판매 기록 12행이 아니라 날짜별 표의 6행이다. 보고서에서 표본 수를 적을 때도 적합에 실제로 사용한 관측 단위를 기준으로 센다.
기울기를 평균 상품 가격이나 인과 효과로 쓴다
기울기는 날짜별 수량과 금액을 연결하는 직선의 계수다. 상품별 가격은 prices에 따로 들어 있다. 회귀 계수만으로 수량을 늘리는 행동의 효과를 입증할 수도 없다.
# 틀린 코드
sentence <- sprintf("상품 하나의 가격은 %.0f원이다.", slope)
# 고친 코드
sentence <- sprintf(
"관측한 날짜에서 하루 수량이 1개 많을 때 적합한 금액은 약 %.0f원 높았다.",
slope
)
관계의 크기를 쓴 다음에는 관측 범위와 자료의 한계를 붙인다. 그래프에서 보이는 증가, 회귀가 요약하는 관계, 실제 업무에서 수량을 늘렸을 때의 변화는 각각 확인해야 할 내용이 다르다.
한눈에 보기
| 단계 | 주요 도구 | 남기는 결과 | 확인 사항 |
|---|---|---|---|
| 생성·읽기 | data.frame(), read.csv() | 입력 CSV | 행의 단위와 열의 의미 |
| 정리 | duplicated(), complete.cases() | 제외 행 CSV | 제외 기준과 개수 |
| 집계 | aggregate(), order() | 날짜별·분류별 CSV | 집계 전후 합계 |
| 그리기 | png(), plot(), barplot() | PNG 두 개 | 축의 단위와 정렬 |
| 관계 분석 | lm(), coef() | 계수와 결정계수 | 관측 단위와 범위 |
| 보고 | sprintf(), writeLines() | 텍스트 보고서 | 결과와 한계를 함께 서술 |
다른 코드에서 tidyverse 이름을 만났을 때는 다음 대응표를 참고할 수 있다. tidyverse는 데이터 작업에 쓰이는 여러 패키지의 묶음이다. 아래 이름은 작업 목적을 비교하기 위한 안내이며, 함수의 모든 동작이 같은 것은 아니다. 이번 프로그램은 base R만 사용한다.
| 작업 | 이번 코드 | 대응 이름 |
|---|---|---|
| 조건으로 행 선택 | 표[조건, ] | dplyr::filter() |
| 열 생성 | 표$열 <- 값 | dplyr::mutate() |
| 기준별 집계 | aggregate() | dplyr::group_by(), dplyr::summarise() |
| 행 정렬 | order() | dplyr::arrange() |
분석 프로그램은 숫자를 계산하는 도구이면서, 어떤 자료를 어떤 기준으로 사용했는지 남기는 기록이다. 입력 파일, 제외 기록, 집계표, 그래프, 해석 문장이 함께 있으면 계산을 다시 실행하고 결과의 근거를 확인할 수 있다.
연습 문제
- 음료의 판매 금액이 전체 판매 금액에서 차지하는 비율을 계산한다. 소수 첫째 자리까지 백분율로 표시하고, 그 비율이 이익 비율을 뜻하지 않는 이유를 한 문장으로 쓴다.
- 하루 판매 금액이 가장 큰 날짜를 daily에서 찾는다. 같은 최댓값을 가진 날짜가 여러 개면 모두 남기는 코드를 작성한다.
- 날짜별 표에 회귀의 적합값과 잔차를 추가해 daily_model.csv로 저장한다. 첫 날짜의 잔차를 소수 둘째 자리까지 구하고, 음수 부호의 뜻을 설명한다.
- ‘결정계수가 0.971이므로 다음 달 판매 금액을 97.1% 정확하게 예측할 수 있다’라는 문장을 고친다. 현재 자료로 말할 수 있는 내용과 추가로 확인해야 할 내용을 나누어 쓴다.
정답과 해설
1. 음료의 판매 금액 비율
beverage_amount <- category_totals$amount[
category_totals$category == "beverage"]
share <- beverage_amount / sum(category_totals$amount)
cat(sprintf("음료 금액 비율: %.1f%%\n", 100 * share))
결과는 ‘음료 금액 비율: 63.6%’다. 분자는 음료의 판매 금액 84,000원이고 분모는 전체 판매 금액 132,000원이다. ‘비용 자료가 없으므로 이 비율은 판매 금액의 구성비이며 이익의 구성비는 알 수 없다’라고 설명할 수 있다. sprintf()에서 %%는 퍼센트 기호 하나를 출력한다.
2. 판매 금액이 가장 큰 날짜
best_days <- daily[daily$amount == max(daily$amount), ]
cat(paste(as.character(best_days$date), collapse = ", "), "\n",
sep = "")
출력은 2026-09-06이다. 이 날짜의 판매 금액은 30,000원이다. which.max()는 처음 발견한 최댓값의 위치 하나를 돌려준다. 문제처럼 동률을 모두 남기려면 최댓값과 같은 행을 논리 조건으로 선택한다.
3. 적합값과 잔차 저장
daily_model <- daily
daily_model$fitted_amount <- unname(fitted(fit))
daily_model$residual <- unname(residuals(fit))
write.csv(daily_model, "daily_model.csv", row.names = FALSE,
fileEncoding = "UTF-8")
cat(sprintf("첫 날짜 잔차: %.2f원\n", daily_model$residual[1]))
출력은 ‘첫 날짜 잔차: -142.86원’이다. 첫 날짜의 실제 금액 14,000원이 직선이 나타내는 금액 약 14,142.86원보다 낮다는 뜻이다. 잔차는 실제값에서 적합값을 뺀 값이다. 음수라고 해서 판매 기록 자체가 잘못되었다는 뜻은 아니다.
4. 결정계수 해석 문장 고치기
‘이 가상 자료의 여섯 날짜에서 수량을 사용한 직선 모형은 하루 금액 변동의 약 97.1%를 설명했다. 다음 달 예측 성능을 확인하려면 적합에 사용하지 않은 날짜의 자료로 예측값과 실제값을 비교해야 한다’라고 쓸 수 있다.
실제 자료로 확장할 때는 기록 없는 날짜의 의미, 반품 처리, 상품 구성과 가격 변화도 확인한다. 코드가 끝까지 실행되는 것과 업무 질문에 맞는 결론을 얻는 것은 함께 점검해야 한다. 이번에 만든 main.R은 그 점검의 출발점이 되는 분석 과정과 결과 파일을 한곳에 남긴다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.