Scala는 객체지향 프로그래밍과 함수형 프로그래밍을 모두 지원하는 프로그래밍 언어입니다. Scala에서는 object, class, case class, trait 같은 여러 키워드를 사용해 다양한 타입의 데이터 구조와 행동을 정의할 수 있습니다.
Java와 비교하면서 각각에 대해 설명하고 예제를 들어 보겠습니다.
1. Class
Scala의 class는 Java의 class와 매우 비슷합니다. 클래스는 객체의 청사진을 제공하며, 데이터와 그 데이터를 조작하는 메소드를 포함할 수 있습니다.
Scala 예제:
class Person(var name: String, var age: Int) {
def greet(): Unit = {
println(s"Hello, my name is $name and I am $age years old.")
}
}
Java 예제:
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void greet() {
System.out.println("Hello, my name is " + name + " and I am " + age + " years old.");
}
}
class는 일반적으로 인스턴스를 생성하기 위해 사용됩니다. new 키워드를 사용하여 클래스의 인스턴스를 생성할 수 있습니다.
2. Case Class
Scala의 case class는 불변의 데이터를 저장하기 위해 사용되며, 자동으로 equals(), hashCode(), toString() 메소드를 구현합니다. 또한, 패턴 매칭에 유용하게 사용됩니다. Java에는 이와 직접적으로 대응하는 기능이 없습니다.
case class Point(x: Int, y: Int)
case class가 불변의 데이터를 저장한다는 말은, 이 클래스의 인스턴스가 생성된 후 그 인스턴스의 필드 값들이 변경될 수 없다는 의미입니다. Scala에서 case class를 사용할 때, 일반적으로 클래스 필드들은 불변(val)로 선언됩니다. 이는 데이터가 더 안전하고 예측 가능하게 관리될 수 있도록 해줍니다.
위의 Point 클래스는 x와 y라는 두 개의 필드를 갖고 있으며, 이 필드들은 불변입니다. 이 객체의 인스턴스를 생성한 후에는 x와 y의 값을 변경할 수 없습니다. 만약 객체의 상태를 변형하고 싶다면, 기존 인스턴스를 변경하는 대신 새로운 인스턴스를 생성해야 합니다.
val p1 = Point(1, 2)
val p2 = p1.copy(y = 3)
여기서 p1은 변경되지 않고, p1의 y 값만 바뀐 새로운 객체 p2가 생성됩니다. 이러한 방식으로 불변성을 유지하면서도 필요에 따라 객체의 상태를 "변경"할 수 있습니다.
case class도 인스턴스화가 가능합니다. 일반 클래스와 다르게 new 키워드 없이도 인스턴스를 생성할 수 있습니다. 이는 case class가 자동으로 apply 메소드를 생성하기 때문입니다.
apply 메소드의 역할
Scala에서 apply 메소드는 일반적으로 객체의 생성자와 같은 역할을 합니다. 이 메소드는 객체의 인스턴스를 반환하는 팩토리 메소드로 작동하며, 객체를 생성할 때 new 키워드 대신 apply 메소드를 자동으로 호출하여 인스턴스를 반환합니다.
new 키워드와의 관계
일반적인 클래스에서는 객체를 생성할 때 new 키워드를 사용해야 합니다. 이 키워드는 JVM에 새 객체를 만들라고 지시하며, 이때 생성자가 호출되어 객체의 초기 상태를 설정합니다.
반면에, case class에서는 apply 메소드가 자동으로 정의되기 때문에, new를 사용하지 않고도 객체를 생성할 수 있습니다. 이는 apply 메소드가 생성자처럼 작동하고, 객체를 생성할 때 내부적으로 new를 사용하여 실제 객체를 생성하기 때문입니다.
case class Person(name: String, age: Int)
// 'apply' 메소드를 사용하는 경우 (일반적으로 이렇게 사용됩니다)
val p1 = Person("Alice", 30) // 내부적으로 Person.apply("Alice", 30)를 호출
// 'new' 키워드를 사용하는 경우 (일반적이지 않음)
val p2 = new Person("Bob", 25)
3. Object
Scala의 object는 싱글턴 패턴을 구현한 것으로, 해당 클래스의 단 하나의 인스턴스만 존재합니다. Java에서는 이를 수동으로 구현해야 합니다.
Scala 예제:
object Singleton {
def greet(): Unit = {
println("Hello, I am a singleton object.")
}
}
Java 예제:
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
public void greet() {
System.out.println("Hello, I am a singleton object.");
}
}
object는 싱글턴 객체로, 프로그램 전체에서 단 하나의 인스턴스만 존재합니다. object는 이미 인스턴스화된 상태이기 때문에 사용자가 추가로 인스턴스를 생성할 수 없습니다. object의 멤버에 접근하려면 직접 object의 이름을 사용합니다.
Object의 상속 가능성
Scala에서 object는 다른 클래스나 트레잇(trait)을 상속할 수 있습니다. 하지만, object는 싱글턴 패턴을 구현하기 때문에, 다른 object에서는 상속받을 수 없습니다. 즉, object는 클래스나 트레잇의 모든 멤버를 상속받아 사용할 수 있지만, 다른 object를 직접 상속할 수는 없습니다.
예를 들어, 하나의 트레잇과 그 트레잇을 상속받는 object를 정의할 수 있습니다:
trait Greeter {
def greet(): Unit = println("Hello, this is a Greeter trait.")
}
object DefaultGreeter extends Greeter {
override def greet(): Unit = {
super.greet()
println("Hello, this is DefaultGreeter object.")
}
}
이 예제에서 DefaultGreeter는 Greeter 트레잇을 상속받아 greet 메소드를 구현하고 있습니다.
Object의 인스턴스화 시점
Scala에서 object는 프로그램 내에서 처음으로 참조될 때 자동으로 인스턴스화됩니다. 이는 지연 초기화(lazy initialization) 또는 초기화 지연이라고도 불리는데, 실제로 object가 필요할 때까지 객체 생성을 연기합니다. object가 한 번 인스턴스화되면, 해당 인스턴스는 프로그램의 생명 주기 동안 계속 존재하며, 다시는 인스턴스화되지 않습니다.
object MyApp {
println("MyApp is initialized.")
def run(): Unit = {
println("MyApp is running.")
}
}
// 이 시점에서는 아직 MyApp은 초기화되지 않음
println("Starting application...")
MyApp.run() // 첫 MyApp 참조 시점. 여기서 "MyApp is initialized."가 출력되고, "MyApp is running."이 이어서 출력됨.
이 예제에서 MyApp object는 run 메소드가 호출될 때 처음으로 참조되므로, 그 때 초기화되고 실행됩니다. 초기화는 object가 프로그램 내에서 처음 사용될 때 단 한 번만 발생하며, 이후에는 동일한 인스턴스가 재사용됩니다.
이러한 특성 덕분에 object는 싱글턴 패턴의 구현을 매우 간단하게 만들어 주며, 상태를 안전하게 관리할 수 있는 중앙 집중화된 접근 방법을 제공합니다.
4. Trait
Scala의 trait는 Java의 인터페이스와 유사하지만, 구현 코드를 포함할 수 있습니다. Java 8 이상에서는 인터페이스에 default 메소드를 사용하여 비슷하게 구현할 수 있습니다.
Scala 예제:
trait Greeter {
def greet(name: String): Unit = {
println(s"Hello, $name")
}
}
class DefaultGreeter extends Greeter
Java 예제:
public interface Greeter {
default void greet(String name) {
System.out.println("Hello, " + name);
}
}
public class DefaultGreeter implements Greeter {}
trait 자체는 인스턴스화할 수 없습니다. 하지만, trait는 다른 클래스나 trait에 상속되어 그 기능을 확장하는 데 사용될 수 있습니다. 구체적인 구현을 제공하는 클래스를 통해 trait의 기능을 인스턴스화할 수 있습니다.
trait Greeter {
def greet(): Unit
}
class DefaultGreeter extends Greeter {
def greet(): Unit = println("Hello, World!")
}
val greeter = new DefaultGreeter()
greeter.greet() // 출력: "Hello, World!"
이 예제에서 DefaultGreeter 클래스는 Greeter 트레잇을 상속받아 greet 메소드를 구현하고, DefaultGreeter의 인스턴스를 생성하여 Greeter의 기능을 사용할 수 있습니다.
즉, class와 case class는 직접 인스턴스화가 가능하고, trait는 다른 클래스를 통해 간접적으로 인스턴스화가 가능합니다.
다중상속에서 함수가 겹칠때
Scala에서 trait을 사용한 다중 상속에서 함수가 겹칠 때, 상속받는 클래스는 명시적으로 충돌하는 메소드를 오버라이드(override)해야 합니다. 이는 상속받는 trait들 사이에서 동일한 메소드 서명을 가진 메소드들이 여러 개 존재할 때 발생합니다. 클래스는 이 메소드를 재정의하여 어떤 구현을 사용할지 결정해야 합니다.
예시
두 개의 trait가 동일한 메소드를 갖고 있고, 하나의 클래스가 이 두 trait를 모두 상속받는 경우를 생각해보겠습니다:
trait PrinterA {
def print(): Unit = println("Printer A")
}
trait PrinterB {
def print(): Unit = println("Printer B")
}
class ConcretePrinter extends PrinterA with PrinterB {
override def print(): Unit = {
super[PrinterA].print() // PrinterA의 print 메소드 호출
super[PrinterB].print() // PrinterB의 print 메소드 호출
}
}
이 코드에서 ConcretePrinter 클래스는 PrinterA와 PrinterB 두 trait을 상속받습니다. 두 trait 모두 print() 메소드를 정의하고 있으므로, ConcretePrinter는 print() 메소드를 오버라이드하여 충돌을 해결합니다. super[트레잇이름].메소드이름() 구문을 사용해서 특정 trait의 메소드를 호출할 수 있습니다. 위 예제에서는 PrinterA와 PrinterB의 print() 메소드를 모두 호출하고 있습니다.
선택적 접근
클래스가 여러 trait에서 상속받은 메소드 중 하나만 사용하려면, 해당 trait의 메소드를 직접 호출하여 다른 하나는 무시할 수 있습니다. 예를 들어, PrinterB의 print()만 사용하고 싶다면, 오버라이드된 print()에서 super[PrinterB].print()만 호출하면 됩니다.
선택적 접근 예시
두 trait 중 하나의 메소드만 사용하는 상황을 가정해 봅시다.
trait PrinterA {
def print(): Unit = println("Print from PrinterA")
}
trait PrinterB {
def print(): Unit = println("Print from PrinterB")
}
class SelectivePrinter extends PrinterA with PrinterB {
override def print(): Unit = {
super[PrinterB].print() // PrinterB의 print 메소드만 호출
}
}
object Main extends App {
val printer = new SelectivePrinter()
printer.print() // 출력: "Print from PrinterB"
}
이 예제에서 SelectivePrinter 클래스는 PrinterA와 PrinterB를 상속받지만, print() 메소드를 오버라이드하여 PrinterB의 print()만 호출합니다. 이렇게 특정 trait의 구현을 선택적으로 사용할 수 있습니다.
특별한 경우: 선형화
Scala는 클래스의 선형화(linearization)라는 과정을 통해 trait 상속의 순서를 결정합니다. 선형화는 클래스가 상속받는 모든 trait을 선형 순서로 정렬하는 것을 의미하며, 이 순서는 super 호출에 영향을 미칩니다. 기본적으로 가장 마지막에 선언된 trait의 메소드가 우선적으로 호출됩니다. 이러한 선형화 규칙을 이해하는 것도 함수 오버라이드 결정에 중요할 수 있습니다.
Scala에서 클래스의 선형화는 상속 순서에 따라 결정되며, 이 순서는 super 호출의 동작에 영향을 미칩니다.
trait A {
def greet(): Unit = println("Hello from A")
}
trait B extends A {
override def greet(): Unit = {
println("Entering B")
super.greet()
println("Leaving B")
}
}
trait C extends A {
override def greet(): Unit = {
println("Entering C")
super.greet()
println("Leaving C")
}
}
class D extends B with C {
override def greet(): Unit = {
println("Starting D")
super.greet()
println("Ending D")
}
}
object Main extends App {
val d = new D()
d.greet()
}
이 예제에서, D 클래스는 B와 C를 상속받습니다. D에서 super.greet()을 호출하면, 선형화에 따라 C의 greet()가 실행됩니다. Scala의 선형화 규칙에 따르면, D가 가장 먼저 B를 상속받고, 그 다음으로 C를 상속받으므로, 마지막으로 섞인 C의 구현이 사용됩니다.
출력은 다음과 같습니다:
Starting D
Entering C
Entering B
Hello from A
Leaving B
Leaving C
Ending D
선형화는 상속받는 trait의 순서를 선형적으로 정렬하며, 이는 super 호출 시 어떤 메소드가 실행될지 결정하는 데 중요한 역할을 합니다. super는 항상 "다음" trait의 메소드를 호출하며, 이 "다음"은 선형화 과정에서 결정됩니다.
5. 정리
각자의 기능
class | 일반적인 객체지향의 클래스의 기능 , 상속받고 인스턴스화 하여 사용 |
case class | 데이터 클래스의 개념 , 불변 객체 , 일반 클래스와 다르게 new를 쓰지 않고 인스턴스화 생성 가능 |
object | 싱글톤으로 생성된 객체 , 이미 인스턴스화 되어 있음, trait이나 다른 클래스는 상속 가능 object 자체는 상속 불가. |
trait | 인터페이스의 개념 , 다중 상속이 가능하며 함수 정의도 가능함 , 인스턴스화는 불가능 |
JAVA 와의 개념 비교
개념 | Scala | Java |
싱글턴(Object) | object 키워드로 싱글턴 패턴 자동 구현. | 수동으로 싱글턴 패턴 구현 필요 (private 생성자 사용). |
불변성(Immutable Data) | case class로 불변 데이터 구조 구현. 기본적으로 val 사용. | 불변 객체는 명시적으로 final을 필드에 사용하여 구현. |
패턴 매칭 | case class와 함께 패턴 매칭 구문 제공. | 패턴 매칭 직접 구현 없음, 일부 대체 가능한 기능은 switch와 instanceof 사용. |
트레잇(Trait) | 인터페이스와 유사하지만 구현 코드 포함 가능. | Java 8 이후 인터페이스에 default 메소드로 유사 구현 가능. |
데이터 클래스 | case class를 사용하여 자동으로 메소드(equals, hashCode, toString) 생성. | 데이터 클래스에 해당하는 직접적 구현 없음, 필요한 메소드 수동 구현. |