Spring WebFlux를 이용한 chat 프로그램 - 회원가입 및 로그인
2025. 5. 2. 14:39ㆍJAVA/Spring Boot
먼저 회원가입 및 로그인 처리 기능 작성
DB 생성
사용자 관리 테이블 생성
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
nickname VARCHAR(100) NOT NULL,
create_at Timestamp NOT NULL
);
DTO 생성
ApiResponse
package com.company.common.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 프론트엔드로 데이터를 전송하기 위한 DTO Class
* @param <T>
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success; // 성공여부
private String message; // 전송 메시지
private T data; // 전송 데이터
}
User
package com.company.common.database.entity;
import java.time.LocalDateTime;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* users 테이블과 매핑되는 사용자 엔티티 클래스
*/
@Table("users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id private Long id; // 사용자 ID (기본키)
private String username; // 사용자 로그인 ID
private String password; // 암호화된 비밀번호
private String nickname; // 사용자 별명
private LocalDateTime createAt; //가입일시
}
AuthRequest
package com.company.chat.dto;
import lombok.Data;
/**
* 로그인 요청 시 전달되는 JSON 데이터를 담는 DTO
*/
@Data
public class AuthRequest {
private String username; // 사용자 ID
private String password; // 비밀번호
}
AuthService 생성
package com.company.chat.service;
import java.time.LocalDateTime;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import com.company.chat.dto.AuthRequest;
import com.company.chat.repository.UserRepository;
import com.company.common.database.entity.User;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
/**
* 사용자 회원가입 처리
*
* 1. 등록하려고 하는 username이 DB에 존재하는지 여부를 파악한다.
* 만약 발견된게 있으면 오류 메시지를 발생시킨다.
* 2. 비어 있는 경우에는 비밀번호를 암호화 하고 사용자를 등록한다.
* @param user
* @return
*/
public Mono<User> signup(User user) {
return userRepository.findByUsername(user.getUsername())
.flatMap(_ -> Mono.<User>error( new RuntimeException("이미 존재하는 사용자입니다.")))
.switchIfEmpty(
Mono.defer(() -> {
// 사용자 정보 저장
user.setPassword(passwordEncoder.encode(user.getPassword())); //비밀번호 암호화
user.setCreateAt(LocalDateTime.now());
return userRepository.save(user);
})
);
}
/**
* 로그인 처리 및 JWT 발급
*
* 사용자 정보를 조회를 하고 있으면 비밀번호를 비교 한다.
* 일치하는 사용자가 아닌경우
* @param request
* @return
*/
public Mono<User> login(AuthRequest request) {
return userRepository.findByUsername(request.getUsername())
.filter(user -> passwordEncoder.matches(request.getPassword(), user.getPassword())) //비밀번호가 일치하면 통과
.switchIfEmpty(Mono.<User>error(new RuntimeException("아이디 또는 비밀번호가 올바르지 않습니다."))); //일치하지 않으면 오류 발생
}
}
UserRepository 생성
package com.company.chat.repository;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import com.company.common.database.entity.User;
import reactor.core.publisher.Mono;
/**
* 사용자 정보를 PostgreSQL에서 CRUD 하기 위한 Reactive Repository
*/
public interface UserRepository extends ReactiveCrudRepository<User, Long>{
// username으로 사용자 조회
Mono<User> findByUsername(String username);
}
AuthController 생성
package com.company.chat.controller;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.company.chat.dto.AuthRequest;
import com.company.chat.service.AuthService;
import com.company.common.database.entity.User;
import com.company.common.dto.ApiResponse;
import com.company.common.utils.JwtUtil;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
private final JwtUtil jwtUtil;
/**
* 회원가입 처리
* @param user
* @return
*/
@PostMapping("/signup")
public Mono<ResponseEntity<ApiResponse<User>>> signup(@RequestBody User user) {
return authService.signup(user)
.map(savedUser -> ResponseEntity.ok().body(new ApiResponse<>(true, "회원가입성공", savedUser)))
.onErrorResume(error -> Mono.just(ResponseEntity.badRequest().body(new ApiResponse<>(false, error.getMessage(), null))));
}
/**
* 로그인 처리
*
* 사용자 정보가 있는 경우
* jwt token을 생성하고 token 정보와 user 정보를 같이 프론트엔드로 전달한다.
* @param request
* @return
* {
* "token" : "토큰값",
* "user" :
* {
* "id" : 12345,
* "username" : "testid",
* "password" : null,
* "nickname" : "nickname",
* "createAt" : "2025-04-29T02:00:00.000Z"
* }
* }
*/
@PostMapping("/login")
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> login(@RequestBody AuthRequest request) {
return authService.login(request)
.map(user -> {
String token = jwtUtil.generateToken(user.getUsername());
Map<String, Object> data = new HashMap<String, Object>();
data.put("token", token);
user.setPassword(null); // 비밀번호는 빼고 전달
data.put("user", user);
return ResponseEntity.ok(new ApiResponse<>(true, "로그인 성공!!", data));
})
.onErrorResume(error -> Mono.just(
ResponseEntity
.badRequest()
.body(new ApiResponse<>(false, error.getMessage(), null))
));
}
}
여기까지가 사용자 로그인 및 회원가입 백엔드 처리 입니다.
다음은 프론트엔드 처리 시작!!
우선 프론트엔드를 시작하기 전에 axios를 사용하여 api 통신을 하려고 한다.
axios를 설치 및 interceptor 생성
> npm install axios
그리고 나서 interceptor을 생성했다.
axiosInterceptor.js
import axios from "axios";
import CommonError from "../entity/error/CommonError";
/**
* axios Instance를 생성한다.
*/
const axiosInstance = axios.create({
baseURL: 'http://localhost:8080/api/v1', // localhost host에 /api/v1 prefix 로 경로를 설정
headers: {
'Content-Type': 'application/json' // 전송은 json 방식으로 처리
}
})
/**
* 요청 Interceptor
* JWT Token을 자동으로 추가 해서 요청한다.
*/
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if( token ) {
config.headers.Authroization = `Bearer ${token}`
}
return config
}
)
/**
* 결과 Interceptor
*/
axiosInstance.interceptors.response.use(
(response) => response,
error => {
if( error.response && error.response.data ) {
const {message, status, data} = error.response.data;
// CommonError로 변환하고 던진다.
return Promise.reject(new CommonError({message, status, data}))
} else {
return Promise.reject(new CommonError({
message: '네트워크 오류가 발생하였습니다.',
status: 0,
data: null
}))
}
}
)
export default axiosInstance
CommonError 생성
그리고 공통으로 오류 처리를 위해 CommonError 파일을 생성한다.
CommonError.js
/**
* Error 메시지를 통일하기 위한 class
*/
class CommonError extends Error {
constructor(errorData) {
super(errorData?.message); // 부모 Error 생성자 호출
this.name = 'CommonError'; // 에러 이름 설정
this.status = errorData?.status; // HTTP 상태코드 저장
this.data = errorData?.data; // 서버가 내려준 데이터 저장
}
}
export default CommonError
AuthService 생성
AuthService.js
import axiosInstance from "../../interceptor/axiosInterceptor";
/**
* 회원 가입 및 로그인 처리 Service
*/
class AuthService {
/**
* 사용자 가입처리를 한다.
* @param {Object} user 사용자 정보를 담고 있는 Object
* @returns
*/
async singup( user ) {
return await axiosInstance.post('/auth/signup', user)
}
/**
* 사용자 로그인 처리 한다.
* @param {Object} request 로그인 정보 저장 Objcet {username, password}
* @returns
* data: {
* "token" : "토큰값",
* "user" :
* {
* "id" : 12345,
* "username" : "testid",
* "password" : null,
* "nickname" : "nickname",
* "createAt" : 2025-05-02 00:00:00
* }
* },
* message: 로그인 후 메시지
*/
async login(request) {
return await axiosInstance.post('/auth/login', request)
}
}
export const authService = new AuthService();
회원가입 Page 생성
Singup.jsx
import { useState } from "react";
import { authService } from "../api/service/auth/AuthService";
import { useNavigate } from "react-router-dom";
function Signup() {
const navigate = useNavigate();
const [form, setForm] = useState({ username: '', password: '', nickname: '' })
const [message, setMessage] = useState(null)
// 입력값 변경 핸들러
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value})
}
/**
* 회원가입 요청 처리
* 회원 가입 후 login 페이지로 이동한다.
*/
const handleSubmit = async (e) => {
e.preventDefault()
try {
const response = await authService.singup(form)
if( response.data.success ) {
alert('회원가입 성공하였습니다.')
navigate("/login")
} else {
setMessage(response.data.message)
}
} catch (error) {
setMessage(error.response?.data?.message || '오류발생!')
}
}
return (
<div className="max-w-md mx-auto mt-10 p-6 border rounded shadow">
<h2 className="text-xl font-bold mb-4">회원가입</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<input
name="username"
type="text"
placeholder="아이디"
onChange={handleChange}
className="w-full p-2 border rounded"
required />
<input
name="password"
type="password"
placeholder="비밀번호"
onChange={handleChange}
className="w-full p-2 border rounded"
required />
<input
name="nickname"
type="text"
placeholder="별명"
onChange={handleChange}
className="w-full p-2 border rounded"
required />
<button
type="submit"
className="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600"
>가입하기</button>
</form>
{message && <p className="mt-4 text-center text-red-600">{message}</p>}
</div>
)
}
export default Signup
회원 가입이 완료 되면 login 페이지로 바로 이동하게 처리 했다.
로그인 Page 생성
Login.jsx
import { useState } from "react";
import { authService } from "../api/service/auth/AuthService";
function Login() {
const [form, setForm] = useState({username: '', password: ''})
const [message, setMessage] = useState(null)
const handleChange = (e) => {
setForm({...form, [e.target.name]: e.target.value})
}
const handleSubmit = async (e) => {
e.preventDefault()
try {
const response = await authService.login(form)
console.log( response?.data )
if( response.data.success ) {
const token = response.data.data.token;
const user = response.data.data.user;
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
setMessage('로그인 성공!!!')
} else {
setMessage(response.data.message)
}
} catch (error) {
setMessage(error.response?.data?.message || '오류 발생!!')
}
}
return (
<div className="max-w-md mx-auto mt-10 p-6 border rounded shadow">
<h2 className="text-xl font-bold mb-4">로그인</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<input
name="username"
type="text"
placeholder="아이디"
onChange={handleChange}
className="w-full p-2 border rounded"
required />
<input
name="password"
type="password"
placeholder="비밀번호"
onChange={handleChange}
className="w-full p-2 border rounded"
required />
<button
type="submit"
className="w-full bg-green-500 text-white p-2 rounded hover:bg-green-600"
>로그인</button>
</form>
{message && <p className="mt-4 text-center text-red-600">{message}</p>}
</div>
)
}
export default Login
우선 로그인 성공 여부 출력까지만 작성했다.
이후 ChatRoom으로 이동하도록 할 계획이다.
여기서 문제가 발생했다.
Access to XMLHttpRequest at 'http://localhost:8080/api/api/v1/auth/signup' from origin
'http://localhost:5173' has been blocked by CORS policy: Response to preflight request
doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on
the requested resource.
CORS 문제로 백엔드 처리가 필요했다.
그래서 Config 파일 생성해서 처리했다.
CorsGlobalConfig.java
package com.company.common.config.filter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
@Configuration
public class CorsGlobalConfig {
@Bean
CorsWebFilter corsWebFilter() {
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.addAllowedOrigin("http://localhost:5173"); // React 개발서버 Origin 허용
corsConfig.addAllowedMethod("*"); // 모든 HTTP 메소드 허용 (GET, POST, PUT, DELETE 등)
corsConfig.addAllowedHeader("*"); // 모든 헤더 허용
corsConfig.setAllowCredentials(true); // 인증정보 포함 허용 (ex: 쿠키)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig); // 모든 경로에 대해 CORS 설정 적용
return new CorsWebFilter(source);
}
}
그런데 또 오류... 발생..
java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter
JWT 암호화 알고리즘에서 오류가 발생했다. 이유는 DatatypeConverter Class가 없다는 것이다.
그래서 build.gradle에 dependencies에 추가 했다.
implementation 'javax.xml.bind:jaxb-api:2.3.1'
Refresh Gradle project 하고 재시작 하니 성공!!!!
우선 여기까지 회원가입과 로그인 처리 끝!!!
'JAVA > Spring Boot' 카테고리의 다른 글
Spring WebFlux를 이용한 chat 프로그램 - Dynamic Route 설정 (1) | 2025.05.03 |
---|---|
Spring WebFlux를 이용한 chat 프로그램 - 백엔드 기본 구성 (0) | 2025.05.01 |
Spring WebFlux를 이용한 chat 프로그램 - 프론트엔드 환경 구성 (0) | 2025.05.01 |
Spring WebFlux를 이용한 chat 프로그램 - 환경구성 (0) | 2025.05.01 |
시작 (0) | 2017.10.16 |