1. NodeJS 설치
먼저 프론트엔드에 앞서 NodeJS를 설치하여주자.
나의 경우에는 Window11에서 nodeJS를 설치하였는데 링크를 클릭해서 다운받아주자.
그리고 다운 받은 파일을 실행하고 설치 후 node -v 명령어를 통해서 노드 버전을 확인하면 끝이다.
하지만 여기서 끝이 아니다. react init 하는데 이래도 저래도 에러 발생하기 시작..
그래서 결국 제어판으로 지우고 새로 다운 받기로 했다.
이것저것 찾아보면서 삭제했다 다운받았다 반복했는데 아래 방법으로 하니까 잘 되었다.
링크에서 nvm-setup.exe 를 다운로드 하고 설치한 후 관리자 권한으로 아래 명령어를 입력해주자.
nvm install 18.17.1
nvm use 18.17.1
이후에 npx create-react-app test 하면 잘 생성된다.
2. 프로젝트 생성과 기본 구조
React 프로젝트를 만들면 기본적으로 public 폴더와 index.html 파일이 존재한다.
public/index.html은 <body> 태그 안에 <div id="root"></div>가 있다.
이 root는 React가 동적으로 렌더링할 최상위 컨테이너 역할을 한다.
src/index.js는 document.getElementById('root')를 가져와서 ReactDOM이 React 컴포넌트를 해당 root에 렌더링한다.
ReactDOM.createRoot(root).render(<App />) 실행 시 App.js가 root 안에서 렌더링된다.
폴더 구조는 다 지우고 위에 내용만 남겨준다.
리액트의 default port는 3000이다.
Router를 사용하기 위해서 react-router-dom을 설치하여야 한다.
npm install react-router-dom
먼저 src/pages 폴더 하위에 FirstPage, SecondPage.js를 만들고 간단하게 내용을 작성해주자.
import React from "react";
export const FirstPage = () => {
return (
<div>
<h1>첫 번째 페이지</h1>
</div>
);
};
그리고 App.js에서 FirstPage, SecondPage를 호출할 건데 reactDOM을 쓰기 때문에 기본적으로 import React를 해야한다.
Layout을 생성하고 router를 사용해서 각 경로를 지정해서 Page를 확인할 수 있게 하였다.
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Layout from "./Layout";
import { FirstPage } from "./pages/FirstPage";
import { SecondPage } from "./pages/SecondPage";
function App() {
return (
<Router>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<h1>메인 페이지</h1>} />
<Route path="/first" element={<FirstPage />} />
<Route path="/second" element={<SecondPage />} />
</Route>
</Routes>
</Router>
);
}
export default App;
import React from "react";
import { Link, Outlet } from "react-router-dom";
const Layout = () => {
return (
<div>
<nav>
<ul>
<li><Link to="/">홈</Link></li>
<li><Link to="/first">첫 번째 페이지</Link></li>
<li><Link to="/second">두 번째 페이지</Link></li>
</ul>
</nav>
<hr />
<Outlet /> {}
</div>
);
};
export default Layout;
그리고 실행하면 아래와 같이 작동한다.
3. Redux 적용해보기
먼저 Redux 패키지를 설치하자.
npm install @reduxjs/toolkit react-redux
@reduxjs/toolkit: Redux를 더 간편하게 사용할 수 있도록 도와주는 툴킷
react-redux: React에서 Redux를 사용하기 위한 라이브러리
그리고 Redux 설정할 폴더 구조는 아래와 같다.
src/
├── store/
│ ├── store.js # Redux Store 설정
│ ├── slice/
│ │ ├── authSlice.js # 로그인 관련 상태 관리 (authSlice)
├── index.js
├── App.js
Redux Store를 생성하고 authSlice를 Store에 등록한다.
Store에는 여러 개의 Slice들을 넣을 수 있다.
store.js에 아래와 같이 코드를 작성하였다.
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "./slice/authSlice";
export const store = configureStore({
reducer: {
auth: authReducer,
}});
export default store;
authSlice를 Redux Store에 등록한다.
configureStore를 통해서 Redux Store를 생성하고 auth를 등록하여 Redux Store에서 로그인 상태 관리가 가능하다.
그럼 authSlice.js 코드는 어떨까?
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
username: "",
accessToken: null,
refreshToken: null,
};
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setUserInfo(state, action) {
const { username, accessToken, refreshToken } = action.payload;
state.username = username;
state.accessToken = accessToken;
state.refreshToken = refreshToken;
},
clearInfo(state) {
state.username = "";
state.accessToken = null;
state.refreshToken = null;
},
},
});
export const { setUserInfo, clearInfo } = authSlice.actions;
export default authSlice.reducer;
createSlice(Redux Slice)를 사용하여 로그인 상태를 관리한다.
먼저 초기 상태를 설정하고 setInfo와 clearInfo reducer를 추가한다.
이 때 name: "auth"는 slice 이름, 즉 store에서 저장한 auth와 맞춰주는 것이 관습이라고 한다.
setUserInfo는 로그인 시 사용자 정보를 저장하고 clearInfo는 로그아웃 시 정보를 초기화하는 역할을 한다.
그리고 액션 생성 함수 export const {setUserInfo, clearInfo} ... 를 내보내고 reducer인 authSlice.reducer를 내보낸다.
index.js에서도 Redux Store를 적용해야하는데,
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import store from "./store/store";
import App from "./App";
ReactDOM.render(
<Provider store={store}> {}
<App />
</Provider>,
document.getElementById("root")
);
Redux Store를 React 애플리케이션에 연결하려면 Provider로 감싸야 한다.
store를 Provider의 store 속성에 전달하면서 모든 컴포넌트에서 Redux 상태를 사용할 수 있다.
그리고 Redux의 상태를 변경하기 위해 useDispatch()를 사용해서 setUserInfo, clearInfo를 호출해야한다.
먼저 App.js에서 로그인 상태(UserProfile), 로그인/로그아웃 버튼(LoginButton)을 보여주자.
import React from "react";
import LoginButton from "./components/LoginButton";
import UserProfile from "./components/UserProfile";
function App() {
return (
<div>
<UserProfile />
<LoginButton />
</div>
);
}
export default App;
LoginButton은 아래와 같다.
import React from "react";
import { useDispatch } from "react-redux";
import { setUserInfo, clearInfo } from "../store/slice/authSlice";
function LoginButton() {
const dispatch = useDispatch();
const handleLogin = () => {
dispatch(
setUserInfo({
username: "user@example.com",
accessToken: "abc123",
refreshToken: "xyz789",
})
);
};
const handleLogout = () => {
dispatch(clearInfo());
};
return (
<div>
<button onClick={handleLogin}>로그인</button>
<button onClick={handleLogout}>로그아웃</button>
</div>
);
}
export default LoginButton;
임시로 로그인과 로그아웃을 setUserInfo를 통해서 작동하게 하였다.
UserProfile은 Redux Store에서 username과 isAuthenticated 값을 가져와서 로그인 상태를 표시한다.
import React from "react";
import { useSelector } from "react-redux";
function UserProfile() {
const username = useSelector((state) => state.auth.username);
const isAuthenticated = useSelector((state) => !!state.auth.accessToken);
return (
<div>
{isAuthenticated ? (
<h1>환영합니다, {username}님!</h1>
) : (
<h1>로그인 해주세요.</h1>
)}
</div>
);
}
export default UserProfile;
폴더 구조는 아래와 같다.
계속 삽질해서 왜인가 엄청 찾았는데 authSlice에 .js를 안 붙여서였다ㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏ하하.
4. Axios적용해보기
api 폴더 하위에 axiosCommon.js 파일을 만든다.
axiosInstance = axios.create로 axios를 사용할 때마다 이런 설정이 붙는다는 것을 알려준다.
baseURL: 'http://localhost:8080'으로 백엔드 엔드포인트 기본 URL을 설정할 수 있다.
axiosInstance.interceptors.request.use로 헤더 설정을 해주는데 config랑 error가 주로 많이 쓰인다.
그리고 export default axiosInstance를 해준다.
/src/component 에 화면들을 만들 건데 export할 때 태그로 사용되는 UI Component는 무조건 앞에 대문자로 파일명을 지정하는 것이 컨벤션이라고 한다.
Login.js, Home.js, Profile.js, Signup.js로 간단하게 화면을 만드려고 한다.
const dispatch = useDispatch()라는 hook을 사용하는데 redux에서 export한 reducer들을 사용할 수 있다.
const handlerSubmit = async (e) => {...} 형태로 선언된 함수에서 handler는 주로 이벤트 후처리를 담당하는 함수인데 handlerSubmit, handlerClick 등의 네이밍 패턴이 과거부터 자주 사용되었다.
React Hooks 이전에는 handle~ 네이밍을 많이 사용했지만 이후에는 const handler = () => {...} 처럼 handler라는 변수명을 쓰는 경우도 많다.
const handlerSubmit = async (e) => {
e.preventDefault();
const response = await axios.post('http://localhost:8080/login', {
username: username,
password: password
});
const { accessToken, refreshToken } = response.data;
dispatch(setUserInfo(accessToken, refreshToken, username));
};
RESTful한 방식으로 API 요청을 보낼 때 axios.get(), axios.post() 등을 사용한다.
async/await는 비동기 코드를 동기 코드처럼 작성할 수 있도록 도와주는 문법으로 await는 비동기 함수 안에서만 사용 가능하고 Promise가 처리 될 때까지 기다린 후 값을 반환한다.
실행 흐름을 Blocking해서 동기처럼 보이지만 내부적으로는 비동기적으로 실행된다.
동기/비동기/블로킹/논블로킹
1. 동기(Synchronous) vs 비동기(Asynchronous)
📌 정의
- 동기(Synchronous): 작업이 순차적으로 실행되며, 하나의 작업이 끝나야 다음 작업이 실행됨.
- 비동기(Asynchronous): 하나의 작업이 끝날 때까지 기다리지 않고, 다른 작업을 동시에 실행할 수 있음.
📌 비유
- 동기: ATM기에서 앞사람이 돈을 다 찾을 때까지 기다려야 하는 것.
- 비동기: 은행 창구에서 번호표를 받고 다른 일을 하다가 내 순서가 되면 처리하는 것.
2. 블로킹(Blocking) vs 논블로킹(Non-Blocking)
📌 정의
- 블로킹(Blocking): 작업이 끝날 때까지 멈춰서 기다림. (CPU가 다른 작업을 수행하지 못함)
- 논블로킹(Non-Blocking): 작업이 끝나지 않아도, 다른 작업을 먼저 실행함. (CPU가 쉬지 않고 다른 작업 수행 가능)
📌 비유
- 블로킹: 라면을 끓이기 위해 물을 끓이는 동안 아무것도 하지 않고 기다리는 것.
- 논블로킹: 물이 끓는 동안 다른 요리를 준비하는 것.

내가 구현한 OAuth2+JWT+Redis 정리
✅ OAuth2 로그인 및 회원가입 과정 정리
📌 1. 로그인 페이지 (localhost:3000/login)
- 사용자가 Google 로그인 버튼을 누름
- Google OAuth2 로그인 창이 뜸
📌 2. 백엔드에서 OAuth2 로그인 (oauth2/authorization/google)
- SecurityConfig.java에서 .oauth2Login() 설정에 의해 oauth2/authorization/google로 리디렉트
- OAuth2UserService (loadUser)가 실행됨
- Google에서 받은 사용자 정보 (email, name, sub) 가져옴
- DB에서 email을 조회하고 없으면 새 사용자 생성
- JWT accessToken 및 refreshToken 생성
- refreshToken은 Redis에 저장
📌 3. 로그인 성공 후 successHandler에서 처리
- 로그인 성공 시 successHandler가 실행됨
- user의 email로 DB에서 기존 사용자 정보 조회
- JWT accessToken을 HttpOnly Cookie로 저장 (보안 강화)
- userId와 email을 포함하여 http://localhost:3000/signup으로 리디렉트
java복사편집response.sendRedirect("http://localhost:3000/signup?userId=" + user.getUserId() + "&email=" + user.getEmail());
📌 4. 프론트에서 localhost:3000/signup 실행
- Signup.js에서 useEffect() 실행
- URL에서 userId, email 가져와서 useState에 저장
- 추가 정보 (nickname, birth) 입력 후 "회원가입 완료" 버튼 클릭
- Axios POST 요청 → /user/sign-up
- 요청 데이터:
json복사편집{ "userId": "12345", "nickname": "myNickname", "birth": "2000-01-01" }
📌 5. 백엔드에서 /user/sign-up 실행 (completeSignup)
- UserController.java에서 회원가입 완료 후 UserDto 반환
- nickname, birth을 저장하고 isRegistered = true로 업데이트
📌 6. 프론트에서 accessToken을 Redux에 저장 & 프로필 페이지로 이동
- Signup.js에서:
- response.status === 201 확인
- redux에 username, nickname 저장
javascript복사편집dispatch(setUserInfo({ username: email, nickname }));
- navigate("/profile") 실행
📌 7. Profile.js에서 Redux 상태를 가져와 사용자 정보 출력
- useSelector()를 사용하여 Redux Store에서 username, nickname 가져와 화면에 출력
위 내용은 내가 이해하기 위해서 정리한 것을 GPT한테 정리 부탁한 내용이다.
5. 작동 화면 미리보기
코드 정리 전에 삽질 끝에 드디어 작동해서 작동 화면을 미리 보여주려고 한다.

리팩토링.. 할거에오..
달라진 것은 3000 port, 프론트랑 연동해서 작동한다는 것이다.
로그아웃하면 처음 로그인 화면으로 돌아가고 다시 로그인 클릭 시 profile로 이동한다.

6. 코드 정리하기 (Backend)
리팩토링이 필요한 코드라 이후에 다시 리팩토링 하는 과정을 글로 써서 남기려고 한다.
작동하는 행복함을 느끼면서 코드를 정리해보려고 한다.
우선은 기존에 사용했던 dashboard.html과 signup.html을 삭제하였다.
그리고 SecurityConfig에서 몇 가지 변경점이 있는데
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
3000과 8080이 주고 받아야하므로 CORS 정책을 설정하였다.
Origins http://localhost:3000을 허용하였고 Methods와 헤더를 전부 허용하였다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
그리고 oauth2 로그인 시 loginPage는 구글 로그인으로 설정하였고(나중에는 Kakao, Naver 등 나눠줘야함), successHandler를 추가하여 성공 시 동작 내용을 따로 파일로 지정하였다.
.oauth2Login(oauth2 -> oauth2
.loginPage("/oauth2/authorization/google")
.successHandler(oAuth2LoginSuccessHandler)
.failureUrl("http://localhost:3000/login?error=true")
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("http://localhost:3000/")
.logoutSuccessHandler(oAuth2LogoutSuccessHandler)
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("accessToken")
)
로그아웃 역시 logout 후 oAuth2LogoutSuccessHandler로 처리해주었다.
OAuth2LoginSuccessHandler와 OAuth2LogoutSuccessHandler는 아래와 같다.
로그인 성공 시 이메일로 해당 유저가 있는지 확인하였고 cookie에 accessToken을 설정해서 전달하였다.
쿠키 유효기간은 1일로 설정하였고 accessToken은 쿠키, refreshToken은 Redis에 관리하도록 구현하였다.
그리고 추가정보 작성 여부에 따라 profile과 signup으로 각각 이동하게 하였다.
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
public OAuth2LoginSuccessHandler(UserRepository userRepository, JwtTokenProvider jwtTokenProvider) {
this.userRepository = userRepository;
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
Optional<User> userOptional = userRepository.findByEmailQueryDSL(email);
if (userOptional.isPresent()) {
User user = userOptional.get();
String accessToken = jwtTokenProvider.createAccessToken(user.getEmail());
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setSecure(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(60 * 60 * 24); // 1일
accessTokenCookie.setAttribute("SameSite", "None");
response.addCookie(accessTokenCookie);
if (user.getIsRegistered()) {
response.sendRedirect("http://localhost:3000/profile");
} else {
response.sendRedirect("http://localhost:3000/signup?userId=" + user.getUserId() + "&email=" + user.getEmail());
}
} else {
response.sendRedirect("http://localhost:3000/login?error=user_not_found");
}
}
}
public class OAuth2LogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", "")
.path("/")
.httpOnly(true)
.secure(true)
.sameSite("None")
.maxAge(0) // 즉시 만료
.build();
response.setHeader("Set-Cookie", accessTokenCookie.toString());
}
}
로그아웃의 경우에는 로그아웃 시 쿠키를 즉시 만료하도록 설정하였다.
JwtTokenProvider는 accessToken을 생성하고 refreshToken을 생성하고 검증하고 삭제하는데 accessToken은 30분으로 설정했는데 쿠키를 1일로 해버렸네..?
public String createAccessToken(String email) {
// 30분
long accessTokenValidity = 1000 * 60 * 30;
return Jwts.builder()
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessTokenValidity))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String createRefreshToken(String email) {
// 7일
long refreshTokenValidity = 1000 * 60 * 60 * 24 * 7;
String refreshToken = Jwts.builder()
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenValidity))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
// key: email, value: refreshToken
redisTemplate.opsForValue().set(email, refreshToken, refreshTokenValidity, TimeUnit.MILLISECONDS);
return refreshToken;
}
쿠키와 accessToken은 주기를 맞추려고 한다.
둘 다 현재는 조금 길지만 1일로 설정해서 맞췄다.
OAuthController에서는 logout과 현재 쿠키에서 전달 받은 토큰을 통한 정보를 넘겨주는 역할을 한다.
@GetMapping("/user-info")
public ResponseEntity<?> getUserInfo(@CookieValue(value = "accessToken", required = false) String accessToken) {
if (accessToken == null || !jwtTokenProvider.validateToken(accessToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid Token");
}
String email = jwtTokenProvider.getEmailFromToken(accessToken);
Optional<User> userOptional = userRepository.findByEmailQueryDSL(email);
if (userOptional.isPresent()) {
User user = userOptional.get();
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("email", user.getEmail());
userInfo.put("nickname", user.getNickname());
return ResponseEntity.ok(userInfo);
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
}
}
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
Cookie accessTokenCookie = new Cookie("accessToken", null);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setSecure(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(0); // 즉시 만료
response.addCookie(accessTokenCookie);
return ResponseEntity.status(HttpStatus.OK).body("Logged out successfully");
}

'dev > 프로젝트' 카테고리의 다른 글
[프로젝트] 프론트 배포 (S3 + CloudFront + Route 53 + Gabia) + EC2 재배포 시 참고 적용 ver1 (10) | 2025.02.11 |
---|---|
[프로젝트] S3 + CloudFront + Route53 (10) | 2025.02.09 |
[프로젝트] 프론트엔드(React & Redux) (4) | 2025.02.07 |
[프로젝트] JWT + Redis 전환 (2) | 2025.02.06 |
[프로젝트] 테스트 코드 적용 (2) | 2025.01.30 |