
이번 단계에서는 CoreERP 프론트엔드에 인증 기능을 실제로 연결하고, 기존 ERP 화면 구조와 자연스럽게 통합하는 작업을 진행했다. 기존에는 Dashboard, Inventory, Purchase Order, Inbound, Outbound, Warehouse 같은 주요 업무 페이지가 먼저 구성되어 있었고, 이번 작업의 핵심은 여기에 로그인, 회원가입, 인증 상태 유지, 사용자 표시, 로그아웃, 접근 제어를 붙여 실제 서비스처럼 동작하는 프론트 구조를 만드는 것이었다.
이번 작업의 목적은 단순히 로그인 화면을 만드는 것이 아니라, 프론트엔드 전체를 인증 흐름과 연결하는 데 있었다. 즉 사용자가 로그인하면 토큰이 저장되고, 새로고침을 해도 로그인 상태가 유지되며, 로그인한 사용자 정보가 상단 Layout에 표시되고, 로그아웃하면 인증 상태가 정리되도록 만드는 것이 목표였다.
정리하면 이번 단계의 핵심 목적은 다음과 같았다.
CoreERP는 단순 CRUD 화면 모음이 아니라 실제 업무 흐름을 다루는 ERP 프로젝트이기 때문에, 화면만 보이는 상태에서 끝나면 프로젝트 완성도가 크게 떨어진다. 실제 ERP는 누가 로그인했고, 어떤 권한을 가지고 있으며, 어떤 화면에 접근할 수 있는지가 중요하다. 그래서 이번 단계는 단순 UI 구현이 아니라 서비스 구조 완성에 가까운 작업이었다.
특히 이번 작업을 통해 Frontend와 Backend가 다음 흐름으로 연결되도록 만들었다.
Frontend Login / Signup → Auth API 호출 → Spring Security + JWT 검증 → token 발급 / 사용자 조회 → React Auth State 저장 → Layout 및 라우팅 반영
이번 작업에서 새로 추가되거나 실제 역할이 확실해진 영역은 아래와 같다.
src
├─ apis
│ ├─ axios.ts
│ └─ authApi.ts
├─ auth
│ ├─ AuthProvider.tsx
│ ├─ useAuth.ts
│ ├─ tokenStorage.ts
│ ├─ authQueryKeys.ts
│ ├─ Login.tsx
│ └─ Signup.tsx
├─ components
│ └─ auth
│ ├─ ProtectedRoute.tsx
│ └─ PublicOnlyRoute.tsx
├─ types
│ └─ auth.ts
├─ components/layout
│ └─ Layout.tsx
├─ App.tsx
└─ main.tsx
여기서 중요한 점은 인증 관련 로직을 페이지 안에 흩뿌리지 않고, API Layer, Auth State Layer, Route Guard Layer, Page Layer로 분리했다는 점이다. 이 구조 덕분에 인증 기능이 커져도 유지보수가 쉬워졌다.
이 부분은 Frontend의 DTO contract layer에 해당한다. Spring Boot Backend의 request / response 구조를 TypeScript 타입으로 맞춰두면, 프론트에서 API 연동할 때 실수가 줄고, 화면 코드도 안정적으로 작성할 수 있다.
export type UserRole = "MASTER" | "ADMIN" | "EMPLOYEE";
export type UserStatus = "ACTIVE" | "INACTIVE" | "LOCKED";
export type LoginRequest = {
loginId: string;
password: string;
};
export type AuthTokenResponse = {
accessToken: string;
refreshToken: string;
userId: number;
loginId: string;
name: string;
role: UserRole;
};
export type MeResponse = {
id: number;
loginId: string;
name: string;
role: UserRole;
status: UserStatus;
enabled: boolean;
};
이 타입들은 Backend의 LoginRequest, AuthTokenResponse, MeResponse와 직접 연결된다. 즉 프론트는 임의의 JSON 구조를 추측하지 않고, 서버와 같은 계약을 기반으로 움직이게 된다.
이 부분은 Frontend Session Persistence Layer이다. 로그인 성공 시 받은 token을 저장하고, 앱 시작 시 이를 기반으로 인증 상태를 복구할 수 있도록 분리했다.
const ACCESS_TOKEN_KEY = "coreerp.accessToken";
const REFRESH_TOKEN_KEY = "coreerp.refreshToken";
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
export function clearTokens() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
여기서 핵심은 user 자체를 localStorage에 저장하지 않고, token만 저장한 뒤 /api/auth/me로 사용자 정보를 다시 불러온다는 점이다. 이 방식이 서버 기준의 최신 사용자 상태를 유지하기에 더 안전하다.
이 부분은 Frontend API Infrastructure Layer이다. 모든 업무 API 요청에 access token을 자동으로 붙이고, 401이 발생하면 refresh token으로 access token을 재발급받아 원래 요청을 재시도하게 만들었다.
apiClient.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
const refreshToken = getRefreshToken();
if (error.response?.status !== 401 || !refreshToken || originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
const { data } = await refreshClient.post("/auth/refresh", { refreshToken });
setTokens(data.accessToken, data.refreshToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return apiClient(originalRequest);
}
);
이 구조 덕분에 업무 화면에서는 인증 만료를 일일이 신경 쓰지 않아도 된다. 즉 Inventory, PO, Inbound 같은 도메인 페이지는 본인 기능에만 집중하고, 인증 갱신 로직은 axios 공통 계층이 담당하게 된다.
이 부분은 Frontend Global Auth State Layer이다. 앱 시작 시 token 존재 여부를 확인하고, 정상 토큰이면 /api/auth/me를 호출해서 현재 사용자 상태를 복구한다.
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isInitializing, setIsInitializing] = useState(true);
const refreshMe = useCallback(async () => {
const me = await getMeApi();
setUser(me);
}, []);
useEffect(() => {
const bootstrapAuth = async () => {
const accessToken = getAccessToken();
const refreshToken = getRefreshToken();
if (!accessToken || !refreshToken) {
clearTokens();
setUser(null);
setIsInitializing(false);
return;
}
try {
await refreshMe();
} catch {
clearTokens();
setUser(null);
} finally {
setIsInitializing(false);
}
};
void bootstrapAuth();
}, [refreshMe]);
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isInitializing }}>
{children}
</AuthContext.Provider>
);
}
이 구조 덕분에 앱 어디서든 현재 로그인한 사용자를 참조할 수 있게 되었다. 또한 새로고침 후에도 인증 상태가 유지되기 때문에 실제 서비스처럼 동작하게 되었다.
이 부분은 Frontend Page Layer이다. 기존 임시 fetch 기반 로그인 코드를 정리하고, auth state와 연결되는 구조로 변경했다.
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!form.loginId.trim()) {
showToast("로그인 ID를 입력하세요.", "warning");
return;
}
if (!form.password.trim()) {
showToast("비밀번호를 입력하세요.", "warning");
return;
}
try {
setSubmitting(true);
await signIn({
loginId: form.loginId.trim(),
password: form.password,
});
showToast("로그인되었습니다.", "success");
navigate(redirectPath, { replace: true });
} catch (error) {
showToast(extractErrorMessage(error), "error");
} finally {
setSubmitting(false);
}
};
여기서 중요한 점은 단순히 로그인 API만 호출하는 것이 아니라, 로그인 성공 후 token 저장과 사용자 복구까지 연결된다는 점이다. 즉 Login 페이지는 화면 입력을 받고, 실제 인증 상태 반영은 AuthProvider가 담당하는 구조로 분리되었다.
회원가입 페이지도 단순 UI에서 끝나지 않고, 실제 Backend SignupRequest 구조와 맞는 payload를 보내도록 정리했다. 특히 기존의 username 필드는 loginId로 변경했고, 프론트 validation과 백엔드 DTO 구조를 일치시키는 데 집중했다.
const payload: SignupRequest = {
companyCode: form.companyCode.trim(),
loginId: form.loginId.trim(),
password: form.password,
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
employeeNo: form.employeeNo.trim(),
department: form.department,
};
await signupApi(payload);
showToast("회원가입이 완료되었습니다. 로그인해주세요.", "success");
navigate("/login", { replace: true });
이 부분에서 중요한 것은 프론트와 백엔드의 계약을 맞추는 것이다. 회원가입 폼이 아무리 화려해도 실제 서버 DTO와 맞지 않으면 400 에러가 나기 때문에, 화면 필드명과 요청 구조를 일치시키는 작업이 반드시 필요했다.
이 부분은 Frontend Routing / Authorization Layer이다. 로그인하지 않은 사용자가 ERP 업무 페이지에 직접 접근하는 것을 막고, 반대로 이미 로그인한 사용자가 다시 로그인 페이지로 가는 것도 막았다.
export default function ProtectedRoute() {
const { isAuthenticated, isInitializing } = useAuth();
const location = useLocation();
if (isInitializing) {
return <div>인증 상태를 확인하는 중입니다...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <Outlet />;
}
export default function PublicOnlyRoute() {
const { isAuthenticated, isInitializing } = useAuth();
if (isInitializing) {
return <div>인증 상태를 확인하는 중입니다...</div>;
}
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
return <Outlet />;
}
이 구조 덕분에 비로그인 사용자는 업무 페이지 진입이 차단되고, 로그인한 사용자는 불필요하게 다시 로그인 화면을 보지 않게 되었다. 즉 사용자 경험과 인증 흐름이 동시에 정리되었다.
이 부분은 Frontend Layout Layer이다. 처음에는 상단 사용자 영역이 하드코딩 상태였지만, 최종적으로는 현재 로그인한 사용자 정보를 동적으로 표시하도록 수정했다.
const { user, signOut } = useAuth();
function getRoleLabel(role?: string) {
if (role === "MASTER") return "마스터";
if (role === "ADMIN") return "관리자";
if (role === "EMPLOYEE") return "사원";
return "-";
}
const avatarText = user?.name?.trim()?.charAt(0)?.toUpperCase() || "U";
<div className="userbox">
<div className="user-avatar">{avatarText}</div>
<div className="user-meta">
<div className="user-role">{getRoleLabel(user?.role)}</div>
<div className="user-company">
{user?.name ?? "-"} / {user?.loginId ?? "-"}
</div>
</div>
</div>
이제 어떤 사용자가 로그인하든 상단 영역은 자동으로 해당 사용자의 이름, 아이디, 권한을 표시하게 되었다. 즉 Master Admin 같은 테스트용 하드코딩은 더 이상 필요 없게 되었다.
로그아웃도 단순히 화면 이동만 하는 것이 아니라, 실제 refresh token revoke와 프론트 인증 상태 초기화까지 포함하도록 연결했다.
const onLogout = async () => {
await signOut();
navigate("/login", { replace: true });
};
Frontend에서는 signOut을 호출하고, 내부적으로는 logout API 호출 → token 제거 → user state 초기화 흐름이 실행된다. 즉 로그아웃 이후에는 다시 인증되지 않은 상태로 돌아가게 된다.
이번 작업 중 작은 문제처럼 보였지만 실제로는 중요한 수정 포인트가 하나 있었다. 기존 CSS는 username 기준 selector를 사용하고 있었는데, 백엔드와 맞추기 위해 input name을 loginId로 바꾸면서 아이콘이 사라지는 문제가 생겼다.
.loginForm input[name="username"] {
padding-left: 44px;
background-image: url(...);
}
이 부분을 아래처럼 수정해야 했다.
.loginForm input[name="loginId"] {
padding-left: 44px;
background-image: url(...);
}
회원가입 CSS도 같은 이유로 username → loginId로 수정했다. 이 과정은 단순 스타일 수정처럼 보이지만, 실제로는 Frontend Form field naming과 CSS selector가 같이 움직여야 한다는 점을 다시 확인하게 해줬다.
이번 작업이 끝난 뒤 CoreERP 프론트엔드 인증 흐름은 아래처럼 정리되었다.
회원가입
→ /api/auth/signup
→ users 테이블 저장
로그인
→ /api/auth/login
→ accessToken / refreshToken 발급
→ localStorage 저장
→ /api/auth/me 호출
→ 현재 사용자 정보 복구
업무 페이지 접근
→ ProtectedRoute 검사
→ 통과 시 Layout 렌더링
→ 사용자 정보 표시
로그아웃
→ /api/auth/logout
→ refresh token revoke
→ localStorage 제거
→ user state 초기화
→ /login 이동
이번 단계는 화면 몇 개를 추가한 작업이 아니라, CoreERP를 실제 서비스처럼 동작하게 만드는 인증 기반을 완성한 단계였다. 기존에는 업무 페이지가 있어도 인증이 분리되어 있었기 때문에 “보이는 프로젝트” 수준에 가까웠다면, 지금은 로그인 사용자 중심으로 화면이 열리고, 새로고침 후에도 상태가 유지되며, 상단 Layout이 실제 사용자 정보를 표시하는 구조가 되었다.
즉 이 단계부터는 CoreERP가 단순 CRUD 모음이 아니라, 실제 ERP 흐름을 갖춘 애플리케이션으로 올라왔다고 볼 수 있다.
이번 프론트엔드 인증 연동 작업을 통해 React + TypeScript 기반 프론트 구조와 Spring Boot JWT 인증 구조를 실제로 연결할 수 있었다. 특히 API Layer, Auth State Layer, Route Guard Layer, Layout Layer를 나눠서 구성한 점은 앞으로 사용자 관리, 권한별 메뉴 분기, 감사 로그 화면 같은 기능을 확장할 때도 큰 장점이 될 것이다.
다음 단계에서는 현재까지 정리한 인증 기반 위에서 더 실무적인 ERP 기능, 특히 이력 관리나 감사 로그 같은 구조를 붙이면 프로젝트 완성도가 한 단계 더 올라갈 것 같다.