ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 순수 함수란?
    Programming🧑‍💻/Functional Programming 2025. 2. 10. 23:22

     

    수학에서 f(x) = x + 1 같이 함수는 x에 특정 값을 넣으면 항상 동일한 결과가 나옵니다. 

    프로그래밍에서 순수 함수도 이와 같은 원칙을 따릅니다. 

    1. 항상 단일 값을 반환합니다 (동일한 입력에 대해 동이한 출력 반환)
    2. 반환 값은 오직 입력 매개변수에만 기반하여 계산됩니다
    3. 기존 값을 변경하거나 부작용을 일으키지 않습니다

    위의 원칙들을 따르면서 나타나는 다음 특성들 때문에 이를 '순수'하다고 합니다 

    • 입력과 출력의 관계가 순수하게 유지됩니다 (같은 입력 = 같은 출력)
    • 외부 세계와의 상호작용 없이 순수하게 계산만 수행합니다
    • 부작용 없이 순수하게 자신의 역할만 수행합니다

     

    순수함수가 왜 필요한데?

    전통적인 클래스 기반 접근 방식의 문제점

    장바구니에 책이 포함되어 있을 때 할인을 해주는 비즈니스 로직을 구현한다고 해봅시다. 

    기존의 클래스 기반의 장바구니 구현은 다음과 같을 수 있습니다. 

    • ShoppingCart라는 장바구니 클래스를 만들었습니다.
    • 그 안에 포함된 물건 목록과 책 포함 여부를 상태값으로 관리합니다. 
    • 이 값들의 접근과 변경을 통해 로직을 구현합니다. 
    class ShoppingCart {
        private var items: [String] = []
        private var bookAdded = false
        
        func addItem(_ item: String) {
            items.append(item)
            if item == "Book" {
                bookAdded = true
            }
        }
        
        func getDiscountPercentage() -> Int {
            return bookAdded ? 5 : 0
        }
        
        func getItems() -> [String] {
            return items
        }
    }

     

    이 코드는 언뜻 보기에는 문제가 없어 보이지만, 실제 운영 환경에서는 여러 가지 문제를 일으킬 수 있습니다. 

     

    1. 상태 관리의 취약성 

    • 현재 addItem 함수에서 bookAdded 상태값을 관리하고 있습니다. 
    • 하지만 getItems 를 통해 ShoppingCart의 상태값에 직접 접근하여 변경이 가능합니다. 
    • 따라서 직접 리스트에 접근하여 삭제하는 경우 bookAdded 상태 값이 정상적으로 변경되지 않을 수 있습니다. 
    let cart = ShoppingCart()
    let items = cart.getItems() // items 리스트를 받아옴
    items.remove("Book")        // 직접 리스트 수정
    print(cart.getDiscountPercentage()) // 여전히 5% 할인을 반환

     

    2.  동시성 문제

    • 여러 쓰레드에서 하나의 장바구니 객체에 동시에 접근하여 상태값 변경을 시도하면 동시성 이슈가 발생합니다. 
    // 여러 스레드에서 동시에 실행될 경우
    Thread 1: cart.addItem("Book")
    Thread 2: cart.getItems().removeAll()
    Thread 3: cart.getDiscountPercentage() // 결과를 예측할 수 없음

     

     

    위와 같은 문제들이 발생하는 근본적인 이유는 바로 '상태'를 가지고 있기 때문입니다. 

    상태를 가진다는 것은 다음을 의미합니다. 

    1. 데이터가 시간에 따라 변경될 수 있음 
    2. 여러 곳에서 같은 데이터를 수정 할수 있음
    3. 데이터의 일관성을 보장하기 어려움 

    위와 같은 문제들을 해결하기 위해 순수 함수적 접근이 필요졌습니다. 

     

    순수 함수의 이용 

    이제 같은 기능을 순수 함수로 구현했을 때 어떠한 장점이 있는지 살펴봅시다. 

    struct ShoppingCart {
        static func getDiscountPercentage(items: [String]) -> Int {
            return items.contains("Book") ? 5 : 0
        }
    }

     

    1. 예측 가능성 

    • 순수 함수는 입력이 같으면 항상 같은 결과를 반환합니다. 
    • 내부 상태가 없기 때문에, 프로그램의 다른 부분에서 무슨 일이 발생하던 결과를 정확히 예측할 수 있습니다. 
    let items = ["Book", "Apple"]
    // 항상 5를 반환합니다
    let discount = ShoppingCart.getDiscountPercentage(items: items)

     

    2. 멀티쓰레드 환경에서의 안전성

    • 순수 함수는 공유 상태가 없기 때문에 여러 쓰레드에서 동시에 실행해도 안전합니다. 
    // 각 스레드가 자신만의 데이터 복사본을 사용
    DispatchQueue.concurrentPerform(iterations: 100) { index in
        var threadItems = ["Book", "Apple"]
        let discount = ShoppingCart.getDiscountPercentage(items: threadItems)
        // 각 스레드의 결과는 항상 예측 가능
    }

     

    3. 테스트 용이성 

    • 순수함수는 테스트하기가 용이합니다. 
    • 복잡한 설정이나 목(mock) 객체가 필요 없으며, 단순히 입력과 출력만 확인하면 됩니다. 
    func testDiscounts() {
        assert(ShoppingCart.getDiscountPercentage(items: []) == 0)
        assert(ShoppingCart.getDiscountPercentage(items: ["Book"]) == 5)
    }

     

    4. 조합 가능성

    • 순수 함수는 자신이 필요한 모든 데이터를 매개변수로 받고, 결과를 반환값으로만 전달하기 때문에 명확한 연결지점을 제공합니다.
    • 순수 함수는 외부 상태를 변경하지 않기 때문에 여러 함수를 조합할 때 예기치 않은 상호작용을 걱정하지 않아도 됩니다. 
    • 따라서 여러 순수함수들을 조합하여 데이터 처리 파이프라인을 만들 수 있습니다. 
    func calculateFinalPrice(items: [String], basePrice: Double) -> Double {
        let discount = Double(ShoppingCart.getDiscountPercentage(items: items))
        return basePrice * (100.0 - discount) / 100.0
    }

     

    순수 함수의 한계와 현실적 접근 

    모든 코드를 순수 함수로 작성하는 것은 현실적으로 어렵고, 때로는 비효율적일 수 있습니다.

    다음과 같은 상황에서는 비순수 함수를 사용하는 것이 더 적절할 수 있습니다.

     

    1. 로깅(Logging)의 경우

    • 매번 실행할 때마다 로그 파일이라는 '외부 세계'(상태)가 변경됩니다
    • 같은 입력을 넣어도 시간에 따라 다른 결과가 발생합니다(로그 파일이 계속 커짐)
    • 하지만 이런 기록은 필수적입니다(나중에 문제가 생겼을 때 확인이 필요)
    func logUserAction(_ action: UserAction) {
        // 로깅은 본질적으로 부작용을 포함합니다
        Logger.log("User performed action: \(action)")
    }

     

    2. 하드웨어 상호작용

    • 카메라는 물리적 장치를 제어하기 때문에 해당 장치 상태에 따라 결과가 달라질 수 있습니다. 
    func capturePhoto() async throws -> Image {
        // 카메라로 사진 촬영
        return try await Camera.capturePhoto()
    }

     

    위와 같이 태생적으로 '비순수'한 작업들은 다음 방법을 이용해 다룰 수 있습니다. 

    class PhotoService {
        // 비순수 작업을 최대한 분리하여 관리
        private let camera: Camera
        private let logger: Logger
        
        // 순수한 부분: 이미지 처리 로직
        func processImage(_ image: Image) -> ProcessedImage {
            // 순수 함수로 이미지 처리
            return image.resize().enhance().compress()
        }
        
        // 비순수한 부분: 실제 카메라 작동
        func takePhoto() async throws -> ProcessedImage {
            // 1. 비순수 작업: 카메라로 사진 촬영
            let rawImage = try await camera.capturePhoto()
            
            // 2. 순수 작업: 이미지 처리
            let processed = processImage(rawImage)
            
            // 3. 비순수 작업: 로깅
            logger.log("Photo taken and processed")
            
            return processed
        }
    }

     

    위와 같은 방식을 통해

    • 순수한 부분과 비순수한 부분을 명확히 구분할 수 있습니다
    • 테스트하기 쉬운 부분(순수 함수)과 어려운 부분(비순수 함수)을 분리할 수 있습니다
    • 비순수한 작업들을 한 곳에서 관리할 수 있습니다

    순수 함수는 코드의 예측 가능성, 테스트 용이성, 재사용성을 높이는 강력한 도구입니다.

    하지만 이는 모든 코드를 순수 함수로 작성해야 한다는 의미는 아닙니다.

    대신, 비즈니스 로직의 핵심 부분은 가능한 한 순수 함수로 작성하고,

    필요한 부작용은 명확히 분리된 계층에서 처리하는 것이 좋은 접근 방법입니다.

     

    'Programming🧑‍💻 > Functional Programming' 카테고리의 다른 글

    함수야 거짓말 하지마라  (0) 2025.02.09

    댓글

Designed by Tistory.