문자열 보간

문자열 보간

문자열 안에 변수를 넣어 쓰고 싶을 때가 많죠. "안녕, 제임스", "제임스는 30살" 같은 문장은 변수와 고정된 문장이 섞여 있는 경우가 대부분이에요. Scala의 문자열 보간(string interpolation)은 바로 이렇게 문자열 안에서 변수를 직접 쓸 수 있게 해주는 기능이에요.

출처: Scala 3 Book — String Interpolation

본문

소개

문자열 보간은 문자열 안에서 변수를 사용할 수 있는 방법을 제공해요. 예를 들어 볼게요.

val name = "James"
val age = 30
println(s"$name is $age years old")   // "James is 30 years old"

문자열 보간을 사용하려면 문자열 따옴표 앞에 s를 붙이고, 변수 이름 앞에는 $ 기호를 붙이면 돼요.

문자열 보간자(Interpolator)

문자열 앞에 붙인 s는 Scala가 제공하는 수많은 보간자 중 하나일 뿐이에요.

Scala는 기본으로 세 가지 문자열 보간 메서드를 제공해요. s, f, raw예요. 게다가 문자열 보간자는 사실 특별한 메서드일 뿐이라서 직접 정의하는 것도 가능해요. 예를 들어 어떤 데이터베이스 라이브러리들은 데이터베이스 쿼리를 반환하는 sql 보간자를 정의하기도 해요.

s 보간자 (s-Strings)

아무 문자열 리터럴 앞에 s를 붙이면 문자열 안에서 변수를 직접 사용할 수 있어요. 앞에서 이미 예시를 봤죠.

val name = "James"
val age = 30
println(s"$name is $age years old")   // "James is 30 years old"

여기서 문자열 안의 $name$age 자리 표시자(placeholder)는 각각 name.toStringage.toString을 호출한 결과로 바뀌어요. s-String은 현재 스코프에 있는 모든 변수에 접근할 수 있어요.

당연한 말일 수 있지만, 여기서 중요한 점은 보간이 일반 문자열 리터럴에서는 일어나지 않는다는 거예요.

val name = "James"
val age = 30
println("$name is $age years old")   // "$name is $age years old"

문자열 보간자는 임의의 표현식도 받을 수 있어요. 예를 들어 볼게요.

println(s"2 + 2 = ${2 + 2}")   // "2 + 2 = 4"
val x = -1
println(s"x.abs = ${x.abs}")   // "x.abs = 1"

${} 안에는 어떤 임의의 표현식이든 넣을 수 있어요.

문자열 안에 넣을 때 이스케이프해야 하는 특수 문자들도 있어요. 실제 달러 기호를 나타내려면 $$처럼 두 번 쓰면 돼요.

println(s"New offers starting at $$14.99")   // "New offers starting at $14.99"

쌍따옴표도 이스케이프해야 해요. 아래처럼 삼중 따옴표(triple quotes)를 쓰면 해결돼요.

println(s"""{"name":"James"}""")     // `{"name":"James"}`

마지막으로, 여러 줄 문자열 리터럴에도 보간을 적용할 수 있어요.

println(s"""name: "$name",
           |age: $age""".stripMargin)

이렇게 출력돼요.

name: "James"
age: 30

f 보간자 (f-Strings)

아무 문자열 리터럴 앞에 f를 붙이면 다른 언어의 printf와 비슷하게 간단한 포맷 문자열을 만들 수 있어요. f 보간자를 쓸 때는 모든 변수 참조 뒤에 %d 같은 printf 스타일의 포맷 문자열을 붙여야 해요. 예시를 볼게요.

val height = 1.9d
val name = "James"
println(f"$name%s is $height%2.2f meters tall")  // "James is 1.90 meters tall"

f 보간자는 타입 안전(type-safe)해요. 정수에만 맞는 포맷 문자열을 넘기면서 double을 넘기려고 하면 컴파일러가 오류를 내요. 예를 들어 볼게요.

val height: Double = 1.9d

scala> f"$height%4d"
-- Error: ----------------------------------------------------------------------
1 |f"$height%4d"
  |   ^^^^^^
  |   Found: (height : Double), Required: Int, Long, Byte, Short, BigInt
1 error found

f 보간자는 Java에서 제공하는 문자열 포맷 유틸리티를 사용해요. % 문자 뒤에 허용되는 포맷은 Formatter javadoc에 정리되어 있어요. 변수 정의 뒤에 % 문자가 없으면 %s(String) 포맷터가 쓰인다고 가정해요.

마지막으로 Java와 마찬가지로 출력 문자열에서 리터럴 % 문자를 얻으려면 %%를 써요.

println(f"3/19 is less than 20%%")  // "3/19 is less than 20%"

raw 보간자

raw 보간자는 s 보간자와 비슷하지만, 문자열 안의 리터럴을 이스케이프하지 않아요. 처리된 문자열 예시를 볼게요.

scala> s"a\nb"
res0: String =
a
b

여기서 s 문자열 보간자는 \n 문자를 리턴 문자로 바꿨어요. raw 보간자는 그렇게 하지 않아요.

scala> raw"a\nb"
res1: String = a\nb

raw 보간자는 \n 같은 표현식이 리턴 문자로 바뀌는 걸 피하고 싶을 때 유용해요.

게다가 raw 보간자도 s 보간자처럼 변수를 지원하고, 그 값으로 바꿔줘요.

scala> val foo = 42
scala> raw"a\n$foo"
res1: String = a\n42

고급 사용법

기본 보간자 세 개 외에도 사용자가 직접 보간자를 정의할 수 있어요.

리터럴 s"Hi $name"은 Scala에서 처리된 문자열(processed string) 리터럴로 파싱돼요. 이는 컴파일러가 이 리터럴에 추가 작업을 한다는 뜻이에요. 처리된 문자열과 문자열 보간의 구체적인 내용은 SIP-11에 설명되어 있지만, 여기서는 동작 방식을 보여주는 간단한 예시를 들어볼게요.

커스텀 보간자

Scala에서 모든 처리된 문자열 리터럴은 단순한 코드 변환이에요. 컴파일러가 다음 형태의 처리된 문자열 리터럴을 만나면,

id"string content"

그것을 StringContext 인스턴스에 대한 메서드 호출(id(...))로 변환해요. 이 메서드는 암시적 스코프에서도 사용할 수 있어요. 우리만의 문자열 보간자를 정의하려면 StringContext에 새 메서드를 추가하는 암시적 클래스(Scala 2) 또는 extension 메서드(Scala 3)를 만들어야 해요.

간단한 예로, Point 클래스가 있고 p"a,b"Point 객체로 바꿔주는 커스텀 보간자를 만들고 싶다고 해 볼게요.

case class Point(x: Double, y: Double)

val pt = p"1,-2"     // Point(1.0,-2.0)

커스텀 p-보간자를 만들려면 먼저 StringContext 확장을 이렇게 구현하면 돼요.

extension (sc: StringContext)
  def p(args: Any*): Point = ???

이 확장이 스코프에 있고 Scala 컴파일러가 p"some string"을 만나면, some string을 처리해서 문자열 안의 각 내장 변수에 대한 String 토큰과 표현식 인자로 바꿔요.

예를 들어 p"1, $someVar"은 다음과 같이 바뀌어요.

StringContext("1, ","").p(someVar)

결과적으로 처리된 문자열의 각 조각(fragment)은 StringContext.parts 멤버에 노출되고, 문자열 안의 표현식 값은 메서드의 args 매개변수로 전달돼요.

구현 예시

Point 보간자 메서드의 단순한(naive) 구현은 대략 아래와 같을 거예요. 물론 더 정교한 메서드는 s-보간자를 재사용하는 대신 문자열 parts와 표현식 args의 처리를 더 정밀하게 제어할 수도 있어요.

extension (sc: StringContext)
  def p(args: Double*): Point = {
    // reuse the `s`-interpolator and then split on ','
    val pts = sc.s(args*).split(",", 2).map { _.toDoubleOption.getOrElse(0.0) }
    Point(pts(0), pts(1))
  }

val x=12.0

p"1, -2"        // Point(1.0, -2.0)
p"${x/5}, $x"   // Point(2.4, 12.0)

문자열 보간자는 원래 어떤 형태의 문자열을 만드는 데 쓰였지만, 위의 커스텀 보간자처럼 쓰면 강력한 문법적 축약(shorthand)을 만들 수 있어요. 커뮤니티는 이미 이 문법을 ANSI 터미널 색 확장, SQL 쿼리 실행, 마법 같은 $"identifier" 표현 등에 빠르게 활용하고 있어요.

패턴 매칭

문자열 보간을 패턴에서도 사용할 수 있어요. 내장 보간자와 사용자 정의 보간자 모두에요.

some_value match

  // built-in interpolator:
  case s"Hello, $name!" => // Executes for Strings which start with "Hello, " and end in "!"

  // hypothetical custom interpolator:
  case p"$a, 0" => // Executes for example for Points whose second coordinate is 0

다만 fraw 보간자에는 기본적으로 extractor(추출자)가 없어서 case f"..."case raw"..."도 동작하지 않아요. (라이브러리가 제공하지 않는 한 말이죠.)

컴파일러가 다음 형태의 처리된 문자열 패턴을 만날 때마다,

id"string content"

그것을 StringContext 인스턴스에 대한 패턴(id(...))으로 변환해요. 우리만의 문자열 보간자를 정의하려면 StringContext에 extractor 멤버를 추가하는 암시적 클래스(Scala 2) 또는 Conversion 인스턴스(Scala 3)를 만들어야 해요.

예를 들어 Point 클래스가 있고 Point 객체의 좌표를 추출하는 커스텀 패턴 p"$a,$b"를 만들고 싶다고 해 볼게요.

case class Point(x: Double, y: Double)

val pt: Point = Point(1, 2)
pt match case p"$a,$b" => a + b // a = 1, b = 2

두 좌표를 추출하는 커스텀 추출자를 만들고, StringContext의 멤버 p로 동작하게 하면 돼요.

class PointExtractor(sc: StringContext):
    def unapply(point: Point): Option[(Double,Double)] = ???

extension (sc: StringContext)
  def p = PointExtractor(sc)

이 확장이 스코프에 있고 Scala 컴파일러가 패턴 p"some string"을 만나면, some string을 처리해서 문자열에서 내장 패턴 사이에 있는 String 토큰들을 추출해요.

예를 들어 point match case p"$a, $b"는 다음과 같이 바뀌어요.

val someIdentifier = StringContext("",",","")
point match case someIdentifier.p(a, b)

여기서 someIdentifier는 스코프 안의 어떤 것과도 충돌하지 않음이 보장되는 식별자예요. 그리고 p는 우리의 확장 메서드가 제공해요.

결과적으로 처리된 문자열의 각 조각은 StringContext.parts 멤버에 노출되고, extractor p가 변수를 추출해요.

구현 예시

Point 문자열 보간자 extractor의 구현은 대략 아래와 같을 거예요. 물론 더 정교한 구현은 더 다양한 패턴(예: p"0, $b")이나 더 강한 완전성(exhaustiveness) 보장을 처리하도록 고를 수 있어요.

case class Point(x: Double, y: Double)

extension (sc: StringContext)
  def p = PointExtractor(sc)

class PointExtractor(sc: StringContext):
  def unapply(point: Point): Option[(Double,Double)] =
    sc.parts match

      // checks if the pattern is p"$a,$b" or p"$a, $b"
      case Seq("", "," | ", ", "") =>
        Some((point.x, point.y))

      case _ =>
        throw IllegalArgumentException("The pattern was not well-formed")

Point(2, 3) match
//  case p"$x$y" => x + y // IllegalArgumentException: The pattern was not well-formed
  case p"$x,$y" => x + y

더 알아보기