왜 당신은 코드를 성능 있게 만들면서 시간을 낭비하고 있는가
Source: Dev.to
데이터
// Note to really senior developers: I'm aware that Double is not an
// appropriate data type for prices, but I'm using it for the sake of simplicity.
case class Product(name: String, price: Double)
val products = List(
Product("Apple", 50),
Product("Banana", 21),
Product("Orange", 70)
)함수형 버전
def applyDiscount(discount: Double)(product: Product) =
product.price * (1 - discount)
def priceGreaterThan(limit: Double)(price: Double) =
price > limit
def calculateTotalPrice(firstPrice: Double, secondPrice: Double) =
firstPrice + secondPrice
val totalPrice = products
.map(applyDiscount(0.1)) // apply 10 % discount
.filter(priceGreaterThan(20)) // keep only prices > 20
.reduce(calculateTotalPrice) // sum the remaining prices명령형 버전
var totalPrice = 0.0
for product 20 then
totalPrice += discountedPrice원래 논쟁
원본 게시자는 (Hic sunt dracones 주석은 무시하고) 어느 옵션이 더 보기 좋은지 물었으며, 가독성과 의도 전달 방식 때문에 함수형 버전을 약간 선호한다고 밝혔습니다.
예상대로 논의는 곧 성능으로 전환되었습니다. 일부 시니어 개발자들은 함수형 코드가 O(3 n) 시간 복잡도를 가지고, 명령형 코드는 O(n) 라고 주장했습니다.
Note: “O(3 n) is not a thing. Period.”
그들의 논리: 함수형 파이프라인은 컬렉션을 세 번 순회합니다 (map 한 번, filter 한 번, reduce 한 번), 반면 명령형 버전은 한 번만 루프를 돕니다.
왜 “O(3 n)”은 잘못된 표현인가
빅‑오 표기법은 입력 크기가 → ∞ 로 갈 때의 점근적 성장률을 설명한다.
모든 선형 함수—n/150, n, 3 n, 12 500 000 n—는 **Θ(n)**이며, 즉 같은 복잡도 클래스에 속한다. 상수 계수(3, 12 500 000 등)는 빅‑오를 논할 때 무시된다.
간단한 성능 모델
요소당 세 개의 연산이 있다고 가정하고, 각각의 실행 시간은 a, b, c 입니다.
요소를 역참조하는 데는 시간 d가 소요됩니다.
| 시나리오 | n 요소에 대한 총 시간 |
|---|---|
| 세 번 패스 | 3 d n + a n + b n + c n = (3 d + ℓ) n |
| 한 번 패스 | d n + a n + b n + c n = (d + ℓ) n |
여기서 ℓ = a + b + c 로 정의합니다.
두 실행 시간의 비율은
[ \frac{3d + ℓ}{d + ℓ} ]
ℓ = 0 (즉, 루프 내부에 작업이 없을 때)만 세 번 패스 버전이 세 배 느려집니다.
루프 내부 작업이 역참조 비용보다 한 차례 정도 크게 (ℓ = 10 d)면, 비율은 13/11 ≈ 1.18 → 18 % 느려짐이며, 300 % 느려짐이 아닙니다.
가독성 vs. 성능
함수형 버전의 커리된 함수들은 의도를 명확히 전달함으로써 가독성을 향상시킵니다.
보다 간결한 함수형 파이프라인을 원한다면, 커리된 헬퍼들을 익명 함수(또는 메서드 레퍼런스)로 교체하고 reduce 대신 sum을 사용할 수 있습니다:
val totalPrice = products
.map(_.price * 0.9) // apply discount
.filter(_ > 20) // keep only prices > 20
.sum // total공간 복잡도
단순한 함수형 파이프라인을 사용할 때, 각 변환은 중간 컬렉션을 생성합니다:
map→ 크기가n인 컬렉션filter→ 크기가 ≤n인 컬렉션sum은 필터링된 컬렉션을 소비합니다
따라서 단순한 접근 방식은 O(n) 추가 공간을 사용합니다 (중간 컬렉션).
실제로, 많은 Scala 컬렉션(예: view, Iterator)은 이러한 연산들을 결합하여 중간 컬렉션을 실체화하지 않고, O(1) 추가 공간을 달성할 수 있습니다.
요약
- 시간 복잡도: 두 버전 모두 Θ(n)입니다. 상수 계수 차이는 보통 미미합니다.
- 공간 사용량: 순진한 함수형 파이프라인은 중간 컬렉션을 할당하지만, 결합/스트리밍 버전은 그 오버헤드를 없앱니다.
- 가독성: 함수형 코드는 특히 잘 이름 붙여진 헬퍼 함수와 함께 사용할 때 더 선언적이고 표현력이 풍부할 수 있습니다.
두 스타일 모두 각각의 역할이 있습니다; 프로젝트의 성능 제약과 가독성 목표에 가장 잘 맞는 방식을 선택하세요.
스칼라 컬렉션에서의 지연 평가
어쨌든, 중첩 변환을 수행하지 않는 한 입력 크기에 비례하여 공간이 선형적으로 증가하고, 파이프라인 단계 수에 따라 더 악화될 수 있는 해결책을 갖게 됩니다.
이 시점에 도달하면 기능적 코드에 메서드 호출을 포함시켜 질문을 최종적으로 해결할 수 있습니다:
val totalPrice = products.view
.map(_.price * 0.9)
.filter(_ > 20)
.sum.view 메서드가 보이시나요? 이것이 Views를 사용해 컬렉션에서 지연 평가로 전환하는 스칼라 방식입니다.
엄격한 평가—모든 변환이 즉시 적용되어 새로운 구조를 생성하는 방식—와 달리, 지연 평가는 계산이 실제로 필요할 때까지 연산을 미룹니다.
- Map과 filter는 실제 연산을 수행하지 않고 다른
View를 반환합니다(기저 view를 감싸고 값을 계산하는 클로저를 기억합니다). sum을 호출해 요소에 접근해 결과를 만들어야 할 때 비로소 실제 연산이 수행됩니다.
지연 평가 덕분에 원본 입력을 한 번만 순회하고 중간 구조를 전혀 만들지 않으므로, 이 기능적 코드는 처음에 본 단일 루프 명령형 코드와 실질적으로 동일하게 동작합니다.
트레이드‑오프
지연 평가는 비용이 없습니다:
- 중간 view 구조를 생성합니다.
- 중간 클로저를 생성하고 저장합니다.
입력 크기가 작을 경우, 이러한 오버헤드가 “루프”를 여러 번 수행하고 중간 컬렉션을 만드는 것보다 더 비용이 많이 들 수 있습니다.
Languages and Evaluation Strategies
- Haskell – 기본적으로 지연(lazy, 비‑엄격) 평가.
- Scala – 기본적으로 엄격(strict) 평가이지만,
.view와 같은 메커니즘을 사용해 지연 평가로 전환할 수 있음.
시니어리티, 코드 길이, 그리고 최적화
이 글의 서두에서 말했듯이, 시니어리티는 종종 다음과 같은 것처럼 보입니다:
- 직접 비례하는 요소는 당신이 알고 있는 언어 기능의 수입니다.
- 역비례하는 요소는 당신이 생성하는 코드의 길이입니다.
또한 코드의 마이크로 최적화를 할 수 있는 능력과도 연결된 것처럼 보이며, O(n) 솔루션을 O(3n)으로 바꾸는 것과 같습니다(다시 농담입니다).
비즈니스 환경에서 실제로 중요한 것은?
가치 있는 개발자는 소프트웨어 제품의 총 소유 비용을 낮추는 사람이며, 즉 회사의 비용을 절감하는 사람입니다.
우리의 노력을 어디에 집중해야 할까?
업계 데이터에 따르면 (O’Reilly 저자의 인용):
- 소프트웨어 수명 주기 비용의 **60 %**는 유지보수에서 발생합니다.
- 유지보수 중, 비용의 **60 %**는 사용자 생성 개선(요구사항 변경)과 관련됩니다.
- **17 %**의 유지보수 비용은 버그 수정 때문입니다.
따라서 유지보수 비용은 운영 비용(소프트웨어를 실행하는 데 필요한 인프라)보다 훨씬 큽니다. 당신이 구글처럼 성능 향상이 수백만 대의 기계에 영향을 미치는 경우가 아니라면, 다음을 우선시해야 합니다:
- 유지보수성
- 버그 없는 코드
이는 미미한 성능 향상을 위한 마이크로 최적화를 중단하고, 대신 동료 개발자와 미래의 자신이 읽고 발전시키는 데 즐거움을 느낄 수 있는 아름다운 이야기를 전달하는 코드를 작성하는 데 집중한다는 의미입니다. 함수형 프로그래밍이 여기서 종종 장점을 가지고 있지만, 이는 다음 글의 주제입니다.
“항상 코드를 작성하라, 마치 당신의 코드를 유지보수하게 될 사람이 당신이 사는 곳을 아는 폭력적인 사이코패스라고 생각하듯이.”
— Martin Golding (기사에서 인용)