프론트엔드에서의 세션과 쿠키
2023년 10월, 원티드 프리온보딩 프론트엔드 챌린지 요약
세션
사용자의 로그인 이후 로그아웃 혹은 로그인 만료까지의 기간을 말한다.
세션은 쉽게 말해, 유저에게 사원증을 배부하는 것이라고 이해하면 된다. 사원증을 받고 반납하기 전까지는 그 회사의 직원으로 인식되는 것과 같다.
세션의 기술적 실체는 무엇일까?
데이터 저장 방식? 통신 프로토콜? 인증 방법론? 암호화 방식?
세션 로그인
사용자 로그인이 유효한 시간 동안 서버에 세션 아이디를 기록해두고 인증에 사용하는 방식이다.
클라이언트에서 서비스에 회원정보를 전달한다. (예. username: blue, password: 1234!@#$)
그렇다면, 서비스에서는 전달 받은 값들을 확인하여 그에 맞는 회원정보를 확인하고 토큰을 전달한다.
회원정보가 확인이 되고, 해당 유저만의 토큰을 전달받게 되면 그 회원은 그 토큰을 이용해서 다양한 기능을 요청할 수 있다.
그리고 만약 브라우저(클라이언트)에서 다시 요청을 보내면, 위의 username과 password에 맞는 값을 서비스에서 세션 아이디를 보내도록 세팅한다. (예. username: blue, session-id: s-blue-0001)
그래서 그 다음부터는 그 세션 아이디를 활용해 기능을 사용할 수 있다.
세션을 기록하는 방법
쿠키
서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각으로 세션 관리, 개인화, 트래킹에 자주 사용한다.
브라우저에서 해당 페이지를 요청하면, 서버가 그 페이지와 쿠키를 전송한다. 그리고 브라우저가 같은 서버의 다른 페이지(및 API) 등을 요청한다.
서버가 브라우저에 쿠키를 저장하는 방법은 Header에 "Set-Cookie"에 저장한다.
그리고 클라이언트에서 요청할 때, Header에는 "Cookie"라는 값이 서버에 전달하게 된다.
쿠키를 사용할 때는, 쿠키를 누구에게 보낼 것인지 제대로 고민해보고 사용해야 한다.
그래서 쿠키 관련 정책을 지정하게 되는데, "SameSite"에서 "None", "Lax", "Strict" 중 선택해서 지정한다.
app.use(session({
sercet: "쿠키 예제",
resave: false,
saveUninitialized: true,
cookie: {
maxAge: 24 * 6 * 60 * 10000,
sameSite: 'lax',
httpOnly: true
},
}))
정책을 지정할 때, 외부에서 탈취를 방지하기 위해 "httpOnly"도 설정하게 된다.
CORS(Cross-origin resource sharing)
페이지의 리소스가 허가받은 도메인에서 사용할 수 있도록 만든 규정으로, 허가받지 않은 도메인에서 리소스를 사용하려고 하는 것을 방지한다.
"CORS"를 프론트엔드에서 에러를 해결할 수 있을까? 없다고 봐도 무방하다.
이는 서버에서 처리해야 할 문제이다.
프론트엔드에서는 보통 단순 호출하는 역할만 맡아 진행하기 때문에 문제를 해결하는 역할을 담당하지는 않는다.
만약, 프론트엔드에서 쿠키까지 쓰려면 "mkcert"를 설치해서 사용할 수 있다.
그렇다면, 백엔드에서 CORS를 위해 해야 하는 일은 무엇일까?
app.enableCors({
origin: /^(http:\/\/localhost:[0-9]{4})|(http:\/\/127.0.0.1:[0-9]{4})$/,
methods: ['GET', 'POST', 'OPTIONS'],
credentials: true
})
app.use(session({
sercet: "쿠키 예제",
resave: false,
saveUninitialized: true,
cookie: {
maxAge: 24 * 6 * 60 * 10000,
sameSite: 'lax',
httpOnly: true
},
}))
리소스 접근을 허가할 도메인을 설정하고, 어떤 메소드를 사용하게 할 지 설정한다. 그리고 가장 중요한 것은 "credentials"를 설정해야 하는 것이다. 잊지 말자.
위 처럼 로컬 환경에서 브라우저의 보안 플러그를 해제 상태로 시작하게 되면, "CORS" 문제는 발생하지 않는다.
클라이언트를 5173의 포트번호로 실행하고, 서버를 4000의 포트번호로 실행하여 하나의 브라우저에서 실행했다.
실제로 코드를 작성해보자
여기서 실습한 코드는 실제 현업에서는 사용할 수 없으니, 배움으로만 얻고 가야 한다.
로그인을 할 때, 세션기반으로 로그인을 호출하도록 해보자.
Body에 username과 password를 담아 전송한다.
const fetchClient = async (url: string, options: RequestInit) => {
return fetch(url, {
headers: {
"Content-Type": "application/json",
credentials: "include",
},
...options,
});
};
export const login = async (args: LoginRequest): Promise<LoginResult> => {
const loginRes = await fetchClient(`${BASE_URL}/auth/login`, {
method: "POST",
body: JSON.stringify(args),
});
return loginRes.ok ? "success" : "fail";
};
const Login = () => {
const { routeTo } = useRouter();
const loginSubmitHandler = async (
event: React.FormEvent<HTMLFormElement>
) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const isUserLoggedIn: boolean = await isLoggedIn();
if (isUserLoggedIn) {
routeTo("/page-a");
return;
}
const loginResult = await login({
username: formData.get("username") as string,
password: formData.get("password") as string,
});
if (loginResult === "fail") {
alert("로그인 실패");
return;
}
routeTo("/page-a");
};
위의 코드에서 Header에 정보를 보낼 때, 세션 방식 로그인을 위해서 "credentials: 'include'"를 꼭 설정해야 한다.
이럴 경우, 별도 개발 없이도 자동으로 로그인 여부를 검증하기 때문에, 유저 정보 수신의 성공 여부만 확인하면 된다.
그렇다면, 세션에 저장된 유저 정보를 반환하는 방법은 무엇일까?
export const getCurrentUserInfo = async (): Promise<User | null> => {
try {
const userInfoRes = await fetchClient(`${BASE_URL}/profile`, {
method: "GET",
headers: {
"Content-Type": "application/json",
credentials: "include",
},
});
return userInfoRes.ok ? userInfoRes.json() : null;
} catch (e) {
console.error(e);
return null;
}
};
호출이 성공하면, 유저의 정보를 가져온다. 호출할 때, 위에서 작성한 "fetchClient"를 사용하거나, 직접 "headers"를 작성하여 호출한다. "header"가 올바르게 추가된 경우에는 쿠키는 자동으로 함께 전송된다.
만약, 페이지를 이동할 때마다 로그인 여부를 체크하려면 어떻게 해야 할까?
상위 컴포넌트에 구현하여, 중복 사용이 되지 않도록 할 수 있다.
const GeneralLayout: React.FC<GeneralLayoutProps> = ({ children }) => {
const [userProfile, setUserProfile] = useState<User | null>(null);
const { routeTo } = useRouter();
const fetchUserProfile = useCallback(async () => {
const userProfileResponse = await getCurrentUserInfo();
if (userProfileResponse === null) {
routeTo("/login");
return;
}
setUserProfile(userProfileResponse);
}, []);
useEffect(() => {
console.log("page changed!");
fetchUserProfile();
}, [children]);
유저 정보가 있다면, 해당 정보를 가져와서 로그인 유무를 파악할 수 있다. 회원 정보가 있다면 로그인이 되어 있다고 판단되어 해당 정보를 저장하고, 없다면 로그인 페이지로 이동하도록 구현한다.
전체적인 과정을 다시 한 번 복습하자면,
클라이언트에서 로그인을 시도하게 되면, 사용자가 입력한 값에 해당하는 토큰의 유무를 확인하고, 유효기간이 남아있는지 확인한다. 만약, 해당 토큰이 있다면 그 토큰을 회원에게 전달하며 로그인을 성공하게 된다. 이 때, 사용자가 요청할 때는 세션 방식으로 요청하도록 한다.
세션 로그인 동작
세션 로그인이 어떻게 동작하는지 전체 흐름을 살펴보자.
- 유저 진입
- 세션이 유효한가?
- 서버에 요청하여 세션 체크
- 유효함
- 세션 사용(서버에 요청)
- 유효하지 않음
- 로그인 진행 → 아이디와 비밀번호를 서버에 요청하여 세션 아이디(쿠키) 전달받기
- 로그인 성공 시 세션 사용
실제 서비스가 되기 위해 로그인 외 더 필요한 것은 무엇일까?
실제 서비스가 되기 위해 더 필요한 것들은 예로 들면 다음과 같다.
- 회원가입
- 로그아웃
- 마이페이지
- 권한 관리 등
세션 vs JWT
실제 서비스에서는 어떤 방식을 더 많이 사용할까?
정답은 없다. 말 그대로 "케바케"이다. 프로젝트 성향, 회사 상황 등을 고려하여 결정하게 된다.
그렇다면, 세션과 JWT의 장점과 단점은 무엇일까?
세션
- 서버/백엔드(인프라) 비용 대폭 증가
- 프론트엔드 인증 쉬움
- 보안 상 약간의 향상 → 길거리에서 핸드폰을 직접 뺏기는 것처럼 완전히 뺏기는 것이 아니라면
- 서버 쪽에서 계속 홀드 → 서버 유지 필수
JWT
- 서버/백엔드(인프라) 비용 감소
- 프론트엔드 복잡도 증가
- 보안 상 세션보다 조금 더 위험 → 탈튀 가능성이 더 높음
- 서버에서 클라이언트가 올바르게 가져오기만 하면 쉽게 사용 가능
무엇을 선택해야 할 지 고민이라면, 서비스의 상황에 따라 결정하면 된다.
- 동시접속자 수
- 서비스 규모
- 앱/웹 동시운용 여부
- 팀 내 인력구성
- 일정
- 등
HTTP Request
이는 "하이퍼 텍스트를 주고받는 프로토콜"이다.
이 때, 하이퍼 텍스트는 "문서", "링크들이 있는 문서 리스트", "누르면 이동하는 문서"라고 이해하면 쉽다.
클라이언트에서 서버에 요청을 할 때, Requests에서 POST를 보내는 것을 생각하면 된다.
Bearer
"Bearer"는 토큰을 전송하는 규약 표준이다. 즉, 약속이라고 생각하면 된다.
HTML을 작성할 때, 최상단에 사용하는 이 문서가 어떤 종류인지, 어떤 형식인지 선언하는 "!DOCTYPE"와 같다.
cURL
요청 도구이자 전송 엔진, 툴이다. 이는 리눅스 기반으로 만들어졌다.
POST를 요청할 때, 어디로 어떤 헤더를 전달할 건지, 어떤 엑세스 토큰을 보낼 건지 나타낸다.
브라우저에서 cURL 복사가 가능하며, 특정 문제가 발생했을 경우에 cURL 주소를 확인해서 이것이 백엔드 문제인지 프론트엔드 문제인지 확인이 가능하다.
쉽게 말하자면, 브라우저에서 "Postman(포스트맨)"을 사용하는 개념이라고 보면 된다.
프로젝트 디렉토리 구조 어떻게 구성해야 할까?
리액트에서는 그런 규정 없다. 즉, 마음대로 하라는 것이다.
우리가 어떤 목적으로 디렉토리 구조를 구성해야 하는지 고민해야 한다.
유지보수? 협업? 아니면 다른 목적?
꼭 디렉토리 구조를 구성해야 한다면, 다른 사람들도 다 같이 필요한 컴포넌트를 쉽게 찾을 수 있도록 하자.
단, 기능상 이슈가 없는 선에서 협의를 통해 모두가 마음에 드는 방법으로!
기능상 이슈가 발생하는 경우는 주로 "public", "pages" 폴더같이 기능성 영향을 주는 경우를 말한다.