함수형 프로그래밍이란?
함수를 사용하는 프로그래밍이다.
함수란?
함수란 수학적인 함수와 같다. 0개 이상의 인수를 갖으며 부작용이 없는 함수이다.
함수형 프로그래밍에서 부작용이란 연관된 객체의 필드 또는 상태가 변화하는 것을 의미한다.
위 그림과 같이,
Java에서는 수학적인 함수냐 아니냐로 메서드와 함수를 구분할 수 있다.
이러한 부작용이 없는 함수를 사용하는 것이 함수형 프로그래밍이라고 할 수 있다.
그러나 자바는 온전한 함수형 언어로써 동작하지 못한다.
가장 단순한 예시로 자바의 Scanner 클래스의 nextLine() 메서드가 있다. 이 메서드는 I/O에서 한 행을 소비한다. 두번째 호출에서는 첫번째 호출과 다른 결과값을 갖는다.
실제로 Java 프로그램에서는 이러한 부작용이 존재하지만, 이를 아무도 보지 못하게 함으로써 함수형 프로그래밍을 구현할 수 있다.
부작용을 일으키는 어떤 메서드가 있을 때, 그 값을 원상복구 시킨다면 싱글 스레드 입장에서는 부작용이 없다고 볼 수 있다.
그러나 멀티 스레드에서 접근하면 부작용이 존재하므로 이때는 Lock을 거는 것처럼 동시성 문제를 해결하여 함수임을 보장해야한다.
다음은 함수형 프로그래밍의 기본 조건이다.
- 함수나 메서드는 지역 변수만을 변경해야 함수형이라고 할 수 있으며, 그 함수가 참조하는 객체는 불변상태(final)이어야 한다.
- 어떠한 예외도 발생시키지 않아야한다.
- 비함수형 동작을 감출 수 있는 상황에서만 부작용을 포함하는 라이브러리를 사용해야한다.
반복문과 재귀 함수
다음과 같은 단순한 반복 코드가 있다.
public void searchForGold(List〈String〉 1, Stats stats) {
for(String s: 1) {
if("gold".equals(s)) {
stats.incrementFor("gold");
}
}
}
이 코드에서는 stats의 상태를 변화시키며, 함수형 프로그래밍에서는 이를 지양하는 것이 기본적인 원칙이다.
따라서 함수형 프로그래밍에서는 이런 이유뿐 아니라 다양한 이유로 반복문보다 재귀 호출 방식을 선호한다.
public int countGold(List<String> list, int index) {
if (index >= list.size()) {
return 0;
}
int count = "gold".equals(list.get(index)) ? 1 : 0;
return count + countGold(list, index + 1);
}
위 코드에서는 countGold 메소드가 리스트의 현재 인덱스에서 gold를 찾고, 나머지 리스트에서 재귀적으로 gold의 개수를 계산하여 결과를 반환합니다.
함수형 프로그래밍 언어 : Scala
결론적으로는 객체지향 패러다임을 반영하는 Java로는 온전한 함수형 프로그래밍을 구현하기 어렵다.
온전한 함수형 프로그래밍을 지향하는 개발자들은 스칼라(Scala)를 사용한다고 한다.
스칼라(Scala)는 객체지향과 함수형 프로그래밍을 혼합한 언어이다. JVM 기반으로 동작하여 자바 느낌을 원하는 프로그래머들이 많이사용한다. 스칼라코드에서는 모든 자바 라이브러리를 사용할 수 있다.
Scala를 사용한 함수형 프로그래밍은 다음과 같은 작업에서 사용된다고 한다.
- 데이터 처리 및 빅데이터: Spark는 Scala로 작성되었으며, 데이터 처리와 분석에 많이 사용된다.
- 웹 애플리케이션: Play 프레임워크는 Scala와 Java로 작성된 웹 프레임워크로, 빠르고 확장 가능한 웹 애플리케이션을 만드는 데 사용된다.
- 분산 시스템과 마이크로서비스: Akka를 통한 분산 시스템 개발과 마이크로서비스 아키텍처 구현에 많이 사용된다.
스칼라는 몇가지 자바와 다른 특징을 갖는다.
val name = "Scala" // immutable
var age = 25 // mutable
변수는 val(불변 변수) 과 var(가변 변수) 로 선언할 수 있다.
def add(x: Int, y: Int): Int = x + y
스칼라에서는 함수가 1급 객체로서, 함수 자체를 변수처럼 다른 함수에 전달하거나 반환할 수 있다.
def applyTwice(f: Int => Int, x: Int): Int = f(f(x))
applyTwice(x => x + 1, 5) // 결과: 7
고차 함수는 함수를 인자로 받거나 반환하는 함수를 뜻한다.
val number = 2
val result = number match {
case 1 => "One"
case 2 => "Two"
case _ => "Other"
}
패턴 매칭 문법은 함수형 프로그래밍의 특징상 자주 사용되며, java의 switch와 유사하지만 더 강력하다.
case class Person(name: String, age: Int)
val john = Person("John", 25)
케이스 클래스는 불변 객체를 쉽게 만들고, 패턴 매칭에서 사용하기 위해 사용하는 객체 문법이다.
trait Printable {
def print(): Unit
}
class Document extends Printable {
def print(): Unit = println("Printing document")
}
트레이트는 인터페이스와 유사하지만 구현을 포함할 수 있으며, 다중 상속이 가능하도록 한다.
이 외에도 다양한 java와 비슷한 듯 다른 문법들이 많이 존재한다. 같은 JVM 기반 언어지만, 함수형 프로그래밍의 요구사항을 만족하기 위해 특화된 언어이므로 scala는 java보다 추상화가 적고 직관적이라고 느꼈다.
Scala와 Java(+Spring Framework)의 차이점
스칼라는 Java에 비해 다음과 같은 차이점을 갖는다.
1. 비즈니스 로직과 구현의 분리
Scala는 고차 함수와 모나드(monad), for-comprehensions와 같은 기능을 활용하여 비즈니스 로직과 구현의 분리를 더욱 명확히 할 수 있다. 이러한 기능 덕분에 로깅, 트레이싱, 에러처리, 비동기 로직이 모두 깔끔하게 분리될 수 있다.
2. par 키워드를 통한 직렬/병렬 처리 선택
Scala에서는 par 키워드를 사용하여 간단하게 병렬 처리를 수행할 수 있으며, 이는 데이터 컬렉션뿐만 아니라 IO 작업에도 쉽게 적용된다. Future를 통해 비동기 작업을 매우 간결하게 사용할 수 있어 병렬화가 필요한 경우 코드가 깔끔해지고 복잡도가 줄어든다.
3. 리플렉션 없이 DI 구현 (함수 합성을 통한 DI)
Scala는 함수형 프로그래밍의 합성 원리를 기반으로 디펜던시 인젝션을 리플렉션 없이 구현할 수 있다. 함수와 객체의 조합을 통해 의존성을 삽입할 수 있어, 개발자는 코드의 흐름과 동작을 보다 직관적으로 이해할 수 있다.
하나씩 자세히 적어보겠음.
비즈니스 로직과 구현의 분리
Scala는 고차 함수와 모나드(monad), for-comprehensions와 같은 기능을 활용하여 비즈니스 로직과 구현의 분리를 더욱 명확히 할 수 있다.
스프링 AOP와는 어떤 차이가 있을까?
모나드와 함수형 프로그래밍(FP) 도구는 비즈니스 로직 내에서 Option, Try, Future와 같은 컨텍스트를 통해 에러 처리, 비동기 작업 등을 명시적으로 다룰 수 있게 하여 가독성과 안정성을 높인다.
반면, Spring AOP는 객체 지향 프로그래밍을 기반으로 리플렉션과 프록시를 활용해 로깅, 트랜잭션 관리 등 횡단 관심사를 비즈니스 로직과 분리하지만, 런타임에 적용되므로 코드에서 명확히 드러나지 않아 추적성이 떨어지고, 성능 오버헤드가 발생할 가능성이 있다.
FP 도구는 타입 시스템을 통해 컴파일 타임에 오류를 미리 확인할 수 있는 장점이 있지만, AOP는 대부분 런타임에서 오류가 드러나며 설정 문제를 사전에 인지하기 어렵다.
모나드를 사용한 간단한 예제
// Option을 사용하지 않는 경우
def divide(a: Int, b: Int): Int = {
if (b == 0) throw new ArithmeticException("0으로 나눌 수 없습니다.")
else a / b
}
val result = divide(10, 0) // 예외 발생
위 코드는 예외를 발생시키는데, 예외를 발생시킨다는 것은 함수형 프로그래밍 원칙에 어긋나는 것이다.
모나드의 기본적인 예로 Option 모나드가 있다. Option 모나드는 값이 존재할 수도, 없을 수도 있는 상황을 안전하게 처리할 수 있도록 한다. Some(value)는 값이 존재할 때를 나타내고, None은 값이 없을 때를 나타낸다.
이를 사용하여 위의 이슈를 해결하면 다음과 같은 코드가 된다.
// Option을 사용하는 경우
def safeDivide(a: Int, b: Int): Option[Int] = {
if (b == 0) None // 값이 없음을 나타냄
else Some(a / b) // 값이 있을 때를 나타냄
}
// 0이 아닌 값으로 나누는 경우
val result1 = safeDivide(10, 2) match {
case Some(value) => s"결과는 $value입니다."
case None => "0으로 나눌 수 없습니다."
}
// 0으로 나누는 경우
val result2 = safeDivide(10, 0) match {
case Some(value) => s"결과는 $value입니다."
case None => "0으로 나눌 수 없습니다."
}
println(result1) // 결과는 5입니다.
println(result2) // 0으로 나눌 수 없습니다.
여기서 safeDivide 함수는 Option 모나드를 반환하여 0으로 나누는 경우 None을 반환하고, 그렇지 않은 경우 Some(결과값)을 반환한다. 이후 호출하는 코드에서 match 구문을 통해 값이 있을 때와 없을 때를 안전하게 처리할 수 있다.
이처럼 Option 모나드를 사용하면 에러 상황을 코드 흐름 내에서 명시적으로 처리할 수 있어 코드의 안전성과 가독성을 높일 수 있다.
par 키워드를 통한 직렬/병렬 처리 선택
Scala에서는 par 키워드를 사용하여 간단하게 병렬 처리를 수행할 수 있으며, 이는 데이터 컬렉션뿐만 아니라 IO 작업에도 쉽게 적용된다. Future를 통해 비동기 작업을 매우 간결하게 사용할 수 있어 병렬화가 필요한 경우 코드가 깔끔해지고 복잡도가 줄어든다.
val results = List(1, 2, 3, 4).par.map { n => n * 2 }
리스트와 같은 컬렉션에 .par를 붙이기만 해도 병렬 처리가 이루어진다. 이로 인해 개발자가 스레드 풀이나 실행 컨텍스트를 직접 다루지 않아도 비동기 작업을 쉽게 구현할 수 있다.
val futureResult = Future {
// 비동기 작업
}
Scala의 Future 문법은 단순하다. Future 키워드 뒤에 비동기로 수행할 작업들을 나열하면 비동기로 작업이 수행된다.
하지만 Java는 이러한 비동기 처리가 쉽지않다. Java 21 부터 경량 스레드 API를 제공하지만 여전히 코루틴, 고루틴 등의 비동기 작업에 비하면 활용성이 좋지 못하다는 평가를 받는다.
List<Integer> list = Arrays.asList(1, 2, 3, 4);
list.parallelStream().map(n -> n * 2).collect(Collectors.toList());
이렇게 컬렉션 객체에 parallelStream을 적용할 수 있지만 여전히 좋지 못하다는 평가를 받는다.
코드를 더 많이 작성해야 하며 Scala만큼 직관적이지 않다. 또한, 병렬 스트림을 사용할 때 자원을 관리하는 데 있어 제한이 따를 수 있다.
리플렉션 없이 DI 구현
Java에서 Dependency Injection은 스프링 프레임워크를 통해 간편하게 구현할 수 있다.
이러한 프레임워크는 어노테이션 또는 설정 파일을 사용하여 특정 의존성을 자동으로 주입해준다. 자동 DI를 활용하면 개발자는 의존성 주입을 명시적으로 처리하지 않아도 되며, DI 컨테이너가 런타임에 필요한 객체를 자동으로 생성하고 주입한다.
이 과정에서 리플렉션과 프록시가 사용되며, 이는 개발자에게 명시적으로 보여지지 않아 디버깅에서 많은 어려움을 주는 원인이 되기도한다.
Java에서도 수동 DI(프레임워크 없이)가 가능하지만, Scala는 함수형 프로그래밍의 장점을 살려 DI를 더 간결하고 유연하게 사용할 수 있는 여러 방식을 제공한다. Scala에서는 다양한 DI 패턴을 지원하여 코드의 재사용성을 높이고 로직을 깔끔하게 구성할 수 있다.
1. 함수 합성을 통한 DI
Scala는 함수를 값처럼 다룰 수 있어 함수 합성을 통해 객체뿐 아니라 함수를 주입하는 형태로 DI를 구현할 수 있다. 이는 DI를 단순한 객체 주입의 개념을 넘어 로직 조합의 일환으로 사용하게 하며, 비즈니스 로직의 분리를 더 유연하게 할 수 있게 한다.
class BusinessLogic(log: String => Unit) {
def process(): Unit = {
log("Processing business logic")
}
}
val businessLogic = new BusinessLogic(msg => println(msg))
businessLogic.process()
위와 같이, LoggingService를 함수로 구현하고 BusinessLogic 클래스에서 특정 함수로 주입받아 실행할 수 있다. 이를 통해 필요에 따라 로그 기록 로직을 쉽게 교체할 수 있다.
2. 암묵적(Implicit) 주입
Scala는 암묵적 매개변수(implicit parameters)를 통해 DI를 프레임워크 없이 자동화할 수 있는 기능을 제공한다. 특정 매개변수를 암묵적으로 주입할 수 있어 필요한 경우마다 DI 프레임워크 없이도 객체를 자동으로 전달받을 수 있다.
trait LoggingService {
def log(message: String): Unit
}
implicit val consoleLogger: LoggingService = new LoggingService {
def log(message: String): Unit = println(message)
}
class BusinessLogic(implicit logging: LoggingService) {
def process(): Unit = {
logging.log("Processing business logic")
}
}
val businessLogic = new BusinessLogic // consoleLogger가 자동으로 주입된다.
businessLogic.process()
결론적으로, 함수형 프로그래밍 원칙을 지키면서 함수 합성을 통해 비즈니스 로직의 유연성을 높이고, 암묵적 주입을 통해 DI 프레임워크 없이도 자동화된 주입을 간편하게 사용할 수 있다.
스칼라, 좋아보이지만 사람들이 자바 스프링을 선택하는 이유
스칼라는 함수형 프로그래밍의 강점을 통해 비즈니스 로직과 구현을 깔끔하게 분리하고, 데이터 처리와 병렬 처리에서 효율적이기에 확실히 매력적이다. 하지만 현실에서 사람들은 자바 스프링을 더 많이 선택한다.
자바 스프링은 검증된 안정성, 방대한 생태계, 그리고 강력한 기술 지원 덕분에 엔터프라이즈 애플리케이션 개발에서 계속해서 사랑받고 있다.
스프링의 DI, AOP, 그리고 Spring Boot 같은 도구는 신속한 개발과 유지보수를 돕고, 대규모 서비스가 요구하는 다양한 기능들을 안정적으로 제공하는 데 큰 강점이 된다.
결국 스칼라가 제공하는 함수형 프로그래밍의 이상은 매력적이지만, 실용적이고 확장성이 뛰어난 자바 스프링이 기업 환경에서 더 널리 쓰이는 이유다.
참고
도서 : 모던 자바 인 액션
'개발 지식' 카테고리의 다른 글
좋은 개발자 이력서란 무엇일까? (1) | 2024.12.04 |
---|---|
로그인은 어떻게 구현해야 하는가? (0) | 2024.10.01 |