Spring WebFlux를 이용한 chat 프로그램 - 회원가입 및 로그인

2025. 5. 2. 14:39JAVA/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 하고 재시작 하니 성공!!!!

 

우선 여기까지 회원가입과 로그인 처리 끝!!!