not-a-gardener 개발기 2. 회원 가입과 CORS 에러 해결

메밀·2022년 11월 28일
0

not-a-gardener 개발기

목록 보기
2/6

1. 아이디 중복 확인

1) Axios

yarn add axios를 통해 Axios를 다운받는다. 기존 프로젝트들은 JQuery 기반이었기 때문에 AJAX를 사용했지만, 이번 토이 프로젝트는 react.js를 기반이므로 Axios를 사용한다.


- Ajax와 Axios 간단 비교

공통점: 비동기 HTTP 통신을 위한 기술

AJAXAxios
Asynchronous JavaScript And XML
JqueryJS 라이브러리
Error, Suceess, Complete 상태를 통해 실행 흐름 조절애초에 Promise 기반 (return Promise)
Promise 기반이 아님모듈 설치 필요


2) http-proxy-middleware

Axios 코드를 작성하기 전에 proxy 설정을 해야한다. 나는 http-proxy-middleware를 사용하였다. React의 src 디렉토리에 setProxy.js 파일을 만든다.

// 
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
    app.use(
        proxy('/', {
            target: 'http://localhost:8080', // Spring 포트
            changeOrigin: true
        })
    );
};

이제 react의 localhost:3000에 해당하는 request는 Spring의 기본포트인 8080으로 연결된다.



3) Register.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'

const Register = () => {
  // submit용 객체
  const [register, setRegister] = useState({
    id: "",
    email: "",
    name: "",
    pw: ""
  })

  // ID 중복 검사와 그에 따른 메시지를 담을 변수
  const [idCheck, setIdCheck] = useState(false);
  const [idCheckMsg, setIdCheckMsg] = useState("");

  // input 값의 변동이 있을 시 객체 데이터 setting
  const onChange = (e) => {
    // 객체 세팅
    const {name, value} = e.target;
    setRegister(setRegister => ({...register, [name]: value }))
  }

  // ID 중복 검사를 위한 useEffect
  useEffect(() => {
    if(register.id == ""){
       return;
    }

    // 아이디 중복 검사
    axios.post("/idCheck", {'id': register.id})
    .then((res) => {
      console.log(res);

      if(res.data == ''){
        setIdCheck(true);
        setIdCheckMsg(register.id + "은(는) 사용 가능한 아이디입니다.");
      } else {
        setIdCheck(false);
        setIdCheckMsg(register.id + "은(는) 이미 사용중인 아이디입니다.");
      }
    })
  }, [register.id]);

  /* 중략 */ 
  
export default Register

onChange 함수를 통해 input 값의 변동이 있을 시 register 객체의 정보를 업데이트 했다. 이때 아이디가 입력되는 동시에 아이디 중복검사 여부 메시지를 띄우는 기능을 구현하고 싶었는데, 자꾸만 입력값의 마지막 한 글자가 반영되지 않는 오류가 발생했다. useEffect를 사용하면 즉각적인 변화를 잡아낼 수 있다는 검색 결과에 따라 useEffect를 적용하여 해결하였다.

이에 input 창에 변화가 발생하면(onChange) setRegister를 호출하여 state를 재설정하고, 의존성 배열 내의 요소인 register.id에 변화가 발생하면 effect 함수를 실행시켜 서버에서 중복 아이디 여부를 받아온다.

이후 서버에서 받아온 값에 따라 ID 중복여부 체크 메시지를 띄운다.

서버 단의 로직은 간단하므로(그냥 DB에 있나 없나 체크..) 생략한다.

2. JPA(Java Persistence API)

1) 의존성

JPA를 사용하기 위해 build.gradle에 의존성을 추가한다.

dependencies {
	implementation group: 'org.hibernate', name: 'hibernate-core', ext: 'pom'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	// 생략
}

이때, 서버를 켜는데에 한~참이 걸린다면

IntelliJ > Preference > Built, Execution, Deployment > Build Toos > Gradle > Build and run using: Intellij IDEA / Run tests using: IntelliJ IDEA

로 변경해준다.



2) MemberRepository

프로젝트 not-a-gardener의 데이터 흐름은 다음과 같다.

Client — Controller — Service — (DataHandler) — DAO — Repository

이때 JPA를 사용하면 놀랍게도 Repository의 코드는 달랑 아래와 같다.

@Repository
public interface MemberRepository extends JpaRepository<MemberEntity, String> {
}

JpaRepository를 상속받고 해당 Repository와 연결할 엔티티와 id column의 타입을 적어주면 (아이디 중복검사에서 사용한) findById() 같은 다양한 메서드를 사용할 수 있다. Spring Data JPA가 알아서 구현체를 생성해주기 때문이다.




3. 회원가입 구현과 CORS 문제 발생...

다음으론 회원가입을 구현했고, 말로만 듣던 CORS 문제가 발생하여 며칠을 헤맸다. (그 사이에 코로나도 걸렸다 🥲)

1) React

import React, { useState, useEffect } from 'react'
import axios from 'axios'

const Register = () => {
  // submit용 객체
  const [register, setRegister] = useState({
    id: "",
    email: "",
    name: "",
    pw: ""
  })

  // ID 중복 검사와 그에 따른 메시지를 담을 변수
  const [idCheck, setIdCheck] = useState(false);
  const [idCheckMsg, setIdCheckMsg] = useState("");

  // 비밀번호 확인 여부와 그에 따른 메시지를 담을 변수
  const [repeatPw, setRepeatPw] = useState("");
  const [pwCheck, setPwCheck] = useState(false);
  const [pwCheckMsg, setPwCheckMsg] = useState("");

  // input 값의 변동이 있을 시 객체 데이터 setting
  const onChange = (e) => {
    // 객체 세팅
    const {name, value} = e.target;
    setRegister(setRegister => ({...register, [name]: value }))
  }

  // ID 중복 검사를 위한 useEffect
  useEffect(() => {
    if(register.id == ""){
       return;
    }

    // 아이디 중복 검사
    axios.post("/idCheck", {'id': register.id})
    .then((res) => {
      console.log(res);

      if(res.data == ''){
        setIdCheck(true);
        setIdCheckMsg(register.id + "은(는) 사용 가능한 아이디입니다.");
      } else {
        setIdCheck(false);
        setIdCheckMsg(register.id + "은(는) 이미 사용중인 아이디입니다.");
      }

      console.log("idCheck: " + idCheck)
    })
  }, [register.id]);

  // 비밀번호 검사
  useEffect(() => {
    if(register.pw == ''){
      // 비밀번호란이 공란인 경우
      setPwCheck(false);
      setPwCheckMsg("");
    } else if(repeatPw !== register.pw){
      setPwCheck(false);
      setPwCheckMsg("비밀번호를 확인해주세요");
    } else {
      setPwCheck(true);
      setPwCheckMsg("비밀번호 확인 완료");
    }

  }, [register.pw, repeatPw])


  const onSubmit = (e) => {
    e.preventDefault();

      console.log(register);
    
    // TODO 유효성 검사 함수 만들기(idCheck, pwCheck, 정규식)

      axios.post("/register", register)
      .then((res) => {
        console.log("register form submit")
        console.log(res);
      })
    
  } // end for onSubmit


  return ( /* 생략 */ )
}

export default Register

요구되는 데이터는 아이디, 이메일, 이름, 비밀번호다.

아이디 중복 검사를 통과하면 idCheck 변수에 true를, 비밀번호 확인 여부 검사를 통과하면 pwCheck 변수에 true를 저장한다. 현재는 검사 함수를 만들지 않았지만, 추후 정규식을 포함하여 유효성 검사 함수를 작성할 계획이다. 이 부분을 왜 뛰어넘었냐면, JPA의 놀라움에 도취되어 빨리 save() 함수를 실행해보고 싶었기 때문인데......

2) Spring Security 적용과 CORS 문제의 시작...

Controller는 로직이 없으므로 생략하고 바로 Service로 넘어가보면...

@Service
@Slf4j
public class LoginServiceImpl implements LoginService {
    @Autowired
    private LoginDao loginDao;

    @Autowired
    private BCryptPasswordEncoder encoder;

    @Override
    public RegisterDto addMember(RegisterDto paramRegisterDto) {
        log.debug("addMember(): " + paramRegisterDto);

        // DTO에 암호화된 비밀번호 저장하기
        paramRegisterDto.encryptPassword(encoder.encode(paramRegisterDto.getPw()));

        // DB에 저장
        MemberEntity memberEntity = loginDao.addMember(paramRegisterDto.toEntity());
        log.debug("DB save: " + memberEntity);

        // TODO 회원 가입 시 자동 로그인 구현하기

        return null;
    }
}

JPA를 사용하면 비밀번호를 어떻게 암호화하지?

기존 프로젝트에서는 SQL의 password() 함수를 사용하여 DB에 바로 암호화된 비밀번호를 저장했다. 그러나 이번엔 말로만 듣던 Spring Security를 적용해보고 싶었기에 의존성을 추가하고 설정파일도 만들었으나...

@Configuration
@EnableWebSecurity // Spring Security를 사용한다
public class SpringSecurityConfig {
    // 해당 메서드에서 리턴되는 객체를 Bean으로 등록
    @Bean
    public BCryptPasswordEncoder encodePw(){
        return new BCryptPasswordEncoder();
    }
}

CORS

악명 높은 CORS 에러에 직면하고 말았다.



3) Spring Security와 CORS 에러

- 상관관계

그렇다면 왜 Spring Security를 적용하면 CORS 에러가 발생하는가? 이미 react에서 proxy middleware 코드를 작성하였으므로 이전까지는 CORS 에러가 발생하지 않았다.

그 해답은 reflectoring.io/spring-cors/에서 찾을 수 있었다. 우선 알아두어야 할 것은 Spring Security가 가장 선두에서 클라이언트 요청을 처리한다는 것이다.

만약 스프링 어플리케이션에 Spring Security를 적용했다면, Spring Security 동작 전에 CORS를 처리해야 한다. preflight request에 쿠키가 포함되어 있지 않으므로 Spring Security는 권한 없는 유저라고 판단하여 request를 거절할 것이기 때문이다.



- 해결방안: Spring Security에서 CORS 설정

Spring Security와 CORS 에러 해결을 구글링하면 가장 많이 뜨는 자료는 WebSecurityConfigurerAdapter를 상속받은 @Configuration 클래스에 설정을 오버라이딩 하는 것이다. 그러나 이 방법을 따라하면 WebSecurityConfigurerAdapter는 deprecated 되었다는 경고가 발생한다.

고로 우리는 (공식문서의 권고에 따라) SecurityFilterChain를 Bean으로 등록해야 한다. 할일은 다음과 같다.

1) CorsConfiguration에 설정 정보를 등록
2) UrlBasedCorsConfigurationSource에 1)과 URL 패턴을 저장
3) CorsFilter(CorsConfigurationSource configSource) 생성자를 사용하여 CorsFilter 생성
4) 3)에서 생성한 CorsFilter를 SecurityFilterChain에 등록



- corsFilter()와 filterChain(HttpSecurity httpSecurity)

@Configuration
@EnableWebSecurity
public class SpringSecurityConfig {
    @Bean
    public CorsFilter corsFilter() {
    	// CORS요청의 허용 origin, 헤더, 메소드등 처리되어야하는지 방법을 지정
        CorsConfiguration config = new CorsConfiguration();

		// 서버 response 시 자바스크립트가 JSON을 처리할 수 있게 할지
        config.setAllowCredentials(true);
        
        // 응답을 허용할 IP
        // cf) config.addAllowedOriginPattern("*") == 모든 ip에 응답 허용 (보안상 좋지 않음)
        config.addAllowedOrigin("http://localhost:3000");
        
        // 모든 header에 응답을 허용
        config.addAllowedHeader("*");
        
        // 모든 http method(GET, POST, PUT, PATCH, DELETE)의 응답 허용
        config.addAllowedMethod("*");
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        
        // "/**": matches zero or more 'directories' in a path
        source.registerCorsConfiguration("/**", config);

        return new CorsFilter(source);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
        		// Http basic Auth 기반으로 로그인 인증창 해제
                .httpBasic().disable()
                // 사이트간 위조요청 보호 해제
                // rest api를 이용한 서버라면, session 기반 인증과는 다르게 stateless하기 때문에 서버에 인증정보를 보관X. -> crsf 설정 필요 X
                .csrf().disable() 
               
				// SpringSecurity의 세션 생성/기존 세션 사용 X           .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
				
                // 특정 리소스의 접근 허용
                .authorizeRequests()
                .antMatchers("/**").permitAll() // 모든 주소에 대해 모두 허용
                // .antMatchers("/admin/**").hasAnyRole("ADMIN") // /admin으로 들어오는 요청은 ADMIN 권한이 있는 유저만 접근 가능

                .and()
                
                // Custom Filter 추가
                // Spring Security 사용시 CORS에 걸리지 않으려면 Authentication Filter 인증 보다 앞단계의 필터/인터셉터에서 path 검증로직이 일어나야만 한다.
                .addFilter(this.corsFilter()); // ** 위에서 만든 CorsFilter 등록 **
                // cf) addFilterBefore: 특정 filter 이전에 동작할 order / addFilterAfter

        return httpSecurity.build();
    }

    // 해당 메서드에서 리턴되는 객체를 빈으로 등록
    @Bean
    public BCryptPasswordEncoder encodePw(){
        return new BCryptPasswordEncoder();
    }
}

이렇게 CORS 문제를 간신히 해결...했다. 서버를 백 번은 껐켰한 것 같다🥲



4. 뭐 하나 쉬운 게 없다

1) BCryptPasswordEncoder.encode

BCryptPasswordEncoder.encode() 메소드로 비밀번호 암호화에도 성공했고, Builder 패턴을 사용하여 DTO에 toEntity() 메소드도 만들었다. JPA를 통해 DB에 정보를 저장하는 것에도 성공했다.

이제 기존 프로젝트에서 했던 것과 마찬가지로 DB에서 암호화된 비밀번호의 일치여부를 조회하면 되는 줄 알았는데...

BCryptPasswordEncoder.encode()는, 똑같은 데이터를 인코딩하더라도 매번 다른 문자열을 반환한다!

고로 기존에 쓰던 "SELECT id FROM member WHERE id = ? AND pw = ?" 식의 쿼리는 부적절해보인다. 게다가 기존 프로젝트에선 Session에 로그인 정보를 담아 Filter를 구현하였는데, 왜 CORS 에러를 위해 참고한 자료들은 'JWT를 사용할 것이므로 세션과 csrf 설정을 해제한다'라고 말하는가?

2) 뭐 하나 쉬운 게 없다

이제 남은 질문은 다음과 같다.

1) 세션 기반 인증 방식과 JWT를 통한 인증 방식은 어떻게 다르고, 어떤 장단점이 있는가?
2) 토큰은... 무엇인가?
2) Spring Security는 어떻게 동작하는가? 큰 흐름은 무엇인가? 지금 너무 아무것도 모르고 쓰고 있다.
4) 로그인 이렇게 어려울 일인가!!!

정말... 뭐 하나 쉬운 게 없다. 하나 가면 하나 온다.
그래도 화이팅🥲

참고
https://ji-gwang.tistory.com/76
https://dkswnkk.tistory.com/457
https://velog.io/@ohzzi/SpringBoot-React-%ED%95%9C-%EB%B2%88%EC%97%90-%EB%B9%8C%EB%93%9C%ED%95%98%EA%B8%B0
https://cocoon1787.tistory.com/756
https://velog.io/@woohobi/Spring-security-csrf%EB%9E%80
https://letsmakemyselfprogrammer.tistory.com/89
https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/
https://powernote.tistory.com/2

0개의 댓글