Intro 👋
배경 : 본인인증 서비스 리뉴얼하는 태스크를 진행했었다.
AS-IS
수도코드로 구현한 기존 구조는 아래와 같다.
마찬가지로 백엔드 로직은 예민할 수 있어 정말 최소한의 수도코드만 작성했다.
본인인증 시작점
<button name='본인인증 버튼' onClick=doAuth() />
<script>
function doAuth() {
window.afterCallback = afterCallbackFunc; // 윈도우객체에 콜백함수 삽입
window.open('authWindow', '/PhoneAuth') ; // 본인인증 창 열어주기
}
</script>
- 먼저 callback함수를 윈도우객체에 심는다.
- callback함수 내부에는 본인인증 이후에 할 행동들(회원가입관련 검증 api를 호출한다던가, 단순 인증완료후 페이지 이동이라던가) 이 정의되어있다.
서버의 엔트리포인트
@GetMapping("/PhoneAuth")
public String authEntryPoint() {
doingSomeReserveActions(); // 인증을 위한 작업 진행
setReserveDataOnSession(); // 작업 결과 데이터중 특정 값들을 세션에 저장
setDataForCallingModuleOnModel(); // 모델에 본인인증 모듈 호출을 위한 값들을 심어주고
return "authView"; // 뷰를 리턴한다.
}
- 간단하게 소개하면 딱 이정도 인 것 같다.
- 세션에 인증 예약정보를 심어주고, 모듈 호출을 위한 메타데이터들을 모델에 싣어준다.
서버 엔트리포인트가 반환한 뷰에서
<form name="본인인증 폼">
<fieldsFromServer />
<fieldsFromServer />
<fieldsFromServer />
<form />
<script>
...
$form.submit(); // 본인인증 사이트 이동!
</script>
- fieldsFromServer는 위에서 모델에 실어준 데이터고, form을 submit하면서 본인인증사이트로 접근한다.
본인인증 사이트가 콜백해주는 api
@GetMapping("/PhoneAuthCallback")
public String authEntryPoint() {
setAuthHashOnSession(); // 세션에 인증 완료 데이터를 심는다
setDataOnModel(); // 마찬가지로 인증 결과 데이터를 모델에 싣고
return "afterCallback"; // 뷰를 리턴한다.
}
콜백함수가 반환하는 뷰에서
<div />
<script>
var params = getParamsOnModel(); // 본인인증 이후 데이터를 추출해서
opener.afterCallback(...params); // 부모창에 아까 심어뒀던 함수를 호출해주고
window.close(); // 창을 닫아준다.
</script>
- 아까 심어뒀던 callBack함수를 호출하면서 끝난다.
해야 할 일 정리하기 ✅
백엔드는 api 정리하는게 복잡하고 일이 조금 많았지만, 해야 할 일은 단순했다.
- 세션에 데이터 심어주고 모델에 주던 데이터를 내려주는 인터페이스로 만들기
- 프론트가 콜백을 받게 되었으니, 기존 Callback api에서 해주던 일을 하는 인터페이스 추가하기
- callback 이후 호출하는 api들도 view와 엮여있다면 restApi로 정리하기 이렇게 끝이었다.
다만 신규 프론트엔드 프로젝트에서 보안적인 이유로 window를 직접 이용하는게 불가능하게 되었고, 본인인증 api를 호출하기 위한 컨텍스트가 부족해서 다른 로직이 생겨났는데, 정교하게 관리할 필요가 생겼다.
또 심지어 신규도입하기로한 특정 인증대행사의 sdk는 정말 독특한구조를 가지고 있어서 opener로 처리 할 수 없었다. (sdk를 실행시킬때 윈도우에 심어둔 함수 이름을 적어서 실행시킨다던가…)
이런저런 시행착오를 겪던 도중 postMessage를 프론트 팀원분께 소개받아서 해당 기능을 이용하기로 했다.
결론적으로 정리하면
- 백엔드 api 개편
- 기존 로직을 신규 프론트엔드에서 postMessage로 리뉴얼
TO-BE
인증창을 열어주는 부분
export const openIdentityVerificationWindow = (
authType: AuthType,
setAuthData: (authData: IAuthData, someToken: string) => void // 인증 이후 콜백
) => {
let openUrl: string;
let target: string;
let features: string;
if (authType === AuthType.Phone) {
openUrl = OUR_AUTH_PAGE_URL;
target = 'popupPhoneAuth';
features = PHONE_WINDOW_FEATURES;
}
const handleMessage = async (event: MessageEvent) => { // 콜백을 실행시키는 이벤트핸들러
if (event.origin !== window.location.origin) {
return;
}
const { data } = event;
if (data.type === 'AUTH_SUCCESS') {
const { authData, someToken } = data.payload;
await setAuthData(authData, csrfToken);
window.removeEventListener('message', handleMessage);
}
};
window.addEventListener('message', handleMessage); // 이벤트 핸들러 등록
window.open(openUrl, target, features);
};
- 서로 다른 오리진끼리의 통신이 가능한게 주요한 특징이다.
- 우리는 콜백받아서 부모창을 호출하는 용도이므로 보안적으로 꼭 오리진 체크를 해줘야한다. postMessage참고!
- 콜백과 추가적으로 별도의 인증로직을 정갈하게 정리 할 수있다.
- 아무튼 콜백은 창을 열어주는 시점에 전달하는것은 동일하지만, 윈도우객체에 직접 심어주는것에서 postMessage 호출 이벤트의 리스너에서 호출하도록 수정했다.
인증 윈도우 페이지
const PhoneAuthPage = () => {
const { isSuccess: hasAuth } = useObserverAuthRefresh();
const formRef = useRef<ElementRef<'form'> | null>(null);
// 아까 그 model에 실어주던 데이터
const { mutate: mutationKcb } = useMutationMembershipPhoneAuthInfo<PhoneAuthInfo>({
onSuccess: ({ data: { 데이터들 } }) => {
if (formRef.current) {
// form 데이터 셋해주기!
}
formRef.current.submit();
},
onError(error) {
console.error('응답 오류 : ', error);
...
},
});
useEffect(() => {
if (hasAuth && formRef.current) {
mutationPhoneAuth();
}
}, [hasAuth, mutationPhoneAuth, formRef]);
return <PhoneAuthForm ref={formRef} />;
};
export default PhoneAuthPage;
- 자세히 이야기 하기는 어렵지만, 새 창에서 인증관련 컨텍스트가 부족해서 호출이 안되는 부분을 보완하기위해 별도의 훅을 만들어서 처리했다.
- 그 외에는 tanstack의 useQuery, useMutation을 프론트분들이 예쁘게 사용하는걸 보고 나도 작성해서 사용했다.
콜백 페이지
const PhoneAuthCallbackPage = () => {
const searchParams = useSearchParams();
const { isSuccess: hasAuth, data: authData } = useObserverAuthRefresh();
const { isApp } = useDevice();
const { mutate: mutatePhoneAuthCallback } = useMutationMembershipPhoneAuthCallback({
onSuccess: (response) => {
if (authData) {
handleAuthSuccess(response, authData);
}
},
onError: () => {
handleAuthFailure(failureMessage);
},
});
useEffect(() => {
if (hasAuth && authData && searchParams) {
mutatePhoneAuthCallback({ searchParams });
} }, [hasAuth, authData, searchParams]);
return <div />;
};
export default PhoneAuthCallbackPage;
export const handleAuthSuccess = (ourData: ResData<ourData>, authData: OurAuthData) => {
if (response.data && authData?.ourToken) {
const payload = {
type: 'AUTH_SUCCESS',
payload: {
authData: ourData.data,
ourToken: authData.ourToken,
}, };
}
window.opener.postMessage(payload, window.location.origin);
self.close();
return;
}};
- 위와 같이 아까 등록한 이벤트리스너가 호출되도록 함수를 호출한다.
- 그러면 미리 저장했던 콜백함수가 호출되면서 이후 절차로 넘어가게 된다.
후기
막상 정말 많은 작업과 시행착오를 거쳤는데, 보안관련된 부분이 많아 자세히 작성할 수 없어서 중간에 포스팅을 그만할까 고민했다. 그래도 postMessage관련해서 사용예시를 정리한 정도로도 괜찮을 것 같아서 작성했다.
기존 구조를 그대로 유지하면서 보완하는 것보다, 새로운 패턴을 도입하는 것이 유지보수성 측면에서 더 나은 경우가 있다.
- 기존 구조를 수정하는 방향도 검토했지만, 결국 보안성과 확장성을 고려하면 새로운 방식이 더 적합했다.
보안과 확장성을 고려한 API 설계의 중요성
- 오리진 체크를 철저히 해야 하고, 인증 데이터를 안전하게 주고받을 수 있도록 설계해야 했다.
- 단순히 기능 구현이 아닌, “이 방식이 앞으로도 안전하고 유연하게 유지될 수 있을까?” 라는 질문을 계속 던지며 설계했다.
- 사실 일정이 촉박해지고 나서는 “이 방식이 기존 방식 만큼은 안전한가"를 주요한 기준으로.
프론트엔드와 백엔드 간 명확한 역할 분리 필요
- 기존 구조에서는 백엔드에서 인증 후 UI 로직까지 일부 관여하고 있었지만,
- 리뉴얼 후에는 백엔드는 인증 결과를 반환하는 API 제공에 집중하고, 프론트엔드는 이를 처리하는 역할로 분리되었다.
- 덕분에 백엔드 API도 RESTful하게 정리할 수 있었고, 프론트엔드에서도 관리가 용이해졌다.
새로운 기술을 도입할 때는 작은 실험과 반복적인 검증이 중요하다.
- 처음에는 postMessage를 도입하는 것이 최선인지 확신이 없었고, 몇 가지 다른 방식도 고려했지만, 실제로 작동하는 최소한의 프로토타입을 만들어보면서 점진적으로 확신을 가지게 되었다.