백엔드

[그리고 시큐리티를 곁들인] #3 : ID/PW 방식의 회원가입과 로그인과 JWT 토큰 발급

0woodev 2024. 8. 12. 17:34

인트로

이전 글(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