터미널 다크/라이트 모드 자동 감지: COLORFGBG 환경 변수의 조용한 진실
브라우저엔 `prefers-color-scheme`가 있는데 터미널엔 뭐가 있을까? COLORFGBG 환경 변수, OSC 11 이스케이프 쿼리, TERM_PROGRAM 휴리스틱을 비교하고, Rust TUI 프로젝트에서 쓴 12줄짜리 디텍터와 그 한계를 정리합니다.
1. 문제 상황
웹에서는 사용자의 다크 모드 선호를 읽기가 쉽습니다.
@media (prefers-color-scheme: dark) { ... }
window.matchMedia('(prefers-color-scheme: dark)').matches
브라우저가 운영체제의 설정을 중계해주기 때문에, 웹 개발자는 한 줄로 "지금 사용자가 다크 모드인가?"를 알 수 있습니다. 터미널 앱을 만들다 보면 이게 얼마나 고마운 일이었는지 새삼 깨닫게 됩니다.
터미널에는 prefers-color-scheme가 없습니다. 같은 디바이스에서도 어떤 사용자는 다크 배경 터미널을, 어떤 사용자는 라이트 배경 터미널을 쓸 수 있고, 심지어 같은 사용자가 시간대에 따라 바꾸기도 합니다. TUI 앱은 이걸 어떻게 알아내야 할까요?
Rust TUI 프로젝트 duru에 Rosé Pine 테마를 적용하면서 이 벽에 부딪혔습니다. Rosé Pine은 공식적으로 다크/라이트 두 모드를 제공하는데, 사용자가 매번 --theme dark/--theme light 플래그를 넣지 않아도 "알아서" 맞춰주고 싶었습니다.
결과적으로 쓴 코드는 12줄입니다. 놀랍게 짧지만, 그만큼 한계도 명확합니다. 이 글은 그 12줄과 그 배경의 이야기입니다.
2. 원인 분석
2.1 터미널은 왜 이렇게 어려운가
터미널 에뮬레이터는 대부분 POSIX 시절의 유산을 이고 있습니다. 1970-80년대에 설계된 통신 규약을 현대적인 확장으로 덧입힌 구조라, "어떤 사용자 환경인가?"를 질의하는 표준 방법이 오랫동안 제대로 합의된 적이 없습니다.
특히 GPU 기반 현대 터미널(Alacritty, Kitty, WezTerm)은 소켓으로 연결된 전통 터미널과 달리, 자기가 렌더링하는 색을 자기가 안다는 점에서 더 많은 정보를 줄 수 있어야 합니다. 하지만 셸 프로세스 입장에서 보면, 그 정보를 읽을 수 있는 표준 채널은 여전히 제한적입니다.
일반적으로 쓰이는 감지 방법은 세 가지입니다.
COLORFGBG환경 변수: 일부 터미널이 시작 시 설정하는 정적 값- OSC 10/11 이스케이프 쿼리: 터미널에 "너의 배경색이 뭐냐"를 물어봄
TERM_PROGRAM휴리스틱: 터미널 종류를 보고 추측
각각 장단점이 뚜렷합니다.
2.2 COLORFGBG 변수란
COLORFGBG는 1990년대 rxvt에서 도입한 환경 변수로, 터미널이 현재 사용 중인 전경·배경색을 ANSI 256 색상 팔레트 인덱스로 표현해서 자식 프로세스에 알려주는 용도입니다.
형식은 두 가지가 흔합니다.
COLORFGBG=15;0 # foreground=15, background=0
COLORFGBG=15;default;0 # foreground=15, default 중간 값, background=0
두 번째 형식은 rxvt 계열에서 쓰는 확장인데, ;로 split한 마지막 토큰이 배경색이라는 규칙은 동일합니다. 파싱 로직은 rsplit(';').next() 한 줄이면 됩니다.
값의 의미는 ANSI 256 팔레트 인덱스입니다. 앞 16개 값이 기본 팔레트로, 이 구역만 알면 대부분의 경우를 커버할 수 있습니다.
| Index | 이름 | 보통의 색 |
|---|---|---|
| 0 | black | 검정 |
| 1 | red | 빨강 |
| 2 | green | 녹색 |
| 3 | yellow | 노랑 |
| 4 | blue | 파랑 |
| 5 | magenta | 자홍 |
| 6 | cyan | 청록 |
| 7 | white (light gray) | 밝은 회색 |
| 8 | bright black (dark gray) | 진한 회색 |
| 9..15 | bright variants | 밝은 기본색들 |
일반적인 터미널 기본값을 관찰해보면 이런 패턴이 나옵니다.
- 어두운 테마: 배경이
0(검정) 또는 드물게8(진한 회색) - 밝은 테마: 배경이
7(밝은 회색) 또는15(순백)
"배경 번호가 크면 밝은 테마"라는 규칙이 대략 성립합니다. duru는 bg >= 8을 임계값으로 잡아 이 휴리스틱을 단순화했습니다. 8..=15는 모두 bright 팔레트에 속하고, 밝은 테마의 배경은 대부분 이 구간에 들어갑니다.
2.3 OSC 10/11 쿼리란
OSC(Operating System Command)는 \x1b] 로 시작하는 이스케이프 시퀀스로, 터미널과 프로세스가 양방향 통신하는 메커니즘입니다. OSC 11은 "현재 배경색을 알려달라"는 요청이고, 터미널은 RGB 값으로 응답합니다.
요청: \x1b]11;?\x07
응답: \x1b]11;rgb:191a/1721/2403\x07
응답의 RGB 값을 파싱해서 luma(명도)를 계산하면 다크/라이트를 판단할 수 있습니다.
luma = 0.299·R + 0.587·G + 0.114·B
luma < 128 → 다크
luma >= 128 → 라이트
장점: 대부분의 현대 터미널(Alacritty, Kitty, iTerm2, xterm, WezTerm)이 응답합니다. COLORFGBG를 설정하지 않는 터미널도 OSC 11에는 응답하는 경우가 많습니다.
단점 다섯 가지가 만만치 않습니다.
- raw mode 필요: 응답을 읽으려면 터미널을 raw 모드로 전환해서 stdin을 직접 읽어야 합니다. 이 시점에 이미 TUI 초기화가 끝나 있어야 하는 경우가 많아, 테마 결정 타이밍이 애매합니다.
- 응답 타임아웃: 모든 터미널이 OSC 11에 응답하지는 않습니다. 응답이 안 오면 프로그램이 멈추므로 타임아웃 처리가 필수입니다.
- tmux/ssh 환경: tmux는 기본적으로 OSC를 패스스루하지 않습니다.
set -g allow-passthrough on같은 설정이 필요하고, ssh는 더 복잡해집니다. - 비동기 I/O 복잡도: 요청/응답 모델이 한 줄짜리 동기 코드로는 안 됩니다. 스레드나 async runtime을 끌어와야 할 수 있습니다.
- RGB → 테마 매핑의 모호함: 배경이
#2d2d2d(진한 회색)처럼 애매한 색일 때 luma 128이 정말 맞는 경계인지 논쟁의 여지가 있습니다.
이 다섯 개가 모두 다루기 귀찮아서 duru는 OSC 11을 일단 미뤘습니다. COLORFGBG는 "부작용 0, 동기, 실패해도 크래시 없음"이라는 속성을 가졌으므로 첫 단계 기본값으로 적합합니다.
2.4 TERM_PROGRAM 휴리스틱이란
TERM_PROGRAM 환경 변수를 보고 터미널 종류를 추측하는 방법입니다. 예를 들어:
TERM_PROGRAM=iTerm.app→ iTerm, 기본 라이트 모드 아님TERM_PROGRAM=Apple_Terminal→ macOS TerminalKITTY_WINDOW_ID존재 → KittyWEZTERM_PANE존재 → WezTerm
문제는 이게 "터미널 종류"만 알려줄 뿐 사용자의 테마 설정은 알려주지 않는다는 점입니다. iTerm 사용자가 다크 테마를 쓸 수도 있고 라이트 테마를 쓸 수도 있는데, TERM_PROGRAM만으로는 구별이 안 됩니다.
그래서 TERM_PROGRAM은 보통 보조적인 힌트로만 쓰입니다. "이 터미널은 COLORFGBG를 지원한다"는 화이트리스트용으로 붙이거나, OSC 11 쿼리의 fallback 순서를 결정하는 데 씁니다. 단독 감지 수단으로는 부족합니다.
3. 해결 방법
3.1 duru의 12줄 디텍터
duru는 "가장 단순한 것부터 쌓아올리자"는 전략을 택했습니다. COLORFGBG만 보고 판단하고, 없거나 애매하면 다크로 fallback, 그리고 사용자가 --theme 플래그로 언제든 오버라이드할 수 있게.
// src/theme.rs
impl Theme {
pub fn from_option(theme_arg: Option<&str>) -> Self {
match theme_arg {
Some("light") => Self::light(),
Some("dark") => Self::dark(),
_ => Self::detect(), // ← 자동 감지
}
}
fn detect() -> Self {
// Check COLORFGBG env var: "foreground;background"
// Background >= 8 usually means light theme
if let Ok(val) = std::env::var("COLORFGBG")
&& let Some(bg) = val.rsplit(';').next() // ← 마지막 세그먼트
&& let Ok(bg_num) = bg.parse::<u8>() // ← u8 파싱
&& bg_num >= 8 // ← 임계값
{
return Self::light();
}
Self::dark() // ← fallback
}
}
한 줄씩 해체해봅니다.
if let ... && let ... && ... 체인 (let chains): Rust 1.88+에서 안정화된 문법입니다. 이전에는 각 단계마다 nested if let을 써야 해서 가독성이 떨어졌는데, 지금은 한 조건문에 연쇄할 수 있습니다. 하나라도 실패하면 전체 fallback으로 떨어집니다.
rsplit(';').next(): "15;default;0"처럼 세미콜론이 여러 개여도 마지막 토큰만 꺼냅니다. 일반 split이 아닌 rsplit을 쓰는 게 핵심입니다. split을 썼다면 인덱스를 계산하거나 last()를 호출해야 했을 텐데, rsplit(';').next()는 첫 토큰이 곧 마지막 토큰이므로 우아하게 해결됩니다.
bg.parse::<u8>(): 값은 0-255 사이의 팔레트 인덱스이므로 u8이 딱 맞습니다. 만약 COLORFGBG="15;default;0"에서 중간 토큰 "default"을 꺼냈다면 파싱이 실패하고 fallback으로 가지만, 마지막 토큰만 보기 때문에 문제되지 않습니다.
bg_num >= 8 임계값: 앞서 설명한 "bright 팔레트 = 밝은 배경" 휴리스틱입니다. 7을 포함할지 말지는 취향 차이가 있는데, 7은 "일반 회색"이라 dark 테마에서도 종종 쓰입니다. 안전하게 >= 8로 잡으면 false positive가 줄어드는 대신 false negative가 늘어납니다 (즉, 일부 밝은 테마가 "어두운 것"으로 분류될 수 있음). 사용자가 --theme light로 오버라이드할 수 있으므로 이 방향이 실용적입니다.
fallback이 dark인 이유: 통계적으로 개발자 터미널의 70% 이상이 다크 테마입니다. 잘 모를 때 "다크로 가정"이 맞을 확률이 더 높고, 틀렸을 때도 밝은 배경에 다크 UI가 올라가는 편이 덜 거슬립니다 (그 반대의 경우는 UI가 "안 보이는" 수준의 문제가 될 수 있습니다).
3.2 실제 터미널 호환성 — 소스 코드 기반 검증
이론만으로는 부족합니다. 실제로 어떤 터미널이 COLORFGBG를 설정해주는지, 각 터미널의 GitHub 소스 코드와 이슈 트래커를 직접 확인했습니다.
| 터미널 | COLORFGBG 설정? |
포맷 | 근거 |
|---|---|---|---|
| rxvt / urxvt | ✅ | fg;bg 또는 fg;default;bg |
원조. XPM 빌드 시 3-value 포맷 사용 |
| xterm | ✅ | fg;bg |
설정에 따라 export |
| Konsole | ✅ | 15;0 또는 0;15 만 |
Session.cpp — HSV 기반 dark 판정, 근사값만 내보냄 |
| iTerm2 | ✅ | fg;bg (ANSI 매칭) |
PTYSession.h — ANSI 팔레트 매칭 + useColorfgbgFallback 기본 ON |
| Terminal.app (macOS) | ❌ | — | 소스 비공개, OSC 11에는 응답 |
| Alacritty | ❌ | — | #3996 wontfix — "escape query를 써라" |
| Kitty | ❌ | — | 소스에 COLORFGBG 없음. Mode 2031 지원 |
| WezTerm | ❌ | — | 소스에 COLORFGBG 없음. Mode 2031 PR 진행 중 |
| Ghostty | ❌ | — | 소스에 COLORFGBG 없음. Mode 2031 지원 |
| GNOME Terminal (VTE) | ❌ | — | Bug 733423 WONTFIX — "근본적으로 결함 있는 스펙" |
| foot | ❌ | — | 소스에 COLORFGBG 없음. Mode 2031 지원 |
| VS Code 터미널 | ❌ | — | 부분적으로 OSC 11 |
패턴이 뚜렷합니다.
- 설정하는 쪽: rxvt/urxvt (원조), xterm, Konsole, iTerm2 — 전통적인 터미널 또는 macOS 에코시스템
- 명시적으로 거부한 쪽: Alacritty (wontfix), VTE/GNOME Terminal (WONTFIX) — "환경 변수로 색을 알리는 건 근본적으로 깨진 설계"라는 입장
- 무시하는 쪽: Kitty, WezTerm, Ghostty, foot — 대신 Mode 2031 (다크/라이트 구독 프로토콜)을 지원
Konsole의 구현은 흥미롭습니다. 실제 fg/bg 색을 팔레트 인덱스로 매칭하는 대신, HSV 밝기가 127 미만이면 15;0 (dark), 이상이면 0;15 (light)로 이진 근사값만 내보냅니다. 소스 주석에도 "this is not strictly accurate use of the COLORFGBG variable" 이라고 적혀 있습니다.
iTerm2는 가장 정직한 구현입니다. 현재 fg/bg RGB를 16색 ANSI 팔레트에서 가장 가까운 인덱스로 매칭하고, 매칭이 실패하면 useColorfgbgFallback advanced 설정(기본 ON)에 따라 15;0 또는 0;15로 fallback합니다.
현실적으로 "COLORFGBG 감지의 커버리지"는 iTerm2 + Konsole + xterm/rxvt 사용자 합산입니다. macOS 개발자 중 iTerm2 점유율이 높다는 점을 고려하면 체감 커버리지가 제법 있지만, Alacritty·Kitty·Ghostty 같은 현대 터미널을 쓰는 사용자는 전부 fallback으로 떨어집니다.
3.2.1 COLORFGBG의 후계자 — Color Palette Update Notifications (DEC Mode 2031)
리서치 중에 발견한 중요한 움직임이 있습니다. Contour Terminal이 제안한 Color Palette Update Notifications 프로토콜(DEC private mode 번호 2031)이 2024년부터 빠르게 채택되고 있습니다. "Mode 2031"이라는 비공식 약칭으로 불리기도 합니다.
# 현재 다크/라이트 모드 질의
CSI ? 996 n
# 응답: CSI ? 997 ; 1 n (dark) 또는 CSI ? 997 ; 2 n (light)
# 변경 시 알림 구독
CSI ? 2031 h
# 이후 테마가 바뀔 때마다 자동으로 CSI ? 997 ; N n 수신
# 구독 해제
CSI ? 2031 l
COLORFGBG와 비교하면:
| COLORFGBG | Mode 2031 | |
|---|---|---|
| 타이밍 | 프로세스 시작 시 1회 | 실시간 구독 |
| 런타임 변경 감지 | ❌ | ✅ |
| 표준화 | 비공식 (rxvt 관습) | 터미널 컨소시엄 합의 |
| 지원 터미널 | rxvt, xterm, Konsole, iTerm2 | Ghostty, Kitty, foot, VTE, Contour |
| 구현 복잡도 | 1줄 (env var 읽기) | 이스케이프 시퀀스 요청/응답 파싱 |
Neovim (PR #31350), Helix (PR #14356) 같은 주요 편집기가 이미 Mode 2031을 채택하기 시작했습니다. 장기적으로 COLORFGBG는 레거시 호환 계층이 되고, Mode 2031이 표준으로 자리잡을 가능성이 높습니다.
duru는 아직 Mode 2031을 직접 구현하지 않았습니다. 그리고 직접 구현하지 않을 예정입니다. 이유는 3.5에서 다룹니다.
3.3 사용자 오버라이드 설계
자동 감지가 완벽할 수 없다는 걸 받아들이면, 사용자가 언제든 오버라이드할 수 있는 탈출구가 더 중요해집니다. duru는 CLI 플래그 하나로 해결합니다.
// src/main.rs
use clap::Parser;
#[derive(Parser)]
struct Cli {
/// Override theme: "dark" or "light". Auto-detects if omitted.
#[arg(long)]
theme: Option<String>,
// ...
}
fn main() -> io::Result<()> {
let cli = Cli::parse();
let theme = Theme::from_option(cli.theme.as_deref());
// ...
}
사용법:
duru # COLORFGBG 기반 자동 감지, 실패 시 다크
duru --theme light # 강제 라이트
duru --theme dark # 강제 다크
이 설계의 미덕은 "명시적이면 명시를 존중, 아니면 최선을 다해 추측"하는 계층 구조입니다. from_option이 Option<&str>를 받아서 Some일 때는 사용자 의도를 그대로 따르고, None일 때만 자동 감지로 내려갑니다. 단 하나의 함수로 CLI 플래그, 설정 파일, 환경 변수 어느 소스에서 온 값이든 일관되게 처리할 수 있습니다.
pub fn from_option(theme_arg: Option<&str>) -> Self {
match theme_arg {
Some("light") => Self::light(),
Some("dark") => Self::dark(),
_ => Self::detect(),
}
}
Some("other") 같은 알 수 없는 값은 조용히 감지 fallback으로 떨어집니다. panic도 에러도 없이, "이해할 수 없는 입력은 무시한다"는 포용적 태도입니다. 더 엄격하게 하고 싶다면 match에 명시적 에러 arm을 추가할 수 있지만, TUI 앱의 첫 인상을 "잘못된 플래그입니다"라는 에러로 끝내는 건 대부분의 경우 과하다고 봅니다.
3.4 테스트로 계약 못박기
이 작은 함수에도 테스트 세 개가 붙어 있습니다.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dark_theme_has_correct_base_color() {
let theme = Theme::dark();
assert_eq!(theme.mode, ThemeMode::Dark);
assert_eq!(theme.base, Color::Rgb(25, 23, 36));
}
#[test]
fn light_theme_has_correct_base_color() {
let theme = Theme::light();
assert_eq!(theme.mode, ThemeMode::Light);
assert_eq!(theme.base, Color::Rgb(250, 244, 237));
}
#[test]
fn from_option_respects_explicit_choice() {
let dark = Theme::from_option(Some("dark"));
assert_eq!(dark.mode, ThemeMode::Dark);
let light = Theme::from_option(Some("light"));
assert_eq!(light.mode, ThemeMode::Light);
}
}
의도적으로 detect()는 테스트하지 않습니다. 이유는 그 함수가 프로세스 환경 변수에 의존하기 때문입니다. 단위 테스트에서 std::env::set_var를 쓸 수는 있지만, Rust의 테스트는 기본적으로 병렬 실행되므로 환경 변수를 건드리는 테스트끼리는 race condition이 생깁니다. 이걸 막으려면 #[serial] 마커(serial_test crate)를 쓰거나 테스트 스레드 수를 1로 제한해야 합니다.
**"환경 변수 의존 코드는 I/O 경계로 간주하고 mock 없이 남겨두자"**는 선택이 더 실용적인 경우가 많습니다. detect()는 실제 터미널에서 돌려보며 검증하고, 테스트에서는 "명시적 입력이 명시된 대로 동작한다"는 계약만 확인합니다. 자동 감지의 품질은 로그와 수동 QA로 따라잡습니다.
3.5 실전 조언: 직접 만들지 마세요
여기까지 읽으면 "그러면 Mode 2031도 구현하고, OSC 11도 추가하고, 점진적으로 확장하면 되겠네"라는 생각이 자연스럽습니다. 하지만 이 글을 쓰면서 얻은 가장 중요한 교훈은 정반대였습니다.
감지 로직을 직접 확장하는 건 바퀴를 재발명하는 것이다.
Rust 생태계에는 이미 이 문제를 풀어놓은 crate가 세 개 있습니다.
| crate | 버전 | 다운로드 | 감지 방식 | 사용처 |
|---|---|---|---|---|
| terminal-colorsaurus | v1.0.3 | 204만 | OSC 10/11 + 터미널 피처 감지 | bat, delta |
| terminal-light | v1.8.0 | 48만 | OSC + COLORFGBG | broot |
| termbg | v0.6.2 | 53만 | OSC + COLORFGBG fallback | — |
특히 terminal-colorsaurus는 bat과 delta가 쓰는 crate입니다. OSC 10/11 타임아웃, 터미널 피처 감지, 크로스플랫폼 처리, 응답이 안 오는 터미널에서의 안전한 실패 — 이 모든 걸 이미 수천 개의 환경에서 검증받았습니다.
duru의 12줄 detect()는 학습용 프로토타입으로는 훌륭했지만, 프로덕션 감지기로는 부족합니다. 실제로 개선할 때는 이렇게 바뀔 예정입니다.
// Before: 12줄 자체 구현 (COLORFGBG만)
fn detect() -> Self {
if let Ok(val) = std::env::var("COLORFGBG")
&& let Some(bg) = val.rsplit(';').next()
&& let Ok(bg_num) = bg.parse::<u8>()
&& bg_num >= 8
{
return Self::light();
}
Self::dark()
}
// After: crate에 위임 (OSC 10/11 + 터미널 피처 감지 포함)
fn detect() -> Self {
use terminal_colorsaurus::{theme_mode, QueryOptions};
match theme_mode(QueryOptions::default()) {
Ok(terminal_colorsaurus::ThemeMode::Dark) => Self::dark(),
Ok(terminal_colorsaurus::ThemeMode::Light) => Self::light(),
Err(_) => Self::dark(), // ← 감지 실패 시 fallback
}
}
변경의 핵심은 "어떻게 감지할지"의 책임을 crate에 넘기고, duru는 "감지 결과를 어떻게 쓸지"만 관리하는 것입니다. OSC 11 타임아웃 로직, Mode 2031 지원, 터미널별 호환성 테이블 — 모두 crate 메인테이너가 추적합니다.
"필요하면 crate, 안 필요하면 12줄" — 소규모 프로젝트의 생존 전략은 "잘 만들어진 의존성을 가려 쓰는 것"입니다. 타임아웃, 이스케이프 시퀀스 파싱, 크로스플랫폼 edge case 같은 것들은 혼자 커버하기엔 표면적이 너무 넓습니다.
4. 핵심 개념 정리
| 개념 | 설명 |
|---|---|
COLORFGBG |
rxvt 시절부터 있는 환경 변수. fg;bg 또는 fg;default;bg 형식으로 ANSI 팔레트 인덱스 전달 |
| ANSI 16색 팔레트 | 0-7 기본색 + 8-15 bright 변형. 밝은 배경은 대개 7 또는 15 |
bg >= 8 임계값 |
bright 팔레트 = 밝은 테마라는 경험적 규칙. false positive 최소화가 목적 |
| OSC 11 쿼리 | \x1b]11;?\x07 이스케이프로 터미널에 배경 RGB를 직접 물어보기. 정확하지만 구현 복잡 |
TERM_PROGRAM 휴리스틱 |
터미널 종류만 알려줌. 사용자 테마는 안 알려주므로 보조 수단 |
rsplit(';').next() |
";"로 split한 마지막 토큰을 꺼내는 관용구. last()보다 간결 |
| Let chain | if let ... && let ... && ... 문법 (Rust 1.88+). 중첩 if let을 평탄화 |
| 사용자 오버라이드 | 자동 감지 실패를 인정하고 CLI 플래그로 탈출구 제공 |
Option<&str> 진입점 |
CLI, 설정 파일, 환경 변수 어떤 소스에서든 일관된 인터페이스 |
terminal-colorsaurus |
bat/delta가 쓰는 OSC 10/11 기반 테마 감지 crate (204만 DL). 직접 구현 대신 위임하는 실전 선택 |
| Color Palette Update Notifications | Contour Terminal이 제안한 DEC Mode 2031. 다크/라이트 변경을 실시간 구독하는 차세대 프로토콜 |
5. 베스트 프랙티스
터미널 테마 감지 체크리스트
- [ ] 자동 감지는 "최선을 다해 추측"이라는 전제를 받아들이세요. 100% 커버리지는 OSC 11까지 내려가도 어렵고, 그만큼의 복잡도가 과연 값어치가 있는지 스스로 물어보세요.
- [ ] 가장 단순한 수단부터 시작하세요.
COLORFGBG→ OSC 11 →TERM_PROGRAM순서로 확장하면 각 단계에서 실제 수요를 확인할 수 있습니다. - [ ] 사용자 오버라이드를 항상 제공하세요. 자동 감지가 실패해도
--theme light, 설정 파일, 환경 변수 어느 하나로든 사용자가 탈출할 수 있어야 합니다. - [ ] fallback은 다크로. 통계적으로 더 흔하고, 틀렸을 때 UI가 완전히 안 보이는 최악의 시나리오를 피할 수 있습니다.
- [ ] 엣지케이스를 조용히 무시하세요.
COLORFGBG="broken"처럼 파싱 실패가 발생하면 panic 대신 fallback으로 떨어지는 게 맞습니다.
Rust 환경 변수 코드 체크리스트
- [ ]
std::env::var는Result를 반환합니다. 에러 케이스(미설정, UTF-8 아님)를 모두 if-let으로 처리하세요. - [ ] 환경 변수 의존 함수는 단위 테스트에 쉽지 않습니다.
std::env::set_var는 병렬 테스트에 안전하지 않으므로serial_test를 쓰거나 아예 단위 테스트를 피하고 실제 실행으로 검증하세요. - [ ] Let chain(
if let ... && let ... && ...)을 적극 활용하세요. Rust 1.88 이상이라면 중첩if let을 평탄화해서 가독성이 크게 올라갑니다. - [ ]
rsplit+.next()패턴: 구분자 여러 개인 문자열에서 마지막 필드만 꺼낼 때 가장 짧은 표현입니다. - [ ]
parse::<u8>()같은 작은 타입을 명시하세요. 값 범위가 확실하면 작은 타입이 의도를 드러내고, 오버플로우 가능성을 타입 수준에서 차단합니다.
6. FAQ
Q: COLORFGBG=7인데 실제로는 다크 테마일 수 있지 않나요?
A: 맞습니다. 7은 "light gray"로 애매한 위치입니다. 제 기준으로는 bg >= 8로 "light gray는 dark 쪽으로" 분류했지만, 이건 보수적인 선택입니다. 더 정확하게 하려면 터미널의 실제 RGB를 OSC 11로 물어봐야 하는데, 그 복잡도를 감당하느니 --theme light 오버라이드로 드물게 틀리는 경우를 해결하는 게 실용적이라 판단했습니다. "간혹 틀린다"는 자동 감지의 본질이고, 그걸 완벽하게 만들려는 욕심이 보통 코드를 망칩니다.
Q: 왜 TERM_PROGRAM=Apple_Terminal 같은 체크를 추가하지 않았나요?
A: TERM_PROGRAM은 "터미널 종류"만 알려주지 "사용자의 테마 선호"를 알려주지는 않습니다. macOS Terminal 사용자가 다크 모드를 쓸 수도 있고 라이트 모드를 쓸 수도 있는데, TERM_PROGRAM 값만으로는 구별이 불가능합니다. 그래서 단독 감지 수단으로는 쓸 수 없고, "COLORFGBG를 지원하지 않는 터미널은 OSC 11로 폴백"같은 라우팅 힌트로 쓰는 게 맞습니다. duru는 OSC 11을 아직 안 쓰므로 TERM_PROGRAM도 필요 없는 상태입니다.
Q: macOS의 "시스템 외모" 설정을 직접 읽을 수는 없나요?
A: defaults read -g AppleInterfaceStyle로 macOS 다크 모드 여부를 확인할 수 있습니다. 하지만 이건 시스템 전체 설정이고, 사용자가 터미널만 밝은 테마로 쓰는 경우를 놓칩니다 (의외로 흔합니다). 터미널 앱의 테마는 "시스템 설정"보다 "터미널 에뮬레이터 설정"을 따라가는 게 맞고, 그걸 읽는 가장 정직한 방법이 COLORFGBG 또는 OSC 11입니다. 시스템 설정 조회는 플랫폼 종속적이라 cross-platform CLI에서 무리가 있습니다.
Q: tmux 안에서 COLORFGBG가 통과되나요?
A: 상황에 따라 다릅니다. tmux는 자식 프로세스에 환경 변수를 상속시키므로, tmux를 시작한 시점에 변수가 설정되어 있었다면 그 값이 유지됩니다. 하지만 tmux 안에서 다른 terminal 창으로 옮겨가도 COLORFGBG는 그대로 남아 있어서 잘못된 값일 수 있습니다. 이건 tmux 고유의 제약이고, OSC 11이 (allow-passthrough 설정과 함께) 더 정확한 답을 줍니다.
Q: OSC 11 쿼리를 비동기로 구현하는 것 말고 더 간단한 방법 없나요?
A: crossterm이나 termwiz 같은 상위 레벨 라이브러리에 OSC 쿼리 헬퍼가 들어 있는 경우가 있습니다. 예를 들어 termwiz의 Capabilities::probe()는 배경색 쿼리를 내장하고 있습니다. 다만 이 헬퍼들은 "터미널 capabilities 전체"를 한 번에 물어보려는 경향이 있어서 시작이 느려질 수 있습니다. 단순 테마 감지 하나 때문에 거기까지 갈지는 프로젝트 규모 나름입니다.
Q: 12줄이면 너무 짧은데, 이 글의 가치가 그 12줄에만 있는 건가요?
A: 12줄은 정답이 아닙니다, 정답의 출발점입니다. 글의 진짜 메시지는 "터미널 테마 감지는 표준이 없는 영역이고, 작은 부분부터 쌓아가되 사용자 오버라이드를 항상 남겨두라"입니다. 비슷한 결정이 필요한 상황 — 로케일 감지, timezone 감지, 시스템 언어 감지 — 모두 같은 교훈이 적용됩니다. 코드 자체보다 "언제 단순함을 택하고 언제 복잡함을 택할지"의 기준이 더 오래 남는 가치입니다.
7. 참고 자료
- duru — 이 글의 theme.rs가 있는 오픈소스 프로젝트
- Rosé Pine — 본 글에서 적용한 테마
- xterm Control Sequences (OSC 10/11 설명)
- ANSI escape code — Wikipedia
- Color Palette Update Notifications (Mode 2031) — Contour 공식 스펙
- Rust Let chains RFC
- Ratatui로 Miller Columns 3-Pane 파일 탐색 UI 만들기 — 이 테마가 실제로 적용된 duru UI 설계
- Claude Code 프로젝트 폴더명 디코딩 — 같은 duru 프로젝트의 다른 디깅 이야기