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/v3는 AddOperator 함수로 커스텀 연산자를 등록할 수 있습니다.
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 과정에서 배운 것
- 문서화의 중요성: 메인테이너가 README에 커스텀 연산자 문서와 "공식 스펙이 아님" 경고문 추가를 요청했습니다. 오픈소스에서는 코드만큼 문서도 중요합니다.
- 코드 구조 맞추기: 기존 코드베이스의 패턴을 따라야 합니다.
init()함수를 별도 파일에 두지 않고 기존operation.go에 병합하라는 피드백을 받았습니다. - 커뮤니티 반응: JSON Logic 스펙 메인테이너(TotalTechGeek)가 "향후 JSON Logic 기본 권장사항에 포함될 가능성이 있다"고 긍정적인 피드백을 남겼습니다. 실제 유즈케이스 기반의 기여가 환영받는다는 걸 느꼈습니다.
마무리
이 구현 중 배열 연산자 3개는 라이브러리에 PR로 제출했습니다. 비슷한 니즈가 있는 분들에게 도움이 되길 바랍니다.
댓글