spring/게시판 api

Spring boot 게시판 API 서버 제작 (5) - 로그인 - 서비스 로직 구현

얼킴 2023. 2. 18. 18:34

이번 시간에는 저번 시간에 구현한 JWT 관련 기능을 사용하는 TokenService와 이를 활용한 SignService를 만들어 보겠습니다.

 

TokenService

application.yml 설정

먼저  TokenService에서 사용할 설정 값들을 application.yml에 추가하겠습니다.

spring:
  ...
  profiles:
    active: local
    include: secret

이렇게 설정하면 profile이 local로 활성화가 되고, secret을 include하여 application-sercret.yml 파일에 있는 값을 읽을 수 있습니다.

application-secret.yml 파일에는 다음과 같이 작성합니다.

jwt:
  key:
    access: tmpAccessKey
    refresh: tmpRefreshKey
  max-age:
    access: 1800 # 60 * 30
    refresh: 604800 # 60 * 60 * 24 * 7

jwt.key.access값과 jwt.key.refresh 값은 임시로 수정하지 않고 저 값을 그대로 사용해도 에러 없이 실행이 되지만 나중에 실제 서비스 개발때에는 제대로 된 값을 넣어야 합니다. 그리고 application-secret.yml 파일은 .gitigonore에 추가하여 노출되지 않도록 합니다.

TokenService.java 생성

이제 application에서 설정한 값들을 불러와 읽어 access token 과 refresh token 생성을 요청하는 서비스 로직을 작성해 보겠습니다.

@Service
@RequiredArgsConstructor
public class TokenService {
    private final JwtHandler jwtHandler;

    @Value("${jwt.max-age.access}") // Access 토큰의 만료시간을 지정하는데 사용됩니다.
    private long accessTokenMaxAgeSeconds;

    @Value("${jwt.max-age.refresh}") // Refresh 토큰의 만료시간을 지정하는데 사용됩니다.
    private long refreshTokenMaxAgeSeconds;

    @Value("${jwt.key.access}") // Access 토큰을 생성할 때 사용되는 비밀키를 지정하는데 사용됩니다.
    private String accessKey;

    @Value("${jwt.key.refresh}") // Refresh 토큰을 생성할 때 사용되는 비밀키를 지정하는데 사용됩니다.
    private String refreshKey;

    public String createAccessToken(String subject) {
        return jwtHandler.generateJwtToken(accessKey, subject, accessTokenMaxAgeSeconds);
    }

    public String createRefreshToken(String subject) {
        return jwtHandler.generateJwtToken(refreshKey, subject, refreshTokenMaxAgeSeconds);
    }
}

TokenService 테스트

이번 테스트는 MockitoExtension을 사용해서 테스트를 진행합니다. 먼저 전체 코드를 보고나서 항목별로 나누어서 설명드리겠습니다.

(MockitoExtension에 대해서는 다음 포스트를 참고하세요 -> https://coding-kim.tistory.com/27

 

[Spring] MockitoExtension에 대해서 알아보자

Mockito는 유닛 테스트에서 모의 객체를 생성할 수 있게 해줍니다. 이를 통해서 의존성 및 동작의 모의 객체를 생성하여 컴포넌트의 기능을 독립적으로 테스트 할 수 있습니다. 그리고 MockitoExtensio

coding-kim.tistory.com

@ExtendWith(MockitoExtension.class)
class TokenServiceTest {
    @InjectMocks TokenService tokenService;
    @Mock
    JwtHandler jwtHandler;

    @BeforeEach
    void beforeEach() {
        ReflectionTestUtils.setField(tokenService, "accessTokenMaxAgeSeconds", 10L);
        ReflectionTestUtils.setField(tokenService, "refreshTokenMaxAgeSeconds", 10L);
        ReflectionTestUtils.setField(tokenService, "accessKey", "accessKey");
        ReflectionTestUtils.setField(tokenService, "refreshKey", "refreshKey");
    }

    @Test
    public void createAccessTokenTest(){
        //given
        given(jwtHandler.generateJwtToken(anyString(), anyString(), anyLong())).willReturn("access");

        //when
        String token = tokenService.createAccessToken("subject");

        //then
        Assertions.assertThat(token).isEqualTo("access");
        verify(jwtHandler).generateJwtToken(anyString(), anyString(), anyLong());
    }
    @Test
    void createRefreshTokenTest() {
        // given
        given(jwtHandler.generateJwtToken(anyString(), anyString(), anyLong())).willReturn("refresh");

        // when
        String token = tokenService.createRefreshToken("subject");

        // then
        assertThat(token).isEqualTo("refresh");
        verify(jwtHandler).generateJwtToken(anyString(), anyString(), anyLong());
    }
}

그럼 이제 세부적으로 살펴보겠습니다.

Mockito 환경 설정

@ExtendWith(MockitoExtension.class)
class TokenServiceTest {
    @InjectMocks TokenService tokenService;
    @Mock
    JwtHandler jwtHandler;

@ExtendWith(MockitoExtension.class)는 Mockito 확장을 사용하여 테스트 클래스에 Mockito를 연결합니다.

@InjectMocks는 TokenService 인스턴스를 생성하고 해당 인스턴스의 생성자에 @Mock로 주석이 달린 JwtHandler 의 모의 객체를 자동으로 주입합니다.

 

@BeforeEach

@BeforeEach
    void beforeEach() {
        ReflectionTestUtils.setField(tokenService, "accessTokenMaxAgeSeconds", 10L);
        ReflectionTestUtils.setField(tokenService, "refreshTokenMaxAgeSeconds", 10L);
        ReflectionTestUtils.setField(tokenService, "accessKey", "accessKey");
        ReflectionTestUtils.setField(tokenService, "refreshKey", "refreshKey");
    }

@BeforeEach 어노테이션이 달린 beforeEach() 메서드는 각 테스트 메서드가 실행되기 전에 호출되는 메서드입니다.

ReflectionTestUtils 클래스의 setField() 메서드를 사용하여 TokenService 클래스의 필드 값을 설정합니다.

 

createAccessTokenTest

@Test
    public void createAccessTokenTest(){
        //given
        given(jwtHandler.generateJwtToken(anyString(), anyString(), anyLong())).willReturn("access");

        //when
        String token = tokenService.createAccessToken("subject");

        //then
        Assertions.assertThat(token).isEqualTo("access");
        verify(jwtHandler).generateJwtToken(anyString(), anyString(), anyLong());
    }
  • given() 메서드는 Mockito 라이브러리에서 제공하는 메서드로, jwtHandler.generateJwtToken() 메서드에 대한 입력 매개 변수로 anyString(), anyString(), anyLong()을 사용하면 jwtHandler.generateJwtToken() 메서드에서 "access" 문자열을 반환한다는 것을 알려줍니다.
  • verify() 메서드를 사용하여 jwtHandler.generateJwtToken() 메서드가 anyString(), anyString(), anyLong() 매개 변수와 함께 호출되었는지를 검증하는 코드입니다.

createRefreshTokenTest

@Test
    void createRefreshTokenTest() {
        // given
        given(jwtHandler.generateJwtToken(anyString(), anyString(), anyLong())).willReturn("refresh");

        // when
        String token = tokenService.createRefreshToken("subject");

        // then
        assertThat(token).isEqualTo("refresh");
        verify(jwtHandler).generateJwtToken(anyString(), anyString(), anyLong());
    }

 

이 테스트 함수는 createAccessTokenTest와 거의 동일하므로 생략하겠습니다.

 

SignService

TokenService를 테스트 완료했으니 이제 SignService를 구현해보겠습니다.

SignService를 구현하기 전에 몇가지 코드 수정과 추가 작업을 진행하겠습니다.

Member.java

Member클래스에 List<MemberRole>을 포함한 생성자를 추가합니다. 

public Member(String email, String password, String username, String nickname, List<Role> roles) {
        this.email = email;
        this.password = password;
        this.username = username;
        this.nickname = nickname;
        roles.stream().forEach(role -> this.roles.add(new MemberRole(this, role)));
    }

MemberRepository.java

MemberRole클래스에 email과 nickname으로 회원이 있는지 판단해주는 함수를 추가합니다.

boolean existsByEmail(String email);
boolean existsByNickname(String nickname);

MemberRole.java

Member클래스에서 생성자를 추가하면서 반영되는 수정 사항입니다.

public MemberRole(Member member, Role role) {
    this.member = member;
    this.role = role;
}

RoleRepository.java

public interface RoleRepository extends JpaRepository<Role, Long> {
    Optional<Role> findByRoleType(RoleType roleType);
}

RoleRepository를 생성했습니다.

그리고 roleType를 가지고 role이 존재하는지 확인해주는 함수가 포함되어있습니다.

 

추가로 SignService에서 사용하기 위한 DTO들을 생성해주겠습니다.

SignupRequest.java

@Data
@AllArgsConstructor
public class SignUpRequest {
    private String email;
    private String password;
    private String username;
    private String nickname;

    public static Member toEntity(SignUpRequest req, Role role, PasswordEncoder encoder){
        return new Member(req.email, encoder.encode(req.password), req.username, req.nickname, List.of(role));
    }
}

SignInRequest.java

@Data
@AllArgsConstructor
public class SignInRequest {
    private String email;
    private String password;
}

SignInResponse.java

@Data
@AllArgsConstructor
public class SignInResponse {
    private String accessToken;
    private String refreshToken;
}

SignService.java

이제 서비스 로직을 작성해보겠습니다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SignService {
    private final MemberRepository memberRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenService tokenService;

    public void signUp(SignUpRequest req){
        validateSignUpInfo(req);
        memberRepository.save(SignUpRequest.toEntity(
                req,
                roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RuntimeException::new),
                passwordEncoder
        ));
    }
    private void validateSignUpInfo(SignUpRequest req) {
        if(memberRepository.existsByEmail(req.getEmail()))
            throw new RuntimeException();
        if(memberRepository.existsByNickname(req.getNickname()))
            throw new RuntimeException();
    }

    public SignInResponse signIn(SignInRequest req){
        Member member = memberRepository.findByEmail(req.getEmail()).orElseThrow(RuntimeException::new);
        validatePassword(req, member);
        String subject = createSubject(member);
        String accessToken = tokenService.createAccessToken(subject);
        String refreshToken = tokenService.createRefreshToken(subject);
        return new SignInResponse(accessToken, refreshToken);
    }

    private void validatePassword(SignInRequest req, Member member) {
        if(!passwordEncoder.matches(req.getPassword(), member.getPassword())) {
            throw new RuntimeException();
        }
    }

    private String createSubject(Member member) {
        return String.valueOf(member.getId());
    }

}

RuntimeException에 대해서는 어느정도 진행 후에 프로젝트 전체적으로 예외 처리 코드를 작성하겠습니다.

SignUp - 회원가입 서비스 로직

SignIn - 로그인 서비스 로직

 

SignService 테스트

@ExtendWith(MockitoExtension.class)
class SignServiceTest {

    @InjectMocks SignService signService;
    @Mock
    MemberRepository memberRepository;
    @Mock
    RoleRepository roleRepository;
    @Mock
    PasswordEncoder passwordEncoder;
    @Mock TokenService tokenService;

    @Test
    public void signUpTest(){ //정상적인 회원가입 로직 테스트
        //given
        SignUpRequest req = createSignUpRequest();
        given(roleRepository.findByRoleType(RoleType.ROLE_NORMAL)).willReturn(Optional.of(new Role(RoleType.ROLE_NORMAL)));

        //when
        signService.signUp(req); 

        //then
        verify(passwordEncoder).encode(req.getPassword());
        verify(memberRepository).save(any());
    }
    @Test
    void validateSignUpByDuplicateEmailTest() { //이메일 중복 검증 테스트
        // given
        given(memberRepository.existsByEmail(anyString())).willReturn(true);

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void validateSignUpByDuplicateNicknameTest() { //닉네임 중복 검증 테스트 
        // given
        given(memberRepository.existsByNickname(anyString())).willReturn(true);

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void signUpRoleNotFoundTest() { //등록되지 않은 권한 등급으로 회원 가입시에 해당 권한 등급을 찾을 수 없습니다. 
        // given
        given(roleRepository.findByRoleType(RoleType.ROLE_NORMAL)).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void signInTest() {  //정상적인 로그인 로직
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.of(createMember()));
        given(passwordEncoder.matches(anyString(), anyString())).willReturn(true);
        given(tokenService.createAccessToken(anyString())).willReturn("access");
        given(tokenService.createRefreshToken(anyString())).willReturn("refresh");

        // when
        SignInResponse res = signService.signIn(new SignInRequest("email", "password"));

        // then
        assertThat(res.getAccessToken()).isEqualTo("access");
        assertThat(res.getRefreshToken()).isEqualTo("refresh");
    }

    @Test
    void signInExceptionByNoneMemberTest() { //등록된 이메일이 아닐경우 에러 발생
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> signService.signIn(new SignInRequest("email", "password")))
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void signInExceptionByInvalidPasswordTest() { //비밀번호가 잘못되었다면 에러 발생
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.of(createMember()));
        given(passwordEncoder.matches(anyString(), anyString())).willReturn(false);

        // when, then
        assertThatThrownBy(() -> signService.signIn(new SignInRequest("email", "password")))
                .isInstanceOf(RuntimeException.class);
    }


    private SignUpRequest createSignUpRequest() {
        return new SignUpRequest("email", "password", "username", "nickname");
    }

    private Member createMember() {
        return new Member("email", "password", "username", "nickname", emptyList());
    }

}

기본적인 테스트 구성은 TokenServiceTest.java와 동일하므로 설명은 생략하겠습니다.

 


궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.

github : https://github.com/jaeyeon423/spring_board_api