Search
Duplicate

나만의 JSON 파서로 재귀 파싱 연습하기

간단소개
너무 길어져서 블로그에 써야 했나 싶지만…
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
C++
C
Web
Scrap
태그
9 more properties

어떤 형식의 설정 파일을 사용할까?

Webserv 과제를 진행하다보면 프로그램에서 사용할 설정 파일을 만들고 파싱(parsing)해야 하는 경우를 마주하게 됩니다. 많은 경우 Nginx를 참고하여 프로그램을 만들다 보니 Nginx의 config 형식을 사용하게 되는데, Nginx의 config는 Nginx만의 정해진 구문 규칙을 따르고 각 요소들이 계층 구조를 이루어, 구현하기 조금 까다롭고 확장성도 떨어진다는 단점이 있는 것 같습니다.
Nginx의 config를 어떻게 파싱할까 고민하던 와중… 문득 ‘굳이 Nginx의 config 형식을 그대로 따라야만 할까?’ 고민을 하게 되었습니다. Nginx의 구문 규칙에 JSON 형식을 적용한다면 Nginx config의 계층 구조도 따를 수 있고, JSON은 확장성이 넓고 보편적인 형식이기에 해당 과제 이후에도 널리 사용할 수 있겠다는 생각이 들어, 팀원에게 양해를 구하고 나만의 작은 JSON 파서(parser)를 만들어보게 되었습니다.
그리고 조금씩 JSON 파서를 만들다보니… JSON 파서를 만들어보는 일이 재귀 파싱을 연습하는 데 좋은 대상이 될 것 같다는 생각을 하게 되었고, 과제를 끝내고 게으르게 보내는 시간에 오랜만에 팔코에 들어와 JSON 파싱에 관한 글을 남겨봅니다…

JSON이란?

우리의 든든한 ChatGPT 선생님에게 물어보니 좋은 답변을 해주지만… 너무 길기에 간단히 요약해본다면…
JSON(JavaScript Object Notation)은 데이터 교환을 위한 텍스트 형식으로, 이해하기 쉽고, 플랫폼 독립적이며, Key-Value 쌍과 배열을 사용하여 복잡한 데이터 구조를 나타낼 수 있다. 또한 간단한 구문 규칙을 가져 데이터의 유효성을 검사할 수 있다.
… 라고 합니다. JSON의 자료형과 문법을 하나씩 확인해봅시다.

기본 자료형

수(Number)
문자열(String)
참/거짓(Boolean)
배열(Array)
객체(Object)
null
마지막으로 전체적인 JSON의 모습을 한 번 확인해볼까요?
{ "name": "John Doe", "age": 30, "isStudent": false, "courses": ["Math", "Science", "History"], "address": { "street": "123 Main St", "city": "Exampleville" } }
JSON
복사

Structure of JSON

JSON을 파싱하는 일을 시작하기에 앞서, 위 JSON을 살펴보며 JSON의 구조를 한 번 유추해봅시다.
음… 우선 JSON의 가장 첫 번째는 중괄호로 시작하고, 가장 끝은 중괄호로 끝나네… 아하, 객체(object)이구나! 그리고 그 안에는 String으로 된 Key가 있고, Key와 일대일 대응하는 여러 형식의 Value가 있구나… 배열(Array)은 대괄호로 시작하고 대괄호로 끝나면서 하나의 Value가 되고, 객체는 중괄호로 시작해서 중괄호로 끝나면서 하나의 Value가 되네. 비슷하게 문자열은 쌍따옴표로 시작해서 끝나지만, 정수나 부울 값은 따로 특정 시작-끝 문자를 지니지 않는구나… 마지막으로 각 요소의 끝을 보면, 하나의 요소 뒤에 또 다른 요소가 오면 그 사이에 쉼표가 오지만, 마지막 요소인 경우에는 쉼표가 없네! 음… 객체에서 마지막 요소인지 아닌지 구분하려면 어떻게 해야 할까…
이 정도만 떠올리셔도 JSON 구조를 대부분 파악하신겁니다! JSON의 중요한 구조 요소들을 아래에 다시 한 번 정리해보겠습니다.
1.
JSON은 객체 또는 배열로 시작합니다.
2.
객체는 중괄호로 둘러싸여 있으며, 키-값(Key-Value) 쌍의 모음입니다.
3.
배열은 대괄호로 둘러싸여 있으며, 값(Element)의 목록입니다.
4.
문자열은 쌍따옴표로 둘러싸여 있습니다.
5.
숫자에는 둘러싸는 문자가 없으며, 정수 또는 실수입니다.
6.
부울 값에는 둘러싸는 문자가 없으며, true 또는 false 로 표현됩니다.
7.
null 값에는 둘러싸는 문자가 없으며, null로 표현됩니다.
8.
각 요소(키-값 쌍 또는 값) 간에는 쉼표로 구분합니다.
a.
하지만 객체 또는 배열 내에서 마지막 요소의 뒤에는 쉼표가 없어야 합니다.

Using BNF

대충 어떻게 파싱해야 할 지 감이 오는 것 같지만… 그래도 어딘가 모호하고 잘 와닿지 않는 것 같습니다. 아무래도 프로그래머의 피가 흐르고 있는 우리로썬 정확한 명세를 눈으로 확인해야만 이 모호함을 걷어낼 수 있을 것 같아요. 그렇다면… JSON의 구조를 BNF로 확인해봅시다!
그런데 BNF란 무엇일까요? 이번에도 ChatGPT 선생님에게 물어보니…
BNF(Backus-Naur Form)는 컨텍스트-자유 문법(모든 프로그래밍 언어와 구조를 설명하는 데 사용되는 형식)을 정의하는 데 사용되는 메타언어의 한 형태로, 프로그래밍 언어의 문법이나 다른 형식의 구조를 형식적으로 설명하는 데 도움을 줍니다.
… 라고 하는군요… 설명이 조금 어려우니 16진수를 BNF로 나타낸 예시를 한 번 살펴봅시다.
<digit> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" <letter> ::= "A" | "B" | "C" | "D" | "E" | "F" <number> ::= <digit> | <letter> <integer> ::= <number> | <number><integer>
BNF
복사
아하! 대충 감이 오는 것 같습니다. digit에는 0부터 9까지의 숫자 문자가, letter에는 A부터 F까지의 알파벳 문자가 올 수 있고, numberdigit 또는 letter 가 되어 16진수 단일 문자를 나타내는군요.
재미있는 지점은 <integer>의 두 번째 규칙인 <number><integer> 입니다. 만약 <integer>에 첫 번째 규칙인 <number>만 있었다면, <integer> 는 단 하나의 16진수 문자(<number>)만 표현할 수 있었을테지만, <number><integer>를 두 번째 규칙으로 추가하게 됨으로써, 다양한 길이의 16진수 문자열을 표현할 수 있게 되었습니다. 이런 동작이 가능한 이유는 두 번째 규칙이 16진수 문자(<number>) 하나를 표현한 다음, 이어서 자기 자신을 다시 호출하는 재귀성을 가지기 때문입니다.
좋습니다! 이제 BNF가 대충 어떤 느낌인지 알았으니, JSON의 BNF 형식을 살펴봅시다.
아래 표현식은 BNF의 표준 형식과 완전히 일치하지 않습니다! (임의로 작성한 부분이 있어서요…)
또한 JSON의 BNF와 완전히 일치하지 않을수도 있습니다! (역시 임의로 해석한 부분이 있어서요…)
<json> ::= <object> | <array> <object> ::= <begin-object> [ <member> *( <value-separator> <member> ) ] <end-object> <array> ::= <begin-array> [ <value> *( <value-separator> <value> ) ] <end-array> <member> ::= <string> <name-separator> <value> <value> ::= <number> <string> <boolean> <array> <object> <null> <begin-object> ::= <ws> '{' <ws> <end-object> ::= <ws> '}' <ws> <begin-array> ::= <ws> '[' <ws> <end-array> ::= <ws> ']' <ws> <name-separator> ::= <ws> ':' <ws> <value-separator> ::= <ws> ',' <ws> <ws> ::= *( Space, Horizontal tab, Line feed or New line, Carriage return ) <number> ::= [ '-' | '+' ] <int> [ <frac> ] [ <exp> ] <int> ::= '0' | ( <digit1-9> *DIGIT ) <digit1-9> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' <frac> ::= '.' 1*DIGIT <exp> ::= ( 'e' | 'E' ) [ '-' | '+' ] 1*DIGIT <string> ::= <quotation-mark> *<char> <quotation-mart> <quotation-mark> ::= '"' <char> ::= <unescaped> | <escape> <unescaped> ::= printable characters <escape> ::= '\' ( '"' | '\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 4HEXDIG ) <boolean> ::= false | true <null> ::= null
BNF
복사
위 BNF에서 대괄호([, ])는 선택적인 요소, 즉 있을 수도 있고 없을 수도 있는 요소를 의미하고, 소괄호((, ))는 그룹화, 즉 소괄호로 둘러싸인 요소들을 하나의 논리적 단위로 처리하는 것을 의미합니다. 별표(*)는 다음 요소가 0회 이상 반복함을 의미합니다. 따라서 아래 BNF를 다음과 같이 해석할 수 있습니다.
<begin-object> [ <member> *( <value-separator> <member> ) ] <end-object>
BNF
복사
1.
<begin-object><end-object> 사이에 [ <member> *( <value-separator> <member> ) ]있을 수도 있고 없을 수도 있다.
2.
만약 있다면, <begin-object><end-object> 사이에 하나의 <member>가 표현되고, 이어서 <value-separator> <member> 0회 이상 반복하여 표현된다.
3.
만약 없다면, 결과적으로 <begin-object><end-object>만 표현된다.
조금 복잡해보이지만… 코드로 하나씩 옮겨보면 아주 쉽게 이해가 될거에요!
아래 파트부턴 제가 만들었던 JSON 파서에 대한 긴(지루한) 설명이 이어집니다. 훌륭한 파서 코드는 아니지만… 나름대로 JSON 파서를 만들어보는 과정에서, 재귀적인 문자열 파싱이 어떻게 진행되는지 감을 잡기 좋은 미니 프로젝트라는 생각이 들었습니다. 저처럼 문자열 파싱에 익숙하지 않으셨던 분들에게 작은 힌트가 되었으면 좋겠다는 생각을 해보며… 코드 설명을 진행해보겠습니다!

Making JSON Parser

But Wait… JsonData

파싱에 앞서, 파싱한 데이터를 어떤 방식으로 파서에 담아놓을 것인지 정하는 일이 필요합니다. 저는 JsonData 라는 클래스(class)를 따로 만들어, 파싱한 데이터를 JsonData형식에 맞게 변환하여 저장하였습니다.
class JsonData { public: jsonType _type; std::string _str; std::vector<JsonData> _arr; std::vector<std::pair<std::string, JsonData> > _obj; }
C++
복사
만약 아직 C++을 배우시지 않았다면 classvector와 같은 개념이 생소하실 수 있습니다. 간단하게 class는 구조체와 비슷한 개념으로, vector는 배열과 비슷한 개념으로 생각해볼 수 있을 것 같습니다(물론 그 둘은 엄격하게 다르다는 점도 중요하지만, 지금은 이해가 우선이니까요).
예시를 들어보겠습니다. 만약 JSON에서 string 요소를 파싱했을 경우, 해당 요소는 다음과 같이 JsonData로 변환되어 저장됩니다.
JsonData value; value._type = TYPE_STRING; value._str = "parsed string"; // nothing to do for value._arr and value._obj
C++
복사
예시를 하나 더 들어보겠습니다. object는 여러 개의 Key-Value 쌍을 가지고 있는 데이터 덩어리입니다. 만약 JSON에서 object 요소를 파싱했을 경우, 다음과 같이 JsonData로 저장됩니다.
JsonData value; value._type = TYPE_OBJECT; value._obj = objectVector; // nothing to do for value._str and value._arr
C++
복사
위와 같은 방식으로 파서를 구성하게 되면 확연한 장단점이 드러나게 되는 것 같습니다. 이에 대한 내용은 파싱에 대한 설명을 모두 마친 후에 작성해보겠습니다.

parseJson

좋아요, 이제 정말로 시작해봅시다! 우선 JSON 데이터가 저장된 파일의 데이터를 모두 읽어, 하나의 문자열(string)으로 가지고 있다고 가정하겠습니다(readFile).
먼저 JSON의 시작 부분입니다. JSON은 항상 객체 또는 배열로 시작해야 합니다.
std::string text; std::string::iterator start; readFile(filepath, text); start = text.begin(); _skipWhiteSpaces(text, start); if (*start == '{') { this->_json = parseObject(text, start); } else if (*start == '[') { this->_json = parseArray(text, start); } else { _errorExit("Error: Invalid Json format"); } if (start == text.end()) { _errorExit("Error: JSON is not properly terminated by format"); else { start++; } _skipWhiteSpaces(text, start); if (start != text.end()) { _errorExit("Error: Failed to parse JSON file"); } else { // parse Json successfully }
C++
복사
마찬가지로 C++을 아직 배우시지 않았다면 이터레이터(iterator)가 생소할 수 있습니다. 이터레이터를 자료형의 위치를 가리키는 포인터와 유사한 개념으로 생각해볼 수 있을 것 같습니다. 따라서 위 코드에서 text.begin()text 문자열의 시작 위치를 start 가 가리키고 있다고 이해할 수 있습니다.
전체적인 파싱이 문자열의 문자들을 하나씩 읽어가며 상태를 체크하고, 상태에 맞는 행동을 수행하는 과정으로 진행된다는 점을 눈여겨볼만 하다고 생각이 듭니다!
<json> ::= <object> | <array> <object> ::= <begin-object> [ <member> *( <value-separator> <member> ) ] <end-object> <array> ::= <begin-array> [ <value> *( <value-separator> <value> ) ] <end-array> <begin-object> ::= <ws> '{' <ws> <end-object> ::= <ws> '}' <ws> <begin-array> ::= <ws> '[' <ws> <end-array> ::= <ws> ']' <ws>
BNF
복사
JSON의 BNF와 함께 살펴봅시다. 객체와 배열의 시작 문자의 앞과 뒤를 _skipWhiteSpaces로 공백 문자를 모두 무시해줍시다.
이터레이터의 위치가 중괄호의 시작 문자({)를 가리킨다면 객체를 파싱하는 parseObject로 넘어가고, 대괄호의 시작 문자([)를 가리킨다면 배열을 파싱하는 parseArray로 넘어갑니다.
만약 객체와 배열 파싱하는 긴 작업이 모두 완료되었다면, 이터레이터는 중괄호 혹은 대괄호의 끝 문자를 가리키고 있을 것입니다. _skipWhiteSpaces로 끝 문자 이후의 공백 문자를 모두 넘어가게 되면 text문자열의 끝이 나오는지 확인합니다. 가장 상위 객체 혹은 배열은 단 하나만 존재할 수 있기에, 만약 text가 EOF를 가리키지 않는다면 하나 이상의 객체 또는 배열이 있을 수도 있다는 의미가 되므로 오류로 처리합니다.
이어서 객체를 처리하는 parseObject 함수로 넘어가봅시다.

parseObject

객체를 파싱하는 파트의 BNF는 아래와 같습니다.
<object> ::= <begin-object> [ <member> *( <value-separator> <member> ) ] <end-object> <member> ::= <string> <name-separator> <value> <value> ::= <number> <string> <boolean> <array> <object> <null> <name-separator> ::= <ws> ':' <ws> <value-separator> ::= <ws> ',' <ws>
BNF
복사
<begin-object>에 대한 부분을 처리하고 왔으니, 이제 객체 내부의 요소들을 하나씩 처리해봅시다. 아래 코드는 객체 요소를 파싱하기에 앞서 잘못된 부분이 없는지 다시 한 번 확인해봅니다.
JsonData jsonData; std::vector<std::pair< std::string, JsonData> > jsonObject; if (it == text.end()) { _errorExit("Error: EOF encountered before reading object"); } else if (*it != '{') { _errorExit("Error: Object must start with an open curly bracket"); } else { it++; } _skipWhiteSpaces(text, it); if (it == text.end()) { _errorExit("Error: EOF encountered while reading object"); } else if (*it == '}') { jsonData._str = "Object"; jsonData._type = TYPE_OBJECT; return jsonData; } else { // proceed (object has key-value) }
C++
복사
만약 현재 이터레이터가 가리키는 시작 문자가 중괄호({)가 아니라면 오류로 처리합니다. 객체는 반드시 중괄호로 시작하기 때문입니다.
확인을 완료했다면 _skipWhiteSpaces로 중괄호의 시작 문자 이후의 공백 문자들을 모두 넘어가줍니다(<begin-object> ::= <ws> '{' <ws>를 기억해봅시다!).
공백 문자를 모두 넘어가고 나서, 이터레이터가 중괄호 끝 문자(})을 가리키게 된다면 빈 객체로 판단하여, 내부 요소가 들어있지 않은 객체 타입의 JsonData을 반환합니다. 그렇지 않다면 객체 내부의 요소를 읽는 작업을 진행합니다.
while (it != text.end() && *it != '}') { std::string key; JsonData value; if (it == text.end()) { _errorExit("Error: EOF encountered before reading object key"); } else if (*it == '\"') { key = parseStringKey(text, it); it++; } else { _errorExit("Error: Object key must starts with double quote"); } _skipWhiteSpaces(text, it); if (it == text.end()) { _errorExit("Error: EOF encountered while reading object key"); } else if (*it != ':') { _errorExit("Error: Missing colon after key"); } else { it++; } _skipWhiteSpaces(text, it); // ...
C++
복사
객체의 내부 요소는 Key-Value 형태를 가져야 합니다. BNF를 다시 한 번 살펴봅시다.
<object> ::= <begin-object> [ <member> *( <value-separator> <member> ) ] <end-object> <member> ::= <string> <name-separator> <value> <name-separator> ::= <ws> ':' <ws>
BNF
복사
객체 내부 요소(<member>)가 존재하는 상황이므로, 반드시 하나 이상의 <member>를 파싱해야 합니다. <member>는 Key-Value의 값으로 각각 <string><value>를 가지며, 이 두 값 사이는 <name-separator>로 구분됩니다.
먼저 Key값이 될 <string>을 파싱합니다. 만약 이터레이터가 끝을 가리키거나, <string>의 시작 문자인 쌍따옴표()를 가리키지 않는다면, <string> 타입의 Key를 파싱할 수 없으므로 오류 처리를 합니다.
parseStringkey 함수는 쌍따옴표로 둘러싸인 문자열에서 문자열만 추출하여 반환한 후, 이터레이터를 Key의 끝 쌍따옴표로 향하게 합니다(그래서 it++를 해줘야 합니다). Key 파싱이 완료되었다면 <name-seperator>를 확인합니다.
공백 문자를 모두 무시한 후, <name-seperator>(:)가 나오지 않는다면 오류 처리를 합니다. 그리고 다시 한 번 공백 문자를 모두 무시하며 <value> 파싱을 진행합니다.
// ... if (it == text.end()) { _errorExit("Error: EOF encountered while reading object value"); } else { value = parseValue(text, it); jsonObject.push_back(std::make_pair(key, value)); } // ...
C++
복사
이번에도 낯선 함수 make_pair가 등장하는군요… 간단하게 두 개의 필드를 지닌 구조체로 이해할 수 있을 것 같습니다.
struct make_pair { char* key; JsonData value; };
C++
복사
push_backvector라는 배열 혹은 연결리스트에 값을 추가(append)하는 역할을 한다고 볼 수 있습니다.
parseValue 함수는 <value>를 파싱합니다(객체 파싱을 모두 마친 후 <value>에 대한 설명을 이어가겠습니다). Key-Value가 모두 파싱되었다면 두 값을 하나의 쌍으로 만들어 객체 vector(jsonObject)에 추가합니다.
객체 벡터는 아래와 같이 선언되었습니다.
std::vector<std::pair< std::string, JsonData> > jsonObject;
C++
복사
조금 복잡해보이지만… C언어로 풀어서 다시 써본다면 아래와 같습니다. 이전에 vector를 배열과 유사하다고 설명드렸지만, vector는 배열과 다르게 동적으로 크기를 조절할 수 있습니다.
make_pair jsonObject[VECTOR_SIZE];
C++
복사
// ... if (it == text.end()) { _errorExit("Error: EOF encountered while reading object value"); } else if (checkKeyValueEnd(text, it) == false) { // found character which is not included in end parts of object _errorExit("Error: Object key must starts with double quote"); } else { it++; } _skipWhiteSpaces(text, it); if (*it == ',') { it++; _skipWhiteSpaces(text, it); } else { // nothing to do } } jsonData._str = "Object"; jsonData._type = TYPE_OBJECT; jsonData._obj = jsonObject; return jsonData;
C++
복사
아마 눈치채신 분들도 계시겠지만, 위 코드가 표현하는 <member>를 파싱하는 부분이 JSON의 BNF와 완전히 일치하지 않습니다…
JSON의 BNF는 *( <value-separator> <member> )로, <value-separator><member>가 ‘하나의 묶음’으로써 0회 이상 반복되어야 하는데, 제가 작성했던 JSON 파서의 코드는 이 부분을 놓치고 다른 방식으로 <member>의 반복을 확인하였습니다. (왜냐하면 파서를 모두 만들고 나서 팔코에 글을 쓸 때 쯤, JSON에도 BNF가 있다는 점을 알게 되었기 때문입니다… 수정은 훗날에 해봐야겠어요…)
제가 작성한 코드를 잠깐만 살펴본다면, checkKeyValueEnd 함수는 현재 <value>가 마지막이거나 반복되는 경우에만 해당하는지 확인합니다. <value>가 마지막이거나 반복되는 경우인지 확인하는 방법으로, <value> 뒤에 오는 문자가 중괄호 끝(})인지 혹은 쌍따옴표()인지 확인합니다. (하지만 중요한 내용이 아닌 것 같아 자세한 코드는 생략하겠습니다.)
<value> 뒤에 중괄호 끝 문자가 온다면 객체의 내부 요소를 모두 파싱했다는 의미가 되므로, 반복문을 벗어나 jsonData를 반환하는 과정으로 넘어갑니다. <value> 뒤에 쌍따옴표가 온다면 아직 파싱할 Key-Value가 남았다는 의미가 되어, <value> 뒤에 중괄호 끝 문자가 올 때까지 반복문을 돌면서 Key-Value를 jsonObject에 차곡차곡 추가하게 됩니다.
만약 JSON의 BNF를 따른다면, 아래처럼 pseudo code를 작성하여 적용할 수 있을 것 같습니다. 여러모로 JSON의 BNF를 따르는 방식이 보다 명료한 코드가 나오게 되는 것 같네요…
parseObject JSON BNF pseudo code
좋아요, 객체를 파싱하는 코드는 모두 살펴보았지만, 명확하게 짚고 넘어가지 않은 부분이 있죠. Key-Value 요소에 대해 확인해봅시다!

parseValue

parseObject 함수에서 Key는 parseStringKey 함수로, Value는 parseValue 함수로 파싱합니다. parseStringKey는 6개의 Value Type 중 하나인 문자열을 사용하는 것이 전부이기에, parseValue 와 겹치는 부분이 존재합니다. 따라서 parseValue를 중심으로 살펴보겠습니다.
parseValue 함수는 아래와 같습니다. <value>에 관한 JSON의 BNF도 함께 적어보겠습니다.
JsonData value; if (*it == '{') { value = parseObject(text, it); } else if (*it == '\"') { value = parseString(text, it); } else if (*it == '[') { value = parseArray(text, it); } else if (std::isalnum(*it) || *it == '.' || *it == '-' || *it == '+') { value = parsePrimitive(text, it); } else { _errorExit("Error: Invalid value"); } return value;
C++
복사
<value> ::= <number> <string> <boolean> <array> <object> <null> <number> ::= [ '-' | '+' ] <int> [ <frac> ] [ <exp> ] <int> ::= '0' | ( <digit1-9> *DIGIT ) <digit1-9> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' <frac> ::= '.' 1*DIGIT <exp> ::= ( 'e' | 'E' ) [ '-' | '+' ] 1*DIGIT <string> ::= <quotation-mark> *<char> <quotation-mart> <quotation-mark> ::= '"' <char> ::= <unescaped> | <escape> <unescaped> ::= printable characters <escape> ::= '\' ( '"' | '\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 4HEXDIG ) <boolean> ::= false | true <null> ::= null
BNF
복사
여기서 재미있는 요소가 등장합니다. 현재 최상위 객체를 파싱하기 위해 parseObject 를 수행하며 <value>를 파싱하고 있습니다. 그런데 만약 파싱해야 하는 <value>가 객체라면 어떤 일이 발생할까요? 그림을 한 번 그려봅시다.
Top Object: [(String, String), (String, Object), (String, Number)] | Object: [(String, Number), (String, String)]
Plain Text
복사
위 그림은 최상위 객체의 Key-Value 요소에, 자기 자신과 타입이 동일한 객체가 들어있는 상황을 그립니다.
이전에 16진수를 BNF로 표현한 그림을 기억하시나요? <integer> ::= <number> | <number><integer> 라는 규칙을 통해 다양한 길이의 16진수를 표현할 수 있게 되었죠.
JSON도 동일합니다! JSON은 <value>에 자기 자신과 동일한 타입의 값 넣는 것을 허용함으로써, 데이터 구조를 무한히 확장할 수 있게 됩니다.
{ "obj": { "obj": { "obj": { "str": "hello!" }, }, } }
JSON
복사
Object: (String, Object) | Object: (String, Object) | Object: (String, Object) | Object: (String, String)
Plain Text
복사
이런 점이 바로 재귀 형식의 매력이라고 생각이 듭니다. 시간과 자원만 충분하다면 무한한 상황을 표현할 수 있는 가능성을 보여준다는 점이 정말 재미있는 것 같아요.
하지만 재귀를 사용하는 파서의 또다른 매력이라면, 재귀적인 상황을 완벽하게 다룰 수 있어야 한다는 점인 것 같습니다. 만약 상황이 재귀적으로 흘러가지만, 오류를 전혀 예측할 수 없고 이해할 수 없는 흐름이 조금이라도 남아있게 된다면, 재귀 구조는 유령이 되어 프로그래머를 불안에 빠지게 할 것입니다. 하지만 재귀적인 과정을 완벽하게 다룰 수 있게 된다면, 무한한 가능성을 표현할 수 있는 신의 권능을 손에 쥘 수 있게 되는 것이지요…
Piscine에서도 재귀를 다루는 문제가 있었던걸로 기억합니다. 그때는 제게 재귀가 유령처럼 보였습니다. 동작은 하더라도 머릿속에서 전체 과정을 완전하게 그리며 이해하기 어려워서, 코드에 손을 대기가 무서웠던 기억이 생생하게 남아있어요.
하지만 약간의 시간이 흐르고 난 지금, 우연히 JSON 파서를 만들어보며 재귀를 활용하게 되는 과정에서, 재귀의 놀라운 매력을 다시금 발견하게 되는 것 같습니다. 좋아요, 호들갑은 이 쯤에서 마치도록 하겠습니다…
객체에 대한 설명은 어느정도 완료된 것 같으니, 나머지 <value>의 타입에 대해 하나씩 짚어보겠습니다.

getStringData

문자열(<string>)은 간단합니다! 말 그대로 문자열을 의미하는 값입니다. 한 가지 특징이 있다면, JSON 문자열은 특수 문자와 제어 문자를 표현하기 위해 escape sequence를 사용합니다. 가령 아래와 같은 JSON 문자열은 escape sequence를 사용하여 문자열 내의 특수 문자와 제어 문자를 표현합니다.
"key": "Hello,\n\"42\"\nWorld!"
JSON
복사
문자열을 파싱하는 코드와 BNF를 함께 살펴보겠습니다.
std::string str; bool escape = false; char ch; if (*it != '\"') { _errorExit("Error: String must starts with a double quote"); } else { it++; } if (it == text.end()) { _errorExit("Error: EOF encountered while reading string value"); } else { // proceed } while (it != text.end()) { ch = *it; if (ch == '\n') { _errorExit("Error: Malformed String Data type"); } else { // string format is not broken } if (escape) { switch (ch) { case '\"': case '\\': case '/': case 'b': case 'f': case 'n': case 'r': case 't': str += ch; break; case 'u': str += ch; for (std::size_t i = 0; i < 4; ++i) { it++; if (it == text.end()) { _errorExit("Error: Malformed string data type"); } else if (isxdigit(*it)) { str += *it; } else { _errorExit("Error: Invalid unicode string"); } } break; default: _errorExit("Error: Invalid escape sequence in string"); } escape = false; } else if (ch == '\\') { str += ch; escape = true; } else if (ch == '\"') { break; } else { str += ch; } it++; } return str;
C++
복사
<string> ::= <quotation-mark> *<char> <quotation-mart> <quotation-mark> ::= '"' <char> ::= <unescaped> | <escape> <unescaped> ::= printable characters <escape> ::= '\' ( '"' | '\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 4HEXDIG )
BNF
복사
코드가 약간 길지만 크게 어려운 내용은 없습니다. 먼저 이터레이터가 쌍따옴표를 발견하고 getStringData로 들어와 문자열 파싱을 시작하므로, 현재 이터레이터는 시작 쌍따옴표를 가리키고 있습니다. 이터레이터를 한 칸 앞으로 밀어(it++) 쌍따옴표 내부의 문자열 추출을 시작합니다.
반복문을 돌며, 문자열이 일렬로 연결되지 않고 개행으로 분절되게 된다면 오류로 처리합니다. 만약 escape sequence를 발견하게 된다면 escape 플래그를 true로 변경하고, 다음 반복에서 escape sequence 문자를 읽어서 처리합니다. escape sequence를 처리하지 않는 상황이라면, 쌍따옴표가 발견되었을 경우 문자열의 끝에 도달했다는 의미가 되므로 반복문을 빠져나옵니다. 그 외 문자는 별다른 처리를 하지 않고 문자열에 문자를 하나씩 더해줍니다.

parsePrimitiveValue

제가 작성한 JSON 파서 코드는 <number>, <boolean>, <null> 타입을 모두 하나의 함수로 처리해주었습니다. 위 타입들은 모두 둘러싸는 문자가 없다는 공통점을 가지기 때문입니다. (물론 명확하게 하나씩 구분하는 방식도 아주 좋다고 생각합니다! 오히려 더 나을 수도 있구요…) 이번에는 BNF를 먼저 보겠습니다.
<number> ::= [ '-' | '+' ] <int> [ <frac> ] [ <exp> ] <int> ::= '0' | ( <digit1-9> *DIGIT ) <digit1-9> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' <frac> ::= '.' 1*DIGIT <exp> ::= ( 'e' | 'E' ) [ '-' | '+' ] 1*DIGIT <boolean> ::= false | true <null> ::= null
BNF
복사
<number> 타입을 처리하기 위해 뭔가 복잡한 과정을 많이 처리해주어야 할 것 같지만… strtol, strtod 함수를 사용하면 아주 간단하게 처리할 수 있습니다. 코드를 살펴보겠습니다.
std::string value; while (it != text.end() && (std::isalnum(*it) || *it == '.' || *it == '-' || *it == '+')) { value += *it; it++; } if (it == text.end()) { _errorExit("Error: EOF encountered while reading primitive"); } else if (!(std::isspace(*it) || *it == ',' || *it == ']' || *it == '}')) { _errorExit("Error: Invalid primitive"); } else { it--; } type = getPrimitiveType(value); if (type == TYPE_ERROR) { _errorExit("Error: Invalid primitive"); } else { // primitive has valid type } return value;
C++
복사
잠깐, 코드를 읽으시다가 직관적으로 잘 와 닿지 않는 부분이 있으셨나요?
else if (!(std::isspace(*it) || *it == ',' || *it == ']' || *it == '}')) { _errorExit("Error: Invalid primitive"); }
C++
복사
네… 의도치않게 예외 처리 로직이 느슨해져서, 제대로 오류 출력을 하기 위해 hard coding 으로 설정한 조건입니다… 위 조건은 primitive 뒤에 유효한 문자가 들어오지 않는 경우를 확인한다고 설명할 수 있을 것 같아요. 만약 123$$와 같이 특수문자가 포함된 primitive가 들어왔을 경우, it는 특수문자 $에서 멈추게 되어 해당 조건에 걸리고, “Invalid primitive” 오류를 출력합니다. 하지만 123 $$ 와 같이 들어온다면, it는 공백문자를 가리키게 되어 유효한 primitive(123)로 판단되고, 특수문자는 해당 <value>체크 이후, 다른 로직에서 예외 처리가 됩니다.
뭔가 지저분하지요… JSON의 BNF에 맞게 작성하였다면 이런 번거로운 작업이 생기지 않았을지 궁금해지네요… 구조의 중요성을 느끼게 되는 것 같습니다…
먼저 <number>, <boolean>, <null> 이 포함하는 모든 문자를 읽습니다. 크게 본다면 알파벳, 숫자, 점(.), 부호(-, +)문자가 되겠지요. 여기서 읽어들인 문자를 바탕으로 getPrimitiveType로 넘어가 <value>의 타입을 확인합니다.
// getPrimitiveType char* endptr; static_cast<void>(strtol(str.c_str(), &endptr, 10)); if (*endptr == '\0') { return TYPE_INTEGER; } else { // fallthrough } static_cast<void>(strtod(str.c_str(), &endptr)); if (*endptr == '\0') { return TYPE_FLOAT; } else { // fallthrough } if (str == "true" || str == "false") { return TYPE_BOOLEAN; } else { // fallthrough } if (str == "null") { return TYPE_NULL; } else { // fallthrough } return TYPE_ERROR;
C++
복사
strtol, strtod 함수는 주어진 문자열을 정수 또는 실수로 변환합니다. strtol, strtod는 변환 가능한 부분까지 문자열을 변환하고, 변환된 부분 이후의 문자를 가리키는 포인터를 endptr를 통해 반환합니다. 만약 변환 가능한 부분 뒤에 변환할 수 없는 문자가 나타난다면, endptr는 해당 문자를 가리키게 됩니다. 반대로 모든 문자열이 변환 가능하다면 endptr는 끝을 나타내는 NULL 문자(\0)를 가리키게 됩니다. 따라서 endptr이 NULL 문자를 가리키는 경우에 대해 올바르게 정수 또는 실수로 변환이 되었다고 판단할 수 있습니다.
정수 및 실수로 변환되지 못한 문자열은 이어서 true, false, null문자열과 동일한지 확인합니다. 만약 이 경우에도 속하지 않는 문자열이라면, 올바르지 않은 primitive로 오류 처리를 합니다.
<number>, <boolean>, <null> 타입은 아래와 같이 JsonData에 설정됩니다.
jsonData._type = TYPE_NUMBER; // TYPE_NUMBER | TYPE_BOOLEAN | TYPE_NULL jsonData._str = primitive;
C++
복사
이제 마지막 타입이 남았군요… <array> 타입도 <object> 타입처럼 파싱을 하기 위해선 몇 가지 작업이 필요하지만, <object> 타입에서 이루어지는 작업과 아주 유사합니다. 마지막까지 힘내서 가봅시다!

parseArray

JSON 배열은 객체와 마찬가지로 다양한 값들을 저장하는 형식입니다. 하지만 객체와 배열 사이에는 몇 가지 차이점이 있습니다. 비교를 위해 <object><array>의 BNF를 함께 확인해보겠습니다.
<object> ::= <begin-object> [ <member> *( <value-separator> <member> ) ] <end-object> <array> ::= <begin-array> [ <value> *( <value-separator> <value> ) ] <end-array> <member> ::= <string> <name-separator> <value> <value> ::= <number> <string> <boolean> <array> <object> <null> <begin-object> ::= <ws> '{' <ws> <end-object> ::= <ws> '}' <ws> <begin-array> ::= <ws> '[' <ws> <end-array> ::= <ws> ']' <ws> <name-separator> ::= <ws> ':' <ws> <value-separator> ::= <ws> ',' <ws>
BNF
복사
파싱(구문 형식)에 있어 객체와 배열의 차이점은 아래처럼 정리할 수 있을 것 같습니다.
객체는 중괄호({})로 둘러싸여 있으며, 요소는 Key-Value 쌍의 형식을 가진다.
배열은 대괄호([])로 둘러싸여 있으며, 요소는 Value 자체의 형식을 가진다.
이를 통해 배열과 객체의 파싱 흐름은 동일하지만, 시작과 끝 문자 체크와 요소의 저장 과정이 다를 것이라고 예상할 수 있습니다. 코드를 살펴보겠습니다!
JsonData jsonData; std::vector<JsonData> jsonArray; if (it == text.end()) { _errorExit("Error: EOF encountered before reading array"); } else if (*it != '[') { _errorExit("Error: Array must start with an open square bracket"); } else { it++; } _skipWhiteSpaces(text, it); if (it == text.end()) { _errorExit("Error: EOF enconutered while reading array"); } else if (*it == ']') { jsonData._str = "Array"; jsonData._type = TYPE_ARRAY; return jsonData; } else { // proceed (array has elements) } // ...
C++
복사
위 코드는 객체 파싱 파트와 동일하니 길게 설명하지 않고 넘어가겠습니다. 안전하게 배열을 파싱하는 코드로 넘어왔는지 체크하고, 내부 요소가 없을 경우, 빈 배열을 반환하는 과정입니다. 바로 다음 코드를 살펴보죠.
while (it != text.end() && *it != ']') { JsonData element; if (it == text.end()) { _errorExit("Error: EOF encountered while reading array element"); } else { element = parseValue(text, it); jsonArray.push_back(element); } if (it == text.end()) { _errorExit("Error: EOF encountered while reading array element"); } else if (checkElementEnd(text, it) == false) { _errorExit("Error: Malformed array format"); } else { it++; } _skipWhiteSpaces(text, it); if (*it == ',') { it++; _skipWhiteSpaces(text, it); } else { // nothing to do } } jsonData._str = "Array"; jsonData._type = TYPE_ARRAY; jsonData._arr = jsonArray; return jsonData;
C++
복사
<array> ::= <begin-array> [ <value> *( <value-separator> <value> ) ] <end-array> <value> ::= <number> <string> <boolean> <array> <object> <null>
BNF
복사
배열 내부 요소(<value>)가 존재하는 상황이므로, 하나 이상의 (<value>)를 파싱해야 합니다. 이전에 객체에서 Key와 Value를 각각 파싱하고 하나의 쌍(make_pair)으로 묶어 vector에 저장하는 방식과 달리, 배열에서는 Value만 파싱하여 vector(jsonArray)에 저장하면 되기 때문에, 보다 단순하게 과정을 처리할 수 있습니다. 또한 배열도 객체와 동일하게 모든 타입의 값을 저장할 수 있기 때문에, 중첩된 배열 혹은 객체를 표현할 수 있습니다.
Top Array: [ "String", "Object", "Number", "Boolean", "Array" ] | | Object: { "String": "Array" } Array: [ "Number" ] | Array: [ "Null" ]
Plain Text
복사
그리고 물론… 객체와 마찬가지로 JSON의 BNF를 따르지 않은 상황입니다. (또한 배열은 ‘순서가 있는 리스트’라고 하는데, 정확한 의미를 파악하지 못해서 우선 파싱되는 순서대로 배열에 요소를 추가하였습니다. 만약 배열 내 요소가 순서대로 정렬이 되어야 한다는 의미라면 수정이 필요하겠지요…) JSON의 BNF를 따른 pseudo code를 아래에 적어보겠습니다.
parseArray JSON BNF pseudo code
좋아요, JSON 파서의 핵심적인 코드를 모두 살펴보았네요! 여기까지 따라오시느라 고생 정말 많으셨습니다. 아래 깃허브 레포지토리에서 보다 자세한 코드를 살펴보실 수 있습니다.

Back to… JsonData

파싱을 시작하기에 앞서 JsonData의 형식에 대해 살펴보았습니다. 데이터를 어떻게 저장하는가에 따라 파서의 효율성이 달라지게 된다고 생각합니다. 가령, 제가 처음 JSON 파서의 아이디어를 얻었던 코드에서는 데이터를 union 으로 저장합니다.
union JsonValue { int i; double d; std::map<std::string, JsonValue>* json; };
C++
복사
union 으로 데이터를 저장하게 된다면, union 내 모든 멤버가 동일한 메모리 위치를 공유하여 메모리를 효율적으로 사용할 수 있습니다. 반면 제가 작성한 JsonData는 데이터로 하여금 4개의 필드를 모두 들고 있게 하기 때문에(심지어 그 중 2개의 필드는 사용하지도 않게 되구요), 상대적으로 메모리를 많이 잡아먹게 됩니다.
하지만 JsonData의 구조가 완전히 나쁜 것만은 아니라고 생각합니다. 무엇보다 데이터를 저장하는 과정이 직관적으로 이해하기 쉬워지고, 각 요소마다 타입을 지정하여 이후 사용에 있어 보다 원활하게 타입 체크를 할 수 있게 됩니다. 무엇보다 편하기도 하고요! 반면 union은 이런 점에 있어 다루기 어려워지게 되는 것 같습니다.
그렇지만 위 두 구조가 데이터를 저장하는 유일한 방식은 아닙니다. 효율성과 편의성을 모두 잡는 구조도 분명히 있을테지만, 보다 강조하고 싶은 점은, 어떤 방식으로 데이터를 저장할지 생각해보는 시간을 가져보는 것도 아주 재미있는 경험이 될 수 있다고 생각합니다. 비록 효율이 좋지 않아도, 혹은 반대로 사용하기 까다로운 면이 있어도 뭐 어떤가요! 재미있게 만들면 끝이라고 생각합니다.

Let’s check!

좋아요, 마지막으로 실제 돌아가는 케이스를 확인해보고 마치겠습니다. 비로소 감동의 순간입니다!
먼저, 과제 Webserv의 config 형식의 JSON 파일입니다. (사진에 파란색 문자로 표시된 타입에 주목해주세요)
{ "server": { "listen": 8080, "server_name": "virtual.com", "location": { "path": "/", "limit_except": ["GET"], "autoindex": false, "index": ["index.html", "index.htm"] }, "location": { "path": "/upload", "root": "data/", "error_page": [404, "/404.html"], "client_max_body_size": "10M", "limit_except": ["POST", "DELETE"], "index": ["list.html"] } }, "server": { "listen": 8081, "server_name": "example.com", "location": { "path": "/", "autoindex": true, "limit_except": ["GET"] }, "location": { "path": "/cgi", "limit_except": ["GET", "POST"], "cgi": ".php" } }, "server": { "listen": 8082, "server_name": "fakedomain.com", "root": "/var/www/html", "return": [301, "https://youtube.com/v/dQw4w9WgXcQ"] } }
JSON
복사
그리고 몇몇 테스트 케이스를 넣어본 JSON 파일입니다.
{ "string" : "hello", "object": { "primitive": -42.42,"primitive": true , "primitive": null}, "array": [42.42E+4, false, "\"404\"", { }, { "string": "world" }, [ ]], "recursive": { "recur": { "recur": { "recur": { "string": "goodbye", "recur": { "recur": { "string": "cruel" } } } }, "string": "world..." } }, "key": "\t\n\\\uaaaa", "recur": [ 42, [ [ [ [ [ [ { "recur": "hello again!" } ] ] ], null ] ] ] ] }
JSON
복사
물론… 이렇게 JSON을 작성하시면 순식간에 인기스타가 될 수 있습니다…

맺음말

와… 이렇게 긴 글이 될 줄을 전혀 예상하지 못했습니다… 그럼에도 설명이 부족하다고 생각된다는 점에서… 상대방에게 정보를 설명하고 전달하는 일은 정말 어렵지만, 그럼에도 세심하게 이루어져야 하는 작업인 것 같아요.
어쩌면 이렇게 길게 쓸 만한 내용의 글은 아니었을 수도 있습니다. 다음 번에는 보다 간결하게, 그리고 이해하기 쉽게 적어보도록 연습을 해보겠습니다. 만약 이 글을 끝까지 읽어주신 분이 계시다면… 고개 숙여 깊이 감사의 말씀을 드립니다… 부족한 문장력에 지루한 내용이 많았는데… 고생 많으셨습니다…
끝으로, 과제 하느라 바쁜 와중 JSON 파서를 만드는 작업(뻘짓)을 너그럽게 허용해주신 Hyeonjan 님께 깊은 감사의 말씀을 올립니다… 무엇보다 과제가 끝나고서도 JSON 파서 코드를 계속 봐주시면서, 코드를 깔끔하게 정리하는 방법을 하나씩 짚어주셔서 너무 감사했습니다. 덕분에 이전보다 훨씬 강건하고 깔끔한 코드를 작성할 수 있었습니다…
이제 정말 본문 끝입니다! 모두 42서울에서 즐코하는 행복한 삶을 보내시길 바랍니다! EOF!

Reference

레퍼런스가 남았군요…
EOF!