9 분 소요

기본 경로 설정과 USER API 호출 과정

index.js에서 그려지는 App 컴포넌트에서 모든 경로가 설정이 된다. 이 글에서는 홈페이지에 처음 접속했을 때 페이지가 렌더링되는 과정을 정리한다.

이 페이지에서는 Spring Security의 자세한 인증 과정에 대해서는 다루지 않는다.

<Header {로그인 여부} {관리자 여부} {로그아웃 핸들러} />

Header 컴포넌트는 모든 페이지에서 고정되는 값이고, 로그인 여부에 따라 보여지는 정보가 달라지게 된다.

1. 로그인 여부 확인

홈페이지에 접속하면 useEffect() 함수에 의해 단 한 번 로그인 정보를 확인한다. 토큰 값을 확인해서 로그인이 되어 있다면, 현재 인증된 사용자의 정보를 불러오고, 관리자라면 그에 맞는 컴포넌트들을 활성화해준다.

useEffect(() => {
  const token = localStorage.getItem("token");
  if (token) {
    setIsLoggedIn(true);
    api.get("/users/me").then((response) => {
      const user = response.data.body;
      setIsAdmin(user.role === "ROLE_ADMIN");
    });
  }
}, []);

📚 GET /api/users/me

현재 인증된 사용자의 정보를 불러온다.

**DTO 클래스를 사용하여 데이터를 주고 받을 때,
DTO를 Request, Response 클래스로 분리하여 활용하는 이유**

- 유연한 데이터 구조
	DTO의 데이터 구조 외에, 요청과 응답에서 사용되는 데이터가
	추가적으로 필요할 수 있음.

- 보안 및 데이터 노출 제어
	요청과 응답 시, 필요한 최소한의 데이터만 제공하고자 한다.
	위의 유연한 데이터 구조 내용과 일맥상통하는 부분이다.
	ex) request에는 사용자 비밀번호가 사용되지만, response에서는
			사용자 비밀번호 정보가 제외되어야 한다.
public class Api<T> {
	private Result result;
	private T body;
}	

UserController에서 response 요청의 결과 값을 정의하는 Result 객체와 사용자 정보를 정의하는 UserResponseMe 객체를 한 번에 Api 객체로 묶어서 리턴해준다. Api = {result : Result, body : UserResponseMe} 형태의 JSON 값이 전달이 되고, 따라서 response.data.body 명령어를 통해 리액트에서 user 객체 정보를 확인할 수 있다.

**객체를 생성할 때 builder를 활용하는 이유**

- 생성자를 많이 만들 필요가 없어진다.
	상황에 따라 필요한 파라미터의 종류가 다른 경우, 일일이 그 
	파라미터를 가지는 생성자를 만들어 두어야 하는데, builder를
	사용하면 그럴 필요가 없이 setter 메서드를 사용하듯이 코드를
	구현할 수 있다.
	
- 메서드 체이닝을 통해 가독성이 높아진다.

📄 Result.java

결과 값 중 하나인 Reusult 클래스에서 ErrorCode 또는 SuccessCode의 정보를 가져와서 리턴해준다.

  • OK → { resultcode : 200, resultMessage : “성공”, resultDescription : “성공” }
  • PAGE_SAVED → { resultcode : 201, resultMessage : “페이지가 성공적으로 저장되었습니다.”, resultDescription : “성공” }
  • BAD_REQUEST → { resultcode : 400, resultMessage : “잘못된 요청”, resultDescription : “에러발생” }
  • SERVER_ERROR → { resultcode : 500, resultMessage : “서버 에러”, resultDescription : “에러발생” }
  • NULL_POINT → { resultcode : 512, resultMessage : “Null point”, resultDescription : “에러발생” }

UserResponseMe 객체의 값은 Mapper를 통해 가져오는 것이 아니라, 사용자 객체의 정보를 담고 있는 Authentication 인터페이스의 principal 필드를 통해 가져온다.

2. 로그인 하기

사용자가 아이디와 패스워드를 입력하면, 사용자 인증 절차를 거친 후 위의 “인증된 사용자 정보”를 불러오는 GET “/api/users/me” 경로로 이동하여 관리자인지 한 번 더 확인하는 과정을 거친다.

📚 POST /api/users/authenticate

사용자 로그인 수행 및 JWT 토큰(access/refresh token)을 리턴한다. Api 객체로 요청에 대한 결과(Result 객체)와 토큰 두 가지를 묶어서 값을 전달해준다.

passwordEncoder.matches(’입력받은 비밀번호’, ‘db에 저장된 암호화된 비밀번호’)

  • 사용자의 비밀번호 정보의 경우 보안을 위해 PasswordEncoder클래스를 이용해 BCrypt 방식으로 암호화를 하여 db에 저장해두게 된다.
  • 암호화된 해시 값을 그냥 비교할 수가 없기 때문에 matches() 함수를 활용하여 값을 비교할 수 있다.
public UserAuthenticationResponse authenticate(UserLoginRequest body) {
    var userEntity = getUserEntityByUserEmail(body.getEmail());

    if (passwordEncoder.matches(body.getPassword(), userEntity.getPassword())) {
        var accessToken = jwtService.generateAccessToken(userEntity);
        var refreshToken = jwtService.generateRefreshToken(userEntity);
        return new UserAuthenticationResponse(accessToken, refreshToken);
    } else {
        throw new ApiException(UserErrorCode.INVALID_PASSWORD);
    }
}

입력된 이메일에 해당하는 User DTO를 가져와서, 입력된 비밀번호와 값을 비교한다. 제대로 입력이 되었다면 토큰을 생성하여 리턴해준다.

const response = await api.post('/users/authenticate', { email, password });
const { accessToken, refreshToken } = response.data.body;
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);
onLogin(); // 로그인 성공 후 콜백 함수 호출
//const from = location.state?.from?.pathname || "/";
navigate(from, { state: { from: location.pathname } }); // 원래 목적지로 이동

리액트에서 Link 컴포넌트나 navigate 함수를 사용할 때는 상태를 따로 설정하지 않으면 location.state가 undefined로 유지된다. 따라서 위와 같이 target 주소(from)와 함께 state 값을 지정해주어야 한다.

accessToken을 통해 이 홈페이지가 로그인 된 상태인지 확인할 수 있다.

**Access Token과 Refresh Token을 함께 사용하는 이유**

- Token에는 유효기간이 존재한다.
	Access Token을 통해 계정을 사용할 수 있도록 인증을 받는데,
	이 토큰이 탈취당하게 되면 다른 사람이 해당 계정을 사용할 수
	있다. 이 문제를 해결하기 위해 유효 기간을 두어 시간이 지나면
	재로그인을 하게 해야한다.

- 빈번한 재로그인을 없애기 위해 Refresh Token을 사용한다.
	보안을 위해 주기적으로 재로그인을 하는 것도 UX의 측면에서 매우 
	좋지 않다. **따라서 Access Token이 만료된 경우 유효 기간이 
	더 긴 Refresh Token값을 대신 넣어 재요청을 보낸다.** 그러면
	서버는 응답으로 새로운 Access Token을 발급해주는 식으로 개선을
	하는 것이다.

다음은 service/JwtService.java 파일의 내용으로, 우리 프로젝트는 이와 같은 방식으로 두 가지 토큰의 유효 기간을 설정했다.

// 액세스 토큰을 생성합니다. 유효 기간은 15분입니다.
public String generateAccessToken(UserDetails userDetails){
    return generateToken(userDetails.getUsername(), 1000 * 60 * 15); // 15분
}

// 리프레시 토큰을 생성합니다. 유효 기간은 7일입니다.
public String generateRefreshToken(UserDetails userDetails){
    return generateToken(userDetails.getUsername(), 1000 * 60 * 60 * 24 * 7); // 7일
}

이 내용을 이해했다면, Refresh Token을 넘기는 과정도 이해할 수 있다.

📚 POST /api/users/refresh

15분이 지나 클라이언트로부터 만료된 Access Token을 받은 서버는 401(Unauthorized) 에러로 응답을 보낸다. 이 경우에, 직전에 얘기한 대로 Access Token 대신 Refresh Token을 보내서 새롭게 토큰을 발급 받아 주어야 하는 것이다. 모든 api에 대하여 토큰이 만료된 경우를 따져야 하므로 응답 인터셉터에서 status가 401인 경우에 이 API를 호출해주도록 하자.

정리하자면 다음과 같다.

  1. 웹 페이지에서 어떠한 동작을 하기 위해 요청을 보낸다.
  2. Access Token이 만료되어 서버는 응답으로 401 에러를 보낸다.
  3. 우리는 이 때 응답 인터셉터를 통해 Refresh Token을 통해 Access Token을 새로 발행하는 로직을 실행한다.
  4. 성공한다면, 재로그인이 이루어져 사용자 입장에서는 어떤 일이 있었는지 알지 못하고 계속해서 서비스를 이용
  5. 실패한다면(Refresh Token까지도 만료가 되었다면), 이때는 로그인 페이지로 이동해 다시 로그인을 하도록 유도

이외에도, 구글로 로그인하는 방식도 제공을 하고 있다.

**OAuth란?**

- **OAuth**("**O**pen **Auth**orization")는 인터넷 사용자들이 비밀번호를 
	제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 
	애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 
	사용되는, 접근 위임을 위한 개방형 표준이다. 이 매커니즘은 여러 
	기업들에 의해 사용되는데, 이를테면 아마존, 구글, 페이스북, 
	마이크로소프트, 트위터가 있으며 사용자들이 타사 애플리케이션이나 
	웹사이트의 계정에 관한 정보를 공유할 수 있게 허용한다.

한 마디로 구글로 로그인하기 기능이다. 이를 사용하기 위해서는 구글 클라우드 플랫폼에서 OAuth 관련 설정을 해주어야 하고, 그렇게 만들어진 client ID를 App.js에 다음과 같이 넣어주었다.

<GoogleOAuthProvider clientId={clientID}>
	<Routes>...</Routes>
</GoogleOAuthProvider>

📚 POST /api/users/oauth2/google

googleLogin.png

구글 로그인을 하기 위한 과정이다. 로그인 성공 시, Access Token을 발급받으며 onSuccess 콜백함수가 호출이 되는데, 우리는 이 콜백 함수를 잘 만져서 위 이미지의 Access Token 전달 과정부터 구현을 해내야 한다.

<GoogleLogin onSuccess={handleGoogleSuccess} onError={handleGoogleError} />

@react-oauth/google 모듈에서 가져온 로그인 버튼이다. 방금 설명한 대로, 우리의 경우에는 handleGoogleSuccess 함수를 구현해야 할 것이다.

const handleGoogleSuccess = (response) => {
  console.log('Login Success:', response);

  fetch('http://localhost:8080/api/users/oauth2/google', {
      method: 'POST',  // POST 요청으로 수정
      headers: {
          'Content-Type': 'application/json',
      },
      body: JSON.stringify({ token: response.credential }),  // body에 데이터 포함
  })
      .then((res) => {
          console.log('Response status:', res.status);
          console.log('Response headers:', res.headers);
          return res.text();  // Change to text for debugging
      })
      .then((data) => {
          try {
              console.log('Response text:', data);
              const jsonData = JSON.parse(data);  // 수동으로 JSON 파싱
              console.log('Parsed JSON:', jsonData);
              if (jsonData.accessToken) { 
                  localStorage.setItem("token", jsonData.accessToken);
                  onLogin();
                  if (jsonData.isFirstLogin) {  // 첫 로그인 여부 확인
                      navigate('/survey');  // 설문조사 페이지로 이동
                  } else {
                      navigate('/'); // 홈 페이지로 이동
                  }
              } else {
                  console.error('Login failed, no access token in response:', jsonData);
              }
          } catch (error) {
              console.error('Error parsing JSON:', error);
              console.log('Response text:', data);
          }
      })
      .catch((error) => {
          console.error('Error:', error);
      });
};

위 코드를 보면서 그대로 따라오면 이해하기 쉽다.

  1. 콜백 함수인 handleGoogleSuccess의 인자로 Google Account로부터 반환 받은 AccessToken이 들어가 있고, 이 내용을 /api/users/oauth2/google 경로로 POST 방식으로 전달한다.
  2. 백엔드에서 처리가 이루어질 차례인데, 우리의 경우에는 구글에서 제공하는 api를 활용하여 신규 회원 가입 처리를 바로 해버리고, 이후에는 일반 로그인과 똑같이 {AccessToken, RefreshToken} 값을 리턴해주게 된다.
  3. 구글 로그인의 경우에 바로 회원가입 처리를 해버리면 성별이나 전화번호, 생일을 파악할 수 없기 때문에(payload 객체에 유저 이메일 정보와, 이름 정보만 가져올 수 있다), 위 그림과 같이 신규회원인지 여부를 확인해서 {AccessToken, RefreshToken, isNewUser} 값을 리턴해주고, 신규회원이라면 추가적인 회원정보를 입력받는 창으로 이동을 시킨다.

     public UserAuthenticationResponse googleLogin(String token) {
     	GoogleIdToken.Payload payload = verifyGoogleToken(token);
     	String email = payload.getEmail();
     	//null 값을 명시적으로 처리하기 위해 optional 클래스를 컨테이너로 활용
     	Optional<User> optionalUser = userMapper.findByEmail(email); 
     	User user;
     	if (optionalUser.isEmpty()) { //신규회원인 경우 회원 가입 처리
     	    user = new User();
     	    user.setEmail(email);
     	    user.setName((String) payload.get("name"));
     	    user.setPassword(""); // 구글로그인으로 AccessToken을 따로 받았기에 비밀번호 필요 x
     	    user.setRole("ROLE_USER");
     	    user.setCreatedAt(LocalDateTime.now());
     	    user.setUpdatedAt(LocalDateTime.now());
     	    userMapper.insert(user);
     	} else {
     	    user = optionalUser.get();
     	    user.setUpdatedAt(LocalDateTime.now());
     	    userMapper.update(user);
     	}
        	
     	String jwt = jwtService.generateAccessToken(user);
     	String refreshToken = jwtService.generateRefreshToken(user);
     	return new UserAuthenticationResponse(jwt, refreshToken);
     }
    
  4. 이렇게 되면, 첫 번째 메서드체이닝의 인자로 response 정보가 들어와 있을 것이고, 이것의 텍스트 값을 구하면, 방금 백엔드에서 REST controller로 보낸 {AccessToken, RefreshToken} 값을 받게 되는 것이다.
  5. 이후에는 일반 로그인과 똑같이, onLogin() 콜백 함수를 호출하여 토큰이 존재하는지 검사 후, 관리자인지 검사하여 적절한 페이지를 렌더링해주게 된다. (관리자라면 관리자 페이지가 노출되도록)

3. 회원 가입하기

📚 POST /api/users/register

회원 가입을 진행하는 api이다. 회원 가입 버튼을 누르면 라우터를 통해 /signup 경로로 이동해 SignUp 컴포넌트를 렌더링한다.

회원 가입 폼에 적힌 정보를 서버로 넘겨주게 되는데, 이 요청 정보를 받는 클래스인 UserRegisterRequest 클래스에서 입력 받은 정보가 옳은지 검증하는 절차를 거친다.

@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$",
        message = "비밀번호는 최소 8자 이상이어야 하며, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다.")
private String password;

@NotBlank(message = "전화번호는 필수 입력 값입니다.")
@Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다. (010-1234-5678)")
private String phoneNumber; // 전화번호 추가

@NotBlank(message = "생년월일은 필수 입력 값입니다.")
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "올바른 생년월일 형식이 아닙니다. (yyyy-MM-dd)")
private String birthdate;

이렇게 정규식 표현을 통해 입력 정보를 제한하여 받을 수 있다.

userMapper.findByEmail(request.getEmail()).ifPresent(user -> {
    throw new ApiException(UserErrorCode.USER_ALREADY_EXISTS);
});

동일한 이메일을 가지고 회원가입을 여러 번 하지 못하도록 예외처리를 하는 부분이다. ifPresent 함수는 Optional 클래스의 메서드로 값이 존재하면 콜백 함수를 호출하고, 그렇지 않다면 무시하고 다음 코드를 실행하게 된다.

4. 회원 정보 관리하기

📚 PUT /api/users/me

put 방식으로 요청을 보내게 되면, 회원 정보를 수정할 수 있다.

UserUpdateRequest 객체를 통해 바뀐 회원 정보 값을 @RequestBody로 가져온다. 현재 본인의 정보는 Authentication 객체를 통해 바로 접근한다.

var userEntity = userMapper.findById(currentUser.getUserId())
  .orElseThrow(() -> new ApiException(UserErrorCode.USER_NOT_FOUND));

회원 가입 때와 마찬가지로, Optional 클래스의 메서드인 orElseThrow 함수를 통해 만약 Mapper에서 값을 가져오지 못하면 바로 예외처리를 할 수 있게 코드를 구현하여 가독성을 높였다.

if (!isGoogleUser && (request.getCurrentPassword() != null || request.getNewPassword() != null)) {
    if (request.getCurrentPassword() == null || request.getCurrentPassword().isEmpty()) {
        throw new ApiException(UserErrorCode.INVALID_PASSWORD, "현재 비밀번호는 필수 입력 값입니다.");
    }

    if (!passwordEncoder.matches(request.getCurrentPassword(), userEntity.getPassword())) {
        throw new ApiException(UserErrorCode.INVALID_PASSWORD);
    }

    if (request.getNewPassword() != null && !request.getNewPassword().isEmpty()) {
        userEntity.setPassword(passwordEncoder.encode(request.getNewPassword()));
    }
}

사용자 명이나 이메일 등 다른 정보는 별도의 인증 없이 변경할 수 있게 했고, 위의 코드를 통해 비밀번호를 바꾸는 경우에 대해서만 별도의 처리를 해주었다.

  1. 구글 사용자는 비밀번호를 따로 관리하지 않기 때문에 비밀번호 변경을 지원하지 않는다.
  2. 현재 비밀번호를 입력하지 않았거나 잘못 입력한 경우 변경을 막는다.
  3. 변경하고자 하는 비밀번호가 null값인 경우 변경을 막는다.

→ 즉, 구글 사용자가 아니면서 현재 비밀번호를 올바르게 입력하고, 바꾸고자 하는 비밀번호가 있어야 하는 것인데, 사실 이건 UserUpdateRequst 클래스에서 정규표현식을 통해 제약 조건을 두어 필요가 없는 코드일 수 있다. 다만 안정성을 위해 구현해두었다.

📚 DELETE /api/users/{userId}

단순히 userId에 해당하는 유저 정보를 테이블에서 삭제하는 작업을 수행한다.

5. 아이디 / 비밀번호 찾기

📚 POST /api/users/find-email

단순 select문으로, 회원의 이름과 휴대폰 번호로 이메일을 찾아준다.

📚 POST /api/users/request-password-reset

비밀번호 재설정 코드를 이메일로 전송해주는 API이다.

이메일과 사용자 이름을 입력 받아 해당 이메일로 랜덤한 4자리 숫자를 전송한다.

**private** **static** **final** Map<String, String> ***verificationCodes*** = **new** HashMap<>();

이메일과 재설정코드를 키와 값으로 갖는 Map 컨테이너를 정해두고, 추후에 코드 인증을 할 때 이 컨테이너의 내용과 비교해서 비밀번호 변경 여부를 결정한다.

public void sendVerificationCode(String email) {
    String code = generateVerificationCode();
    verificationCodes.put(email, code);
    emailService.sendEmail(email, "Verification Code", "Your verification code is: " + code);
}

private String generateVerificationCode() {
    return String.valueOf((int)(Math.random() * 10000)); // 4자리 랜덤 코드 생성
}

Math.random()은 0이상 1미만을 리턴하므로 10000을 곱하면 0000에서 9999사이의 랜덤한 코드를 가질 수 있다.

private final JavaMailSender mailSender;

public void sendEmail(String to, String subject, String text) {
    SimpleMailMessage message = new SimpleMailMessage();
    message.setTo(to);
    message.setSubject(subject);
    message.setText(text);
    mailSender.send(message);
}

JavaMailSender 인터페이스를 활용해서 메일을 보내는 객체를 주입받아온다.

DI 패턴으로 받아오므로, 보내는 객체에 대한 설정은 다음과 같이 application.properties에서 처리해주었다.

spring.mail.host=smtp.gmail.com
spring.mail.port=${MAIL_PORT}
spring.mail.username=${SENDER_EMAIL}
spring.mail.password=${SENDER_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000

📚 POST /api/users/reset-password

이메일과 재설정 코드, 새로운 비밀번호를 입력 받아 코드를 검증하고, 비밀번호를 업데이트하는 과정을 구현했다.

댓글남기기