TL;DR
- URL 쿼리는 항상 화면 상태가 아니다. NFC 태그가 만든 일회성 자격증명일 수 있다.
- SDM/SUN 계열 태그는 읽을 때마다 동적 파라미터를 만들고, 서버는 카운터로 재사용을 막을 수 있다.
- 자격증명 쿼리를 라우팅 중 계속 붙이면 같은 값을 재사용하게 되어 보안 모델과 충돌한다.
- 같은 쿼리를 다시 보내도 되는 값은 URL 상태로 둘 수 있고, 다시 보내면 안 되는 값은 진입 요청에서만 써야 한다.
NFC 웹 프로젝트에서 태그를 찍으면 브라우저가 URL을 연다. 이때 URL에 담긴 값이 화면 상태인지 자격증명인지에 따라 라우팅 전략이 달라진다. 두 프로젝트는 겉으로 비슷했다.
| 프로젝트 | 진입 URL |
|---|---|
| 첫 번째 NFC 웹 | /?picc_data=...&enc=...&cmac=... |
| 두 번째 NFC 키링 웹 | /keyring?pstNo=123 |
첫 번째 프로젝트에서는 파일 라우팅을 줄이고 화면 상태를 클라이언트 store로 내렸다. 두 번째 프로젝트에서는 router.push('/keyring?pstNo=123')를 그대로 쓸 수 있었다. 차이는 쿼리의 의미였다.
고정 URL과 동적 URL
NFC 태그에는 NDEF URI 레코드를 쓴다. 일반적인 태그라면 같은 URL이 열린다.
/keyring?pstNo=123
이 값은 화면 상태다. 같은 URL을 다시 열어도 같은 콘텐츠를 보여주면 된다. 반면 SDM(Secure Dynamic Messaging) 또는 SUN(Secure Unique NFC) 계열 동작에서는 태그가 열어주는 URL이 매번 달라질 수 있다.
/?picc_data=<hex>&enc=<hex>&cmac=<hex>
태그는 내부 UID와 읽기 카운터를 암호화하거나 MAC과 함께 URL에 넣는다. 서버는 이 값을 복호화·검증하고, 저장된 카운터와 비교해 재사용을 막는다.
NFC tag read
-> picc_data / enc / cmac 생성
-> browser GET
-> server decrypt/verify
-> uid, counter 확인
-> 저장된 counter 갱신
이 구조에서 쿼리는 화면 상태가 아니라 일회성 자격증명이다.
카운터 기반 재사용 방지
서버의 검증은 대략 이런 모양이었다.
const { uid, count } = await decryptTagPayload({ picc, enc, cmac });
const row = await findByUid(uid);
if (row.count <= count) { await updateCount({ uid, count: count + 1 }); return { ok: true }; }
return { ok: false };
첫 요청이 통과하면 서버에 저장된 count는 count + 1이 된다. 같은 URL을 다시 보내면 태그가 준 count는 그대로이고, 서버 값은 이미 더 커져 있다. 두 번째 요청은 실패한다. 이게 보안 모델이다. 복사된 URL이나 캡처된 파라미터를 다시 보내도 통과하지 않게 만든다. 따라서 라우팅할 때마다 같은 쿼리를 다시 붙이는 방식은 이 보안 모델과 충돌한다.
라우팅이 자격증명을 소모하는 방식
파일 라우팅으로 화면을 나누면 경로 이동이 생긴다.
/?picc_data=...&enc=...&cmac=...
-> /create-profile
-> /fortune
-> /result
이동 후 주소창에는 진입 쿼리가 사라진다. 새로고침이나 뒤로가기가 발생하면 서버는 더 이상 자격증명을 받지 못한다. 반대로 쿼리를 계속 붙이는 래퍼를 만들면 다른 문제가 생긴다.
function pushWithTagParams(url: string) {
const next = new URL(url, location.origin);
next.searchParams.set("picc_data", picc);
next.searchParams.set("enc", enc);
next.searchParams.set("cmac", cmac);
return router.push(next.toString());
}
이 코드는 모든 화면 이동마다 같은 자격증명을 다시 보낸다. 첫 요청에서 카운터를 갱신했다면 바로 다음 요청부터 재사용 공격과 같은 모양이 된다. 보안 검사를 유지하려면 같은 자격증명을 다시 보내면 안 된다. 같은 자격증명을 계속 보내려면 카운터 검사를 포기해야 한다. 둘은 동시에 만족하기 어렵다.
화면 상태로 내리기
첫 번째 프로젝트에서는 라우트 파일을 줄이고 하나의 페이지 안에서 화면 상태만 바꾸는 방식으로 정리했다.
type Screen = "/" | "/create-profile" | "/fortune" | "/result";
const useScreenStore = create<{
screen: Screen;
go: (screen: Screen) => void;
}>((set) => ({
screen: "/",
go: (screen) => set({ screen }),
}));
서버 요청은 NFC 진입 한 번에서만 발생한다.
NFC URL 진입
-> getServerSideProps에서 복호화/카운터 검증
-> 통과하면 client screen store로 내부 화면 전환
-> 이후 화면 이동은 서버 자격증명 재검증 없음
이 선택에는 포기하는 것도 있다.
| 포기한 것 | 이유 |
|---|---|
| 새로고침 복구 | 진입 URL을 다시 보내면 카운터에 막힘 |
| 화면별 딥링크 | 화면 상태가 URL에 없음 |
| 브라우저 뒤로가기 | 내부 screen state와 history가 분리됨 |
보안 요구가 더 강하면 이 제약을 UX 사양으로 받아들여야 한다. 사용자가 새로고침하거나 뒤로가기를 누르면 태그를 다시 찍게 하는 쪽이 보안 모델과 맞다.
상태 쿼리
두 번째 NFC 키링 프로젝트의 쿼리는 성격이 달랐다.
/keyring?pstNo=123
pstNo는 특정 콘텐츠를 고르는 화면 상태다. 같은 값을 다시 보내도 같은 결과가 나와야 한다. 재사용을 막는 자격증명이 아니다. 이 경우 URL 상태로 두는 것이 자연스럽다.
router.push(`/keyring?pstNo=${itemNo}`);
새로고침, 공유, 뒤로가기도 같은 모델 안에서 동작한다.
| 쿼리 | 같은 값 재요청 | URL 상태 적합성 |
|---|---|---|
pstNo=123 |
같은 콘텐츠 조회 | 적합 |
picc_data&enc&cmac |
재사용으로 거부 | 부적합 |
판단 기준
URL에 값을 둘 때 먼저 물어야 하는 질문은 하나다.
이 값을 그대로 다시 보내도 되는가?
답이 예라면 화면 상태다. 라우팅, 새로고침, 공유 링크에 실을 수 있다. 답이 아니라면 자격증명이다. 진입 요청에서만 소모하고, 이후 화면 전환은 다른 상태 모델로 내려야 한다.
상태 쿼리
-> 재요청 가능
-> URL에 유지
-> history와 공유 가능
자격증명 쿼리
-> 재요청 불가
-> 서버 진입에서 소모
-> 내부 상태로 화면 전환
정리
NFC 태그가 여는 URL은 단순 링크일 수도 있고, 일회성 자격증명일 수도 있다. 둘을 같은 라우팅 상태로 다루면 문제가 생긴다. ?pstNo=123은 URL에 남겨도 된다. 같은 값을 다시 보내도 같은 콘텐츠를 고르는 상태이기 때문이다. ?picc_data=...&enc=...&cmac=...는 URL에 계속 붙이면 안 된다. 같은 값을 다시 보내는 순간 재사용 방지 모델과 충돌한다. 라우팅 전략은 프레임워크 취향보다 쿼리의 의미가 먼저 정한다. URL에 담긴 값이 상태인지 자격증명인지부터 나눠야 한다.