Devin.KR

조건·반복·함수 만들기

개발자KR 조회 0

이 장에서 배우는 것

지금까지는 벡터와 데이터 프레임을 만들고 들여다보는 일을 했다. 이 장에서는 R 이 "상황에 따라 다르게 행동하고, 같은 일을 여러 번 하고, 자주 쓰는 절차에 이름을 붙이는" 방법을 익힌다. 엑셀로 치면 IF 수식, 수식 채우기, 그리고 자주 쓰는 계산을 한 번 만들어 두고 부르는 일에 해당한다. 예제는 동네 편의점 한 곳의 판매 기록이다.

  • if·else if·else 로 값에 따라 다른 문장을 실행하고, ifelse 로 열 전체를 한 번에 채운다.
  • for 와 while 로 반복하고, next 와 break 로 반복의 흐름을 바꾼다.
  • function 으로 함수를 만들고 기본 인수와 반환값을 다룬다.
  • 변수가 어디에서 보이고 어디에서 사라지는지(스코프)를 설명하고, 함수 안에서 바깥 값을 바꾸려다 생기는 실수를 피한다.

문제 상황

편의점 점장이 하루치 판매 기록을 정리한다고 하자. 기록에는 상품, 분류, 수량, 금액이 있다. 점장은 건별로 "금액이 7,000원 이상이면 대량, 4,000원 이상이면 보통, 그보다 작으면 소액"이라는 딱지를 붙이고 싶다. 분류별로 건수와 합계, 평균도 매일 같은 모양으로 뽑고 싶다. 엑셀이라면 IF 수식을 열 끝까지 끌어 내리고, 분류마다 SUMIF 수식을 복사해 붙였을 것이다.

이 방식은 기록이 늘수록 손이 많이 가고, 기준이 바뀌면 수식을 모두 찾아 고쳐야 한다. 게다가 이 기록에는 금액이 비어 있는 행이 하나 있다. 비어 있는 값(결측값, NA)을 만났을 때 어떻게 할지도 코드에 적어 두어야 한다. 이 장에서는 그 규칙을 한 곳에 적어 두고 매번 부르는 방식으로 문제를 푼다.

조건 분기: if 와 ifelse

if · else if · else

if 는 괄호 안의 조건이 TRUE 일 때만 중괄호 안을 실행한다. 조건이 여러 갈래이면 else if 를 이어 붙인다. 위에서부터 차례로 검사해서 처음 TRUE 가 나온 갈래 하나만 실행하고 나머지는 건너뛴다. 그래서 순서가 중요하다. 아래 그림은 이 장의 예제 함수가 금액 하나를 검사하는 순서다.

금액은 결측 검사, 7000 이상, 4000 이상, 나머지 순으로 위에서 아래로 검사하며 처음 참인 갈래의 값만 돌려준다.

7,500원은 "7000 이상" 조건에서 이미 걸리므로 그 아래의 "4000 이상" 조건은 검사하지 않는다. 두 조건의 순서를 바꾸면 7,500원도 "보통"이 되어 버린다.

조건은 길이가 1인 TRUE 또는 FALSE 여야 한다. 여러 개가 들어 있는 벡터를 넣으면 R 4.2 부터는 오류가 난다. 조건을 이을 때는 하나짜리 값끼리 &&(그리고), ||(또는)를 쓴다. 벡터끼리 원소별로 비교하는 &, | 와는 용도가 다르다.

결측값도 조심해야 한다. NA 가 들어간 비교는 TRUE 도 FALSE 도 아닌 NA 를 돌려주고, if (NA) 는 오류다. 그래서 값이 비어 있을 수 있으면 먼저 is.na() 로 걸러 내거나, NA 를 FALSE 로 취급하는 isTRUE() 를 쓴다.

ifelse: 열 전체를 한 번에

ifelse(test, yes, no) 는 논리 벡터 test 의 원소마다 TRUE 이면 yes, FALSE 이면 no 를 골라 같은 길이의 벡터를 만든다. 데이터 프레임의 열 하나를 조건에 따라 채울 때 편하다. test 가 NA 인 자리는 결과도 NA 가 된다. 비어 있는 행에 다른 라벨을 주고 싶으면 바깥에 ifelse 를 한 겹 더 씌우면 된다.

if 문과 ifelse 는 입력과 결과의 모양이 다르다
구분조건에 넣는 것결과어울리는 일
if · else길이 1 의 논리값실행할 문장 하나를 고른다한 건씩 처리, 여러 문장 실행
ifelse길이 n 의 논리 벡터길이 n 의 벡터열 하나를 한 번에 채우기

if 도 값을 돌려준다. n <- if (na_rm) 1 else 2 처럼 결과를 변수에 담을 수 있고, 함수 끝에 놓인 if 의 결과가 그대로 함수의 반환값이 된다. 완성 코드의 grade_sale 함수가 그렇게 쓴다.

반복: for 와 while

for

for (i in 값들) 은 값들을 하나씩 변수 i 에 넣으며 중괄호 안을 되풀이한다. 데이터 프레임의 행을 하나씩 돌 때는 seq_len(nrow(sales)) 를 쓴다. 행이 0개일 때는 빈 순서가 되어 반복이 한 번도 일어나지 않기 때문이다. 문자열 벡터를 바로 돌아도 된다. 완성 코드는 등급 이름을 담은 벡터를 돌면서 등급별 건수를 센다.

반복 안에서 next 는 이번 차례를 건너뛰고 다음 값으로 넘어가며, break 는 반복 전체를 끝낸다. 금액이 비어 있는 행을 건너뛰고 합계를 내는 것이 next 의 대표적인 쓰임이다. 합계처럼 값을 쌓는 반복은 반복 앞에서 변수를 0 으로 만들어 두고, 안에서 그 변수에 더해 나간다.

결과를 담을 벡터가 필요하면 미리 길이를 정해 만들어 둔다. 완성 코드의 character(nrow(sales)) 는 빈 문자열 10개짜리 벡터를 만든다. 이렇게 하면 반복 안에서 grades[i] <- ... 로 자리를 채워 넣기만 하면 된다.

while

while 은 횟수가 아니라 조건으로 반복한다. 조건이 TRUE 인 동안 되풀이하고, 처음부터 FALSE 이면 한 번도 실행하지 않는다. 안에서 조건에 쓰인 값을 바꾸는 문장이 없으면 반복이 끝나지 않으므로, 조건이 언젠가 FALSE 가 되는지 늘 확인한다. 예제에서는 재고가 하루 판매량보다 적어질 때까지 며칠이 걸리는지 센다.

함수 만들기: 정의·인수·반환값·스코프

정의와 기본 인수

함수는 이름 <- function(인수) { 본문 } 으로 만든다. 인수(argument)는 함수가 밖에서 받는 값이다. 인수에 rate = 0.1 처럼 등호로 값을 적어 두면 기본값이 된다. 호출할 때 그 인수를 생략하면 기본값이 쓰이고, 적으면 적은 값이 쓰인다. 호출할 때 이름 = 값 으로 적으면 순서와 상관없이 짝이 맞는다. 인수가 여러 개인 함수는 이름을 붙여 부르는 편이 읽기 쉽다.

기본값으로 NULL 을 두면 "지정하지 않음"을 나타낼 수 있다. 완성 코드의 summarize_sales 는 category 에 분류 이름을 주면 그 분류만, 주지 않으면 전체를 요약한다.

반환값

함수는 본문에서 마지막으로 계산한 값을 돌려준다. 중간에 끝내고 싶으면 return(값) 을 쓴다. 이 장의 예제도 결측이면 return("기록없음") 으로 먼저 빠져나온다. 돌려줄 값이 여러 개이면 리스트로 묶는다. 리스트는 앞 장에서 다룬 것처럼 이름 붙은 상자를 여러 개 담는 그릇이어서, 호출한 쪽에서 결과$total 처럼 꺼내 쓸 수 있다.

스코프

스코프(scope)는 변수 이름이 통하는 범위다. 함수를 부르면 R 은 그 호출만을 위한 임시 작업 공간을 만들고, 함수 안에서 만든 변수는 거기에만 있다가 함수가 끝나면 사라진다. 변수를 읽을 때는 함수 안에서 먼저 찾고, 없으면 바깥으로 나가며 찾는다. 그래서 함수 안에서 바깥 변수를 읽는 것은 된다. 반대로 함수 안에서 x <- 값 을 하면 바깥의 x 를 고치는 것이 아니라 안쪽에 같은 이름의 새 변수를 만든다.

함수 안에서 counter 에 1 을 더해 저장해도 바깥 counter 는 0 그대로이며 함수가 끝나면 안쪽 환경은 사라진다.

바깥 변수를 정말 바꾸는 <<- 연산자가 있지만, 함수가 어디선가 몰래 값을 바꾸면 결과를 추적하기 어렵다. 이 책에서는 쓰지 않고, 바꾼 결과를 return 으로 돌려주어 호출한 쪽이 다시 저장하는 방식을 쓴다.

tidyverse 와의 대응

tidyverse 는 R 위에 얹어 쓰는 외부 패키지 묶음이다. 이 책은 설치 없이 base R 만 쓰지만, 다른 자료에서 만날 때를 위해 대응하는 표현을 적어 둔다.

이 장의 base R 표현과 tidyverse 표현의 대응
base Rtidyverse차이
ifelse()dplyr::if_else()if_else 는 yes 와 no 의 자료형이 다르면 오류를 낸다
if · else if 사슬dplyr::case_when()조건을 위에서부터 검사해 처음 참인 것을 쓰는 점은 같다
function(x) { }같음함수 정의는 패키지와 상관없이 똑같다

완성 코드

아래 코드를 main.R 한 파일에 저장한다. 데이터는 코드 안에서 만들고 외부 패키지는 쓰지 않는다.

# main.R - 편의점 판매 기록으로 익히는 조건·반복·함수

sales <- data.frame(
  product  = c("삼각김밥", "생수", "컵라면", "우유", "삼각김밥",
               "커피", "컵라면", "생수", "우유", "커피"),
  category = c("식품", "음료", "식품", "음료", "식품",
               "음료", "식품", "음료", "음료", "음료"),
  qty      = c(3, 2, 4, 1, 5, 2, NA, 6, 2, 3),
  amount   = c(4500, 2000, 5200, 2400, 7500, 5000, NA, 6000, 4800, 7500),
  stringsAsFactors = FALSE
)

# 1. 금액 하나를 등급으로 바꾸는 함수
grade_sale <- function(amount) {
  if (is.na(amount)) {
    return("기록없음")
  }
  if (amount >= 7000) {
    "대량"
  } else if (amount >= 4000) {
    "보통"
  } else {
    "소액"
  }
}

# 2. for 로 행마다 등급 매기기
grades <- character(nrow(sales))
for (i in seq_len(nrow(sales))) {
  grades[i] <- grade_sale(sales$amount[i])
}
sales$grade <- grades
cat("등급: ", paste(grades, collapse = " "), "\n", sep = "")

for (g in c("대량", "보통", "소액", "기록없음")) {
  cat(g, ": ", sum(sales$grade == g), "건\n", sep = "")
}

# 3. ifelse 로 열 한 개를 한 번에 채우기
sales$size <- ifelse(is.na(sales$amount), "기록없음",
                     ifelse(sales$amount >= 5000, "큰 판매", "작은 판매"))
cat("큰 판매 건수: ", sum(sales$size == "큰 판매"), "\n", sep = "")

# 4. next 로 결측 행을 건너뛰며 합계 내기
total <- 0
valid <- 0
for (i in seq_len(nrow(sales))) {
  if (is.na(sales$amount[i])) {
    next
  }
  total <- total + sales$amount[i]
  valid <- valid + 1
}
cat("유효 ", valid, "건, 합계 ", format(total, big.mark = ","), "\n", sep = "")

# 5. while 로 재고가 바닥날 때까지 세기
stock <- 20
daily <- 6
days <- 0
while (stock >= daily) {
  stock <- stock - daily
  days <- days + 1
}
cat("재고 ", stock, "개, 버틴 날 ", days, "일\n", sep = "")

# 6. 기본 인수와 리스트 반환값을 가진 함수
summarize_sales <- function(df, category = NULL, na_rm = TRUE) {
  if (is.null(df$amount)) {
    stop("amount 열이 필요하다")
  }
  if (is.character(category)) {
    df <- df[df$category == category, ]
  }
  amount <- df$amount
  n <- if (na_rm) length(na.omit(amount)) else length(amount)
  list(n = n,
       total = sum(amount, na.rm = na_rm),
       mean = mean(amount, na.rm = na_rm))
}

show_summary <- function(label, s) {
  cat(label, ": 건수 ", s$n, ", 합계 ", format(s$total, big.mark = ","),
      ", 평균 ", format(round(s$mean), big.mark = ","), "\n", sep = "")
}

show_summary("전체", summarize_sales(sales))
for (cat_name in unique(sales$category)) {
  show_summary(cat_name, summarize_sales(sales, cat_name))
}
show_summary("식품(NA 포함)", summarize_sales(sales, "식품", na_rm = FALSE))

discount <- function(amount, rate = 0.1) {
  amount * (1 - rate)
}
cat("기본 ", discount(5000),
    ", 20% ", discount(5000, rate = 0.2),
    ", 순서 바꿔 ", discount(rate = 0.5, amount = 5000), "\n", sep = "")

# 7. 스코프: 바깥 값 읽기와 안쪽에 새로 만들기
big_limit <- 5000
is_big <- function(amount) {
  isTRUE(amount >= big_limit)
}
cat("7500: ", is_big(7500), ", NA: ", is_big(NA_real_), "\n", sep = "")

counter <- 0
bump <- function() {
  counter <- counter + 1
  counter
}
inside <- bump()
cat("함수 안 ", inside, ", 바깥 ", counter, "\n", sep = "")

줄별 해설

데이터 만들기. data.frame() 으로 10건의 판매 기록을 만든다. 수량과 금액의 일곱 번째 값이 NA 이므로 컵라면 한 건은 기록이 비어 있다. 나머지 아홉 건의 금액을 더하면 44,900원이다.

grade_sale. 첫 if 는 결측을 먼저 처리한다. 결측이면 return 으로 바로 함수를 끝내므로, 아래의 amount >= 7000 비교에 NA 가 들어갈 일이 없다. 뒤쪽의 if · else if · else 는 함수의 마지막 식이라서 고른 문자열이 그대로 반환값이 된다.

등급 매기기 반복. character(nrow(sales)) 로 담을 그릇을 먼저 만들고, 행 번호 i 를 1부터 10까지 돌며 함수 결과를 i 번째 자리에 넣는다. paste(..., collapse = " ") 는 벡터를 공백으로 이어 한 줄로 만든다. cat 에 sep = "" 를 주면 조각 사이에 공백을 넣지 않아 원하는 모양대로 찍힌다. 이어지는 for 는 등급 이름 벡터를 돌면서, sales$grade == g 가 TRUE 인 개수를 sum 으로 센다. TRUE 는 1 로 계산되기 때문이다.

ifelse. 바깥 ifelse 가 결측 행을 "기록없음"으로 정하고, 안쪽 ifelse 가 나머지를 5,000원 기준으로 나눈다. 5,000원은 "이상"이므로 큰 판매에 들어가 큰 판매가 5건, 작은 판매가 4건이다.

next 반복. 결측 행에서는 next 로 뒷부분을 건너뛴다. 합계 44,900원과 유효 건수 9건이 나온다. format(total, big.mark = ",") 는 천 단위 쉼표를 넣은 문자열을 만든다. 숫자를 그대로 cat 에 넘기면 100000 이 1e+05 로 찍히는 일이 있어서 금액은 format 을 거친다.

while. 재고 20개에서 하루 6개씩 빼면 14, 8, 2 가 되고, 2 는 6 보다 작아 조건이 FALSE 가 되어 끝난다. 3일을 버티고 2개가 남는다.

summarize_sales. category = NULL 이 기본값이다. is.character(category) 는 분류 이름이 문자열로 주어졌을 때만 TRUE 여서, 이때만 행을 걸러 낸다. na_rm 는 결측을 뺄지 정하는 기본 인수이며 sum 과 mean 의 na.rm 로 그대로 넘긴다. na.omit(amount) 는 결측을 뺀 벡터이므로 그 길이가 유효 건수다. 마지막의 list(...) 가 함수의 반환값이다. 인수 이름 amount 는 데이터 프레임의 열 이름과 같지만, 함수 안에서는 지역 변수이므로 서로 영향이 없다.

show_summary 와 호출. 결과 리스트에서 s$n 등을 꺼내 한 줄로 찍는다. 평균은 round 로 정수로 반올림한다. 분류 이름을 주지 않은 첫 호출은 전체를 요약하고, for 는 unique(sales$category) 로 처음 나온 순서대로 "식품", "음료"를 돌며 분류별로 요약한다. 마지막 호출은 na_rm = FALSE 로 기본값을 바꾼다. 결측이 남아 있으므로 합계와 평균이 NA 이고 건수는 4다.

discount. 인수를 생략하면 기본 할인율 0.1, 적으면 그 값, 이름을 붙이면 순서와 상관없이 짝이 맞는다는 세 가지를 보여 준다.

스코프 예. is_big 은 자기 안에 없는 big_limit 을 바깥에서 찾아 읽는다. isTRUE 는 결과가 정확히 TRUE 일 때만 TRUE 이므로 NA 가 들어와도 FALSE 가 된다. bump 안의 counter <- counter + 1 은 오른쪽의 counter 를 바깥에서 읽어 0 + 1 을 계산하고, 왼쪽에는 안쪽에 새 변수를 만든다. 그래서 반환값은 1 이고 바깥 counter 는 0 이다.

실행 결과

$ Rscript main.R
등급: 보통 소액 보통 소액 대량 보통 기록없음 보통 보통 대량
대량: 2건
보통: 5건
소액: 2건
기록없음: 1건
큰 판매 건수: 5
유효 9건, 합계 44,900
재고 2개, 버틴 날 3일
전체: 건수 9, 합계 44,900, 평균 4,989
식품: 건수 3, 합계 17,200, 평균 5,733
음료: 건수 6, 합계 27,700, 평균 4,617
식품(NA 포함): 건수 4, 합계 NA, 평균 NA
기본 4500, 20% 4000, 순서 바꿔 2500
7500: TRUE, NA: FALSE
함수 안 1, 바깥 0

식품과 음료의 합계 17,200원과 27,700원을 더하면 전체 합계 44,900원과 같다. 이렇게 부분의 합이 전체와 맞는지 확인하는 습관을 들이면 조건을 잘못 적은 실수를 일찍 잡을 수 있다.

실무에서 자주 틀리는 것

NA 를 == 로 비교한다

결측인지 확인하려고 등호를 쓰면 안 된다. NA 와의 비교는 언제나 NA 이기 때문이다.

amount <- NA
if (amount == NA) {
  cat("비어 있음\n")
}
# 오류: missing value where TRUE/FALSE needed

결측 확인은 is.na() 로 한다.

if (is.na(amount)) {
  cat("비어 있음\n")
}

1:length(x) 로 반복 범위를 만든다

벡터가 비어 있으면 1:length(x) 는 빈 범위가 아니라 1, 0 이 되어 반복이 두 번 돈다.

x <- numeric(0)
for (i in 1:length(x)) {
  cat(i, x[i], "\n")   # 1 NA 와 0 numeric(0) 이 찍힌다
}

seq_len() 은 길이가 0 이면 정말로 빈 범위를 만든다.

for (i in seq_len(length(x))) {
  cat(i, x[i], "\n")   # 한 번도 실행되지 않는다
}

함수 안에서 바깥 데이터를 바꿨다고 믿는다

함수 안의 대입은 안쪽 복사본에만 일어나고, 바깥의 sales 는 그대로다.

add_flag <- function(df) {
  df$flag <- TRUE
}
add_flag(sales)
"flag" %in% names(sales)   # FALSE

바꾼 결과를 반환하고 호출한 쪽에서 다시 저장한다.

add_flag <- function(df) {
  df$flag <- TRUE
  df
}
sales <- add_flag(sales)
"flag" %in% names(sales)   # TRUE

else 를 새 줄에서 시작한다

중괄호 안에서는 괜찮아 보이지만, 스크립트 맨 바깥에서 닫는 중괄호 뒤에 줄을 바꾸면 R 은 그 줄에서 if 문이 끝났다고 읽는다.

x <- 5
if (x > 3) {
  cat("큼\n")
}
else {
  cat("작음\n")
}
# 오류: unexpected 'else'

닫는 중괄호와 else 를 같은 줄에 둔다.

if (x > 3) {
  cat("큼\n")
} else {
  cat("작음\n")
}

한눈에 보기

이 장의 문법과 기억할 점
주제문법기억할 점
조건 분기if (조건) { } else if (조건) { } else { }조건은 길이 1 의 TRUE 또는 FALSE, 위에서부터 처음 참인 갈래만 실행
벡터 조건ifelse(test, yes, no)test 가 NA 이면 결과도 NA
결측 다루기is.na(), isTRUE()NA 는 등호로 비교하지 않는다
횟수 반복for (i in seq_len(n))next 는 건너뛰기, break 는 끝내기, 1:length(x) 는 피한다
조건 반복while (조건) { }안에서 조건에 쓰인 값을 바꿔야 끝난다
함수f <- function(a, b = 기본값) { }마지막 식이 반환값, 여러 값은 list 로 묶는다
스코프함수 안의 대입은 안쪽 변수바깥은 읽을 수 있고 바꿀 수 없다, 바꿀 값은 return 으로 돌려준다

연습 문제

  1. 이 장의 sales 에서 수량이 3 이상인 행이 몇 건인지 for 반복으로 세어라. 수량이 NA 인 행은 건너뛴다.
  2. ifelse 로 sales 에 bulk 열을 만들어라. 수량이 3 이상이면 "묶음", 아니면 "낱개"로 한다. 수량이 NA 인 행은 결과가 어떻게 되는가.
  3. 상품 이름을 받아 그 상품의 수량 합계를 돌려주는 함수 total_qty(df, name, na_rm = TRUE) 를 만들어라. total_qty(sales, "삼각김밥") 과 total_qty(sales, "컵라면", na_rm = FALSE) 는 각각 무엇을 돌려주는가.
  4. 다음 코드를 실행했을 때 찍히는 값을 실행하지 말고 예측하고, 이유를 설명하라.
    x <- 10
    f <- function() {
      x <- 20
      g <- function() x + 1
      g()
    }
    r <- f()
    cat(r, x, "\n")
    

정답과 해설

  1. 수량은 3, 2, 4, 1, 5, 2, NA, 6, 2, 3 이므로 3 이상은 3, 4, 5, 6, 3 의 다섯 건이다.
    count <- 0
    for (i in seq_len(nrow(sales))) {
      if (is.na(sales$qty[i])) {
        next
      }
      if (sales$qty[i] >= 3) {
        count <- count + 1
      }
    }
    count   # 5
    
    NA 를 먼저 걸러 내지 않으면 if (NA >= 3) 에서 오류가 난다.
  2. sales$bulk <- ifelse(sales$qty >= 3, "묶음", "낱개") 로 만든다. 수량이 NA 인 일곱 번째 행은 조건이 NA 이므로 결과도 NA 다. 이 행에 다른 라벨을 주려면 이 장처럼 ifelse(is.na(sales$qty), "기록없음", ...) 를 바깥에 씌운다.
  3. total_qty <- function(df, name, na_rm = TRUE) {
      sum(df$qty[df$product == name], na.rm = na_rm)
    }
    total_qty(sales, "삼각김밥")                # 8
    total_qty(sales, "컵라면", na_rm = FALSE)  # NA
    
    삼각김밥은 3 + 5 = 8 이다. 컵라면은 수량이 4 와 NA 이고, na_rm 을 FALSE 로 주면 결측이 합계에 섞여 NA 가 된다. 기본값대로 두면 4 다.
  4. 찍히는 값은 21 10 이다. g 안의 x 는 g 자신에게 없으므로 g 를 만든 f 의 x, 즉 20 을 찾아 21 을 돌려준다. f 안의 x <- 20 은 f 안에 새 변수를 만들 뿐이라 바깥 x 는 10 그대로다. 변수는 함수를 부른 자리가 아니라 함수가 만들어진 자리에서부터 바깥으로 찾아 나간다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.