1P by GN⁺ 23시간전 | ★ favorite | 댓글 1개
  • 프로그래밍 시 타입 시스템을 활용하여 서로 다른 데이터 의미를 명확히 구분할 수 있음
  • 문자열이나 정수처럼 일반적인 타입을 그대로 사용하는 것은 맥락을 잃게 하며 버그로 이어질 수 있음
  • 동일한 기반 타입이더라도 목적에 맞게 새로운 타입을 정의하면 컴파일 타임 오류로 실수를 방지 가능
  • Go 라이브러리 libwx에서는 측정 단위를 명확히 구분하는 타입들을 정의해 float64 혼용에 의한 실수를 방지
  • 예시 코드에서 UUID 타입을 UserID와 AccountID로 분리해 잘못된 사용을 컴파일러가 차단
  • Go처럼 타입 시스템이 강하지 않은 언어에서도 간단한 타입 래핑으로 버그를 예방할 수 있음

타입 시스템을 적극적으로 활용하자

문제의 출발점: 단순 타입의 혼용

  • 프로그래밍에서는 string, int, UUID 같은 기본 타입만으로 많은 값을 표현하는 경우가 많음
  • 하지만 프로젝트 규모가 커지면 이런 단순 타입이 서로 구분 없이 혼용되어 사용되는 실수가 잦아짐
    • 예: userID 문자열을 실수로 accountID로 넘기거나, int 인자가 3개 있는 함수에서 순서를 잘못 넘기는 실수 등

해결책: 의도를 드러내는 타입 정의

  • intstring빌딩 블록일 뿐, 시스템 전반에 그대로 넘기면 의미 있는 맥락이 사라짐
  • 이를 방지하려면 역할별로 고유한 타입을 정의해서 사용해야 함
    • 예:
      type AccountID uuid.UUID  
      type UserID uuid.UUID  
      
      func UUIDTypeMixup() {  
          {  
              userID := UserID(uuid.New())  
              DeleteUser(userID)  
              // 에러 없음  
          }  
      
          {  
              accountID := AccountID(uuid.New())  
              DeleteUser(accountID)  
              // 에러: AccountID 타입을 UserID로 사용할 수 없음  
          }  
      
          {  
              accountID := uuid.New()  
              DeleteUserUntyped(accountID)  
              // 컴파일 타임 에러 없음, 런타임에 문제가 발생할 가능성 높음  
          }  
      }  
      
  • 이렇게 하면 잘못된 타입의 인자를 컴파일 타임에 차단할 수 있음

실제 적용 사례: libwx 라이브러리

  • 필자는 자신의 Go 라이브러리 libwx에서 이 기법을 실천 중
  • 모든 측정 단위에 대해 전용 타입을 정의하고, 단위 변환 메서드도 타입에 연결
    • 예: Km.Miles() 메서드를 통해 단위를 명확히 구분함
  • 아래는 잘못된 함수 인자 순서와 단위 혼동을 컴파일러가 차단하는 예시:
    // 화씨 온도 선언  
    temp := libwx.TempF(84)  
    
    // 상대습도 선언(퍼센트)  
    humidity := libwx.RelHumidity(67)  
    
    // 화씨 대신 섭씨 온도를 요구하는 함수에 잘못 전달  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointC(temp, humidity))  
    // 컴파일러가 타입 mismatch 오류를 바로 검출  
    // temp (TempF 타입)는 TempC로 사용할 수 없음  
    
    // 함수에 인자 순서 잘못 전달  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointF(humidity, temp))  
    // 컴파일러가 인자 타입 오류를 막아줌  
    
  • 단순히 float64를 썼다면 발생할 수 있는 실수들을 모두 예방 가능

결론: 타입 시스템을 적극 활용하자

  • 타입 시스템은 단순히 문법 검사용이 아니라 버그 예방 도구
  • 모델마다 ID 타입을 따로 정의하고, 함수 인자도 float이나 int 대신 명확한 타입으로 감싸야 함
  • 이 방식은 Go처럼 타입 시스템이 강하지 않은 언어에서도 매우 효과적이며 구현도 간단
  • 현실에서는 UUID나 문자열 타입 혼용에 의한 버그가 정말 많음
  • 이 간단한 방식이 생산 코드에서 흔히 사용되지 않는 현실이 놀랍다고 저자는 강조함

관련 코드

Hacker News 의견
  • 나는 이 접근 방식을 좋아함, '나쁜 상태를 표현 자체로 막기(make bad state unrepresentable)'는 방식임, 다만 이 패턴에서 흔히 생기는 문제점은 개발자들이 타입 구현의 첫 단계에만 머물러 있다는 점임, 모든 것이 타입이 되고 서로 잘 호환되지 않으며, 미묘하게 변형된 많은 타입들이 생겨서 코드를 추적하고 이해하기 어려워짐, 이런 상황이라면 차라리 약하게 타입이 적용된 동적 언어(JS)나 강하게 타입된 동적 언어(Elixir)로 쓰고 싶음, 하지만 개발자들이 조건문 로직을 패턴 매칭 가능한 유니언 타입에 밀어 넣고, 위임을 잘 활용하는 등 타입 주도 흐름을 계속 밀어붙이면 개발 경험이 다시 쾌적해짐, 예를 들어 'DewPoint' 함수는 여러 타입을 받아도 자연스럽게 동작하도록 만들 수 있음

    • 이 이유로, 더 많은 언어들이 바운드드(Integer 범위 제한) 타입을 기본 지원했으면 함, 예를 들어 x: u32 말고 x는 [0,10) 범위만 허용하게 타입 시스템에서 강제할 수 있기를 바람, 이러면 배열 인덱싱에서 바운드 체크가 필요 없어짐, Option 같은 경우에도 peephole 최적화가 훨씬 쉬워짐, Rust에선 함수 내부에선 LLVM 덕분에 일부 이런 지원이 있지만 함수 간 변수 전달에선 지원되지 않음

    • 참고로 Ruby는 약한 타입이 아니라 강한 타입임, 1 + "1" 같은 연산을 하면 'TypeError: String can't be coerced into Integer'와 같은 오류를 냄

    • '타입 구현의 첫 단계에서 멈추는 것'이 실패의 원인임, 예시로 int를 struct로 감싸 UUID로 쓰기 시작하는 건 좋은 출발이지만, 누군가 int만 있으면 타입 래핑해서 넘겨버려 실제로 고유해야 할 UUID 속성이 깨질 수 있음, 결국 'Correct by construction(구축 시점에 올바름 보장)'이 중요한데, UUID처럼 유일해야 하는 타입은 함수나 생성자에서 예외를 던지든 뭔가 방식으로 정말 증명되지 않으면 생성 못하도록 막아야 함, 이 개념은 UUID뿐 아니라 어떤 타입과 불변식에도 적용 가능함

    • 최근에 Red-Green-Refactor 패턴을 따르는데, 실패하는 테스트 대신 타입 시스템을 더 엄격하게 만들어 버그가 타입 체커에서 잡히게 함, 새 기능이나 엣지 케이스, 타입으로 에러가 유도 안 되는 버그는 여전히 테스트로 처리하지만, 타입 시스템을 활용한 red-green-refactor가 일반적으로 빠르고 버그의 대범주를 완전히 막을 수 있음

    • 구조적 타입(structural types)으로 대부분 문제를 완화할 수 있음, 정말 필요하면 명목적 타입(nominal types)으로 강제도 가능함

  • 예외와 타입에 인접한 얘기로, 체크드 예외를 잘 활용해서 타입별로 적합하게 처리하는 게 좋다고 생각함, Java의 체크드 예외가 비난받는 이유를 이해하지 못하겠음, 내가 맡은 프로젝트에서 강제로 체크드 예외를 쓰게 했을 때 초반엔 모두 싫어했지만, 코드 흐름의 모든 예외 케이스를 고민하는 과정에 익숙해지니 다들 좋아하게 됨, 단위 테스트엔 그리 엄격하지 않았지만 프로젝트가 매우 견고해짐

    • Java 체크드 예외에 대한 불만은 예외 처리가 너무 번거롭기 때문임, 라이브러리 작성자는 체크드 예외를 명확히 결정할 수 없고, 클라이언트 쪽에서 함수를 호출할 때마다 쓸데없이 예외 처리를 해야 하니 싫어질 수밖에 없음, 예외를 다른 타입으로 혹은 런타임 예외로 쉽게 전환하거나 모듈/앱 단위로 선언만 하면 이런 문제가 줄어들 텐데 너무 번거로움, 또, 서명을 깨기 쉬우니 도메인별 예외를 써야 하는데 Java가 예외 변환도 불편하게 만듦, 체크드 예외는 좋지만 Java 예외 처리의 사용성이 싫음

    • 체크드 예외가 비난받은 이유는 남용 때문임, Java가 체크드와 언체크드 둘 다 지원하는 건 좋은 선택임, 하지만 Eric Lippert가 말한 'exogenous' 예외 같은 데만 체크드 예외를 쓰고 대부분은 언체크드로 전환하는 게 바람직함, 예를 들어 DB가 언제든 연결이 끊길 수는 있지만 'throws SQLException'을 콜스택 위까지 계속 표시하긴 너무 번거로움, 최상위에서 catch-all로 처리하고 HTTP 500을 반환하면 됨, 관련 글

    • 체크드 예외(비체크드와 비교해서)는 콜스택 깊은 함수가 예외를 던지게 바뀌면, 처리 함수뿐만 아니라 그 사이 함수들 전부를 변경해야 할 수도 있음, 즉 시스템 변경 시 유연성이 떨어짐, async 함수 coloring 논란도 비슷한 맥락임, 예외를 던질 수 있으면 try/catch로 감싸든지, 호출자도 예외를 던진다고 선언해야 함

    • C#은 타입은 명확하지만 언체크드 예외를 채택했음, 에러 스택이 깔끔하게 정리되고 문제 없음, 패턴 매칭된 예외 핸들러가 레벨마다 bespoke 처리하는 것보다 깨끗함, robust한 언래핑 에러 결과가 있다면 비슷하다고 생각함

    • Java에선 체크드 타입의 사용성이 떨어진다는 점이 있고, 예를 들어 stream API 사용 시 map/filter 함수에서 체크드 예외를 던지면 정말 난감함, 여러 서비스 호출에서 각각 자신의 체크드 예외가 있다면 결국 Exception 잡기나 터무니없이 긴 예외 목록을 써야 함

  • 전반적으로는 '고유 타입 만들기' 방침에 동의하지만, 모든 것이 고유 타입인 시스템에서 힘들었던 경험이 많았음, 특히 바이트만 이리저리 옮기는 코드와 도메인 계산 코드가 뒤섞이면 정말 어렵게 느껴짐

    • 그 느낌 이해함, 이미 필요한 데이터가 있는데, 우선 타입을 만들거나 인스턴스를 생성하는 방법부터 찾아야 하니 레시피가 없으면 문서와 사투하는 기분임, 예를 들어 {x, y, z} 객체가 있지만 createVector(x, y, z): Vector 함수부터 써야 하고, Face를 만들려면 createFace(vertices: Vector[]): Face 같은 식이라 괜히 절차가 길어짐, BouncyCastle 같이 바이트 배열이 준비돼있어도 타입 여러 개를 만들고 서로 methods를 써야 실제 원하는 기능을 쓸 수 있음

    • Go 언어에선 타입 alias를 원래 타입(ex: AccountID → int)로 되돌리는 게 꽤나 쉬움, 제대로 구조를 잡으면 도메인 로직은 타입 alias를 쓰고, 도메인 신경 안 쓰는 라이브러리 측은 higher/lower 타입으로 변환해서 처리하는 클린 아키텍처 스타일도 가능함, 하지만 변환 코드가 매우 많이 필요함

    • Phantom types(팬텀 타입)이 이런 경우에 유용함, 타입 파라미터(즉 제네릭)를 추가하지만 실제 그 파라미터는 아무 데도 안 씀, 예전에 Scala에서 암호화 코드 짤 때 배열은 전부 바이트였지만 팬텀 타입으로 서로 섞이는 걸 방지함, 관련 사례

    • 이상적으로는, 컴파일러가 타입만 확인하면 남은 도메인 로직은 모두 단순 바이트 복사로 내려주면 좋겠음, 내가 네 의도를 제대로 이해한 건지는 모르겠지만

  • 타입 시스템도 80/20 법칙이 적용된다고 생각함, 지나치게 과하게 적용하면 라이브러리 사용이 부담스러워지고 실 이득도 거의 없음, UUID나 String 정도는 익숙하지만 AccountID, UserID 같은 건 모르기 때문에 새로 배워야 하니 비용이 큼, elaborate 타입 시스템이 가치가 있을 수도 없을 수도 있음(테스트가 충분하다면 특히), 관련 참고

    • 어차피 소프트웨어를 쓰려면 Account나 User가 뭔지는 알아야 해서, getAccountById 처럼 AccountId를 받는 함수가 UUID를 받는 함수보다 이해가 어렵진 않다고 생각함

    • 사실 String은 그저 바이트 집합일 뿐 아무 의미도 없음, AccountID라면 대부분 ‘계정의 ID’임을 알 수 있음, 진짜 내부 표현이 궁금하면 타입 정의를 보면 되지만 대부분의 맥락에선 AccountID가 뭔지만 알면 됨, 타입이라는 건 결국 명확한 이름이 붙으면 쓸 때 덜 헷갈림, grugbrain.dev 링크는 오히려 너무 기본적인 수준임, grug brain이라면 이 정도 타입 분리는 찬성할 것임

    • foo(UUID, UUID)보다 foo(AccountId, UserId) 형태가 훨씬 바람직함, 자기 설명적이고, 실수로 순서를 바꿔 호출할 때 컴파일러가 잡아줄 수 있음, 복잡한 데이터 구조에서도 새로운 타입을 만들지 않고 명확하게 쓸 수 있음

      Map<UUID, List<UUID>>
      Map<AccountId, List<UserId>>
      
    • "UUID 혹은 String이면 이미 친숙하다"는 말에, 실제로 UUID가 GUIDv1, UUIDv4, UUIDv7 등 어떤 형태로 저장/변환되는지 제대로 알기 어려움, 경험상 Java+MS SQL 조합에서 UUID와 uniqueidentifier 간 변환 시 엔디언 변환 문제로 직접 손을 봐야 했던 적 있음, 데이터베이스 타임존 자동 변환 꼬임과 비슷한 문제로 추측함

    • 사실 이런 타입을 알아야 하는 건 어차피 필요한 일이었음, 아니면 잘못된 데이터를 그대로 함수에 넘겼을 수밖에 없음

  • 최근 우리 팀도 C++ 코드에서 여러 숫자 값이 혼용된 부분에 타입을 적용해봤음, 계기는 버그를 찾아 고치다가 안전한 타입을 도입하고, 그랬더니 비슷한 잘못된 값 사용이 세 군데 더 있음이 밝혀짐

  • mp-units(mp-units 공식 문서) 라이브러리가 이런 물리단위 문제에 초점을 맞춘 예시를 떠올리게 함, 강력한 단위 타입을 쓰면 안전성 확보와 복잡한 단위 변환 로직이 자동화되고, 제네릭 코드로 다양한 유닛을 처리할 수 있음, 이를 Prolog 세계에 도입해보려고 했지만 주변 동료들은 그리 호응하지 않음, prolog용 예제

    • 예전에 여러 물리량(거리, 속도, 온도, 압력 등)을 다루는 프로젝트를 했는데, 전부 그냥 float로 넘겨서 거리값을 속도자리에 넣어도 컴파일에는 문제없고 런타임에서야 버그가 드러남, 단위(예: km/h vs miles/h) 잘못 전달로 인한 문제도 마찬가지임, 타입을 늘려서 개발 단계에서 이런 문제를 잡고 싶었지만, 당시에는 주니어였고 설득이 힘들었음

    • 물리 단위별 타입 적용이 너무 복잡할까봐 포기했었는데 mp-units를 살펴볼 계획임, 특히 변수가 어떤 단위인지를 명확히 표시하지 않아서 문제가 자주 생김, 외부 데이터나 표준 함수 등은 단위 미표시가 흔함

  • C#에서 다음과 같이 타입을 만듦

    readonly struct Id32<M> {
      public readonly int Value { get; }
    }
    

    그럼

    public sealed class MFoo { }
    public sealed class MBar { }
    Id32<MFoo> x;
    Id32<MBar> y;
    

    이런 식으로 각기 다른 integer ID를 구분해 쓸 수 있음, IdGuid나 IdString 등으로 확장도 가능하고 새 마커 타입(M)도 한 줄 추가만 하면 됨, TypeScript와 Rust에서도 비슷한 변형을 사용함

    • 비슷한 패턴을 사용해 본 적 있음, 그리고 int ID면 enum이 가장 friction이 낮지만 너무 헷갈릴 것 같아 실제 코드에 넣진 않았음, 관련 토론

    • 이 패턴은 MFoo나 MBar의 값이 런타임에 존재하지 않으므로 'fanthom type'이라 부름

    • 이런 용도로 Vogen 등 라이브러리도 있음, Vogen은 Value Object Generator의 약자로, 소스 코드 생성으로 Value object 타입 추가를 지원함, readme에 비슷한 라이브러리와 링크도 있음

  • 이 방식 예전에도 본 적 있었지만 목적을 몰랐음, 오늘도 세 개의 문자열 인자를 받는 함수를 작성하면서 직접 타입 파싱을 강제할지, 함수 내에서 할지 고민했는데, 사실 파싱값이 필요 없었던 상황이라 이 방법이 바로 내가 찾던 답임, 올해 내 코딩 스타일에 가장 큰 영향을 줄 듯함

  • 내 친구 Lukas가 이 아이디어를 'Safety Through Incompatibility'로 정리해 둔 게 있음, 나는 이 패턴을 golang 코드에 전부 적용해서 아주 유용하다고 느낌, 잘못된 ID 전달을 원천적으로 막아줌
    관련 글 1
    관련 글 2

  • Swift에서는 typealias 키워드가 있지만 기본 타입이 같으면 서로 자유롭게 변환될 수 있으니 실질적으로 이 목적엔 적합하지 않음, 구조체 래퍼(wrapper struct)가 Swift에선 관용성이 좋고 ExpressibleByStringLiteral까지 활용하면 그럭저럭 편리함, 하지만 '강한 타입별칭(strong typealias)' 같은 새 키워드(typecopy 등)가 있어 "이건 그냥 String이지만 특별한 의미의 String이니 다른 String과 섞지 말 것"을 명시할 수 있으면 좋겠음

    • 실제 대부분 언어들이 이런 식임, 예를 들어 rust/c/c++도 그렇고, Go 예시처럼 래퍼 타입 안 만들어도 될 때가 기분 좋음, C++에선 생성자를 명시적으로(explicit) 표시하지 않으면 int를 Foo 타입 자리에 자유롭게 넣을 수 있어 더욱 주의가 필요함

    • 이론상 우아해 보여도 실전 적용은 복잡할 수 있음, C++에서 std::cout에 넣거나, 기존에 String을 받던 서드파티 함수 또는 확장 포인트와의 호환성 등 실제 동작이 고민됨

    • Haskell에는 이런 개념이 newtype으로 있음, OOP 언어에서는 타입이 final이 아니면 서브클래스를 쉽게 만들어 원하는 행위를 추가/특화할 수 있음, 부가 래퍼나 박싱 없이 저렴하고 간단함, 그러나 Java에선 String이 final이기 때문에 이 방법이 어렵고, String 자체를 specialization 하는 게 어려움

    • 구체적으로 구조체 래퍼랑 어떻게 다르게 동작하기를 원하는지 궁금함