CodeGym /행동 /C++ SELF /버전 관리와 JSON 마이그레이션

버전 관리와 JSON 마이그레이션

C++ SELF
레벨 65, 레슨 4
사용 가능

1. 왜 포맷 버전이 필요한가

예를 들어 users.json를 저장하고 한동안 잊고 지내다가, 일주일 뒤에 email 필드를 추가하고 name을 fullName으로 바꾸고 active를 필수로 만들었다고 합시다. 그러면 오래된 파일은 갑자기 '고고학적 유물'이 되어 프로그램이 읽고 난 뒤 멈출 수 있습니다. 포맷 버전 관리는 데이터가 우리의 설계보다 오래 살아남을 수 있음을 솔직하게 인정하는 방법입니다.

핵심: 버전은 '장식'이 아니라 계약의 일부입니다. 프로그램이 v2 포맷만 읽을 수 있다면, 앞에 v1이 놓여 있을 때 이를 인식하고 안전하게 변환하거나 명확히 거부해야 합니다.

포맷 버전 vs 프로그램 버전

사람들은 '버전'이라 하면 종종 앱 릴리스 번호(1.0, 1.1, 2.0)를 떠올립니다. 하지만 여기서 중요한 것은 데이터 포맷의 버전입니다. 포맷은 JSON의 형태와 각 필드가 의미하는 바입니다. 프로그램은 열 번 업데이트될 수 있지만 같은 포맷을 계속 읽고 쓴다면 포맷 버전은 변하지 않습니다.

간단히 기억하기 좋은 모델: 프로그램은 자주 진화하고, 포맷은 드물게—하지만 더 고통스럽게—변한다. 포맷 버전은 구조적 '고통스러운' 변경을 견디기 위해 도입합니다.

포맷 버전은 어디에 저장할까

아래는 보통 버전을 어디에 두는지에 대한 간단한 표입니다:

버전을 저장하는 위치 형태 장점 단점
문서 루트
{"version": 2, ...}
간단하고 명시적이며 마이그레이션이 쉽다 루트가 객체라는 합의를 해야 한다
각 객체 내부
{"user": {..., "version": 2}}
스트리밍 데이터에서 유용할 때가 있다 대개 불필요한 중복
파일명에 포함
users_v2.json
한눈에 버전 파악 가능 파일을 이름 바꾸면 깨질 수 있다
필드 기반 휴리스틱 «만약 full_name이 있으면 v1» 편해 보일 수 있다 신뢰할 수 없고 점점 복잡해진다

거의 항상 '루트 JSON 객체에 버전'을 두는 방식이 가장 실용적입니다.

2. 하위 호환성과 안전한 변경

포맷을 바꾼다는 것은 단순히 JSON을 바꾸는 것이 아니라 의미를 바꾸는 것입니다. 하위 호환성은 새 프로그램이 이전 데이터를 읽을 수 있음을 의미합니다. 이는 반드시 이전 프로그램이 새 데이터를 읽을 수 있다는 뜻은 아닙니다(그건 상향 호환성이고 보통 더 비용이 듭니다).

보통 문제 없이 지나가는 변경이 있습니다. 예를 들어 기본값이 있는 새 옵셔널 필드를 추가하면 이전 파일에는 없지만 새 프로그램이 기본값을 채워 정상 동작할 수 있습니다. 반면 필드 이름을 바꾸면 위험합니다: 예전 파일에는 full_name이 있고 새 프로그램은 name을 기대하면 둘은 만나지 못합니다.

참고용 요약 표(법칙은 아니지만 유용한 체크리스트):

포맷 변경 일반적으로 호환되나? 읽을 때 대처법
필드 추가(옵셔널) 예 value("x", default) 또는 contains() + 기본값
필드 추가(필수) 아니요 마이그레이션으로 필드를 추가하거나 읽기가 명확한 오류로 실패
필드 이름 변경 아니요 마이그레이션 또는 폴백: 이전 이름을 읽어 새 이름으로 기록
필드 타입 변경 ("42" → 42) 보통 아니요 명시적 마이그레이션과 검증 필요
필드 제거 때때로 옛 필드를 무시하거나 마이그레이션이 제거
필드 의미 변경(가장 위험) 거의 아니요 버전을 반드시 올리고, 단순 키 변경이 아닌 데이터 자체를 변경하는 마이그레이션 필요

특히 교묘한 경우는 '타입은 동일하지만 의미가 바뀐' 경우입니다. 예를 들어 price가 이전에는 달러 단위였는데 이제 센트 단위라면 JSON은 거의 비슷해 보이지만 사용자가 나중에 '커피가 3000$'라며 놀랄 수 있습니다. 이런 변경은 거의 항상 버전을 요구합니다.

3. from_json 전에 하는 마이그레이션: 일반 파이프라인

모든 버전 지원 코드를 from_json 안에 몰아넣으려 하면 함수가 금세 복잡해지고 이해하기 어려워집니다. 대신 읽기 파이프라인을 분리하세요: JSON을 읽고 → 버전을 확인하고 → 필요하면 최신 형태로 변환하고 → 검증한 뒤 → j.get<Model>()를 호출합니다.

이 과정은 집 수리와 비슷합니다. 먼저 벽을 평탄하게(마이그레이션), 그다음 벽이 있는지 확인(검증), 마지막에 가구 들이기(구조체 생성). 가구를 먼저 들이면 계속 옮겨야 합니다.

전형적인 처리 흐름은 다음과 같습니다:

flowchart TD
    A[텍스트/파일 읽기] --> B[json::parse / in >> j]
    B --> C["버전 확인: j.value('version', 1)"]
    C --> D[latest로 마이그레이션: v1->v2->...]
    D --> E[latest 포맷 구조 검증]
    E --> F["j.get<Model>() / from_json"]
    F --> G[프로그램 동작]
    G --> H["저장: latest (with version)"]

주요 장점은 마이그레이션 후 나머지 코드가 하나의 포맷에서만 동작한다는 점입니다. 이는 조건문(if) 수를 줄이고 마음의 평화를 가져옵니다.

4. 실습 예제: v1과 v2 포맷

이제 예제를 통해 설명하겠습니다. 학습용 미니 프로젝트로 users.json에 사용자 배열을 저장한다고 합시다. 첫 버전(v1)에서는 사용자에 full_name 필드가 있었고 파일에 버전은 전혀 저장되지 않았습니다(전형적인 '나중에 추가하자' 패턴).

v1 데이터 예시(참고: version 없음, 이름은 full_name에 저장):

#include <iostream>
#include <string>

int main() {
    const std::string v1 = R"({"users":[{"id":1,"full_name":"Ann"}]})";
    std::cout << v1 << '\n'; // {"users":[{"id":1,"full_name":"Ann"}]}
}

v2에서는 세 가지 변경을 합니다. 루트에 version: 2를 추가하고, full_name을 name으로 바꾸며, active 필드를 도입합니다(기본값은 true). 이전 파일에는 active가 없으므로 마이그레이션에서 추가해야 합니다.

v2는 다음과 같습니다:

#include <iostream>
#include <string>

int main() {
    const std::string v2 = R"({"version":2,"users":[{"id":1,"name":"Ann","active":true}]})";
    std::cout << v2 << '\n'; // {"version":2,"users":[{"id":1,"name":"Ann","active":true}]}
}

이제 새 프로그램은 v1과 v2를 읽을 수 있어야 하지만 내부에서는 오직 v2만 존재하는 것처럼 동작해야 합니다.

5. 단계별 마이그레이션: v1 → v2

중요한 점: 마이그레이션은 반드시 거대한 함수가 될 필요가 없습니다. 오히려 작은 단계로 나누어 migrate_v1_to_v2, migrate_v2_to_v3처럼 구현하는 것이 좋습니다. 이렇게 하면 v3을 추가할 때 전체를 고치지 않고 새로운 단계만 추가하면 됩니다.

먼저 버전을 읽는 함수를 만듭시다. 필드가 없으면 v1로 간주합니다:

#include <nlohmann/json.hpp>

int read_version(const nlohmann::json& j) {
    if (!j.is_object()) return 0;            // 0 = "전혀 다른 형식"
    return j.value("version", 1);            // version이 없으면 v1로 간주
}

이제 v1→v2 마이그레이션입니다. 루트에 users가 있고 각 사용자에 full_name이 있을 수 있다고 기대하겠습니다. 우리는 name을 만들고 active를 추가하고 version을 2로 설정한 뒤 full_name을 제거해 잔재를 남기지 않겠습니다.

#include <nlohmann/json.hpp>

void migrate_v1_to_v2(nlohmann::json& j) {
    j["version"] = 2;

    for (auto& u : j.at("users")) {
        if (u.contains("full_name") && !u.contains("name")) {
            u["name"] = u["full_name"];
            u.erase("full_name");
        }
        if (!u.contains("active")) {
            u["active"] = true;
        }
    }
}

두 가지를 주목하세요. 첫째, 우리는 at("users")를 사용합니다: 마이그레이션은 구조가 적어도 대략 기대한 형태임을 전제로 합니다. 만약 users가 없으면, 이는 단순한 'v1'이 아니라 데이터가 깨진 것이고 검증에서 실패시키는 것이 낫습니다. 둘째, 우리는 JSON을 제자리에서 수정합니다. 이는 연속된 단계에 편리하기 때문입니다.

이제 migrate_to_latest 함수를 구성합시다. latest = 2로 합시다:

#include <nlohmann/json.hpp>
#include <stdexcept>

constexpr int kLatestVersion = 2;

nlohmann::json migrate_to_latest(nlohmann::json j) {
    const int v = read_version(j);
    if (v == 1) migrate_v1_to_v2(j);
    if (read_version(j) != kLatestVersion) throw std::runtime_error("Unsupported version");
    return j;
}

학습용으로는 이렇게 직관적으로 보이는 방식이 괜찮습니다. 실제 코드에서는 v < kLatestVersion인 경우를 더 상세히 진단하거나, 반복문 while (v < kLatestVersion)처럼 처리할 수 있습니다.

6. 읽기와 쓰기: 모든 버전 읽기, 쓰기는 latest

마이그레이션을 처음 도입할 때 유혹은 '입력이 v1이면 쓰기도 v1로 하자'는 것입니다. 이는 거의 항상 나쁜 아이디어입니다: 두 개의 '살아있는' 포맷이 생기고 둘을 병행 유지해야 합니다. 훨씬 단순한 규칙은 쓰기에는 항상 현재 포맷(latest)만 사용하는 것입니다.

이는 사무실 문서를 업데이트하는 것과 비슷합니다: 오래된 양식을 읽을 수는 있지만, 새로 저장할 때는 최신 템플릿을 사용하는 편이 결국엔 통일을 가져옵니다.

간단한 파일 읽기 함수(스트림 세부 처리 생략):

#include <nlohmann/json.hpp>
#include <fstream>
#include <stdexcept>

nlohmann::json load_json_file(const std::string& path) {
    std::ifstream in(path);
    if (!in) throw std::runtime_error("Cannot open file");
    nlohmann::json j;
    in >> j;
    return j;
}

그리고 예쁘게 출력하는 저장 함수(dump(2) 사용):

#include <nlohmann/json.hpp>
#include <fstream>
#include <stdexcept>

void save_json_file(const std::string& path, const nlohmann::json& j) {
    std::ofstream out(path);
    if (!out) throw std::runtime_error("Cannot write file");
    out << j.dump(2) << '\n';
}

이제 main에서의 접합 코드입니다: 읽고 → 마이그레이션 → (여기서 검증과 get<UserDb>()을 할 수 있음) → 최신 형태로 저장:

#include <nlohmann/json.hpp>
#include <iostream>

int main() {
    try {
        nlohmann::json j = load_json_file("users.json");
        j = migrate_to_latest(std::move(j));
        save_json_file("users.json", j);
        std::cout << "OK\n"; // OK
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << '\n';
        return 1;
    }
}

이렇게 하면 사용자가 오래된 v1 파일을 주면 프로그램이 이를 v2로 '치료'하여 다시 저장합니다. 그 시점부터 데이터는 최신 포맷으로 유지됩니다.

7. 업데이트 전략과 버전 지원 정책

프로젝트가 작을 때는 '필드 하나 추가하면 되겠지'라고 생각하기 쉽습니다. 하지만 프로젝트가 커지면 JSON 파일, 설정, 저장된 데이터는 일종의 타임머신이 됩니다. 따라서 업데이트 전략은 포맷을 어떻게 변경할지에 대한 팀 내 합의입니다.

가장 중요한 규칙은 단조롭지만 유용합니다: 버전은 읽기/해석 방식이 바뀔 때만 올린다. 필드 순서를 바꾸거나 무시 가능한 필드를 추가한 경우에는 버전을 올릴 필요가 없을 수 있습니다. 하지만 키 이름을 바꾸거나 필드를 필수로 하거나 타입을 변경하면 버전을 올려야 합니다.

또한 '마이그레이션 계단' 관점으로 사고하는 것이 좋습니다. 각 마이그레이션은 한 단계씩 올라가는 것이고, v1 → v2 → v3 → latest처럼 연결됩니다. 정책에 따라 너무 옛 버전의 지원을 중단할 수 있습니다(예: '마지막 3개 포맷만 지원').

상태 전이를 시각화하면 다음과 같습니다:

stateDiagram-v2
    [*] --> V1
    V1 --> V2: migrate_v1_to_v2
    V2 --> V3: migrate_v2_to_v3
    V3 --> V4: migrate_v3_to_v4
    V4 --> [*]

또 한 가지 실무적인 점: '미래 버전'에 대해 어떻게 할지 정하세요. 파일에 version: 999 같은 값이 있으면 이는 파싱 오류나 구조 오류가 아니라 '파일이 프로그램보다 최신'인 상황입니다. 이 경우 보통은 '파일이 더 최신 버전으로 생성되었습니다. 앱을 업데이트하세요'와 같은 명확한 오류를 표시하는 것이 정직합니다.

8. 버전 관리와 마이그레이션에서 흔한 실수

실수 №1: 버전은 있지만 아무도 확인하지 않는다.
가끔 개발자가 "version": 2를 추가해놓고도 읽을 때 여전히 j.at("name")만 호출해 옛 파일에서 예외가 발생하는 일을 겪습니다. 이런 경우 버전은 단지 스티커에 불과합니다. 버전이 있다면 읽기는 반드시 이에 반응해야 합니다: 마이그레이션을 하거나 명확히 거부해야 합니다.

실수 №2: 마이그레이션과 검증을 섞는다.
마이그레이션에서 값의 허용 범위를 검사(age >= 0, name 비어있지 않음)하면서 동시에 키를 바꾸면 '구조를 고치는 곳'과 '정합성을 검사하는 곳'이 섞여 혼란이 생깁니다. 더 낫게는 마이그레이션은 JSON을 최신 형태로 만들고, 검증은 최신 형태에 대한 계약을 검사하도록 분리하세요.

실수 №3: 마이그레이션이 멱등(아이덤포턴트)하지 않다.
예컨대 마이그레이션이 실행될 때마다 이름에 접미사를 계속 붙이거나 아무 검증 없이 필드를 덮어쓰면 반복 실행 시 파일이 점점 손상됩니다. 좋은 마이그레이션은 재적용해도 변화가 없거나 최소한 상태를 악화시키지 않습니다: contains() 검사와 신중한 조건문을 사용하세요.

실수 №4: 필드 존재 여부로 버전을 유추하려 한다.
오늘은 'full_name이 있으면 v1'이라고 했지만, 내일 v3에서 full_name을 보조 이름으로 다시 도입하면 휴리스틱은 깨집니다. 휴리스틱은 대개 '두 음으로 노래 맞추기'가 되므로 명시적 version 필드가 낫습니다.

실수 №5: 입력이 구버전이면 그걸로 쓰기도 한다.
이러면 여러 포맷을 영원히 지원하게 됩니다. 읽기는 넉넉히, 쓰기는 하나(latest)만 하세요. 그러면 시스템은 저장 시점에 데이터가 점진적으로 회복됩니다.

1
과제
C++ SELF, 레벨 65, 레슨 4
잠금
포맷 버전
포맷 버전
1
과제
C++ SELF, 레벨 65, 레슨 4
잠금
버전 올리기
버전 올리기
1
과제
C++ SELF, 레벨 65, 레슨 4
잠금
사용자 마이그레이션
사용자 마이그레이션
1
과제
C++ SELF, 레벨 65, 레슨 4
잠금
버전 단계
버전 단계
1
설문조사/퀴즈
JSON 직렬화, 레벨 65, 레슨 4
사용 불가능
JSON 직렬화
JSON 직렬화
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION