패키징과 임포트
패키징과 임포트 (Packaging and Imports)
규모가 커지는 프로젝트에서는 이름 공간(namespace)을 잘 나누는 게 정말 중요해요. Scala는 *패키지(package)*를 사용해 프로그램을 모듈화하고 이름 충돌을 막아줘요. Java 식 이름짓기 방식과 C++/C#류의 '중괄호' 표기법을 모두 지원하고, 임포트(import)도 Java와 비슷하면서 더 유연해요. 하나씩 살펴볼게요.
출처: Scala 3 Book
본문
Scala는 패키지를 사용해 프로그램을 모듈화하고 이름 공간 충돌을 막는 데 도움을 주는 이름 공간을 만들어요. Scala는 Java에서 쓰는 패키지 이름짓기 스타일과, C++·C# 같은 언어에서 쓰는 '중괄호(curly brace)' 이름 공간 표기법을 모두 지원해요.
Scala의 임포트 방식도 Java와 비슷하면서 더 유연해요. Scala에서는 다음을 할 수 있어요:
-
패키지, 클래스, 객체(object), 트레이트(trait), 메서드를 임포트한다
-
임포트 문을 어디에든 둘 수 있다
-
임포트할 때 멤버를 숨기거나 이름을 바꿀 수 있다
이 기능들을 아래 예시로 하나씩 볼게요.
패키지 만들기
패키지는 Scala 파일의 맨 위에 패키지 이름을 하나 이상 선언해서 만들어요. 예를 들어 도메인 이름이 acme.com이고 myapp이라는 애플리케이션의 model 패키지에서 작업한다면, 패키지 선언은 이렇게 생겼어요:
package com.acme.myapp.model
class Person ...
관례상 패키지 이름은 모두 소문자여야 하고, 공식 이름짓기 관례는 <최상위 도메인>.<도메인 이름>.<프로젝트 이름>.<모듈 이름> 형태예요.
필수는 아니지만, 패키지 이름은 보통 디렉터리 구조 이름을 따르므로, 이 관례를 따라가면 이 프로젝트의 Person 클래스는 MyApp/src/main/scala/com/acme/myapp/model/Person.scala 파일에서 찾을 수 있어요.
같은 파일에서 여러 패키지 사용하기
위에서 본 문법은 소스 파일 전체에 적용돼요. 파일 시작 부분의 패키지 절(clause)에 따라 Person.scala 파일의 모든 정의가 패키지 com.acme.myapp.model에 속하게 되죠.
또는, 자신이 감싸는 정의에만 적용되는 패키지 절을 쓰는 것도 가능해요:
package users:
package administrators: // the full name of this package is users.administrators
class AdminUser // the full name of this class is users.administrators.AdminUser
package normalusers: // the full name of this package is users.normalusers
class NormalUser // the full name of this class is users.normalusers.NormalUser
패키지 이름 뒤에 콜론(:)이 온다는 점과, 패키지 내부의 정의가 들여쓰기된다는 점에 주목하세요.
이 방식의 장점은 패키지 중첩을 허용하고, 특히 같은 파일 안에서 스코프와 캡슐화에 대한 더 명확한 통제를 제공한다는 점이에요.
임포트 문, 1부
임포트 문은 다른 패키지의 엔티티에 접근하는 데 사용돼요. 임포트 문은 크게 두 부류로 나뉘어요:
-
클래스, 트레이트, 객체, 함수, 메서드를 임포트하기
-
given절을 임포트하기
Java 같은 언어에 익숙하다면, 첫 번째 부류의 임포트 문은 Java가 쓰는 것과 비슷하고, 약간 다른 문법으로 더 유연성을 제공해요. 이 예시들이 그 유연성 중 일부를 보여줘요:
import users.* // import everything from the `users` package
import users.User // import only the `User` class
import users.{User, UserPreferences} // import only two selected members
import users.{UserPreferences as UPrefs} // rename a member as you import it
이 예시들은 첫 번째 부류의 import 문이 어떻게 동작하는지 감을 잡게 해주려는 것이에요. 이어지는 하위 섹션에서 더 자세히 설명할게요.
임포트 문은 given 인스턴스들을 스코프로 가져오는 데도 사용돼요. 그것은 이 챕터 끝부분에서 다룰게요.
넘어가기 전에 한 가지 참고:
같은 패키지의 멤버에 접근할 때는 임포트 절이 필요하지 않아요.
하나 또는 여러 멤버 임포트하기
Scala에서 패키지에서 멤버 하나를 이렇게 임포트할 수 있어요:
import scala.concurrent.Future
여러 멤버는 이렇게요:
import scala.concurrent.Future
import scala.concurrent.Promise
import scala.concurrent.blocking
여러 멤버를 임포트할 때는 이렇게 더 간결하게 쓸 수 있어요:
import scala.concurrent.{Future, Promise, blocking}
scala.concurrent 패키지에서 모든 것을 임포트하려면 이 문법을 사용해요:
import scala.concurrent.*
임포트할 때 이름 바꾸기
이름 충돌을 피하기 위해 가끔은 임포트할 때 엔티티 이름을 바꾸는 게 도움이 돼요. 예를 들어 Scala List 클래스와 java.util.List 클래스를 동시에 사용하고 싶다면, 임포트할 때 java.util.List 클래스 이름을 바꿀 수 있어요:
import java.util.{List as JavaList}
이제 그 클래스를 가리킬 때 JavaList라는 이름을 쓰고, List는 Scala 리스트 클래스를 가리키는 데 써요.
이 문법으로 한 번에 여러 멤버의 이름을 바꿀 수도 있어요:
import java.util.{Date as JDate, HashMap as JHashMap, *}
이 코드는 'Date와 HashMap 클래스의 이름을 보여준 대로 바꾸고, java.util 패키지의 다른 멤버는 이름을 바꾸지 않고 전부 임포트한다'는 뜻이에요.
임포트할 때 숨기기
임포트 과정에서 멤버를 숨길 수도 있어요. 이 import 문은 java.util.Random 클래스를 숨기면서, java.util 패키지의 나머지는 전부 임포트해요:
import java.util.{Random as _, *}
Random 클래스에 접근하려 하면 동작하지 않지만, 그 패키지의 다른 모든 멤버에는 접근할 수 있어요:
val r = new Random // won’t compile
new ArrayList // works
여러 멤버 숨기기
임포트 과정에서 여러 멤버를 숨기려면, 마지막 와일드카드 임포트 앞에 그 멤버들을 나열하세요:
scala> import java.util.{List as _, Map as _, Set as _, *}
이번에도 그 클래스들은 숨겨지지만, java.util의 다른 모든 클래스는 사용할 수 있어요:
scala> new ArrayList[String]
val res0: java.util.ArrayList[String] = []
이 Java 클래스들이 숨겨져 있으므로, 이름 충돌 없이 Scala의 List, Set, Map 클래스를 사용할 수도 있어요:
scala> val a = List(1, 2, 3)
val a: List[Int] = List(1, 2, 3)
scala> val b = Set(1, 2, 3)
val b: Set[Int] = Set(1, 2, 3)
scala> val c = Map(1 -> 1, 2 -> 2)
val c: Map[Int, Int] = Map(1 -> 1, 2 -> 2)
임포트를 어디에든 쓰기
Scala에서는 import 문을 어디에나 둘 수 있어요. 소스 파일 맨 위에 쓸 수 있고:
package foo
import scala.util.Random
class ClassA:
def printRandom(): Unit =
val r = new Random // use the imported class
// more code here...
원한다면 필요에 더 가까운 위치에 import 문을 쓸 수도 있어요:
package foo
class ClassA:
import scala.util.Random // inside ClassA
def printRandom(): Unit =
val r = new Random
// more code here...
class ClassB:
// the Random class is not visible here
val r = new Random // this code will not compile
'정적(static)' 임포트
Java의 '정적 임포트' 방식과 비슷하게, 즉 클래스 이름을 접두어로 붙이지 않고 멤버 이름을 직접 참조하고 싶을 때는 다음과 같은 방법을 사용해요.
Java Math 클래스의 모든 정적 멤버를 임포트하려면 이 문법을 사용해요:
import java.lang.Math.*
이제 클래스 이름을 앞에 붙이지 않고 sin, cos 같은 Math 클래스의 정적 메서드에 접근할 수 있어요:
import java.lang.Math.*
val a = sin(0) // 0.0
val b = cos(PI) // -1.0
기본으로 임포트되는 패키지
두 패키지는 모든 소스 파일의 스코프에 암시적으로 임포트돼요:
-
java.lang.*
-
scala.*
Scala 객체 Predef의 멤버들도 기본으로 임포트돼요.
List,Vector,Map같은 클래스를 임포트하지 않고 쓸 수 있는 이유가 궁금했다면, 그것들은Predef객체의 정의 덕분에 사용할 수 있는 거예요.
이름 충돌 처리하기
드물지만 이름 충돌이 있어서 프로젝트 루트에서 임포트해야 한다면, 패키지 이름 앞에 _root_를 붙여요:
package accounts
import _root_.accounts.*
given 인스턴스 임포트하기
컨텍스트 추상화(Contextual Abstractions) 챕터에서 보겠지만, Scala 3에서는 특별한 형태의 import 문으로 given 인스턴스를 임포트해요. 기본 형태는 이 예시에서 볼 수 있어요:
object A:
class TC
given tc: TC
def f(using TC) = ???
object B:
import A.* // import all non-given members
import A.given // import the given instance
이 코드에서 객체 B의 import A.* 절은 A의 모든 멤버를 given 인스턴스 tc를 제외하고 임포트해요. 반대로 두 번째 임포트인 import A.given은 오직 그 given 인스턴스만 임포트해요. 두 import 절은 하나로 합칠 수도 있어요:
object B:
import A.{given, *}
Scala 2에는 이런 형태의 임포트가 존재하지 않아요. Scala 2에서는 와일드카드 임포트가 항상 암시적 정의를 임포트했어요.
논의
와일드카드 선택자 *는 given이나 확장(extension)을 제외한 모든 정의를 스코프로 가져오는 반면, given 선택자는 모든 given —확장에서 비롯된 것들을 포함해서— 을 스코프로 가져와요.
이 규칙들에는 두 가지 주요 이점이 있어요:
-
스코프 안의 given이 어디서 왔는지 더 명확해져요. 특히, 길게 늘어선 다른 와일드카드 임포트 목록 안에 given이 숨겨지는 일이 불가능해요.
-
다른 것은 아무것도 임포트하지 않고 모든 given을 임포트할 수 있어요. given은 익명(anonymous)일 수 있어서, 이름을 지정한 임포트의 일반적인 사용이 실용적이지 않기 때문에 특히 중요해요.
타입별 임포트
given은 익명일 수 있으므로 이름으로 임포트하는 게 항상 실용적이진 않아요. 그래서 보통 와일드카드 임포트를 사용해요. *타입별 임포트(by-type imports)*는 와일드카드 임포트보다 더 구체적인 대안을 제공해서, 무엇이 임포트되는지 더 명확하게 해줘요:
import A.{given TC}
이것은 A에서 타입이 TC에 부합(conform)하는 어떤 given이든 임포트해요. 여러 타입 T1,...,Tn의 given을 임포트하는 것은 여러 given 선택자로 표현돼요:
import A.{given T1, ..., given Tn}
매개변수화된 타입의 모든 given 인스턴스를 임포트하는 것은 와일드카드 인자로 표현돼요. 예를 들어 이런 object가 있을 때:
object Instances:
given intOrd: Ordering[Int]
given listOrd[T: Ordering]: Ordering[List[T]]
given ec: ExecutionContext = ...
given im: Monoid[Int]
이 임포트 문은 intOrd, listOrd, ec 인스턴스를 임포트하지만, im 인스턴스는 지정된 경계(bounds) 중 어느 것에도 맞지 않기 때문에 제외해요:
import Instances.{given Ordering[?], given ExecutionContext}
타입별 임포트는 이름별 임포트와 섞을 수 있어요. 임포트 절에 둘 다 있으면, 타입별 임포트가 마지막에 와요. 예를 들어 이 임포트 절은 im, intOrd, listOrd를 임포트하지만 ec는 제외해요:
import Instances.{im, given Ordering[?]}
예시
구체적인 예시로, 두 개의 given 정의를 가진 MonthConversions 객체가 있다고 해볼게요:
object MonthConversions:
trait MonthConverter[A]:
def convert(a: A): String
given intMonthConverter: MonthConverter[Int] with
def convert(i: Int): String =
i match
case 1 => "January"
case 2 => "February"
// more cases here ...
given stringMonthConverter: MonthConverter[String] with
def convert(s: String): String =
s match
case "jan" => "January"
case "feb" => "February"
// more cases here ...
이 given들을 현재 스코프로 임포트하려면 이 두 import 문을 사용해요:
import MonthConversions.*
import MonthConversions.{given MonthConverter[?]}
이제 이 given 인스턴스들을 사용하는 메서드를 만들 수 있어요:
def genericMonthConverter[A](a: A)(using monthConverter: MonthConverter[A]): String =
monthConverter.convert(a)
그런 다음 애플리케이션에서 그 메서드를 사용할 수 있어요:
@main def main =
println(genericMonthConverter(1)) // January
println(genericMonthConverter("jan")) // January
앞서 언급했듯이, 'import given' 문법의 핵심 설계 이점 중 하나는 스코프 안의 given이 어디서 왔는지 명확하게 만든다는 점이에요. 이 import 문들에서 given이 MonthConversions 객체에서 왔다는 게 분명하죠.