0. 이 글의 배경
백엔드 개발자로서 오랫동안 당연하게 여겨온 관행이 있었습니다. 사용자의 평문 비밀번호를 받아, BCrypt로 해싱하고 데이터베이스에 저장하는 방식입니다. 저 역시 이전 회사에서 그렇게 인증 시스템을 구현했습니다.
하지만 제 마음 한구석에는 늘 가시처럼 박힌 질문이 하나 있었습니다.
"해싱되기 직전, 그 찰나의 순간에 존재하는 평문은 과연 안전한가?"
이 글은 AsyncSite라는 스터디 플랫폼 사이드 프로젝트에서, 바로 그 의문을 해결하기 위해 Passkey를 도입한 과정을 담은 기록입니다.
우리가 당연하게 여겼던 보안의 관행에 근본적인 질문을 던지고, '훔칠 비밀번호가 없는 시스템'이라는 목표를 향해 나아갔던 여정을 공유하고자 합니다.
1. 해시(Hash) 뒤에 숨겨진, 우리 모두가 외면해온 진실
앞서 던졌던 "해싱 전 평문은 안전한가?"라는 질문에 답하기 위해, 우리가 만든 시스템의 전체 흐름을 잠시 복기해 봅시다.
사용자가 입력한 비밀번호는 HTTPS를 통해 암호화되어 API 게이트웨이로 들어오고, 게이트웨이는 다시 내부망을 통해 인증을 담당하는 User Service로 요청을 전달합니다. 이 모든 과정을 거쳐 마침내 서비스의 메모리에 도달한 뒤에야, 비로소 비밀번호는 해시값으로 변환됩니다.
왜 굳이 해싱을 해야 할까요? 간단합니다. 만약 데이터베이스가 통째로 유출되더라도, 공격자가 사용자들의 실제 비밀번호를 알아낼 수 없도록 막아주는 최후의 방어선인 셈입니다. 여기까지는 모두가 동의하는, 견고해 보이는 성벽입니다.
하지만 이것만으로 정말 충분할까요? 저는 이 질문에 늘 명쾌하게 답할 수 없었습니다.
늘 이런 게 궁금했습니다. 아무리 최종 저장 형태가 안전한 해시값이라 한들, 서버에 도달하는 그 순간에 존재하는 평문은 어떻게 하죠?
예를 들어서, Spring Boot 애플리케이션에 다음과 같은 로깅 AOP 코드가 단 한 조각 추가되었다고 상상해 봅시다.
@Aspect
@Component
public class RequestLoggingAspect {
private static final Logger logger = LoggerFactory.getLogger(RequestLoggingAspect.class);
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
public void controller() {}
@Around("controller()")
public Object logRequest(ProceedingJoinPoint joinPoint) throws Throwable {
// 메서드 실행 전, 모든 파라미터를 로깅
logger.debug("Request params: {}", joinPoint.getArgs());
return joinPoint.proceed();
}
}
위 코드는 모든 API 컨트롤러에 들어오는 요청 파라미터를 그대로 로그 파일에 기록합니다. 즉, 로그인 API로 전달된 사용자의 아이디와 비밀번호가 담긴 DTO가 해싱되기 전에 고스란히 별도의 파일로 저장되는 겁니다. 이런 위험은 비단 로깅뿐만이 아닙니다.
APM 툴이 트랜잭션을 추적하는 과정 등, 비밀번호가 메모리 위에서 평문으로 존재하는 모든 구간이 잠재적인 유출 지점이 될 수 있습니다.
이전 회사에서 이런 고민을 나눴던 적이 있어 어렴풋이 기억이 나는데요. "로그 레벨을 DEBUG로 설정하고 Request Body를 전부 찍어버리면... 이론적으로는 모든 비밀번호를 수집할 수 있는 거 아닌가요?" "맞아, 그래서 우리는 서로를 신뢰해야 하고, 서버 접근 권한을 엄격하게 관리해야 하는 거지."
하지만 만약 그 '신뢰'가 깨진다면요? 악의를 품은 내부 개발자 한 명이 단 몇 줄의 코드를 심는다면, 그 순간 시스템은 거대한 비밀번호 수집기가 되어버립니다.
2019년 3월, 페이스북은 최대 6억 명에 달하는 사용자의 비밀번호를 수년간 평문으로 내부 서버에 저장해왔다는 사실을 인정했습니다. 문제는 디버깅을 위해 남겨둔 로그 시스템이었고, 이 데이터에 2만 명이 넘는 페이스북 직원이 접근할 수 있었다는 사실은 더 큰 충격을 주었습니다. 그 1년 전인 2018년 5월에는 트위터에서 해싱 프로세스가 완료되기 전, 내부 로그에 비밀번호가 그대로 기록되는 버그가 발견되어 3억 3천만 명의 모든 사용자에게 비밀번호 변경을 권고하는 일이 벌어졌습니다.
최고의 엔지니어들이 모인 곳에서도 시스템적인 허점과 단순한 버그 하나가 수억 명의 민감 정보를 무력화시킬 수 있다면, 이는 더 이상 사람의 양심이나 주의력에 기댈 문제가 아니었습니다. 그 순간, 막연했던 불안감은 '언젠가 우리에게도 닥칠 수 있는 현실적인 위협'으로 다가왔습니다.
2. 완벽한 대안을 찾아서: 이상과 현실의 간극
"애초에 서버가 사용자의 비밀번호를 평문으로 받지 않으면 되는 거 아닐까?"
이 불가능해 보이는 목표를 달성하기 위해, 다음과 같은 방안들을 리서치하고 검토했습니다.
첫 번째 시도: 클라이언트에서 해싱
가장 먼저 떠오른 건 클라이언트, 즉 사용자의 브라우저에서 비밀번호를 먼저 해싱해서 서버로 보내는 방법이었습니다.
// client-side.js
import sha256 from 'crypto-js/sha256';
function login(email, password) {
const hashedPassword = sha256(password).toString();
// 서버로는 해싱된 비밀번호만 전송
fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, hashedPassword })
});
}
간단하고 그럴듯해 보입니다. 네트워크를 감청하더라도 원래 비밀번호는 절대 노출되지 않으니까요. 하지만 이 방식은 'Pass-the-hash' 공격이라는 치명적인 허점을 그대로 노출합니다.
서버 입장에서 생각해보면, 서버는 hashedPassword
가 진짜 비밀번호인지, 아니면 원래 비밀번호를 해싱한 값인지 구분할 방법이 없습니다. 그저 DB에 저장된 해시값과 일치하는지만 볼 뿐이죠. 만약 공격자가 어떤 방법으로든 이 hashedPassword
값을 탈취한다면, 원래 비밀번호가 무엇이었는지 몰라도 그 해시값 자체를 비밀번호처럼 사용하여 로그인할 수 있게 됩니다. 결국 해시값이 새로운 비밀번호가 되는 셈이라, 근본적인 해결책이 아니라고 판단했습니다.
두 번째 검토: SRP (Secure Remote Password)
다음으로 검토한 건 SRP입니다. '영지식 증명(Zero-Knowledge Proof)'이라는 개념을 사용하는데, 서버와 클라이언트가 서로에게 '나는 비밀번호를 알고 있다'는 사실을 '증명'만 할 뿐, 비밀번호 자체나 그에 상응하는 어떤 값도 네트워크로 전송하지 않는 방식입니다. 이론적으로는 매우 안전하고 이상적이었습니다.
하지만 이걸 현실 서비스에 적용하기엔 실질적인 제약이 너무 많았습니다. SRP는 클라이언트와 서버가 여러 차례 복잡한 수학적 계산값을 주고받는 '핸드셰이크' 과정이 필요한데, 표준적인 구현체나 최신 웹 환경(React, Spring Boot)에서 바로 가져다 쓸 수 있는 안정적인 라이브러리가 거의 전무했습니다.
학술적으로는 매우 아름다운 방식이지만, 이걸 직접 구현하는 것은 암호학 전문가 수준의 지식과 엄청난 공수를 요구했습니다. 작은 실수 하나가 전체 시스템의 보안을 무너뜨릴 수 있다는 위험 부담도 컸죠. 사이드 프로젝트에서 감당하기에는 리스크가 너무 컸습니다.
최종 선택: WebAuthn
그래서 이미 검증된 '표준' 기술로 눈을 돌렸습니다. 바로 W3C가 표준화하고 FIDO Alliance가 주도하는 WebAuthn입니다.
WebAuthn은 'Web Authentication'의 줄임말로, 비밀번호 대신 사용자의 기기(노트북, 스마트폰)와 생체 정보(지문, 얼굴 인식 등)를 이용해 온라인 서비스를 안전하게 인증하는 W3C 웹 표준입니다. Windows Hello, 안드로이드 지문 인식, Apple의 Face ID/Touch ID가 바로 이 기술을 활용한 대표적인 예입니다.
정확히는 FIDO 얼라이언스가 주도하는 FIDO2 프로젝트의 핵심 웹 표준으로, 브라우저를 위한 WebAuthn API와 인증 장치와 통신하기 위한 CTAP 프로토콜로 구성됩니다. 이 덕분에 웹 개발자는 복잡한 하드웨어나 운영체제를 직접 다루지 않고도 표준화된 방법으로 강력한 인증 기능을 구현할 수 있습니다. 또한, 사용자의 개인 키가 기기 밖으로 절대 나가지 않는 구조 덕분에 피싱 공격에 매우 강력한 저항력을 갖는다는 핵심적인 장점이 있습니다.
WebAuthn과 FIDO에 대한 더 자세한 내용이 궁금하시다면 아래 공식 자료들을 참고해 보세요.
- WebAuthn.guide: WebAuthn의 개념과 작동 방식을 가장 쉽게 이해할 수 있는 사이트
- FIDO Alliance 공식 홈페이지: FIDO 기술 표준과 최신 동향 확인
이 기술의 작동 방식을 간단한 비유로 설명하자면, 모든 사용자가 자신만의 '개인 열쇠(Private Key)'와 그에 맞는 '자물쇠(Public Key)' 한 쌍을 갖게 되는 것과 같습니다.
1. 최초 등록 과정
- 사용자가 우리 서비스에 Passkey를 등록하면, 사용자의 노트북이나 스마트폰은 이 한 쌍의 키를 기기 내 안전한 공간(Secure Enclave 등)에 생성합니다.
- 가장 중요한 점은, '개인 열쇠'는 절대로 기기 밖으로 나가지 않는다는 것입니다. 마치 집 열쇠를 나만 가지고 있는 것과 같습니다.
- 대신, 복제해도 안전한 '자물쇠'만 우리 서비스(AsyncSite) 서버에 등록해둡니다.
2. 로그인 과정
- 이후 사용자가 로그인을 시도하면, 우리 서버는 '이 메시지에 서명해주세요'라는 일종의 '도전(Challenge)'을 보냅니다.
- 사용자는 지문이나 얼굴 인식 등으로 기기에 본인임을 증명하고, 기기는 '개인 열쇠'를 사용해 이 도전에 서명합니다.
- 이 '서명된 결과'만이 서버로 전송됩니다. 서버는 미리 받아두었던 '자물쇠'를 이용해 이 서명이 사용자의 '개인 열쇠'로 만들어진 것이 맞는지 검증만 하면 됩니다.
핵심은 공개키 암호화 방식입니다. 사용자의 기기에 개인키를 두고 서버엔 공개키만 저장합니다. 인증할 땐 비밀번호 대신 기기가 만들어준 '서명'만 서버로 보내는 거죠. 비밀번호 자체가 네트워크를 통해 오고 가지 않으니, 제가 해결하려던 문제의 본질에 정확히 부합했습니다.
복잡하게 직접 무언가를 구현하는 대신, 이미 잘 만들어진 표준을 제대로 이해하고 도입하는 것이 더 현실적이고 안전한 길이었습니다.
3. Passkey 구현기: 표준과 현실 사이의 이슈들
표준 기술을 도입하기로 했다고 해서 모든 과정이 순탄했던 것은 아닙니다.
스펙 문서의 명세와, 실제 세상의 수많은 브라우저와 기기들의 구현 사이에는 분명한 간극이 존재했습니다.
첫 번째 복병: 눈으로 보이지 않는 인코딩의 함정, Base64
vs Base64URL
처음 마주한 문제는 아주 사소해 보이는 곳에서 시작됐습니다. 분명 프론트엔드에서 넘겨준 credentialId
를 그대로 DB에서 조회하는데, 계속 '찾을 수 없음' 에러만 반복됐습니다. 로그를 찍어보니 두 문자열은 눈으로 보기엔 완벽히 똑같았습니다.
// 로그에 찍힌 두 문자열
Frontend Log: credentialId = "Abc-123_Xyz"
Backend Log: credentialId = "Abc-123_Xyz"
몇 시간을 헤맨 끝에야 두 문자열을 바이트 단위로 비교해보고 나서야 원인을 찾을 수 있었습니다. 범인은 Base64
인코딩이었습니다. 일반적인 Base64
는 URL에서 특별한 의미를 갖는 +
, /
문자를 포함하는데, WebAuthn 스펙은 이 문자들을 URL에 안전한 -
, _
로 치환한 Base64URL
을 사용하도록 권고합니다. 한쪽에서는 Base64
로, 다른 한쪽에서는 Base64URL
로 데이터를 다루면서 눈에 보이지 않는 불일치가 발생했던 겁니다.
// 문제 코드
Base64.getEncoder().encodeToString(bytes)
// 해결 코드: URL과 파일명에 안전한 인코더를 사용하고, 패딩을 제거
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
클라이언트와 서버 양쪽의 모든 인코딩/디코딩 로직을 스펙에 명시된 Base64URL
로 통일하고 나서야 이 미묘하고 끈질긴 버그를 잡을 수 있었습니다.
두 번째 딜레마: 삼성 패스와 signCount
두 번째 딜레마는 '재사용 공격(Replay Attack)'이라는 고전적이면서도 강력한 위협을 막는 과정에서 시작됐습니다. 재사용 공격이란, 해커가 네트워크상의 정상적인 인증 요청을 그대로 가로채서, 나중에 그 요청을 다시 서버로 보내 마치 합법적인 사용자인 것처럼 위장하는 방식입니다.
WebAuthn 스펙은 이 문제를 signCount
라는 똑똑한 장치로 막습니다. 일종의 '요청 카운터'라고 생각하면 쉽습니다. 서버는 DB에 마지막으로 성공한 요청의 카운트(예: 5)를 저장해 둡니다. 사용자가 정상적으로 로그인하면, 기기는 다음 숫자인 6
을 포함해서 요청을 보냅니다. 서버는 6
이 저장된 5
보다 크다는 것을 확인하고 인증을 성공시킨 뒤, DB의 값을 6
으로 업데이트합니다. 만약 해커가 6
이 포함된 요청을 훔쳐서 다시 보낸다면, 서버는 DB에 저장된 6
보다 크지 않으므로 이 요청을 '이미 사용된 요청'으로 판단하고 거부하게 됩니다.
이론적으로는 완벽했지만, 테스트 과정에서 예상치 못한 현실과 마주했습니다. 특정 기기들, 특히 삼성 패스를 사용하는 경우 이 signCount
값이 증가하지 않고 항상 0
으로 고정되어 오는 것이었습니다.
스펙대로라면 이 요청은 signCount
규칙 위반이므로 당연히 거부해야 했습니다. (0 > 6)
은 거짓이니까요. 하지만 그랬다간 국내의 수많은 갤럭시 사용자들은 Passkey를 쓸 수 없게 되는 상황이었습니다. 스펙을 지킬 것인가, 현실과 타협할 것인가의 딜레마였죠.
결론적으로 저는 실용성과 호환성을 선택하기로 했습니다. signCount
가 0
으로 오는 예외적인 경우에만 검증을 건너뛰도록 수정한 것입니다.
// signCount 검증 로직 수정
val credential = credentialRepository.findById(req.id).orElseThrow()
val newSignCount = parsedAuthenticatorData.signCount.toLong()
// 기존 signCount가 0보다 클 때만 검증 로직을 수행 (최초 등록 시 0)
// 삼성 패스 등 일부 authenticator가 항상 0을 반환하는 경우를 허용
if (credential.signCount > 0) {
require(newSignCount > credential.signCount) {
"Sign count validation failed. Possible replay attack."
}
}
// 새로운 signCount로 업데이트
credential.signCount = newSignCount
이런 결정을 내릴 수 있었던 데에는 WebAuthn의 또 다른 강력한 방어선인 Challenge
덕분이었습니다. Challenge
는 서버가 인증을 시작할 때마다 생성하는, 암호학적으로 안전한 일회성 난수(random number)입니다. 서버는 클라이언트에게 이 Challenge
값을 보내며 "이 값을 포함해서 서명해줘"라고 요청하고, 클라이언트가 보낸 서명에 올바른 Challenge
값이 포함되어 있는지를 검증합니다.
해커가 Challenge
가 xyz789
일 때의 인증 요청을 가로챘더라도, 다음 로그인 시도에는 서버가 pqr456
이라는 완전히 새로운 Challenge
를 요구하기 때문에 훔친 요청은 쓸모가 없어집니다. 이 강력한 Challenge
메커니즘이 이미 재사용 공격을 효과적으로 막고 있다고 판단했기에, signCount
에 대해서는 호환성을 위한 예외를 둘 수 있었습니다.
여기서 잠깐.
Q. 삼성이 스펙을 안 지키는 것처럼 보이는데, 괜찮은 건가요?
A. 저도 처음엔 의아했지만, 결론부터 말하면 스펙을 지키고 있는 것이 맞습니다. WebAuthn 표준은 여러 기기에 걸쳐 키를 동기화해야 하는 플랫폼 인증기기(삼성 패스 등)가 카운터를 안정적으로 구현하기 어려운 현실을 고려했습니다. 그래서 카운터를 지원하지 않는 경우, signCount를 0으로 반환하도록 명시적으로 허용하고 있습니다. 이 때문에 재사용 공격을 막는 진짜 핵심 방어선은 선택 사항인 signCount가 아니라, 필수 사항인 Challenge가 되는 것입니다.
세 번째 복병: "제 로컬에선 잘 되는데요?"
마지막 문제는 '제 로컬 환경에선 잘 되는데요?'라는, 개발자라면 누구나 한 번쯤 겪어봤을 법한 상황이죠. 로컬 환경(localhost
)에서는 아무 문제 없이 Passkey 등록과 로그인이 잘 되는데, 개발 서버(asyncsite.com
)에 배포만 하면 rpIdHash
가 일치하지 않는다는 에러가 발생하며 인증이 실패했습니다.
WebAuthn 스펙에서 rpId
(Relying Party ID)는 '신뢰 당사자 ID', 즉 우리 서비스의 도메인을 의미합니다. 브라우저는 피싱 공격을 막기 위해 현재 접속한 사이트의 도메인이 Passkey를 등록했던 rpId
와 일치하는지 검증하고, 이 rpId
를 해싱한 rpIdHash
를 서버로 보냅니다. 서버는 이 해시값을 검증하여 요청의 신뢰성을 확인하는 중요한 절차입니다.
원인은 단순했지만, 그래서 더 찾기 어려웠습니다. 서버의 설정 파일에 rpId
가 localhost
로 하드코딩되어 있었던 겁니다. 로컬에서 테스트할 때는 당연히 문제가 없었지만, 서버 환경에서는 브라우저가 보낸 asyncsite.com
의 해시값과 서버가 기대하는 localhost
의 해시값이 다르니 인증이 실패할 수밖에 없었습니다.
이 문제를 해결하기 위해 환경별로 설정 파일(.yml
)을 분리하여, 각 환경에 맞는 rpId
가 동적으로 설정되도록 수정했습니다.
# application-local.yml
webauthn:
rp-id: localhost
origin: http://localhost:3000
# application-prod.yml
webauthn:
rp-id: asyncsite.com
origin: https://asyncsite.com
결국 Passkey 구현 과정은 단순히 스펙을 코드로 옮기는 작업이 아니었습니다. 인코딩의 미세한 차이부터, 거대 기업의 독자적인 구현, 그리고 개발과 운영 환경의 차이까지, 수많은 '현실'의 변수들을 고려하고 해결해나가는 과정이었습니다. 이 과정을 통해 표준 기술을 제대로 다룬다는 것의 의미를 다시 한번 고민하게 되었습니다.
4. 숫자 대신 '확신'을 얻다: Passkey가 우리에게 먼저 준 선물
아직 정식으로 서비스를 런칭하기 전이라, '로그인 전환율 N% 상승' 같은 화려한 지표를 보여드릴 수는 없습니다. 하지만 숫자로 표현할 수 없는, 어쩌면 그보다 더 중요한 것들을 Passkey를 구현하며 먼저 얻을 수 있었습니다. 바로 개발자로서 느끼는 '확신'입니다.
"와, 이게 되네."
로컬 환경에서 처음으로 Passkey 로그인을 테스트하던 순간을 기억합니다. 이메일을 입력하고 버튼을 누르자마자, 노트북의 지문 인식 센서에 불이 들어왔고, 손가락을 대자마자 1초도 안 되어 로그인이 완료되었습니다. 복잡한 비밀번호를 타이핑하고, 2FA 코드를 확인하던 기존의 과정이 주마등처럼 스쳐 지나갔습니다. 사용자에게 이 경험을 빨리 전달하고 싶다는 강한 동기부여가 되었습니다.
사라질 코드, 줄어들 걱정
물론 아직 기존의 비밀번호 로그인과 OAuth 2.0(Google) 로그인을 지원하기에 '비밀번호 찾기' 기능이 완전히 사라지진 않았습니다. 하지만 Passkey라는, 비밀번호를 잊어버릴 걱정이 없는 완벽한 대안이 생겼습니다. 이제 '비밀번호 찾기'는 점차 사용 빈도가 줄어들, 언젠가는 사라질 '레거시(legacy)' 기능이 된 셈입니다.
개발자로서 가장 만족스러운 순간 중 하나는 복잡하고 골치 아픈 코드를 자신 있게 삭제할 수 있을 때입니다. 비록 지금 당장 이메일 인증, 임시 비밀번호 발급 등의 코드를 지우진 못했지만, Passkey를 구현함으로써 이 기능에 대한 사망 선고를 내리고, 언젠가 삭제할 수 있다는 확신을 얻은 것만으로도 비슷한 만족감을 느낄 수 있었습니다.
느슨한 결합, 견고한 시스템
Passkey가 없는 신규 사용자를 등록시키는 과정에는 이메일로 OTP를 보내는 절차가 필요했습니다. 가장 간단한 방법은 User Service가 직접 이메일 발송 API를 호출하는 것이었겠죠. 하지만 MSA 환경에서 이런 동기(Synchronous) 방식의 직접 호출은 '장애 전파'라는 위험을 안고 있습니다. 만약 알림을 담당하는 Noti Service가 잠시라도 응답이 없으면, 그 장애는 User Service로 전파되어 회원가입 전체가 실패하게 됩니다.
그래서 이 과정에 Kafka를 도입했습니다. User Service는 'Passkey 등록을 위한 OTP가 필요하다'는 사실을 이벤트로 발행해 Kafka에 던져두기만 하면 역할이 끝납니다. 그 이후의 실제 이메일 발송은 Noti Service가 Kafka로부터 이벤트를 구독해서 비동기적으로 처리합니다. 이 구조 덕분에 두 서비스는 서로의 상태에 영향을 받지 않는, 느슨하지만 견고한 연결을 갖게 되었습니다. 새로운 기능을 추가하면서, 동시에 시스템 전체의 안정성을 높이는 방향으로 아키텍처를 개선할 수 있었다는 점 역시 개발자로서 큰 확신을 주었습니다.
"훔칠 비밀번호가 없다"
그리고 가장 중요한 것. 바로 '훔칠 비밀번호가 없다'는 기술적 자신감입니다. 이 글의 맨 앞에서 언급했던, '해싱 전 평문'에 대한 오랜 찜찜함이 완전히 해소되는 순간이었습니다. 이제 악의적인 내부자가 서버에 접근하더라도, 혹은 시스템 어딘가에 치명적인 취약점이 발견되더라도, 사용자의 계정을 직접적으로 탈취할 수 있는 비밀번호는 서버 그 어디에도 존재하지 않습니다. 이것은 출시를 앞둔 서비스의 개발자로서 얻을 수 있는 가장 큰 심리적 안정감이었습니다.
5. 코드 너머 신뢰를 설계한다는 것
Passkey 도입 여정을 돌이켜보면, 단순히 하나의 인증 기능을 추가한 것 이상의 의미가 있었다는 생각이 듭니다.
기존의 비밀번호 시스템이 '유출된 후에도 안전하게'를 목표로 하는 사후 대응에 가까웠다면, Passkey는 '애초에 유출될 비밀이 없도록' 만드는 사전 예방에 가깝습니다. 이것은 개발자로서 '보안'을 바라보는 관점 자체를 바꾸는 경험이었습니다. 더 복잡한 암호화 알고리즘을 추가하는 것이 아니라, 사용자의 민감한 정보를 아예 만지지 않는 방식으로 시스템을 설계하는 것. 그것이 더 높은 수준의 신뢰를 구축하는 길이라는 것을 깨달았습니다.
이제 AsyncSite는 사용자에게 더 안전하고 편리한 로그인 선택지를 제공할 수 있게 되었습니다. 물론 모든 사용자가 하루아침에 Passkey로 전환하지는 않을 겁니다. 기존 비밀번호 로그인 방식과 공존하며 점진적으로 전환을 유도해야 하는 과정이 남아있습니다.
결국 인증은 사용자가 우리 서비스의 진짜 가치인 '함께 성장하는 스터디'를 경험하기 위한 첫 관문일 뿐입니다. Passkey 도입은 그 관문의 문턱을 낮추고, 더 안전하게 만들기 위한 노력의 일환이었습니다.
개발자는 코드를 작성하는 사람이지만, 동시에 사용자의 경험과 신뢰를 설계하는 사람이기도 합니다. 이번 Passkey 도입은 기술을 통해 어떻게 더 나은 신뢰를 설계할 수 있는지 고민해볼 수 있었던 값진 경험이었습니다.