워크플로우 자동화 엔진 만들기 - Go에서 JSON Logic 확장하기
본문 바로가기
Go

워크플로우 자동화 엔진 만들기 - Go에서 JSON Logic 확장하기

by IYK2h 2025. 12. 23.
728x90

TL;DR

폼 빌더 SaaS에서 사용자 정의 자동화 조건을 처리해야 했습니다. JSON Logic 라이브러리를 사용했는데, 필요한 연산자가 없어서 직접 10개를 구현했습니다. 이 글에서는 왜 필요했고, 어떻게 구현했는지 공유하겠습니다.


배경: 왜 JSON Logic이 필요했나요?

문제 상황

Walla는 폼 빌더 SaaS입니다. 사용자가 폼을 만들고, 응답이 들어오면 자동으로 Webhook, 이메일, Google Sheets 등으로 데이터를 보내는 자동화 기능이 있습니다.

여기서 핵심은 조건 분기입니다:

"응답자가 'VIP'와 '프리미엄' 둘 다 선택했으면 → Slack 알림"
"이메일이 @company.com으로 끝나면 → 내부 시스템 연동"
"선택한 옵션 중 하나라도 '긴급'이 있으면 → 즉시 알림"

이 조건들은 사용자가 UI에서 설정합니다.

왜 JSON Logic인가요?

조건을 저장하고 런타임에 평가해야 하므로, 선택지는 다음과 같았습니다:

방법 장점 단점
직접 DSL 만들기 완전한 통제 파서, 평가기 다 만들어야 함
JavaScript eval 유연함 보안 위험, Go에서 못 씀
JSON Logic 표준화됨, 안전함, 다양한 언어 지원 연산자가 제한적

JSON Logic을 선택했습니다. jsonlogic.com에서 정의한 표준이고, Go 라이브러리도 있습니다.


JSON Logic 기본 개념

JSON으로 조건문을 표현합니다:

// "age > 18"
{">" : [{"var": "age"}, 18]}

// "name == 'John'"
{"==" : [{"var": "name"}, "John"]}

// "age > 18 AND country == 'KR'"
{"and": [
  {">": [{"var": "age"}, 18]},
  {"==": [{"var": "country"}, "KR"]}
]}

Go에서 평가하는 방법입니다:

import "github.com/diegoholiveira/jsonlogic/v3"

logic := `{">" : [{"var": "age"}, 18]}`
data := `{"age": 25}`

result, _ := jsonlogic.ApplyRaw([]byte(logic), []byte(data))
// result: true

문제: 필요한 연산자가 없습니다

저희 자동화 시스템에서 필요한 조건들입니다:

조건 기본 제공?
배열에 모든 값이 포함되어 있나? ❌ 없음
배열에 하나라도 포함되어 있나? ❌ 없음
배열에 아무것도 포함 안 되어 있나? ❌ 없음
문자열이 ~로 시작하나? ❌ 없음
문자열이 ~로 나나? ❌ 없음
문자열이 ~를 포함하나? ❌ 없음

기본 in 연산자는 "값이 배열에 있나?"만 체크합니다. 저희가 필요한 것은 "배열 A의 모든 값이 배열 B에 있나?" 같은 것이었습니다.


해결: 커스텀 연산자 구현

github.com/diegoholiveira/jsonlogic/v3AddOperator 함수로 커스텀 연산자를 등록할 수 있습니다.

1. contains_all - 모든 요소 포함 확인

사용 케이스: "VIP와 프리미엄 둘 다 선택한 사람만"

jsonlogic.AddOperator("contains_all", func(values, data any) any {
    valuesSlice, ok := values.([]interface{})
    if !ok || len(valuesSlice) != 2 {
        return false
    }

    // 첫 번째: 검색 대상 배열
    searchArray, ok := toInterfaceSlice(valuesSlice[0])
    if !ok {
        return false
    }

    // 두 번째: 필수 값 배열
    requiredArray, ok := toInterfaceSlice(valuesSlice[1])
    if !ok {
        return false
    }

    // 모든 필수 값이 검색 배열에 있는지 확인
    for _, required := range requiredArray {
        found := false
        for _, item := range searchArray {
            if reflect.DeepEqual(item, required) {
                found = true
                break
            }
        }
        if !found {
            return false
        }
    }
    return true
})

사용 예시:

// 사용자가 선택한 옵션: ["VIP", "프리미엄", "골드"]
// 조건: VIP와 프리미엄 둘 다 있어야 함

{
  "contains_all": [
    {"var": "selected_options"},
    ["VIP", "프리미엄"]
  ]
}
// 결과: true

2. contains_any - 하나라도 포함 확인

사용 케이스: "긴급, 중요, 우선 중 하나라도 있으면 알림"

jsonlogic.AddOperator("contains_any", func(values, data any) any {
    valuesSlice, ok := values.([]interface{})
    if !ok || len(valuesSlice) != 2 {
        return false
    }

    searchArray, ok := toInterfaceSlice(valuesSlice[0])
    if !ok {
        return false
    }

    checkArray, ok := toInterfaceSlice(valuesSlice[1])
    if !ok {
        return false
    }

    // 하나라도 있으면 true
    for _, check := range checkArray {
        for _, item := range searchArray {
            if reflect.DeepEqual(item, check) {
                return true
            }
        }
    }
    return false
})

3. contains_none - 아무것도 포함 안 됨

사용 케이스: "블랙리스트 키워드가 하나도 없을 때만 진행"

jsonlogic.AddOperator("contains_none", func(values, data any) any {
    valuesSlice, ok := values.([]interface{})
    if !ok || len(valuesSlice) != 2 {
        return true
    }

    searchArray, ok := toInterfaceSlice(valuesSlice[0])
    if !ok {
        return true
    }

    checkArray, ok := toInterfaceSlice(valuesSlice[1])
    if !ok {
        return true
    }

    // 하나라도 있으면 false
    for _, check := range checkArray {
        for _, item := range searchArray {
            if reflect.DeepEqual(item, check) {
                return false
            }
        }
    }
    return true
})

4. 문자열 연산자들

// begins_with - 문자열 시작 확인
jsonlogic.AddOperator("begins_with", func(values, data any) any {
    valuesSlice, ok := values.([]interface{})
    if !ok || len(valuesSlice) != 2 {
        return false
    }
    str, ok1 := valuesSlice[0].(string)
    prefix, ok2 := valuesSlice[1].(string)
    if !ok1 || !ok2 {
        return false
    }
    return len(str) >= len(prefix) && str[:len(prefix)] == prefix
})

// ends_with - 문자열 끝 확인
jsonlogic.AddOperator("ends_with", func(values, data any) any {
    valuesSlice, ok := values.([]interface{})
    if !ok || len(valuesSlice) != 2 {
        return false
    }
    str, ok1 := valuesSlice[0].(string)
    suffix, ok2 := valuesSlice[1].(string)
    if !ok1 || !ok2 {
        return false
    }
    return len(str) >= len(suffix) && str[len(str)-len(suffix):] == suffix
})

// string_contains - 부분 문자열 포함
jsonlogic.AddOperator("string_contains", func(values, data any) any {
    valuesSlice, ok := values.([]interface{})
    if !ok || len(valuesSlice) != 2 {
        return false
    }
    str, ok1 := valuesSlice[0].(string)
    substr, ok2 := valuesSlice[1].(string)
    if !ok1 || !ok2 {
        return false
    }
    return strings.Contains(str, substr)
})

사용 예시:

// 이메일이 @company.com으로 끝나는지
{"ends_with": [{"var": "email"}, "@company.com"]}

// 이름이 "김"으로 시작하는지
{"begins_with": [{"var": "name"}, "김"]}

삽질 포인트: 타입 강제 변환

문제

JSON에서 오는 숫자는 전부 float64입니다. 그런데 비교할 때 int와 비교하면 실패합니다:

// JSON에서 파싱된 데이터
data := map[string]interface{}{
    "age": float64(25),  // JSON은 항상 float64
}

// 비교하려는 값
compareValue := 25  // int

reflect.DeepEqual(float64(25), 25)  // false! 타입이 다르니까

해결

== 연산자를 오버라이드해서 타입 강제 변환을 추가했습니다:

jsonlogic.AddOperator("==", func(values, data any) any {
    valuesSlice, ok := values.([]interface{})
    if !ok || len(valuesSlice) != 2 {
        return false
    }
    return isEqual(valuesSlice[0], valuesSlice[1])
})

func isEqual(a, b interface{}) bool {
    // 1. 먼저 직접 비교 시도
    if reflect.DeepEqual(a, b) {
        return true
    }

    // 2. 둘 다 숫자면 float64로 변환해서 비교
    aNum, aIsNum := toNumber(a)
    bNum, bIsNum := toNumber(b)
    if aIsNum && bIsNum {
        return aNum == bNum
    }

    return false
}

func toNumber(val interface{}) (float64, bool) {
    switch v := val.(type) {
    case float64:
        return v, true
    case int:
        return float64(v), true
    case int64:
        return float64(v), true
    case string:
        var num float64
        if _, err := fmt.Sscanf(v, "%f", &num); err == nil {
            return num, true
        }
        return 0, false
    default:
        return 0, false
    }
}

전체 아키텍처에서의 위치

┌─────────────────────────────────────────────────────────┐
│  사용자가 UI에서 자동화 조건 설정                           │
│  "선택 옵션에 VIP와 프리미엄이 모두 포함되면 → Slack 알림"   │
└─────────────────────────────────────────────────────────┘
                            ↓
                   JSON Logic으로 저장
                            ↓
┌─────────────────────────────────────────────────────────┐
│  {                                                       │
│    "contains_all": [                                     │
│      {"var": "selected_options"},                        │
│      ["VIP", "프리미엄"]                                  │
│    ]                                                     │
│  }                                                       │
└─────────────────────────────────────────────────────────┘
                            ↓
                     폼 응답 제출됨
                            ↓
┌─────────────────────────────────────────────────────────┐
│  Automation Worker (Go)                                  │
│                                                          │
│  func executeRouterNode(...) {                          │
│      // JSON Logic 평가                                  │
│      result := jsonLogic.Evaluate(routerLogic, data)    │
│                                                          │
│      if result == "route-slack" {                       │
│          // Slack으로 분기                               │
│      }                                                   │
│  }                                                       │
└─────────────────────────────────────────────────────────┘

오픈소스 기여: PR 머지 성공 🎉

이 구현 중 배열 연산자 3개(contains_all, contains_any, contains_none)를 라이브러리에 PR로 제출했고, 머지되었습니다!

이제 github.com/diegoholiveira/jsonlogic/v3를 사용하면 별도 구현 없이 바로 사용할 수 있습니다:

import "github.com/diegoholiveira/jsonlogic/v3"

// 이제 기본 제공!
logic := `{"contains_all": [{"var": "tags"}, ["VIP", "프리미엄"]]}`

PR 과정에서 배운 것

  1. 문서화의 중요성: 메인테이너가 README에 커스텀 연산자 문서와 "공식 스펙이 아님" 경고문 추가를 요청했습니다. 오픈소스에서는 코드만큼 문서도 중요합니다.
  2. 코드 구조 맞추기: 기존 코드베이스의 패턴을 따라야 합니다. init() 함수를 별도 파일에 두지 않고 기존 operation.go에 병합하라는 피드백을 받았습니다.
  3. 커뮤니티 반응: JSON Logic 스펙 메인테이너(TotalTechGeek)가 "향후 JSON Logic 기본 권장사항에 포함될 가능성이 있다"고 긍정적인 피드백을 남겼습니다. 실제 유즈케이스 기반의 기여가 환영받는다는 걸 느꼈습니다.

마무리

이 구현 중 배열 연산자 3개는 라이브러리에 PR로 제출했습니다. 비슷한 니즈가 있는 분들에게 도움이 되길 바랍니다.


참고

728x90

댓글