macOS는 여행으로 지역이 바뀌면 시스템 시간대를 알아서 바꾼다. Omarchy(Arch/Hyprland) 리눅스에는 그 기능이 없어, 상하이에 도착해도 노트북은 서울 시간에 멈춰 있었다. 그래서 직접 만들었다. 흥미로운 건 macOS가 이걸 IP로 하지 않는다는 점이다. 그 "제대로 된" 방식을 리눅스에서 그대로 따라 만들어 봤고, 왜 결국 IP로 되돌렸는지, 그리고 그 과정에서 exit node를 켜 둔 채로도 진짜 위치를 얻는 우회와 재부팅에도 살아남는 서비스를 어떻게 풀었는지 코드까지 짚는다.
무엇을 만들었나: 네트워크가 바뀌는 순간 현재 위치를 판별해 timedatectl set-timezone으로 시스템 시간대를 자동 전환하는 스택. 스크립트 둘(whereami, city-watch)과 systemd user 서비스 하나.
먼저 제대로 시도했다: macOS처럼 주변 WiFi로 위치를 잡는 방식(Google Geolocation API)을 실제로 구현했다. 서울의 원격 머신에서는 건물 단위(오차 20m)로 맞았고 exit node도 우회했다. 그런데 중국에서는 WiFi 위치 데이터가 없어 실패했다. 그래서 시간대엔 도시 단위면 충분하다고 보고 IP 방식으로 되돌렸다.
실제로 도나: 이 문서의 모든 명령과 로그는 여행 중인 실제 노트북에서 지금 재본 것이다. 저널에 timezone changed: Asia/Seoul -> Asia/Shanghai가 남아 있고, 지금 이 순간 시스템 시간대는 Asia/Singapore(+08)로 맞아 있다.
먼저 흔한 오해부터 걷어내야 한다. "위치로 시간대를 맞춘다"고 하면 대개 공인 IP를 지도에 찍는 걸 떠올리지만, macOS는 그렇게 하지 않는다. macOS의 "현재 위치를 사용하여 자동으로 시간대 설정"은 Location Services(CoreLocation) 위에서 돈다. 위치를 얻는 주체는 locationd이고, 그 위치의 판단 근거는 IP가 아니라 주변 WiFi 핫스팟과 셀 타워, 센서다. Apple이 스트리트뷰 차량과 기기들로 모은 "이 공유기(BSSID)는 여기 있다"는 거대한 데이터베이스에 주변 신호를 대조해 위치를 찾는다.
이 차이가 실용적으로 중요하다. 판단 근거가 현지 무선 신호라서, VPN을 켜 출구 IP가 다른 나라여도 실제 위치가 나온다. IP를 지도에 찍는 방식이 VPN 하나에 통째로 속는 것과 대조된다. 참고로 시간대를 동기화하는 것처럼 보이는 timed는 위치 데몬이 아니라 시각(NTP) 동기화 데몬이고, systemsetup -setusingnetworktime도 시각 동기화지 자동 시간대 토글이 아니다. 위치로 시간대를 고르는 건 Location Services 계층의 몫이다.
리눅스에는 이런 통합 계층이 없다. systemd-timesyncd는 시각만 맞추고 시간대는 건드리지 않는다. 시간대를 바꾸는 systemd-timedated(timedatectl set-timezone)는 설정 메커니즘일 뿐 위치를 판별하지 않는다. 위치는 GeoClue라는 별도의 D-Bus 서비스가 담당하는데(GNOME의 "자동 시간대"가 이걸 쓴다), GeoClue의 WiFi 위치원은 오랫동안 Mozilla Location Service를 가리켰다. 그 MLS가 2024년에 문을 닫으면서(신규 키 중단 3월, 서드파티 키 제거 6월, 저장소 보관 7월) GeoClue에 기대던 리눅스 위치 기능들이 줄줄이 깨졌다. 즉 macOS를 그대로 따라 하려 해도, 리눅스에는 기댈 표준 인프라조차 마땅치 않은 상황이다. Hyprland처럼 최소 구성으로 꾸린 데스크톱에는 GeoClue 연동조차 없다.
그래도 macOS의 방식이 정공법이니 먼저 그대로 만들어 봤다. 리눅스에서도 nmcli로 주변 AP를 스캔할 수 있고, 그 목록을 Google Geolocation API에 보내면 macOS와 같은 원리로 좌표가 나온다.
POST https://www.googleapis.com/geolocation/v1/geolocate?key=<KEY>
{
"considerIp": false, # ← 핵심. IP 폴백을 끈다
"wifiAccessPoints": [
{ "macAddress": "aa:bb:cc:dd:ee:ff", "signalStrength": -43 },
{ "macAddress": "11:22:33:44:55:66", "signalStrength": -67 }
]
}
→ { "location": { "lat": …, "lng": … }, "accuracy": 20 }
considerIp: false가 이 방식의 핵심이다. IP 폴백을 꺼 두면 순전히 WiFi로만 판단하므로, exit node를 켜 둔 채로도 실제 위치가 나오고, 못 찾을 땐 가짜를 지어내지 않고 정직하게 실패한다.
쓰려면 준비가 좀 필요하다. 구글 클라우드 콘솔에서 프로젝트를 만들고, 결제 계정(카드)을 등록한 뒤, Geolocation API를 켜고 키를 발급받는다. 조회는 월 10,000건까지 무료다(예전의 월 200달러 크레딧은 2025년 2월로 끝났고, 지금은 API별 월 무료 한도 방식이다. 초과분은 1,000건당 5달러). 네트워크가 바뀔 때만 한 번 부르는 개인 용도는 한 달에 많아야 수십 번이라 청구가 날 일이 없다. 다만 무료 한도 안이라도 카드 등록은 필수다. 이 카드 등록 허들이 뒤의 판단에 영향을 준다.
이론은 그렇고, 실제로 두 곳에서 돌려 봤다.
서울에서는 정확히 들어맞았다. 테일넷으로 붙은 원격 머신에서 주변 WiFi를 스캔해 보내니, 노트북이 exit 노드를 켜 둔 상태에서도 오차 20m로 그 건물을 콕 짚었다. WiFi 방식이 exit node를 우회한다는 것과, 정확도가 IP와는 차원이 다르다는 것이 한 번에 확인됐다.
그런데 상하이에서는 404가 돌아왔다. considerIp:false로 물으니 "No location could be estimated". Google이 중국 본토에서 스트리트뷰를 접은 지 오래라 중국 WiFi 위치 데이터가 사실상 비어 있기 때문이다. 키·엔드포인트가 멀쩡한지 확인하려고 IP 폴백을 켜 보니(considerIp:true) 좌표는 나왔는데, 오차 15km짜리 싱가포르였다. 그때 쓰던 AWS Lightsail 싱가포르 exit 노드의 IP를 찍은 것이다. 무료 대안인 beaconDB도 같은 이유로 상하이에서 404였다. 다만 이 실패한 스캔이 뜻밖의 걸 남겼는데, 그건 다음 섹션에서 따로 다룬다.
여기서 판단이 갈렸다. WiFi 방식은 분명히 더 정확하고 VPN에도 안 속지만, (1) API 키와 카드 등록이 필요하고, (2) 정작 중국에서는 그 데이터가 없다. 반면 시간대를 맞추는 데에는 건물 단위 20m가 필요 없다. 도시, 정확히는 어느 시간대인지만 알면 된다. 그 정확도는 IP로도 충분히 나온다. 세 방식이 실제로 얼마나 어긋나는지를 같은 축에 놓으면 선택은 분명해진다.
WiFi 측위(20m)는 시간대엔 과하고 중국에선 실패한다. 우회 없는 IP(3,810km)는 exit 노드의 나라를 찍어 아예 틀린다. 우리가 노릴 지점은 가운데, 실회선으로 물은 IP가 주는 도시 단위 정확도다. 그거면 시간대는 정확히 맞는다. 그래서 WiFi 측위는 접고, 대신 IP가 exit node에 속는 문제 하나만 정면으로 풀기로 했다.
| 방식 | 키·과금 | 한국(서울) | 중국(상하이) |
|---|---|---|---|
| WiFi 측위(Google) | 필요 | ±20m 건물 | 404 데이터 없음 |
| IP 지오 + exit 우회 | 없음 | 도시 정확 | 도시 정확 |
WiFi 측위가 상하이에서 404를 준 건 좌표를 못 줬다는 뜻이지, 스캔 자체가 헛수고였다는 뜻은 아니다. 잡힌 AP 목록만 들여다봐도 "여기가 어느 나라인지"는 아주 확실하게 나온다. 정밀 위치와 나라 판별은 서로 다른 층이고, 뒤에서 볼 eSIM·로밍 문제를 바로잡을 실마리가 여기 있다.
스캔은 한 줄이면 된다. sudo iw dev wlan0 scan이 신호세기(dBm), BSSID, SSID, 그리고 AP가 beacon에 실어 보내는 규제 국가코드(Country IE)까지 한 번에 준다. 걸러낼 건 둘이다. 무작위로 바뀌는 로컬 관리 MAC(첫 옥텟에 0x02 비트가 선 것, 대개 폰 핫스팟)과, _nomap으로 끝나는 SSID(측위에서 빼 달라는 opt-out 규약)다. 제조사는 인터넷 없이 로컬 IEEE OUI 데이터베이스(/usr/share/hwdata/oui.txt)에서 BSSID 앞 3옥텟으로 찾는다. exit node도 인터넷도 필요 없는 완전 오프라인 조회다.
상하이의 한 호텔에서 잡힌 AP 15개 전부다. 신호 강한 순으로 늘어놓고, 앞 3옥텟을 로컬 OUI DB로 제조사까지 붙였다.
iw 스캔 + 로컬 OUI 조회로 뽑은 주변 AP 15개(신호 강한 순, 있는 그대로).| BSSID | dBm | 제조사(OUI) | SSID |
|---|---|---|---|
| 74:c9:a3:87:e3:1e | -42 | Fiberhome Telecommunication | ChinaNet-47Pd |
| 7a:d2:3e:28:1d:35 | -51 | 무작위(로컬 관리) MAC | seapy-mudi |
| 0e:82:3a:d7:53:21 | -51 | 무작위(로컬 관리) MAC | seapy-mudi |
| 68:25:dd:4c:a7:f9 | -53 | Espressif Inc. | 8602 |
| fc:c0:cc:33:4b:00 | -65 | Yunke China Information Technology | Hidden Hotel |
| fc:c0:cc:33:4b:02 | -65 | Yunke China Information Technology | OFFICE |
| fc:c0:cc:33:4b:01 | -67 | Yunke China Information Technology | HomeinnsIOT |
| 68:25:dd:4b:e1:79 | -68 | Espressif Inc. | 8603 |
| 68:25:dd:4d:41:95 | -69 | Espressif Inc. | 8601 |
| fc:c0:cc:33:86:12 | -76 | Yunke China Information Technology | OFFICE |
| 74:c9:a3:87:ed:78 | -79 | Fiberhome Telecommunication | ChinaNet-LVWi |
| 68:25:dd:4d:76:69 | -80 | Espressif Inc. | 8605 |
| fc:c0:cc:36:65:61 | -81 | Yunke China Information Technology | HomeinnsIOT |
| fc:c0:cc:33:ae:e0 | -92 | Yunke China Information Technology | Hidden Hotel |
| 58:3b:d9:a4:f3:aa | -92 | Fiberhome Telecommunication | ChinaNet-2aib |
제조사별로 세면 Yunke China 6대, Espressif 4대, Fiberhome 3대, 정체를 못 밝히는 무작위 MAC 2대다. 신호세기로 색을 입혀 보면 이 동네 전파가 무슨 장비로 채워져 있는지 한눈에 들어온다.
여기서 세 가지가 서로 독립적으로 같은 답을 가리킨다.
ChinaNet-*는 차이나텔레콤 망, HomeinnsIOT는 如家(홈인) 호텔 체인, 8601~8607은 객실 번호다. 나라를 넘어 "중국의 홈인 계열 호텔"까지 좁혀진다.정리하면 위치는 두 층이다. 정밀 위치(좌표)는 Google WiFi 측위로, 데이터가 쌓인 지역(한국 등)에서만 얻는다. 나라·지역 판별은 Country IE와 SSID, OUI 휴리스틱으로, 중국에서도 그것도 오프라인으로 된다. 이 아래층은 아직 whereami에 붙이지 않았지만, 뒤에서 볼 IP의 약점을 메울 카드다.
현재 위치를 IP로 알아내는 건 쉽다. 공인 IP를 지오 데이터베이스에 넣으면 도시와 시간대가 나온다. 문제는 tailscale exit node를 상시 켜 둔 사람이다. exit node는 모든 트래픽을 골라 둔 노드(서울이나 싱가포르 등)로 내보낸다. 그 상태에서 "내 공인 IP가 뭐냐"를 물으면 돌아오는 답은 exit 노드의 IP다. 상하이에 앉아 있어도 싱가포르가 잡히고, 시간대도 따라서 틀린다. 그림 1의 3,810km가 바로 이 오차다.
모든 트래픽을 터널 밖으로 뺄 필요는 없다. "내 IP 알려줘"라고 묻는 그 조회 하나만 실제 물리 회선으로 보내면 된다. 이걸 리눅스의 정책 라우팅(policy routing)으로 한다. ip rule은 목적지나 출발지 같은 조건에 따라 어느 라우팅 테이블을 쓸지 정하는 규칙 목록이고, 우선순위 숫자가 작을수록 먼저 평가된다.
tailscale이 exit node로 트래픽을 다 내보낼 때 실제로 규칙 목록에 무엇을 넣는지 이 노트북에서 그대로 떠 봤다. 아래 사다리가 그 규칙들이고, 위에서부터 평가된다.
ip rule 우선순위 사다리와 두 갈래 경로. 조회 서버로 가는 패킷은 우리가 끼운 prio 5100에서 실회선으로 빠지고, 나머지 모든 트래픽은 tailscale이 심은 prio 5270에서 터널(테이블 52)로 가 exit 노드로 나간다.핵심은 5270: from all lookup 52 한 줄이다. tailscale은 우선순위 5270에 "출발지 불문 모든 트래픽을 테이블 52로 보내라"는 규칙을 심는다. 테이블 52는 터널로 나가는 경로다. 그래서 아무것도 안 하면 IP 조회도 이 규칙에 걸려 터널로 나가고, exit 노드 위치가 잡힌다. 우회는 이 catch-all보다 먼저 걸리는 규칙 하나를 끼워 넣는 것이다.
# 조회 서버로 가는 패킷만 실회선(main 테이블)으로
sudo ip rule add to <조회서버_IP> lookup main priority 5100
우선순위 5100은 tailscale의 catch-all(5270)보다 작다. 그래서 그 조회 서버로 향하는 패킷은 5100 규칙에 먼저 걸려 main 테이블(실제 wlan0 회선)을 타고, 나머지 트래픽은 여전히 5270에 걸려 터널로 나간다. 딱 한 목적지만 터널 밖으로 뺀 것이다. 조회가 끝나면 규칙을 바로 지운다. 이 규칙이 실제로 먹히는지 지금 노트북에서 넣고, 확인하고, 지워 봤다(문서용 예시 IP 203.0.113.9 사용).
$ sudo ip rule add to 203.0.113.9 lookup main priority 5100
$ ip rule | grep 203.0.113.9
5100: from all to 203.0.113.9 lookup main ← catch-all(5270)보다 먼저 평가됨
$ sudo ip rule del to 203.0.113.9 lookup main priority 5100
이걸 whereami 스크립트의 함수 하나로 감쌌다. exit node가 켜져 있을 때만 우회를 쓴다(꺼져 있으면 직접 조회가 이미 실 IP다).
# 실회선 우회로 응답 얻기. $1=도메인 $2=포트 $3=URL
via_real() {
local dom="$1" port="$2" url="$3" svc
svc=$(getent ahostsv4 "$dom" | awk '{print $1; exit}'); [ -z "$svc" ] && return 1
sudo -n ip rule add to "$svc" lookup main priority 5100 2>/dev/null
curl -s --max-time 10 --resolve "$dom:$port:$svc" "$url" 2>/dev/null
sudo -n ip rule del to "$svc" lookup main priority 5100 2>/dev/null
}
몇 가지 결정이 들어 있다. 목적지를 도메인이 아니라 IP로 고정한다(--resolve). 규칙이 특정 IP에만 걸리게 하려면 미리 주소를 알아야 하고, 이름 해석도 실회선에서 해야 하기 때문이다. 이렇게 얻은 "진짜 IP"는 그 다음엔 아무 경로로 지오 조회를 해도 된다. 주어진 IP를 해석하는 것이라 어디서 물어도 답이 같기 때문이다. 조회 서버는 한 곳만 믿지 않고 층을 쌓았다. 중국처럼 특정 서비스가 막힌 곳을 대비해 중국계 echo → ip-api(HTTP) → ipinfo(HTTPS) 순으로 폴백한다(옛 폴백 서버는 DNS 자체가 사라져 실제로 갈아끼웠다). 시간대는 반드시 /usr/share/zoneinfo/에 실재하는 값만 통과시켜, 오염된 문자열이 timedatectl로 흘러가는 걸 막는다.
모드는 셋이다. 사람용 출력, 도시 한 줄(--oneline, 변경 감지용), IANA 시간대 한 줄(--tz, 시스템 동기화용). 지금 여행지에서 실제로 돌린 결과다.
$ whereami
현재 도시: Singapore, Singapore, SG
실 네트워크 IP: 198.51.100.23 (exit node: 꺼짐 → 직접조회)
시간대: Asia/Singapore (시스템: Asia/Singapore)
$ whereami --tz
Asia/Singapore
스크립트를 손으로 돌리는 건 자동화가 아니다. 진짜 어려운 건 폴링 없이 반응하는 것과 재부팅에도 사는 것이다.
1분마다 IP를 긁는 타이머는 배터리와 데이터를 낭비한다. 대신 NetworkManager의 이벤트 스트림(nmcli monitor)을 읽는다. 네트워크가 실제로 바뀌는 순간에만 깨어나 재조회하고, 아무 이벤트도 없을 때를 대비해 1시간에 한 번은 강제로 확인하는 안전장치만 둔다. 루프의 뼈대는 이렇다.
while true; do
exec 3< <(nmcli monitor 2>/dev/null)
while true; do
t="$BACKSTOP"; [ "$RETRY" = 1 ] && t="$FAIL_RETRY"
if read -t "$t" -u 3 _line; then
sleep 6; check # 네트워크 이벤트 → 안정 대기 후 체크(디바운스)
else
rc=$?
if [ "$rc" -gt 128 ]; then check 1 # 타임아웃: 1시간 경과 → 강제 재체크
else break; fi # EOF: monitor 종료 → 바깥 루프가 재기동
fi
done
exec 3<&-; sleep 2
done
시간대를 맞추는 부분은 매 성공 체크마다 현재 존과 비교해, 다를 때만 바꾼다. 자가치유 구조라 어긋나면 다음 체크에서 스스로 교정된다.
sync_tz() {
local tz cur
tz="$(timeout 75 whereami --tz 2>/dev/null)"; [ -z "$tz" ] && return
cur="$(timedatectl show -p Timezone --value 2>/dev/null)"
if [ -n "$cur" ] && [ "$tz" != "$cur" ]; then
sudo -n timedatectl set-timezone "$tz" && \
logger -t city-watch "timezone changed: $cur -> $tz"
fi
}
권한은 최소한만 연다. timedatectl set-timezone과 우회용 ip rule 두 개만 sudoers에 NOPASSWD로 등록한다. 이 노트북에서 sudo -n이 암호 없이 통과하는 걸 위 실증에서 이미 확인했다.
여기가 제일 까다롭다. 유닛 파일은 짧지만, 세 줄이 각각 실측으로 잡은 함정을 담고 있다.
[Unit]
Description=City change notify + timezone sync (real-network IP)
After=network-online.target
StartLimitIntervalSec=0 # ← [Unit]에 둬야 먹는다
[Service]
Type=simple
ExecStart=%h/.local/bin/city-watch
Restart=always
RestartSec=15
[Install]
WantedBy=default.target
StartLimitIntervalSec=0은 [Service]가 아니라 [Unit]에 둬야 한다. [Service]에 두면 조용히 무시되고 기본 제한(짧은 시간에 재시작 여러 번이면 포기)이 그대로 남는다. 0으로 두면 어떤 크래시 루프에도 포기하지 않는다. 스크립트가 가벼워 부담이 없으니 무한 재시작이 안전하다. Restart=always와 짝이다.PartOf=graphical-session.target을 걸지 않는다. 로그인 전에도 도는 데몬(아래 linger)에 이걸 걸면, 로그아웃 때 함께 내려간 뒤 재로그인해도 세션 타깃이 재트리거되지 않아 영구 정지하는 함정이 있다. 이 서비스는 그래픽 세션이 필요 없으니 아예 뗐다.enable + linger. systemctl --user enable로 default.target에 걸고, loginctl enable-linger로 로그인 전에도 user 서비스가 뜨게 한다. 그러면 로그인 화면 단계에 이미 시간대가 맞아 있다.지금 이 노트북에서 서비스가 실제로 이렇게 물려 있는지 확인했다.
$ systemctl --user status city-watch
● city-watch.service - City change notify + timezone sync (real-network IP)
Loaded: loaded (…/city-watch.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-07-20 15:00:53 +08
Main PID: 986 (bash)
CGroup: …/city-watch.service
├─ 986 bash …/city-watch
└─1126 nmcli monitor
실전에선 조회가 실패한다. 부팅 직후엔 네트워크가 늦게 붙고, 나쁜 회선에선 curl이 걸린다. 두 가지로 눌러 담았다. 조회가 실패하면 다음 이벤트나 1시간 강제 확인까지 기다리지 않고 90초 간격으로 재시도하다가, 성공하면 다시 1시간 간격으로 돌아간다. 그리고 whereami 호출은 timeout으로 감싸, 회선이 나빠 조회가 멈춰도 루프 자체는 죽지 않게 했다. 조회에 실패하면 시간대를 바꾸지 않고 직전 값을 유지한다(없는 값으로 덮어써 오탐을 만들지 않기 위해서다).
이 기기(Asahi/Omarchy)에서만 겪은 함정도 하나 있었다. systemd user 서비스가 세션 PATH를 임포트하기 전에 뜨면 ~/.local/bin이 PATH에서 빠져 whereami를 못 찾고 매 체크가 조용히 무산된다. 서비스 스크립트 맨 위에서 export PATH="$HOME/.local/bin:$PATH"로 직접 보장해 막았다. 재현 가능한 본론은 아니니 이 정도만 적어 둔다.
만드는 것만으로는 반이다. 실제로 시간대를 바꾸는지가 핵심이다. 이 노트북의 시스템 저널(logger -t city-watch가 남긴 기록)에서, 스택이 스스로 시간대를 바꾼 순간들을 그대로 꺼냈다.
| 시각(현지) | 전환 | 무엇이 바뀌었나 |
|---|---|---|
| 07-19 23:28 | Asia/Seoul → Asia/Shanghai | 스크립트를 처음 설치한 직후 첫 동작. 서울에 멈춰 있던 시계가 상하이로 |
| 07-20 12:03 | Asia/Shanghai → Asia/Singapore | 싱가포르 기반 eSIM으로 전환. 그 회선 IP의 등록지가 싱가포르라 +08 그대로 |
| 07-20 15:02 | Asia/Singapore → Asia/Seoul | SKT 로밍 폰의 핫스팟에 연결. 로밍 IP의 등록지가 한국이라 +09 |
| 07-20 15:12 | Asia/Seoul → Asia/Singapore | 핫스팟을 떼고 eSIM으로 복귀. 다시 +08 |
네 번의 전환에서 노트북은 내내 상하이에 있었다. 시간대가 바뀐 건 위치 판별이 틀려서가 아니라, 인터넷으로 나가는 회선(현지 회선 → 싱가포르 eSIM → SKT 로밍)이 바뀔 때마다 그 회선 IP가 등록된 지역이 달랐기 때문이다. 회선 IP를 기준으로 보면 매번 정확했다. 이 회선-등록지 어긋남은 exit node와 무관한, IP 방식 자체의 성질이다. 뒤에서 다시 다룬다.
그리고 그 우회가 실제로 도는 증거는 sudo 로그에 그대로 남는다. 도시가 바뀌기 직전, 조회 서버로 가는 규칙을 넣고, 조회하고, 지운 뒤 시간대를 바꾼 자취다(호스트명과 계정명 스크럽).
15:12:31 ip rule add to <조회서버> lookup main priority 5100
15:12:39 ip rule del to <조회서버> lookup main priority 5100
15:12:42 timedatectl set-timezone Asia/Singapore
결과적으로 바뀌는 건 시간대 문자열 하나지만, 그 위에 얹힌 모든 것이 따라 온다. 잠금화면 시계, date 출력, 캘린더, 로그 타임스탬프가 손 하나 대지 않고 현지 시각이 된다. 지금 이 순간 노트북의 상태다.
$ timedatectl
Local time: Mon 2026-07-20 15:29:19 +08
Time zone: Asia/Singapore (+08, +0800)
System clock synchronized: yes
NTP service: active
이 스택은 회선 IP가 말하는 위치를 믿는다. 그래서 회선 IP에 대해서는 거의 항상 정확하다. 문제는 물리적 위치와 회선 IP의 등록지가 갈릴 때다. 표 3의 12:03과 15:02가 그 예다. 싱가포르 eSIM이나 SKT 로밍을 쓰면 상하이에 앉아 있어도 IP는 싱가포르나 서울로 등록돼 있어, 시간대가 그쪽을 따라간다. exit node를 우회해 실회선 IP를 얻어도 이건 못 잡는다. 회선 자체의 등록지가 다른 나라이기 때문이고, exit node 문제와는 별개다. 다행히 상하이와 싱가포르는 둘 다 +08이라 그 사이에선 시계가 맞고, +09(서울)로 갈 때만 한 시간 어긋난다. 변경마다 알림이 떠서 사람이 바로 본다.
이걸 바로잡을 단서는 이미 손에 있다. 앞서 상하이에서 스캔한 주변 AP들이다. IP가 싱가포르라고 우겨도, 곁의 공유기 제조사는 Yunke China·Fiberhome이고 AP가 광고하는 국가코드는 CN이었다. IP와 주변 AP가 어긋날 때 AP 쪽 신호로 나라를 바로잡는 것, 그게 다음 개선 방향이다. Google 측위 DB가 중국에서 비어 좌표까지는 못 얻어도, 제조사 OUI와 국가코드만으로 어느 나라인지는 가려진다. 정확히 macOS가 IP 대신 WiFi를 보는 이유이기도 하다.
설계상 분명한 한계도 있다. 실제 재부팅으로 부팅 초기 경로까지 통째로 검증하지는 않았다. 부팅에 관여하는 조각(enable, linger, PATH, 조회 폴백)은 각각 따로 실측했지만, 전원을 껐다 켜는 전 과정을 한 번에 돌려본 건 아니다.
macOS의 자동 시간대는 특별한 기능처럼 보이지만 원리는 단순하다. IP가 아니라 주변 WiFi로 위치를 잡는다는 것. 그 정공법을 리눅스에서 그대로 만들면 서울에서 건물을 짚을 만큼 정확했지만, 키와 카드 등록이 필요한 데다 정작 중국에서 데이터가 없어 무너졌다. 그래서 목적에 맞게 눈높이를 낮췄다. 시간대엔 도시 단위면 충분하니 IP로 가되, exit node에 속는 문제만 ip rule 한 줄로 우회하고, 네트워크 이벤트에 반응하는 systemd 서비스로 자동화했다. 우선순위 5100 규칙과 유닛 파일의 함정 세 줄이 이 글의 알맹이다. "제대로"가 늘 정답은 아니고, 문제에 맞는 정확도를 고르는 것이 먼저다.