BLOG기술

하드웨어는 200 OK를 주지 않습니다

정종길-DEV15분 읽기

하드웨어는 200 OK를 주지 않습니다

IoT 단말을 관제 플랫폼에 붙이면서 깨진 가정 6가지

디라이브는 차량 관제 플랫폼을 만드는 회사입니다. 차에 붙은 단말이 위치와 상태를 보내면 그걸 받아서 지도에 띄우고, 운행 기록을 만들고, 이상이 있으면 알려줍니다.

작년까지 저는 단말을 직접 다뤄본 적이 없습니다. 차량 데이터는 다른 회사 서버가 모아둔 걸 API로 받아 썼습니다. 서버와 서버가 얘기하는 거라 익숙한 세계였습니다. 요청하면 응답이 오고, 스키마는 문서대로 오고, 안 되면 에러 코드가 옵니다.

올해는 IoT 트래커 단말을 플랫폼에 직접 붙였습니다. 결론부터 말씀드리면, 서버 개발하면서 당연하게 깔고 있던 가정이 단말 앞에서는 하나씩 다 깨졌습니다. 어려웠던 건 프로토콜 해석이나 성능 같은 게 아니었습니다. "이건 당연히 이렇게 되겠지"가 하나도 안 통하는 게 제일 어려웠습니다. 깨진 가정을 하나씩 적어봤습니다.

가정 1. 요청을 보내면 상대에게 닿는다

서버끼리는 상대가 항상 켜져 있고, 주소만 알면 아무 때나 요청을 보낼 수 있습니다. 단말은 둘 다 아닙니다.

단말은 LTE 망 뒤에 있어서 서버가 먼저 말을 걸 방법이 없습니다. 단말이 먼저 서버에 TCP 연결을 맺고, 서버는 그 연결이 살아 있는 동안에만 명령을 내려보낼 수 있습니다. 그래서 단말은 주기적으로 하트비트를 보내서 연결을 유지합니다.

여기서 배터리 문제가 끼어듭니다. 저희 요구사항 중 하나가 "완충 후 최소 14시간"이었습니다. 통신 모듈이 제일 전기를 많이 먹으니까 하트비트를 1시간으로 늘리고 싶었습니다. 설정 자체는 됩니다. 그런데 이런 답이 돌아왔습니다.

통신사 망의 NAT 때문에 TCP 연결은 10분 이상 유지할 수 없습니다.

이동통신망의 NAT 장비는 일정 시간 패킷이 없는 연결의 매핑을 지워버립니다. 단말과 서버 둘 다 연결이 살아 있다고 믿고 있는데, 중간에서는 이미 없는 연결입니다. 하트비트 주기는 저희가 정하는 값인 줄 알았는데, 실제로는 통신망이 상한을 정하고 있었습니다.

절전까지 들어가면 상황이 더 나빠집니다. 단말이 절전에 들어가면 다음 접속까지 연결이 끊겨 있고, 그 사이에 보낸 제어 명령은 단말에 닿지 않습니다. 재접속할 때 대기 중이던 명령이 전달되긴 하는데, 타이밍에 따라 불안정할 수 있습니다.

결국 "명령은 즉시 실행된다"는 전제를 설계에서 뺐습니다.

  서버가 명령을 보냄 ──> [대기] ──(단말 재접속)──> [전달됨] ──(재조회로 확인)──> [적용 확인]
                           │
                           └─ 화면에는 "적용됨"이 아니라 "대기 중"으로 표시

화면에서 설정을 바꾸면 "저장되었습니다"가 아니라 "단말이 다음에 접속할 때 적용됩니다"가 맞는 안내입니다. 문구 하나 차이인데, 이게 없으면 고객 입장에서는 "바꿨는데 왜 안 바뀌죠 ?"가 됩니다.

가정 2. OK 응답이 왔으면 적용된 거다

REST API에서 200을 받으면 처리가 끝난 걸로 봅니다. 단말의 OK는 "명령을 받았다"는 뜻에 가깝고, 설정이 실제로 바뀌었는지는 별개 문제였습니다.

그래서 제어 명령을 검증할 때 등급을 셋으로 나눴습니다.

등급의미기능으로 인정
A조회 명령으로 값이 읽히는 것까지 확인조회만
B값을 바꾸고, 실제 동작이 바뀐 것까지 실측O
C쓰기 명령에 응답은 옴. 동작 변화는 아직 미확인X

기록 주기를 예로 들면, 값을 바꾸고 나서 실제로 데이터가 그 주기로 올라오는지 시간을 재봐야 B입니다. C에 있는 명령은 B가 될 때까지 플랫폼 기능으로 열지 않았습니다.

같은 이유로 단말 출하값도 믿지 않습니다. 실제로 절전 중 연결 방식 설정은 출하값과 저희 운용 모드의 권장값이 달랐습니다. 단말을 받을 때마다 사람이 설정을 확인할 수는 없으니, 플랫폼에 단말을 등록하는 순간 아래 절차가 돕니다.

// 단순화한 코드
async function converge(device: Device, desired: Settings) {
  const reported = await device.queryAll();            // 1. 현재 설정을 전부 조회
  const diff = Object.entries(desired)
    .filter(([key, value]) => reported[key] !== value); // 2. 권장값과 다른 것만 추림

  for (const [key, value] of diff) {
    await device.set(key, value);                       // 3. 다른 것만 변경
  }

  const after = await device.queryAll();                // 4. 다시 조회해서 확인
  return diff.filter(([key, value]) => after[key] !== value); // 남아 있으면 미수렴
}

"이 값으로 바꿔라"를 보내고 끝내지 않습니다. "원하는 상태"를 정해두고, 단말이 보고한 상태가 거기에 수렴할 때까지 확인합니다. 시간대(UTC 고정), 기록 주기, 하트비트, 묶음 설정, 절전 관련 값이 전부 이 방식으로 맞춰집니다. 3번을 "전부 다시 쓰기"로 안 하고 "다른 것만"으로 한 건, 불필요한 쓰기 명령 하나하나가 단말을 깨우는 배터리 비용이라서입니다.

가정 3. 데이터는 문서대로 온다

여기가 제일 많이 깨졌습니다.

주기. 요구사항은 "10초마다 기록, 30초마다 전송"이었습니다. 설정 항목 이름만 보면 될 거 같았는데, 실기기로 재보니 수집 주기라는 개념이 따로 없었습니다. 30초마다 그 시점 위치 하나를 바로 보냅니다. 이후 묶음 전송 방식으로 풀었고, 주기를 바꿔가며 실측해서 식 하나를 확정했습니다.

업로드 주기 = 기록 주기 × 묶음 개수     (묶음당 최대 10개, 프로토콜 한계)

행 수. 묶음 개수를 5로 뒀는데 기록이 3개뿐이면 패딩해서 5개로 맞춰 보낸다고 설명을 들었습니다. 실물은 3개만 옵니다. 단말이 안 움직이면 기록 자체가 줄어듭니다.

측위 실패. 지하 주차장에 들어간 단말은 데이터를 안 보내는 게 아닙니다. 마지막으로 잡힌 좌표를 그대로 반복해서 보내고, 측위 성공 플래그만 false로 바뀝니다. 이 플래그를 안 보면 관제 화면에서는 "정상 수신 중이고 한자리에 가만히 서 있는 차"가 됩니다. 실제로는 이미 다른 데 가 있을 수도 있고요.

null. 회선 사용량 필드는 가끔 null로 옵니다. 0이 아니라 "모름"입니다.

서버 간 API였다면 하나하나 버그 리포트감입니다. 단말에서는 이게 그냥 기본값이라고 받아들였습니다. 그래서 수신부는 의심하는 쪽으로 짭니다.

// 단순화한 코드
for (const row of batch.rows) {              // 행 수를 가정하지 않는다
  const position = row.gpsValid
    ? { lat: row.lat, lng: row.lng }
    : null;                                  // 측위 실패면 좌표를 믿지 않는다

  emit({
    deviceId: batch.deviceId,
    fixTime: row.fixTime,                    // 측위를 "시도한" 시각은 실패해도 찍혀 온다
    position,
    received: true,                          // 수신 여부와 측위 성공 여부는 별개 축
  });
}

// null은 "값 없음"이 아니라 "이번엔 모름" → 직전 값을 유지하고 동기화 시각만 갱신 안 함
if (usage !== null) sim.update({ usage, syncedAt: now });

화면에도 같은 구분이 그대로 올라갑니다. "수신 중"과 "위치 확인됨"은 다른 상태입니다. 그리고 값 옆에 "마지막 동기화 시각"을 보여줘서, 그 값이 언제 기준인지 사용자가 알 수 있게 했습니다.

가정 4. 시각은 하나다

서버에서는 created_at 하나면 대부분 해결됩니다. 단말 데이터에는 시각이 최소 세 개 붙습니다.

  1. 측위 시각: 단말이 위치를 잡은(잡으려고 시도한) 시각
  2. 중계 서버 수신 시각: 단말 보고가 중간 서버에 도착한 시각
  3. 우리 서버 도착 시각: 저희 서버에 들어온 시각

평소에는 셋이 몇 초 차이입니다. 문제는 음영 구간입니다. 단말은 전송에 실패하면 데이터를 버리지 않고 쌓아뒀다가 연결이 돌아오면 한꺼번에 보냅니다.

측위 시각   09:00  09:01  09:02  ...  09:29   (지하. 전송 실패, 단말에 쌓임)
도착 시각                                    09:30 에 30건이 한꺼번에 도착

도착 시각 기준으로 경로를 그리면 09:30에 차가 30개 지점을 순간이동한 걸로 나옵니다. "마지막 수신 시각"을 측위 시각으로 계산하면, 방금 통신이 복구된 단말이 30분 전에 죽은 단말로 표시됩니다.

그래서 화면마다 어떤 시각을 쓸지 먼저 못 박았습니다.

질문기준 시각쓰는 곳
차가 언제 어디 있었나 ?측위 시각지도 마커, 경로, 기간 조회, 운행기록부 날짜
단말이 살아 있나 ?중계 서버 수신 시각디바이스 수신 상태, 마지막 수신
우리 시스템이 언제 받았나 ?우리 서버 도착 시각원본 로그, 디버깅

이걸 정해두고 나니 "화면마다 시간이 왜 다르죠 ?"라는 질문에 바로 답할 수 있게 됐습니다. 정해두기 전에는 제가 질문을 받으면 매번 코드를 열어봐야 했습니다 ㅋㅋㅋ

가정 5. 데이터가 안 오면 끊긴 거다

이건 이전에 배차 관제 웹을 만들 때 겪은 일입니다. 차량 GPS가 1초에서 10초 사이의 들쭉날쭉한 간격으로 들어왔습니다. "N초 동안 안 오면 마커를 지운다"를 고정값으로 두니까 지도에서 차들이 계속 깜빡였습니다. N을 짧게 잡으면 멀쩡한 차가 사라졌다 나타나고, 길게 잡으면 시동 끈 차가 한참 떠 있습니다.

문제는 "안 온다"의 기준이 차마다 다르다는 거였습니다. 2초마다 보내던 차가 10초 조용하면 이상한 거고, 원래 10초마다 보내던 차는 10초 조용한 게 정상입니다. 그래서 차량별로 수신 간격을 추정해서 기다려주는 시간을 다르게 줬습니다.

// 단순화한 코드
const ALPHA = 0.22;                       // 최근 간격을 얼마나 반영할지

function onGps(v: Vehicle, now: number) {
  const dt = now - v.lastSeen;
  v.ema = ALPHA * dt + (1 - ALPHA) * v.ema;   // 차량별 수신 간격 추정 (EMA)
  v.lastSeen = now;
}

function isPresent(v: Vehicle, now: number) {
  const grace = clamp(v.ema * 1.8, 12_000, 26_000);  // 평소 간격의 1.8배, 12~26초 사이
  return now - v.lastSeen <= grace;
}

그리고 이 판정 결과를 한 곳에만 뒀습니다. 지도 마커도, 목록의 "수신 중" 배지도 같은 저장소를 봅니다. 전에는 배지가 응답의 timestamp를 따로 보고 있어서, 지도에는 떠 있는데 목록에서는 꺼져 있는 차가 있었습니다. 같은 판정을 여러 곳에서 각자 계산하면 결과가 서로 어긋납니다.

가정 6. 버그는 고쳐서 배포하면 된다

저희 서버는 하루에도 몇 번씩 배포합니다. 펌웨어는 그렇지 않습니다. "수집 10초, 전송 30초"는 구현 가능하다는 답을 받았지만 펌웨어 수정이 필요한 일이었고, 그 일정은 저희가 정할 수 없었습니다. 충전 케이블을 꽂아도 충전 중으로 인식이 안 되는 건 설계상 분리가 안 된다는 답을 받았고, 들고 걸으면 속도가 계속 0으로 오는 것도 단말 특성입니다.

여기서 정한 원칙은 단순합니다. 안 되는 걸 기다리지 않고, 관측된 동작을 사실로 놓고 그 위에 만듭니다.

  • 속도로 보행을 못 잡는다면, 속도 기반 판정이 필요한 기능은 기획 단계에서 미리 뺍니다.
  • API가 없는 작업은 억지로 자동화하지 않고 수동 절차로 둡니다. 안전하게 판단할 수 있는 것만 자동으로 돌리고, 나머지는 사람이 개입하게 두는 게 낫습니다.
  • 회선 정보는 단말이 접속하는 순간에 그 회선만 조회합니다. 접속이 없는 회선을 위해 저빈도 폴링을 백업으로 둡니다. 상시 폴링으로 전체를 계속 긁지 않습니다.

구조도 같은 생각으로 잡았습니다. 지금은 단말 데이터를 중계 서버를 거쳐서 받습니다. 직접 받으려면 단말 프로토콜을 해석하는 수신 서버를 만들어야 하는데, 지금 거기에 개발 기간을 쓸 때는 아니라고 봤습니다. 대신 단말의 서버 주소는 원격으로 바꿀 수 있습니다.

지금     [단말] ──> [중계 서버] ──> [수신 어댑터 A] ──┐
                                                     ├──> [정규화된 위치 이벤트] ──> 저장 / 관제 / 운행기록
나중에   [단말] ─────────────────> [수신 어댑터 B] ──┘

뒤쪽(저장, 관제, 운행기록)은 "정규화된 위치 이벤트"만 봅니다. 그 이벤트를 누가 만들었는지는 모릅니다. 직접 수신으로 넘어갈 때는 어댑터만 하나 더 만들고 단말의 서버 주소를 원격으로 돌리면 됩니다. 위에서 말한 의심하는 파서도, 시각 세 개도 전부 이 경계 안에서 처리하고 밖으로는 내보내지 않습니다.

저장도 마찬가지입니다. 단말이 보낸다고 다 저장하지 않습니다. 고객사에 매핑된 단말이고 설정한 수집 시간 안일 때만 저장하고, 나머지는 받아도 버립니다.

마무리

정리하면 이렇습니다.

서버끼리는 당연했던 것단말 앞에서는
요청하면 닿는다단말은 자고 있고, 통신망은 연결을 끊는다
OK면 적용된 거다다시 조회해서 확인할 때까지는 모른다
데이터는 문서대로 온다문서는 가설이고, 실측이 사실이다
시각은 하나다셋이고, 질문마다 다른 시각을 써야 한다
안 오면 끊긴 거다"안 온다"의 기준이 기기마다 다르다
버그는 고쳐서 배포한다펌웨어는 우리 배포 주기에 없다

하드웨어를 붙인다는 건 제가 통제할 수 없는 걸 시스템 안으로 들이는 일이었던 거 같습니다. 통제할 수 없는 걸 통제하려고 애쓰기보다, 어디까지가 확인된 사실인지 선을 긋고 그 선 안쪽에서만 약속하는 게 결국 제일 빨랐습니다.

다음 단말을 붙일 때는 실측 체크리스트부터 돌릴 생각입니다. 비슷한 일 하시는 분들께 도움이 됐으면 좋겠습니다. 다르게 풀어보신 분 계시면 편하게 말씀 주세요!

목록으로

우리 현장에 맞는 서비스를 찾아보세요

운영 방식에 맞는 플랫폼을 제안해드립니다