로그인을 유지하는데에는 두가지 방법이 있다. 첫번째는 세션 기반의 인증, 두번째는 JWT 기반의 인증이다.
세션 기반 인증과 JWT 기반 인증에 대해 간략히 정리해보자.
세션 기반 인증
작동 원리
사용자가 로그인: 사용자가 로그인하면, 서버는 사용자에 대한 세션을 생성하고, 세션 ID를 클라이언트에게 전달.
세션 ID 저장: 세션 ID는 클라이언트의 쿠키에 저장.
요청 시 세션 ID 전송: 이후의 모든 요청에서 클라이언트는 이 세션 ID를 쿠키를 통해 서버로 전송.
서버에서 세션 관리: 서버는 요청에 포함된 세션 ID를 기반으로 세션 데이터를 확인하고, 사용자 정보를 조회.
특징
상태 유지: 서버는 로그인 상태를 세션 데이터로 유지.
서버에 상태 저장: 서버는 각 사용자의 세션 데이터를 저장하고 관리.
세션 무효화 가능: 서버에서 세션을 쉽게 무효화할 수 있음.
확장성 문제: 서버가 여러 대일 경우 세션 데이터를 공유하거나 중앙 관리해야 하는 복잡성이 추가.
장점
구현이 비교적 간단하며, 많은 웹 프레임워크에서 기본적으로 지원. 세션이 서버에서 관리되므로, 토큰 무효화와 같은 복잡한 처리가 필요하지 않음.
단점
모든 세션 데이터를 서버가 관리해야 하므로, 사용자 수가 많아지면 서버 부하가 증가할 수 있음.
서버가 여러 대일 경우 세션 동기화 문제 해결 필요.
JWT 기반 인증
작동 원리
사용자가 로그인: 사용자가 로그인하면, 서버는 사용자 정보를 포함한 JWT 토큰을 생성하여 클라이언트에 전달.
JWT 토큰 저장: 클라이언트는 이 토큰을 localStorage 또는 sessionStorage에 저장.
요청 시 JWT 토큰 전송: 이후의 모든 요청에서 클라이언트는 이 JWT 토큰을 HTTP 헤더에 포함시켜 서버로 전송.서버에서 토큰 검증: 서버는 요청 시마다 토큰을 검증하고, 유효한 경우 사용자 정보를 확인하여 요청을 처리.
특징
무상태성: 서버는 로그인 상태를 유지할 필요 없이, 각 요청에서 토큰을 검증하여 사용자를 인증.
서버에 상태 저장 안 함: 서버가 사용자 정보를 별도로 저장하지 않으므로, 서버 확장성이 높음.
클라이언트가 인증 상태 관리: 클라이언트가 토큰을 관리하므로, 서버 부하 감소.
장점
확장성이 뛰어나며, 서버가 여러 대일 경우에도 문제가 없음. RESTful API와 같이 무상태성을 요구하는 시스템에 적합. 토큰을 통해 추가적인 정보를 전달할 수 있어 유연한 인증 관리가 가능.
단점
토큰이 유출되면 보안 문제가 발생할 수 있음.
토큰의 만료와 갱신을 관리해야 하며, 토큰 무효화가 어려움.
클라이언트에서 토큰을 안전하게 저장하고 관리해야 함.
이중 내가 선택한 것은 세션 기반의 인증인데, 어차피 내 수준에 JWT는 너무 어렵고 그렇게 거창한 프로젝트도 아니라 세션 기반으로 할 생각이었고, 그냥 적어만 두려고 GPT 시켜서 정리했다. 실제로는 이전에 참고했던 포스트 주인의 다른 글에서 정보를 얻었다.
이제부터 세션 기반의 인증을 추가하고 로그인이 유지되도록 구현해보겠다. 우선 application.properties에 다음 설정을 추가한다.
application.properties
# 세션 설정 (필요에 따라 조정)
# 세션 타임아웃 설정 (30분)
server.servlet.session.timeout=30m
# HTTPS를 사용하는 경우 설정
server.servlet.session.cookie.secure=true
# JavaScript에서 쿠키 접근을 막기 위해 설정
server.servlet.session.cookie.http-only=true
OuthController.java
import jakarta.servlet.http.HttpSession;
HttpSession을 import하고 다음과 같이 코드를 추가한다.
@GetMapping("/login/{oauthServerType}")
ResponseEntity<Long> login(
@PathVariable OauthServerType oauthServerType,
@RequestParam("code") String code,
HttpSession session // 세션을 파라미터로 추가
) {
Long userId = oauthService.login(oauthServerType, code);
System.out.println("로그인 성공, 반환된 ID: " + userId); // 디버깅 로그
// 세션에 사용자 ID 저장하도록 추가
session.setAttribute("userId", userId);
return ResponseEntity.ok(userId);
}
기존의 login -> userId로 수정된 것을 확인할 수 있다.
또한, user/{userId}로 되어있던 사용자 정보 조회 엔드포인트를 다음과 같이 수정하였다.
@GetMapping("/user-info")
public ResponseEntity<OauthMember> getUserInfo(HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
if (userId == null) {
return ResponseEntity.status(401).build(); // 세션에 사용자 정보가 없는 경우, 401 Unauthorized
}
OauthMember member = oauthMemberRepository.findById(userId).orElse(null);
if (member != null) {
System.out.println("사용자 정보 조회 성공, ID: " + userId);
return ResponseEntity.ok(member);
} else {
System.out.println("사용자 정보 조회 실패, 존재하지 않는 ID: " + userId);
return ResponseEntity.status(404).build(); // 해당 ID의 사용자가 없는 경우, 404 Not Found
}
}
기존에는 userId만으로 사용자 정보를 조회하였지만, 이를 세션을 통해 조회하도록 수정한것이다.
그리고 로그아웃 엔드포인트를 추가한다.
// 로그아웃 엔드포인트: 세션 무효화
@GetMapping("/logout")
public ResponseEntity<String> logout(HttpSession session) {
session.invalidate(); // 세션 무효화
return ResponseEntity.ok("로그아웃 성공");
}
현재는 사용하지 않지만, 다음 포스트에서 네비게이션 바 수정부분에 다룰 것이다.
수정된 전체 OauthController.java
package zerogod.ecetaskhelper.controller;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import zerogod.ecetaskhelper.domain.OauthMember;
import zerogod.ecetaskhelper.domain.OauthMemberRepository;
import zerogod.ecetaskhelper.service.OauthService;
import zerogod.ecetaskhelper.domain.OauthServerType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpSession;
@RequiredArgsConstructor
@RequestMapping("/oauth")
@RestController
public class OauthController {
private final OauthService oauthService;
private final OauthMemberRepository oauthMemberRepository; // 이 부분을 추가
@SneakyThrows
@GetMapping("/{oauthServerType}")
ResponseEntity<Void> redirectAuthCodeRequestUrl(
@PathVariable OauthServerType oauthServerType,
HttpServletResponse response
) {
String redirectUrl = oauthService.getAuthCodeRequestUrl(oauthServerType);
response.sendRedirect(redirectUrl);
return ResponseEntity.ok().build();
}
// 로그인 엔드포인트: 로그인 후 세션에 사용자 정보 저장
@GetMapping("/login/{oauthServerType}")
ResponseEntity<Long> login(
@PathVariable OauthServerType oauthServerType,
@RequestParam("code") String code,
HttpSession session // 세션을 파라미터로 추가
) {
Long userId = oauthService.login(oauthServerType, code);
System.out.println("로그인 성공, 반환된 ID: " + userId); // 디버깅 로그
// 세션에 사용자 ID 저장
session.setAttribute("userId", userId);
return ResponseEntity.ok(userId);
}
// 사용자 정보 조회 엔드포인트
@GetMapping("/user-info")
public ResponseEntity<OauthMember> getUserInfo(HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
if (userId == null) {
return ResponseEntity.status(401).build(); // 세션에 사용자 정보가 없는 경우, 401 Unauthorized
}
OauthMember member = oauthMemberRepository.findById(userId).orElse(null);
if (member != null) {
System.out.println("사용자 정보 조회 성공, ID: " + userId);
return ResponseEntity.ok(member);
} else {
System.out.println("사용자 정보 조회 실패, 존재하지 않는 ID: " + userId);
return ResponseEntity.status(404).build(); // 해당 ID의 사용자가 없는 경우, 404 Not Found
}
}
// 로그아웃 엔드포인트: 세션 무효화
@GetMapping("/logout")
public ResponseEntity<String> logout(HttpSession session) {
session.invalidate(); // 세션 무효화
return ResponseEntity.ok("로그아웃 성공");
}
}
프론트엔드 부분도 수정한다.
HomePage.js
// 사용자 정보를 가져오는 함수
const fetchUserInfo = async () => {
try {
const response = await axios.get('http://localhost:8080/oauth/user-info');
setUserInfo(response.data);
} catch (error) {
console.error("사용자 정보 조회 중 오류 발생:", error);
}
};
fetchUserInfo 함수를 추가하였다. 이 함수는 세션에 저장된 사용자 정보를 /oauth/user-info 엔드포인트에서 가져온다.
// OAuth 리디렉션 처리
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const code = searchParams.get('code');
const oauthProvider = location.pathname.split('/').pop(); // URL 경로에서 OAuth 제공자 정보 추출
if (code) {
handleOAuthLogin(oauthProvider, code).then(() => {
// OAuth 로그인 후 사용자를 홈 페이지로 이동시키면서 정보를 가져옴
fetchUserInfo().then(r => {});
});
} else {
// 리디렉션이 없을 경우, 홈 페이지 로드 시 사용자 정보를 가져옴
fetchUserInfo().then(r => {});
}
}, [location]);
useEffect 훅에서 리디렉션 후 fetchUserInfo 함수를 호출해 사용자 정보를 가져오도록 하였다. 리디렉션이 없다면 홈페이지 로드 시에 마찬가지로 사용자 정보를 가져온다.
const handleOAuthLogin = async (oauthProvider, code) => {
try {
const response = await axios.get(`http://localhost:8080/oauth/login/${oauthProvider}?code=${code}`);
const userId = response.data;
alert("로그인 성공: " + userId);
navigate("/home", { replace: true }); // URL을 /home으로 변경
} catch (error) {
console.error("로그인 또는 사용자 정보 조회 중 오류 발생:", error);
alert("로그인 실패");
navigate("/fail", { replace: true });
}
};
handleOAuthLogin 함수는 에러 로그만 추가하였다. 나도 이 함수의 역할을 잘 몰라 한번 정리하였다.
1. OAuth 제공자로부터 받은 인증 코드 처리
OAuth 제공자로부터 리디렉션된 후, URL에 포함된 code 파라미터를 추출한다. 이 code는 사용자가 OAuth 제공자에게 인증을 완료하고 백엔드로 돌아올 때 제공되는 인증 코드이다.
2. 백엔드 서버에 인증 코드 전달
백엔드 서버의 /oauth/login/{oauthProvider} 엔드포인트에 이 인증 코드를 전달한다. 백엔드는 이 코드를 사용해 OAuth 제공자와 통신하여 액세스 토큰을 받고, 이를 통해 사용자의 정보를 가져온다.
3. 로그인 성공 시 사용자 ID 반환
백엔드에서 해당 사용자 정보를 조회하고, 새로운 사용자라면 가입 처리 후 사용자 ID를 반환한다.이 사용자 ID는 이후 세션을 통해 관리된다.
대략 이정도의 역할을 수행한다.
수정된 HomePage.js의 전체 코드
// src/main/frontend/src/pages/HomePage.js
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import axios from 'axios';
function HomePage() {
const [hello, setHello] = useState('');
const [userInfo, setUserInfo] = useState(null);
const location = useLocation();
const navigate = useNavigate();
// OAuth 리디렉션 처리
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const code = searchParams.get('code');
const oauthProvider = location.pathname.split('/').pop(); // URL 경로에서 OAuth 제공자 정보 추출
if (code) {
handleOAuthLogin(oauthProvider, code).then(() => {
// OAuth 로그인 후 사용자를 홈 페이지로 이동시키면서 정보를 가져옴
fetchUserInfo().then(r => {});
});
} else {
// 리디렉션이 없을 경우, 홈 페이지 로드 시 사용자 정보를 가져옴
fetchUserInfo().then(r => {});
}
}, [location]);
const handleOAuthLogin = async (oauthProvider, code) => {
try {
const response = await axios.get(`http://localhost:8080/oauth/login/${oauthProvider}?code=${code}`);
const userId = response.data;
alert("로그인 성공: " + userId);
navigate("/home", { replace: true }); // URL을 /home으로 변경
} catch (error) {
console.error("로그인 또는 사용자 정보 조회 중 오류 발생:", error);
alert("로그인 실패");
navigate("/fail", { replace: true });
}
};
// 사용자 정보를 가져오는 함수
const fetchUserInfo = async () => {
try {
const response = await axios.get('http://localhost:8080/oauth/user-info');
setUserInfo(response.data);
} catch (error) {
console.error("사용자 정보 조회 중 오류 발생:", error);
}
};
// 백엔드의 /api/hello 엔드포인트로부터 데이터를 가져오는 비동기 요청
useEffect(() => {
axios.get('/api/hello')
.then(response => {
setHello(response.data);
})
.catch(error => {
console.log(error);
});
}, []);
return (
<div>
<h1>Welcome to the Task Helper</h1>
<p>백엔드에서 가져온 데이터입니다: {hello}</p>
{userInfo && (
<div>
<h2>사용자 정보</h2>
<p>ID: {userInfo.id}</p>
<p>닉네임: {userInfo.nickname}</p>
</div>
)}
</div>
);
}
export default HomePage;
이제 실행해보면 다른 페이지를 로드해도 로그인이 유지되는 것을 확인할 수 있다.
다음 포스트에서는 네비게이션 바를 수정하여 좌측 수직 배치 및, 로그인 버튼 추가/로그인 후 계정 정보 표시 를 구현해보도록 한다.