[Swift] for-in문과 forEach 사이의 차이, 그리고 고차함수(map, filter, reduce)의 활용까지

2024. 3. 11. 15:57Swift, iOS Foundation

1️⃣ 내가 이 글을 쓰게 된 이유


프로그래밍을 조금이라도 배워본 사람이라면, 반복문의 대표주자로 for-in문이 있다는 것은 당연히 알 것이라 생각한다.

Swift에도 당연히 for문을 지원하기에 평소 for-in 코드를 사용한 반복을 많이 사용하곤 했었는데,
반복을 목적으로 짠 코드에서 forEach문 역시 동일한 기능을 제공한다는 것을 다른 협업 팀원들의 코드에서 많이 확인할 수 있었다.

이에 그동안 나도 무지성 따라 치기(?) 스킬로 forEach라는 코드를 사용했지만,
사실 for문과 어떤 점에서 차이가 있는지, 성능 차이는 존재하는지에 대한 고찰이 없었기에 항상 공부하고 싶었던 내용이었다.


여기에 덧붙여, 최근 진행하던 <TOASTER 토스터> 프로젝트에서 iOS 멘토님에게 받은 피드백 중 하나가 "for + append 조합보다 map과 같이 고차함수를 활용할 수 있는 부분은 바꿔봐도 좋을 것 같아요"라는 말이었다.

예전 글에서 고차함수에 대해 다뤄본 적은 있었지만,
코드 내에서 이 부분을 대체해서 사용할 수 있겠다고 느껴본 적은 없었기에, 피드백을 받았을 때 조금 새로운 느낌이었는데.
"이 참에 반복과 관련해서 사용할 수 있는 Swift의 코드를 모두 비교해 보자!"에서 시작된 것이 이번 글을 쓰게 된 가장 핵심 목적이라고 볼 수 있겠다.

 

2️⃣ for-in문과 forEach는 어떤 점에서 차이가 있는걸까?


아래 두 방식으로 쓰인 코드는 동일한 Array(numbers)를 반복하는 액션을 수행하는 코드이다.

직관적으로 코드에서 차이나는 부분을 확인해 보자면,
for문은 루프 안에 들어 있는 임시 상수(number)에 반복되는 요소가 하나씩 할당되는 방식이고, -> print(number) 수행
forEach 함수는 반복되는 요소가 클로저의 파라미터($0)로 넘어오는 방식이라는 것을 확인할 수 있다. -> print($0) 수행

let numbers = [1, 2, 3, 4, 5]

// 1. for-in문 예시
for number in numbers {
    print(number)
}

// 2. forEach문 예시
numbers.forEach {
    print($0)
}

이 부분이 가장 큰 코드상의 차이점이었다면, 이제는 forEach 공식문서 상에 쓰여있는 차이점을 비교해보고자 한다.

1. break 혹은 continue문을 사용해서 forEach 클로저의 호출을 중단하거나 건너뛸 수 없다. (You cannot use a break or continue statement to exit the current call of the body closure or skip subsequent callls.)

2. forEach 클로저 내에서 return문을 사용하면 오로지 현재 실행 중인 body(구문)만 벗어날 수 있을 뿐, 다른 범위의 후속 호출을 벗어나지는 못한다. (Using the return statement in the body closure will exit only from the current call to body, not from any outer scope, and won't skip subsequent calls.)


1번 차이는 제어 흐름(Control Flow, 조건문 반복문을 모두 포함)에서 사용되는 break와 continue 키워드가, 제어 흐름 키워드가 아닌 메서드에 해당하는 forEach에서 사용될 수 없다는 취지인 것 같은데..
return문 사용 범위에 해당하는 2번 차이가 조금 이해가 되지 않아서 예시를 통해 살펴보고자 한다.

일반적인 for문과 return문을 사용했을 때의 코드를 먼저 살펴보자.

함수 내에서 numbers Array의 요소를 하나씩 print 하는 일반적인 반복 중, 3을 만나게 되면 return 하도록 하는 코드가 있을 때,
아래 상황에서는 3에서 return을 만나면 someFor 함수 자체가 반환되며, 나머지 4 5에 대한 반복이 수행되지 않는다.

func someFor() {
    let numbers = [1, 2, 3, 4, 5]
    
    for number in numbers {
        print(number)
        if number == 3 { return }
    }
}

// 1 2 3


하지만, 같은 반복을 ForEach로 구성한 코드의 경우에는 3에서 return이 된 이후에도 4 5까지 계속 반복이 돌게 된다.

왜 이런 상황이 되는 것일까 생각을 해보면, 결국 forEach가 하나의 메서드에 해당한다고 위에서 언급을 한 바가 있고
여기서 사용되는 return은 forEach라는 이름 없는 함수(= 클로저)에 대한 return으로 받아들여질 뿐, 이것보다 더 상위 함수인 someForEach 함수에 대한 return으로 받아드려지지 않게 되는 것이다.

즉, 정리하자면 ForEach문에서 사용되는 return은 현재 돌고 있는 반복 클로저에 대한 반환값으로 받아드려질 뿐이기에, 나머지 반복도 정상적으로 잘 동작하게 되는 것이다.

func someForEach() {
    let numbers = [1, 2, 3, 4, 5]
    
    numbers.forEach {
        print($0)
        if $0 == 3 { return }
    }
}

// 1 2 3 4 5


이를 더 직관적으로 확인하고자 하면, 아래처럼 코드를 수정해 볼 수도 있겠다.

아래 예시에는 print를 먼저 수행했던 위의 코드와는 다르게,
반복 파라미터의 값이 3일 때만 값을 print하고 반환하라고 액션을 주고 있기에 3을 제외한 1 2 4 5가 각각 반환에 대한 값으로 출력될 것이다.

func someForEach2() {
    let numbers = [1, 2, 3, 4, 5]
    
    numbers.forEach {
        if $0 == 3 { return }
        print($0)
    }
}

// 1 2 4 5


어떻게 생각해 보면, for-in문에서 continue 구문으로 중간에 예외 처리를 두고 있었던 부분을 forEach에서 구현하기 위해서는 return을 사용해 클로저 내의 코드가 수행되기 전에 반환시키는 방법으로 구현할 수 있다는 뜻이기도 하다.

다만, break나 더 큰 범위의 함수를 종료시키기 위한 return문은 forEach를 사용할 때 여전히 사용할 수 없다!

참고로, forEach 또한 for문으로 구성되어 있어 성능상 큰 차이는 갖지 않는다고 하니
어떤 구문을 사용할지는 위의 차이점을 고려한 상황에서 순전히 개발자 본인의 선택이 될 것 같다!
(개인적으로는 클로저 파라미터로 반복을 수행하는 것이 가독성 측면에서 더 낫다고 생각하는 편이긴 하다. ^_^)

 

3️⃣ 반복문과 고차함수(map, filter, reduce)


"for + append 조합보다 map과 같이 고차함수를 활용할 수 있는 부분은 바꿔봐도 좋을 것 같아요"

고차함수를 어디 가서 공부한 적 있다고 말하기가 부끄러운 순간이었다.
고차함수 키워드는 map, filter, reduce라는 게 존재하고 각각은 어떤 역할을 하고 있는지 머리로만 알고 있을 뿐, 이를 실제로 코드에서 어떻게 사용할 수 있을지는 생각하지 않은 채 말 그대로 "공부만을 위한 공부"를 했다는 게 걸린 것 같았다.


아래와 같은 코드가 있었다고 해보자.
allViews Array의 개수만큼 반복을 할 건데, 반복 시마다 newView라는 View 객체를 새로 만들어서 views라는 외부 Array에 만들어진 뷰를 할당하는 단순한 로직이다.

let views: [UIView] = []

for _ in 0..<allViews.count {
    let newView = UIView()
    views.append(newView)
}

위와 같이 코드를 작성하는 게 잘못되었다고 말하는 것은 아니다.

하지만, Swift의 고차함수 기본만 잘 알고 있었어도 쉽게 응용할 수 있다는 것을 아래 예시를 통해 보여주고 싶다.
Swift의 고차함수(Higher-order function)는 표준 라이브러리의 컨테이너 타입(Array, Set, Dictionary 등)이 각각 변형, 필터, 결합 시에 일괄적으로 수행할 수 있도록 도와주는 것이 핵심 내용이다.

즉, 위의 상황에서도 views라는 새로운 컨테이너 객체에 대해 일괄적으로 이미 있는 allViews에 따라 반복적인 작업을 수행한다면 map이라는 변형 함수를 사용할 수 있었다는 의미다.

let views: [UIView] = allViews.map { _ in
    let newView = UIView()
    return newIndicator
}


결론.
"공부를 위한 공부"가 아니라 "이를 어디에 적용시킬 수 있는지"를 생각하는 공부가 필요하다는 것!
이런 방식의 공부가 내가 짠 코드를 더 특별하게 보일 수 있는 좋은 길이 될 것이라 생각한다 ^___^