Rust에서 YAML 파일을 프로그래밍 방식으로 수정하면서 포맷과 주석을 보존할 수 있는 라이브러리들을 비교하고, 실제 테스트를 통해 yamlpath + yamlpatch가 현재 가장 실용적인 선택지임을 살펴봅니다.
2026년 5월 8일
YAML 파일을 프로그래밍 방식으로 패치하는 일은 원칙적으로는 간단합니다. 파싱하고, 수정하고, 직렬화하면 됩니다. 이상적으로는 이 과정이 또한 정중해야 합니다. 즉, 원본 파일의 다음 속성들을 보존해야 합니다.
items:
- 1
- 2
- 3
또는 flow 스타일로 표현할 수도 있습니다.
items: [1, 2, 3]
범용 YAML 라이브러리는 보통 직렬화할 때 하나의 정규 형태를 선택하고, 그것을 문서 전체에 적용합니다.
둘 중 어느 하나라도 잃으면 문제가 됩니다. 주석이 사라지면 사실상 과거의 맥락도 함께 사라집니다. 포매팅이 망가지면 결과 파일이 유효하지 않게 될 수 있고, 특정 상황을 위해 신중하게 선택한 레이아웃이 사라질 수도 있습니다. 예를 들어 의도적으로 flow 리스트로 만든 것을 block 리스트로 바꿔버릴 수 있습니다.
인기 있는 범용 YAML 라이브러리를 찾는 것이 당연한 선택처럼 보이지만, 둘 다 보존하는 라이브러리는 없습니다.
serde_yaml은 더 이상 유지보수되지 않으며, 훨씬 이전에 기능 요청도 범위 밖이라는 이유로 거절되었습니다.yaml-rust2는 주석을 보존하지 않습니다. 기능 요청은 대신 그 작업이 saphyr에서 이뤄질 것이라는 메모와 함께 닫혔습니다.saphyr는 같은 유지보수자가 만든 yaml-rust2의 정신적 후속작입니다. 주석 지원은 계획되어 있지만 아직 출시되지 않았습니다.그래서 좀 더 틈새적인 도구가 필요합니다.
crates.io와 lib.rs에서 주석 보존을 내세우는 라이브러리를 찾아보면 후보가 네 개 나옵니다.
yamlpath + yamlpatch — 주석과 포맷을 보존하는 라우팅(yamlpath)과 패치 연산(yamlpatch).yaml-edit — README에 따르면 포매팅, 주석, 공백 을 보존합니다.rust-yaml — README에 Comment Preservation 전용 섹션이 있습니다.yamp — README에서 comment preservation을 프로젝트의 설계 목표 중 하나로 나열합니다.아래 예시는 트레이딩 봇 설정을 단순화한 예입니다. 자산은 이름 있는 그룹들로 묶여 있고, 모든 것을 받는 default 그룹이 있습니다.
# outer comment
asset_groups:
group_abc: # group_abc comment
- BTC
- ETH
- SOL
# group_xyz outer comment
group_xyz:
- DOGE # asset comment
- PEPE
default:
# default group inner comment
- 1INCH
- ATOM
- LINK
여기서 사용하는 장난감 CLI는 두 가지 연산을 지원합니다.
list-assets ASSET1,ASSET2 — 나열된 자산을 default 그룹에 추가하되, 알파벳순으로 정렬합니다.delist-assets ASSET1,ASSET2 — 나열된 자산을 속해 있는 그룹에서 제거합니다. 어떤 그룹이 비게 되면 그 그룹 전체를 삭제합니다.첫 번째 테스트는 네 개 자산에 대한 단일 list-assets 호출입니다. 한 번에 세 가지 경우를 모두 건드리도록 골랐습니다.
list-assets 1INCH,BTC,XRP,BNB
1INCH는 이미 default에 있음 → 아무 일도 하지 않음.BTC는 이미 group_abc에 있음 → 이것도 아무 일도 하지 않음.XRP와 BNB는 새 항목이므로 default에 들어가야 하며, 기존 항목들과 함께 알파벳순으로 정렬되어야 합니다.기대 출력은 다음과 같습니다.
# outer comment
asset_groups:
group_abc: # group_abc comment
- BTC
- ETH
- SOL
# group_xyz outer comment
group_xyz:
- DOGE # asset comment
- PEPE
default:
# default group inner comment
- 1INCH
- ATOM
- BNB
- LINK
- XRP
yamlpath + yamlpatch — 정확히 일치
# outer comment
asset_groups:
group_abc: # group_abc comment
- BTC
- ETH
- SOL
# group_xyz outer comment
group_xyz:
- DOGE # asset comment
- PEPE
default:
# default group inner comment
- 1INCH
- ATOM
- BNB
- LINK
- XRP
yaml-edit — 바깥 주석이 사라지고, "default" 들여쓰기가 잘못됨
asset_groups:
group_abc: # group_abc comment
- BTC
- ETH
- SOL
# group_xyz outer comment
group_xyz:
- DOGE # asset comment
- PEPE
default:
# default group inner comment
- 1INCH
- ATOM
- BNB
- LINK
- XRP
rust-yaml — 여러 문제가 있어 탈락
# outer comment
asset_groups:
group_abc:
- BTC
- ETH
- SOL
group_xyz:
- DOGE
- PEPE
default:
- 1
- 1INCH
- ATOM
- BNB
- INCH
- LINK
- XRP
# group_abc comment
# outer comment
# outer comment
# group_abc comment
주석이 여기저기 흩어지고, 어떤 것은 파일 맨 아래로 가고, 어떤 것은 중복됩니다. 1INCH는 두 개의 리스트 항목(- 1과 - INCH)으로 쪼개졌고, DOGE에 의도적으로 넣어 둔 공백과 인라인 주석도 둘 다 사라졌습니다.
이 라이브러리 자체의 comment_preservation_demo.rs도 수정 없이 실행해 보면 같은 식의 주석 흩어짐 현상을 보입니다.
yamp — 파싱 문제로 탈락
여기서는 입력 안의 일부 주석이 yamp의 파서를 혼란스럽게 만들기 때문에 출력 예시를 보여주지 않습니다.
list-assets는 두 연산 중 더 쉬운 편입니다. 하나의 그룹만 건드리고 추가만 하기 때문입니다. yamlpath + yamlpatch는 파일을 정확하게 round-trip합니다. yaml-edit도 두 속성을 위반하긴 하지만, 이 테스트 하나만으로 탈락시킬 정도로 심각하지는 않습니다.
delist-assets는 더 까다로운 연산입니다. 어떤 그룹이든 수정될 수 있고, 어떤 자산이든 제거될 수 있으며, 그룹 전체가 삭제될 수도 있습니다. 테스트는 다음과 같습니다.
delist-assets DOGE,PEPE,BTC,SOL,ATOM,SHIB
이 한 번의 테스트가 흥미로운 경우를 모두 포함합니다.
DOGE와 PEPE는 둘 다 group_xyz의 구성원입니다. 둘 다 제거하면 그룹이 비게 되므로 group_xyz 전체를 삭제해야 합니다.BTC와 SOL은 group_abc에서 제거되어, ETH만 남게 됩니다.ATOM은 default에서 제거됩니다.SHIB는 파일에 아예 없습니다. → 아무 일도 하지 않아야 합니다.기대 출력은 다음과 같습니다.
# outer comment
asset_groups:
group_abc: # group_abc comment
- ETH
default:
# default group inner comment
- 1INCH
- LINK
yamlpath + yamlpatch — 거의 맞지만, 주석 하나가 재배치됨
# outer comment
asset_groups:
group_abc: # group_abc comment
- ETH # group_xyz outer comment
default:
# default group inner comment
- 1INCH
- LINK
이제 비어버린 group_xyz: 키를 제거할 때, 바로 위 줄에 있던 독립 주석도 함께 제거되지 않습니다. 대신 가장 가까이 살아남은 내용 줄로 이동해 인라인 주석이 됩니다. 출력은 유효한 YAML이고 주석도 사라지지 않았지만, 이제 그 주석은 잘못된 리스트 항목에 붙어 있습니다.
yaml-edit — 논리 구조가 바뀌어 탈락
asset_groups:
group_abc: # group_abc comment
- ETH
# group_xyz outer comment
default:
# default group inner comment
- 1INCH
- LINK
여기서 default:의 들여쓰기 변화는 단순히 보기 문제만이 아닙니다. 이제 default는 group_abc의 형제 노드가 아니라 그 안에 중첩되었습니다. 두 개의 최상위 그룹이 하나로 붕괴한 셈입니다.
yamlpath + yamlpatch는 유효한 YAML을 만들어내지만, 갈 곳을 잃은 주석은 여기서 정의한 “정중함” 속성 위반입니다. 다만 다른 라이브러리들의 상태를 생각하면, 아마 치명적인 문제라고 보기는 어려울 것입니다.
현재 사용 가능한 선택지 중에서는 yamlpath + yamlpatch가 가장 낫습니다. 완벽하지는 않지만, 활발히 유지보수되고 있으며, 약간의 우회책과 타협을 통해 실사용 가능하게 만들 수 있습니다. 실제 사용 사례에 적용하려 하면서 제가 마주친 몇 가지 주의사항은 다음과 같습니다.
Op::Replace는 시퀀스에서 동작하지 않는다yamlpatch-replace-list.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
``````rust
use std::collections::HashSet;
use anyhow::Context as _;
use clap::Parser;
use yamlpatch::{Op, Patch, apply_yaml_patches};
const INPUT: &str = "\
asset_groups:
default:
- 1INCH
- ATOM
- LINK
";
#[derive(Parser)]
struct Args {
/// Comma-separated assets to list (i.e. add to `default` if missing).
#[arg(long)]
assets: String,
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
let new_assets: Vec<String> = args
.assets
.split(',')
.map(|s| s.trim().to_string())
.collect();
// parse old assets
let parsed: serde_yaml::Value = serde_yaml::from_str(INPUT).unwrap();
let default_old = parsed
.get("asset_groups")
.and_then(|v| v.get("default"))
.and_then(|v| v.as_sequence())
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>();
// construct new assets
let mut default_new = Vec::<String>::from_iter(HashSet::<String>::from_iter(
default_old.iter().cloned().chain(new_assets),
));
default_new.sort();
let new_seq: Vec<serde_yaml::Value> = default_new
.into_iter()
.map(serde_yaml::Value::String)
.collect();
let doc = yamlpath::Document::new(INPUT.to_string()).unwrap();
let patch = Patch {
route: yamlpath::route!("asset_groups", "default"),
operation: Op::Replace(serde_yaml::Value::Sequence(new_seq)),
};
let new_doc = apply_yaml_patches(&doc, &[patch]).context("apply patches")?;
print!("{}", new_doc.source());
Ok(())
}
$ cargo run --example yamlpatch-replace-list -- --assets 1INCH,BTC,XRP,BNB
Error: apply patches
Caused by:
0: YAML query error: input is not valid YAML
1: input is not valid YAML
Op::Replace를 시퀀스에 사용할 수 없기 때문에, 리스트 전체를 한 번에 업데이트하려면 우회책이 필요합니다. 먼저 원하는 최종 리스트 전체를 끝에 추가한 다음, 앞쪽의 원래 항목들을 하나씩 제거하는 방식입니다.
yamlpatch-rotate-replace-list.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
``````rust
use std::collections::HashSet;
use anyhow::Context as _;
use clap::Parser;
use yamlpatch::{Op, Patch, apply_yaml_patches};
const INPUT: &str = "\
asset_groups:
default:
- 1INCH
- ATOM
- LINK
";
#[derive(Parser)]
struct Args {
/// Comma-separated assets to list (i.e. add to `default` if missing).
#[arg(long)]
assets: String,
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
let new_assets: Vec<String> = args
.assets
.split(',')
.map(|s| s.trim().to_string())
.collect();
// parse old assets
let parsed: serde_yaml::Value = serde_yaml::from_str(INPUT).unwrap();
let default_old = parsed
.get("asset_groups")
.and_then(|v| v.get("default"))
.and_then(|v| v.as_sequence())
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>();
// construct new assets
let mut default_new = Vec::<String>::from_iter(HashSet::<String>::from_iter(
default_old.iter().cloned().chain(new_assets),
));
default_new.sort();
let mut patches: Vec<Patch> = Vec::new();
for item in default_new {
patches.push(Patch {
route: yamlpath::route!("asset_groups", "default"),
operation: Op::Append {
value: serde_yaml::Value::String(item),
},
});
}
for _ in 0..default_old.len() {
patches.push(Patch {
route: yamlpath::route!("asset_groups", "default", 0usize),
operation: Op::Remove,
});
}
let doc = yamlpath::Document::new(INPUT.to_string()).unwrap();
let new_doc = apply_yaml_patches(&doc, &patches).context("apply patches")?;
print!("{}", new_doc.source());
Ok(())
}
$ cargo run --example yamlpatch-rotate-replace-list -- --assets 1INCH,BTC,XRP,BNB
asset_groups:
default:
- 1INCH
- ATOM
- BNB
- BTC
- LINK
- XRP
yamlpatch-flow-list.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
``````rust
use std::collections::HashSet;
use anyhow::Context as _;
use clap::Parser;
use yamlpatch::{Op, Patch, apply_yaml_patches};
const INPUT: &str = "\
asset_groups:
default: [1INCH, ATOM, LINK]
";
#[derive(Parser)]
struct Args {
/// Comma-separated assets to list (i.e. add to `default` if missing).
#[arg(long)]
assets: String,
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
let new_assets: Vec<String> = args
.assets
.split(',')
.map(|s| s.trim().to_string())
.collect();
// parse old assets
let parsed: serde_yaml::Value = serde_yaml::from_str(INPUT).unwrap();
let default_old = parsed
.get("asset_groups")
.and_then(|v| v.get("default"))
.and_then(|v| v.as_sequence())
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>();
// construct new assets
let mut default_new = Vec::<String>::from_iter(HashSet::<String>::from_iter(
default_old.iter().cloned().chain(new_assets),
));
default_new.sort();
let mut patches: Vec<Patch> = Vec::new();
for item in default_new {
patches.push(Patch {
route: yamlpath::route!("asset_groups", "default"),
operation: Op::Append {
value: serde_yaml::Value::String(item),
},
});
}
let doc = yamlpath::Document::new(INPUT.to_string()).unwrap();
let new_doc = apply_yaml_patches(&doc, &patches).context("apply patches")?;
print!("{}", new_doc.source());
Ok(())
}
$ cargo run --example yamlpatch-flow-list -- --assets 1INCH,BTC,XRP,BNB
Error: apply patches
Caused by:
Invalid operation: append operation is not permitted against flow sequence route: Route { route: [Key("asset_groups"), Key("default")] }
yamlpath + yamlpatch는 여기서 정의한 “정중한” 패치에 진짜로 가장 가깝게 다가가는 유일한 선택지입니다. 모든 경우를 즉시 지원하지는 않지만, 실제로 사용하기에는 충분히 쓸 만합니다.
전체 예제 소스 코드는 여기에서 볼 수 있습니다. 빌드는 rustc 1.95.0으로 했습니다. 사용한 라이브러리 버전은 yamlpath 1.24.1, yamlpatch 1.24.1, yaml-edit 0.2.1, rust-yaml 0.0.5, yamp 0.1.0입니다.