이전 게시글
[토이 프로젝트] spring boot 스프링 시큐리티 토큰 적용하기 (Bearer Token)
이전 글 [토이 프로젝트] spring boot 로그인 기능 만들기-2 (스프링 시큐리티 적용)이전 프로젝트 [토이 프로젝트] 회원가입 기능 만들기-1사실 아직 만들고 싶은 프로젝트는 생각안해봤다.다만 프
devhan.tistory.com
이번에 HttpOnly Cookie + JWT 조합을 구현해보려고 했으나.. 회원가입 기능과 테스트 쪽을 먼저 구현해야 할 것 같아서 노선을 틀었다. 그래서 이번에 할 목록은 아래 4개다.
- 회원가입 기능 강화
- 로그아웃 기능
- AuthController 리팩토링
사실 게시글 제목은 회원가입 기능-2지만 더 다양한 걸 한 번에 쭉 해볼 예정이다.
뭔가 제일 금방할 것 같은 로그아웃 기능을 먼저 만들어보자.
logout 기능
먼저 로그아웃 기능을 화면에서 만질 수 있게 바꿔보자.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>메인</title>
<style>
.hidden {
display: none;
}
</style>
</head>
<body>
<h2>메인입니다.</h2>
<a href="member/login.html">로그인 이동</a>
<a href="member/signup.html">회원가입 이동</a>
<a href="test/test.html">인증 필요 화면</a>
<button id="btnLogout">로그아웃</button>
<script>
window.onload = function() {
const token = localStorage.getItem("token");
if(!token) {
document.getElementById("btnLogout").classList.add("hidden");
}
}
document.getElementById("btnLogout").addEventListener("click", function(){
logout();
});
async function logout() {
const token = localStorage.getItem("token");
try {
const response = await fetch('api/auth/logout', {
method: 'POST',
headers: {
"Authorization" : `Bearer ${token}`,
"Content-Type" : "application/json"
}
});
if(!response.ok) throw new Error("로그아웃이 실패했습니다.");
alert("로그아웃 성공");
window.location.replace(window.location.href);
} catch (error) {
console.error(error);
alert("로그아웃 실패");
}
}
</script>
</body>
</html>
메인 진입 시 token 값이 있으면 로그아웃 버튼을 숨기도록 했다. 테스트해보자.


로그인하지 않았을 경우 로그아웃 버튼이 보이지 않고, 로그인하면 로그아웃 버튼이 보인다.
그리고 서버에 logout 로직을 만들어주자.
AuthController
@PostMapping("logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String token) {
if(token.startsWith("Bearer ")) {
token = token.replace("Bearer ", "");
}
authService.logout(token);
return ResponseEntity.ok().body("로그아웃 성공");
}
Token인지 확인하기 위해 Bearer 문자열 검사를 하고, 없애준다.
AuthService
public void logout(String token) {
if(validateToken(token)) {
tokenStore.remove(token);
} else {
throw new IllegalArgumentException("유효하지 않은 토큰입니다.");
}
}
validationToken 메서드를 이용해서 토큰 저장소에 해당 토큰이 있나 검사하고 있으면 토큰 저장소에서 삭제하고 토큰 저장소가 없으면 에러를 뱉는다.
테스트 해보자! 로그아웃해도 메인에 여전히 로그아웃 버튼이 남아있다.

생각해보니까 로그아웃 버튼의 display 속성 값을 localStorage에 toekn이 있나 없나 검사하기 때문에 로그아웃 시 localStorage의 token 값도 삭제해줘야할 것 같다.
alert("로그아웃 성공");
localStorage.removeItem("token");
window.location.replace(window.location.href);
다시 로그인하고 로그아웃 해보자.


잘 되는 걸 확인할 수 있다.
Controller 리팩토링
이제 AuthController에 되~~~게 거슬렸던 Response 타입을 신경써서 바꿔보자. 지금 Response 타입은 아래와 같다.
public ResponseEntity<?> validateToken(...) {
// ..
}
지금은 ResponseEntity<?>로 되어있다. 예전에 영한쓰 자바 강의를 들었을 때 ?라는 와일드 카드는 되도록 사용하지 않는 것이 좋다고 했다. 왜냐면 어떤 타입이든 다 들어갈 수 있어서 어떤 타입이 반환될지 예측하기 어렵고 유지보수가 복잡하기 떄문이다.
AuthController에서 반환 타입은 내가 만든 DTO 아니면 ResponseEntity 객체에 http status 코드나 메세지를 담은 객체이므로 ApiResponse용 DTO를 하나 더 만들어 ApiResponse에 응답 데이터와 http status 코드 등을 넣어 유지보수 하기 쉽도록 변경해보려한다.
ApiResponse
@Getter
@AllArgsConstructor
public class ApiResponse<T> {
private int status; // HTTP 상태 코드
private String message; // 응답 메세지
private T data; // 데이터
}
일단 만들고 하나의 api..회원가입의 코드를 변경하고 테스트 해보자.
@PostMapping("signup")
public ResponseEntity<ApiResponse<Boolean>> signup(@RequestBody SignupRequestDto signupRequestDto) {
boolean isSuccess = authService.signup(signupRequestDto);
HttpStatus status = isSuccess ? HttpStatus.OK : HttpStatus.BAD_REQUEST;
String message = isSuccess ? "회원 가입 성공" : "회원 가입 실패";
return ResponseEntity.status(status).body(new ApiResponse<>(status.value(), message, isSuccess));
}

잘 나온다. 근데 컨트롤러에서 ResponseEntity를 반환하는 부분을 뭔가 더 편하게 바꿔줄 수 있을 거 같아 ApiResponse에 정적 메서드를 추가했다.
@Getter
@AllArgsConstructor
public class ApiResponse<T> {
private final int status; // HTTP 상태 코드
private final String message; // 응답 메세지
private final T data; // 데이터
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(HttpStatus.OK.value(), "요청을 성공적으로 처리했습니다.", data);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(HttpStatus.OK.value(), message, data);
}
public static <T> ApiResponse<T> error(HttpStatus status, String message) {
return new ApiResponse<>(status.value(), message, null);
}
public static <T> ApiResponse<T> error(HttpStatus status, String message,T data) {
return new ApiResponse<>(status.value(), message, data);
}
}
이 정적 메서드를 이용해서 컨트롤러 반환 부분을 다시 바꿔 주자.
@PostMapping("signup")
public ResponseEntity<ApiResponse<Boolean>> signup(@RequestBody SignupRequestDto signupRequestDto) {
boolean isSuccess = authService.signup(signupRequestDto);
return isSuccess ?
ResponseEntity.ok(ApiResponse.success(true)) :
ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST, "회원 가입 실패", false));
}
이렇게 컨트롤러의 모든 메서드들을 바꿔주었다.
@PostMapping("signup")
public ResponseEntity<ApiResponse<Boolean>> signup(@RequestBody SignupRequestDto signupRequestDto) {
boolean isSuccess = authService.signup(signupRequestDto);
return isSuccess ?
ResponseEntity.ok(ApiResponse.success(true)) :
ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST, "회원 가입 실패", false));
}
@PostMapping("login")
public ResponseEntity<ApiResponse<LoginResponseDto>> login(@RequestBody LoginRequestDto loginRequestDto) {
LoginResponseDto loginResponseDto = authService.login(loginRequestDto);
return loginResponseDto.getToken() != null ?
ResponseEntity.ok(ApiResponse.success("로그인 성공", loginResponseDto)) :
ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error(HttpStatus.UNAUTHORIZED, "아이디와 비밀번호를 확인해주세요."));
}
@GetMapping("validate")
public ResponseEntity<ApiResponse<String>> validateToken(@RequestHeader("Authorization") String authorizationHeader) {
// Authorization 헤더가 없으면 401 반환
if(authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(HttpStatus.UNAUTHORIZED, "인증 필요"));
}
String token = authorizationHeader.replace("Bearer ", "");
boolean isValid = authService.validateToken(token);
return isValid ?
ResponseEntity.ok(ApiResponse.success("유효한 토큰")) :
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(HttpStatus.UNAUTHORIZED, "인증 필요"));
}
@PostMapping("logout")
public ResponseEntity<ApiResponse<String>> logout(@RequestHeader("Authorization") String token) {
if(token.startsWith("Bearer ")) {
token = token.replace("Bearer ", "");
}
authService.logout(token);
return ResponseEntity.ok(ApiResponse.success("로그아웃 성공"));
}
뭔가 좀 더 길어보이지만 그래도 "일관성"이 생겼다.
회원가입 기능 강화
앞의 테스트를 하면서 몇 가지 발견한 결함들이 있다.
- 아이디 중복 검사 필요( 중복된 아이디가 저장되면 에러 발생함 )
- 필수 입력인 필드 표시
- 회원가입 버튼 & 엔터 누를 시 확인 창 하나 띄우기
일단~! 제일 먼저 Member의 Entity를 확인해보자.
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userId;
private String password;
private String nickname;
private String email;
private String job;
private int age;
}
정말 날 것의 Membe이다. 프론트 쪽에서는 간단하게나마 유효성 검사를 해놨기 때문에 프론트와 설정을 맞춰주고자 html을 열어봤다.
signup.html
근데 정규식 같은 유효성 검사는 적혀있는데 글자수 이런 건 안적혀 있길래 또 추가해줬다.
<div class="signup-container">
<h1>회원가입</h1>
<form id="signupForm">
<div>
<label for="userId">아이디:</label>
<input type="text" id="userId" name="userId" placeholder="아이디 입력"
pattern="^[A-Za-z][A-Za-z0-9]*$"
minlength="2" maxlength="30"
title="영문+숫자. 숫자로 시작하면 안되며 2~30자여야 합니다." required/>
</div>
<div>
<label for="password">비밀번호:</label>
<input type="password" id="password" name="password" placeholder="비밀번호 입력"
pattern="^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=-])[A-Za-z\\d!@#$%^&*()_+=-]{8,}$"
minlength="8" maxlength="30"
title="비밀번호는 8~30자이며, 영어, 숫자, 특수문자(!@#$%^&*)가 모두 포함되어야 합니다."
required/>
</div>
<div>
<label for="confirmPassword">비밀번호 확인:</label>
<input type="password" id="confirmPassword" name="confirmPassword" placeholder="비밀번호 확인" required/>
</div>
<div>
<label for="nickname">닉네임:</label>
<input type="text" id="nickname" name="nickname" placeholder="닉네임은 2~20자로 입력해주세요."
minlength="2" maxlength="20" required/>
</div>
<div>
<label for="email">이메일:</label>
<input type="email" id="email" name="email" placeholder="이메일 입력" required/>
</div>
<div>
<label for="job">직업:</label>
<input type="text" id="job" name="job" placeholder="직업 입력"/>
</div>
<div>
<label for="age">나이:</label>
<input type="number" id="age" name="age" placeholder="나이 입력"/>
</div>
<div>
<button type="submit">회원가입</button>
</div>
</form>
<!-- 결과 메시지를 보여줄 영역 -->
<div id="message"></div>
</div>
이를 기반으로 Member Entity에 유효성 어노테이션들을 추가하자.
SignupRequestDto.java
@NotBlank
@Size(min = 2, max = 30)
@Pattern(regexp = "^[A-Za-z][A-Za-z0-9]*$", message = "아이디는 영문자로 시작하고, 영문자와 숫자만 포함해야합니다.")
private String userId;
@NotBlank
@Size(min = 8)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@!#$%^&*()_+=-])[A-Za-z\\d@!#$%^&*()_+=-]{8,}$\n"
, message = "비밀번호는 8자 이상이며, 대문자 혹은 소문자, 숫자, 특수문자를 포함해야 합니다.")
private String password;
@NotBlank
@Size(min = 2, max = 20)
private String nickname;
@Email
private String email;
private String job;
@Min(0)
@Max(150)
private int age;
필드에 @Pattern을 꼭 걸어야하나 싶어 ChatGPT한테 물어보니 서버쪽에도 당연히 유효성 정보를 세팅하는게 좋다고 한다. 클라이언트의 요청이 꼭 페이지만을 통해서 오지 않기 때문이다.
예를 들면 포스트맨이나, cURL 등으로 API 요청을 보내면 서버쪽에서는 입력 값에 대해 검증할 방도가 없기 때문! 그래서 Pattern도 만들고 메세지도 적었다.
이 어노테이션에 대한 검증들은 테스트 코드를 짜면서 해보겠다! 일단 스킵!
그리고 이제 회원가입 화면에 필수인 필드들을 사용자가 쉽게 확인할 수 있게 해보자.
signup.html
<!-- CSS -->
.required {
color: red;
font-weight: bold;
margin-left: 5px;
}
<div>
<label for="userId"><span class="required">*</span> 아이디:</label>
<input type="text" id="userId" name="userId" placeholder="아이디 입력"
pattern="^[A-Za-z][A-Za-z0-9]*$"
minlength="2" maxlength="30"
title="영문+숫자. 숫자로 시작하면 안되며 2~30자여야 합니다." required/>
</div>
일단 간단하게 아이디에만 추가해봤다.

음 괜찮은 거 같아서 중복을 최대한 제거하기 위해 CSS 코드로 추가해주었다.
<!-- CSS -->
.required-label::before {
content : "* ";
color : red;
font-weight : bold;
}
<!-- body -->
<div>
<label for="userId" class="required-label"> 아이디:</label>
<input type="text" id="userId" name="userId" placeholder="아이디 입력"
pattern="^[A-Za-z][A-Za-z0-9]*$"
minlength="2" maxlength="30"
title="영문+숫자. 숫자로 시작하면 안되며 2~30자여야 합니다." required/>
</div>
이렇게 필수 컴포넌트의 label에 클래스를 추가해주었다.

이제 아이디 중복 체크 기능을 만들어보자. 화면 쪽에 중복 체크 버튼을 만들까하다가 네이버 회원가입에는 그냥 회원가입 버튼 누르면 중복 체크 결과를 알려주길래 나도 똑같이하려고 화면 쪽에 버튼을 추가하지 않았다.
먼저 컨트롤러를 바꿔주자. 이전 컨트롤러는 authService에 있는 signup() 메서드의 결과값을 받아와서 삼항연산자를 통해 응답이 ok일 경우와 error 일 경우 두 가지 방법으로 나눠서 보냈었는데 CustomException을 적용하면서 예외처리 하는 방식도 좀 바꿀거라 더 간단하게 만들어놨다.
AuthController
@PostMapping("signup")
public ResponseEntity<ApiResponse<Boolean>> signup(@RequestBody SignupRequestDto signupRequestDto) {
authService.signup(signupRequestDto);
return ResponseEntity.ok(ApiResponse.success(true));
}
AuthService
/**
* 회원가입
* @param signupRequestDto
* @return
*/
@Transactional
public void signup(SignupRequestDto signupRequestDto) {
String encodedPassword = passwordEncoder.encode(signupRequestDto.getPassword());
checkUserIdDuplicate(signupRequestDto.getUserId());
authRepository.save(signupRequestDto.toEntity(encodedPassword));
}
/**
* 사용자 아이디 중복 체크
* @param userId
*/
public void checkUserIdDuplicate(String userId) {
boolean result = authRepository.existsByUserId(userId);
if(result) {
throw new DuplicateUserIdException("이미 사용 중인 아이디입니다.");
}
}
사용자 아이디 중복 체크를 하는 checkUserIdDuplicate() 메서드를 만들고 중복일 경우 DuplicateUserIdException()을 던진다.
DuplicateUserIdException
@ResponseStatus(HttpStatus.CONFLICT)
public class DuplicateUserIdException extends RuntimeException{
public DuplicateUserIdException(String message) {
super(message);
}
}
간단하게 customException을 만들어준다. 그리고 전역 예외처리를 해준다.
GlobalExceptionHandler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateUserIdException.class)
public ResponseEntity<ApiResponse<String>> handelDuplicateUserIdException(DuplicateUserIdException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error(HttpStatus.CONFLICT, ex.getMessage()));
}
}
에러의 경우도 Response<ApiResponse<?>>의 형식으로 응답이 반환되게 했다.
그리고 회원가입 화면 쪽에 async와 await이 적용안되어있길래 적용해주고 에러 message를 화면에 보여줄 수 있도록 전체적으로 바꿨다.
signup.html
<script>
async function signup(event) {
const password = document.getElementById('password').value;
const password2 = document.getElementById('confirmPassword').value;
if(password !== password2) {
alert("비밀번호가 일치하지 않습니다.");
return;
}
const formData = {
userId: document.getElementById('userId').value
, password: document.getElementById('password').value
, nickname: document.getElementById('nickname').value
, email: document.getElementById('email').value
, job: document.getElementById('job').value
, age: document.getElementById('age').value
};
try {
// API 엔드포인트로 JSON 데이터 전송
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: {
'Content-Type' : 'application/json'
},
body: JSON.stringify(formData)
})
const data = await response.json();
console.log("서버 응답 데이터 : " + data);
if(!response.ok) {
throw new Error(data.message);
}
// API 응답에 따라 메세지 출력
alert("회원가입에 성공했습니다.");
window.location.replace("login.html");
} catch(error) {
console.error('Error: ', error);
document.getElementById('message').innerText = error.message;
}
}
document.getElementById('signupForm').addEventListener('submit', function(event) {
event.preventDefault(); // 기본 폼 제출 동작 차단
signup(event);
})
</script>
테스트를 해보자! Member 테이블에는 이미 'test1'이라는 userId가 있다.

화면에서 'test1'로 또 가입을 해보자.


409 에러가 발생하면서 서버에서 던진 error message가 화면에 출력된다. 참고로 응답 데이터는 아래와 같다.

아이디 중복 같은 경우는 따로 조금 더 리팩토링 해봐야겠다. Member 엔티티에 유효성 어노테이션이랑... customException 처리 쪽을 좀 손봐야할 듯.
아무튼 아이디 중복 검사 기능 완료!!
이제 제일 간단한 회원가입 버튼을 눌렀을 때 confirm창을 띄워 사용자에게 한 번 더 확인받는 로직으로 변경하자.
signup.html
const result = window.confirm("회원가입을 진행하시겠습니까?");
if(!result) return;
signup(event);
signup()를 호출하기 전에 confirm의 결과가 "취소"면 회원가입 api를 요청할 수 없게 해놨다.
오늘도 즐거운 코딩!!