Get your free and exclusive 80-page Banking Passkey Report
Back to Overview

디지털 자격증명 검증기(Verifier) 구축 방법 (개발자 가이드)

Next.js, OpenID4VP, ISO mDoc을 사용하여 디지털 자격증명 검증기를 처음부터 구축하는 방법을 배워보세요. 이 단계별 개발자 가이드는 모바일 운전면허증 및 기타 디지털 자격증명을 요청, 수신, 검증할 수 있는 검증기 제작 방법을 안내합니다.

Amine

Created: August 20, 2025

Updated: August 21, 2025

Blog-Post-Header-Image

See the original blog version in English here.

DigitalCredentialsDemo Icon

Want to experience digital credentials in action?

Try Digital Credentials

1. 소개#

온라인에서 신원을 증명하는 것은 늘 어려운 과제입니다. 이 때문에 비밀번호에 의존하거나, 안전하지 않은 채널로 민감한 문서를 공유하는 일이 많아졌습니다. 이는 기업의 신원 확인 절차를 느리고, 비용이 많이 들며, 사기에 취약하게 만들었습니다. 디지털 자격증명은 사용자가 자신의 데이터를 다시 제어할 수 있게 하는 새로운 접근 방식을 제공합니다. 이는 운전면허증부터 대학 학위까지 모든 것을 담을 수 있는 물리적인 지갑의 디지털 버전과 같습니다. 여기에 암호학적으로 안전하고, 개인 정보를 보호하며, 즉시 검증할 수 있다는 장점이 더해집니다.

이 가이드는 개발자에게 디지털 자격증명 검증기를 구축하기 위한 실용적인 단계별 튜토리얼입니다. 표준은 존재하지만 이를 구현하는 방법에 대한 안내는 거의 없습니다. 이 튜토리얼은 브라우저의 네이티브 디지털 자격증명 API, 프레젠테이션 프로토콜을 위한 OpenID4VP, 그리고 자격증명 형식으로서 ISO mDoc(예: 모바일 운전면허증)을 사용하여 검증기를 구축하는 방법을 보여줌으로써 그 격차를 메웁니다.

최종 결과물은 호환되는 모바일 지갑에서 디지털 자격증명을 요청, 수신 및 검증할 수 있는 간단하면서도 기능적인 Next.js 애플리케이션이 될 것입니다.

최종 애플리케이션이 실제로 작동하는 모습을 잠시 살펴보겠습니다. 프로세스는 주로 네 단계로 이루어집니다.

1단계: 초기 페이지 사용자가 초기 페이지에 접속하여 "디지털 신원으로 확인"을 클릭하여 프로세스를 시작합니다.

2단계: 신뢰 확인 프롬프트 브라우저가 사용자에게 신뢰 여부를 묻습니다. 사용자는 "계속"을 클릭하여 진행합니다.

3단계: QR 코드 스캔 QR 코드가 표시되면, 사용자는 호환되는 지갑 애플리케이션으로 스캔합니다.

4단계: 디코딩된 자격증명 성공적으로 검증되면 애플리케이션은 디코딩된 자격증명 데이터를 표시합니다.

1.1 작동 방식#

디지털 자격증명의 핵심은 세 가지 주요 주체가 참여하는 간단하지만 강력한 "신뢰 삼각형(trust triangle)" 모델에 있습니다.

  • 발급기관(Issuer): 사용자에게 자격증명을 암호학적으로 서명하여 발급하는 신뢰할 수 있는 기관(예: 정부 기관, 대학, 은행)입니다.
  • 소유자(Holder): 자격증명을 받아 자신의 기기에 있는 개인 디지털 지갑에 안전하게 저장하는 사용자입니다.
  • 검증기(Verifier): 사용자의 자격증명을 확인해야 하는 애플리케이션 또는 서비스입니다.

사용자가 서비스에 접근하고자 할 때, 지갑에서 자격증명을 제시합니다. 그러면 검증기는 원래의 발급기관에 직접 연락할 필요 없이 즉시 그 진위성을 확인할 수 있습니다.

1.2 왜 검증기가 필수적인가 (그리고 여러분이 이 글을 읽는 이유)#

탈중앙화 신원(decentralized identity) 생태계가 활성화되려면 검증기의 역할이 절대적으로 중요합니다. 검증기는 이 새로운 신뢰 인프라의 게이트키퍼(gatekeeper)이며, 자격증명을 소비하고 현실 세계에서 유용하게 만드는 주체입니다. 아래 다이어그램에서 볼 수 있듯이, 검증기는 소유자로부터 자격증명을 요청, 수신 및 검증함으로써 신뢰 삼각형을 완성합니다.

개발자라면, 이러한 검증을 수행하는 서비스를 구축하는 것은 차세대 보안 및 사용자 중심 애플리케이션을 위한 기초 기술입니다. 이 가이드는 바로 그 과정을 안내하기 위해 설계되었습니다. 핵심 개념과 표준부터 서명 유효성 검사 및 자격증명 상태 확인에 대한 단계별 구현 세부 정보까지, 자신만의 검증 가능한 자격증명 검증기를 구축하는 데 필요한 모든 것을 다룰 것입니다.

바로 코드를 보고 싶으신가요? 이 튜토리얼의 완성된 전체 프로젝트는 GitHub에서 찾을 수 있습니다. 직접 클론하여 실행해 보세요: https://github.com/corbado/digital-credentials-example

이제 시작하겠습니다.

2. 검증기 구축을 위한 사전 요구사항#

시작하기 전에 다음 사항을 확인하세요.

  1. 디지털 자격증명 및 mdoc에 대한 기본적 이해
    • 이 튜토리얼은 ISO mDoc 형식(예: 모바일 운전면허증)에 초점을 맞추며 W3C 검증 가능한 자격증명(VC)과 같은 다른 형식은 다루지 않습니다. mdoc의 기본 개념에 익숙하면 도움이 될 것입니다.
  2. Docker와 Docker Compose
    • 저희 프로젝트는 OIDC 세션 상태를 관리하기 위해 Docker 컨테이너의 MySQL 데이터베이스를 사용합니다. 둘 다 설치하고 실행 중인지 확인하세요.
  3. 선택한 프로토콜: OpenID4VP
    • 자격증명 교환 흐름에는 OpenID4VP(OpenID for Verifiable Presentations) 프로토콜을 사용할 것입니다.
  4. 기술 스택 준비
    • 백엔드 로직에는 TypeScript(Node.js)를 사용합니다.
    • 백엔드(API 라우트)와 프론트엔드(UI) 모두에 Next.js를 사용합니다.
    • 주요 라이브러리: mdoc 파싱을 위한 CBOR 디코딩 라이브러리 및 MySQL 클라이언트.
  5. 테스트용 자격증명과 지갑
    • 저희는 Android용 **CMWallet**을 사용할 것입니다. 이 지갑은 OpenID4VP 요청을 이해하고 mdoc 자격증명을 제시할 수 있습니다.
  6. 기초 암호학 지식
    • mdoc 및 OIDC 흐름과 관련된 디지털 서명 및 공개/개인 키 개념을 이해해야 합니다.

이제 이 mdoc 기반 검증기의 기반이 되는 표준 및 프로토콜부터 시작하여 각 사전 요구사항을 자세히 살펴보겠습니다.

2.1 프로토콜 선택#

저희 검증기는 다음을 위해 구축되었습니다.

표준 / 프로토콜설명
W3C VCW3C 검증 가능한 자격증명 데이터 모델. 클레임, 메타데이터, 증명을 포함한 디지털 자격증명의 표준 구조를 정의합니다.
SD-JWTJWT를 위한 선택적 공개. JSON 웹 토큰을 기반으로 한 VC 형식으로, 소유자가 자격증명에서 특정 클레임만 선택적으로 공개할 수 있게 하여 개인 정보 보호를 강화합니다.
ISO mDocISO/IEC 18013-5. 모바일 운전면허증(mDL) 및 기타 모바일 ID에 대한 국제 표준으로, 오프라인 및 온라인 사용을 위한 데이터 구조와 통신 프로토콜을 정의합니다.
OpenID4VP검증 가능한 프레젠테이션을 위한 OpenID. OAuth 2.0 기반의 상호 운용 가능한 프레젠테이션 프로토콜입니다. 검증기가 자격증명을 요청하고 소유자의 지갑이 이를 제시하는 방법을 정의합니다.

이 튜토리얼에서는 특별히 다음을 사용합니다.

  • 자격증명 요청 및 수신 프로토콜로 OpenID4VP.
  • 자격증명 형식(예: 모바일 운전면허증)으로 ISO mDoc.

범위에 대한 참고: 더 넓은 맥락을 제공하기 위해 W3C VC 및 SD-JWT를 간략하게 소개하지만, 이 튜토리얼은 OpenID4VP를 통해 ISO mDoc 자격증명만 구현합니다. W3C 기반 VC는 이 예제의 범위를 벗어납니다.

2.1.1 ISO mDoc (Mobile Document)#

ISO/IEC 18013-5 mDoc 표준은 모바일 운전면허증(mDL)과 같은 모바일 문서의 구조와 인코딩을 정의합니다. mDoc 자격증명은 CBOR로 인코딩되고 암호학적으로 서명되며, 검증을 위해 디지털로 제시될 수 있습니다. 저희 검증기는 이러한 mdoc 자격증명을 디코딩하고 검증하는 데 중점을 둘 것입니다.

2.1.2 OpenID4VP (OpenID for Verifiable Presentations)#

OpenID4VP는 OAuth 2.0 및 OpenID Connect 위에 구축된, 디지털 자격증명을 요청하고 제시하기 위한 상호 운용 가능한 프로토콜입니다. 이 구현에서 OpenID4VP는 다음을 위해 사용됩니다.

  • 자격증명 제시 흐름 시작(QR 코드 또는 브라우저 API를 통해)
  • 사용자 지갑에서 mdoc 자격증명 수신
  • 안전하고, 상태를 유지하며, 개인 정보를 보호하는 자격증명 교환 보장

2.2 기술 스택 선택#

이제 표준과 프로토콜에 대한 명확한 이해를 바탕으로 검증기를 구축할 올바른 기술 스택을 선택해야 합니다. 저희의 선택은 견고성, 개발자 경험 및 최신 웹 생태계와의 호환성을 위해 설계되었습니다.

2.2.1 언어: TypeScript#

프론트엔드와 백엔드 코드 모두에 TypeScript를 사용할 것입니다. JavaScript의 상위 집합으로서 정적 타이핑을 추가하여 오류를 조기에 발견하고 코드 품질을 개선하며 복잡한 애플리케이션을 더 쉽게 관리할 수 있도록 도와줍니다. 자격증명 검증과 같은 보안에 민감한 맥락에서 타입 안전성은 엄청난 이점입니다.

2.2.2 프레임워크: Next.js#

Next.js는 풀스택 애플리케이션을 구축하기 위한 원활하고 통합된 경험을 제공하기 때문에 저희가 선택한 프레임워크입니다.

  • 프론트엔드: Next.jsReact를 사용하여 검증 프로세스가 시작되는 사용자 인터페이스(예: QR 코드 표시)를 구축할 것입니다.
  • 백엔드: Next.js API Routes를 활용하여 서버 측 엔드포인트를 생성할 것입니다. 이 엔드포인트는 유효한 OpenID4VP 요청을 생성하고, CMWallet으로부터 최종 응답을 안전하게 수신하고 검증하기 위한 redirect_uri 역할을 담당합니다.

2.2.3 주요 라이브러리#

저희 구현은 프론트엔드와 백엔드를 위해 특정 라이브러리 세트에 의존합니다.

  • next: 백엔드 API 라우트와 프론트엔드 UI 모두에 사용되는 Next.js 프레임워크입니다.
  • reactreact-dom: 프론트엔드 사용자 인터페이스를 구동합니다.
  • cbor-web: CBOR로 인코딩된 mdoc 자격증명을 사용 가능한 JavaScript 객체로 디코딩하는 데 사용됩니다.
  • mysql2: 챌린지 및 검증 세션을 저장하기 위한 MySQL 데이터베이스 연결을 제공합니다.
  • uuid: 고유한 챌린지 문자열(nonces)을 생성하기 위한 라이브러리입니다.
  • @types/uuid: UUID 생성을 위한 TypeScript 타입입니다.

openid-client에 대한 참고: 더 발전된 프로덕션급 검증기는 openid-client 라이브러리를 사용하여 백엔드에서 직접 OpenID4VP 프로토콜을 처리하여 동적 redirect_uri와 같은 기능을 활성화할 수 있습니다. redirect_uri를 사용하는 서버 주도 OpenID4VP 흐름에서 openid-clientvp_token 응답을 직접 파싱하고 검증하는 데 사용됩니다. 이 튜토리얼에서는 프로세스를 더 쉽게 이해할 수 있도록 이를 요구하지 않는 더 간단한 브라우저 중재 흐름을 사용하고 있습니다.

이 기술 스택은 브라우저의 디지털 자격증명 API 및 ISO mDoc 자격증명 형식에 중점을 둔 견고하고, 타입-세이프하며, 확장 가능한 검증기 구현을 보장합니다.

2.3 테스트 지갑 및 자격증명 받기#

검증기를 테스트하려면 브라우저의 디지털 자격증명 API와 상호 작용할 수 있는 모바일 지갑이 필요합니다.

저희는 Android용 견고한 OpenID4VP 호환 테스트 지갑인 **CMWallet**을 사용할 것입니다.

CMWallet 설치 방법 (Android):

  1. Android 기기에서 직접 위의 링크를 사용하여 APK 파일을 다운로드합니다.
  2. 기기의 설정 > 보안을 엽니다.
  3. 파일을 다운로드한 브라우저에 대해 **"알 수 없는 앱 설치"**를 활성화합니다.
  4. "다운로드" 폴더에서 다운로드한 APK를 찾아 탭하여 설치를 시작합니다.
  5. 화면의 지시에 따라 설치를 완료합니다.
  6. CMWallet을 열면 검증 흐름에 사용할 수 있는 테스트 자격증명이 미리 로드되어 있는 것을 확인할 수 있습니다.

참고: 신뢰할 수 있는 출처의 APK 파일만 설치하세요. 제공된 링크는 공식 프로젝트 저장소의 것입니다.

2.4 암호학 지식#

구현에 들어가기 전에, 검증 가능한 자격증명의 기반이 되는 암호학적 개념을 이해하는 것이 중요합니다. 이것이 바로 자격증명을 "검증 가능"하고 신뢰할 수 있게 만드는 요소입니다.

2.4.1 디지털 서명: 신뢰의 기반#

검증 가능한 자격증명의 핵심은 발급기관에 의해 디지털 서명된 클레임(이름, 생년월일 등)의 집합입니다. 디지털 서명은 두 가지 중요한 보증을 제공합니다.

  • 진위성: 자격증명이 사칭자가 아닌 실제로 발급기관에 의해 생성되었음을 증명합니다.
  • 무결성: 자격증명이 서명된 이후로 변경되거나 조작되지 않았음을 증명합니다.

2.4.2 공개/개인 키 암호학#

디지털 서명은 공개/개인 키 암호학(비대칭 암호학이라고도 함)을 사용하여 생성됩니다. 저희의 맥락에서 작동 방식은 다음과 같습니다.

  1. 발급기관은 키 쌍을 가집니다: 비밀로 유지되는 개인 키와 모든 사람에게 공개되는 공개 키(보통 DID 문서를 통해 제공됨)입니다.
  2. 서명: 발급기관이 자격증명을 생성할 때, 개인 키를 사용하여 해당 특정 자격증명 데이터에 대한 고유한 디지털 서명을 생성합니다.
  3. 검증: 저희 검증기가 자격증명을 받으면 발급기관의 공개 키를 사용하여 서명을 확인합니다. 확인이 통과되면 검증기는 자격증명이 진짜이며 조작되지 않았음을 알 수 있습니다. 자격증명 데이터를 조금이라도 변경하면 서명이 무효화됩니다.

DID에 대한 참고: 이 튜토리얼에서는 DID를 통해 발급자 키를 확인하지 않습니다. 프로덕션 환경에서 발급기관은 일반적으로 DID 또는 다른 신뢰할 수 있는 엔드포인트를 통해 공개 키를 노출하며, 검증기는 이를 암호학적 유효성 검사에 사용합니다.

2.4.3 JWT로서의 검증 가능한 자격증명#

검증 가능한 자격증명은 종종 JSON 웹 토큰(JWT) 형식으로 만들어집니다. JWT는 두 당사자 간에 전송될 클레임을 나타내는 간결하고 URL-안전한 방법입니다. 서명된 JWT(JWS라고도 함)는 점(.)으로 구분된 세 부분으로 구성됩니다.

  • 헤더: 사용된 서명 알고리즘(alg)과 같은 토큰에 대한 메타데이터를 포함합니다.
  • 페이로드: issuer, credentialSubject 등을 포함한 검증 가능한 자격증명의 실제 클레임(vc 클레임)을 포함합니다.
  • 서명: 헤더와 페이로드를 포함하여 발급기관이 생성한 디지털 서명입니다.
// JWT 구조의 예 [헤더].[페이로드].[서명]

참고: JWT 기반 검증 가능한 자격증명은 이 블로그 게시물의 범위를 벗어납니다. 이 구현은 ISO mDoc 자격증명 및 OpenID4VP에 중점을 두며, W3C 검증 가능한 자격증명이나 JWT 기반 자격증명은 다루지 않습니다.

2.4.4 검증 가능한 프레젠테이션: 소유 증명#

검증기는 자격증명이 유효하다는 것을 아는 것만으로는 충분하지 않습니다. 자격증명을 제시하는 사람이 합법적인 소유자인지도 알아야 합니다. 이는 누군가 훔친 자격증명을 사용하는 것을 방지합니다.

이것은 **검증 가능한 프레젠테이션(Verifiable Presentation, VP)**을 사용하여 해결됩니다. VP는 하나 이상의 VC를 감싸고 소유자 자신이 서명한 래퍼입니다.

흐름은 다음과 같습니다.

  1. 검증기는 사용자에게 자격증명을 제시하도록 요청합니다.
  2. 사용자의 지갑은 검증 가능한 프레젠테이션을 만들고, 필요한 자격증명을 그 안에 묶은 다음, 소유자의 개인 키를 사용하여 전체 프레젠테이션에 서명합니다.
  3. 지갑은 이 서명된 VP를 검증기에게 보냅니다.

그런 다음 저희 검증기는 두 가지 별도의 서명 확인을 수행해야 합니다.

  1. 자격증명 검증: 프레젠테이션 내의 각 VC에 대한 서명을 발급기관의 공개 키를 사용하여 확인합니다. (자격증명이 진짜임을 증명합니다).
  2. 프레젠테이션 검증: VP 자체의 서명을 소유자의 공개 키를 사용하여 확인합니다. (그것을 제시하는 사람이 소유자임을 증명합니다).

이 2단계 확인은 자격증명의 진위성과 그것을 제시하는 사람의 신원을 모두 보장하여 견고하고 안전한 신뢰 모델을 만듭니다.

참고: W3C VC 생태계에서 정의된 검증 가능한 프레젠테이션의 개념은 이 블로그 게시물의 범위를 벗어납니다. 여기서 검증 가능한 프레젠테이션이라는 용어는 W3C의 JSON-LD 서명 모델이 아닌 ISO mDoc 의미 체계를 기반으로 하는 W3C VP와 유사하게 작동하는 OpenID4VP vp_token 응답을 의미합니다. 이 가이드는 ISO mDoc 자격증명 및 OpenID4VP에 중점을 두며, W3C 검증 가능한 프레젠테이션이나 그 서명 유효성 검사는 다루지 않습니다.

3. 아키텍처 개요#

저희 검증기 아키텍처는 브라우저에 내장된 디지털 자격증명 API를 보안 중개자로 사용하여 웹 애플리케이션과 사용자의 모바일 CMWallet을 연결합니다. 이 접근 방식은 브라우저가 네이티브 QR 코드 표시 및 지갑 통신을 처리하도록 하여 흐름을 단순화합니다.

  • 프론트엔드 (Next.js & React): 가벼운 사용자 대면 웹사이트입니다. 백엔드에서 요청 객체를 가져와 브라우저의 navigator.credentials.get() API에 전달하고, 결과를 받아 검증을 위해 백엔드로 전달하는 역할을 합니다.
  • 백엔드 (Next.js API Routes): 검증기의 핵심 동력입니다. 브라우저 API를 위한 유효한 요청 객체를 생성하고, 최종 검증을 위해 프론트엔드로부터 자격증명 프레젠테이션을 수신하는 엔드포인트를 노출합니다.
  • 브라우저 (Credential API): 조력자입니다. 프론트엔드에서 요청 객체를 받아 openid4vp 프로토콜을 이해하고 네이티브로 QR 코드를 생성합니다. 그런 다음 지갑이 응답을 반환할 때까지 기다립니다.
  • CMWallet (모바일 앱): 사용자의 지갑입니다. QR 코드를 스캔하고 요청을 처리하며 사용자 동의를 얻은 후 서명된 응답을 브라우저로 다시 보냅니다.

다음은 완전하고 정확한 흐름을 보여주는 시퀀스 다이어그램입니다.

흐름 설명:

  1. 시작: 사용자가 프론트엔드에서 "확인" 버튼을 클릭합니다.
  2. 요청 객체: 프론트엔드가 백엔드(/api/verify/start)를 호출하면, 백엔드는 쿼리와 논스(nonce)를 포함하는 요청 객체를 생성하여 반환합니다.
  3. 브라우저 API 호출: 프론트엔드가 요청 객체와 함께 navigator.credentials.get()을 호출합니다.
  4. 네이티브 QR 코드: 브라우저openid4vp 프로토콜 요청을 보고 네이티브로 QR 코드를 표시합니다. .get() 프로미스(promise)는 이제 보류 상태입니다.

참고: 이 QR 코드 흐름은 데스크톱 브라우저에서 발생합니다. 모바일 브라우저(실험적 플래그가 활성화된 Android Chrome)에서는 브라우저가 동일한 기기에 있는 호환되는 지갑과 직접 통신할 수 있어 QR 코드 스캔이 필요 없습니다. Android Chrome에서 이 기능을 활성화하려면 chrome://flags#web-identity-digital-credentials로 이동하여 플래그를 "Enabled"로 설정하세요.

  1. 스캔 및 제시: 사용자가 CMWallet으로 QR 코드를 스캔합니다. 지갑은 사용자 승인을 받고 검증 가능한 프레젠테이션을 브라우저로 다시 보냅니다.
  2. 프로미스 해결: 브라우저가 응답을 수신하고 프론트엔드의 원래 .get() 프로미스가 마침내 해결되어 프레젠테이션 페이로드를 전달합니다.
  3. 백엔드 검증: 프론트엔드가 프레젠테이션 페이로드를 백엔드의 /api/verify/finish 엔드포인트로 POST합니다. 백엔드는 논스와 자격증명을 검증합니다.
  4. 결과: 백엔드는 최종 성공 또는 실패 메시지를 프론트엔드로 반환하고, 프론트엔드는 UI를 업데이트합니다.

4. 검증기 구축하기#

이제 표준, 프로토콜 및 아키텍처 흐름에 대한 확실한 이해를 바탕으로 검증기 구축을 시작하겠습니다.

따라 하거나 최종 코드 사용하기

이제 설정 및 코드 구현을 단계별로 진행하겠습니다. 완성된 제품으로 바로 넘어가고 싶다면 GitHub 저장소에서 전체 프로젝트를 클론하여 로컬에서 실행할 수 있습니다.

git clone https://github.com/corbado/digital-credentials-example.git

4.1 프로젝트 설정#

먼저, 새 Next.js 프로젝트를 초기화하고 필요한 종속성을 설치한 다음 데이터베이스를 시작하겠습니다.

4.1.1 Next.js 앱 초기화#

터미널을 열고 프로젝트를 생성할 디렉토리로 이동한 후 다음 명령을 실행하세요. 이 프로젝트에서는 App Router, TypeScript, Tailwind CSS를 사용합니다.

npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*" --use-npm

이 명령은 현재 디렉토리에 새로운 Next.js 애플리케이션의 기본 구조를 만듭니다.

4.1.2 종속성 설치#

다음으로 CBOR 디코딩, 데이터베이스 연결 및 UUID 생성을 처리할 라이브러리를 설치해야 합니다.

npm install cbor-web mysql2 uuid @types/uuid

이 명령은 다음을 설치합니다:

  • cbor-web: mdoc 자격증명 페이로드 디코딩용.
  • mysql2: 데이터베이스용 MySQL 클라이언트.
  • uuid: 고유한 챌린지 문자열 생성용.
  • @types/uuid: uuid 라이브러리용 TypeScript 타입.

4.1.3 데이터베이스 시작#

저희 백엔드는 OIDC 세션 데이터를 저장하기 위해 MySQL 데이터베이스가 필요하며, 이를 통해 각 검증 흐름이 안전하고 상태를 유지하도록 보장합니다. 이를 쉽게 하기 위해 docker-compose.yml 파일을 포함했습니다.

저장소를 클론했다면 docker-compose up -d를 실행하기만 하면 됩니다. 처음부터 구축하는 경우, 다음과 같은 내용으로 docker-compose.yml이라는 파일을 만드세요.

services: mysql: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: digital_credentials MYSQL_USER: app_user MYSQL_PASSWORD: app_password ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 20s retries: 10 volumes: mysql_data:

이 Docker Compose 설정에는 SQL 초기화 스크립트도 필요합니다. sql이라는 디렉토리를 만들고 그 안에 필요한 테이블을 설정하기 위해 다음 내용으로 init.sql이라는 파일을 만드세요.

-- Create database if not exists CREATE DATABASE IF NOT EXISTS digital_credentials; USE digital_credentials; -- Table for storing challenges CREATE TABLE IF NOT EXISTS challenges ( id VARCHAR(36) PRIMARY KEY, challenge VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, used BOOLEAN DEFAULT FALSE, INDEX idx_challenge (challenge), INDEX idx_expires_at (expires_at) ); -- Table for storing verification sessions CREATE TABLE IF NOT EXISTS verification_sessions ( id VARCHAR(36) PRIMARY KEY, challenge_id VARCHAR(36), status ENUM('pending', 'verified', 'failed', 'expired') DEFAULT 'pending', presentation_data JSON, verified_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE, INDEX idx_challenge_id (challenge_id), INDEX idx_status (status) ); -- Table for storing verified credentials data (optional) CREATE TABLE IF NOT EXISTS verified_credentials ( id VARCHAR(36) PRIMARY KEY, session_id VARCHAR(36), credential_type VARCHAR(255), issuer VARCHAR(255), subject VARCHAR(255), claims JSON, verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES verification_sessions(id) ON DELETE CASCADE, INDEX idx_session_id (session_id), INDEX idx_credential_type (credential_type) );

두 파일이 모두 준비되면 프로젝트 루트의 터미널을 열고 다음을 실행하세요.

docker-compose up -d

이 명령은 백그라운드에서 MySQL 컨테이너를 시작합니다.

4.2 Next.js 앱의 아키텍처 개요#

저희 Next.js 애플리케이션은 같은 프로젝트의 일부임에도 불구하고 프론트엔드와 백엔드 간의 관심사를 분리하도록 구조화되었습니다.

  • 프론트엔드 (src/app/page.tsx): 검증 흐름을 시작하고 결과를 표시하는 단일 React 페이지입니다. 브라우저의 디지털 자격증명 API와 상호 작용합니다.
  • 백엔드 API 라우트 (src/app/api/verify/...):
    • start/route.ts: OpenID4VP 요청과 보안 논스를 생성합니다.
    • finish/route.ts: 지갑으로부터(브라우저를 통해) 프레젠테이션을 수신하고, 논스를 검증하며, 자격증명을 디코딩합니다.
  • 라이브러리 (src/lib/):
    • database.ts: 모든 데이터베이스 상호 작용(챌린지 생성, 세션 검증)을 관리합니다.
    • crypto.ts: CBOR 기반 mDoc 자격증명의 디코딩을 처리합니다.

다음은 내부 아키텍처를 보여주는 다이어그램입니다.

4.3 프론트엔드 구축하기#

저희 프론트엔드는 의도적으로 가볍게 만들었습니다. 주요 책임은 검증 흐름에 대한 사용자 대면 트리거 역할을 하고, 백엔드와 브라우저의 네이티브 자격증명 처리 기능 모두와 통신하는 것입니다. 복잡한 프로토콜 로직은 자체적으로 포함하지 않으며, 모두 위임됩니다.

구체적으로 프론트엔드는 다음을 처리합니다.

  • 사용자 상호 작용: 사용자가 프로세스를 시작할 수 있도록 "확인" 버튼과 같은 간단한 인터페이스를 제공합니다.
  • 상태 관리: UI 상태를 관리하여 검증이 진행되는 동안 로딩 표시기를 보여주고 최종 성공 또는 오류 메시지를 표시합니다.
  • 백엔드 통신 (요청): /api/verify/start를 호출하고 지갑이 무엇을 제시해야 하는지 정확히 설명하는 구조화된 JSON 페이로드(protocol, request, state)를 수신합니다.
  • 브라우저 API 호출: 해당 JSON 객체를 navigator.credentials.get()에 전달하면, 네이티브 QR 코드를 렌더링하고 지갑 응답을 기다립니다.
  • 백엔드 통신 (응답): 브라우저 API가 검증 가능한 프레젠테이션을 반환하면, 최종 서버 측 검증을 위해 이 데이터를 /api/verify/finish 엔드포인트에 POST 요청으로 보냅니다.
  • 결과 표시: 백엔드의 응답에 따라 검증 성공 여부를 사용자에게 알리기 위해 UI를 업데이트합니다.

핵심 로직은 startVerification 함수에 있습니다.

// src/app/page.tsx const startVerification = async () => { setLoading(true); setVerificationResult(null); try { // 1. 브라우저가 API를 지원하는지 확인합니다. if (!navigator.credentials?.get) { throw new Error("브라우저가 Credential API를 지원하지 않습니다."); } // 2. 백엔드에 요청 객체를 요청합니다. const res = await fetch("/api/verify/start"); const { protocol, request } = await res.json(); // 3. 해당 객체를 브라우저에 전달합니다 – 이것이 네이티브 QR 코드를 트리거합니다. const credential = await (navigator.credentials as any).get({ mediation: "required", digital: { requests: [ { protocol, // "openid4vp" data: request, // dcql_query, nonce 등을 포함 }, ], }, }); // 4. 지갑 응답(브라우저로부터)을 서버 측 검사를 위해 finish 엔드포인트로 전달합니다. const verifyRes = await fetch("/api/verify/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credential), }); const result = await verifyRes.json(); if (verifyRes.ok && result.verified) { setVerificationResult(`성공: ${result.message}`); } else { throw new Error(result.message || "검증에 실패했습니다."); } } catch (err) { setVerificationResult(`오류: ${(err as Error).message}`); } finally { setLoading(false); } };

이 함수는 프론트엔드 로직의 네 가지 핵심 단계를 보여줍니다: API 지원 확인, 백엔드에서 요청 가져오기, 브라우저 API 호출, 검증을 위해 결과 다시 보내기. 파일의 나머지 부분은 상태 및 UI 렌더링을 위한 표준 React 상용구 코드이며, GitHub 저장소에서 볼 수 있습니다.

digitalmediation: 'required'를 사용할까요?#

navigator.credentials.get() 호출이 더 간단한 예제와 달라 보일 수 있습니다. 이는 저희가 공식 W3C 디지털 자격증명 API 사양을 엄격하게 준수하기 때문입니다.

  • digital 멤버: 사양에 따르면 모든 디지털 자격증명 요청은 digital 객체 내에 중첩되어야 합니다. 이는 이 API에 대한 명확하고 표준화된 네임스페이스를 제공하여 다른 자격증명 유형(password 또는 federated 등)과 구별하고 충돌 없이 향후 확장을 허용합니다.

  • mediation: 'required': 이 옵션은 중요한 보안 및 사용자 경험 기능입니다. 사용자가 자격증명 요청을 승인하기 위해 프롬프트(예: 생체 인식 스캔, PIN 입력 또는 동의 화면)와 적극적으로 상호 작용해야 함을 강제합니다. 이것이 없으면 웹사이트가 백그라운드에서 조용히 자격증명에 접근을 시도할 수 있어 심각한 개인 정보 보호 위험을 초래합니다. 중재를 요구함으로써, 우리는 사용자가 항상 제어권을 가지고 모든 거래에 대해 명시적인 동의를 제공하도록 보장합니다.

4.4 백엔드 엔드포인트 구축하기#

React UI가 준비되었으므로 이제 서버에서 무거운 작업을 수행하는 두 개의 API 라우트가 필요합니다.

  1. /api/verify/start – OpenID4VP 요청을 만들고, 일회용 챌린지를 MySQL에 저장한 후 모든 것을 브라우저에 전달합니다.
  2. /api/verify/finish – 지갑 응답을 수신하고, 챌린지를 검증하며, 자격증명을 확인하고 디코딩한 후, 간결한 JSON 결과를 UI에 반환합니다.

4.4.1 /api/verify/start: OpenID4VP 요청 생성#

// src/app/api/verify/start/route.ts import { NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { createChallenge, cleanupExpiredChallenges } from "@/lib/database"; export async function GET() { // 1️⃣ 수명이 짧은 임의의 논스(챌린지)를 생성합니다. const challenge = uuidv4(); const challengeId = uuidv4(); const expiresAt = new Date(Date.now() + 5 * 60 * 1000); await createChallenge(challengeId, challenge, expiresAt); cleanupExpiredChallenges().catch(console.error); // 2️⃣ 우리가 원하는 것을 설명하는 DCQL 쿼리를 만듭니다. const dcqlQuery = { credentials: [ { id: "cred1", format: "mso_mdoc", meta: { doctype_value: "eu.europa.ec.eudi.pid.1" }, claims: [ { path: ["eu.europa.ec.eudi.pid.1", "family_name"] }, { path: ["eu.europa.ec.eudi.pid.1", "given_name"] }, { path: ["eu.europa.ec.eudi.pid.1", "birth_date"] }, ], }, ], }; // 3️⃣ 브라우저가 navigator.credentials.get()에 전달할 수 있는 객체를 반환합니다. return NextResponse.json({ protocol: "openid4vp", // 브라우저에 사용할 지갑 프로토콜을 알려줍니다. request: { dcql_query: dcqlQuery, // 무엇을 제시할지 nonce: challenge, // 재전송 방지 response_type: "vp_token", response_mode: "dc_api", // 지갑이 /finish로 직접 POST합니다. }, state: { credential_type: "mso_mdoc", // 나중 확인을 위해 유지 nonce: challenge, challenge_id: challengeId, }, }); }

주요 매개변수

nonce – 요청과 응답을 바인딩하는 암호학적 챌린지입니다(재전송 방지). • dcql_query – 우리가 필요로 하는 정확한 클레임을 설명하는 객체입니다. 이 가이드에서는 아직 확정된 표준은 아니지만, 디지털 자격증명 쿼리 언어의 최신 초안에서 영감을 받은 dcql_query 구조를 사용합니다. • state – 지갑이 다시 보내주는 임의의 JSON으로, DB 레코드를 조회할 수 있게 합니다.

4.4.2 데이터베이스 헬퍼#

src/lib/database.ts 파일은 챌린지 및 검증 세션에 대한 기본 MySQL 작업(삽입, 읽기, 사용됨으로 표시)을 래핑합니다. 이 로직을 단일 모듈에 유지하면 나중에 데이터 저장소를 쉽게 교체할 수 있습니다.


4.5 /api/verify/finish: 프레젠테이션 검증 및 디코딩#

// src/app/api/verify/finish/route.ts import { NextResponse, NextRequest } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getChallenge, markChallengeAsUsed, createVerificationSession, updateVerificationSession, } from "@/lib/database"; import { decodeDigitalCredential, decodeAllNamespaces } from "@/lib/crypto"; export async function POST(request: NextRequest) { const body = await request.json(); // 1️⃣ 검증 가능한 프레젠테이션 조각들을 추출합니다. const vpTokenMap = body.vp_token ?? body.data?.vp_token; const state = body.state; const mdocToken = vpTokenMap?.cred1; // dcqlQuery에서 이 ID를 요청했습니다. if (!vpTokenMap || !state || !mdocToken) { return NextResponse.json( { verified: false, message: "잘못된 형식의 응답입니다." }, { status: 400 }, ); } // 2️⃣ 일회용 챌린지 유효성 검사 const stored = await getChallenge(state.nonce); if (!stored) { return NextResponse.json( { verified: false, message: "유효하지 않거나 만료된 챌린지입니다." }, { status: 400 }, ); } const sessionId = uuidv4(); await createVerificationSession(sessionId, stored.id); // 3️⃣ (의사) 암호학적 검사 – 프로덕션에서는 실제 mDL 유효성 검사로 대체해야 합니다. // 실제 애플리케이션에서는 전용 라이브러리를 사용하여 발급기관의 공개 키에 대해 // mdoc 서명의 전체 암호학적 유효성 검사를 수행해야 합니다. const isValid = mdocToken.length > 0; if (!isValid) { await updateVerificationSession(sessionId, "failed", { reason: "mdoc 검증 실패", }); return NextResponse.json( { verified: false, message: "자격증명 검증에 실패했습니다." }, { status: 400 }, ); } // 4️⃣ 모바일-DL (mdoc) 페이로드를 사람이 읽을 수 있는 JSON으로 디코딩합니다. const decoded = await decodeDigitalCredential(mdocToken); const readable = decodeAllNamespaces(decoded)["eu.europa.ec.eudi.pid.1"]; await markChallengeAsUsed(state.nonce); await updateVerificationSession(sessionId, "verified", { readable }); return NextResponse.json({ verified: true, message: "mdoc 자격증명이 성공적으로 검증되었습니다!", credentialData: readable, sessionId, }); }

지갑 응답의 중요 필드

vp_token – 지갑이 반환하는 자격증명을 담는 맵입니다. 저희 데모에서는 vp_token.cred1을 가져옵니다. • state/start에서 제공한 블롭의 에코입니다. DB 레코드를 조회할 수 있도록 nonce를 포함합니다. • mdocToken – ISO mDoc를 나타내는 Base64URL로 인코딩된 CBOR 구조입니다.

4.6 mdoc 자격증명 디코딩#

검증기가 브라우저로부터 mdoc 자격증명을 수신하면, 이는 CBOR로 인코딩된 바이너리 데이터를 포함하는 Base64URL 문자열입니다. 실제 클레임을 추출하기 위해 finish 엔드포인트는 src/lib/crypto.ts의 헬퍼 함수를 사용하여 다단계 디코딩 프로세스를 수행합니다.

4.6.1 1단계: Base64URL 및 CBOR 디코딩#

decodeDigitalCredential 함수는 인코딩된 문자열을 사용 가능한 객체로 변환하는 작업을 처리합니다.

// src/lib/crypto.ts export async function decodeDigitalCredential(encodedCredential: string) { // 1. Base64URL을 표준 Base64로 변환 const base64UrlToBase64 = (input: string) => { let base64 = input.replace(/-/g, "+").replace(/_/g, "/"); const pad = base64.length % 4; if (pad) base64 += "=".repeat(4 - pad); return base64; }; const base64 = base64UrlToBase64(encodedCredential); // 2. Base64를 바이너리로 디코딩 const binaryString = atob(base64); const byteArray = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); // 3. CBOR 디코딩 const decoded = await cbor.decodeFirst(byteArray); return decoded; }
  • Base64URL에서 Base64로: 자격증명을 Base64URL에서 표준 Base64 인코딩으로 변환합니다.
  • Base64에서 바이너리로: Base64 문자열을 바이너리 바이트 배열로 디코딩합니다.
  • CBOR 디코딩: cbor-web 라이브러리를 사용하여 바이너리 데이터를 구조화된 JavaScript 객체로 디코딩합니다.

4.6.2 2단계: 네임스페이스가 지정된 클레임 추출#

decodeAllNamespaces 함수는 디코딩된 CBOR 객체를 추가로 처리하여 관련 네임스페이스에서 실제 클레임을 추출합니다.

// src/lib/crypto.ts export function decodeAllNamespaces(jsonObj) { const decoded = {}; try { jsonObj.documents.forEach((doc, idx) => { // 1) issuerSigned.nameSpaces: const issuerNS = doc.issuerSigned?.nameSpaces || {}; Object.entries(issuerNS).forEach(([nsName, entries]) => { if (!decoded[nsName]) decoded[nsName] = {}; (entries as any[]).forEach((entry) => { const bytes = Uint8Array.from(entry.value); const decodedEntry = cbor.decodeFirstSync(bytes); Object.assign(decoded[nsName], decodedEntry); }); }); // 2) deviceSigned.nameSpaces (존재하는 경우): const deviceNS = doc.deviceSigned?.nameSpaces; if (deviceNS?.value?.data) { const bytes = Uint8Array.from(deviceNS.value); decoded[`deviceSigned_ns_${idx}`] = cbor.decodeFirstSync(bytes); } }); } catch (e) { console.error(e); } return decoded; }
  • 디코딩된 자격증명의 모든 문서를 반복합니다.
  • 이름, 생년월일 등과 같은 실제 클레임 값을 추출하기 위해 각 네임스페이스(예: eu.europa.ec.eudi.pid.1)를 디코딩합니다.
  • 존재하는 경우 발급기관 서명 및 기기 서명 네임스페이스를 모두 처리합니다.

예제 출력#

이 단계를 거치면 finish 엔드포인트는 mdoc에서 클레임을 포함하는 사람이 읽을 수 있는 객체를 얻습니다. 예를 들면 다음과 같습니다.

{ "family_name": "Doe", "given_name": "John", "birth_date": "1990-01-01" }

이 프로세스는 검증기가 표시 및 추가 처리를 위해 mdoc 자격증명에서 필요한 정보를 안전하고 신뢰성 있게 추출할 수 있도록 보장합니다.

4.7 UI에 결과 표시하기#

finish 엔드포인트는 프론트엔드에 최소한의 JSON 객체를 반환합니다.

{ "verified": true, "message": "mdoc 자격증명이 성공적으로 검증되었습니다!", "credentialData": { "family_name": "Doe", "given_name": "John", "birth_date": "1990-01-01" } }

프론트엔드는 startVerification()에서 이 응답을 받아 React 상태에 저장하기만 하면 됩니다. 그러면 멋진 확인 카드를 렌더링하거나 개별 클레임을 표시할 수 있습니다. 예: "환영합니다, John Doe님 (1990-01-01 출생)!".

5. 검증기 실행 및 다음 단계#

이제 브라우저의 네이티브 자격증명 처리 기능을 사용하는 완전하고 작동하는 검증기를 갖게 되었습니다. 로컬에서 실행하는 방법과 이를 개념 증명에서 프로덕션 준비 애플리케이션으로 발전시키기 위해 할 수 있는 일은 다음과 같습니다.

5.1 예제 실행 방법#

  1. 저장소 클론:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. 종속성 설치:

    npm install
  3. 데이터베이스 시작: 컴퓨터에서 Docker가 실행 중인지 확인한 다음 MySQL 컨테이너를 시작합니다.

    docker-compose up -d
  4. 애플리케이션 실행:

    npm run dev

    브라우저에서 http://localhost:3000으로 이동하면 검증기의 UI가 표시됩니다. 이제 CMWallet을 사용하여 QR 코드를 스캔하고 검증 흐름을 완료할 수 있습니다.

5.2 다음 단계: 데모에서 프로덕션으로#

이 튜토리얼은 검증기를 위한 기본적인 구성 요소를 제공합니다. 이를 프로덕션에 사용할 수 있도록 하려면 몇 가지 추가 기능을 구현해야 합니다.

  • 전체 암호학적 유효성 검사: 현재 구현은 자리 표시자 검사(mdocToken.length > 0)를 사용합니다. 실제 시나리오에서는 발급기관의 공개 키(예: DID를 확인하거나 공개 키 인증서를 가져옴)에 대해 mdoc 서명의 전체 암호학적 유효성 검사를 수행해야 합니다. DID 확인 표준에 대해서는 W3C DID Resolution 사양을 참조하세요.

  • 발급기관 폐지 확인: 자격증명은 만료일 이전에 발급기관에 의해 폐지될 수 있습니다. 프로덕션 검증기는 발급기관이 제공하는 폐지 목록 또는 상태 엔드포인트를 쿼리하여 자격증명의 상태를 확인해야 합니다. W3C Verifiable Credentials Status List는 자격증명 폐지 목록에 대한 표준을 제공합니다.

  • 견고한 오류 처리 및 보안: 포괄적인 오류 처리, 입력 유효성 검사, API 엔드포인트에 대한 속도 제한을 추가하고, 전송 중인 데이터를 보호하기 위해 모든 통신이 HTTPS(TLS)를 통해 이루어지도록 해야 합니다. OWASP API Security Guidelines는 포괄적인 API 보안 모범 사례를 제공합니다.

  • 여러 자격증명 유형 지원: 유럽 디지털 신원(EUDI) PID 자격증명 이외의 자격증명을 받을 것으로 예상되는 경우, 다른 doctype 값 및 자격증명 형식을 처리하도록 로직을 확장하세요. W3C Verifiable Credentials Data Model은 포괄적인 VC 형식 사양을 제공합니다.

5.3 이 튜토리얼의 범위에서 벗어나는 내용#

이 예제는 이해하기 쉽도록 핵심적인 브라우저 중재 흐름에 의도적으로 초점을 맞췄습니다. 다음 주제는 범위에서 벗어나는 것으로 간주됩니다.

  • 프로덕션 수준의 보안: 이 검증기는 교육 목적으로, 라이브 환경에 필요한 강화 조치가 부족합니다.
  • W3C 검증 가능한 자격증명: 이 튜토리얼은 모바일 운전면허증을 위한 ISO mDoc 형식에만 초점을 맞춥니다. JWT-VC 또는 Linked Data Proofs(LD-Proofs)가 포함된 VC와 같은 다른 인기 있는 형식은 다루지 않습니다.
  • 고급 OpenID4VP 흐름: redirect_uri를 사용한 직접적인 지갑-백엔드 통신 또는 동적 클라이언트 등록과 같은 더 복잡한 OpenID4VP 기능은 구현하지 않습니다.

이 기초를 바탕으로 이러한 다음 단계를 통합함으로써, 자신의 애플리케이션에서 디지털 자격증명을 신뢰하고 검증할 수 있는 견고하고 안전한 검증기를 개발할 수 있습니다.

결론#

이것으로 끝입니다! 250줄 미만의 TypeScript 코드로 다음과 같은 기능을 갖춘 엔드투엔드 검증기를 만들었습니다.

  1. 브라우저의 자격증명 API에 대한 요청을 게시합니다.
  2. 모든 호환 지갑이 검증 가능한 프레젠테이션을 제공할 수 있도록 합니다.
  3. 서버에서 프레젠테이션을 검증합니다.
  4. 실시간으로 UI를 업데이트합니다.

프로덕션 환경에서는 자리 표시자 유효성 검사를 전체 ISO 18013-5 검사로 대체하고, 발급기관 폐지 조회, 속도 제한, 감사 로깅, 그리고 물론 엔드투엔드 TLS를 추가해야 하지만, 핵심 구성 요소는 정확히 동일하게 유지됩니다.

참고 자료#

이 튜토리얼에서 사용되거나 참조된 주요 리소스, 사양 및 도구는 다음과 같습니다.

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start Free Trial

Share this article


LinkedInTwitterFacebook