로그인, 회원가입시 입력 사항에 관한 에러(이메일 중복, 닉네임 중복 등)를 유저 화면에서 띄우도록 하겠습니다. 그러기 전에 우선 auth-service의 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 요청을 만들도록 하겠습니다.
@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를 반환하는 방식입니다.
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);
}
...
@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개만 검색하고 존재하는지 아닌지만 반환합니다.
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에서 이 기능과 연결해보도록 하겠습니다.
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를 추후에 만들도록 하겠습니다.
...
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에서 사용해보도록 하겠습니다.
...
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);
...
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으로 보내어 사용자 화면에 띄우도록 하겠습니다.
...
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를 만들겠습니다.
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}
...
...
...
로그인에 관한 부분을 구현완료가 되었으니 이제 MyPage부분을 수정하도록 하겠습니다.
수정하려는 내용은 로그인이 되지 않았을 때 상단 마이페이지 아이콘 클릭 시 로그인 페이지로 이동시키는 것입니다.
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에 정보가 없다면 로그인 상태가 아니므로 로그인 페이지로 이동시켰습니다.
마지막으로 로그아웃도 구현을 해보도록 하겠습니다. 우선 auth-service에 다음의 메서드를 추가하도록 하겠습니다.
...
@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을 위한 엔드포인트를 추가하겠습니다.
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에 다음의 메서드를 정의하겠습니다.
...
export const logout = () => client.post('/auth-service/logout');
...
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에서 모듈을 가져와 연결하도록 하겠습니다.
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);
...
const LogoutButton = ({ onLogout }) => {
return(
<Button onClick={ onLogout }>
Logout
</Button>
);
};
export default LogoutButton;
로그아웃도 구현이 완료되었습니다.
그럼 최종적으로 테스트를 진행하겠습니다.
1) 회원가입 - 정보 미입력
2) 회원가입 - 이메일 중복
3) 회원가입 - 닉네임 중복
4) 회원가입 - 비밀번호 불일치
5) 로그인 - 비인가, 이메일과 비밀번호 불일치
6) 마이페이지 이동
7) 로그아웃
앞서 구현해두었던 에러들이 모두 정상적으로 처리되는 모습을 볼 수 있습니다.
이로써 회원 가입, 로그인, 로그아웃에 대한 전반적인 작업이 끝났습니다. 다음 포스트에서는 게시글에 관해 작성하도록 하겠습니다.
리액트를 다루는 기술 : 저자 - 김민준