예제: 전자우편 주소 추출

익스트랙터가 해결하는 문제를 묘사하기 위해, 전자우편 주소를 표현하는 문자열을 분석할 필요가 있다고 가정하자. 주어진 문자열이 전자우편 주소인지 여부를 결정하고, 전자 우편 주소라면 주소 중 사용자와 도메인 부분에 접근하고 싶다. 이를 해결하기 위한 전통적인 방법은 보통 세 가지 도우미 함수를 사용한다.

def isEmail(s: String): Boolean
def domain(s: String): String
def user(s: String): String

이런 함수가 있다면, 여러분은 문자열을 다음과 같이 파싱할 수 있었을 것이다.

if (isEmail(s)) println(user(s) + " AT " + domain(s))
else println("not an email address")

동작하기는 하지만 뭔가 어설프다. 게다가 이런 테스트를 여러 가지 합쳐야 한다면 프로그램이 더욱 복잡해져 버린다.

바로 여기서 스칼라의 익스트랙터가 역할을 할 수 있다. 익스트랙터를 사용하면 기존 타입에 대한 새로운 패턴을 정의 할 수 있다. 이때 패턴이 타입의 내부 표현을 꼭 따를 필요는 없다.


익스트랙터

스칼라 익스트랙터는 멤버 중에 unapply 라는 메소드가 있는 객체다. unapply 메소드의 목적은 값을 매치시켜 각 부분을 나누는 것이다. 반대로 값을 만들어내는 apply 라는 반대 방향 메소드가 익스트랙터 객체에 들어 있는 경우도 자주 있다. 다만, apply 메소드가 (패턴 매치를 위해) 꼭 있어야 하는 건 아니다. 예를 들어, 다음은 전자우편 주소에 대한 익스트랙터를 보여준다.

object Email {
    // 인젝션(injection) 메소드(선택적)
    def apply(user: String, domain: String) = user + "@" + domain
    // 익스트랙터 메소드(필수)
    def unapply(str: String): Option[(String, String)] = {
        val parts = str split "@"
        if (parts.length == 2) Some(parts(0), parts(1)) else None
    }
}

이 객체는 applyunapply 메소드를 동시에 정의한다. apply 메소드는 언제나 똑같은 의미다. 즉 Email 객체를 메소드 호출과 같은 방법으로 괄호 안에 인자를 넣어 호출할 수 있게 만든다. 따라서 Email("John", "epfl.ch") 라고 쓰면 "John@epfl.ch"를 만든다. 이를 더 명시적으로 만들고 싶다면, 다음과 같이 스칼라의 함수 타입을 상속하면 된다.

object Email extends ((String, String) => String) { ... }

unapply 메소드는 Email을 익스트랙터로 바꿔준다. 어떤 의미에서 이 메소드는 apply 를 역으로 진행한다. apply 가 두 문자열을 취해서 전자우편 주소 문자열을 만들듯이, unapply 는 전자우편 주소 문자열을 받아서 전체적으로 사용자와 도메인 문자열을 반환한다. 하지만 unapply 는 주어진 문자열이 전자우편 주소가 아닌 경우도 처리해야 한다. 그래서 unapply 는 문자열 쌍이 들어간 Option 타입의 값을 반환한다. 결과는 문자열 str이 전자우편 주소라면 user와 domain에 정해진 부분이 들어간 Some(user, domain)이고, str이 전자우편 주소가 아니라면 None이다. 다음은 몇 가지 예다.

  • unapply("John@epfl.ch")Some("John", "epfl.ch")를 반환한다.
  • unapply("John Doe")None을 반환한다.

이제, 패턴 매치 시 익스트랙터 객체를 참조하는 패턴을 만나면 항상 그 익스트랙터의 unapply 메소드를 셀럭터 식에 대해 호출한다. 그래서

selectorString match { case Email(user, domain) => ... }

위 식은 다음과 같은 호출을 일으킨다.

Email.unapply(selectorString)

앞에서 본 것처럼 Email.unapplyNone이나 Some(u, d)를 반환한다. None의 경우 패턴 매치가 이뤄지지 않고, 시스템은 다른 패턴을 시도하거나 MatchError 예외를 내고 패턴 매치에 실패한다. Some(u, d)인 경우 패턴이 매치되어, unapply 가 반환한 값이 각 변수에 바인딩된다.

Email 패턴 매치 예제에서, 셀렉터 식인 selectorString의 타입인 String은 unapply 의 인자 타입과 부합해야 한다. 보통 이런 경우가 아주 흔하긴 하지만, 꼭 그래야만 하는 것은 아니다. Email 익스트랙터를 더 일반적인 타입의 셀렉터 식과 매치하는 데 사용할 수도 있다. 예를 들어 임의의 값 x가 전자우편 주소 문자열인지 검사하고 싶다면, 다음과 같이 쓸 수 있을 것이다.

val x: Any = ...
x match { case Email(user, domain) => ... }

패턴 매처(pattern matcher)가 이런 코드를 본다면, 먼저 주어진 값 x가 Email의 unapply 메소드 인자의 타입인 String에 부합하는지 살펴볼 것이다. 부합하는 경우, 매처는 값을 String으로 캐스팅해서 앞에서와 마찬가지로 처리한다. 만약 부합하지 않는다면 매치가 바로 실패한다.

객체 Email 안의 apply 메소드는 인젝션(injection) 이라고 부른다. 이 메소드는 인자를 몇 가지 받아서 어떤 집합의 원소를 만들어내기 때문이다. unapply 메소드는 익스트랙션(extraction) 이라고 하는데, 그 이유는 어떤 집합에 속한 원소에서 여러 부분의 값을 뽑아내기 때문이다. 인젝션이나 익스트랙션은 종종 한 객체 안에서 짝 지워지곤 한다. 이는 케이스 클래스와 패턴 매치 사이의 관계를 시뮬레이션한다. 하지만 어떤 객체의 익스트랙션을 대응하는 인젝션 없이 정의할 수도 있다. 이때 객체 자체는(unapply만 있다면) 내부에 apply 메소드가 있는지 여부와 관계없이 익스트랙터(extractor) 라 부른다.

인젝션 메소드가 포함된 경우라면, 인젝션은 익스트랙션 메소드와 서로 쌍대성(dual)이어야 한다. 예를 들어

Email.unapply(Email.apply(user, domain))

위와 같은 호출은 다음을 반환해야만 한다.

Some(user, domain)

즉 인자가 똑같은 순서대로 Some에 둘러쌰여서 다시 나와야 한다. 반대 방향으로 가는 것은 아래 코드와 같이 먼저 unapply 를 한 다음 apply 를 하는 것을 의미한다.

Email.unapply(obj) match {
    case Some(u, d) => Email.apply(u, d)
}

이 코드에서 만약 obj에 대한 매치가 성공하면 apply의 결과도 같은 객체가 되리라 예상했을 것이다. applyunapply 의 쌍대성에 대한 이런 두 조건은 좋은 설계 원칙이다. 스칼라가 이를 강제로 요구하지는 않지만, 익스트랙터를 설계할 때 이를 지키는 편이 좋다.


익스트랙터와 케이스 클래스

케이스 클래스는 아주 유용하긴 하지만 데이터의 구체적인 표현이 드러난다는 단점이 있다. 이는 그 생성자 패턴에 있는 클래스 이름이 셀렉터 객체의 구체적인 표현 타입과 대응한다는 뜻이다. 만약 다음과 같은 매치가 성공한다면,

case C(...)

여러분은 이 셀렉터 식이 클래스 C의 인스턴스라는 사실을 안다.

익스트랙터는 데이터 표현과 패턴 사이에 존재하는 이런 연결을 끊는다. 익스트랙터를 사용하면 패턴과 그 패턴이 선택하는 객체의 내부 데이터 표현 사이에 아무런 관계가 없도록 만들 수 있다. 이런 특성을 표현 독립성(representation independence) 이라 한다. 커다란 열린 시스템에서는 표현 독립성이 매우 중요하다. 표현 독립성이 있으면, 어떤 컴포넌트를 사용하는 클라이언트에는 영향을 끼치지 않으면서 컴포넌트가 사용하는 구현 타입을 변경할 수 있기 때문이다.

어떤 컴포넌트가 여러 케이스 클래스를 정의해서 외부에 노출했다고 하자. 그 케이스 클래스에 대해 패턴 매치를 하는 클라이언트 코드가 이미 있다면, 쉽게 그 케이스 클래스를 바꿀 수 없다. 케이스 클래스의 이름을 바꾸거나 클래스 계층구조를 변경하면 클라이언트 코드에 영향을 끼친다. 익스트랙터는 이런 문제가 없다. 익스트랙터가 데이터 표현과 클라이언트에게 보여주는 방식 사이에 간접 계층을 제공하기 때문이다. 어떤 타입에 대해 일관된 익스트랙터를 계속 제공하는 한, 그 구체적인 표현 방식을 원하는 대로 바꿀 수 있다.

표현 독립성은 케이스 클래스에 비교해 익스트랙터가 지닌 중요한 장점이다. 반면, 케이스 클래스가 익스트랙터보다 더 좋은 점도 있다. 우선, 설정하고 정의하기가 훨씬 쉽고, 코드도 적게 필요하다. 두 번째로, 보통 익스트랙터보다 더 효과적인 패턴 매치가 가능하다. 스칼라 컴파일러가 케이스 클래스의 패턴 매치를 익스트랙터의 패턴 매치보다 더 잘 최적화할 수 있기 때문이다. 그 이유는 케이스 클래스의 메커니즘은 변하지 않는 반면, 익스트랙터의 unapplyunapplySeq 메소드 안에서는 거의 아무 일이나 할 수 있기 때문이다. 세 번째로, 어떤 케이스 클래스가 봉인된 케이스 클래스(sealed case class)를 상속하는 경우, 패턴 매치가 모든 가능한 패턴을 다 다루는지(match exhaustiveness)를 스칼라 컴파일러가 검사해서 그렇지 않은 경우 경고를 해준다. 하지만 익스트랙터를 사용하면 그런 검사를 하지 않는다.

그렇다면 패턴 매치 시 두 방법 중 어떤 방법을 사용하는 것이 더 좋을까? 상황에 따라 다르다. 여러분이 닫힌 애플리케이션 코드를 작성한다면 케이스 클래스 쪽이 더 좋다. 케이스 클래스는 더 간결하고, 빠르며, 컴파일 시점 검사가 가능하다는 이점이 있다. 나중에 클래스 계층구조를 변경하기로 결정하면 애플리케이션을 리팩토링해야 하지만, 보통 이것은 문제가 되지 않는다. 반면, 어떤 타입을 미리 알지 못하는 여러 클라이언트에게 노출해야 한다면, 표현 독립성을 위해 익스트랙터를 사용하는 편이 더 좋을 것이다.

다행히, 이를 즉시 결정할 필요는 없다. 항상 케이스 클래스로 시작한 다음, 필요에 따라 익스트랙터로 바꿀 수 있다. 스칼라에서는 익스트랙터나 케이스 클래스에 대한 패턴이 모두 똑같아 보이기 때문에 클라이언트가 사용하는 패턴 매치는 계속 작동할 수 있다.


결론

익스트랙터를 사용하면 여러분 자신만의 패턴을 만들 수 있고, 그 패턴을 여러분이 선택하려는 대상 타입과 다르게 만들 수도 있다. 이는 매치 시 사용할 수 있는 패턴에 대한 더 많은 유연성을 가져다준다. 결과적으로, 같은 데이터에 대해 다른 뷰를 제공하는 것과도 같다. 이를 통해 어떤 타입의 내부 표현과 클라이언트가 그 타입을 보는 방식 사이에 간접 계층을 둘 수 있다. 따라서 표현 독립성을 유지하면서 패턴 매치를 계속 사용할 수 있다.

익스트랙터는 유연한 라이브러리의 추상화를 정의하기 위해 사용할 수 있는, 여러분의 유틸리티 상자에 넣을 수 있는 또 하나의 도구다. 스칼라의 라이브러리는 익스트랙터를 아주 많이 사용한다.

참조