본문 바로가기
Backend/Architecture

[Architecture] 엣지 서버의 로컬 ID와 전역 ID 충돌을 피하는 방법

미지시료 2026. 9. 20.

하나의 엣지 서버가 자기 데이터와 중앙 플랫폼의 다른 운영 단위 데이터를 함께 조회할 때, 같은 숫자를 가진 로컬 ID와 전역 ID를 API 경계에서 분리한 과정을 정리했다.





시작하며

엣지 서버는 인터넷 연결이 끊겨도 자기 데이터를 조회할 수 있도록 로컬 MySQL을 가지고 있다. 온라인일 때는 중앙 플랫폼을 통해 다른 운영 단위의 데이터도 조회한다.

처음에는 요청의 site_id가 로컬 DB의 site.id와 같으면 자기 데이터, 다르면 다른 운영 단위의 데이터라고 판단했다.

site_id == local site.id → 로컬 조회
site_id != local site.id → 중앙에서 위치 확인 후 원격 조회

단일 서버만 볼 때는 자연스러운 규칙처럼 보였다. 그러나 온라인 조회를 붙이면서 이 방식에는 식별자 공간이 섞여 있다는 문제가 드러났다.

엣지 서버의 로컬 DB
└── site.id = 1      → 엣지 서버 자신

중앙 플랫폼
└── site_id = 1      → 다른 운영 단위

두 값은 숫자만 같을 뿐 서로 다른 시스템이 독립적으로 발급한 ID다. 클라이언트가 site_id=1을 보내면 엣지 서버 자신과 중앙 플랫폼의 1번 운영 단위 중 어느 쪽을 뜻하는지 구분할 수 없었다.

이 문제는 DB의 ID 값을 바꾸는 대신 API에서 사용하는 식별자 공간을 분리하는 방식으로 해결했다.





조회 결과가 정상처럼 보여도 대상이 틀릴 수 있다

이 문제에서 중요하게 본 것은 응답 성공 여부가 아니라 실제로 선택한 데이터 저장소였다. 로컬 DB의 1번과 중앙의 1번에 서로 다른 데이터가 있어도, 숫자 비교만으로 로컬에 분기하면 요청은 정상적으로 처리된 것처럼 보인다.

예를 들어 로컬에 LOCAL_ONLY, 중앙의 1번에 REMOTE_ONLY라는 서로 다른 값을 두고 비교하면 차이가 분명해진다.

클라이언트의 의도 기존 요청 기존 분기에서 생기는 문제 분리한 요청
현재 엣지 서버 조회 site_id=1 로컬 조회로 처리되지만 전역 ID와 구분되지 않음 site_id=0
중앙의 1번 대상 조회 site_id=1 로컬 조회로 잘못 처리될 수 있음 site_id=1

이제 확인할 결과도 명확하다. 0 요청에서는 LOCAL_ONLY, 1 요청에서는 REMOTE_ONLY가 나와야 한다. 두 요청이 모두 HTTP 200이라는 사실만으로는 충돌 해결을 확인할 수 없다.

같은 숫자라도 같은 식별자는 아니다

ID가 충돌한 이유는 발급 주체가 달랐기 때문이다.

구분 발급 주체 유효 범위 의미
로컬 ID 각 엣지 서버의 DB 해당 서버 내부 로컬 테이블과 관계를 연결하는 값
전역 ID 중앙 플랫폼 전체 시스템 다른 운영 단위와 구분하는 값
엣지 장치 ID 설치 및 등록 시스템 전체 시스템 중앙에서 어느 엣지 서버인지 식별하는 값

로컬 ID 1은 해당 DB 안에서만 유일하다. 다른 엣지 서버에도 1이 있을 수 있고, 중앙 플랫폼의 전역 ID 1과도 관계가 없다.

문제는 API가 두 ID를 모두 site_id라는 같은 필드로 받으면서도 어느 공간의 값인지 표시하지 않았다는 점이었다. 값만 비교해서는 출처와 의미를 복구할 수 없다.





검토했던 해결 방법

로컬 ID를 그대로 외부에 노출하기

기존 구현을 가장 적게 바꾸는 방법이다. 하지만 로컬 ID와 같은 전역 ID가 존재하는 순간 요청의 의미가 모호해진다.

로컬 DB를 먼저 찾는 규칙을 추가해도 문제는 남는다. 중앙 플랫폼의 전역 ID 1을 요청해도 로컬 데이터가 반환되므로, 정상 요청이 조용히 다른 데이터로 바뀐다. 오류가 발생하는 것보다 발견하기 어렵다.

로컬 DB의 ID를 전역 ID로 바꾸기

로컬 DB의 모든 FK와 데이터를 전역 ID에 맞춰 이관하면 숫자 충돌은 줄어든다. 그러나 엣지 서버는 중앙 등록이 완료되기 전이나 오프라인 상태에서도 설치되고 동작할 수 있어야 했다.

전역 ID를 받는 시점에 따라 설치 절차가 달라지고, 이미 운영 중인 로컬 DB의 PK와 FK를 함께 변경해야 한다. 단순한 API 구분 문제를 데이터 마이그레이션 문제로 키우게 된다.

ID와 출처를 객체로 전달하기

{
  "scope": "local",
  "siteId": 1
}

의미는 가장 명확하지만 기존 클라이언트와 모든 API 계약을 크게 바꿔야 했다. 당시에는 대부분의 요청과 차트 설정이 숫자형 site_id를 전제로 하고 있어 변경 범위가 컸다.

결국 기존 숫자형 계약을 유지하면서도 두 공간을 확실히 나눌 수 있는 예약값을 사용했다.





API에서는 0을 엣지 서버 자신으로 고정했다

클라이언트와 엣지 API 사이에서는 site_id=0을 “현재 접속한 엣지 서버 자신”이라는 뜻으로 사용한다.

export const LOCAL_SITE_ID = 0;

규칙은 다음과 같다.

요청 대상 API에서 사용하는 값 실제 조회 위치
엣지 서버 자신 0 로컬 DB 또는 로컬 데이터 서비스
다른 운영 단위 중앙에서 발급한 실제 전역 ID 해당 운영 단위의 데이터 노드

0은 로컬 DB의 실제 ID가 아니다. API 경계에서만 사용하는 Sentinel Value다. 중앙 플랫폼의 실제 ID는 양수라는 전제에서 예약했다. 이 전제가 바뀌어 중앙에서도 0을 발급할 수 있게 되면 계약을 다시 설계해야 한다.

또한 0은 접속한 서버에 따라 대상이 달라지는 상대적인 값이다. 엣지 서버 A의 0과 엣지 서버 B의 0은 같은 대상을 뜻하지 않는다. 여러 서버의 응답을 한곳에 캐시하거나 합칠 때는 서버 식별자까지 키에 포함해야 한다. 숫자 충돌을 없앤 것이 아니라 로컬이라는 의미를 명시적으로 예약한 것이다.

클라이언트
├── site_id = 0  ──→ 엣지 API ──→ 로컬 데이터
└── site_id = 27 ──→ 엣지 API ──→ 중앙에서 위치 확인 ──→ 원격 데이터

이 규칙을 적용하면서 세 가지 경계도 함께 정했다.

  • 0은 클라이언트와 엣지 서버 내부 API에서만 사용한다.
  • 중앙 API를 호출할 때는 대상의 실제 전역 ID만 전달한다.
  • 중앙에서 엣지 서버 자체를 식별할 때는 로컬 운영 단위 ID가 아니라 별도의 엣지 장치 ID를 사용한다.

즉, 0을 전역 ID로 변환해 중앙으로 보내는 것이 아니다. 중앙 플랫폼에 “0번 운영 단위”가 존재하는 것처럼 만들지 않고, 엣지 서버가 자기 요청을 로컬에서 끝내기 위한 표식으로만 사용한다.





실제 로컬 ID로의 변환은 DB 직전에만 한다

외부 계약을 0으로 통일해도 로컬 DB의 값까지 바꿀 필요는 없다. 로컬 테이블을 실제로 조회하는 시점에만 0을 로컬 DB의 ID로 변환한다.

async function resolveQueryTarget(siteId: number) {
  if (siteId === LOCAL_SITE_ID) {
    const localSite = await getConfiguredLocalSite();
    if (!localSite) {
      throw new Error('로컬 대상 설정을 찾을 수 없습니다.');
    }

    return {
      type: 'local',
      databaseSiteId: localSite.id,
    };
  }

  return {
    type: 'remote',
    globalSiteId: siteId,
  };
}

여기서 로컬 대상은 현재 엣지 서버에 연결된 설정으로 찾는다. 테이블의 첫 행을 임의로 선택하거나 로컬 ID를 항상 1로 가정하는 방식은 피해야 한다. 로컬 설정이 없다면 중앙 조회로 우회하지 않고 설정 오류로 처리하는 편이 요청의 의미를 지킬 수 있다.

이렇게 변환 지점을 늦춘 이유는 ID의 의미가 섞이는 구간을 줄이기 위해서다.

HTTP 요청과 응답, 화면 상태, 차트 설정
└── 0은 언제나 엣지 서버 자신

로컬 Repository 호출 직전
└── 필요한 경우에만 0을 실제 local site.id로 변환

중앙 및 원격 API 호출
└── 실제 global site_id만 사용

조회 가능한 운영 단위 목록을 내려줄 때도 엣지 서버 자신의 실제 로컬 ID를 노출하지 않고 0으로 응답했다. 클라이언트는 목록에서 받은 값을 그대로 다른 API에 사용할 수 있고, 로컬 DB 구조를 알 필요가 없다.





Trino 쿼리는 라우팅 후 조건을 다시 써야 했다

일반 REST API는 서비스 계층에서 0을 변환하면 됐지만, 사용자가 작성한 SQL을 여러 데이터 노드로 보내는 Trino 경로는 한 단계 더 필요했다.

다음 조건은 어느 데이터를 조회할지 결정하는 라우팅 정보다.

SELECT measured_at, value
  FROM current_values
 WHERE site_id = 0;

라우팅 단계에서는 site_id=0을 보고 로컬 카탈로그를 선택할 수 있다. 그러나 이 SQL을 로컬 DB에 그대로 실행하면 두 가지 문제가 생겼다.

로컬 테이블 구조 site_id = 0을 그대로 실행한 결과 처리 방식
site_id 컬럼이 없음 Unknown column 오류 라우팅 후 조건 제거
site_id 컬럼이 있고 실제 로컬 ID를 저장 조건에 맞는 행이 없어 0건 실제 로컬 ID로 치환

따라서 쿼리 블록이 로컬 대상을 가리킨다는 사실을 먼저 확정한 뒤 조건을 수정했다.

1. SQL에서 site_id를 읽어 대상 노드를 결정한다.
2. site_id가 0이면 로컬 카탈로그를 선택한다.
3. 대상 테이블에 site_id가 없으면 해당 조건을 제거한다.
4. 컬럼이 있으면 0을 실제 로컬 ID로 바꾼다.
5. 변경한 SQL을 Trino에 전달한다.

조건을 제거한다는 말을 문자열에서 site_id = 0을 지운다는 뜻으로 받아들이면 안 된다. 로컬 전용 테이블이라 해당 조건이 불필요하다는 사실과 조건이 속한 쿼리 블록을 먼저 알아야 한다.

특히 site_id = 0 OR value > 10에서 왼쪽 조건만 지우면 원래 의도와 다른 필터가 된다. JOIN이나 서브쿼리도 조건의 적용 대상이 달라질 수 있다. 라우팅 조건과 데이터 필터를 구분할 수 없는 형태는 추측해서 변환하지 말아야 한다.

여러 테이블이 있는데 Alias 없이 site_id=0만 적혀 어느 테이블의 조건인지 알 수 없는 경우에는 추측해서 고치지 않았다. 잘못된 조건을 제거해 다른 범위의 데이터를 반환하는 것보다 쿼리를 실패시키는 편이 안전하다고 판단했다.





월별 테이블 이름에서도 같은 규칙을 적용했다

월별로 분리된 시계열 테이블은 테이블명 자체에 대상 ID가 포함될 수 있다.

measurements_0_2026-09
measurements_2026-09
measurements_27_2026-09

엣지 서버 자신을 가리키는 표기가 두 종류였다.

  • API 규칙에 맞춰 0을 명시한 measurements_0_2026-09
  • 로컬 DB의 기존 물리 이름인 measurements_2026-09

파서는 두 형식을 모두 LOCAL_SITE_ID로 해석하도록 했다. 반면 measurements_27_2026-09처럼 숫자가 들어간 이름은 실제 전역 ID로 유지한다.

이 처리를 빠뜨리면 같은 로컬 테이블을 일반 조회에서는 로컬로 판단하면서, 월별 조회에서는 대상 ID를 찾지 못하거나 원격 테이블로 판단하는 불일치가 생길 수 있다.





모든 데이터에 0을 기본값으로 쓰면 안 된다

관측 데이터는 ID를 생략하면 현재 엣지 서버의 데이터라고 해석할 수 있었다. 그러나 중앙에서 여러 운영 단위에 공통으로 제공하는 공유 데이터는 의미가 달랐다.

관측 데이터
└── ID 생략 가능 → 현재 엣지 서버 자신(0)

중앙 공유 데이터
└── ID 생략 불가 → 실제 전역 ID 필수

공유 데이터는 로컬 운영 단위의 테이블이 아니며, 같은 자료라도 어느 전역 운영 단위의 조건으로 조회하는지가 중요했다. 여기에 0을 넣으면 중앙에도 로컬 Sentinel의 의미가 있다고 오해하게 된다.

그래서 데이터 식별자를 파싱할 때 관측 데이터에만 ID 생략을 허용하고, 공유 데이터에는 실제 전역 ID를 필수로 받았다. Sentinel은 편리한 기본값이 아니라 정해진 경계 안에서만 의미가 있는 계약이어야 했다.





적용 뒤 발견한 두 가지 실수

타입 표기만으로 URL 파라미터가 숫자가 되지는 않았다

NestJS Controller에서 다음과 같이 선언하면 siteId가 숫자로 들어올 것이라고 생각했다.

async getDevices(@Param('site_id') siteId: number) {
  return this.service.getDevices(siteId);
}

하지만 TypeScript의 number는 컴파일 시점의 타입일 뿐이다. URL Path Parameter는 런타임에 문자열로 들어왔다.

'0' === 0; // false

그 결과 엣지 서버 자신을 요청한 "0"LOCAL_SITE_ID와 일치하지 않아 원격 요청으로 분기됐고, 중앙 API에서 권한 오류가 발생했다. Controller 경계에서 ParseIntPipe를 적용해 실제 숫자로 변환했다.

async getDevices(
  @Param('site_id', ParseIntPipe) siteId: number,
) {
  return this.service.getDevices(siteId);
}

Sentinel 비교는 값뿐 아니라 타입까지 고정돼야 했다. NestJS의 ParseIntPipe는 정수로 변환할 수 없는 입력을 핸들러 실행 전에 거절하는 역할도 한다.

다만 정수 변환과 업무 규칙 검증은 별개다. 음수는 이 API의 대상 ID로 허용하지 않고, 0 또는 허용 범위의 양수인지 추가로 검사해야 한다. if (!siteId)처럼 검사하면 유효한 예약값 0도 누락으로 취급하므로, 값이 없는 상태와 0도 구분해야 한다.

중앙 목록을 합치면서 로컬 항목이 빠졌다

온라인 상태에서는 중앙에서 접근 가능한 운영 단위 목록을 받아 로컬 항목과 합친다. 처음에는 중앙 응답을 기준으로 목록을 만들고, 그 안에서 로컬 주소를 가진 항목을 0으로 바꿨다.

문제는 권한 그룹으로 조회하는 중앙 API가 엣지 서버 자신의 항목을 반환하지 않는 경우였다. 인터넷이 연결되자 오히려 목록에서 site_id=0이 사라졌다.

해결 방법은 로컬 항목을 중앙 응답에서 찾는 것이 아니라 항상 먼저 만드는 것이었다.

const result = [await getLocalSiteAsZero()];

try {
  const remoteSites = await getAccessibleRemoteSites();
  result.push(...remoteSites);
} catch {
  // 오프라인이어도 로컬 항목은 그대로 반환한다.
}

온라인 목록은 로컬 목록을 대체하지 않고 확장해야 했다. 이 구조로 바꾸자 중앙 요청이 실패해도 엣지 서버 자신의 항목은 항상 유지됐다.

다만 로컬 항목을 먼저 넣는 것만으로 중복까지 해결되는 것은 아니다. 중앙 응답에 자기 항목이 포함되면 엣지 장치 ID처럼 출처가 명확한 식별자로 제외해야 한다. 로컬 DB의 숫자 ID가 같다는 이유로 제외하면 처음의 충돌 문제가 다시 생긴다.

위 코드는 로컬 우선 병합 흐름에 집중한 형태다. 운영에서는 중앙 요청 실패를 모두 조용히 숨기지 않고, 연결 실패와 인증 실패를 구분해 기록해야 한다. 로컬 조회를 계속 제공하는 것과 중앙 장애를 알아채지 못하는 것은 다른 문제다.





검증한 시나리오

시나리오 기대 결과
site_id=0으로 로컬 데이터 요청 중앙을 거치지 않고 로컬 응답
로컬 실제 ID와 같은 전역 ID 요청 로컬로 오인하지 않고 해당 원격 데이터 조회
중앙 연결이 끊긴 상태에서 목록 요청 로컬 항목 0만이라도 응답
URL에 site_id=0 전달 문자열이 아닌 숫자로 변환된 뒤 로컬 분기
여러 운영 단위 목록 요청 로컬 0을 한 번만 포함하고 원격 항목 병합
measurements_0_YYYY-MM 조회 로컬 월별 테이블로 변환
measurements_YYYY-MM 조회 ID가 생략돼도 로컬 월별 테이블로 해석
중앙 공유 데이터에 ID 생략 잘못된 기본값을 넣지 않고 요청 거부

특히 충돌 테스트에서는 로컬 DB의 실제 ID와 같은 번호의 전역 운영 단위를 준비해 각각 다른 결과가 나오는지 확인해야 한다. 단순히 0 요청만 성공하는 것으로는 원래 문제를 검증할 수 없다.





ID 공간을 분리할 때 지킬 경계

경계 적용할 규칙 놓치기 쉬운 부분
요청 입력 0과 양수 ID를 구분하고 런타임 타입을 정규화 숫자 0을 falsy로 판단해 누락 처리하지 않음
로컬 조회 저장소 접근 직전에 설정된 로컬 ID로 변환 로컬 ID를 1로 하드코딩하지 않음
중앙 호출 실제 전역 ID만 전달 로컬 요청 실패를 중앙의 0번 요청으로 바꾸지 않음
목록 응답 로컬 0을 먼저 유지하고 원격 항목을 병합 자기 항목 중복은 장치 식별자로 구분
SQL 변환 대상과 테이블 구조를 확정한 뒤 조건 변경 OR, JOIN, 서브쿼리 조건을 단순 삭제하지 않음
월별 조회 명시한 0과 ID 생략 표기의 의미를 통일 API 이름과 실제 물리 테이블명을 구분
공유 데이터 실제 전역 ID를 요구 모든 데이터에 0을 기본값으로 적용하지 않음
캐시와 저장 설정 접속 서버와 ID를 함께 보존 다른 서버의 0을 같은 대상으로 합치지 않음

ID를 분리해도 접근 권한 검증이 사라지는 것은 아니다. 양수 전역 ID를 받았다는 사실은 조회 대상을 지정할 뿐이다. 해당 대상의 데이터를 볼 권한은 별도로 확인해야 한다.





마치며

로컬 ID와 전역 ID의 충돌은 단순히 숫자가 겹치는 문제가 아니었다. 서로 다른 발급 주체의 식별자를 하나의 API 필드에서 구분 없이 사용한 것이 원인이었다.

엣지 서버 자신은 0, 다른 운영 단위는 실제 전역 ID로 표현하면서 API의 식별자 공간을 분리했다. 로컬 DB의 ID는 저장 구조 안에 그대로 두고, 실제 조회 직전에만 변환했다. 덕분에 기존 데이터와 FK를 이관하지 않으면서도 자기 데이터와 원격 데이터를 명확하게 구분할 수 있었다.

다만 상수 하나를 추가하는 것으로 끝나지는 않았다. REST 경로의 문자열 타입, 목록 병합, Trino SQL 조건, 월별 테이블 이름, 공유 데이터 예외까지 같은 계약을 적용해야 했다.

분산된 시스템에서 ID의 숫자만 전달해서는 의미가 충분하지 않다. 발급 범위까지 계약에 드러내거나, 기존 계약을 유지해야 한다면 충돌하지 않는 명시적인 Namespace를 만들어야 한다.