9가지 프로그래밍 언어로 배우는 개념: 1편 - 타입 이론

지민규

프로그래밍 언어 시리즈

  1. 9가지 프로그래밍 언어로 배우는 개념: 1편 - 타입 이론
  2. 9가지 프로그래밍 언어로 배우는 개념: 2편 - 다형성
  3. 9가지 프로그래밍 언어로 배우는 개념: 3편 - 메타프로그래밍
  4. 9가지 프로그래밍 언어로 배우는 개념: 4편 - 하이 레벨 언어와 동적 타입 언어
  5. 9가지 프로그래밍 언어로 배우는 개념: 5편 - 동시성 프로그래밍

서론

안녕하세요, 저는 게임 "쿠키런: 킹덤"의 서버 파트에서 근무하고 있는 지민규라고 합니다.

시작하기에 앞서 어째서 이 글을 쓰게 되었는지를 말씀드리고 싶은데요, 흔히 언어는 우리의 사고를 형성한다는 말이 있습니다. 언어가 가진 문장 구조과 발성, 그리고 단어들의 형태에 따라 세상에 대한 시각이 바뀐다고 합니다.

저는 다양한 프로그래밍 언어를 배우면서 비슷한 경험을 했습니다. 첫 프로그래밍 언어로 C++를 배웠을 때 C++가 가진 한계점이 제 프로그래밍 방식의 한계점이 되었었고, Go를 배웠을 때 Go가 가진 언어의 구조가 프로그래밍하기에 충분하다고 생각했었습니다. 하지만 이후 더 많은 프로그래밍 언어들을 배우면서 생각이 더 넓어지고 프로그래밍 언어들 사이의 공통점이 보이기 시작했으며, 어떻게 하면 더욱 깨끗한 코드를 구현할 수 있을지 깨달아가게 되었습니다.

다양한 프로그래밍 언어를 배워야하는 이유는 단순히 프로그래밍 언어들을 적재적소에 사용할 수 있어서가 아닙니다. 다양한 프로그래밍 언어를 배우면 아키텍처 디자인을 할 때 좀 더 넓은 시각을 가질 수 있게 되며, 어떠한 코드가 이상적인 코드인지 좀 더 알게 되고 좀 더 깊은 고민을 하며 구현할 수 있게 된다고 생각합니다.

이 글은 주요 프로그래밍 언어들 중 하나를 깊게 이해하고 있는 분들을 대상으로 썼습니다. 그런 분들이 좀 더 다양한 시각을 가질 수 있도록 돕기위해 제가 프로그래밍 언어들을 배우고 사용하면서 알게된 것들을 조금이나마 나눠드리고자 합니다.

언어 소개

C

  • 1972년에 공개되어 로우 레벨 프로그래밍 언어
  • 초창기 프로그래밍 언어라서 기능이 많지 않아 단순함

C++

  • 1985년에 공개되어 C를 바탕으로 OOP 기능들이 추가된 로우 레벨 언어
  • C와 다르게 템플릿을 지원하고 C에 비해 굉장히 많은 기능들이 붙어 복잡도가 높음

Java

  • 1995년에 공개되어 C++를 바탕으로 OOP 기능을 강화한 JVM 계열 언어
  • C++와 다르게 하이 레벨 언어에 가까우며 메모리를 직접 관리하지 않고 가비지 컬렉터를 사용함

C#

  • 2000년에 공개되고 Java를 벤치마킹하여 만들어진 하이 레벨 언어
  • Java와 많은 유사점을 가지고 있음

Scala

  • 2004년에 공개된 하이 레벨, 함수형, JVM 계열 언어
  • Java가 쓰는 JVM으로 작동하지만 함수형 프로그래밍에서 밴치마킹한 기능들이 많으며 동시에 OOP 개발도 가능함

Go

  • 2009년에 공개된 하이 레벨 언어
  • 현대 프로그래밍 언어가 지원하는 기능 대다수가 존재하지 않는 대신 C와 비견될정도로 단순하며, 성능 또한 로우 레벨 언어 수준까지는 아니지만 빠른 편

Rust

  • 2010년에 공개된 메모리 안전성 문제를 해결하고 타입 시스템을 강화하기 위해 만들어진 로우 레벨 언어
  • 다른 로우 레벨 언어가 갖지 못한 강화된 타입 시스템과 메모리 안전성을 강화하는 버로우 체커가 특징임

Kotlin

  • 2011년에 공개된 하이 레벨, JVM 계열 언어
  • Scala를 벤치마킹하였기에 Scala의 대다수 기능들을 가지고 있으며, 문법도 유사하지만 좀 더 OOP 계열 언어에 가까움

Typescript

  • 2012년에 공개된 하이 레벨, Javascript 기반 언어
  • Javascript과 달리 타입 시스템을 지원하고 있으며 타입 시스템 또한 다른 주요 언어들에 비해 정교함

Null, Nullable, Option

가장 유명하면서도 가장 끔찍한 프로그래밍 언어 기능 중 하나를 꼽으라고 한다면 null이라고 자신있게 말할 수 있습니다. null이라는 값을 데이터에 사용하면 “없음”을 표현할 수 있습니다.

// Java 코드
public class Person {
  public String name;
  public String nickname;
}
Person person = new Person();
person.name = "Min Gyu Chi";
person.nickname = null; // 별명은 없다

null 참조는 “10억 달러짜리 실수”라는 악명이 붙어있습니다. 이는 전세계 IT회사들 통틀어 null을 참조하려다 생겨난 버그로 인해 10억 달러어치의 손실을 낼 정도로 많은 버그를 일으켰다는 관점이 있기 때문입니다. 실수로 null이 들어있는 데이터를 참조하면 값이 존재하지 않아서 프로그램을 진행할 수 없어 런타임 에러를 내게 됩니다.

// Java 코드
Integer a = null;
Integer b = a + a; // null 더하기 null은 어떤 값이지? NullPointerException 에러!

Java에서는 코드에서 null을 참조할 가능성이 있어도 컴파일을 막지 않습니다. 그렇기에 null 에러를 피하려면 모든 필드와 함수 인수를 문서화하여 null이 들어갈 가능성이 있는 값을 표기해야 합니다. 만약 문서가 잘못되었거나 null 체크하는 것을 잊었다면 곧바로 에러로 이어질 수도 있습니다.

// Java 코드
public class Person {
  public String name;
  @Nullable public String nickname; // 이 필드는 null이 들어갈 수도 있어
}

이러한 문제들을 컴파일 타임에 잡기 위해서는 IDE의 도움을 받거나 null이 들어간 필드들의 타입을 감싸야 합니다. 많은 프로그래밍 언어들은 null을 담을 수 있는 타입을 뚜렷하게 구별하고 있으며 Java도 Java 8에서부터 Optional<T> 타입을 추가하여 문서화의 영역이 아닌 타입의 영역으로 가져올 수 있도록 했습니다.

// Java 코드
Optional<String> nickname = Optional.empty();

// Rust 코드
let nickname: Option<String> = None;

// Scala 코드
val nickname: Option[String] = None;

이를 nullable 타입이라고 부릅니다. nullable 타입은 프로그래밍에 광범위하게 쓰이는 만큼 C#과 Kotlin은 nullable 타입 전용 문법을 추가하였습니다.

// C# 코드
String? nickname = null;

// Kotlin 코드
val nickname: String? = null;

다만 Java, C#, Kotlin, Rust, Scala의 nullable 타입 구현 방식은 서로간에 차이가 있습니다.

// Java 코드
public class Optional<T> {
  private final T value; // 필드 하나 그대로 null인지 아닌지 확인
  /* ... */
}

// C# 코드
// 레퍼런스 타입이 아닌 경우
public struct Nullable<T> {
  private bool hasValue; // null인지 저장하는 필드
  private T value;
  /* ... */
}

// 레퍼런스 타입인 경우
String? nickname; // 컴파일 타임에 모든 체크가 강제된다

// Kotlin 코드
val nickname: String?; // 컴파일 타임에 모든 체크가 강제된다

// Rust 코드
pub enum Option<T> { // Option<T> 이라는 타입이 있는데
  None,           // None 이거나
  Some(T)         // T를 가진 Some 이거나
}

// Scala 코드
sealed trait Option[+A]                         // Option[A] 이라는 타입이 있는데
case object None extends Option[Nothing]        // None 이거나
case class Some[+A](value: A) extends Option[A] // A를 가진 Some 이거나

여기서 특이하게도 Scala와 Rust는 null에 해당하는 None과 null이 아님에 해당하는 Some을 합친 Option 타입으로 처리하고 있습니다. Some인 경우 데이터를 저장하고 있는 필드를 가지고 있고 None이면 null에 해당되는 “없음”이라는 정보를 표현합니다. 이 방식은 Typescript의 nullable 타입 처리 방식과 어느정도 비슷합니다.

// Typescript 코드
let nickname: string | null; // string 일 수도 있고 null 일 수도 있어

Scala, Rust, Typescript가 이러한 공통점을 보여주는 이유는 세 언어 모두 타입 이론의 합 타입을 기반으로 nullable 타입을 지원하고 있기 때문입니다.

타입 이론

데이터는 표현합니다. 하지만 표현 가능한 정보의 종류와 수는 타입에 따라 결정됩니다. 타입 이론은 데이터의 타입을 어떠한 방식으로 확장시킬 수 있는지를 정립합니다. 타입 이론은 작은 타입들로 더 큰 타입을 만들어내는 방식으로 타입을 확장시키는데, 여기서 가장 기초가 되는 개념은 **곱 타입(Product Type)**과 **합 타입(Sum Type)**입니다.

곱 타입은 한 데이터에 두 가지 정보를 모두 저장하여 두 정보를 함께 표현합니다. 많은 언어들의 struct, DTO, record들이 이에 해당합니다.

// C/C++ 코드
struct Human {
  string name;
  int age;
}

// Rust 코드
pub struct Human {
  name: String,
  age: i32,
}

// Kotlin 코드
data class Human(val name: String, val age: Int)

// Scala 코드
case class Human(name: String, age: Int)

위 예시로 들자면 사람에게 이름 정보에 더불어 나이 정보도 함께 표현하게 되므로 훨씬 많은 표현을 할 수 있게 됩니다. 이로써 사람이라는 정보가 가지는 경우의 수는 이름이 가지는 경우의 수와 나이가 가지는 경우의 수의 곱입니다.

이번에는 합타입을 살펴봅시다. nullable 타입은 데이터가 “없음”이라는 정보를 추가적으로 표현할 수 있게 됩니다. 이는 기존 데이터가 가진 표현력(Expressiveness)에서 null이라는 표현을 “더한” 것이기 때문입니다. 위 예시에서는 수많은 별명들을 표현할 수 있는 것에 더불어 별명이 없다는 것도 추가적으로 표현할 수 있게 됩니다.

// Typescript 코드
const nickname1: string | null = 'mango'; // mango라는 별명도 있을 수 있고
const nickname2: string | null = 'mystery meat'; // mystery meat도 있을 수 있고
const nickname3: string | null = null; // 별명이 없을 수도 있지

합 타입은 null 타입을 더하는 것에 국한되지 않으며 세 개 이상의 타입들을 합할 수도 있습니다.

// Typescript 코드
type CouponCode = string | number        // 쿠폰 번호는 문자열이거나 숫자
type ExcelCell = string | number | Date  // 엑셀 셀은 문자열, 숫자, 시간 중 하나

// Rust 코드
pub enum CouponCode {
  CouponString(String),
  CouponNumber(i32),
}

// Scala 코드
sealed trait CouponCode
case class CouponString(value: String) extends CouponCode
case class CouponNumber(value: Int) extends CouponCode

이처럼 곱 타입은 많은 언어들이 지원하고 있지만, 합 타입은 일부 프로그래밍 언어들만이 지원하고 있습니다. C/C++, C#은 합 타입을 지원하지 않으며 합 타입을 미약하게나마 구현하려면 추상 클래스나 인터페이스를 이용하여 제한적으로 구현하거나 타입 캐스팅을 하여서 하위 타입을 확정하여 접근해야 합니다.

상속의 한계

아래와 같은 요구사항으로 합 타입을 묘사했다고 해봅시다.

// Java 코드
public abstract class Job {
  public int speedLevel; // speed level은 공유한다
}

public class Police extends Job {
  public int strengthLevel;
  public int fame;
}

public class Robber extends Job {
  public int robberyLevel;
  public int infamy;
}

public class Human {
  public String name;
  public int health;
  public Job job;
}

여기서 Job의 메소드를 추가하여 Police의 표현력과 Robber의 표현력을 그대로 가진 인터페이스를 구현할 수 있을까요?

// Java 코드
public abstract class Job {
  public int speedLevel;

  // 어느 정보가 어디서 왔는지 모른다!
  public abstract Optional<Int> getStrengthLevel();
  public abstract Optional<Int> getRobberyLevel();
  public Int getSpeedLevel() {
    return Optional.of(speedLevel);
  }
  public abstract Optional<Int> getFame();
  public abstract Optional<Int> getInfamy();
}

위 방법으로 개별 필드들을 노출하면 각 필드가 어떤 하위 타입에서 오는지 파악하기가 어렵습니다. 또한 유지보수로 인해 하위 타입이 바뀌게 될 경우 메소드는 삭제되지 않고 남아있기 때문에 유지보수성을 해치고 의미 없는 코드로 인해 코드 가독성 또한 낮아집니다.

그렇다면 타입 캐스팅은 어떨까요?

// Java 코드
public int getScore(Job job) {
  if (job instanceof Police) {
    return ((Police) job).fame * 100;
  } else if (job instanceof Robber) {
    return ((Robber) job).infamy * 150;
  } else {
    // 이 경우는 불가능하니까 exception을 던진다?
    throw new RuntimeException("Unreachable code");
  }
}

타입 캐스팅을 통해 타입들을 명시할 경우 세 가지 문제가 발생합니다.

  1. 하위 타입이 늘어날 수록 if문이 많아지고 처리 속도는 느려진다.
  2. 하위 타입이 추가가 되고 if문이 추가되지 않으면 컴파일 에러 대신 예외가 던져진다.
  3. 코드에 불필요한 예외가 있어서 코드 가독성을 해친다.

여기서 1번 문제는 enum과 switch-case를 이용하여 어느정도 해결이 가능합니다. 하위 타입에서 현재 타입이 어떤 타입인지 enum으로 먼저 알려주고 나서 타입 캐스팅을 하면 됩니다.

// Java 코드
public enum JobType { POLICE, ROBBER }

public abstract class Job {
  public int speedLevel;

  public abstract JobType getType();
}

public class Police extends Job {
  public int strengthLevel;
  public int fame;

  @Override public JobType getType() {
    return JobType.POLICE;
  }
}

public class Robber extends Job {
  public int robberyLevel;
  public int infamy;

  @Override public JobType getType() {
    return JobType.ROBBER;
  }
}

public class Human {
  public String name;
  public int health;
  public Job job;

  public int getScore(Job job) {
    switch (job.getType()) {
      case POLICE:
        return ((Police) job).fame * 100;
      case ROBBER:
        return ((Robber) job).infamy * 150;
    }
    // 이 경우는 불가능하니까 exception을 던진다.
    throw new RuntimeException("Unreachable code");
  }
}

이 경우 하위 타입이 늘어나더라도 switch-case는 한 번만 실행되므로 성능적인 면에서는 손해를 보지 않습니다. 이러한 방식의 데이터 타입은 Tagged Union이라고 부릅니다. 사실 Rust와 Scala의 합 타입들은 마찬가지로 일종의 Tagged Union으로 구현되어 있습니다.

// Rust 코드
// Tagged Union에 필요한 enum 필드가 숨겨져 있다
pub enum Job {
  Police {
    strength_level: i32,
    speed_level: i32,
    fame: i32,
  },
  Robber {
    robbery_level: i32,
    speed_level: i32,
    infamy: i32,
  },
}

pub fn get_score(job: &Job) -> i32 {
  match job { // 내부적으로는 switch-case와 원리가 같다
    Job::Police { fame, .. }   => fame * 100,
    Job::Robber { infamy, .. } => infamy * 150,
  }
}

// Scala 코드
// JVM의 런타임 타입 정보를 이용한다
sealed trait Job
case class Police(strengthLevel: Int, speedLevel: Int, fame: Int) extends Job
case class Robber(robberyLevel: Int, speedLevel: Int, infamy: Int) extends Job

def getScore(job: Job): Int =
  job match { // 내부적으로는 switch-case와 원리가 같다
    case Police(_, _, fame)   => fame * 100
    case Robber(_, _, infamy) => infamy * 150
  }

이렇게 Scala와 Rust의 match 키워드로 데이터가 가질 수 있는 여러가지 경우의 수에 대한 정의를 할 수 있는 기능을 **패턴 매칭(Pattern Matching)**이라고 합니다. 그중에서도 합타입으로 패턴 매칭을 하고 하위 타입에 대한 모든 케이스를 정의할 경우 switch-case와 비슷한 연산으로 최적화되어 분기가 이루어집니다. 이것을 **완전 패턴 매칭(Exhuastive Pattern Matching)**이라고 부릅니다. 완전 패턴 매칭을 사용하면 하위 타입이 추가되어도 예외 대신 컴파일 에러를 내고 코드 추가를 유도하게 됩니다. 또한 이론적으로 “불가능한” 상황에 대한 예외를 던지는 코드를 추가할 필요가 없습니다.

Java는 Java 16에서 패턴 매칭을 추가되었고 Java 17에서 sealed 키워드가 추가되어 합타입이 정식적으로 지원되고 있습니다. C#은 C# 7.0부터 패턴 매칭을 추가했지만 아직 합 타입이 없기 때문에 완전 패턴 매칭이 불가능합니다.

합 타입 없이는 완전 패턴 매칭이 불가능한 이유는 인터페이스나 클래스는 소스 코드 어디서든 상속 받고 구현할 수 있기 때문입니다. 예를들어 라이브러리 코드 내부에서 완전 패턴 매칭을 했다고 합시다. 애플리케이션 코드에서 해당 타입에 하위 타입을 추가하게 되면 라이브러리 코드에는 해당 하위 타입에 대한 경우가 추가되어 있지 않아 오류가 발생합니다.

Rust의 합 타입은 하나의 enum 정의라서 하나의 파일에 모든 하위 타입이 정의되어있고, Scala, Kotlin, Java의 경우 합 타입에 sealed 키워드를 붙이는데 이는 모든 하위 타입을 해당 상위 타입이 선언된 파일에서 구현해야한다는 것을 의미합니다.

이처럼 합 타입과 곱 타입 위주로 데이터를 정의해가며 더 큰 타입을 만들어내는 방식을 **컴포지션(Composition)**이라고 부릅니다. 상속은 잘못된 데이터 설계를 불러일으키기 때문에 Go와 Rust는 상속을 지원하지 않고 컴포지션 위주의 데이터 설계를 유도합니다.

에러 처리와 타입 이론

합 타입과 곱 타입은 자주 쓰이는 만큼 익명으로(anonymously) 정의할 수 있는 경우가 많습니다. 그 중에서도 가장 널리 알려진 것은 곱 타입의 익명 타입인 **튜플(Tuple)**입니다. 튜플은 임의의 타입들을 곱 타입으로 묶습니다.

// C++ 코드
tuple<int, string, double> tp;

// C# 코드
(int, string, double) tp;

// Rust 코드
let tp: (i32, String, f64)

// Scala 코드
val tp: (Int, String, Double)

튜플은 함수에서 여러 값을 함께 리턴해야 할 때 유용합니다.

// C# 코드
// 상품을 구매하면 상품과 영수증이 같이온다.
public (Product, Receipt) PurchaseProduct(string productId)

// Scala 코드
// 상품을 구매하면 상품과 영수증이 같이온다.
def purchaseProduct(productId: String): (Product, Receipt)

합 타입도 익명 타입 버전이 존재합니다. Scala, Rust, Typescript가 익명적 합 타입을 지원하고 있지만, 3개 이상의 타입을 묶는 것은 Scala 3나 Typescript에서만 가능합니다.

// Scala 코드 (Scala 2)
sealed trait Either[+A, +B] // 정규 라이브러리에 정의된 타입
case class Left[A](value: A) extends Either[A, Nothing]
case class Right[B](value: B) extends Either[Nothing, B]

val couponCode: Either[Int, String]

// Scala 코드 (Scala 3)
val couponCode: Int | String | Array[Int]

// Typescript 코드
let couponCode: number | string | number[]

익명적 합 타입도 함수 출력에 쓰이는 경우가 많습니다. 다만 보통 여러 데이터를 한번에 내려주는 용도가 아닌, 오류 또는 실행 성공 결과 값을 내려주는 용도로 사용합니다. 이렇게 사용하는 경우가 너무 많아서 Rust에서는 익명적 합 타입의 이름이 Result입니다.

Scala에서는 성공 타입을 오른쪽 타입 파라미터에 넣는 경향이 있으나 Rust에서는 성공 값은 왼쪽 타입 파라미터에 넣습니다.

// Scala 코드
// 오류 없이 성공했다면 (Product, Receipt),
// 실패했다면 PurchaseError를 내려준다
def purchaseProduct(productId: String): Either[PurchaseError, (Product, Receipt)]

// Rust 코드
// 함수가 오류 없이 성공했다면 (Product, Receipt)
// 실패했다면 PurchaseError를 내려준다
pub fn purchase_product(product_id: &str) -> Result<(Product, Receipt), PurchaseError>

전통적 프로그래밍 언어들은 이러한 출력을 통한 에러 처리가 아닌 예외를 던지고 잡아서 처리하는 경우가 많습니다. 어째서 Rust와 Scala는 어째서 예외를 선택하지 않았을까요? 이는 예외가 가진 문제점들을 살펴보아야 합니다. 주요 프로그래밍 언어들 중 C++에서 가장 처음으로 예외를 추가하였는데, 가장 눈에 띄는 문제점은 함수 선언부를 확인해도 해당 함수가 예외를 던질지 말지를 알 수 없다는 것입니다.

// C++ 코드
Product purchaseProduct(string productId);        // 나는 예외를 과연 던질까 말까...

Product purchaseProduct(string productId) {
  if (productId.empty()) {
    throw new invalid_argument("Invalid id"); // 사실 던졌지롱 ㅋㅋㅋ
  }
  /* ... */
}

만약 예외가 발생하면 안되는 곳에서 이 함수를 호출하면 곧바로 프로그램 크래쉬로 이어집니다. 또한 어떤 예외를 던질지 몰라서 어느 예외를 잡아야할지 불분명해집니다. 만약 해당 함수가 수정되어 더 이상 예외를 던지지 않게 되어도 try-catch는 남아있을 것이며 다른 협업자들은 여전히 예외를 고려하며 함수를 호출할 겁니다. null 문제와 비슷하게 C++의 경우 throw() 키워드나 주석으로 문서화하는 것이 최선입니다.

이 문제를 해결하기 위해 Java는 반드시 선언부에 예외들을 기록하도록 강제하였습니다. 다만 모든 예외를 기록해야하는 것은 아니고 RuntimeException을 상속받지 않는 예외 클래스만 기록해야 합니다. 이것을 Checked Exception이라고 부릅니다.

// Java 코드
public class PurchaseException extends Exception {
  public PurchaseException(String message) {}
}

// throws PurchaseException을 넣지 않으면 컴파일이 되지 않는다
public Product purchaseProduct(String productId) throws PurchaseException {
  if (productId.isEmpty()) {
    throw new PurchaseException("Invalid id");
  }
  /* ... */
}

하지만 Java의 Checked Exception 또한 많은 문제점을 내포하고 있습니다. 이렇게 선언부에 추가해 try-catch를 강제하더라도 부주의한 개발자들이 Checked Exception을 잡아서 RuntimeException으로 감싸 다시 던지는 방법으로 회피를 한다는 의견도 있으며 선언부에 입력해야할 에러의 종류도 호출하는 함수가 많아질수록 불어납니다. 또한 예외가 어디서 발생하는지 명확하게 알 수 없는 문제는 여전히 남아있습니다.

// Java 코드
public Product purchaseProduct(String productId) throws Exception {
  // 이 많은 함수들중에서 언제 어디서 exception이 던져질까?
  ShopSystem shopSystem = getShopSystem();
  CalendarSystem calendarSystem = getCalendarSystem();
  Date date = calendarSystem.getCurrentDate();
  Wallet wallet = getWallet();
  Product product = shopSystem.getProduct(productId);
  transactionManager.makeTransaction(product, wallet);
  return product;
}

이러한 코드를 유지 보수하는 것은 지뢰밭을 건너는 것과 같습니다. 프로젝트와 코드 베이스에 대한 현저한 이해 없이 수정하는 것은 가능하면 피해야 합니다. 또한 선언부에 Exception이 추가될 때마다 그 함수를 호출하는 다른 모든 함수에도 마찬가지로 Exception을 추가해야하는 것이 대부분입니다. 그리고 코드를 읽어도 언제 어디서 예외가 던져지는지 파악하기가 어렵습니다. Rust의 경우 다음과 같이 완화됩니다.

// Rust 코드
// Product 타입이 아닌 Result<Product, MyError> 타입을 출력하기 때문에
// 에러 처리를 강제합니다.
pub fn purchaseProduct(productId: &str) -> Result<Product, MyError> {
  // Rust의 경우 에러가 발생할 수 있는 함수 호출은 에러가 발생할 경우 '?' 기호로 에러를 리턴한다.
  let shopSystem = getShopSystem()?; // 에러 발생가능
  let calendarSystem = getCalendarSystem();
  let date = calendarSystem.getCurrentDate()?; // 에러 발생가능
  let wallet = getWallet()?; // 에러 발생가능
  let product = shopSystem.getProduct(productId);
  transactionManager.makeTransaction(product, wallet)?; // 에러 발생가능
  return product;
}

이외에도 여러가지 문제점들이 있지만 결론적으로는 Checked Exception을 실패한 개념으로 여기는 것이 정론이며 차세대 OOP 언어들은 Checked Exception을 지원하지 않는 경우가 많습니다.

그렇기 때문에 Scala와 Rust는 예외의 문제점들을 피하기 위해 에러와 출력 값을 Sum Type으로 컴포지션하여 해결했습니다. 비슷하게 Go도 exception 대신 error를 다중 출력하는 방향으로 언어가 디자인 되었습니다.

// Scala 코드
def purchaseProduct(productId: String): Either[PurchaseError, Product]

// Rust 코드
pub fn purchase_product(product_id: &str) -> Result<Product, PurchaseError>

// Go 코드
func purchaseProduct(productId string) (Product, error)

이렇게 언어들이 어떠한 방식으로 타입 이론의 기초적인 요소들을 활용하였는지를 알아봤습니다. 하지만 합 타입과 곱 타입만으로는 아직 이해하기 쉽고 확장하기 쉬운 소프트웨어 아키텍처를 구현할 수 없습니다. 다음 편은 코드의 재사용성을 높이는 **다형성(Polymorphism)**이 어떠한 방식으로 이러한 문제를 해결하는지 알아볼 것입니다.

참고자료

데브시스터즈는 최고의 인재를 찾고 있습니다.

자세한 내용은 채용 사이트를 확인해주세요!

© 2024 Devsisters Corp. All Rights Reserved.