Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

CUE의 기본적인 사용 방법

CUE에 관해서 Cedric Charly가 작성한 글을 번역해서 구성(Configuration) 복잡도의 저주CUE가 승리하는 방법을 올렸었는데 그 뒤로는 CUE를 더 공부 못하고 있었다.

CUE

CUE 홈페이지

CUE는 Go언어로 만들어진 오픈소스 언어로 구성(configuration), API, 데이터베이스 스키마, 코드에 대한 데이터를 정의하고 생성하고 유효성검사를 할 수 있는 API와 도구를 제공한다. 구성 언어라고 볼 수 있는데 구성을 다루는 다양한 곳에서 사용할 수 있을 것으로 보인다.

CUE는 Google에서 사용하는 구성 언어인 GCL에 뿌리를 두고 있는데 GCL은 Kubernetes의 전신이 Borg를 구성하기 위해 만들어졌다. 이 경험치를 가지고 현재의 CUE가 만들어진 것으로 보인다.

CUE는 상당히 많은 기능이 있어서 프로그래밍 언어와 유사한 느낌도 많이 받긴 하는데 문법이 익숙한 형태는 아니라서 처음에는 코드를 읽기도 어려웠고 어떤 의미인지 추측하기 어려웠다. 조금씩 살펴보는 중이라 아직 모르는 부분이 많지만 튜토리얼을 보면서 기본적인 사용 방법을 정리했다.

CUE 설치

CUE는 homebrew로도 설치가 가능하지만 난 릴리즈된 버전을 다운받아서 PATH에 추가했다.

$ cue version
cue version v0.4.3 darwin/arm64


JSON의 슈퍼셋

CUE는 JSON의 슈퍼셋으로 주석을 사용할 수 있고 큰따옴표가 없어도 되고 최상위 중괄호와 콤마도 상황에 따라 선택적으로 사용할 수 있다. JSON 객체를 CUE에서는 구조체(structs)라고 부른다. 아래와 같이 json.cue를 정의할 수 있다. JSON과 비슷하지만, JSON이 가진 제약(최상위 중괄호, 콤마, 큰따옴표 등)을 많이 생략한 JSON이라고 볼 수 있다.

// json.cue
one: 1
two: 2

// A field using quotes.
"two-and-a-half": 2.5

list: [
  1,
  2,
  3,
]

그래서 위처럼 정의한 json.cue 파일을 cue export로 JSON으로 변환해서 내보낼 수 있다.

$ cue export json.cue
{
    "one": 1,
    "two": 2,
    "two-and-a-half": 2.5,
    "list": [
        1,
        2,
        3
    ]
}

물론 YAML로 내보내는 것도 가능하다.

$ cue export --out yaml json.cue
one: 1
two: 2
two-and-a-half: 2.5
list:
  - 1
  - 2
  - 3


중복 필드

CUE에서는 중복 필드를 정의할 수 있다. 대신 충돌은 나지 않아야 하는데 기본 타입의 값을 반드시 같아야 하고 구조체의 필드는 합쳐진다. 리스트는 도 값이 똑같아야 한다.

// dup.cue
a: 4
a: 4

s: { b: 2 }
s: { c: 2 }

l: [ 1, 2 ]
l: [ 1, 2 ]

이 CUE 파일을 평가하면(cue eval) 다음과 같이 중복 필드는 없어지고 구조체의 값은 합쳐진 걸 볼 수 있다.

$ cue eval dup.cue
a: 4
s: {
    b: 2
    c: 2
}
l: [1, 2]

다음과 같이 a 필드를 중복 정의하면서 값을 다르게 한다면 값이 충돌했다는 오류가 발생한다.

// dup.cue
a: 4
a: 5
$ cue eval dup.cue
a: conflicting values 5 and 4:
    ./dup.cue:1:4
    ./dup.cue:2:4

CUE에서는 정의된 순서와 상관없이 결과가 항상 똑같이 나온다. 그래서 아래처럼 정의하는 것도 가능하다.

// order.cue
a: {x: 1, y: int}
a: {x: int, y: 2}

b: {x: int, y: 2}
b: {x: 1, y: int}

이를 평가해 보면 선언된 필드가 합쳐져서 잘 출력된 것을 볼 수 있다.

$ cue eval order.cue
a: {
    x: 1
    y: 2
}
b: {
    x: 1
    y: 2
}


타입과 값

CUE는 타입과 값을 합친다.

largeCapital: {
  name:    string
  pop:     >5M
  capital: true
}


제약사항

CUE에서는 어떤 값을 사용할 수 있는지 제약사항(Constraints)을 걸 수 있다.

// check.cue
schema: {
    name:  string
    age:   int
    human: true // always true
}

viola: schema
viola: {
    name: "Viola"
    age:  38
}

위처럼 스키마를 정의하고 viola 필드의 타입을 schema로 정의해서 값을 스키마에 맞게 지정할 수 있다.

$ cue eval check.cue
schema: {
    name:  string
    age:   int
    human: true
}
viola: {
    name:  "Viola"
    age:   38
    human: true
}

여기서 human 같은 경우는 schema에서 고정값으로 지정했기 때문에 viola에서 human을 정의하지 않아도 같은 결과가 나온다. 대신 ageint 타입이므로 문자열을 넣으면 다음과 같이 오류가 발생한다.

$ cue eval check.cue
viola.age: conflicting values int and "hello" (mismatched types int and string):
    ./check.cue:3:12
    ./check.cue:10:11


정의(definition)

위에서 스키마를 정의했지만 보통 CUE에서 스키마는 정의(definition)로 작성되고 정의는 #_#로 시작한다. 그래서 CUE는 정의를 유효성 검사에 사용하고 데이터로는 출력하지 않는다.

// schema.cue
#Conn: {
    address:  string
    port:     int
    protocol: string
}

lossy: #Conn & {
    address:  "1.2.3.4"
    port:     8888
    protocol: "udp"
}

lossy#Conn이라는 정의를 사용한다고 지정하고 스키마에 따라 값을 할당했다. 이를 출력하면 lossy 데이터만 출력되는 것을 볼 수 있다.

$ cue export schema.cue
{
    "lossy": {
        "address": "1.2.3.4",
        "port": 8888,
        "protocol": "udp"
    }
}

정의는 전체 필드를 모두 지정해야 하는 "닫힌" 구조체이다. 그래서 아래처럼 #Conn 정의에 지정하지 않은 name 필드를 추가하면 이를 허용하지 않는다.

// schema.cue
#Conn: {
    address:  string
    port:     int
    protocol: string
}

lossy: #Conn & {
    address:  "1.2.3.4"
    port:     8888
    protocol: "udp"
    name: "demo"
}

이를 실행하면 다음과 같이 name이 허용되지 않은 필드라면 오류가 난다.

$ cue export schema.cue
lossy: field not allowed: name:
    ./schema.cue:1:8
    ./schema.cue:7:8
    ./schema.cue:11:5

정의를 열린 구조체로 만들어서 추가 필드를 사용할 수 있게 하려면 다음과 같이 ...을 추가해 주어야 한다.

// schema.cue
#Conn: {
    address:  string
    port:     int
    protocol: string
    ...
}

다시 실행해 보면 lossy 객체에 name 필드가 출력된 것을 볼 수 있다.

$ cue export schema.cue
{
    "lossy": {
        "address": "1.2.3.4",
        "port": 8888,
        "protocol": "udp",
        "name": "demo"
    }
}


유효성 검사

정의를 이용해서 데이터의 유효성 검사를 할 수 있다. =~는 정규표현식과 일치하도록 하는 것이다. 그 외에도 지원되는 비교 연산자가 더 있다.

  • == : 같다
  • != : 같지 않다
  • < : 적다
  • <= : 적거나 같다.
  • > : 크다
  • >= : 크거나 같다.
  • =~ : 정규표현식과 일치한다.
  • !~ : 정규표현식과 일치하지 않는다.

아래에서 [...#Language]...은 리스트가 #Language정의의 요소가 여러 개 있을 수 있다는 의미가 된다. [#Language]로 정의한다면 요소가 1개인 리스타라는 의미가 된다.

// lang.cue
#Language: {
  tag:  string
  name: =~"^[A-Z]"
}

languages: [...#Language]

그리고 다음과 같이 data.yaml 파일이 있다고 해보자. 위 CUE에서 languages와 일치하는 languages 데이터가 포함되어 있다.

languages:
  - tag: en
    name: English
  - tag: nl
    name: Dutch
  - tag: no
    name: Norwegian

cue vet 명령어로 유효성 검사를 할 수 있다. 아무 결과가 나오지 않았다면 유효성 검사를 통과한 것이다.

$ cue vet lang.cue data.yaml

data.yaml에서 Norwegiannorwegian으로 바꿔서 첫 글자를 소문자로 바꾸고 검사해 보면 다음과 같이 오류가 난다.

$ cue vet lang.cue data.yaml
languages.2.name: invalid value "norwegian" (out of bound =~"^[A-Z]"):
    ./lang.cue:3:8
    ./data.yaml:7:12


단일 필드 구조체의 접기

JSON에서는 중첩된 필드로 정의하는 부분을 CUE에서는 다음과 같이 한 줄로 사용할 수 있다. 위에서는 값을 지정했고 아래에서는 위 중첩된 구조의 제약사항을 정의했다.

// fold.cue

// path-value pairs
outer: middle1: inner: 3
outer: middle2: inner: 7

// collection-constraint pair
outer: [string]: inner: int

outer 아래 아무 문자열이 키로 들어오고 그 아래 inner의 값은 int 타입이 된다. 그래서 이를 출력하면 아래와 같은 JSON이 나온다. 처음 위 CUE 코드를 보면 헷갈릴 수 있지만 JSON과 비교해 보면 쉽게 이해할 수 있다.

$ cue export fold.cue
{
    "outer": {
        "middle1": {
            "inner": 3
        },
        "middle2": {
            "inner": 7
        }
    }
}


타입

CUE에는 다음과 같은 타입 계층이 있다.

null  bool  string  bytes  number  struct  list
                           /   \
                         int  float

여기에 추가로 _|_로 표기하는 bottom 값이나 error가 있고 모든 타입 위에 있는 top, any는 _로 표기한다.

// types.cue
point: {
    x: number
    y: number
}

xaxis: point
xaxis: y: 0

yaxis: point
yaxis: x: 0

origin: xaxis & yaxis

여기서 point를 정의하고 xaxisy만 값을 지정하고 yaxis에는 x만 값을 지정했다. 이 둘을 합쳐서 origin을 정의해서 origin만 완전한 값을 가지게 되었다.

$ cue eval types.cue
point: {
    x: number
    y: number
}
xaxis: {
    x: number
    y: 0
}
yaxis: {
    x: 0
    y: number
}
origin: {
    x: 0
    y: 0
}

위 타입 계층에 따라 number, int, float를 사용할 수 있고 숫자 사이에 _로 가독성을 좋게 하거나 M, Gi 등의 단위도 쓸 수 있다.

a: int
a: 4 // type int

b: number
b: 4.0 // type float

c: int
c: 4.0 // 타입 오류

d: 4  // will evaluate to type int (default)
/
e: [
    1_234,       // 1234
    5M,          // 5_000_000
    1.5Gi,       // 1_610_612_736
    0x1000_0000, // 268_435_456
]

다음과 같이 멀티라인 문자열을 정의할 수도 있고 #의 개수를 맞춰서 묶어주면 Raw 문자열도 정의할 수 있다.

a: """
    Hello
    World!
    """

msg1: #"The sequence "\U0001F604" renders as \#U0001F604."#

구조체에서 close 내장 함수를 써서 해당 구조체는 다른 구조체에 머지되는 것만 허용하고 이를 타입으로 지정해서 사용하지 못하도록 할 수 있다.

a: close({
    field: int
})

b: a & {
    feild: 3
}

"tcp" | "udp"처럼 선언해서 protocoltcpudp 둘 중 하나가 되도록 지정할 수 있다.

#Conn: {
    address:  string
    port:     int
    protocol: "tcp" | "udp"
}

*를 사용해서 기본값을 지정할 수도 있다.

replicas: uint | *1

protocol: *"tcp" | "udp"

let A = a처럼 정의해서 a의 별칭을 정의할 수 있고 다음처럼 최상위 문자열이 평가될 때 참조한 값이 출력되게 할 수도 있다. 이를 평가하면 Hello world!가 출력된다.

"Hello \(#who)!"

#who: "world"

출력을 원하지 않는 필드는 앞에 _를 붙여주면 된다.

"_foo": 2
_foo:   3
foo:    4
_#foo:  5
$ cue export hidden.cue
{
    "_foo": 2,
    "foo": 4
}

아직도 못 본 기능이 많이 있지만 대략적인 특징과 사용 방법을 알게 되어서 이제 CUE 코드를 볼 때 대충 이해할 수 있을 것 같다. (아직 프로젝트 문서는 좀 친절하진 않을 것 같다.)

2022/08/14 22:55 2022/08/14 22:55