[그리고 시큐리티를 곁들인] #3 : ID/PW 방식의 회원가입과 로그인과 JWT 토큰 발급
인트로
이전 글(2편)에서는 이번 시리즈의 주제와 글을 쓰게 된 배경을 소개했다.
[그리고 시큐리티를 곁들인] #2 : 프로젝트 설계 + 유저 도메인 ERD 설계
요구사항과 그에 따른 프로젝트 설계, 그리고 유저 도메인 ERD 설계를 다루고자 한다.
0woodev.tistory.com
ID/PW 로그인 회원가입, JWT 토큰 발급
이번 글에서는 ID/PW 를 이용해서 회원가입과 로그인 기능을 구현하려고 한다. 우선, TDD 개발방법론에 맞게 테스트 코드를 먼저 작성 할 것이다. 만약, 테스트 코드가 필요하다면, 접어둔 부분을 펼쳐서 보면 된다.
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.1'
id 'io.spring.dependency-management' version '1.1.5'
id 'com.epages.restdocs-api-spec' version '0.18.4'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
// Valid 에 필요한 annotation 제공
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'com.fasterxml.jackson.core:jackson-databind'
// USER 의 임의의 ID 를 생성하기 위한 라이브러리 RandomStringUtils.randomAlphabetic(10)
implementation 'org.apache.commons:commons-lang3:3.14.0'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
//Jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Entity와 DTO간의 매핑을 위한 라이브러리
implementation 'org.modelmapper:modelmapper:2.4.2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.4'
}
` io.jsonwebtoken::jwt-*` 의존성을 추가해야 하는데, 로그인과 회원가입 이후, 발행할 jwt 토큰에 관련된 유틸 클래스를 작성하는데 있어 필요한 라이브러리이다.
AuthController
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<String> login(
@Valid @RequestBody LoginRequestDTO request
) {
String token = authService.login(request);
return ResponseEntity.status(HttpStatus.OK).body(token);
}
@PostMapping("/signup")
public ResponseEntity<String> signup(
@Valid @RequestBody SignupRequestDTO request
) {
String token = authService.signup(request);
return ResponseEntity.status(HttpStatus.OK).body(token);
}
}
AuthController 에서 login 과 signup 엔드포인트를 정의한다.
LoginRequestDTO, SignupRequestDTO
// LoginRequestDTO.java
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginRequestDTO {
@NotNull(message = "아이디의 입력은 필수입니다.")
private String username;
@NotNull(message = "패스워드 입력은 필수입니다.")
private String password;
}
// SignupRequestDTO.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SignupRequestDTO {
@NotNull(message = "아이디의 입력은 필수입니다.")
private String username;
@NotNull(message = "패스워드 입력은 필수입니다.")
private String password;
}
엔드포인트별 필요한 RequestDTO 를 정의한다.
TDD 를 지키기 위해 AuthService 에 대한 구현 이전에 엔드포인트에 해당하는 테스트 코드를 작성해보자.
AuthController 같은 경우, Spring 컨텍스트 전체가 사실 필요하지 않다고 생각하기에, ` @MockMvcTest ` 가 아닌, ` MockMvcBuilders.standaloneSetup() ` 를 이용하여 AuthController 의 인스턴스를 수동으로 설정한다.
AuthControllerTest.java
테스트 코드 깁니다. TDD 를 하실 분만 열어보세요~
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.filter.CharacterEncodingFilter;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class AuthControllerTest {
private final AuthService authService = mock(AuthService.class);
private ObjectMapper objectMapper = new ObjectMapper();
private MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders
.standaloneSetup(new AuthController(authService))
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
@DisplayName("로그인 API 엔드포인트 테스트")
void test_login_success() throws Exception {
// given
String mockToken = "mockToken";
LoginRequestDTO request = new LoginRequestDTO("username", "password");
String requestJson = objectMapper.writeValueAsString(request);
when(authService.login(any(LoginRequestDTO.class))).thenReturn(mockToken);
// when & then
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isOk())
.andExpect(content().string(mockToken));
verify(authService, times(1)).login(any(LoginRequestDTO.class));
}
@Test
@DisplayName("로그인 API 엔드포인트 테스트 - 아이디 매칭 실패")
void test_loginFail_noMatchingUsername() throws Exception {
// given
LoginRequestDTO request = new LoginRequestDTO("there is no user id in db", "wrong password");
String requestJson = objectMapper.writeValueAsString(request);
when(authService.login(request)).thenThrow(UsernameNotFoundException.class);
// when & then
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("로그인 API 엔드포인트 테스트 - 비밀번호 실패")
void test_loginFail_wrongPassword() throws Exception {
// given
LoginRequestDTO request = new LoginRequestDTO("there is no user id in db", "wrong password");
String requestJson = objectMapper.writeValueAsString(request);
when(authService.login(request)).thenThrow(IllegalArgumentException.class);
// when & then
// content() 의 data.message 가 "비밀번호가 일치하지 않습니다." 인지 확인
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isBadRequest());
// .andExpect(jsonPath("$.message").value(containsString("비밀번호가 일치하지 않습니다.")));
}
@Test
@DisplayName("LoginRequestDTO 에 정의한 NotNull 필드가 null 일 때 예외 발생 테스트")
void test_login_withoutUsernameInRequestDTO() throws Exception {
// given
LoginRequestDTO request = new LoginRequestDTO(null, "password");
String requestJson = objectMapper.writeValueAsString(request);
// when & then
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson)) // username이 빠진 JSON 요청
.andExpect(status().isBadRequest());
verify(authService, times(0)).login(any(LoginRequestDTO.class));
}
@Test
@DisplayName("회원가입 API 엔드포인트 테스트")
void test_signup_success() throws Exception {
// given
SignupRequestDTO request = new SignupRequestDTO("username", "password");
String requestJson = objectMapper.writeValueAsString(request);
// when & then
mockMvc.perform(post("/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isOk());
verify(authService, times(1)).signup(any(SignupRequestDTO.class));
}
@Test
@DisplayName("회원가입 API 엔드포인트 테스트 - 이미 존재하는 아이디")
void test_signupFail_alreadyExistingUsername() throws Exception {
// given
SignupRequestDTO request = new SignupRequestDTO("username", "password");
String requestJson = objectMapper.writeValueAsString(request);
when(authService.signup(request)).thenThrow(IllegalArgumentException.class);
// when & then
mockMvc.perform(post("/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("회원가입 API 엔드포인트 테스트 - 비밀번호가 너무 짧음")
void test_signupFail_passwordTooShort() throws Exception {
// given
SignupRequestDTO request = new SignupRequestDTO("username", "short");
String requestJson = objectMapper.writeValueAsString(request);
when(authService.signup(request)).thenThrow(IllegalArgumentException.class);
// when & then
mockMvc.perform(post("/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isBadRequest());
// .andExpect(jsonPath("$.message").value(containsString("비밀번호는 8자 이상이어야 합니다.")));
}
@Test
@DisplayName("SignupRequestDTO 에 정의한 NotNull 필드가 null 일 때 예외 발생 테스트")
void test_signup_withoutUsernameInRequestDTO() throws Exception {
// given
SignupRequestDTO request = new SignupRequestDTO(null, "password");
String requestJson = objectMapper.writeValueAsString(request);
// when & then
mockMvc.perform(post("/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson)) // username이 빠진 JSON 요청
.andExpect(status().isBadRequest());
verify(authService, times(0)).signup(any(SignupRequestDTO.class));
}
}
이번 테스트 코드를 짜면서, Mockito 의 테스트 방식에 대해 연습해보려고 했으며, `@NotNull` 이 잘 동작 여부와 `GlobalExceptionHandler(@RestControllerAdvice)`를 통해 잘 처리되는지도 확인해 보았다.
진짜 왕창 길다. 그리고 이렇게까지 넓은 커버리지에 대한 테스트를 해야하는가? 그건 선택에 맡기겠다.
필자는 Copilot, ChatGPT 와 같은 AI 가 등장하면서 테스트 코드는 더이상 짐이 아니라고 생각한다. 테스트 코드는 더 안전한 코드를 작성하고, 변경사항에 대해 사용자가 인지하지 못한 사이드 이펙트를 막아줄 수 있는 도구이다. 그렇기에 필자는 다른 개발자분들도 TDD 를 도입하는 것을 매우 많이 추천한다. (물론, 저런 AI 가 있다고해서 리팩토링을 통해 망가진 테스트코드를 고치는 것이 10초만에 된다는 것은 절대 아니다 - 이부분 유념하시길)
`AuthService` 인터페이스를 이제 구현해야하는 차례이다. 구현에 앞서, AuthService 가 어떤 의존성을 가지는지 먼저 생각해보자.
사용자의 정보를 조회해서 비교해야하므로, `UserRepository` 와 `UserPasswordRepository` 가 필요하다. 또 비밀번호는 데이터베이스에 암호화 되어 있어야 하므로, 사용자가 입력한 비밀번호와 같은지 비교하기 위해 `PasswordEncoder` 가 필요하다.
다음으로는, 사용자가 정보가 정상적으로 확인이 되었을 경우, JWT 토큰을 발급해주어야 하므로, `JwtUtil` 클래스가 필요하며, 엔티티를 DTO 로 매핑하기 위해 `ModelMapper` 가 필요하다.
이렇게 의존관계에 있는 것들을 적어보면
import org.modelmapper.ModelMapper;
import org.springframework.security.crypto.password.PasswordEncoder;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthServiceImpl implements AuthService {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final UserPasswordRepository userPasswordRepository;
private final PasswordEncoder encoder;
private final ModelMapper modelMapper;
// ...
}
이렇게 표현할 수 있겠다.
자, 그럼 간단한 것 부터 먼저 끝내자.
UserRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}
UserPasswordRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserPasswordRepository extends JpaRepository<Password, Long> {
}
AppConfig.java
import org.modelmapper.ModelMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
/**
ModelMapper.ModelMapper 를 이용하려면, 직접 Bean 을 등록해주어야 한다.
*/
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
}
SecurityConfig.java
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
자, 제일 중요한 Jwt 발급, 검증 클래스를 만들어보자. Jwt 발급에 필요한 유저 DTO 인 `CustomUserInfoDTO.java` 를 먼저 개발해보자.
CustomUserInfoDTO.java
import lombok.*;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CustomUserInfoDTO {
private Long id;
private String username;
private String email;
private UserState state;
private Role role;
}
JwtUtil.java
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.imgame.imgame.dto.CustomUserDetails;
import org.imgame.imgame.dto.CustomUserInfoDTO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.time.Instant;
import java.util.Date;
@slf4j
@Component
public class JwtUtil {
private final Key key;
private final long accessTokenExpTime;
public JwtUtil(
@Value("${jwt.secret}") String secretKey,
@Value("${jwt.expiration_time}") long accessTokenExpTime
) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenExpTime = accessTokenExpTime;
}
public String createAccessToken(CustomUserInfoDTO userInfo) {
return createToken(userInfo, accessTokenExpTime);
}
public String createAccessToken(Authentication authentication) {
return createToken(((CustomUserDetails) authentication.getPrincipal()).getUser(), accessTokenExpTime);
}
public String createRefreshToken(CustomUserInfoDTO userInfo) {
// TODO
return "";
}
public String createRefreshToken(Authentication authentication) {
// TODO
return "";
}
private String createToken(CustomUserInfoDTO userInfo, long accessTokenExpTime) {
Claims claims = Jwts.claims();
// 일단, Claims 에 어떤것을 넣을지 정하지 않았기에 가짜 데이터를 넣는다.
claims.put("id", 1L);
claims.put("username", "JWT Test User");
Instant now = Instant.now();
Instant expiration = now.plusSeconds(accessTokenExpTime);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiration))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public Long getUserId(String token) {
return parseClaims(token).get("id", Long.class);
}
// 토큰 검증 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
public Claims parseClaims(String token) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
application.yml
# ...
jwt:
expiration_time: 86400000 # 1 day
secret: base64 encode 되어야 하며, 256 bit 를 넘어야 한다.
이렇게까지 하면 AuthServiceImpl 과 의존관계에 있는 객체에 대한 작업은 다 끝났다.
여기까지 잘 따라오셨다면, 얼마 남지 않았으니, 좀더 힘을 내보자.
`AuthServiceImpl` 을 구현하기 전에 테스트 코드를 먼저 작성해보자.
AuthServiceImplTest.java
테스트 코드는 기니까, 필요하신 분만 펼쳐 보세요~
import org.imgame.imgame.util.JwtUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.modelmapper.ModelMapper;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AuthServiceImplTest {
@InjectMocks
private AuthServiceImpl authService;
@Mock
private JwtUtil jwtUtil;
@Mock
private UserRepository userRepository;
@Mock
private UserPasswordRepository userPasswordRepository;
@Mock
private PasswordEncoder encoder;
@Mock
private ModelMapper modelMapper;
@Test
@DisplayName("로그인 성공 테스트")
void test_login_success() {
// given
LoginRequestDTO request = new LoginRequestDTO("username", "password");
// when
String token = authService.login(request);
// then
assertEquals("mockToken", token);
verify(userRepository, times(1)).findByUsername(anyString());
verify(userPasswordRepository, times(1)).findById(anyLong());
verify(encoder, times(1)).matches(anyString(), anyString());
verify(jwtUtil, times(1)).createAccessToken(any(CustomUserInfoDTO.class));
}
@Test
@DisplayName("로그인 실패 테스트 - 사용자를 찾을 수 없음")
void test_login_fail_userNotFound() {
// given
LoginRequestDTO request = new LoginRequestDTO("nonexistentuser", "password");
when(userRepository.findByUsername(anyString())).thenReturn(Optional.empty());
// when & then
assertThrows(UsernameNotFoundException.class, () -> authService.login(request));
verify(userRepository, times(1)).findByUsername(anyString());
}
@Test
@DisplayName("로그인 실패 테스트 - 비밀번호 불일치")
void test_login_fail_wrongPassword() {
// given
LoginRequestDTO request = new LoginRequestDTO("username", "wrongPassword");
when(encoder.matches(anyString(), anyString())).thenReturn(false);
// when & then
assertThrows(BadCredentialsException.class, () -> authService.login(request));
verify(encoder, times(1)).matches(anyString(), anyString());
}
@Test
@DisplayName("회원가입 성공 테스트")
void test_signup_success() {
// given
SignupRequestDTO request = new SignupRequestDTO("newuser", "password");
// when
String token = authService.signup(request);
// then
assertEquals("mockToken", token);
verify(userRepository, times(1)).save(any(User.class));
verify(userPasswordRepository, times(1)).save(any(UserPassword.class));
verify(jwtUtil, times(1)).createAccessToken(any(CustomUserInfoDTO.class));
}
@Test
@DisplayName("회원가입 실패 테스트 - 이미 존재하는 사용자")
void test_signup_fail_userAlreadyExists() {
// given
SignupRequestDTO request = new SignupRequestDTO("existinguser", "password");
when(userRepository.existsByUsername(anyString())).thenReturn(true);
// when & then
assertThrows(IllegalArgumentException.class, () -> authService.signup(request));
verify(userRepository, times(1)).existsByUsername(anyString());
}
}
테스트가 완료되었으니, 해당 테스트가 통과할 수 있도록 `AuthServiceImpl.java` 을 구현해보자.
AuthServiceImpl.java
import lombok.RequiredArgsConstructor;
import org.imgame.imgame.dto.req.LoginRequestDTO;
import org.imgame.imgame.dto.req.SignupRequestDTO;
import org.imgame.imgame.util.JwtUtil;
import org.modelmapper.ModelMapper;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthServiceImpl implements AuthService {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final UserPasswordRepository userPasswordRepository;
private final PasswordEncoder encoder;
private final ModelMapper modelMapper;
@Override
@Transactional
public String login(LoginRequestDTO request) {
String username = request.getUsername();
String password = request.getPassword();
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
UserPassword userPassword = userPasswordRepository.findById(user.getId())
.orElseThrow(() -> new IllegalArgumentException("비밀번호가 설정되지 않은 사용자입니다."));
if (!encoder.matches(password, userPassword.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
CustomUserInfoDTO userInfo = modelMapper.map(user, CustomUserInfoDTO.class);
return jwtUtil.createAccessToken(userInfo);
}
@Override
@Transactional
public String signup(SignupRequestDTO request) {
String username = request.getUsername();
String password = request.getPassword();
if (userRepository.existsByUsername(username)) {
throw new IllegalArgumentException("이미 사용 중인 사용자 이름입니다.");
}
User user = User.builder()
.username(username)
.build();
UserPassword userPassword = UserPassword.builder()
.user(user)
.password(encoder.encode(password))
.build();
userRepository.save(user);
userPasswordRepository.save(userPassword);
CustomUserInfoDTO userInfo = modelMapper.map(user, CustomUserInfoDTO.class);
return jwtUtil.createAccessToken(userInfo);
}
}
마침
긴 블로그 글을 따라오느라 고생하셨습니다.
물론, JwtUtil 에 대해서도 테스트코드를 작성할 수 있다. 이부분은 블로그 글에서 생략했지만, 유틸 클래스가 잘 동작하는지 검증하기 위해 직접 디버깅하면서 고생하는 것보다는, 테스트 케이스를 이용하는 것이 보다 낫지 않을까 싶다.
다음 시리즈는 이렇게 힘들게 발급한 JWT 토큰을 인증/인가 해주는...
즉, Spring Security 녀석이 처음으로 등장하는 포스트이다.
시큐리티 시리즈 4번 포스트로 넘어가보자.
[그리고 시큐리티를 곁들인] #4 : Spring Security 를 이용한 JWT 인증/인가
이전 글(3편)에서는 Jwt 토큰에 관련된 Util 클래스와 로그인, 회원가입 기능을 구현했다.
0woodev.tistory.com