본문 바로가기
인프런/김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍

[인프런] 김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 / 13. 함수형 프로그래밍

by hxxyeoniii 2025. 7. 6.

프로그래밍 패러다임

* 프로그래밍 패러다임 = 프로그램을 구성하고 구현하는 사상이나 접근법

 

1. 명령형 프로그래밍(Imperative Programming)

:  프로그램이 어떻게 동작해야 하는지 세세한 제어 흐름을 통해 기술

: 절차지향, 객체지향 포함

: 변수의 값이 바뀌며 상태(state)가 변해감

: CPU 동작 방식과 유사하며 전통적 HW와 직관적인 일치

: ex) C, C++, Java 등

 

(+) 컴퓨터 동작 방식과 유사해 이해하기가 직관적, 제어 흐름을 상세히 제어하기 쉬움

(-) 프로그램 규모가 커지면 상태 변경에 따른 복잡도 증가

 

 

 

1.1 절차지향 : 프로시저, 함수 기반으로 로직을 절차적으로 구성

: 공통 로직을 재사용하기 위해 함수나 프로시저를 만들어 사용

   > 함수 : 입력값을 받아 처리하고 결과값을 반환하는 것이 주 목적

   > 프로시저 : 일련의 명령문들을 하나의 단위로 묶은것 -> 특정 작업이나 행동을 수행하는데 중점, 주로 상태 변경이나 특정 동작 수행에 초점 

: 데이터와 절차가 분리되어 있다는 말로 자주 설명됨

: ex) C, Pascal 등

 

(+) 구조적 프로그래밍 기법(모듈화, 함수화)으로 코드 가독성 및 재사용성 증가

(-) 데이터와 로직이 명확히 분리되지 않을때, 유지보수가 어렵고 대형 플젝에서 복잡성 증가

 

1.2 객체지향 : 데이터, 함수를 하나로 묶은 객체를 중심으로 설계

: 캡슐화, 상속, 추상화, 다형성

: 데이터와 데이터를 처리하는 함수를 하나의 객체로 묶어 유지보수성, 확장성을 높임

: ex) Java, C++, C#

 

(+) 객체 단위로 묶여 대규모 시스템 설계에 적합

(-) 과도한 객체 분리나 복잡한 상속 구조로 복잡도가 오히려 증가할 수 있음

 

 

 

2. 선언적 프로그래밍(Declarative Programming)

: 무엇을 할 것인가를 기술하고, 어떻게 구현, 실행될지는 위임하는 방식

: 구체적인 제어 흐름을 직접 작성하기보다 원하는 결과나 조건을 선언적으로 표현

: 상태 변화보다 결과에 초점을 맞춰 코드 작성

: ex) SQL, HTML

 

(+) 구현의 복잡한 로직을 많이 숨길 수 있어 높은 수준에서 문제 해결에 집중 가능, 비지니스 로직을 직관적으로 표현하기 쉬움

(-) 언어나 환경이 제공하는 추상화 수준에 의존적이며, 내부 동작이 보이지 않을 경우 디버깅이 어려울 수 있음

 

 

 

3. 함수형 프로그래밍(Functional Programming)

: 무엇을 할 것인지를 수학적 함수로 구성하고 부수 효과 최소화 및 불변성을 강조하는 프로그래밍 방식

: 선언형 접근에 가까움 -> 어떻게가 아닌 어떤 결과를 원한다고 선언

: 순수 함수 중시 -> 같은 입력이 주어지면 항상 같은 출력

: 데이터는 불변 처리 -> 재할당 대신 새 데이터를 만들어 반환

: ex) Heskell, Clojure, Scala, Java(람다, 함수형 인터페이스를 통한 부분 지원)

 

(+) 상태 변화가 없거나 최소화되므로 디버깅 및 테스트 용이, 병렬 처리 및 동시성 처리가 간단

(-) 초기 접근이 어려울 수 있음, 계산 과정에서 메모리 사용이 증가할 수 있음

 

 

 

=> 이러한 패러다임은 서로 대립되지 않으며 문제 상황이나 설계 목표에 따라 적절히 선택하고 조합해 사용할 수 있다.


함수평 프로그래밍이란?

1. 순수 함수

> 같은 인자를 주면 항상 같은 결과를 반환하는 함수

 

2. 부수 효과 최소화

> 함수형 프로그래밍에서는 상태 변화를 최소화하기 위해 변수나 객체를 변경하는 것 지양

> 이로 인해 버그가 줄어들고, 테스트나 병렬 처리, 동시성 지원이 용이해짐

 

3. 불변성 지향

> 데이터는 생성된 후 가능한 한 변경하지 않고, 변경이 필요한 경우 새로운 값을 생성해 사용

> 프로그램 예측 가능성을 높여줌

 

4. 일급 시민 함수

> 함수가 일반 값(숫자, 문자열, 객체, 자료구조 등)과 동일한 지위를 가짐

> 함수를 변수에 대입하거나, 다른 함수의 인자로 전달하거나, 함수에서 함수를 반환하는 고차 함수

 

5. 선언형 접근

> 어떻게가 아닌 무엇을 계산할지 기술

> 가독성 높은 코드 작성

 

6. 함수 합성

> 간단한 함수를 조합해 더 복잡한 함수를 만드는 것 권장

 

6. 지연 평가

> 필요한 시점까지 계산을 미뤄 불필요한 계산 비용을 줄임

 

 

 

주요 함수형 프로그래밍 언어 및 활용

대표적인 순수 함수형 프로그램이 언어는 Haskell

Java, JS, Python등 전통적 언어들은 순수 함수형 프로그래밍 언어는 아니지만 람다 표현식, 고차 함수 등 함수형 스타일을 점진적으로 지원함

자바는 명령형, 객체지향이 주된 패러다임, 거기에 람다 등 함수형 문접이 일부 도입된 멀티 패러다임 언어!


자바와 함수형 프로그래밍

1. 순수 함수(Pure Function)

package functional;

import java.util.function.Function;

public class PureFunctionMain1 {

    public static void main(String[] args) {
        Function<Integer, Integer> function = x -> x * 2;
        System.out.println(function.apply(10)); // 10 입력시 항상 20 반환 -> 순수 함수!
    }
}

-> 입력값이 동일하면 항상 동일한 결과 반환 = 순수 함수

 

 

 

2. 부수 효과(Side Effect)

package functional;

import java.util.function.Function;

public class SideEffectMain1 {

    public static int count = 0;

    public static void main(String[] args) {
        System.out.println("before count = " + count); // 0

        Function<Integer, Integer> func = x -> {
            count++; // 전역 변수 변경 -> 부수 효과
            return x * 2;
        };

        func.apply(10);
        System.out.println("after count = " + count); // 1

    }
}

-> 함수 호출 전후로 count가 바뀌는데 이런 외부 상태 변화가 부수 효과의 대표적 예

-> 함수형 프로그램이에서는 이러한 외부 상태 변경을 최소화하는 것을 권장

 

 

 

3. 불변성 지향(Immutable State)

 

MutablePerson.java

package functional;

public class MutablePerson {
    private String name;
    private int age;

    public MutablePerson(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "MutablePerson{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

-> setter로 객체 생성 후에도 상태값 변경 가능

 

ImmutablePerson.java

package functional;

public class ImmutablePerson {
    private final String name;
    private final int age;

    public ImmutablePerson(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }


    public int getAge() {
        return age;
    }

    // 변경이 필요한 경우 기존 객체를 수정하지 않고 새 객체 반환
    public ImmutablePerson withAge(int newAge) {
        return new ImmutablePerson(name, newAge);
    }

    @Override
    public String toString() {
        return "ImmutablePerson{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

-> 필드가 final이며 생성 후에는 상태를 변경할 수 없음

-> 나이를 바꿀 때도 withAge로 새 객체를 생성하므로 원본 객체는 변하지 않고 부수효과가 최소화됨

 

 

ImmutableMain1.java

package functional;

public class ImmutableMain1 {
    public static void main(String[] args) {
        MutablePerson m1 = new MutablePerson("Kim", 10);
        MutablePerson m2 = m1;
        m2.setAge(11);
        System.out.println(m1);
        System.out.println(m2);


        ImmutablePerson i1 = new ImmutablePerson("Kim", 10);
        ImmutablePerson i2 = i1.withAge(21);
        System.out.println(i1);
        System.out.println(i2);
    }
}

 

실행 결과

-> m1, m2가 동일 객체를 참조하므로 하나를 변경하면 원본도 변화함

-> ImmutablePerson은 withAge 호출 시 새 객체를 생성하기에 원본이 유지됨

 

 

 

4. 일급 시민 함수(First-class Citizen)

package functional;

import java.util.function.Function;

public class FirstClassCitizenMain {
    public static void main(String[] args) {

        // 함수를 변수에 담음
        Function<Integer, Integer> func = x -> x * 2;

        // 함수를 인자로 전달
        applyFunction(10, func);

        // 함수를 반환
        getFunc().apply(10);
    }

    private static Function<Integer, Integer> getFunc() {
        return x -> x * 2;
    }

    private static Integer applyFunction(int i, Function<Integer, Integer> func) {
        return func.apply(i);
    }
}

-> 함수를 변수처럼 취급

 

 

 

5. 선언형 접근

package functional;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class DeclarativeMain {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // 명령형: for문과 조건 검사로 처리
        List<Integer> result1 = new ArrayList<>();
        for(int number : numbers) {
            if(number % 2 == 0) { // 짝수인지 확인
                result1.add(number * number); // 제곱한 값을 추가
            }
        }
        System.out.println("Imperative Result: " + result1);

        // 선언형: 스트림 API로 처리
        Stream<Integer> result2 = numbers.stream()
                .filter(number -> number % 2 == 0)
                .map(number -> number * number);

        System.out.println("Declarative Result: " + result2);
    }
}

-> 명령형 방식은 어떻게 처리할지를 구체적으로 작성

-> 선언형 방식은 스트림의 filter, map 등을 조합해 무엇을 할지에 집중

 

 

 

7. 함수 합성(Composition)

package functional;

import java.util.function.Function;

public class CompositionMain {
    public static void main(String[] args) {
        // 1. x를 입력받아 x * x 하는 함수
        Function<Integer, Integer> square = x -> x * x;

        // 2. x를 입력받아 x + 1 하는 함수
        Function<Integer, Integer> add = x -> x + 1;

        // 함수 합성
        // 1. compose()를 사용해 새로운 함수 생성
        // square(add(2)) -> (2+1) * (2+1) = 9
        Function<Integer, Integer> newFunc1 = square.compose(add);
        System.out.println(newFunc1.apply(2)); // 9

        // 2. andThen()을 사용해 새로운 함수 생성
        // 먼저 square 적용 후 add를 적용하는 새로운 함수 생성
        Function<Integer, Integer> newFunc2 = square.andThen(add);
        System.out.println(newFunc2.apply(2)); // 5
    }
}

-> compose는 뒤쪽 함수에서 앞쪽 함수 순으로 적용

-> andThen은 앞쪽 함수에서 뒤쪽 함수 순으로 적용