증명서 발급 (전자증명서)
주민등록등본, 주민등록초본, 가족관계증명서를 간편인증으로 발급하는 기능입니다. HYPHEN과 CODEF 두 벤더를 선택적으로 사용할 수 있는 멀티 벤더 구조(v2)로 운영됩니다.
전체 흐름
사용자 입력 → 프론트 암호화 → 서버 복호화 → 벤더 규격 재암호화 → 벤더 API- 사용자가 주민등록번호, 주소 등 민감 정보를 입력
- 프론트에서 얼마집 전용 AES-256 키로 암호화하여 서버에 전송
- 서버에서 복호화 후, 선택된 벤더(HYPHEN 또는 CODEF) API 규격에 맞게 재암호화
- 벤더를 통해 간편인증 요청 → 사용자 승인 → 증명서 PDF 발급
2단계 인증 흐름
모든 벤더는 간편인증이 필요하므로 2회의 API 호출로 증명서가 발급됩니다.
[1차 요청] 간편인증 요청
프론트 → POST /v2/user/certificate/resident-certificate
{ vendor, birthDate, identityEnc, loginOrgCd, sido, sigg }
서버 ← { stepData: "..." } ← HYPHEN인 경우
{ twoWayInfo: { ... } } ← CODEF인 경우
[사용자가 휴대폰에서 간편인증 승인]
[2차 요청] 증명서 발급
프론트 → POST /v2/user/certificate/resident-certificate
{ ..., stepData: "..." } ← HYPHEN인 경우
{ ..., twoWayInfo: { ... } } ← CODEF인 경우
서버 ← { preSignedUrl: "https://s3..." }벤더에 따라 인증 데이터 형식이 다릅니다:
| 벤더 | 1차 응답 | 2차 요청에 포함 |
|---|---|---|
| HYPHEN | stepData (string) | stepData |
| CODEF | twoWayInfo (object) | twoWayInfo |
암호화
얼마집 전용 키 (CERTIFICATE_AES_KEY)
프론트에서 민감 데이터를 암호화할 때 사용하는 AES-256-CBC 키입니다.
- 알고리즘: AES-256-CBC
- IV: 랜덤 16바이트
- 출력 형식:
Base64(IV + 암호문) - 환경변수:
NEXT_PUBLIC_CERTIFICATE_AES_KEY
import {encryptCertificateField} from '@howmuchhome-web/utils-ts';
// 주민등록등본/초본: 주민번호 뒷자리만 암호화
const identityEnc = encryptCertificateField(birthPassword); // 7자리
// 가족관계증명서: 주민번호 뒷자리만 암호화
const rrn2Enc = encryptCertificateField(birthPassword); // 7자리v1과 v2 암호화 차이
| v1 | v2 | |
|---|---|---|
| 알고리즘 | AES-128-CBC | AES-256-CBC |
| 키 | ENCRYPTION_KEY (16바이트) | NEXT_PUBLIC_CERTIFICATE_AES_KEY (32바이트) |
| IV | userId 기반 고정 IV | 랜덤 IV (매 호출 다름) |
| 등본/초본 | encryptField(birth + birthPassword) 전체 암호화 | birthDate 평문 + encryptCertificateField(birthPassword) |
| 가족관계 | rrn1Enc, rrn2Enc 모두 암호화 | rrn1 평문 + rrn2Enc 뒷자리만 암호화 |
v2에서는 주민번호 앞자리(생년월일)를 평문으로, 뒷자리만 암호화합니다. 서버에서 벤더별 규격에 맞게 재암호화하기 때문입니다.
API 엔드포인트
주민등록등본
POST /v2/user/certificate/resident-certificate주민등록초본
POST /v2/user/certificate/resident-abstract가족관계증명서
POST /v2/user/certificate/family-relations요청 — 주민등록등본/초본
type CreateResidentCertificateV2Request = {
vendor: CertificateVendor; // 'HYPHEN' | 'CODEF'
loginOrgCd: string; // 간편인증 서비스 코드 (예: 'kakao', 'toss')
birthDate: string; // 주민번호 앞 6자리 (평문)
identityEnc: string; // 주민번호 뒷 7자리 (AES-256 암호화)
sido: string; // 시/도 (예: '서울특별시')
sigg: string; // 시/군/구 (예: '강남구')
stepData?: string | null; // HYPHEN 2단계 인증 데이터 (2차 요청 시)
twoWayInfo?: TwoWayInfo | null; // CODEF 2단계 인증 데이터 (2차 요청 시)
assignmentId?: string | null; // 과제 ID (과제에서 진입 시)
};요청 — 가족관계증명서
type CreateFamilyRelationsV2Request = {
vendor: CertificateVendor;
loginOrgCd: string;
rrn1: string; // 주민번호 앞 6자리 (평문)
rrn2Enc: string; // 주민번호 뒷 7자리 (AES-256 암호화)
fthrNm?: string | null; // 부 성명
mthrNm?: string | null; // 모 성명
sposNm?: string | null; // 배우자 성명
childNm?: string | null; // 자녀 성명 (가족 관계 최소 1개 필수)
stepData?: string | null;
twoWayInfo?: TwoWayInfo | null;
assignmentId?: string | null;
};응답 (공통)
// 1차 응답: 간편인증 대기
{
residentCertificate: { // 또는 residentAbstract, familyRelationsCertificate
preSignedUrl: null,
stepData: "...", // HYPHEN
twoWayInfo: null,
}
}
// 또는
{
residentCertificate: {
preSignedUrl: null,
stepData: null,
twoWayInfo: { // CODEF
jobIndex: 0,
threadIndex: 0,
jti: "...",
twoWayTimestamp: 1234567890
},
}
}
// 2차 응답: 발급 완료
{
residentCertificate: {
preSignedUrl: "https://s3...signed-url",
stepData: null,
twoWayInfo: null,
}
}벤더 (CertificateVendor)
enum CertificateVendor {
HYPHEN = 'HYPHEN',
CODEF = 'CODEF',
}벤더 값은 과제(assignment) 데이터의 certificateVendor 필드에서 내려옵니다.
프론트에서는 이 값을 queryString으로 전달받아 사용합니다.
- 과제에서 진입:
AssignmentPage→ queryStringvendor→ apply page - 전자증명서 메뉴에서 직접 진입: 기본값
CertificateVendor.HYPHEN
간편인증 서비스 (loginOrgCd)
| 코드 | 서비스 | HYPHEN | CODEF |
|---|---|---|---|
kakao | 카카오톡 | O | O |
naver | 네이버 | O | O |
toss | 토스 | O | O |
kb | KB인증서 | O | O |
shinhan | 신한인증서 | O | O |
CODEF에서 지원하지 않는
loginOrgCd(kakaobank, payco 등)로 요청 시 에러가 반환됩니다.
프론트엔드 구현
파일 구조
apps/app/src/app/(certified-user-only)/more/electronic-certificate/
├── page.tsx # 증명서 목록 (발급/제출 현황)
├── _components/
│ └── CertificateConsentBottomSheet.tsx
├── apply/
│ ├── page.tsx # 발급 퍼널 진입점
│ ├── funnel.ts # 퍼널 스텝 정의
│ ├── type.ts # 폼 타입 (BaseCertificateForm, ResidentRegistrationForm, FamilyRelationsForm)
│ ├── _component/
│ │ └── RegionSelect.tsx # 시/도, 시/군/구 선택
│ └── section/
│ ├── ElectronicCertificateApplyPageIntro.tsx # Step 1: 정보 입력
│ ├── ResidentRegistrationForm.tsx # 등본/초본 입력 폼
│ ├── FamilyRelationsForm.tsx # 가족관계 입력 폼
│ ├── ElectronicCertificateApplyPageSimpleVerification.tsx # Step 2: 간편인증 선택 (1차 API)
│ └── ElectronicCertificateApplyPageConfirm.tsx # Step 3: 인증 완료 (2차 API)퍼널 흐름
intro → simpleVerification → confirm| 스텝 | 역할 | API 호출 |
|---|---|---|
intro | 주민번호/주소/가족관계 입력, 암호화 | 없음 |
simpleVerification | 간편인증 서비스 선택 | 1차 요청 (간편인증 요청) |
confirm | 인증 안내 화면, 완료 버튼 | 2차 요청 (증명서 발급) |
폼 타입
// 공통 필드
type BaseCertificateForm = {
vendor: CertificateVendor;
loginOrgCd: EasyLoginCode;
preSignedUrl?: string | null;
stepData?: string | null; // HYPHEN 인증 데이터
twoWayInfo?: TwoWayInfo | null; // CODEF 인증 데이터
};
// 주민등록등본/초본
type ResidentRegistrationForm = BaseCertificateForm & {
birthDate: string; // 주민번호 앞 6자리 (평문)
identityEnc: string; // 주민번호 뒷 7자리 (암호화)
sido: string;
sigg: string;
};
// 가족관계증명서
type FamilyRelationsForm = BaseCertificateForm & {
rrn1: string; // 주민번호 앞 6자리 (평문)
rrn2Enc: string; // 주민번호 뒷 7자리 (암호화)
fthrNm: string;
mthrNm: string;
sposNm: string;
childNm: string;
};API 호출 예시
// 1차 요청 (simpleVerification 스텝)
const res = await UserAPI.createMyResidentCertificateV2({
vendor: form.vendor,
loginOrgCd: selectedLogin,
birthDate: form.birthDate,
identityEnc: form.identityEnc,
sido: form.sido,
sigg: form.sigg,
});
// → res.stepData 또는 res.twoWayInfo를 폼에 저장
// 2차 요청 (confirm 스텝)
const res = await UserAPI.createMyResidentCertificateV2({
vendor: form.vendor,
loginOrgCd: form.loginOrgCd,
birthDate: form.birthDate,
identityEnc: form.identityEnc,
sido: form.sido,
sigg: form.sigg,
stepData: form.stepData, // HYPHEN이면 값이 있음
twoWayInfo: form.twoWayInfo, // CODEF이면 값이 있음
});
// → res.preSignedUrl로 PDF 다운로드 가능환경 설정
| 환경변수 | 용도 | 비고 |
|---|---|---|
NEXT_PUBLIC_CERTIFICATE_AES_KEY | 얼마집 전용 AES-256 암호화 키 (32바이트) | 각 환경(dev/prod)마다 다른 값 |
주요 패키지/파일 참조
| 패키지/파일 | 역할 |
|---|---|
packages/api/lib/user/schema.ts | v2 요청/응답 타입 정의 |
packages/api/lib/user/index.ts | v2 API 엔드포인트 (createMyResidentCertificateV2 등) |
packages/api/lib/assignment/schema.ts | CertificateVendor enum, AssignmentDto.certificateVendor |
packages/utils-ts/aes-encryption.ts | encryptCertificateField() — AES-256-CBC 암호화 |
apps/app/src/routes/index.ts | electronicCertificateApply 라우트 (queryString: type, vendor) |
Native (Expo)
native 앱(apps/native-app)은 web 웹뷰를 쓰지 않고 증명서 발급 플로우를 RN으로 자체 구현합니다.
API 규격(createMy*V2, stepData/twoWayInfo, preSignedUrl)과 2단계 인증 흐름은 web과 동일하며,
아래는 native에서만 달라지는 점입니다.
관련 파일: apps/native-app/src/features/electronic-certificate/
암호화 구현 차이 (node-forge)
web은 @howmuchhome-web/utils-ts 의 encryptCertificateField()(Node crypto)를 사용하지만,
native는 동일 알고리즘/출력 포맷을 node-forge로 재구현한 별도 함수를 씁니다.
- 파일:
apps/native-app/src/features/electronic-certificate/lib/encryptCertificateField.ts - 왜 utils-ts 대신인가: native(Hermes)에서는 metro가 Node
crypto모듈을 빈 모듈로 stub하므로 utils-ts의 crypto 기반 구현을 그대로 실행할 수 없습니다. (native-app/metro-and-dependencies 의 crypto stub 참고, utils-ts) - 동작: 순수 JS(node-forge, 이미 번들에 존재해 추가 용량 0)로
AES-256-CBC · 랜덤 16byte IV · PKCS#7 패딩 · 출력
base64(IV ‖ 암호문)을 구현합니다. web(Nodecrypto= 서버 복호화)과 양방향 호환됨을 검증했습니다. - IV 난수원:
global.crypto.getRandomValues(react-native-get-random-values폴리필,index.js최상단 import). - 키: web의
NEXT_PUBLIC_CERTIFICATE_AES_KEY와 동일한 공개값을@/config/env의CERTIFICATE_AES_KEY(JS 번들 인라인, 노출 수준 web과 동일)에서 가져옵니다. 필요 시EXPO_PUBLIC_CERTIFICATE_AES_KEY로 override.
앞자리(생년월일) 평문 + 뒷 7자리만 암호화하는 v2 규약은 web과 동일합니다.
ApplyFunnel.tsx의 intro 스텝에서 nativeencryptCertificateField()로 뒷자리를 암호화해 폼 상태에 저장합니다.
간편인증 화면 흐름
web과 동일하게 intro → simpleVerification → confirm 퍼널로 구성됩니다
(@howmuchhome-web/funnel/native 의 useFunnel/Funnel, apps/native-app/.../ApplyFunnel.tsx).
| 스텝 | native 파일 | 역할 | API |
|---|---|---|---|
intro | ResidentRegistrationForm.tsx / FamilyRelationsForm.tsx | 주민번호·주소·가족관계 입력, 암호화 | 없음 |
simpleVerification | SimpleVerification.tsx | 간편인증 provider 선택 그리드 | 1차 createMy*V2 |
confirm | Confirm.tsx | 인증 안내 → 완료 버튼 → 제출 | 2차 createMy*V2 (with stepData/twoWayInfo) |
- provider 선택:
providers.ts의EASY_LOGIN_PROVIDERS(카카오톡·네이버·토스·KB인증서·신한인증서)를 단일 소스로 관리합니다. 노출 순서·라벨·배경색은 web과 동일하며, web에서 주석 처리된 PASS는 동일하게 제외됩니다. - 백엔드 중개: native에는 provider 인증 SDK가 없습니다.
UserAPI.createMy*V2호출로 백엔드가 provider 인증을 중개하고preSignedUrl/stepData(HYPHEN)/twoWayInfo(CODEF)를 반환합니다. 이후 사용자가 provider 앱의 푸시 알림으로 2-way 승인하는 흐름입니다. - 인증 대기 → 제출 활성화: web은 브라우저 포커스(
useWindowFocused)로 “인증 후 눌러주세요” 버튼을 활성화하지만, native는AppState가background/inactive로 바뀌면(= 인증 앱으로 이탈) 버튼을 활성화합니다(Confirm.tsx). - 에러 처리: 잘못된 정보/인증 미완료/발급 실패를
useAlert로 처리하며, 에러 step은 두지 않습니다. - 완료 후 캐시 무효화: native 홈은 focus refetch가 없어, 제출 성공 후
Confirm.tsx에서 증명서 목록·과제 카운트/상세·대기 문서 수 관련 쿼리를 명시적으로invalidateQueries합니다.
외부 앱 열기 스킴 선등록 (withExternalAppSchemes)
향후 provider 앱을 직접 여는 동작에 대비해, config plugin으로 외부 앱 스킴을 선등록합니다.
- 파일:
apps/native-app/plugins/withExternalAppSchemes.js(app.config.ts에서 등록) - 등록 스킴:
kakaotalk,kakaoplus,supertoss,naversearchapp(Android<queries>+ iOSLSApplicationQueriesSchemes—openURL/canOpenURL재빌드 선반영)
주의(코드 기준 현황): 증명서 flow에서 provider 앱을 여는 일반 동작은 아직 정식 구현되어 있지 않습니다.
Confirm.tsx에 카카오 선택 시 카카오톡 채널 채팅방(kakaoplus://..., 실패 시kakaotalk://폴백)으로 여는[TEST]실험 블록만 존재하며, 카카오 외 provider나 안정화된 앱 열기 UX는 미구현입니다. 스킴 선등록은 이 실험/향후 확장을 위한 인프라 성격입니다.