들어가며
Part 1에서는 한글 토큰화의 도전(유니코드, rune 타입)을 다뤘고, Part 2에서는 파서와 평가기를 구현하며 연산자와 출력 함수 문제를 해결했다. 이제 표준어 버전의 프로그래밍 언어 '새김'이 완성되었다!
하지만 무언가 재미있는 요소를 더하고 싶었다. 한글로도 더 다양한 표현이 가능하지 않을까?🤔 표준어뿐만 아니라 충청도 방언으로도 프로그래밍할 수 있다면 더 재미있지 않을까?
표준어 버전
만약 (참) {
출력("안녕")
}
충청도 방언 버전
이쪽이에유 (맞어유) {
출력해유("안녕")
}
이번 편에서는 여러 방언을 지원하는 확장 가능한 구조를 어떻게 설계했는지 소개한다.
방언을 지원하려면 어떻게 해야 할까?
초기에는 아래와 같이 표준어용 렉서/파서/평가기와 충청도 방언용 렉서/파서/평가기를 구현하려 했다.
// 표준어용 렉서
type StandardLexer struct { ... }
// 충청도용 렉서
type ChungcheongLexer struct { ... }
// 표준어용 파서
type StandardParser struct { ... }
// 충청도용 파서
type ChungcheongParser struct { ... }
하지만 이렇게 구현할 경우, 반복되는 코드가 많아 유지보수가 어렵고 새 방언을(ex - 경상도 방언) 추가할 시 모든 컴포넌트를 복사해야 한다. 따라서 이러한 문제를 해결할 수 있는 방법을 고안했다.
KeywordSet 패턴
1. 토큰 타입 분리
먼저 토큰 타입을 언어 중립적으로 정의한다.
token.go
package token
type TokenType string
type Token struct {
Type TokenType
Literal string
}
const (
ILLEGAL = "ILLEGAL"
EOF = "EOF"
IDENT = "IDENT"
INT = "INT"
STRING = "STRING"
ASSIGN = "="
PLUS = "+"
MINUS = "-"
ASTERISK = "*"
SLASH = "/"
LT = "<"
GT = ">"
EQ = "=="
NOT_EQ = "!="
COMMA = ","
SEMICOLON = ";"
LPAREN = "("
RPAREN = ")"
LBRACE = "{"
RBRACE = "}"
// 중복 토큰 타입 정의
VARIABLE = "VARIABLE"
IF = "IF"
ELSE = "ELSE"
TRUE = "TRUE"
FALSE = "FALSE"
FUNCTION = "FUNCTION"
RETURN = "RETURN"
PRINT = "PRINT"
)
type KeywordSet map[string]TokenType
func CheckKeyword(keyword string, keywordSet KeywordSet) TokenType {
if tok, ok := keywordSet[keyword]; ok {
return tok
}
return IDENT
}
2. 방언별 키워드 정의
이제 각 방언에 맞는 키워드 맵핑을 정의한다.
standard.go (표준어)
package token
var StandardKeywords = KeywordSet{
"변수": VARIABLE,
"만약": IF,
"아니면": ELSE,
"참": TRUE,
"거짓": FALSE,
"함수": FUNCTION,
"반환": RETURN,
}
chungcheong.go (충청도)
package token
var ChungcheongKeywords = KeywordSet{
"변수": VARIABLE,
"이쪽이에유": IF,
"저쪽이에유": ELSE,
"맞어유": TRUE,
"아녀유": FALSE,
"함수": FUNCTION,
"도루": RETURN,
}
3. Lexer 수정
기존 코드
// 하드코딩된 키워드
var keywords = map[string]TokenType{
"변수": VARIABLE,
"만약": IF,
// ...
}
이렇게 하면 표준어만 지원할 수 있다. 따라서 공통되는 코드는 keywordSet으로 분리하고 내장 함수를 분리하는 방향으로 개선했다.
개선된 코드
package lexer
type Lexer struct {
input []rune
position int
readPosition int
character rune
keywordSet token.KeywordSet // 추가
}
func New(input string, keywordSet token.KeywordSet) *Lexer { // 수정
lexer := &Lexer{
input: []rune(input),
keywordSet: keywordSet, // 주입
}
lexer.readChar()
return lexer
}
func (lexer *Lexer) NextToken() token.Token {
var tok token.Token
lexer.skipWhitespace()
switch lexer.character {
// 기존 케이스들
default:
if isKorean(lexer.character) {
tok.Literal = lexer.readIdentifier()
// KeywordSet을 사용하여 체크
tok.Type = token.CheckKeyword(tok.Literal, lexer.keywordSet)
return tok
} else if isDigit(lexer.character) {
tok.Type = token.INT
tok.Literal = lexer.readNumber()
return tok
} else {
tok = newToken(token.ILLEGAL, lexer.character)
}
}
lexer.readChar()
return tok
}
위와 같이 코드를 수정하니, KeywordSet만 바꾸면 다른 방언도 지원이 가능했고 렉서 코드를 수정하는 작업이 불필요했다. 이제 출력 내장 함수도 분리해보자.
Builtin 분리
builtins_standard.go
package evaluator
import (
"fmt"
"github.com/Jihyun3478/saegim-lang/object"
)
var StandardBuiltins = map[string]*object.Builtin{
"출력": &object.Builtin{
Fn: func(args ...object.Object) object.Object {
for _, arg := range args {
fmt.Println(arg.Inspect())
}
return NULL
},
},
}
builtins_chungcheong.go
package evaluator
import (
"fmt"
"github.com/Jihyun3478/saegim-lang/object"
)
var ChungcheongBuiltins = map[string]*object.Builtin{
"출력해유": &object.Builtin{
Fn: func(args ...object.Object) object.Object {
for _, arg := range args {
fmt.Println(arg.Inspect())
}
return NULL
},
},
}
evaluator.go 수정
package evaluator
var (
NULL = &object.Null{}
TRUE = &object.Boolean{Value: true}
FALSE = &object.Boolean{Value: false}
)
var builtins map[string]*object.Builtin // 전역 변수로 변경
func SetBuiltins(builtinMap map[string]*object.Builtin) { // 추가
builtins = builtinMap
}
모드 선택
main.go
package main
func main() {
fmt.Println("새김 언어 인터프리터")
fmt.Println("1. 표준어")
fmt.Println("2. 충청도")
var mode int
fmt.Scan(&mode)
var keywords token.KeywordSet
var builtins map[string]*object.Builtin
if mode == 1 {
keywords = token.StandardKeywords
builtins = evaluator.StandardBuiltins
fmt.Println("표준어 모드")
} else {
keywords = token.ChungcheongKeywords
builtins = evaluator.ChungcheongBuiltins
fmt.Println("충청도 모드")
}
evaluator.SetBuiltins(builtins)
// 코드 실행
input := `변수 나이 = 25; 출력(나이);`
l := lexer.New(input, keywords)
p := parser.New(l)
program := p.ParseProgram()
env := object.NewEnvironment()
evaluator.Eval(program, env)
}
사용성 개선
1. 파일 실행 모드
확장자로 언어를 자동으로 감지할 수 있다.
func getKeywordSetFromFile(filename string) token.KeywordSet {
if strings.HasSuffix(filename, ".sg") {
// .sg = 표준어 (Saegim)
return token.StandardKeywords
} else if strings.HasSuffix(filename, ".hbg") {
// .hbg = 충청도 (Haebolgyeo)
return token.ChungcheongKeywords
}
return token.StandardKeywords // 기본값
}
사용 예시
$ go run main.go hello.sg # 표준어로 실행
$ go run main.go hello.hbg # 충청도로 실행
실행 예제
hello.sg (표준어)
변수 점수 = 85;
만약 (점수 >= 90) {
출력("A학점");
} 아니면 {
만약 (점수 >= 80) {
출력("B학점");
} 아니면 {
출력("C학점");
}
}
hello.hbg (충청도)
변수 피보나치 = 함수(숫자) {
이쪽이에유 (숫자 < 2) {
도루 숫자;
}
도루 피보나치(숫자 - 1) + 피보나치(숫자 - 2);
};
출력해유(피보나치(0));
출력해유(피보나치(1));
출력해유(피보나치(5));
출력해유(피보나치(10));
테스트 코드 작성
evaluator_test.go
func TestStandardDialect(t *testing.T) {
tests := []struct {
input string
expected int64
}{
{"변수 나이 = 25; 나이;", 25},
{"만약 (참) { 10 }", 10},
}
for _, tt := range tests {
evaluated := testEvalStandard(tt.input)
testIntegerObject(t, evaluated, tt.expected)
}
}
func TestChungcheongDialect(t *testing.T) {
tests := []struct {
input string
expected int64
}{
{"변수 나이 = 25; 나이;", 25},
{"이쪽이에유 (맞어유) { 10 }", 10},
}
for _, tt := range tests {
evaluated := testEvalChungcheong(tt.input)
testIntegerObject(t, evaluated, tt.expected)
}
}
func TestBuiltinFunctions(t *testing.T) {
tests := []struct {
input string
expected interface{}
}{
{`출력(25)`, nil},
{`출력("안녕")`, nil},
}
for _, tt := range tests {
testEval(tt.input)
}
}
func testEval(input string) object.Object {
l := lexer.New(input)
p := parser.New(l)
program := p.ParseProgram()
env := object.NewEnvironment()
return Eval(program, env)
}
func testEvalStandard(input string) object.Object {
SetBuiltins(StandardBuiltins)
l := lexer.New(input, token.StandardKeywords)
p := parser.New(l)
program := p.ParseProgram()
env := object.NewEnvironment()
return Eval(program, env)
}
func testEvalChungcheong(input string) object.Object {
SetBuiltins(ChungcheongBuiltins)
l := lexer.New(input, token.ChungcheongKeywords)
p := parser.New(l)
program := p.ParseProgram()
env := object.NewEnvironment()
return Eval(program, env)
}
테스트를 통해 아래와 같이 에러가 처리되는 것을 확인할 수 있다.
- 표준어 모드: "이쪽이에유"를 일반 식별자로 인식 → 파서 에러
- 충청도 모드: "이쪽이에유"를 IF로 인식 → 정상 동작
향후 계획
1. 배열 자료구조 추가
2. for 반복문 구현
3. 에러 메시지 개선 (라인 번호 포함)
4. 웹 기반 REPL (브라우저에서 실행)
5. 더 많은 방언 지원 (경상도, 제주도 등)
회고
프로그래밍 언어를 만들면서 깨달았다. 우리가 매일 사용하는 if, for, System.out.println()이 얼마나 많은 고민과 설계 끝에 탄생했는지 말이다. 직접 한글 프로그래밍 언어를 만들어보며 단순히 언어를 사용하는 것에서 그치지 않고, 내부 동작 원리를 깊이 이해할 수 있었다.
실행 방법
저장소 클론
% git clone https://github.com/Jihyun3478/saegim-lang.git
실행
% go run main.go
예제 파일 실행
% go run main.go examples/hello.sg # 표준어
% go run main.go examples/hello.hbg # 충청도
전체 코드
https://github.com/Jihyun3478/saegim-lang
GitHub - Jihyun3478/saegim-lang: 한글 프로그래밍 언어 '새김' & 충청도 방언 버전 '해볼겨'를 제작했습니
한글 프로그래밍 언어 '새김' & 충청도 방언 버전 '해볼겨'를 제작했습니다☺️. Contribute to Jihyun3478/saegim-lang development by creating an account on GitHub.
github.com
이전 시리즈
https://jihyun-devstory.tistory.com/90
[새김 언어 제작기] Part 1 - 한글 처리 (feat. 유니코드와 렉서)
왜 한글 프로그래밍 언어인가?보통 프로그래밍 언어를 사용할 때, 줄곧 영어를 사용해 코드를 구현한다. 그 이유가 무엇일까? 바로 최초의 프로그래밍 언어들이 모두 영어로 구현되었기 때문이
jihyun-devstory.tistory.com
https://jihyun-devstory.tistory.com/94
[새김 언어 제작기] Part 2 - 파서의 함정들 (feat. 연산자와 출력 함수)
들어가며Part 1에서 한글 토큰화에 성공했다.https://jihyun-devstory.tistory.com/90 [새김 언어 제작기] Part 1 - 한글 처리 (feat. 유니코드와 렉서)왜 한글 프로그래밍 언어인가?보통 프로그래밍 언어를 사용
jihyun-devstory.tistory.com
'Project > 새김 언어 제작기' 카테고리의 다른 글
| [새김 언어 제작기] Part 2 - 파서의 함정들 (feat. 연산자와 출력 함수) (0) | 2025.11.23 |
|---|---|
| [새김 언어 제작기] Part 1 - 한글 처리 (feat. 유니코드와 렉서) (0) | 2025.11.19 |