Keycloakify : React에서 에러 메시지 동시 노출을 위한 시행착오와 최적화

Keycloakify : React에서 에러 메시지 동시 노출을 위한 시행착오와 최적화

Keycloakify[1]: React에서 에러 메시지 동시 노출을 위한 시행착오와 최적화

1. 배경

기본적인 인증 페이지는 보안상 이유로 혹은 레거시[2] 방식에 따라 한 번에 하나의 에러 메시지만 노출하는 경우가 많습니다. 예를 들어 아이디와 비밀번호를 모두 잘못 입력했더라도 "아이디가 잘못되었습니다"라는 메시지만 먼저 보여주는 식입니다.

하지만 회원가입 절차가 복잡하고 입력 항목이 많은 프로젝트 특성상, 이러한 방식은 사용자가 수정과 제출을 수차례 반복하게 만들어 큰 피로감을 줍니다.

사용자가 폼을 제출했을 때 발생한 모든 유효성 검사 오류를 즉각적으로 파악하고 한 번에 수정할 수 있도록 필드별 에러 동시 노출 기능을 구현하여 가입 이탈률을 낮추고자 했습니다.

2. 적용 과정

초기 구현 단계에서는 이 문제를 가장 익숙한 React의 상태 관리(State Management) 방식으로 해결하려 했습니다. Keycloak[3] 서버로부터 전달받은 에러 객체를 useEffect 내에서 가로채어 별도의 errors 상태(State)에 담고, 이를 화면에 매핑(Mapping)하여 보여주는 구조를 설계했습니다.

*예시 코드 : 아이디(username)와 이메일(email) 필드에 대한 에러 메시지를 화면에 동시에 노출하는 로직을 구현하였습니다.

// 1. 에러를 관리하기 위한 복잡한 인터페이스 정의
interface IFormErrors {
    email?: string;
    username?: string;
}

const Register = (props: PageProps) => {
    const { kcContext } = props;
    const [fieldErrors, setFieldErrors] = useState<IFormErrors>({});

    useEffect(() => {
        // [문제 발생] 서버에서 온 메시지가 '이메일 중복'인지 '아이디 중복'인지
        // 혹은 둘 다인지 일일이 검사해서 State를 업데이트해야 함
        if (kcContext.message && kcContext.message.type === "error") {
            const newErrors: IFormErrors = {};

            // Keycloakify가 제공하는 원천 데이터(FTL 변수)를 수동으로 매핑 시도
            if (kcContext.emailError !== undefined) newErrors.email = kcContext.emailError;
            if (kcContext.usernameError !== undefined) newErrors.username = kcContext.usernameError;

            setFieldErrors(newErrors);
        }
    }, [kcContext.message]);

    return (
        <form>
            <input name="email" type="email" placeholder="이메일 입력" />
            {/* 상태값(State)에 의존하여 에러 노출 */}
            {fieldErrors.email && <span className="err-msg">{fieldErrors.email}</span>}

            <input name="username" type="text" placeholder="아이디 입력" />
            {fieldErrors.username && <span className="err-msg">{fieldErrors.username}</span>}
        </form>
    );
};

3. 문제 해결 경험

익숙한 방식인 useState와 useEffect를 도입했을 때 두 가지 치명적인 문제에 직면했습니다.

3-1. 동기화 및 생명주기[4]불일치

Keycloak은 서버 사이드[5]에서 검증 후 페이지를 다시 렌더링하는 FTL(FreeMarker)[6] 기반으로 동작합니다. 이를 React State로 강제 이관하는 과정에서, 서버가 보내준 에러 데이터가 State에 반영되기까지 지연이 발생하거나(한 박자 늦은 노출), 페이지 새로고침 시 상태가 유실되는 현상이 나타났습니다. 즉 Keycloak은 서버 사이드에서 모든 검증을 마치고 결과를 FTL 템플릿에 담아 보내는데, 이를 클라이언트[7]의 React State로 옮기려 하면 서버와 클라이언트 사이의 생명주기가 어긋나 데이터가 유실되거나 렌더링이 꼬이는 구조적인 문제가 발생하게 되는 것입니다.

3-2. 복잡도 증가

단순한 에러 노출을 위해 수많은 if문과 타입스크립트의 엄격한 타입 정의가 추가되면서 코드가 비대해졌습니다.

3-3. 해결 방법

Keycloakify가 내부적으로 래핑(Wrapping)해둔 messagesPerField 객체를 직접 활용하기로 방향을 선회했습니다. 이 객체는 이미 서버로부터 넘어온 모든 필드별 에러 정보를 포함하고 있었고, 이를 통해 별도의 상태 관리 없이도 각 입력 필드 하단에 실시간으로(서버 렌더링 시점에 맞춰) 에러를 동시에 노출할 수 있었습니다.

*예시 코드 : 서버의 검증 결과 객체를 직접 참조하여, 여러 필드에 에러가 동시에 존재하더라도 각 필드가 자신의 에러를 스스로 찾아 출력하도록 구현했습니다.

const Register = (props: PageProps) => {
    const { kcContext } = props;

    /* [변경점 1] 복잡한 useState, useEffect를 모두 삭제했습니다.
       Keycloak 서버가 검증 후 페이지를 다시 그릴 때(SSR),
       이미 완성된 에러 객체인 messagesPerField를 바로 활용합니다. */
    const { messagesPerField } = kcContext;

    return (
        <form action={kcContext.url.registrationAction} method="post">
            {/* 이메일 입력 섹션 */}
            <div className="field-group">
                <input
                    name="email"
                    type="email"
                    defaultValue={kcContext.register.formData.email ?? ""}
                />

                {/* [변경점 2] 서버가 'email' 필드에 대해 보낸 모든 에러를 즉시 확인
                   이메일 중복, 형식 오류 등 어떤 에러가 오든 .get() 한 번으로 해결됩니다. */}
                {messagesPerField.existsError("email") && (
                    <span className="error-msg">
                        {messagesPerField.get("email")}
                    </span>
                )}
            </div>

            {/* 아이디 입력 섹션 */}
            <div className="field-group">
                <input
                    name="username"
                    type="text"
                    defaultValue={kcContext.register.formData.username ?? ""}
                />

                {/* [변경점 3] 이메일 에러와 동시에 화면에 노출됩니다.
                   서버가 여러 개의 필드 에러를 보냈다면,
                   각 조건문이 독립적으로 작동하여 모든 에러가 한눈에 파악됩니다. */}
                {messagesPerField.existsError("username") && (
                    <span className="error-msg">
                        {messagesPerField.get("username")}
                    </span>
                )}
            </div>

            <button type="submit">가입하기</button>
        </form>
    );
};

4. 결과 및 성과

불필요한 useEffect와 상태 관리 함수를 제거해 렌더링 로직을 단순화함으로써, Keycloak 서버와의 데이터 정합성[8] 문제를 근본적으로 해결했습니다. 또한 Keycloakify의 표준 객체인 messagesPerField를 직접 활용하는 방식을 채택하여, 향후 라이브러리 업데이트에도 유연하게 대응할 수 있는 견고한 코드 구조를 갖추었습니다. 결과적으로 'React다운 방식'에 매몰되기보다 프레임워크의 설계 의도를 정확히 파악하여 성능과 안정성을 모두 확보한 유의미한 경험이었습니다.

5. 마무리하며

이번 경험은 동기식 서버 렌더링(SSR)[9]환경을 비동기[10] UI 도구인 React로 다룰 때 발생하는 구조적 차이를 깊이 이해하는 계기가 되었습니다. 인증 시스템처럼 서버가 상태의 주도권을 가진 경우, 무조건적인 클라이언트 상태 관리보다는 서버 데이터를 직접 바인딩하는 것이 훨씬 견고한 설계를 낳는다는 점을 배웠습니다.

6. 참고 자료

Keycloakify : https://www.keycloakify.dev/

Keycloak 서버 테마 시스템의 구조:https://www.keycloak.org/docs/latest/server_development/#_themes

각주

[1] Keycloakify (키클락피파이): Keycloak의 사용자 정의 테마(Login, Register 등)를 React 프레임워크를 사용하여 개발할 수 있게 해주는 오픈소스 라이브러리입니다.

[2] 레거시 : 과거에 개발되어 현재까지 사용되고 있는 오래된 시스템이나 코드

[3] Keycloak :오픈 소스 ID 및 액세스 관리(IAM) 솔루션입니다. 현대적인 애플리케이션과 서비스를 위해 인증(Authentication), 인가(Authorization), 싱글 사인온(SSO) 기능을 제공합니다.

[4] 생명주기 : 컴포넌트가 브라우저에 나타나고(Mount), 업데이트되고(Update), 사라지는(Unmount) 일련의 과정을 말합니다.

[5] 서버 사이드 (Server-side) :웹 애플리케이션의 로직이 사용자의 브라우저(클라이언트)가 아닌, 중앙 서버에서 실행되는 것을 의미합니다. Keycloak은 보안상 사용자의 입력값을 서버에서 직접 검증하고, 그 결과에 따라 새로운 페이지나 에러 정보를 다시 브라우저로 내려주는 방식을 취합니다.

[6] FTL (FreeMarker Template Language) : Keycloak의 기본 테마 엔진인 Apache FreeMarker에서 사용하는 템플릿 언어입니다. 서버에서 데이터를 받아 HTML을 동적으로 생성하는 역할을 합니다.

[7] 클라이언트(Client) : 사용자의 브라우저에서 실행되는 소프트웨어 영역. 여기서는 React로 작성된 프론트엔드 애플리케이션을 뜻합니다.

[8] 데이터 정합성 (Data Integrity) : 데이터가 전송되거나 처리되는 과정에서 본래의 의미나 값이 변하지 않고 유지되는 특성입니다.

[9] SSR (Server Side Rendering) : 서버에서 사용자에게 보여줄 페이지의 전체 HTML을 미리 생성하여 브라우저에 전달하는 방식입니다.

[10] UI(User Interface) : 사용자가 소프트웨어와 상호작용하는 화면 요소

mary