Rental Application (React & Spring boot Microservice) - 27 : 로그인, 회원가입, 로그아웃 마무리

yellow_note·2021년 9월 13일
1

#1 auth-service Entity

로그인, 회원가입시 입력 사항에 관한 에러(이메일 중복, 닉네임 중복 등)를 유저 화면에서 띄우도록 하겠습니다. 그러기 전에 우선 auth-service의 UserEntity의 정보를 확인하도록 하겠습니다.

  • UserEntity
package com.microservices.authservice.entity;

import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Data
@Entity
@Table(name="users")
@NoArgsConstructor
public class UserEntity {
    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;

    @Column(nullable = false, length=50, unique = true)
    private String email;

    @Column(nullable = false, length = 50, unique = true)
    private String nickname;

    @Column(nullable = false)
    private String phoneNumber;

    @Column(nullable = false, unique = true)
    private String userId;

    @Column(nullable = false)
    private String createdAt;

    @Column(nullable = false, unique = true)
    private String encryptedPwd;

    ...
}

nickname과 email은 unique한 값입니다. 그러면 회원가입 시 nickname과 email이 unique한지를 확인해야겠죠. 그러기 위해서 Controller에서 이와 관련된 REST 요청을 만들도록 하겠습니다.

  • AuthController
@GetMapping("/check/nickname/{nickname}")
public ResponseEntity<?> checkNickname(@PathVariable("nickname") String nickname) {
    log.info("Auth Service's Controller Layer :: Call checkNickname Method!");

    if(authService.checkNickname(nickname)) {
            return ResponseEntity.status(HttpStatus.OK).body(false);
    }

    return ResponseEntity.status(HttpStatus.OK).body(true);
}

@GetMapping("/check/email/{email}")
public ResponseEntity<?> checkEmail(@PathVariable("email") String email) {
    log.info("Auth Service's Controller Layer :: Call checkNickname Method!");

    if(authService.checkEmail(email)) {
        return ResponseEntity.status(HttpStatus.OK).body(false);
    }

    return ResponseEntity.status(HttpStatus.OK).body(true);
}

nickname과 email의 중복을 체크하기 위한 메서드를 만들었습니다. 값이 존재한다면 false를 반환하고 존재하지 않는다면 true를 반환하는 방식입니다.

  • AuthService
package com.microservices.authservice.service;

import com.microservices.authservice.dto.UserDto;
import org.springframework.security.core.userdetails.UserDetailsService;

public interface AuthService extends UserDetailsService {
    UserDto registerUser(UserDto userDto);

    UserDto getUserDetailsByEmail(String email);

    UserDto getUser(String userId);

    UserDto getRentalsByNickname(String nickname);

    UserDto getBorrowsByNickname(String nickname);

    boolean checkNickname(String nickname);

    boolean checkEmail(String email);
}
  • AuthServiceImpl
...

@Service
@Slf4j
public class AuthServiceImpl implements AuthService {
    ...

    @Transactional
    @Override
    public boolean checkNickname(String nickname) {
        log.info("Auth Service's Service Layer :: Call checkNickname Method!");

        return authRepository.existsByNickname(nickname);
    }

    @Transactional
    @Override
    public boolean checkEmail(String email) {
        log.info("Auth Service's Service Layer :: Call checkEmail Method!");

        return authRepository.existsByEmail(email);
    }

    ...
}

jpa에서는 exists~ 라는 이름으로의 메서드를 지원합니다. 이 메서드는 실제 쿼리에서 limit 1이라는 조건을 활용하여 1개만 검색하고 존재하는지 아닌지만 반환합니다.

  • AuthRepository
package com.microservices.authservice.repository;

import com.microservices.authservice.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AuthRepository extends JpaRepository<UserEntity, Long> {
    UserEntity findByEmail(String email);

    UserEntity findByUserId(String userId);

    boolean existsByNickname(String nickname);

    boolean existsByEmail(String email);
}

auth-service에서 중복검사를 위한 기능을 만들었으니 react에서 이 기능과 연결해보도록 하겠습니다.

#2 react

  • ./src/lib/api/auth.js
import client from './client';

...

export const checkNickname = nickname => client.get(`/auth-service/check/nickname/${nickname}`);


export const checkEmail = email => client.get(`/auth-service/check/email/${email}`);

auth-service에 GET 요청을 보낼 때 header에 jwt를 실어서 보냈지만 이 기능들은 회원가입 때 사용해야하는 기능이므로 jwt가 존재하지 않습니다. 따라서 jwt를 실지 않고 요청이 가능하도록 apigateway-service에서 요청받을 endpoint를 추후에 만들도록 하겠습니다.

  • ./src/modules/auth.js
...
const [CHECK_NICKNAME, CHECK_NICKNAME_SUCCESS, CHECK_NICKNAME_FAILURE] = createRequestActionTypes('auth/CHECK_NICKNAME');
const [CHECK_EMAIL, CHECK_EMAIL_SUCCESS, CHECK_EMAIL_FAILURE] = createRequestActionTypes('auth/CHECK_EMAIL');

...
export const checkEmail = createAction(CHECK_EMAIL, email => email);
export const checkNickname = createAction(CHECK_NICKNAME, nickname => nickname);

...
const checkEmailSaga = createRequestSaga(CHECK_EMAIL, authAPI.checkEmail);
const checkNicknameSaga = createRequestSaga(CHECK_NICKNAME, authAPI.checkNickname);

export function* authSaga() {
    ...
    yield takeLatest(CHECK_EMAIL, checkEmailSaga);
    yield takeLatest(CHECK_NICKNAME, checkNicknameSaga);

const initialState = {
    ...
    checkedEmail: null,
    checkedNickname: null,
    ...
};

const auth = handleActions(
    {
        ...
        [CHECK_EMAIL_SUCCESS]: (state, { payload: checkedEmail }) => ({
            ...state,
            checkedEmail,
        }),
        [CHECK_EMAIL_FAILURE]: (state, { payload: error }) => ({
            ...state,
            authError: error,
        }),
        [CHECK_NICKNAME_SUCCESS]: (state, { payload: checkedNickname }) => ({
            ...state,
            checkedNickname,
        }),
        [CHECK_NICKNAME_FAILURE]: (state, { payload: error }) => ({
            ...state,
            authError: error,
        })
    },
    initialState,
);

export default auth;

email, nickname을 체크할 수 있는 상태 값 리덕스 모듈과 액션함수를 만들었고, 회원가입 때 사용할 수 있도록 RegisterForm에서 사용해보도록 하겠습니다.

  • ./src/components/auth/LoginForm.js
...

const LoginForm = ({ history }) => {
    ...

    useEffect(() => {
        if(authError) {
            setError('에러 발생!');
            
            return;
        }

        if(auth) {
            const { userId } = auth;

            dispatch(check(userId));
        }
    }, [dispatch, user, auth, authError, history]);

    useEffect(() => {
        if(user) {
            try {
                setError(null);

                localStorage.setItem('user', JSON.stringify(user));

                dispatch(initializeForm('auth'));
                
                dispatch(initializeForm('headers'));
                
                history.push('/');
            } catch(e) {
                console.log('localStorage is not working');
            }
        }
    }, [dispatch, user, history]);

    useEffect(() => {
        if(headers) {
            const { 
                userid, 
                token 
            } = headers;

            localStorage.setItem('token', JSON.stringify(token));
            
            dispatch(info(userid));
        }
    }, [dispatch, headers]);

    return (
        <AuthForm
            type='login'
            form={ form }
            onChange={ onChange }
            onSubmit={ onSubmit }
            error={ error }
        />
    );
};

export default withRouter(LoginForm);
  • ./src/components/auth/RegisterForm.js
...

const RegisterForm = ({ history }) => {
    const [error, setError] = useState('');
    const dispatch = useDispatch();
    const { 
        form, 
        auth, 
        authError,
        checkedEmail,
        checkedNickname,
    } = useSelector(({ 
        auth,
    }) => ({
        form: auth.register,
        auth: auth.auth,
        authError: auth.authError,
        checkedEmail: auth.checkedEmail,
        checkedNickname: auth.checkedNickname,
    })); 

    ...

    // Handler that registers form
    const onSubmit = e => {
        e.preventDefault();

        const { 
            email, 
            password,
            passwordConfirm,
            nickname,
            phoneNumber, 
        } = form;

        if([
            email, 
            password,
            passwordConfirm,
            nickname,
            phoneNumber,
        ].includes('')) {
            // setError
            setError('입력하지 않은 사항이 있습니다.');

            return;
        }

        if(password !== passwordConfirm) {
            // setError
            setError('비밀번호가 일치하지 않습니다.');

            return;
        }

        if(checkedEmail && checkedNickname) {
            dispatch(register({
                email,
                password,
                nickname,
                phoneNumber,
            }));
            
            history.push('/');
        }
    };

    useEffect(() => {
        dispatch(initializeForm('register'));
    }, [dispatch]);

    useEffect(() => {
        const { email } = form;

        dispatch(checkEmail(email));

        if(!checkedEmail) {
            setError('이메일 중복!');

            return;
        }
    }, [dispatch, checkedEmail, form]);


    useEffect(() => {
        const { nickname } = form;

        dispatch(checkNickname(nickname));

        if(!checkedNickname) {
            setError('닉네임 중복!');

            return;
        }
    }, [dispatch, checkedNickname, form]);

    return (
        <AuthForm
            type='register'
            form={ form }
            onChange={ onChange }
            onSubmit={ onSubmit }
            error={ error }
        />
    );
};

export default withRouter(RegisterForm);

useState를 사용하여 에러 상태를 담을 수 있는 변수를 만들었습니다. checkEmail, checkNickname메서드를 사용하여 auth-service에서 반환받은 상태값을 각 state에 담았습니다. useEffect를 이용하여 값을 받는대로 REST요청을 수행하게 되고 실질적으로 화면엔 실시간으로 해당 값이 중복인지 아닌지를 체크할 수 있도록 만들었습니다. 최종적으로 checkedNickname, checkedEmail이 true여야만 회원가입이 가능하도록 코드를 수정했습니다. 그러면 이 error에 에러에 관한 상태값들을 담고 AuthForm으로 보내어 사용자 화면에 띄우도록 하겠습니다.

  • ./src/components/auth/AuthForm.js
...

const ErrorMessage = styled.div`
    color: red;
    text-align: center;
    font-size: 14px;
    margin-top: 1rem;
`;

...

const AuthForm = ({ 
    type, 
    form, 
    onChange, 
    onSubmit, 
    error 
}) => {
    const text = textMap[type];

    return(
        <AuthFormBlock>
            { text === 'login' ? (
                <>
                    ...
                    <form onSubmit={ onSubmit }>
                        ...
                        { error && <ErrorMessage>{ error }</ErrorMessage>}
                        <FullButton>
                            로그인        
                        </FullButton>
                        <BorderButton>
                            <Link to="/auth/RegisterPage">
                                회원가입
                            </Link>
                        </BorderButton>
                    </form>
                </>
            ) : (
                <>
                    ...
                    <form onSubmit={ onSubmit }>
                        ...
                        { error && <ErrorMessage>{ error }</ErrorMessage>}
                        <FullButton>
                            가입하기
                        </FullButton>
                    </form>
                </>
            )}
        </AuthFormBlock>
    );
};

export default AuthForm;

마지막으로 apigateway-service에서 check에 관한 endpoint를 만들겠습니다.

  • application.yml
spring:
  ...

  cloud:
    gateway:
      routes:
        ...


        - id: auth
          uri: lb://AUTH-SERVICE
          predicates:
            - Path=/auth-service/check/**
            - Method=GET
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/auth-service/(?<segment>.*), /$\{segment}

        ...

      ...
...

#3 MyPage

로그인에 관한 부분을 구현완료가 되었으니 이제 MyPage부분을 수정하도록 하겠습니다.
수정하려는 내용은 로그인이 되지 않았을 때 상단 마이페이지 아이콘 클릭 시 로그인 페이지로 이동시키는 것입니다.

  • ./src/componens/user/MyPageForm.js
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { Link, withRouter } from 'react-router-dom';
import LogoutButton from './LogoutButton';
import LogoutBox from './LogoutBox';
import { useSelector } from 'react-redux';

...

const MyPageForm = ({ history }) => {
    const { user } = useSelector(({ user }) => ({
        user: user.user
    }));

    useEffect(() => {
        if(!user) {
            history.push('/auth/LoginPage');
        }
    }, [history, user]);

    return(
        <>
            { user &&
                ...
            }
        </>
    );
};

export default withRouter(MyPageForm);

로그인이 되면 user state에 유저의 정보가 채워지니 이 user의 정보가 존재한다면 로그인이 된 것이므로 MyPage의 내용을 띄우도록 하고, user state에 정보가 없다면 로그인 상태가 아니므로 로그인 페이지로 이동시켰습니다.

#4 로그아웃

마지막으로 로그아웃도 구현을 해보도록 하겠습니다. 우선 auth-service에 다음의 메서드를 추가하도록 하겠습니다.

  • ./controller/AuthController
...

@RestController
@RequestMapping("/")
@Slf4j
public class AuthController {
    ...

    @PostMapping("/logout")
    public ResponseEntity<?> logout() {
        log.info("Auth Service's Controller Layer :: Call logout Method!");

        return ResponseEntity.status(HttpStatus.NO_CONTENT).body("Successfully logout");
    }
}

apigateway에 logout을 위한 엔드포인트를 추가하겠습니다.

  • application.yml
spring:
  ...

  cloud:
    gateway:
      routes:
        ...

        - id: auth
          uri: lb://AUTH-SERVICE
          predicates:
            - Path=/auth-service/logout
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/auth-service/(?<segment>.*), /$\{segment}

        ...
...

그리고 react디렉토리로 넘아가서 api에 다음의 메서드를 정의하겠습니다.

  • ./src/lib/api/auth.js
...

export const logout = () => client.post('/auth-service/logout');
  • ./src/modules/user.js
...
const LOGOUT = 'user/LOGOUT';

...
export const logout = createAction(LOGOUT);

...

function* logoutSaga() {
    try {
        yield call(authAPI.logout);
        
        localStorage.removeItem('user');
    } catch(e) {
        console.log(e);
    }
}

export function* userSaga() {
    yield takeLatest(CHECK, checkSaga);
    yield takeLatest(CHECK_FAILURE, checkFailureSaga);
    yield takeLatest(LOGOUT, logoutSaga);
}

...

export default handleActions(
    {
        ...
        [LOGOUT]: state => ({
            ...state,
            user: null
        }),
    },
    initialState,
);

로그아웃 리덕스 모듈의 주목적은 user state를 null값으로 만들어 제거하는 것이 주목적입니다. 로그인 시 화면을 넘어가게 만드는 조건은 user state를 기준으로 구현을 했기 때문에 이 state를 제거하도록 합니다.

로그아웃을 위한 api, module을 작성했으니 MyPageForm에서 모듈을 가져와 연결하도록 하겠습니다.

  • ./src/components/user/MyPageForm.js
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { Link, withRouter } from 'react-router-dom';
import LogoutButton from './LogoutButton';
import LogoutBox from './LogoutBox';
import { useDispatch, useSelector } from 'react-redux';
import { logout } from '../../modules/user';

...

const MyPageForm = ({ history }) => {
    const dispatch = useDispatch();
    ...

    const onLogout = () => {
        dispatch(logout());
    };

    ...

    return(
        <>
            { user &&
                <MyPageFormBlock>
                    <LineBlock>
                        <FullLine>
                            ...
                            <LogoutBox>
                                <LogoutButton onLogout={ onLogout } />
                            </LogoutBox>
                        </FullLine>
                    </LineBlock>
                    ...
                </MyPageFormBlock>
            }
        </>
    );
};

export default withRouter(MyPageForm);
  • ./src/components/user/LogoutButton.js
...

const LogoutButton = ({ onLogout }) => {
    return(
        <Button onClick={ onLogout }>
            Logout
        </Button>
    );
};

export default LogoutButton;

로그아웃도 구현이 완료되었습니다.

그럼 최종적으로 테스트를 진행하겠습니다.

#5 테스트

1) 회원가입 - 정보 미입력

2) 회원가입 - 이메일 중복

3) 회원가입 - 닉네임 중복

4) 회원가입 - 비밀번호 불일치

5) 로그인 - 비인가, 이메일과 비밀번호 불일치

6) 마이페이지 이동

7) 로그아웃

앞서 구현해두었던 에러들이 모두 정상적으로 처리되는 모습을 볼 수 있습니다.

이로써 회원 가입, 로그인, 로그아웃에 대한 전반적인 작업이 끝났습니다. 다음 포스트에서는 게시글에 관해 작성하도록 하겠습니다.

참고

리액트를 다루는 기술 : 저자 - 김민준

0개의 댓글

관련 채용 정보