들어가며
현재 프로젝트에서 Keycloak 기반 로그인과 계정 관련 화면을 맡아 유지보수하고 있습니다. 이번에는 직원 계정 최초 설정 화면에서 새로고침 시 일반 비밀번호 찾기 화면으로 바뀌는 문제를 확인하고 원인을 분석했습니다.
처음에는 일반적인 React SPA처럼 생각해서, 화면을 구분하는 값을 URL 쿼리 파라미터에만 넣어주면 될 것이라고 생각했습니다. 하지만 코드를 따라가 보니 Keycloakify에서는 그 방식만으로는 충분하지 않았습니다. 원인은 Keycloakify의 화면 전환 방식과 Keycloak 라운드트립 과정에 있었습니다.
URL만으로는 부족했던 화면 상태 관리
처음 문제를 봤을 때는 단순하게 접근했습니다. 직원 계정 최초 설정 화면과 일반 비밀번호 찾기 화면을 구분해야 하므로, URL에 page_hint=isTemp 같은 값을 붙이고 그 값을 기준으로 화면을 나누면 된다고 생각했습니다.
일반적인 React SPA라면 이 방식이 크게 어색하지 않습니다. 화면 이동은 보통 react-router로 처리하고, URL path나 query parameter가 바뀌면 그에 맞는 컴포넌트를 렌더링할 수 있습니다. 화면 상태도 React state나 query parameter로 관리하는 경우가 많습니다. 하지만 Keycloakify 화면은 조금 다르게 동작합니다. Keycloak은 로그인, 비밀번호 재설정, 비밀번호 변경 같은 화면을 pageId 단위로 내려줍니다.
login.ftl
login-reset-password.ftl
login-update-password.ftl
Keycloakify는 이 pageId에 맞는 React 컴포넌트를 렌더링합니다. 즉 화면은 React로 만들지만, 큰 흐름은 Keycloak의 인증 플로우 안에서 움직입니다. 예를 들어 비밀번호 찾기 화면으로 이동할 때도 단순히 React 내부 라우터로 이동하는 것이 아니라, Keycloak이 제공한 URL로 전체 페이지 이동을 합니다.
const findPassword = useCallback(() => {
if (!("loginResetCredentialsUrl" in kcContext.url)) return;
window.location.href = kcContext.url.loginResetCredentialsUrl;
}, [kcContext.url]);
이 방식에서는 화면이 바뀔 때 페이지가 통째로 다시 로드되므로 React state에만 저장된 값은 유지되지 않습니다. 일반 SPA에서는 자연스럽게 유지될 것 같은 상태도, Keycloakify에서는 화면 이동 과정에서 사라질 수 있습니다.
Keycloak 라운드트립과 상태 유실
그렇다면 URL 쿼리에 값을 넣으면 될 것 같지만, Keycloakify에서는 이것만으로도 부족합니다. 이유를 이해하려면 먼저 "라운드트립"이 무엇인지 짚어야 합니다.
여기서 말하는 라운드트립은 요청이 브라우저를 떠나 Keycloak 서버까지 갔다가 새 화면이 되어 다시 돌아오는 한 바퀴를 뜻합니다. 일반 SPA에서는 화면이 바뀌어도 브라우저는 같은 페이지에 머문 채 자바스크립트가 화면만 갈아끼웁니다. 반면 Keycloak 인증 흐름은 그렇지 않습니다. 로그인이나 비밀번호 재설정 같은 흐름에서는 프론트가 직접 API를 호출하지 않고, Keycloak이 내려준 action URL로 form POST를 합니다.
<form method="post" action={kcContext.url.loginAction}>
이 form이 submit되면 흐름은 대략 다음과 같이 흘러갑니다.
-
브라우저가 현재 React 화면을 떠나 Keycloak 서버로 POST 요청을 보냅니다.
-
Keycloak이 서버에서 인증 플로우를 처리합니다.
-
처리 결과에 맞는 다음 화면(다음 pageId)을 서버가 새로 만들어 내려줍니다.
-
브라우저는 그 응답을 받아 페이지를 처음부터 다시 렌더링합니다.
즉 화면 전환의 주도권이 React가 아니라 Keycloak 서버에 있고, 그 과정에서 페이지가 통째로 새로 그려집니다. 이 한 바퀴가 라운드트립입니다.
문제는 이때 브라우저 URL도 Keycloak이 다시 만들어 내려준다는 점입니다. 따라서 이전 화면에서 내가 붙여 둔 ?page_hint=isTemp 같은 query parameter가 다음 화면까지 그대로 살아 있다고 보장할 수 없습니다. 반대로 React state는 페이지가 새로 로드되는 순간 당연히 사라집니다. 그래서 이 프로젝트에서는 page_hint를 URL과 sessionStorage 양쪽에서 관리하고 있었습니다.
let page = params.get("page_hint") || sessionStorage.getItem("page_hint");
같은 값을 두 군데서 읽는 이유는 라운드트립을 고려하면 분명해집니다.
-
URL query는 현재 화면 상태를 드러내기 좋지만, 라운드트립 중 사라질 수 있습니다.
-
sessionStorage는 새로고침·라운드트립 후에도 남지만, 너무 오래 남으면 다른 흐름에 영향을 줍니다.
즉 URL만으로도 부족하고 sessionStorage만으로도 위험합니다. 그래서 sessionStorage에 값을 백업해 두고, 렌더링 시 다시 URL에 반영하는 구조가 존재했습니다.
function syncPageHintToUrl(pageHint: string) {
const url = new URL(window.location.href);
url.searchParams.set("page_hint", pageHint);
window.history.replaceState(null, "", url.toString());
}
page_hint로 세부 화면을 구분하는 방식
실제 서비스에서는 Keycloak의 pageId 하나 안에서도 page_hint를 사용하여 여러 화면을 나눠 보여줘야 했습니다. 비밀번호 재설정 화면도 일반 비밀번호 찾기 화면과 직원 계정 최초 설정 화면을 구분해야 했습니다. 직원 계정 최초 설정으로 들어갈 때는 다음처럼 page_hint를 isTemp로 저장한 뒤 비밀번호 재설정 흐름으로 이동했습니다.
onClick={() => {
sessionStorage.setItem("page_hint", "isTemp");
links.findPassword();
}}
도착한 컨테이너에서는 이 값을 기준으로 어떤 화면을 보여줄지 결정했습니다.
return isTemp
? <ResetVerifyStaff kcContext={kcContext} />
: <FindPassword kcContext={kcContext} />;
새로고침 시 화면이 바뀐 원인
문제는 직원 계정 최초 설정 화면에서 새로고침을 했을 때 발생했습니다. 처음 진입할 때는 직원 화면이 정상적으로 보였지만, 새로고침 후에는 일반 비밀번호 찾기 화면으로 바뀌었습니다. 원인을 따라가 보니, 같은 컨테이너 안에 다음 코드가 있었습니다.
useEffect(() => {
sessionStorage.removeItem("page_hint");
const url = new URL(window.location.href);
url.searchParams.delete("page_hint");
window.history.replaceState(null, "", url.toString());
}, []);
컨테이너가 마운트되자마자 직원 화면을 구분하는 page_hint를 sessionStorage와 URL에서 모두 삭제되어서 새로고침 시 직원 화면임을 판단할 수 없었던 것입니다.
삭제 코드가 들어간 이유 확인
바로 제거하려다, 인증·계정 화면은 여러 흐름이 얽혀 있어 커밋 이력부터 확인했습니다. 이 삭제 코드는 마이페이지 비밀번호 변경 화면의 오류를 막기 위한 방어 코드였습니다. page_hint=isTemp가 sessionStorage에 계속 남으면, 이후 다른 경로로 비밀번호 변경 화면에 진입했을 때도 직원 화면으로 잘못 판단될 수 있어 진입 후 삭제하고 있었습니다.
여기서 문제의 본질이 드러났습니다. page_hint=isTemp는 두 역할을 동시에 하고 있었습니다.
-
직원 계정 최초 설정 흐름을 유지하기 위한 값
-
다른 일반 흐름에는 남아 있으면 안 되는 값
한쪽에서는 유지하고 다른 쪽에서는 삭제해야 하므로, 단순히 “삭제 로직을 제거”하면 다른 흐름에 영향을 줄 수 있었습니다.
개선 방향
다시 진입 경로를 확인해 보니, 비밀번호 재설정 화면으로 들어오는 주요 진입부에서는 이미 출발 시점에 page_hint를 명시적으로 세팅하고 있었습니다.
// 일반 비밀번호 찾기
onClick={() => {
sessionStorage.setItem("page_hint", "");
links.findPassword();
}}
// 직원 계정 최초 설정
onClick={() => {
sessionStorage.setItem("page_hint", "isTemp");
links.findPassword();
}}
즉 LoginResetPasswordContainer에서는 진입 시점에 이미 흐름이 정해져 있는데, 도착 후 무조건 page_hint를 삭제하면서 새로고침 시 화면 상태가 유지되지 않았습니다. LoginUpdatePasswordContainer는 마이페이지 비밀번호 변경 흐름과 연결되어 있어서 기존 방어 로직이 여전히 필요했습니다. 반면 LoginResetPasswordContainer는 진입부에서 이미 page_hint를 명시적으로 세팅하고 있었기 때문에, 도착 후 무조건 삭제할 필요가 없다고 판단하여 다음과 같이 수정했습니다.
-
LoginResetPasswordContainer의 마운트 시 page_hint 삭제 로직 제거
-
LoginUpdatePasswordContainer의 삭제 로직은 유지
수정을 많이 하지는 않았지만 안전하게 지우기 위해 Keycloakify의 라우팅 구조, 라운드트립 과정, 기존 방어 코드가 들어간 이유를 함께 확인해야 했습니다.
정리
이번 문제는 화면 구분 값인 page_hint가 마운트 직후 삭제되어, 새로고침 시 직원 화면임을 판단할 수 없던 것이 원인이었습니다. 작업을 통해 Keycloakify 기반 화면은 일반 React SPA와 다르게 상태를 관리해야 함을 이해했습니다. Keycloak이 화면 전환을 주도하고 form POST 이후 서버를 거쳐 화면이 다시 내려오는 라운드트립이 있어, URL query나 React state만으로는 화면 상태를 안정적으로 유지하기 어렵습니다.
또한 sessionStorage처럼 오래 남는 저장소를 사용할 때는 다른 흐름으로 상태가 새어 들어가지 않도록 주의해야 합니다. 이번처럼 하나의 page_hint 값이 “화면 유지”와 “흐름 구분”이라는 역할을 동시에 가지면, 어느 시점에는 유지해야 하고 어느 시점에는 삭제해야 하는 충돌이 생길 수 있습니다.
결과적으로 이번 수정은 몇 줄을 제거하는 작업이었지만, 그 몇 줄을 안전하게 제거하기 위해서는 Keycloakify의 라우팅 구조와 기존 상태 관리 의도를 먼저 이해해야 했습니다.
Lynn