Spring boot 게시판 API 서버 제작 (5) - 로그인 - 서비스 로직 구현
이번 시간에는 저번 시간에 구현한 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와 동일하므로 설명은 생략하겠습니다.
궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.