왜 Scala 3인가?

왜 Scala 3인가? (Why Scala 3?)

Scala를, 특히 Scala 3를 사용할 때 얻는 이점이 많아요. Scala의 모든 이점을 나열하긴 어렵지만, "Top Ten" 목록이라면 이렇게 생겼을 거예요.

출처: Scala 3 Book

본문

  • Scala는 함수형 프로그래밍(FP)과 객체지향 프로그래밍(OOP)의 융합을 안아요.
  • Scala는 정적 타입 언어지만, 종종 동적 타입 언어처럼 느껴져요.
  • Scala의 문법은 간결하면서도 여전히 읽기 쉽고, 흔히 표현적(expressive)이라고 해요.
  • Scala 2의 임플리시트(implicit)는 정의적인 특징이었는데, Scala 3에서 개선되고 단순화됐어요.
  • Scala는 Java와 매끄럽게 통합되므로 Scala와 Java 코드가 섞인 프로젝트를 만들 수 있고, Scala 코드는 수천 개의 기존 Java 라이브러리를 쉽게 사용해요.
  • Scala는 서버에서도, Scala.js로 브라우저에서도 사용할 수 있어요.
  • Scala 표준 라이브러리에는 수십 개의 미리 만들어진 함수형 메서드가 있어 시간을 아껴 주고, 직접 for 루프와 알고리즘을 작성할 필요를 크게 줄여줘요.
  • "베스트 프랙티스"가 Scala에 내장돼 있어, 불변성, 익명 함수, 고차 함수, 패턴 매칭, 기본적으로 확장할 수 없는 클래스 등을 선호해요.
  • Scala 생태계는 세계에서 가장 현대적인 FP 라이브러리를 제공해요.
  • 강력한 타입 시스템.

1) FP/OOP 융합

다른 어떤 언어보다도 Scala는 FP와 OOP 패러다임의 융합을 지원해요. Martin Odersky가 밝혔듯이 Scala의 본질은 타이핑된 환경에서 함수형과 객체지향 프로그래밍의 융합이에요. 논리는 함수로, 모듈성은 객체로 처리하죠.

모듈성의 가장 좋은 예시 중 하나가 아마 표준 라이브러리의 클래스들이에요. 예를 들어 List는 클래스로 정의돼요. 엄밀히 말하면 추상 클래스이고요. 새 인스턴스는 이렇게 만들어요.

val x = List(1, 2, 3)

하지만 프로그래머에게 단순한 List로 보이는 것은 실제로는 Iterable, Seq, LinearSeq라는 이름의 트레이트를 포함해 여러 특화된 타입들의 조합으로 만들어져요. 그 타입들도 비슷하게 다른 작은 모듈형 코드 단위들로 구성돼 있죠.

List 같은 타입을 일련의 모듈형 트레이트로 만드는 것 외에도, List API는 수십 개의 다른 메서드들로 구성돼 있어요. 많은 메서드가 고차 함수죠.

val xs = List(1, 2, 3, 4, 5)

xs.map(_ + 1)         // List(2, 3, 4, 5, 6)
xs.filter(_ < 3)      // List(1, 2)
xs.find(_ > 3)        // Some(4)
xs.takeWhile(_ < 3)   // List(1, 2)

이 예제들에서 리스트 안의 값은 수정할 수 없어요. List 클래스는 불변이라, 이 모든 메서드들은 각 주석의 데이터가 보여주듯 새 값을 반환해요.

2) 동적 언어 같은 느낌

Scala의 타입 추론은 정적 타입 언어임에도 불구하고 언어를 자주 동적 타입 언어처럼 느끼게 만들어요. 변수 선언에서도 그렇죠.

val a = 1
val b = "Hello, world"
val c = List(1,2,3,4,5)
val stuff = ("fish", 42, 1_234.5)

고차 함수에 익명 함수를 넘길 때도 마찬가지예요.

list.filter(_ < 4)
list.map(_ * 2)
list.filter(_ < 4)
    .map(_ * 2)

메서드를 정의할 때도 그렇고요.

def add(a: Int, b: Int) = a + b

이런 느낌은 Scala 3에서 그 어느 때보다 강해요. 유니온 타입을 쓸 때처럼요.

// union type parameter
def help(id: Username | Password) =
  val user = id match
    case Username(name) => lookupName(name)
    case Password(hash) => lookupPassword(hash)
  // more code here ...

// union type value
val b: Password | Username = if (true) name else password

3) 간결한 문법

Scala는 관례가 적은, "간결하지만 여전히 읽을 수 있는" 언어예요. 예를 들어 변수 선언은 간결해요.

val a = 1
val b = "Hello, world"
val c = List(1,2,3)

트레이트, 클래스, 열거형(enumeration) 같은 타입을 만드는 것도 간결해요.

trait Tail:
  def wagTail(): Unit
  def stopTail(): Unit

enum Topping:
  case Cheese, Pepperoni, Sausage, Mushrooms, Onions

class Dog extends Animal, Tail, Legs, RubberyNose

case class Person(
  firstName: String,
  lastName: String,
  age: Int
)

고차 함수도 간결해요.

list.filter(_ < 4)
list.map(_ * 2)

이 표현식들 모두와 그 밖의 것들이 간결하면서도 여전히 매우 읽기 쉬워요. 우리가 표현적(expressive)이라고 부르는 바로 그 상태인 거죠.

4) 단순해진 임플리시트 (Implicits)

Scala 2의 임플리시트는 주요한 구별되는 설계 특징이었어요. 임플리시트는 컨텍스트 위에서 추상화하는 근본적인 방식이었고, 아주 다양한 사용 사례를 제공하는 통일된 패러다임으로 쓰였죠. 그중에는:

  • 타입 클래스 구현하기
  • 컨텍스트 확립하기
  • 의존성 주입
  • 능력(capability) 표현하기

그 뒤로 다른 언어들도 비슷한 개념을 채택했는데, 모두 용어 추론(term inference)이라는 핵심 아이디어의 변형이에요. 타입이 주어지면 컴파일러가 그 타입을 가지는 "정식" 용어를 합성하는 거죠.

임플리시트는 Scala 2에서 정의적인 특징이었지만, 그 설계는 Scala 3에서 크게 개선됐어요.

  • "given" 값을 정의하는 단일한 방법이 있어요.
  • 암묵적 파라미터와 인자를 도입하는 단일한 방법이 있어요.
  • normal import의 바다 속에서 숨어들지 못하게 하는, given을 임포트하는 별도의 방법이 있어요.
  • 암묵적 변환을 정의하는 단일한 방법이 있고, 그렇게 명확히 표시되며 특별한 문법을 요구하지 않아요.

이 변화들이 주는 이점:

  • 새 설계는 기능 간 상호작용을 피하고 언어를 더 일관성 있게 만들어요.
  • 임플리시트를 배우기 쉽고, 남용하기 어렵게 만들어요.
  • 임플리시트를 쓰는 Scala 프로그램의 95%에서 명확성을 크게 개선해요.
  • 원칙적이면서도 접근 가능하고 친근한 방식으로 용어 추론을 가능하게 할 잠재력이 있어요.

이 능력들은 다른 절에서 자세히 설명하므로, Contextual Abstraction 소개와 given·using 절에 대한 섹션을 참고하세요.

5) 완벽한 Java 통합

Scala/Java 상호작용은 여러 면에서 완벽해요. 예를 들어:

  • Scala 프로젝트에서 사용 가능한 수천 개의 Java 라이브러리를 모두 사용할 수 있어요.
  • Scala String은 본질적으로 Java String에 추가 능력이 더해진 것이에요.
  • Scala는 Java java.time._ 패키지의 날짜/시간 클래스를 완벽하게 사용해요.

Scala에서 Java 컬렉션 클래스도 사용할 수 있고, 더 많은 기능을 주기 위해 그것들을 Scala 컬렉션으로 변환하는 메서드도 포함돼 있어요.

거의 모든 상호작용이 완벽하지만, "Interacting with Java" 챕터는 몇몇 기능을 함께 더 잘 사용하는 방법을 보여줘요. 예를 들어:

  • Scala에서 Java 컬렉션 사용하기
  • Scala에서 Java Optional 사용하기
  • Scala에서 Java 인터페이스 사용하기
  • Java에서 Scala 컬렉션 사용하기
  • Java에서 Scala Option 사용하기
  • Java에서 Scala 트레이트 사용하기
  • Java 코드에서 예외를 던지는 Scala 메서드 사용하기
  • Java에서 Scala varargs 파라미터 사용하기

6) 클라이언트와 서버

Scala는 훌륭한 프레임워크로 서버 측에서 사용할 수 있어요.

  • Play Framework는 고도로 확장 가능한 서버 측 애플리케이션과 마이크로서비스 구축을 가능하게 해요.
  • Akka Actors는 액터 모델로 분산·동시성 소프트웨어 애플리케이션을 크게 단순화하게 해줘요.

Scala는 JavaScript의 타입 안전 대체재인 Scala.js 프로젝트로 브라우저에서도 사용할 수 있어요. Scala.js 생태계에는 브라우저에서 React, Angular, jQuery와 많은 다른 JavaScript·Scala 라이브러리를 사용하게 해주는 수십 개의 라이브러리가 있어요.

이 도구들 외에도, Scala Native 프로젝트는 "Scala를 위해 특별히 설계된 최적화 AOT 컴파일러이자 경량 관리 런타임"이에요. 순수 Scala 코드로 "시스템" 스타일의 바이너리 실행 파일 애플리케이션을 만들게 하고, 저수준 프리미티브도 사용할 수 있게 해줘요.

7) 표준 라이브러리 메서드

Scala 표준 라이브러리의 수십 개의 미리 만들어진 함수형 메서드가 시간을 아껴 주고 여러 애플리케이션에서 코드가 더 일관성 있게 유지되도록 도와주기 때문에, 직접 커스텀 for 루프를 다시 작성해야 할 일은 거의 없을 거예요.

다음 예제들은 내장 컬렉션 메서드 중 일부를 보여주고, 이 외에도 많은 게 있어요. 모두 List 클래스를 쓰지만, 같은 메서드들이 Seq, Vector, LazyList, Set, Map, Array, ArrayBuffer 같은 다른 컬렉션 클래스에서도 동작해요.

List.range(1, 3)                          // List(1, 2)
List.range(start = 1, end = 6, step = 2)  // List(1, 3, 5)
List.fill(3)("foo")                       // List(foo, foo, foo)
List.tabulate(3)(n => n * n)              // List(0, 1, 4)
List.tabulate(4)(n => n * n)              // List(0, 1, 4, 9)

val a = List(10, 20, 30, 40, 10)          // List(10, 20, 30, 40, 10)
a.distinct                                // List(10, 20, 30, 40)
a.drop(2)                                 // List(30, 40, 10)
a.dropRight(2)                            // List(10, 20, 30)
a.dropWhile(_ < 25)                       // List(30, 40, 10)
a.filter(_ < 25)                          // List(10, 20, 10)
a.filter(_ > 100)                         // List()
a.find(_ > 20)                            // Some(30)
a.head                                    // 10
a.headOption                              // Some(10)
a.init                                    // List(10, 20, 30, 40)
a.intersect(List(19,20,21))               // List(20)
a.last                                    // 10
a.lastOption                              // Some(10)
a.map(_ * 2)                              // List(20, 40, 60, 80, 20)
a.slice(2, 4)                             // List(30, 40)
a.tail                                    // List(20, 30, 40, 10)
a.take(3)                                 // List(10, 20, 30)
a.takeRight(2)                            // List(40, 10)
a.takeWhile(_ < 30)                       // List(10, 20)
a.filter(_ < 30).map(_ * 10)              // List(100, 200, 100)

val fruits = List("apple", "pear")
fruits.map(_.toUpperCase)                 // List(APPLE, PEAR)
fruits.flatMap(_.toUpperCase)             // List(A, P, P, L, E, P, E, A, R)

val nums = List(10, 5, 8, 1, 7)
nums.sorted                               // List(1, 5, 7, 8, 10)
nums.sortWith(_ < _)                      // List(1, 5, 7, 8, 10)
nums.sortWith(_ > _)                      // List(10, 8, 7, 5, 1)

8) 내장된 베스트 프랙티스

Scala 관용 표현은 여러 면에서 베스트 프랙티스를 장려해요. 불변성을 위해 불변 val 선언을 만들도록 권장해요.

val a = 1                 // immutable variable

List, Map 같은 불변 컬렉션 클래스를 쓰도록 권장해요.

val b = List(1,2,3)       // List is immutable
val c = Map(1 -> "one")   // Map is immutable

Case class는 주로 도메인 모델링에 쓰이도록 의도됐고, 그 파라미터는 불변이에요.

case class Person(name: String)
val p = Person("Michael Scott")
p.name           // Michael Scott
p.name = "Joe"   // compiler error (reassignment to val name)

앞 절에서 봤듯이 Scala 컬렉션 클래스는 고차 함수를 지원하고, 메서드(표시 안 됨)와 익명 함수를 넘길 수 있어요.

a.dropWhile(_ < 25)
a.filter(_ < 25)
a.takeWhile(_ < 30)
a.filter(_ < 30).map(_ * 10)
nums.sortWith(_ < _)
nums.sortWith(_ > _)

match 표현식은 패턴 매칭을 사용하게 해주고, 실제로 값을 반환하는 진짜 표현식이에요.

val numAsString = i match
  case 1 | 3 | 5 | 7 | 9 => "odd"
  case 2 | 4 | 6 | 8 | 10 => "even"
  case _ => "too big"

값을 반환할 수 있기 때문에 메서드의 본문으로 자주 쓰여요.

def isTruthy(a: Matchable) = a match
  case 0 | "" => false
  case _ => true

9) 생태계 라이브러리

Cats와 Zio 같은 함수형 프로그래밍을 위한 Scala 라이브러리는 FP 커뮤니티에서 선도적인 라이브러리예요. 고성능, 타입 안전, 동시성, 비동기, 리소스 안전, 테스트 가능, 함수형, 모듈형, 바이너리 호환, 효율적, 이펙트/이펙트풀 같은 모든 유행어를 이 라이브러리들에 대해 말할 수 있어요.

여기에 수백 개의 라이브러리를 나열할 수도 있지만, 다행히 그것들은 모두 다른 곳에 나열돼 있어요. 자세한 내용은 "Awesome Scala" 목록을 참고하세요.

10) 강력한 타입 시스템

Scala는 강력한 타입 시스템을 갖고 있고, Scala 3에서 더 개선됐어요. Scala 3의 목표는 초기에 정해졌는데, 타입 시스템과 관련된 것에는 다음이 포함돼요.

  • 단순화(Simplification)
  • 비일관성 제거(Eliminate inconsistencies)
  • 안전성(Safety)
  • 인체공학(Ergonomics)
  • 성능(Performance)

단순화는 수십 개의 바뀌고 빠진 특징들로 이뤄져요. 예를 들어 Scala 2의 과부하된 implicit 키워드에서 Scala 3의 givenusing이라는 용어로의 변화는 언어를 더 명확하게 만들어요. 특히 초보 개발자에게요.

비일관성 제거는 Scala 3에서 빠진, 바뀐, 추가된 수십 가지 특징과 관련돼요. 이 범주에서 가장 중요한 특징 중 일부는:

  • 교차 타입(Intersection types)
  • 유니온 타입(Union types)
  • 암묵 함수 타입(Implicit function types)
  • 종속 함수 타입(Dependent function types)
  • 트레이트 파라미터(Trait parameters)
  • 제네릭 튜플(Generic tuples)

안전성은 몇몇 새롭고 바뀐 특징들에 관련돼요.

  • 다중 보편 동등성(Multiversal equality)
  • 암묵 변환 제한(Restricting implicit conversions)
  • 널 안전성(Null safety)
  • 안전한 초기화(Safe initialization)

인체공학의 좋은 예시는 열거형과 확장 메서드인데, 이 둘은 매우 읽기 쉬운 방식으로 Scala 3에 추가됐어요.

// enumeration
enum Color:
  case Red, Green, Blue

// extension methods
extension (c: Circle)
  def circumference: Double = c.radius * math.Pi * 2
  def diameter: Double = c.radius * 2
  def area: Double = math.Pi * c.radius * c.radius

성능은 여러 영역과 관련돼요. 그중 하나가 불투명 타입(opaque type)이에요. Scala 2에서는 도메인 주도 설계(DDD)의 실천인, 값에 더 의미 있는 타입을 부여하는 문제를 해결하려는 시도가 여러 번 있었어요. 그 시도들에는:

  • 타입 별칭(Type aliases)
  • 값 클래스(Value classes)
  • Case class

불행히도 이 접근들은 모두 Opaque Types SIP이 기술하듯 약점이 있었어요. 반대로 그 SIP이 설명하는 불투명 타입의 목표는 "이 래퍼 타입들에 대한 연산이 런타임에 추가 오버헤드를 만들지 않으면서, 컴파일 타임에 타입 안전한 사용을 제공하는 것"이에요.

타입 시스템에 대한 더 자세한 내용은 Reference 문서를 참고하세요.

그 밖의 훌륭한 특징들

Scala에는 훌륭한 특징이 많아서, Top 10 목록을 고르는 건 주관적일 수 있어요. 여러 설문조사가 서로 다른 개발자 그룹이 서로 다른 특징을 사랑한다는 걸 보여줘요. 언어를 사용하면서 더 훌륭한 Scala 특징들을 발견하게 되길 바라요.

더 알아보기

  • "A Taste of Scala" 챕터 (Scala 3 Book)
  • "Contextual Abstraction" 소개 (Scala 3 Book)
  • "Interacting with Java" 챕터 (Scala 3 Book)
  • Scala 3 Reference 문서
  • Awesome Scala 목록