제어 구조

제어 구조 (Control Structures)

Scala에는 프로그래밍 언어에서 기대하는 제어 구조가 다 들어 있어요. if/then/else, for 루프, while 루프, try/catch/finally가 그것이죠. 거기에 프로그래밍 배경에 따라 처음 보는 강력한 구조 두 가지도 함께 제공해요.

  • for 표현식 (일명 for 컴프리헨션)
  • match 표현식

이번 장에서는 이 모두를 하나씩 다뤄 볼게요.

출처: Scala 3 Book

본문

if/then/else 구조 (The if/then/else construct)

한 줄짜리 Scala if 문은 이렇게 생겼어요.

if x == 1 then println(x)

if 비교 뒤에 여러 줄의 코드를 실행해야 한다면 이 구문을 쓰면 돼요.

if x == 1 then
  println("x is 1, as you can see:")
  println(x)

if/else 구문은 이렇게 생겼어요.

if x == 1 then
  println("x is 1, as you can see:")
  println(x)
else
  println("x was not 1")

그리고 if/else if/else 구문은 이렇게 생겼어요.

if x < 0 then
  println("negative")
else if x == 0 then
  println("zero")
else
  println("positive")

end if

** 이 기능은 Scala 3에서 새로 나온 것이고, Scala 2에서는 지원되지 않아요.

원한다면 각 표현식 끝에 end if 문을 선택적으로 붙일 수 있어요.

if x == 1 then
  println("x is 1, as you can see:")
  println(x)
end if

if/else 표현식은 항상 결과를 반환해요

if/else 비교는 *표현식(expression)*을 이룬다는 점을 기억하세요. 즉 변수에 할당할 수 있는 값을 반환한다는 뜻이에요. 덕분에 별도의 삼항 연산자(ternary operator)가 필요 없어요.

val minValue = if a < b then a else b

값을 반환하기 때문에 if/else 표현식을 메서드의 본문으로도 쓸 수 있어요.

def compare(a: Int, b: Int): Int =
  if a < b then
    -1
  else if a == b then
    0
  else
    1

여담: 표현식 지향 프로그래밍 (Expression-oriented programming)

프로그래밍 전반에 대한 짧은 이야기인데, 쓰는 모든 표현식이 값을 반환하는 스타일을 *표현식 지향 프로그래밍(expression-oriented programming, EOP)*이라고 불러요. 예를 들어 이건 표현식이에요.

val minValue = if a < b then a else b

반대로 값을 반환하지 않는 코드 줄은 *문(statement)*이라고 하고, 그 *부수 효과(side-effect)*를 위해 사용해요. 예를 들어 이 코드 줄들은 값을 반환하지 않으니 부수 효과를 위해 쓰이는 거예요.

if a == b then action()
println("Hello")

첫 번째 예시는 ab가 같을 때 부수 효과로 action 메서드를 실행해요. 두 번째 예시는 문자열을 STDOUT에 출력하는 부수 효과를 위해 쓰여요. Scala를 배우다 보면 자연스럽게 보다 표현식을 더 많이 쓰게 될 거예요.

for 루프

가장 단순한 용도에서 Scala for 루프는 컬렉션의 원소를 순회하는 데 써요. 예를 들어 정수 시퀀스가 주어졌을 때, 그 원소들을 돌며 값을 출력하려면 이렇게 해요.

val ints = Seq(1, 2, 3)
for i <- ints do println(i)

i <- ints 코드를 *제너레이터(generator)*라고 해요. 어떤 제너레이터든 p <- e에서 표현식 e는 패턴 p에 바인딩되는 값을 0개 이상 만들어 낼 수 있어요.

Scala REPL에서는 이렇게 결과가 보여요.

scala> val ints = Seq(1,2,3)
ints: Seq[Int] = List(1, 2, 3)

scala> for i <- ints do println(i)
1
2
3

for 제너레이터 뒤에 여러 줄짜리 코드 블록이 필요하면 이 구문을 사용해요.

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

여러 제너레이터 (Multiple generators)

for 루프는 아래 예시처럼 여러 제너레이터를 가질 수 있어요.

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

이 표현식은 이 출력을 만들어 내요.

i = 1, j = a, k = 1
i = 1, j = a, k = 6
i = 1, j = b, k = 1
i = 1, j = b, k = 6
i = 2, j = a, k = 1
i = 2, j = a, k = 6
i = 2, j = b, k = 1
i = 2, j = b, k = 6

가드 (Guards)

for 루프에는 *가드(guard)*라고 부르는 if 문도 포함할 수 있어요.

for
  i <- 1 to 5
  if i % 2 == 0
do
  println(i)

이 루프의 출력은 다음과 같아요.

2
4

for 루프는 필요하다면 가드를 여러 개 가질 수 있어요. 이 예시는 숫자 4를 출력하는 방법 중 하나를 보여줘요.

for
  i <- 1 to 10
  if i > 3
  if i < 6
  if i % 2 == 0
do
  println(i)

for와 함께 Map 사용하기 (Using for with Maps)

Map과 함께 for 루프를 사용할 수도 있어요. 예를 들어 주(state) 약어와 전체 이름을 담은 이 Map이 있다고 해볼게요.

val states = Map(
  "AK" -> "Alaska",
  "AL" -> "Alabama",
  "AZ" -> "Arizona"
)

그러면 for를 사용해서 키와 값을 이렇게 출력할 수 있어요.

for (abbrev, fullName) <- states do println(s"$abbrev: $fullName")

REPL에서는 이렇게 보여요.

scala> for (abbrev, fullName) <- states do println(s"$abbrev: $fullName")
AK: Alaska
AL: Alabama
AZ: Arizona

for 루프가 map을 순회하면서 각 키/값 쌍은 튜플 안에 있는 변수 abbrevfullName에 바인딩돼요.

(abbrev, fullName) <- states

루프가 돌면서 변수 abbrev는 map의 현재 에, 변수 fullName은 현재 map 에 할당돼요.

for 표현식 (for expressions)

앞선 for 루프 예시들은 모두 부수 효과를 위해 쓰였어요. 특히 println으로 값을 STDOUT에 출력하기 위해서였죠.

중요한 점은 값을 반환하는 for 표현식도 만들 수 있다는 거예요. for 표현식은 yield 키워드와 반환할 표현식을 추가해서 만들어요.

val list =
  for i <- 10 to 12
  yield i * 2

// list: IndexedSeq[Int] = Vector(20, 22, 24)

for 표현식이 실행된 후, 변수 list는 보이는 값들을 담은 Vector가 돼요. 이 표현식이 어떻게 동작하는지는 이래요.

  1. for 표현식은 범위 (10, 11, 12)의 값들을 순회하기 시작해요. 먼저 값 10을 처리해서 2를 곱하고, 그 결과인 값 20yield해요.
  2. 다음으로 범위의 두 번째 값인 11을 처리해요. 2를 곱한 뒤 값 22를 yield해요. 이렇게 yield된 값들이 임시 보관 장소에 쌓인다고 생각하면 돼요.
  3. 마지막으로 루프가 범위에서 숫자 12를 가져와 2를 곱해 숫자 24를 만들고 yield해요. 이 시점에서 루프는 완료되어 최종 결과인 Vector(20, 22, 24)를 만들어 내요.

이번 절의 의도는 for 표현식을 보여주는 것이지만, 보이는 for 표현식이 이 map 메서드 호출과 동등하다는 걸 알아두면 도움이 돼요.

val list = (10 to 12).map(i => i * 2)

for 표현식은 컬렉션의 모든 원소를 순회하면서, 그 원소들에 어떤 알고리즘을 적용해 새 리스트를 만들어야 할 때 언제든 쓸 수 있어요.

yield 뒤에 코드 블록을 사용하는 방법을 보여주는 예시를 볼게요.

val names = List("_olivia", "_walter", "_peter")

val capNames = for name <- names yield
  val nameWithoutUnderscore = name.drop(1)
  val capName = nameWithoutUnderscore.capitalize
  capName

// capNames: List[String] = List(Olivia, Walter, Peter)

for 표현식을 메서드 본문으로 사용하기 (Using a for expression as the body of a method)

for 표현식은 결과를 만들어 내기 때문에, 유용한 값을 반환하는 메서드의 본문으로 쓸 수 있어요. 이 메서드는 주어진 정수 리스트에서 310 사이에 있는 모든 값을 반환해요.

def between3and10(xs: List[Int]): List[Int] =
  for
    x <- xs
    if x >= 3
    if x <= 10
  yield x

between3and10(List(1, 3, 7, 11))   // : List[Int] = List(3, 7)

while 루프

Scala while 루프 구문은 이렇게 생겼어요.

var i = 0

while i < 3 do
  println(i)
  i += 1

match 표현식 (match expressions)

패턴 매칭은 함수형 프로그래밍 언어의 주요 기능이고, Scala의 match 표현식은 매우 많은 능력을 갖추고 있어요.

가장 단순한 경우에는 Java의 switch 문처럼 정수 값에 기반해 case를 매칭하는 데 match 표현식을 쓸 수 있어요. 이것이 정말로 표현식이라서 결과로 평가된다는 점을 주목하세요.

// `i` is an integer
val day = i match
  case 0 => "Sunday"
  case 1 => "Monday"
  case 2 => "Tuesday"
  case 3 => "Wednesday"
  case 4 => "Thursday"
  case 5 => "Friday"
  case 6 => "Saturday"
  case _ => "invalid day"   // the default, catch-all

이 예시에서 변수 i는 보이는 case들과 대조돼요. i06 사이면 day는 그 요일을 나타내는 문자열에 바인딩돼요. 그렇지 않으면 _ 문자로 표시된 catch-all case에 매칭되어 day"invalid day" 문자열에 바인딩돼요.

case는 쓰여진 순서대로 고려되고 첫 번째로 매칭되는 case가 사용되므로, 어떤 값이든 매칭하는 기본(default) case는 반드시 마지막에 와야 해요. catch-all 뒤에 오는 case들은 도달할 수 없는 case(unreachable)로 경고가 나요.

이렇게 단순한 match 표현식을 작성할 때는 변수 i@switch 애너테이션을 쓰는 것을 권장해요. 이 애너테이션은 switch가 성능에 더 좋은 tableswitchlookupswitch로 컴파일될 수 없을 때 컴파일 타임 경고를 제공해요.

기본값 사용하기 (Using the default value)

match 표현식에서 catch-all 기본값에 접근해야 할 때는, case 문의 왼쪽에 _ 대신 변수 이름을 쓰고 그 변수 이름을 문의 오른쪽에서 필요한 대로 사용하면 돼요.

i match
  case 0 => println("1")
  case 1 => println("2")
  case what => println(s"You gave me: $what")

패턴에 쓰이는 이름은 소문자로 시작해야 해요. 대문자로 시작하는 이름은 변수를 도입하지 않고, 스코프 안의 값을 매칭해요.

val N = 42
i match
  case 0 => println("1")
  case 1 => println("2")
  case N => println("42")
  case n => println(s"You gave me: $n" )

i42와 같다면 case N이 매칭되어 "42" 문자열을 출력해요. 그러면 기본 case에는 도달하지 않아요.

한 줄에서 여러 매칭 처리하기 (Handling multiple possible matches on one line)

앞서 말했듯 match 표현식은 많은 능력을 갖추고 있어요. 이 예시는 각 case 문에서 여러 개의 가능한 패턴 매칭을 사용하는 방법을 보여줘요.

val evenOrOdd = i match
  case 1 | 3 | 5 | 7 | 9 => println("odd")
  case 2 | 4 | 6 | 8 | 10 => println("even")
  case _ => println("some other number")

case 절에서 if 가드 사용하기 (Using if guards in case clauses)

match 표현식의 case에서도 가드를 사용할 수 있어요. 이 예시에서 두 번째와 세 번째 case는 모두 가드를 사용해 여러 정수 값을 매칭해요.

i match
  case 1 => println("one, a lonely number")
  case x if x == 2 || x == 3 => println("two's company, three's a crowd")
  case x if x > 3 => println("4+, that's a party")
  case _ => println("i'm guessing your number is zero or less")

주어진 값을 숫자 범위에 매칭시키는 방법을 보여주는 또 다른 예시를 볼게요.

i match
  case a if 0 to 9 contains a => println(s"0-9 range: $a")
  case b if 10 to 19 contains b => println(s"10-19 range: $b")
  case c if 20 to 29 contains c => println(s"20-29 range: $c")
  case _ => println("Hmmm...")
케이스 클래스와 match 표현식 (Case classes and match expressions)

case 클래스—그리고 apply/unapply 메서드를 제대로 작성한 클래스—에서 필드를 추출해서 가드 조건에 사용할 수도 있어요. 단순한 Person case 클래스를 사용한 예시를 볼게요.

case class Person(name: String)

def speak(p: Person) = p match
  case Person(name) if name == "Fred" => println(s"$name says, Yubba dubba doo")
  case Person(name) if name == "Bam Bam" => println(s"$name says, Bam bam!")
  case _ => println("Watch the Flintstones!")

speak(Person("Fred"))      // "Fred says, Yubba dubba doo"
speak(Person("Bam Bam"))   // "Bam Bam says, Bam bam!"
매칭된 패턴을 변수에 바인딩하기 (Binding matched patterns to variables)

매칭된 패턴을 변수에 바인딩해서 타입별 동작을 사용할 수 있어요.

trait Animal:
  val name: String
case class Cat(name: String) extends Animal:
  def meow: String = "Meow"
case class Dog(name: String) extends Animal:
  def bark: String = "Bark"

def speak(animal: Animal) = animal match
  case c @ Cat(name) if name == "Felix" => println(s"$name says, ${c.meow}!")
  case d @ Dog(name) if name == "Rex" => println(s"$name says, ${d.bark}!")
  case _ => println("I don't know you!")

speak(Cat("Felix")) // "Felix says, Meow!"
speak(Dog("Rex"))   // "Rex says, Bark!"

match 표현식을 메서드 본문으로 사용하기 (Using a match expression as the body of a method)

match 표현식은 값을 반환하기 때문에 메서드의 본문으로 쓰일 수 있어요. 이 메서드는 Matchable 값을 입력 파라미터로 받고, match 표현식의 결과에 따라 Boolean을 반환해요.

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

입력 파라미터 aMatchable 타입으로 정의돼요—이 타입은 패턴 매칭을 수행할 수 있는 모든 Scala 타입의 뿌리(root)예요. 이 메서드는 입력을 매칭하고 두 개의 case를 제공해 구현돼요. 첫 번째 case는 주어진 값이 정수 0, 빈 문자열, 또는 false인지 확인하고, 이 경우 false를 반환해요. 기본 case에서는 다른 어떤 값에 대해서도 true를 반환해요. 이 메서드가 어떻게 동작하는지 보여주는 예시들이에요.

isTruthy(0)      // false
isTruthy(false)  // false
isTruthy("")     // false
isTruthy(1)      // true
isTruthy(" ")    // true
isTruthy(2F)     // true

match 표현식을 메서드 본문으로 사용하는 것은 매우 흔한 패턴이에요.

match 표현식은 여러 종류의 패턴을 지원해요 (Match expressions support many different types of patterns)

match 표현식을 작성하는 데 사용할 수 있는 패턴의 형태는 매우 다양해요. 예를 들어 이런 것들이 있어요.

  • 상수 패턴 (예: case 3 => )
  • 시퀀스 패턴 (예: case List(els : _*) =>)
  • 튜플 패턴 (예: case (x, y) =>)
  • 생성자 패턴 (예: case Person(first, last) =>)
  • 타입 테스트 패턴 (예: case p: Person =>)

이 모든 종류의 패턴은 다음 pattern 메서드에 나와 있어요. 이 메서드는 Matchable 타입의 입력 파라미터를 받아 String을 반환해요.

def pattern(x: Matchable): String = x match

  // constant patterns
  case 0 => "zero"
  case true => "true"
  case "hello" => "you said 'hello'"
  case Nil => "an empty List"

  // sequence patterns
  case List(0, _, _) => "a 3-element list with 0 as the first element"
  case List(1, _*) => "list, starts with 1, has any number of elements"
  case Vector(1, _*) => "vector, starts w/ 1, has any number of elements"

  // tuple patterns
  case (a, b) => s"got $a and $b"
  case (a, b, c) => s"got $a, $b, and $c"

  // constructor patterns
  case Person(first, "Alexander") => s"Alexander, first name = $first"
  case Dog("Zeus") => "found a dog named Zeus"

  // type test patterns
  case s: String => s"got a string: $s"
  case i: Int => s"got an int: $i"
  case f: Float => s"got a float: $f"
  case a: Array[Int] => s"array of int: ${a.mkString(",")}"
  case as: Array[String] => s"string array: ${as.mkString(",")}"
  case d: Dog => s"dog: ${d.name}"
  case list: List[?] => s"got a List: $list"
  case m: Map[?, ?] => m.toString

  // the default wildcard pattern
  case _ => "Unknown"

=> 오른쪽의 코드를 여러 줄로 쓰는 게 더 읽기 쉽다고 생각되면 그렇게 해도 돼요. 예를 하나 볼게요.

count match
  case 1 =>
    println("one, a lonely number")
  case x if x == 2 || x == 3 =>
    println("two's company, three's a crowd")
  case x if x > 3 =>
    println("4+, that's a party")
  case _ =>
    println("i'm guessing your number is zero or less")

Scala 3에서는 match 표현식을 연결(chaining)할 수도 있어요.

i match
  case odd: Int if odd % 2 == 1 => "odd"
  case even: Int if even % 2 == 0 => "even"
  case _ => "not an integer"
match
  case "even" => true
  case _ => false

match 표현식은 마침표(period) 뒤에도 올 수 있어서, 연결된 메서드 호출이 반환한 결과에 매칭하는 것을 단순하게 만들어 줘요.

List(1, 2, 3)
  .map(_ * 2)
  .headOption
  .match
    case Some(value) => println(s"The head is: $value")
    case None => println("The list is empty")

try/catch/finally

Java처럼 Scala에도 예외를 잡고 관리할 수 있는 try/catch/finally 구조가 있어요. 일관성을 위해 Scala는 match 표현식과 같은 구문을 사용하고, 발생할 수 있는 서로 다른 예외들에 패턴 매칭을 지원해요.

다음 예시에서 openAndReadAFile은 이름이 뜻하는 대로 동작하는 메서드예요. 파일을 열고 그 안의 텍스트를 읽어서 그 결과를 가변 변수 text에 할당하죠.

var text = ""
try
  text = openAndReadAFile(filename)
catch
  case fnf: FileNotFoundException => fnf.printStackTrace()
  case ioe: IOException => ioe.printStackTrace()
finally
  // close your resources here
  println("Came to the 'finally' clause.")

openAndReadAFile 메서드가 파일을 읽기 위해 Java java.io.* 클래스를 사용하고 자기 예외를 잡지 않는다고 가정하면, 파일을 열고 읽으려는 시도는 FileNotFoundExceptionIOException 둘 다를 일으킬 수 있고, 그 두 예외는 이 예시의 catch 블록에서 잡혀요.

커스텀 제어 구조 (Custom Control Structures)

Scala에서는 자신만의 제어 구조를 정의할 수 있어요. if/then/elsewhile 루프 같은 내장 제어 구조와 비슷한 방식으로 사용할 수 있는 메서드를 만들 수 있죠. 이것은 주로 *이름에 의한 파라미터(by-name parameter)*를 통해 이루어져요.

자신만의 제어 구조 정의하기 (Defining your own control structures)

이름에 의한 파라미터는 타입 앞에 =>를 붙여서 지정해요. 예를 들어 body: => Unit처럼요. 일반적인 값에 의한(by-value) 파라미터와 달리, 이름에 의한 파라미터는 메서드가 호출될 때 평가되지 않아요. 대신 메서드 안에서 참조될 때마다 매번 평가돼요.

이 기능 덕분에 필요할 때 실행되는 코드 블록을 받아들일 수 있고, 이것이 제어 로직을 정의하는 데 핵심이에요.

코드 블록을 지정된 횟수만큼 실행하는 단순한 repeat 루프 예시를 볼게요.

def repeat(n: Int)(body: => Unit): Unit =
  if n > 0 then
    body
    repeat(n - 1)(body)

// usage
repeat(3) {
  println("Hello")
}

body 파라미터는 repeat 메서드 안에서 body가 언급될 때마다 평가돼요. repeat가 여러 파라미터 리스트로 정의되어 있어서, 두 번째 인자에 블록 구문 { ... }을 사용할 수 있고, 그래서 마치 언어 키워드처럼 보이게 돼요.

Scala 3.3의 새 기능: boundary와 break (New in Scala 3.3: boundary and break)

** 이 기능은 Scala 3.3에서 도입되었어요.

Scala 3.3은 scala.util 패키지에 boundarybreak를 도입해, 비지역 반환(non-local return)이나 중첩 루프·제어 구조에서 "빠져나오기(break)"를 더 명확하고 구조적으로 처리할 수 있게 했어요. 이는 옛 scala.util.control.Breaks와 제어 흐름을 위해 예외를 던지는 메서드를 만드는 방식을 대체해요.

사용하려면 boundary 블록을 정의해요. 그 블록 안에서 break를 호출하면 그 블록에서 값을 즉시 반환할 수 있어요.

정수 리스트에서 대상 원소의 첫 번째 인덱스를 찾는 메서드 예시를 볼게요.

import scala.util.boundary, boundary.break

def firstIndex(xs: List[Int], target: Int): Int =
  boundary:
    for (x, i) <- xs.zipWithIndex do
      if x == target then break(i)
    -1

val xs = List(1, 2, 3, 4, 5)
val found = firstIndex(xs, 3)   // 2
val notFound = firstIndex(xs, 99) // -1

이 예시에서:

  1. boundary가 스코프를 설정해요.
  2. for 루프가 리스트를 순회해요.
  3. x == target이면 break(i)가 호출돼요. 이는 즉시 boundary 블록을 빠져나와 i를 반환해요.
  4. 루프가 break 없이 끝나면, 루프 뒤의 코드(-1)가 반환돼요.

이 메커니즘은 제어 흐름을 위해 예외를 사용하는 것에 대한 구조적인 대안을 제공해요. 컴파일러는 가능하면 boundarybreak를 단순 점프로 최적화해서(예: 중첩 메서드 호출 안에서 break가 일어나지 않는 경우) 예외 처리의 오버헤드를 피할 수 있어요.

더 알아보기