자바 개발자를 위한 스칼라

스칼라를 사용하면 자바 개발자로 쌓아온 경험을 그대로 살리면서, 훨씬 간결하고 탄탄한 코드를 빠르게 작성할 수 있습니다.

백엔드 개발에 가장 널리 쓰이는 언어 중 하나는 자바(Java)입니다. 백엔드 개발자로 취업을 하려면 자바를 공부해야 하고, 취업하고 나서도 거의 대부분 자바 코드를 다루게 될 것입니다. 자바 생태계는 매우 거대하고 탄탄하며, 개발 작업에 필요한 거의 모든 것이 이미 잘 준비돼 있기 때문에, 무얼 새로 만들 필요 없이, 기존 라이브러리를 잘 가져다 활용하기 좋습니다.

자바를 익숙하게 쓰면서 만족하는 개발자도 많습니다. 전혀 문제 될 게 없습니다. 하지만, 그 강력한 자바 생태계를 더 간결한 문법으로 훨씬 편하게 쓸 수 있다면 어떨까요? 게다가, 다들 좋다고 하는 함수형 프로그래밍을 배워 활용하면서, 이미 내가 잘 알고 있는 객체지향(OOP)도 버리지 않고 함께 활용할 수 있는 좋은 언어가 있다면요? 조금의 노력만으로 내 개발 생산성과 자신감을 대폭 높일 수 있다면요?

스칼라(Scala)는 딱 그런 기대를 만족시킬 프로그래밍 언어입니다. 자바 가상 기계(JVM) 환경 그대로, 자바 라이브러리를 곧바로 쓸 수 있고, 함수형 프로그래밍과 객체 지향 프로그래밍의 강점을 모두 누릴 수 있으며, 훨씬 간결한 코드를 작성할 수 있습니다.

대상 독자와 과정 목표

아무래도 스칼라의 저변이 넓지는 않기 때문에, 새로 개발을 배우는 분들이나 개발자 취업을 목표로 학습 중이신 분들에게 추천하기는 어렵습니다. 이미 자바를 다룰 수 있는 개발자를 대상으로 하며, 자바와 스칼라 문법을 비교해 가며 설명합니다. 기존 자바 개발 지식을 발판 삼아, 빠르게 스칼라의 전체적인 모습을 이해하는 것이 목표입니다.

과정이 끝나면, '스칼라 공부해 볼 만한 가치가 있다'는 결론에 이르면 좋겠습니다.

스칼라 2와 스칼라 3

이 자료를 작성하는 2023년 현재, 스칼라 2와 스칼라 3가 둘 다 널리 쓰이고 있습니다. 마치 파이썬 2와 파이썬 3가 한동안 널리 쓰였듯이, 스칼라도 비슷하게 2와 3이 함께 쓰이는 시절이 당분간 지속될 것 같습니다. 이 자료에서는 스칼라 3.2.2를 기본으로 설명합니다. 파이썬 3와 마찬가지로 스칼라 3가 곧 기본이 될 거라 예상합니다.

저자 소개

김대현은, 카카오(Daum), NHN, LINE+에서 오랫동안 개발자, 개발팀장으로 일했고, 현재는 컨스택츠에서 백엔드 개발자로 일하고 있습니다. 함수형 프로그래밍에 관심이 많아서 클로저(Clojure), 스칼라(Scala), 하스켈(Haskell)을 직업적으로 활용한 경험이 있습니다. 실무 경험을 바탕으로, 그중 가장 실용적인 함수형 프로그래밍 언어는 스칼라라는 결론에 이르러 스칼라를 더 널리 소개할 가치가 있다는 생각에, 이 미니북을 쓰게 되었습니다.

소스 코드 저장소

온라인 강의

  • 이 책의 내용은, 인프런에서 (유료) 온라인 강의로도 제공 중입니다.

연락처: hatemogi at gmail.com

왜 스칼라를 배워야 할까요?

우선 자바보다 훨씬 간결한 문법으로 자바 플랫폼을 위한 코드를 작성할 수 있습니다. 개발자들은 매일 많은 양의 코드를 작성하며, 또 내가 과거에 작성한 코드나 동료나 전 세계 다른 개발자가 작성한 코드를 읽으며 일합니다. 다뤄야 할 코드의 양이 적다는 것은, 한 화면의 코드에서 더 적은 에너지로 더 많은 분량을 파악하고 고민하며 개발할 수 있다는 뜻이며, 그만큼 생산성이 향상된다는 뜻입니다.

게다가 자바 코드와 상호운용(interoperability)이 매우 편하고 직관적이라서, 아주 쉽게 기존 자바 코드를 그대로 활용하면서 스칼라 코드를 추가해 나가는 방식으로도 쓸 수 있습니다. 수많은 자바 라이브러리를 그대로 가져다 쓰는 것은 물론, 우리 회사, 우리 팀 내에서 이미 개발한 자바 코드를 별다른 수고 없이 그대로 활용하기도 좋습니다.

또 다른 측면은 스칼라가 함수형 프로그래밍(functional programming) 언어라는 점입니다. 함수형 프로그래밍은 탄탄한 코드를 더 사람이 이해하기 쉬운 관점에서 접근합니다. 하지만, 아직까지 대세는 명령형 프로그래밍이고, 하루아침에 함수형 프로그래밍을 본격적으로 하려고, 하스켈 같은 순수 함수형 프로그래밍 언어를 배우며 이전하는 것은 현실적으로 어렵습니다. 하지만 스칼라는, 명령형 객체지향 프로그래밍을 하면서도 함수형 프로그래밍을 차근차근 조금씩 부담 없이 적용해 나갈 수 있는 독보적인 프로그래밍 언어입니다.

자바 환경 상호운용성 + 간결한 문법 + OOP + FP

스칼라와 자바의 공통점

스칼라는 자바와 마찬가지로 자바 가상 기계(JVM)에서 동작합니다.

  • 스칼라 코드도 바이트 코드(.class 파일)로 컴파일되며, JAR 파일로 패키지를 묶기도 하고, 자바 가상 기계(JVM)에서 실행합니다.
  • 스칼라도 객체 지향 프로그래밍 언어입니다.
  • 스칼라도 정적(static) 타입 프로그래밍 언어입니다. 컴파일 시점에 타입을 검사합니다.
  • 둘 다 람다 함수를 지원합니다.
  • 둘 다 IntelliJ나 VS Code를 개발 통합 환경(IDE)으로 활용합니다.
  • Gradle, Ant, Maven 같은 빌드 도구를 활용해 프로젝트를 빌드합니다. 스칼라 전문 빌드 도구, sbt도 있습니다.
  • 웹 애플리케이션 백엔드, 마이크로서비스 구축, 머신러닝 등에 활용할 라이브러리나 프레임워크가 매우 풍부합니다. 만약 스칼라 라이브러리가 없다면 자바 라이브러리를 그대로 가져다 쓰면 됩니다.

스칼라와 자바가 다른점

스칼라는 자바 환경을 그대로 쓰면서도, 더 나은 점이 많습니다.

  • 스칼라의 문법은 매우 간결합니다.
  • 스칼라도 정적 타입 프로그래밍 언어이지만, 강력한 타입 추론 기능에 힘입어서, 마치 동적 타입 언어처럼 편리하게 씁니다.
  • 스칼라는 순수 객체지향 프로그래밍 언어입니다. 그래서 모든 객체가 클래스의 인스턴스이며, ++=처럼 연산자로 보이는 기호들도, 사실은 모두 메서드입니다.
  • 스칼라는 순수 OOP 언어이면서 동시에 함수형 프로그래밍 언어이기도 합니다.
  • 스칼라의 모든 것은 식(expression)입니다. if문이나, for문, 패턴 부합, 그리고 try/catch절조차 모두 하나의 식(expression)이며, 각 식을 평가하면 결괏값이 반환됩니다. return 키워드 없이도 최종 평가식이 함수나 메서드의 반환값이 됩니다.
  • 스칼라는 변수나 컬렉션 모두 불변값 사용을 지향합니다.
  • 스칼라는 null을 잘 안 쓰기 때문에, NullPointerException의 영향을 덜 받습니다.

코드 수준 차이점

  • 스칼라에서는 식(expression) 끝에 세미콜론(;)을 쓰지 않아도 됩니다.
  • 중괄호({}) 대신 들여쓰기로 코드 블록을 묶습니다. (중괄호를 써도 됩니다)
  • 자바의 final 변수처럼 val로 불변(immutable) 변수를 선언하고, var로 변이(mutable) 변수를 선언합니다.
  • for함축문이 단순 반복문 수준을 넘어 더 강력하고 편리한 기능을 제공합니다.
  • 패턴 부합(pattern matching) 기능이 있습니다.
  • 여러 트레이트(trait)를 한 클래스나 한 오브젝트에 섞어 넣을 수 있습니다.
  • 확장(extension) 메서드로 닫힌(closed) 클래스에도 새 기능을 추가할 수 있습니다.
  • 스칼라에는 최신 오픈 소스 함수형 프로그래밍 라이브러리들이 있습니다. (cats, scalaz)
  • 케이스 클래스(case class)로 함수형 데이터 모델을 만들기 좋고, 패턴 부합에도 유용합니다.
  • 이름(by-name) 기반 파라미터와 중위 표기법(infix notation), 괄호 생략, 확장 메서드, 고차 함수를 활용해서, 나만의 제어문이나 영역 특화 언어(DSL)을 쉽게 만들 수 있습니다.

스칼라 개발 환경

내 로컬 환경에서 사용할 빌드 툴과, 통합 개발 환경(IDE)을 소개하고, 웹 브라우저에서 곧바로 스칼라 코드를 실험해볼 수 있는 방법을 알려드립니다.

스칼라 빌드 도구

프로젝트 단위로 여러 소스 파일을 작성해 개발할 때는, 컴파일러를 직접 다루기보다는, 빌드 도구 이용해서 컴파일하거나 테스트합니다. 스칼라 프로젝트에 활용할 빌드 도구는 기존 자바 프로젝트에서 활용하던 빌드 도구를 그대로 써도 되고, 스칼라를 위한 빌드 도구인 sbt를 사용해도 됩니다.

자바 프로젝트에서 쓰던 Gradle이나 Maven

스칼라도 자바와 마찬가지로 JVM 생태계의 기능을 활용할 수 있습니다. 그러므로, 자바 프로젝트에 흔히 쓰는 그레이들(Gradle)이나 메이븐(Maven)을 써서 스칼라 프로젝트를 개발해도 됩니다. 자바 개발자에게 이미 익숙한 도구이기 때문에, 처음에는 이미 내가 잘 알고 있는 빌드 도구를 그대로 이용해도 좋겠습니다.

스칼라 라이브러리 역시도 메이븐 저장소(maven repository)에 공개되기 때문에, 스칼라 전용 라이브러리도 전혀 문제없이 의존성 관리를 할 수 있습니다.

스칼라 전문 빌드 도구, sbt

아니면, 스칼라 전용으로 특화된 sbt를 사용하는 방법도 좋습니다. 본격적인 스칼라 개발을 하려면, sbt를 사용하는 것을 권합니다. 그레이들에는 build.gradle이 있듯, sbt에는 buld.sbt 파일에 빌드 설정을 작성하며, 빌드 설정도 스칼라 언어로 기술합니다.

그레이들은 그루비나 코틀린 언어를 사용하고, 메이븐은 xml을 사용하는데요, sbt는 스칼라 언어를 쓰기 때문에, 평소 스칼라 소스코드를 작성하듯이 빌드 설정을 기술할 수 있습니다.

통합 개발 환경 (IDE)

학습 실험 단계를 넘어서 본격 개발 작업에 들어서면, 소스 코드를 작성하고, 컴파일 해보고, 디버그도 하고, 버전 관리도 하는 통합 개발 환경(IDE)을 쓰는 게 기본입니다.

IntelliJ

자바 개발을 할 때에는 IntelliJ를 쓰는 개발자가 많습니다. 그만큼 기능도 훌륭하고 안정적이며, 세상의 수많은 개발자들을 만족시키고 있는 제품입니다.

IntelliJ 환경에도 스칼라 프로그래밍을 지원하는 플러그인이 매우 활발하게 개발되고 있습니다. 해당 무료 플러그인을 쓰면 스칼라 프로그래밍을 하면서, 자바 개발할 때 익숙하게 쓰던 인텔리J를 그대로 활용할 수 있습니다.

VS Code

마이크로소프트의 비주얼 스튜디오 코드도, 통합 개발 환경으로 쓰기에 매우 훌륭합니다. 역시 스칼라 지원 확장 프로그램을 설치해서 쓰면 됩니다.

인텔리J와 VS Code 둘 다 워낙 우수하기 때문에 무얼 선택해도 좋은 선택이겠습니다. 평소 익숙하고 좋아한 도구가 무엇이었느냐에 따라 결정하면 되겠습니다.

웹 브라우저에서 스칼라 맛보기

간단한 실험을 할 때는, 빌드 도구를 내 컴퓨터에 설치하거나 스칼라 통합 개발 환경을 따로 준비하지 않고도, 웹 브라우저에서 바로 확인해 볼 수 있습니다.

https://scastie.scala-lang.org/

새로운 개발 환경을 내 컴퓨터에 설치하는 일도 꽤 수고가 들기 때문에, 예제를 따라 연습할 때는 이 스카스티(scastie) 환경에서 확인해 보는 방법을 추천합니다.

이 과정을 끝내고 스칼라를 본격적으로 배워서 쓰겠다는 마음이 든다면, 그때 내 로컬 컴퓨터에 스칼라 개발 환경을 준비해도 늦지 않습니다.

예제로 보는 스칼라와 자바 비교

자바에서 흔히 쓰는 예제 코드를 스칼라 코드로 작성해서 비교해 보겠습니다.

문자열

스칼라에서 문자열은 자바의 java.lang.String을 그대로 씁니다. 평소 이해한 특성을 그대로 기대해도 되고, 자바에서 쓰던 메서드 그대로 씁니다.

문자열 대체(replace)

java.lang.String에 있는 replace 메서드를 쓰는 예제입니다. 자바와 스칼라에서 똑같이 씁니다.

자바

"Hello, World!".replace("World", "Korea"); // => "Hello, Korea!"

스칼라

"Hello, World!".replace("World", "Korea") // => "Hello, Korea!"

세미콜론(;)이 없다는 점만 다르고 다 똑같습니다.

스칼라 문자열 리터럴(literal)

스칼라에는 큰따옴표("")로 문자열 값을 나타내는 방법 말고도 다른 표현법이 있습니다.

여러 줄 문자열

val multiline =
  """안녕하세요,
  여러 줄에 걸친
  문자열입니다.
  """

큰따옴표 세 개를 연달아 써서, 소스코드 여러 줄에 걸쳐 한 문자열을 표현할 수 있습니다. 개행문자(\n)나 공백문자도 그대로 표현됩니다.

val jsonString =
  """{
    |  "message": "안녕하세요",
    |  "status": 200
    |}""".stripMargin

여러 줄에 걸쳐 문자열을 표현할 때, 소스코드 들여쓰기와 문자열의 공백을 일치시켜 보기 편하게 하려는 목적으로, stripMargin 메서드를 쓰기도 합니다.

치환값을 포함하는 문자열

자바

String name = "홍길동";
final String str = String.format("안녕하세요, %s님!", name);
// => "안녕하세요, 홍길동님!"
final String sum = String.format("1 + 2 = %d", 1 + 2);
// => "1 + 2 = 3"

스칼라

val name = "홍길동"
val str = s"안녕하세요, ${name}님!"
// => "안녕하세요, 홍길동님!"
val sum = s"1 + 2 = ${1 + 2}"
// => "1 + 2 = 3"

s로 시작하는 큰따옴표로 묶은 문자열 안에는, 문맥 안에 있는 식(expression)을 ${}로 포함해 넣을 수 있습니다.

클래스와 메서드

클래스 생성자

클래스의 인스턴스를 만드는 생성자(constructor)

자바 클래스 생성자

public class Person {
  public String name;
  public int age;

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

  @Override
  public String toString() {
    return String.format("%s님은 %d세입니다.", name, age);
  }
}

자바에서는, 클래스명과 같은 이름으로 생성자를 만듭니다. 하위 클래스에서 상위 클래스의 메서드를 재정의할 때,@Override 애너테이션을 써서 명시하면 좋습니다.

스칼라 클래스 기본 생성자

class Person(val name: String, val age: Int) {
  override def toString = s"${name}님은 ${age}세입니다."
}

스칼라에서는 클래스 이름 바로 뒤에 이어서, 주 생성자에 전달할 파라미터를 선언하는 방식으로 씁니다. 여기에, val이나 var로 받은 파라미터들은 그대로 인스턴스 변수(필드)가 됩니다.

메서드를 재정의하려면, 자바에서는 선택적으로 @Override 애너테이션을 쓰고, 스칼라에서는 override 키워드를 필수로 써야 합니다.

그리고, 자바에서는 어떤 이름(identifier) 앞에 타입을 선언하고, 스칼라에서는 이름 뒤에 타입을 명시합니다.

클래스 보조 생성자

주 생성자 말고도, 보조 생성자를 사용해 클래스 인스턴스를 만들 수 있습니다.

자바 클래스 보조 생성자

public class Person {
  public String name;
  public int age;

  // 주 생성자 (primary constructor)
  public Person(String name, int age) {
    this.name = name;
    this.age = age;
  }

  // 인수 없는 생성자
  public Person() {
    this("", 0);
  }

  // 인수 하나 받는 생성자
  public Person(String name) {
    this(name, 0);
  }

  @Override
  public String toString() {
    return String.format("%s님은 %d세입니다.", name, age);
  }
}

자바에서는 클래스명과 같은 이름으로, 파라미터가 다른 생성자를 여럿 작성해서 씁니다.

스칼라 클래스 보조 생성자

class Person(var name: String, var age: Int) {
  // 인수 없는 보조 생성자
  def this() = this("", 0)

  // 인수 하나 받는 보조 생성자
  def this(name: String) = this(name, 0)

  override def toString = s"${name}님은 ${age}세입니다."
}

스칼라에서 생성자를 추가하려면, this라는 이름으로 보조 생성자를 메서드처럼 작성합니다.

한 줄 메서드

자바

public int add(int a, int b) {
  return a + b;
}

스칼라

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

스칼라에서는 메서드 본문을 한 줄로 이어 쓸 수 있으며, return 키워드 없이도, 최종 평가식이 메서드의 결괏값으로 반환됩니다. 함수 선언부와 본문 사이에 = 기호가 있습니다. 위 add 메서드는, 본문이 단 하나의 식이며, 한 줄로 작성했습니다.

여러 줄 메서드

자바

public double triangle(double a, double b) {
    double a2 = a * a;
    double b2 = b * b;
    return Math.sqrt(a2 + b2);
}

// triangle(3.0, 4.0) => 5.0

스칼라

def triangle(a: Double, b: Double): Double = {
  val a2 = a * a
  val b2 = b * b
  Math.sqrt(a2 + b2)
}

스칼라에서도 중괄호({})로 묶어서 여러 줄짜리 메서드 본문을 선언합니다. 따로 return 키워드를 쓰지 않고도, 마지막 식의 결괏값이 메서드의 반환값이 된다는 점이 다릅니다.

필드 변수

자바

final int i = 1;
i = 2; // => 컴파일 에러

자바에서 변수를 만들고, 이후 변경을 막으려면 final 키워드를 붙입니다.

스칼라

val i = 1
i = 2 // => 컴파일 에러

스칼라에서는 val 키워드로 시작해 변수를 만들면, 이후에 값을 바꿀 수 없습니다. 타입을 명시하지 않으면, 스칼라 컴파일러가 적절한 타입을 추론(type inference)합니다. 위 예제의 경우, 변수 i에 대입하는 값이 1이고, 이 값의 기본 타입은 Int이기에, 변수 i의 타입은 Int로 추론됩니다.

자바

int i = 1;
var j = 1;

i = 2; // => 변경 가능
j = 3; // => 변경 가능

자바에서 변수는 기본적으로 변경 가능합니다. 자바 10에 추가된 var키워드를 쓰면, 타입 선언을 생략할 수 있습니다.

스칼라

var i = 1
i = 2 // => 변경 가능

스칼라에서 변경 가능한 변수를 만들려면 var 키워드를 써서 변수를 선언합니다.

정적(static) 메서드

자바

public class StringUtils {
  public static boolean isNullOrEmpty(String s) {
    return s == null || s.trim().isEmpty();
  }
}

자바에서, 정적 static 메서드나 필드는 선언된 클래스들이 공유하게 됩니다. 그리고 public 멤버인 경우 외부에서 곧바로 접근할 수 있습니다.

스칼라

object StringUtils {
  def isNullOrEmpty(s: String): Boolean =
    s == null || s.trim.isEmpty
}

스칼라에서는 클래스 안에 static으로 메서드나 필드를 선언하지는 않고, 오브젝트(object)를 사용합니다. 오브젝트로 만들면 단일(singleton) 인스턴스가 준비되며, 오브젝트 안에 있는 메서드나 필드는, 마치 자바의 static 멤버들처럼 접근해 쓸 수 있습니다.

인터페이스, 트레이트, 상속

인터페이스

자바

public interface Adder {
  int add(int a, int b);
}

자바에서 인터페이스를 만들어, 클래스들이 구현해야 하는 필수 메서드를 선언하고 객체끼리 서로 소통하는 기준과 약속을 정합니다.

스칼라

trait Adder {
  def add(a: Int, b: Int): Int
}

스칼라에서는 트레이트(trait)가 자바의 인터페이스(interface)와 비슷한 일을 할 수 있습니다. 추가 기능도 있습니다만, 우선은 트레이트와 인터페이스가 같은 거라고 생각해 봅시다.

메서드 구현체를 포함한 인터페이스

자바

public interface Adder {
  int add(int a, int b);
  default int multiply(int a, int b) {
    return a * b;
  }
}

자바 8부터는 인터페이스에 디폴트(default)메서드를 선언할 수 있습니다.

스칼라

trait Adder {
  def add(a: Int, b: Int): Int
  def multiply(a: Int, b: Int): Int = a * b
}

스칼라의 트레이트에는 별도 키워드 없이도, 평범히 메서드에 본문을 선언해 둘 수 있습니다.

여러 인터페이스를 구현하는 클래스

자바

interface Adder {
  default int add(int a, int b) {
    return a + b;
  }
}

interface Multiplier {
  default int multiply (int a, int b) {
    return a * b;
  }
}

public class JavaMath implements Adder, Multiplier {}

JavaMath jm = new JavaMath();
jm.add(1, 1);      // => 2
jm.multiply(2, 2); // => 4

여러 인터페이스를 구현(implement)한 클래스 입장에서는, 각 인터페이스에 있는 기본(default) 메서드를 문제없이 호출할 수 있습니다.

스칼라

trait Adder {
  def add(a: Int, b: Int) = a + b
}

trait Multiplier {
  def multiply(a: Int, b: Int) = a * b
}

class ScalaMath extends Adder, Multiplier

val sm = new ScalaMath
sm.add(1, 1)      // => 2
sm.multiply(2, 2) // => 4

스칼라 트레이트에 선언한 메서드도 비슷한 방식으로 호출할 수 있습니다.

제어 구조

if문 본문 한 줄

자바

if (x == 1) System.out.println(x);

자바에서 if는 주어진 조건이 참일 때만, 주어진 명령문을 실행합니다.

스칼라

if (x == 1) println(x)

스칼라에서 if도 비슷한 방식으로 씁니다.

if문 본문 여러 줄

자바

if (x == 1) {
  System.out.println("x는 1입니다:");
  System.out.println(x);
}

if문 안에 여러 구문을 넣을 때는 중괄호({})로 코드 블록을 묶습니다.

스칼라

if (x == 1) {
  println("x는 1입니다:")
  println(x)
}

스칼라도 마찬가지입니다.

if, else if, else

자바

if (x < 0)
  System.out.println("음수");
else if (x == 0)
  System.out.println("zero");
else
  System.out.println("양수");

if조건이 거짓일 때, 이어서 다음 if문으로 중첩하기도 합니다.

스칼라

if (x < 0)
  println("음수")
else if (x == 0)
  println("0")
else
  println("양수")

스칼라에서도 마찬가지로 else if로 조건문을 중첩하기도 합니다.

조건에 따른 결괏값

자바

public int min(int a, int b) {
  if (a < b)
    return a;
  else
    return b;
}

자바에서 if문은, 별도로 결괏값을 반환하는 것이 아니기 때문에, return키워드를 써서, 조건 참거짓에 따른 반환값을 결정할 수 있습니다.

스칼라

def min(a: Int, b: Int): Int =
  if (a < b) a else b

스칼라에서 if문은, 참거짓에 따른 세부 평가값이 전체 if문의 최종 결괏값이 됩니다. 따라서, 별도의 return 키워드 없이도, 전체 메서드의 반환값을 바로 줄 수 있습니다.

자바의 3항 연산자

자바

int minVal = (a < b) ? a : b;

자바의 3항 연산자가, 스칼라의 if문과 비슷하게 작동합니다.

스칼라

val minVal = if (a < b) a else b

스칼라에서는 if문이 자바의 3항 연산자와 동일하게 작동하기 때문에, 별도로 3항 연산자는 없습니다.

while 반복문

자바

while (i < 3) {
  System.out.println(i);
  i++;
}

자바에서 while문은 주어진 조건식이 참인 동안 반복됩니다.

스칼라

while (i < 3) {
  println(i)
  i += 1
}

스칼라에서도 같은 방식으로 작동하지만, 문법만 조금 다릅니다. 스칼라에는 ++ 메서드가 따로 없기 때문에, += 메서드를 써서 처리했습니다.

for 반복문 한 줄짜리

자바

for (int i: ints) System.out.println(i);

자바에서 for문으로 컬렉션이나 배열의 모든 요소에 대해 반복되는 일을 할 수 있습니다.

스칼라

for (i <- ints) println(i)

스칼라도 동일한 방식으로 for 표현식을 쓸 수 있어요. <- 기호를 쓴다는 점이 다릅니다.

for문 여러 줄짜리

자바

for (int i: ints) {
    int x = i * 2;
    System.out.printf("i = %d, x = %d\n", i, x);
}

반복할 본문이 여러 줄이라면 중괄호({})로 묶습니다.

스칼라

for (i <- ints) {
  val x = i * 2
  println(s"i = $i, x = $x")
}

스칼라에서도 마찬가지입니다.

다중 for

자바

for (int i: ints1) {
  for (int j: chars) {
    for (int k: ints2) {
      System.out.printf(
        "i = %d, j = %d, k = %d\n", i, j, k);
    }
  }
}

자바에서 for문을 여러 겹 쌓으려면, 반복할 본문 안에 다시 for문을 넣으면 됩니다.

스칼라

for {
  i <- 1 to 2
  j <- 'a' to 'b'
  k <- 1 to 10 by 5
} println(s"i = $i, j = $j, k = $k")

스칼라에서는 조금 다른 모양으로 for문을 중첩(nesting)합니다. 반복할 제너레이터(generator)에 반복 변수들을 연이어 나열하면, 결과적으로 중첩된 for문과 같아집니다. 제너레이터를 여러 줄에 걸쳐 나열할 때 중괄호로 감싼 점에 유의해주세요.

선별 조건이 있는 for

자바

List<Integer> ints =
  List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

for (int i: ints) {
  if (i % 2 == 0 && i < 5) {
    System.out.println(i);
  }
}

자바에서 반복문을 돌면서, 특정 조건에 따라서 선택적으로 처리하려면, for문 안에서 if문을 쓰면 됩니다.

스칼라

val ints = (1 to 10).toList

for {
  i <- ints
  if i % 2 == 0
  if i < 5
} println(i)

스칼라에서는 for문의 제네레이터에서 if조건을 추가해 반복 대상을 선별할 수 있습니다. 마치, for문 안에 if문이 들어있는 것과 같은 효과를 냅니다.

switch / match (1)

여러 경우를 따지는 조건문이 필요할 때, if문을 여러 번 이어서 써도 되지만, switch문을 써서 한 번에 조건들을 나열하면 편리합니다.

자바

String weekdayAsString = "";
switch (weekday) {
  case 1: weekdayAsString = "월요일";
          break;
  case 2: weekdayAsString = "화요일";
          break;
  default: weekdayAsString = "기타";
          break;
}

switch문으로, 특정 조건이 일치하는 경우(case)에 따른 명령을 수행할 수 있습니다.

스칼라

val weekdayAsString = weekday match {
  case 1 => "월요일"
  case 2 => "화요일"
  case _ => "기타"
}

스칼라에서는 패턴 부합(pattern matching) 기능으로 여러 조건을 한 번에 검사할 수 있습니다. 어떤 값 이후에 match 키워드로 시작하며, 여러 case조건을 순서대로 나열하면 됩니다. 위 예제에서 마지막 case는 밑줄(_)로 나머지 모든 경우를 처리하고 있습니다. 또, 별도의 break 키워드가 없고, case문의 결괏값이 전체 패턴 부합 기능의 결괏값이 된다는 점에 유의하세요.

패턴 부합 기능에 대해서는, 나중에 더 자세히 다룹니다.

switch / match (2)

자바

String numAsString = "";
switch (i) {
  case 1: case 3:
  case 5: case 7: case 9:
    numAsString = "홀수";
    break;
  case 2: case 4:
  case 6: case 8: case 10:
    numAsString = "짝수";
    break;
  default:
    numAsString = "범위초과";
    break;
}

자바에서 switch문을 쓸 때에 여러 조건을 한 번에 다룰 때에는, case 조건을 연이어 쓰면 됩니다. (특정 case 조건에 일치하더라도, break 키워드를 만나기 전까지는 이어서 다음 조건을 검사하게 됩니다)

스칼라

val numAsString = i match {
  case 1 | 3 | 5 | 7 | 9 => "홀수"
  case 2 | 4 | 6 | 8 | 10 => "짝수"
  case _ => "범위초과"
}

스칼라에서 여러 조건(case)을 한 번에 다루려면, | 기호로 조건들을 이어서 나열하면 됩니다. 문법만 조금 다를 뿐, 자바에서의 흐름과 같습니다.

컬렉션

시퀀스

순서대로 나열한 데이터를 다루는 시퀀스(sequence)로, 자바에서는 java.util.List를 주로 씁니다. 스칼라에서는, scala.collection.immutable.List를 쓰며, 임의 위치 접근이 빈번한 경우에는 scala.collection.immutable.Vector를 쓰기도 합니다. 스칼라의 ListVector는 기본으로 임포트 되기 때문에, 별도로 import문을 적지 않아도 됩니다.

자바

List<String> stringList = List.of("a", "b", "c");

자바 9에 추가된 List.of 메서드로 새 리스트를 만들 수 있습니다.

스칼라

val stringList = List("a", "b", "c")
val stringVector = Vector("a", "b", "c")

스칼라에서는, 처음부터 차례로 접근하는 시퀀스의 경우, List를 쓰면 되고, 임의의 n번 째 요소에 접근할 일이 잦은 시퀀스는 Vector를 쓰면 유리합니다. 위 예제에서는, 각각 타입 추론 기능으로 List[String]Vector[String] 타입의 시퀀스를 만들었습니다.

집합 (Set)

자바

Set<String> set = Set.of("a", "b", "c");

자바 9에 추가된 Set.of 메서드로 집합을 만듭니다.

스칼라

val set = Set("a", "b", "c")

스칼라에서는 scala.collection.immutable.Set을 써서 집합을 다룹니다. List, Vector와 마찬가지로, import는 필요하지 않습니다.

맵 Map

자바

Map<String, Integer> map = Map.of(
  "a", 1,
  "b", 2,
  "c", 3
);

자바9에 추가된 Map.of 메서드로 맵을 만듭니다.

스칼라

val map = Map(
  "a" -> 1,
  "b" -> 2,
  "c" -> 3
)

스칼라에서는 scala.collection.immutable.Map으로 맵을 만듭니다. ->는 튜플(tuple)을 만드는 메서드입니다. 각각의 튜플이 키-값 한 쌍을 의미합니다.

변이(mutable) 버전 컬렉션

스칼라에서는 기본적으로 불변(immutable) 컬렉션 사용을 장려하지만, 필요에 따라 변이(mutable) 컬렉션이 경우에는, scala.collection.mutable 패키지에 있는 컬렉션을 쓰기도 합니다.

자바

String[] a = {"a", "b"};

자바에서 배열(array) 타입은, 중괄호를 써서 리터럴로 만들어 쓸 수 있습니다.

스칼라

val a = Array("a", "b")

스칼라의 Array[T] 클래스가 자바의 배열(T[])에 대응됩니다. 자바의 소통을 위해 배열 값이 필요하다면 Array를 쓰면 되겠습니다. 자바의 배열과 마찬가지로, 특정 인덱스로 접근해서, 값을 변경할 수 있습니다. 자바와의 소통(interop)을 위해 배열값을 전달해야 하거나, 자바 메서드의 결괏값으로 배열을 받아올 때도 씁니다.

컬렉션 메소드

자바 8에 추가된 스트림 관련 메서드로 컬렉션을 편하게 다룰 수 있습니다.

  • map - 각 원소를 다른 값으로 변환
  • filter - 조건에 맞는 값들을 선별
  • forEach / foreach - 모든 요소에 대해 수행
  • findFirst / find - 조건에 일치하는 첫 번째 요소 찾기

자바

var squared = List.of(1, 2, 3).stream().map(x -> x * x).collect(toList());

자바 8에 추가된 Streammap을 비롯한 유용한 메서드를 써서 추리고 변환하다가 결국 리스트로 만들어 쓰기도 합니다.

스칼라

val squared = List(1, 2, 3).map(x => x * x)  // => List(1, 4, 9)

스칼라는, 별도로 Stream을 만들어 낼 필요 없이, 컬렉션마다 직접 map같은 메서드를 바로 호출해서 씁니다. 만약, 자바의 Stream처럼 지연 평가가 필요하다면, LazyList를 씁니다.

함수 리터럴

자바는 람다 함수 문법으로, 함수를 이름 없이도 만들어 쓸 수 있습니다.

익명 함수

자바

Stream.of(1, 2, 3).map(x -> x + 1).forEach(System.out::println);
// => 2, 3, 4

자바 8부터 람다 함수 문법이 추가되었습니다. 위 예제에서 x -> x + 1로, x를 인수로 받아서, 1을 더한 값을 반환하는 함수를 그 자리에서 만들었으며, 이 함수에는 따로 이름을 붙이지 않았기 때문에, 익명 함수라고도 부릅니다.

스칼라

List(1, 2, 3).map(x => x + 1).foreach(println) // => 2, 3, 4

스칼라에서도 같은 방법으로 익명 함수를 만듭니다. -> 기호 대신 => 기호를 쓰는 점이 다릅니다.

스칼라 익명 함수 파라미터 줄임 표현

스칼라에서는 익명 함수를 줄여 표현하는 방법이 있습니다.

List(1, 2, 3).map(x -> x + 1)

위 스칼라 구문은 아래와 똑같습니다.

List(1, 2, 3).map(_ + 1)

밑줄(_)이 첫 번째 파라미터를 의미하는 익명 함수 표현식입니다.

익명 함수를 값으로 다루기

다른 메서드에 람다 함수를 넘길 때 쓰는 경우가 가장 흔합니다만, 평범한 값으로도 함수를 다룰 수 있습니다. 평범한 값으로 다룬다는 뜻은, 어떤 변수에 담는다거나, 다른 메서드에 파라미터로 전달한다거나, 아니면 메서드의 반환 값으로도 사용할 수 있다는 의미입니다. 함수를 마치 문자열 다루듯이 평범한 값으로 취급할 수 있다는 뜻이죠.

자바

Function<Integer, Integer> add1 = x -> x + 1;
Stream.of(1, 2, 3).map(add1).forEach(System.out::println);

익명 함수 i -> i + 1add1이라는 변수에 담았습니다. 그 타입은 Function<Integer, Integer>이고, 이는 Integer 하나를 받아서 Integer를 반환하는 함수 인터페이스를 뜻합니다. 그다음 map메서드에 이 add1라는 변수를 그대로 전달했습니다.

스칼라

val add1 = (x: Int) => x + 1
List(1, 2, 3).map(add1) // =>  List(2, 3, 4)

스칼라에서도 똑같은 방식으로 사용합니다. 타입을 구체적으로 명시하지 않고, 타입 추론으로 add1 함수를 만들었습니다.

함수 합성하기

자바

Function<Integer, Integer> add1 = x -> x + 1;
Function<Integer, Integer> square = x -> x * x;
Function<Integer, Integer> add1Square = square.compose(add1);

Stream.of(1, 2, 3).map(add1).map(square).forEach(System.out::println);
Stream.of(1, 2, 3).map(add1Square).forEach(System.out::println);
// => 4, 9, 16

1을 더하는 함수 add1과, 제곱을 하는 square 함수를 연이어 map 메서드에 전달하면, 최종적으로는 \( (x + 1)^2 \) 값을 구할 수 있습니다. 수학시간에 배운 합성 함수 \( g \circ f \)를 만드는 compose 메서드를 활용해서, 새로운 익명 함수를 만들어서 한 번에 처리해도 됩니다.

스칼라

val add1 = (x: Int) => x + 1
val square = (x: Int) => x * x
val add1Square = square.compose(add1)

List(1, 2, 3).map(add1).map(square) // => List(4, 9, 16)
List(1, 2, 3).map(add1Square)       // => List(4, 9, 16)

스칼라에서도 마찬가지로 함수를 합성해서 쓸 수 있습니다.

예외 처리

자바 프로그램 실행 중, 에러가 발생하는 등의 상황을 다룰 때, 예외(Exception)를 씁니다. 코드 수행 중간에 예외가 발생하는 경우 해당 예외가 호출스택을 따라 전파되고, 전파된 예외는 try - catch 구문으로 잡아서 처리합니다.

자바

try {
  writeTextToFile(text);
} catch (IOException ioe) {
  System.out.println(ioe.getMessage());
} catch (NumberFormatException nfe) {
  System.out.println(nfe.getMessage());
} finally {
  System.out.println("여기서 리소스 정리");
}

위 코드는 writeTextToFile 메서드에서 파일에 저장하다가 예외가 발생한 경우, 예외의 종류에 따라 IOException일 때나, NumberFormatException일 때를 잡아서 예외 메시지를 출력하도록 했습니다. 정상 처리된 경우나, 예외가 발생한 경우 모두에 반드시 마무리해야 할 작업은 finally문 안에서 정리합니다.

스칼라

try {
  writeTextToFile(text)
} catch {
  case ioe: IOException =>
    println(ioe.getMessage)
  case nfe: NumberFormatException =>
    println(nfe.getMessage)
} finally {
  println("여기서 리소스 정리")
}

스칼라에서도 try - catch - finally 문이 똑같은 흐름으로 수행됩니다. catch 구문의 문법이 패턴 부합(pattern matching) 기능 형태의 문법으로 작성되는 점이 다릅니다.

검사 필수 예외 (Checked Exception)

자바

자바에서 예외는 크게 두 종류가 있고, (1) 그 중 검사 필수 예외(checked exception)는, 메서드 선언부에 해당 예외가 발생할 수 있다고 명시해야 합니다. (2) 하지만, RuntimeException의 하위 클래스 예외인 경우에는 검사를 강제하지 않고, 메서드 선언부에 명시하지 않습니다.

스칼라

스칼라에서는 예외 검사를 강제하지 않습니다. 자바 코드를 사용하면서 발생하는 예외를 포함한 모든 예외에 대해 검사를 필요로 하지 않습니다. 즉, 스칼라에서는 검사 필수 예외가 따로 없습니다.

예외를 다른 방식으로 다루기

자바 8에 추가된 Optional<T>를 이용해서, 타입 안전(type-safe)하게 "값이 없는 경우"를 다룰 수 있습니다.

자바

import java.util.Optional;

Optional<Integer> makeInt(String s) {
  try {
    return Optional.of(Integer.parseInt(s));
  } catch (NumberFormatException e) {
    return Optional.empty();
  }
}

위 예제에서는 주어진 문자열 s를 정수로 변환할 수 있으면, Optional<Integer>안에 변환에 성공한 정수값이 담기게 되고, 변환할 수 없는 경우에는, empty()를 반환합니다. Optional<Integer> 안에는 Integer로 담겨 있다는 것을 알기 때문에 Integer타입에 대한 연산을 안전하게 수행할 수 있습니다.

스칼라

def makeInt(s: String): Option[Int] =
  try {
    Some(s.toInt)
  } catch {
    case e: NumberFormatException => None
  }

스칼라에서도, 자바의 Optional<T>와 비슷한 Option[T]를 사용합니다.

makeInt("123") match {
  case Some(i) => println(s"정수 i = $i")
  case None => println("정수로 변환할 수 없어요")
}

Option[T]를 사용하는 코드에서는 match 문을 이용해서 값 유무에 따른 처리를 합니다. 첫번째 case Some(i)에서 패턴 부합 기능으로 i값을 바로 꺼낼 수 있다는 점을 눈여겨 보세요.

(스칼라) 둘 중 하나의 값 - Either

Option[T]는 어떤 값이 정상적으로 있는지 없는지를 다룰 때 유용하게 쓸 수 있습니다.

만약, 정상 값이 없을 때 에러 내용 따위를 알고 싶다면 스칼라의 Either[L, R]를 쓸 수 있습니다. Either로 둘 중 하나의 값을 택일해서 쓰는데, 하나는 실패 사유에 쓰고, 다른 하나는 정상값을 표현하는데 쓰곤 합니다.

def makeInt(s: String): Either[String, Int] =
  try {
    Right(s.toInt)
  } catch {
    case e: NumberFormatException => Left(e.getMessage)
  }
makeInt("123") match {
  case Left(e)  => println("에러: " + e)
  case Right(i) => println(s"정수 i = $i")
}

Either값은 "왼쪽 값" 또는 "오른쪽 값"을 담을 수 있는데, 관례로 왼쪽(Left) 값을 예외적인 값으로 활용하고, 오른쪽(Right)을 정상값으로 씁니다. 아마도 Right라는 단어가 오른쪽을 의미하기도 하지만, 올바른 값이라는 중의가 있기 때문인 것 같습니다.

(스칼라) 예외도 값으로 다루자 - Try

예외가 발생하면 기본적으로 메서드 호출 스택을 따라서 전파되게 됩니다. 예외가 발생한 가장 안쪽에서부터 바깥쪽으로 전파되는 과정이 기본입니다만, 이를 전파하지 않고 Try[T]로 감싸서 예외를 으로 다루는 방법도 있습니다.

Try[T]는 둘 중 하나의 값이 되는데, 하나는 정상값 T를 담고 있는 Success이고, 다른 하나는 실패한 예외(Exception)를 담고 있는 Failure입니다.

def makeInt(s: String): Try[Int] = Try(s.toInt)
makeInt("1024") match {
  case Success(n) => println(s"정수 변환: $n")
  case Failure(e) => println(s"예외 발생: $e")
}

Try로 감싼 코드가 정상적으로 실행되면, Success로 결괏값이 담기고, 실행 중 예외가 발생하면 Failure안에 발생한 예외가 담겨 전파되지 않은 채로 남아있게 됩니다.

성패 결과를 값으로 갖고 있다가, 나중에 원하는 시점에 별도로 처리하기 좋습니다.

스칼라에만 있는 기능

이제부터, 자바에는 없거나, 간접적으로 작성해야 하는 기능들을 소개합니다. 처음에는 낯설어서 어렵게 느껴질 수도 있습니다만 알고 보면 간단하고 유용한 기능들입니다.

자바의 문법들을 처음 배울 때 어려웠던 것들도 이내 익숙해지고 자연스러워지듯, 스칼라의 기능들도 마찬가지로 금방 익숙해집니다. 이제부터 소개하는 기능에 익숙해지면 훨씬 더 스칼라다운 코드를 읽고 작성할 수 있게 될 것입니다.

단일 오브젝트 (Object)

클래스와 메서드 설명할 때 잠깐 언급했던 오브젝트에 대해 더 알아보겠습니다. 스칼라의 object는 자바의 단일(singleton) 인스턴스와 비슷합니다. JVM 프로세스 전체에서 딱 하나의 인스턴스가 존재하며, 따로 인스턴스를 생성하지 않고도 멤버 메서드나 변수에 접근해 쓸 수 있습니다.

object StringUtils {
  def isNullOrEmpty(s: String): Boolean =
    s == null || s.trim.isEmpty
}

자바로 생각하자면, StringUtils.isNullOrEmpty라는 정적(static) 메서드를 선언한 것과 비슷합니다.

동반(companion) 오브젝트

단일 오브젝트만 홀로 써도 되지만, 똑같은 이름의 클래스와 더불어 쓸 때도 있습니다. 어떤 클래스와 오브젝트가 서로 이름이 같다면, 그 오브젝트를 동반(companion) 오브젝트라 부릅니다. 마찬가지로, 해당 클래스를 그 오브젝트의 동반 클래스라고 부릅니다. 동반 클래스나 오브젝트는 서로의 프라이빗(private) 멤버에 접근할 수 있습니다.

class Car(val cc: Int) {
  import Car._
  def taxPerYear: Int = taxPerCc(cc).intValue * cc
}

object Car {
  // 배기량에 따른 cc당 자동차세 + 교육세 30%
  private def taxPerCc(cc: Int) =
    (if cc <= 1000 then 80
     else if cc <= 1600 then 140
     else 200) * 1.3
}

val car = new Car(2000)
car.taxPerYear // => 520000

이 예제에서 Car라는 같은 이름으로, 동반 클래스와 오브젝트를 준비했고, 동반 오브젝트에 있는 taxPerCc라는 프라이빗 메서드를, 동반 클래스에서 호출했습니다.

메서드 파라미터

이름 지정(named) 파라미터

스칼라도 자바와 마찬가지로, 메서드 선언부에 적은 파라미터 순서대로 전달하는 것이 기본입니다.

def update(key: String, value: Int) =
  println(s"$key -> $value")

update("x", 3)
update(key = "x", value = 3)
update(value = 3, key = "x")  // 좋은 방법은 아니지만, 순서를 바꿔 쓸 수도 있습니다.

추가로, 파라미터 이름을 적어서 전달하는 방법도 있어서, 같은 타입의 파라미터가 많은 경우나, 바로 다음에 설명하는 파라미터 기본값 중, 일부를 지정할 때 쓸 수 있습니다.

파라미터 기본값

메서드에 전달할 파라미터에 기본값을 선언해 두면, 메소드를 호출할 때 해당 파라미터를 생략할 수 있습니다. 생략하면 기본으로 지정한 값이 파라미터로 전달됩니다.

def greeting(name: String, role: String = "개발자") =
  println(s"안녕하세요, ${role} ${name}님.")

greeting("길동")          // => 안녕하세요, 개발자 길동님.
greeting("둘리", "기획자") // => 안녕하세요, 기획자 둘리님.

위 예제에서는, name은 평범한 파라미터로 받았고, role은 기본값을 지정해 두었기에, 메서드를 호출할 때 생략하면, 기본값인 "개발자"가 전달됩니다.

이름 참조(by-name) 파라미터

기본 파라미터는, 파라미터 자리에 전달한 식(expression)이 먼저 평가된 다음 최종값이 메서드에 전달됩니다. 메서드 호출에 앞서서, 파라미터로 전달하는 식들이 미리 평가가 끝나는 것이죠. 반면, 이름 참조(by-name) 파라미터는, 그 자리에 전달한 식을 평가하지 않고 그대로 전달했다가, 메서드 안에서 사용하는 시점에 평가합니다.

예제로 차이점을 살펴보겠습니다.

def echoInt(n: Int): Int =
  println(s"n = $n")
  n

단순히 정수 Int를 받아서 프린트한 뒤, 그대로 반환하는 함수를 작성했습니다.

def ifByValue(cond: Boolean, onTrue: Int, onFalse: Int): Int =
  if cond then onTrue
  else onFalse

ifByValue(true, echoInt(1), echoInt(2))  // => 1반환, "n = 1", "n = 2" 둘 다 프린트
ifByValue(false, echoInt(1), echoInt(2)) // => 2반환, "n = 1", "n = 2" 둘 다 프린트

이어서, 특정 cond의 진위에 따라, 값을 선택해 반환하는 나만의 if 메서드를 만들었습니다. 의도는 cond 조건이 참일 때는 onTrue값을, 거짓일 때는 onFalse값을 반환받으려 한 것입니다.

그런데 문제는, 이렇게 ifByValue 메서드를 만들어 호출하면, 참과 거짓에 해당하는 두 식 모두가 미리 평가되는 문제가 있습니다. 기본 파라미터는 메서드 호출 전에 이미 모든 파라미터에 있는 식들이 평가되기 때문입니다.

def ifByName(cond: Boolean, onTrue: => Int, onFalse: => Int): Int =
  if cond then onTrue
  else onFalse

ifByName(true, echoInt(1), echoInt(2))  // => 1반환, "n = 1"만 프린트
ifByName(false, echoInt(1), echoInt(2)) // => 2반환, "n = 2"만 프린트

ifByName 메서드 선언에서처럼, onTrue: => Int 처럼 파라미터 선언부에, => 기호를 앞에 두면, 해당 파라미터는 이름 참조(by-name) 파라미터로 취급하며, 해당 파라미터에 위치한 식은, 메서드 안에서 쓰일 때가 돼서야 평가됩니다. 쓰이지 않는다면, 아예 평가하지 않습니다.

그래서, 첫번 째 ifByValue(True, ...)의 경우에는, 메서드 본문에서 echoInt(1)은 평가가 이뤄지고, echoInt(2)의 경우에는 메서드 안에서 쓰이지 않았기 때문에, 평가되지 않습니다. 따라서, echoInt(2)메서드 안에서 진행한 println은 실행되지 않아서, 화면에는 n = 1만 보이게 되는 거죠.

다중 파라미터

def normalAdder(x: Int, y: Int) = x + y
def multiAdder(x: Int)(y: Int): Int = x + y

normalAdder(2, 3) // => 5
multiAdder(2)(3)  // => 5

multiAdder 메서드의 파라미터 목록을 여러 괄호쌍으로 선언하는 다중 파라미터입니다. 평소 사용할 때는, 파라미터 사이에 쉼표 대신 추가로 괄호쌍이 더해지는 모습이 조금 다를 뿐입니다.

val xs = List(1, 2, 3)

xs.map(x => normalAdder(2, x)) // => List(3, 4, 5)
xs.map(normalAdder(2, _))      // => List(3, 4, 5)
xs.map(multiAdder(2))          // => List(3, 4, 5)

다중 파라미터 메서드로, 메서드 파라미터의 일부만 적용하고 나머지 파라미터는 따로 받는, 부분 적용(partial application) 기법을 쓸 수 있습니다. 위 예제 마지막 줄에 multiAdder(2)는 아직 y가 전달되지 않은 상태인데, 이는 곧 y를 받아서 2 + y를 반환하는 함수인 셈이고, xsmap 메서드에 함수 값으로 곧바로 전달 할 수 있습니다.

케이스 클래스

케이스 클래스(case class)는 일반 클래스와 조금 다른데, 불변 데이터 모델을 만드는데 유용하고, 다음 장에서 설명할 패턴 부합(pattern matching) 기능과 더불어 쓰기 좋습니다.

일반 클래스 앞에 case 키워드만 붙여 만듭니다.

case class Point(x: Double, y: Double)
case class Circle(point: Point, radius: Double)

val center = Point(0.0, 0.0)
val smallCircle = Circle(center, 1.0)
println(smallCircle.radius) // => 1.0

케이스 클래스는 new 키워드를 쓰지 않고도 인스턴스를 만들 수 있습니다. 케이스 클래스를 만들면 기본으로 apply 메서드가 만들어지고, 이 메서드로 인스턴스를 만들어 낼 수 있게 되기 때문입니다. 그리고, 스칼라에서 apply 메서드는 특별하게 .apply를 생략해 쓸 수 있습니다.

Circle(center, 1.0) == Circle.apply(center, 1.0) == new Circle(center, 1.0)

케이스 클래스 비교

일반 클래스의 기본 equals 메서드는 참조(reference)값이 동일한지 비교합니다. 그래서 보통은 equals 메서드를 재정의해서 원하는 방식으로 같은지 비교하곤 합니다.

케이스 클래스를 만들면, 담고 있는 값들을 기준으로 비교하는 equals 메서드가 자동으로 만들어집니다.

println(Circle(center, 1.0) == Circle(center, 1.0)) // => true
println(Circle(center, 1.0) == Circle(center, 2.0)) // => false

케이스 클래스 복제

케이스 클래스를 객체로 만들면, 만들 때 지정한 값은 val로 변수를 선언한 것과 마찬가지로 변경할 수 없습니다. 대신, 자동으로 만들어지는 copy 메서드를 써서 일부 값을 새로운 값으로 지정한 새 인스턴스를 만드는 방식을 씁니다.

val aCircle = Circle(Point(0.0, 0.0), 1.0)
val biggerCircle = aCircle.copy(radius = 2.0)
val movedCircle = biggerCircle.copy(point = Point(2.0, 2.0))

기존 인스턴스는 그대로 있고, 일부 속성만 새로운 값으로 바뀐 또 다른 인스턴스가 생성됩니다. 케이스 클래스는 기본적으로 불변(immutable)인 셈입니다.

패턴 부합

패턴 부합(pattern matching) 기능으로 다양한 경우에 따른 식을 평가하기 좋습니다.

def daysToString(day: Int): String = day match {
  case 1 => "하루"
  case 2 => "이틀"
  case 3 => "사흘"
  case 4 => "나흘"
  case _ => day + "일"
}

daysToString(2) // => "이틀"
daysToString(5) // => "5일"

match 블록 안에서 case 키워드로 시작하는 다양한 경우를 나열하며 => 기호로 평가할 식을 적습니다. 자바의 switch 구문과 달리, 해당 식만 평가하고 결괏값이 나오기 때문에 별도로 break 키워드는 쓰지 않습니다.

여기서 _ 기호는 모든 경우를 다 아우르는 용도로 쓰는 관례입니다. default로 이해해도 좋습니다.

케이스 클래스와 패턴 부합

케이스 클래스를 패턴 부합과 함께 쓰면 편리합니다.

trait Animal
case class Cat(name: String, kind: String) extends Animal
case class Dog(name: String, age: Int) extends Animal
case class Person(name: String, adult: Boolean) extends Animal

Animal 트레이트를 확장한 케이스 클래스를 준비했습니다. 다시 말해, Cat, Dog, Person 셋 다 Animal입니다.

def show(animal: Animal): String =
  animal match {
    case Cat(name, kind) => s"고양이 ${name}은 ${kind}종입니다."
    case Dog(name, age) => s"강아지 ${name}는 ${age}살입니다."
    case Person(name, true) => s"${name}님은 성인입니다."
    case Person(name, false) => s"${name}님은 미성년자입니다."
  }

show(Dog("금비", 3))        // => "강아지 금비는 3살입니다."
show(Person("홍길동", true)) // => 홍길동님은 성인입니다.

Animal을 파라미터로 받아서 패턴 부합으로 분류해서, 문자열로 변환했습니다. case로 분류하면서, 멤버 변수의 값을 바로 접근해서 쓸 수 있다는 점이 특별합니다.

평가식에서, dog.name이나 dog.age를 따로 접근할 필요 없이, 곧바로 nameage를 추출했습니다.

그리고, 추출할 대상값에 상수를, 그러니까 위 예제에서는 truefalse를, 두고 패턴 부합 분류에 쓰기도 합니다.

for-함축

스칼라에서 for 구문은, 단순 반복문보다 특별한 기능이 있습니다. 우선 단순 반복문으로 쓰는 예제부터 보겠습니다.

for 이터레이션

스칼라의 for 구문도, 자바에서 쓰는 for 이터레이션과 비슷하게, 컬렉션의 모든 요소를 차례로 다루는 용도로 쓰는 것이 기본입니다.

val xs = List(1, 2, 3)
for (x <- xs) println(x)

만약, 꼭 인덱스가 필요하다면, 0 to 9처럼 Range를 이용합니다.

for (n <- 0 to 9) println(n)

for 구문의 앞부분에 있는 <- 기호로 이은 부분을 제너레이터(generator)라고 부릅니다. 제너레이터에 있는 값들을 차례로 접근해 반복합니다.

중첩 for 루프

중첩한 for 반복문을 써서 구구단 표를 보이겠습니다.

for {
  i <- 2 to 9
  j <- 1 to 9
} println(s"$i x $j = ${i * j}")

for 구문을 두 번 중첩해 쓰는 게 아니라, 제너레이터를 두 번 이어서 쓴 점을 자세히 봐주세요.

for-yield로 컬렉션 만들기

val xs = List(1, 2, 3)

val ys = for (x <- xs) yield x * 2 // => List(2, 4, 6)

for 구문의 제너레이터에 컬렉션을 주었고, 마지막 표현식 직전에 yield라는 키워드를 붙였습니다. 이 경우, yield 이후에 나오는 식을 평가한 값을 차례로 담고 있는 컬렉션을 반환합니다.

즉, 위 예제의 마지막 줄에서는, xs에서 값을 차례로 가져와 x라고 한 뒤, 여기에 x * 2를 해서 차례로 ys에 담은 셈입니다.

제너레이터 여러 개로 컬렉션 다루기

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

for {
  x <- xs
  y <- ys
} yield x * y
// => List(3, 6, 4, 8, 5, 10)

중첩 for 루프에서 설명드린 것처럼, 두 제네레이터를 차례로 썼습니다. xs에서 x를 하나씩 가져온 다음, ys에서 y를 차례로 가져와 둘을 곱한 값을 담아냈습니다.

마무리

이제까지, 자바와 스칼라에서 비슷하게 작성하는 예제를 통해 스칼라 문법에 익숙해진 다음, 스칼라에만 있는 문법까지 소개했습니다. 이 과정으로 스칼라 프로그래밍 언어에 조금은 친숙한 느낌이 드셨기를 바랍니다. 최소한의 기능을 추려서 간단히 소개하느라, 더 소개하지 못한 기능들이 많습니다.

다음으로 실용적 프로그래밍 예제들을 소개해도 좋겠고, 스칼라를 이용한 함수형 프로그래밍을 자세히 안내하고 싶은 욕심도 남아있습니다.

아래에 메일 주소를 남겨주시면, 다음 과정이나 동영상 강의 소식이 있을 때 연락드리겠습니다. 이 미니북은, 유료 버전 전자책과 동영상 강의로 제작할 예정입니다.

감사합니다.