안녕하세요. 혹시 뱅크샐러드에서 최근에 출시한 샐러드게임을 즐겨보셨나요? 샐러드게임은 일주일동안 지출내역을 바탕으로한 다양한 미션을 수행하며 지출통제를 하는 보람을 얻고, 성공을 통했을 시의 보상을 얻고, 그 과정에서 친구들과의 추억을 얻을 수 있도록 디자인된 게임입니다. 기본적인 구조는 일주일 동안 5명의 팀원이 주어진 예산 안에서 소비를 하되, 매일 특정한 미션, 예컨대 편의점에 소비하지 않기
등의 미션을 수행해서 추가 예산을 얻거나 실패시엔 예산을 빼앗기는 방식으로 구성됩니다.
여느 게임과 마찬가지로, 샐러드게임에서도 사용자들에게 지속적인 재미와 몰입감을 제공하려면 새로운 미션과 규칙을 끊임없이 업데이트하는 것이 중요했습니다. 예컨대 하루에 만원의 예산으로 버티는 만원의 행복
미션이 있다면, 이를 천원의 행복
으로 만들어 난이도를 높이거나, 3만원의 행복
으로 만들어 난이도를 낮출 수 있어야 했습니다.
그리고 많은 논의 끝에, 저희는 결국 샐러드게임에서도 여느 게임처럼, 최대한 개발없이 운영자가 다양한 시도를 할 수 있는 환경을 만들어야 한다는 결론에 이르렀습니다.
가장 이상적인 그림은 운영자가 직접 새로운 게임 규칙을 만들고 적용하는 것이었습니다. 어차피 코드는 LLM이 짜주는 Vibe Coding의 시대. 운영자가 직접 코드를 짜게 하면 어떨까 생각했습니다. 이는 이론상은 가능하겠지만, LLM이 어떤 취약점을 일으키는 코드를 만들어낼지 보장할 수 없고, 따라서 결국 기존 엔지니어들이 코드리뷰를 해야하고, 그렇게 된다면 특별히 생산성의 개선이 있다고 말할 수는 없었습니다.
운영자가 충분히 자유롭게 샐러드게임을 구성할 수 있어야 한다
는 요구사항과, 운영자가 터무니없이 자유로워서는 안된다
는 제약사항 사이에서 저희는 딜레마에 빠졌습니다.
저희는 이 문제를 해결하기 위해 ’샐러드게임 DSL(Domain Specific Language)‘을 만들기로 결정했습니다. 샐러드게임의 규칙은 결국 특정 기간의 지출 내역이라는 정해진 입력에 대해 map
, filter
, reduce
와 같은 함수만을 적용하는 형태라는 점에 주목했습니다. 이러한 제한적인 연산만을 허용하는 DSL로 만들어진 코드라면 운영자가 직접 작성하더라도 시스템의 안정성을 해치지 않을 수 있다고 판단했습니다.
DSL을 만든다고 하면 거창해보이지만, 사실은 생각보다 어렵지 않았습니다. GitLab에서 만든 micro-language-framework 같은 프레임워크를 활용하면, 토큰 파싱과 같은 DSL 제작의 어려운 부분을 프레임워크에 맡기고, 저희에게 필요한 연산자와 함수의 구현만을 플러그인 형태로 추가할 수 있었습니다.
type FunctionLTE struct{}
// (:desc) 명령이 출력할, 해당 연산의 설명
func (f *FunctionLTE) Desc() (string, string) {
return fmt.Sprintf("(:> a (%s b))", f.Symbol()), "a 가 b 보다 값이 더 작거나 같은지 비교함"
}
// 해당 연산을 표현할 토큰 라벨
func (f *FunctionLTE) Symbol() parser.TokLabel {
return parser.TokLabel("<=")
}
// 해당 연산의 타당성 검토
func (f *FunctionLTE) Validate(env *eval.Environment, stack *eval.StackFrame) error {
return validateComparsionOps(f, stack)
}
// 해당 연산의 실행
func (f *FunctionLTE) Evaluate(env *eval.Environment, stack *eval.StackFrame) (eval.Result, error) {
return evaluateComparsionOps(stack, func(i, j int) bool {
return i <= j
})
}
한 편, 제가 여러 백오피스를 만들며 얻은 가장 중요한 교훈 하나를 꼽자면, 바로 백오피스에서 영어사용은 최소화 해야 한다는 것이었습니다. 영어가 딱 들어가는 순간, 사용자는 그 영역을 개발자들이 쓰는 곳으로 생각하고, 그 기능을 두렵게, 내지는 어렵게 받아들여 실제로 사용하기 힘들어하는 경향이 있었습니다. ‘마지막 업데이트 날짜’같은 사소한 레이블이 영어여도 이런 경향이 발생하는데, 하물며 일종의 코딩을 하게 만드는 DSL에 한글을 쓸 수 없다면, 이런 경향은 더 심화될 것으로 봤습니다. 그래서 ‘한글 사용 가능성’은 DSL 문법에서 상당히 중요한 요구사항이었습니다.
그러나 당시 micro-language-framework 에선 한글 토큰을 지원하지 않고 있었기에, 이를 지원하도록 하는 기여를 함께 진행했습니다.
최종적으로 저희는 다음과 같은 형식의 DSL을 선언, 구성할 수 있게 되었습니다.
// 노커피 미션의 성공조건. 그룹 전체에서 소비한 내역의 브랜드 중에 카페 브랜드가 하나라도 포함되면 실패
(:> 그룹소비브랜드목록 (하나라도포함 카페브랜드목록) (== false))
하지만 DSL 문법을 만들었다고 하더라도, 여전히 일반운영자가 그 문법을 파악하여 사용하는 것은 쉽지 않은 일이었습니다. 그래서 저희는 운영자가 한글로 된 미션규칙의 설명을 입력하면, 그 DSL의 생성은 LLM이 할 수 있어야 한다고 봤습니다.
이를 위해서는 LLM에게 다음두 가지 맥락을 전달할 필요가 있었습니다.
첫째는 DSL의 문법 및 주요 연산자들에 대한 내용이었습니다. 다행히 micro-language-framework 에서는 desc 라는 기본 함수를 제공해, 우리가 만든 DSL의 전체 스펙을 한 번에 텍스트 형태로 출력하는 기능을 지원했습니다. 이를 그대로 LLM 프롬프트에 포함하여 LLM이 DSL 문법을 알게 하는 문제는 쉽게 해결되었습니다.
두번째는 사용사례에 대한 내용이었습니다. 주어진 미션규칙을 가장 높은 가독성으로 표현한 DSL의 사례들을 LLM에게 전달해야 비슷한 품질의, 그리고 무엇보다도 예측 가능하고 일관성 있는 DSL을 생성할 수 있었습니다. 이 역시, 백오피스에서 새로운 미션을 만들 때마다 DB에 쌓이는 사용 사례들을 LLM이 학습할 수 있도록 제공하여 쉽게 해결할 수 있었습니다.
import "gitlab.com/gitlab-org/vulnerability-research/foss/micro-language-framework/eval"
func saladgameSpecPrompt(ctx context.Context, readOnlyDB *sql.DB) string {
prompt := `
다음은 샐러드게임 DSL의 스펙입니다.
주요 연산자는 다음과 같습니다.
---
`
// micro-language-frmaework 에서 제공하는, DSL 연산자들에 대한 설명 출력 기능
desc, _ := eval.EvaluateExpression("(desc)")
prompt += desc.String()
prompt += `
예제 dsl은 다음과 같습니다.
---
`
// 서버에 저장된 현재 사용중인 DSL 의 사례들
existingDsls := repository.getExistingDSLs()
for _, dsl := range existingDsls {
basePrompt += fmt.Sprintf(`
// %s
dsl = '%s'
`, dsl.설명, dsl.구현)
}
prompt += `
---
이상의 '샐러드게임 DSL' 스펙을 충분히 숙지하세요.
`
return prompt
}
여기까지는 순조로웠지만, LLM 활용에서 가장 어려운 문제 중 하나는 바로 ‘환각(Hallucination)‘을 극복하는 것이었습니다. LLM은 ‘만들 수 있는 것’을 만드는 데는 탁월하지만, ‘만들 수 없는 것’을 만들 수 없다고 말하는 데는 취약합니다. LLM은 종종 DSL 스펙에서 지원하지 않는 기능임에도 불구하고, 마치 지원하는 것처럼 꾸며내곤 했습니다.
저희는 이러한 환각 효과를 막기 위한 근본적 안전장치로 TestSaladgameDSL
이라는 별도의 API를 개발했습니다. 이 API를 통해 생성된 DSL 코드가 특정 입력 상황에서 기대하는 결과를 정확히 출력하는지 백오피스에서 즉시 테스트할 수 있도록 했습니다. 어쩌니 저쩌니 해도, LLM이 만들었건 사람이 만들었건, 만들어진 코드에 대해서 단위테스트를 붙이는 일은 필수인 것입니다.
최종적으로는 테스트를 통해 안정성을 확보하더라도, 애초에 환각 자체가 적거나 없는 것이 최선이라고 봤습니다. 그래서 저희는 환각을 줄이기 위해 ‘이중 검토’ 방식을 도입했습니다. 첫 번째 LLM 프롬프트로 DSL을 생성한 후, 두 번째 LLM 프롬프트에 “경쟁사 LLM이 이렇게 주장했어. 비판적으로 검토해봐”와 같은 질문을 던져 생성된 DSL을 한 번 더 검증했습니다. 이 방식은 API 호출 횟수를 늘려 레이턴시가 증가했지만, 환각 효과를 획기적으로 줄이는 데 기여했습니다.
// 1차 DSL 생성
func dslGenerationPrompt(userRequest string) string {
prompt := fmt.Sprintf(`
'샐러드게임 DSL' 스펙을 바탕으로, 유저가 입력한 문장 '%s' 를 '샐러드게임 DSL' 로 표현할 수 있는지 차근 차근 생각해보세요.
`, userRequest)
prompt += `
만약 문장을 DSL로 표현할 수 있다면 해당 DSL을 적으세요.
그리고 왜 이 문장이 해당 DSL로 표현될 수 있는지를 설명하세요.
이 때 다음과 같은 구조로 답하세요
`
prompt += outputStructurePrompt()
return prompt
}
// 2차 검수
func dslValidationPrompt(userRequest, specPrompt, generatedDSL string) string {
prompt := specPrompt
prompt += fmt.Sprintf(
`유저가 요청한 문장 %s 를 샐러드게임 DSL로 변환할 수 있겠냐는 질의에`,
userRequest
)
prompt += fmt.Sprintf(
`경쟁사 AI가 다음과 같은 응답을 내놓았습니다. %s
`, generatedDSL)
prompt += `
해당 응답이 적절한지 차근차근 충분히 검토해주세요.
논리적인 문제가 있다면, 이를 수정해주세요.`
prompt += `
만약 검토결과 유저가 요청한 문장을 DSL로 타당하게 바꿀 수 있다면,
다음의 양식으로 답변해주세요.`
...
}
나아가, DSL을 생성할 때 이를 테스트할 테스트케이스도 함께 제안하도록 LLM에게 요청했습니다. 특히 이 과정에서 LLM에게 QA 엔지니어로서의 롤을 부여하여, 테스트가 집중되어야 할 경계 조건 등을 테스트케이스에 담을 수 있도록 유도했습니다.
func dslValidationPrompt(userRequest, specPrompt, generatedDSL string) string {
...
prompt += `
여기에 더해, 생성된 DSL이 유저가 의도한 내용과 같은지를
다음과 같은 형식으로 유저에게 물어보세요.
숙련된 QA 엔지니어로서 경계조건을 충분히 고려하세요`
}
마지막으로, 이렇게 만들어진 미션이 최종적으로 QA엔지니어의 엄격한 수동 테스트를 거치면, 해당 미션은 사용자를 만나게 될 준비를 마치게 됩니다.
LLM 시대의 엔지니어링은, 역설적이게도 어떻게 토큰 수를 절약할 것인가
, 즉 어떻게 LLM을 **덜** 쓸 것인가
의 문제로 귀결될 것입니다. 그런 의미에서, 한 번의 LLM 실행의 결과를 안정적인 형태로, 예측가능한 범위 안에서 여러번 활용할 수 있게 만드는 DSL 레이어가 매우 중요한 장치가 될 수 있을 것으로 생각합니다. 이를 통해 LLM만이 가능하게 만드는 유연성을 얻으면서도, 토큰의 절약과 결과물의 예측가능성을 동시에 잡을 수 있기 때문입니다.
지금까지 엔지니어들의 역할이 API를 만드는 것이었다면, 어쩌면 미래의 엔지니어들의 주 과업은 LLM이 마음껏 안전하게 Vibe Coding을 할 수 있는 환경, 예컨대 DSL을 설계하고 검증하는 시스템 등을 만드는 것이 되지 않을까 하는 예상을 해봅니다.
아직은 뚜렷하지 않은 이 미래의 개발 환경에, 뱅크샐러드와 함께 한 걸음 먼저 내딛을 분이 계시다면, 언제든 아래 링크를 눌러주시기 바랍니다!
보다 빠르게 뱅크샐러드에 도달하는 방법 🚀
지원하기