Post

real-world프로젝트 Spring 시작하기(8) - 회원가입API 및 Jwt Token 만들기(2)

real-world project 구현 과정 카테고리 보기

이번에는 토큰을 만들어보고, 응답값을 조립하여 응답을 해볼 것이다.

저번에는 컨트롤러와 DTO를 구현했으므로, 서비스계층을 구현해야한다.

일반적으로 Service는 구현체와 인터페이스를 나눈다.

그 이유로는 다형성을 위함이 있다. 하지만 1대1로 매핑되어있는 경우에는 굳이 할 필요가 없지만 확장성을 고려해서 하면 좋고 협업시 코드 유지보수에도 좋으니까 하는게 좋다.

1
2
3
4
5
6
7
package com.io.realworld.domain.aggregate.user.service;

import com.io.realworld.domain.aggregate.user.dto.*;

public interface UserService {
    UserResponse signup(UserSignupRequest userSignupRequest);
}

따라서 인터페이스를 이렇게 따로 두고

👍 UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Service
@RequiredArgsConstructor
public class UserServiceImpl {

    private final UserRepository userRepository;

    private final PasswordEncoder passwordEncoder;

    private final JwtService jwtService;

    public UserResponse signup(UserSignupRequest userSignupRequest) {
        User findEmail = userRepository.findByEmail(userSignupRequest.getEmail());
        Optional<User> findUsername = userRepository.findByUsername(userSignupRequest.getUsername());

        if ( findEmail != null &&  !findUsername.isEmpty()) {
            throw new CustomException(Error.DUPLICATE_EMAIL_USERNAME);
        }else if(findEmail != null){
            throw new CustomException(Error.DUPLICATE_EMAIL);
        }else if(!findUsername.isEmpty()){
            throw new CustomException(Error.DUPLICATE_USERNAME);
        }
        else {
            return convertUser(userRepository.save(User.builder().
                            username(userSignupRequest.getUsername()).
                            email(userSignupRequest.getEmail()).
                            password(madeHash(userSignupRequest.getPassword())).
                            image("https://api.realworld.io/images/smiley-cyrus.jpeg").build()));
        }
    }

    private String madeHash(String password) {
        return passwordEncoder.encode(password);
    }

    private UserResponse convertUser(User user){
        return UserResponse.builder().username(user.getUsername())
                .email(user.getEmail())
                .bio(user.getBio())
                .image(user.getImage())
                .token(jwtService.createToken(user.getEmail()))
                .build();
    }
}

구현체를 만든다.

아직 만들지 않은 Repository, passwordEncoder, jwtService는 잊고 위에서 부터 차근히 리뷰하겠다.

  • @Service: Service어노테이션은 부트가 최초에 뜰때 스캔하여 빈에 등록하라는 뜻이다.
  • @RequiredArgsConstructor: 이 어노테이션은 final이나 @NotNull이 붙은 필드의 생성자를 자동으로 생성해주는 롬복 어노테이션이다. (생성자 주입) 컨택스트에 등록된 여러 빈(repository, jwtService, passwordencoder)를 사용하기 위해 넣어줘야한다.

로직은 간단하다.

  • 들어온 Email을 기준으로 데이터베이스에 조회하여 Email 중복이 있는지 확인한다. 중복이라면 throw new CustomException(Error.DUPLICATE_EMAIL);로 에러를 발생시킨다.
  • 마찬가지로 UserName도 조회하여 중복값이 있는지 확인한다. 만약 중복이라면 throw new CustomException(Error.DUPLICATE_USERNAME); 에러를 발생시킨다.

공통 예외처리는 다음에 알아보고 일단 저런 에러를 발생시킨다는것만 이 글에 적겠다.

중복 처리가 끝났다면 그대로 들어온 객체를 데이터베이스에 저장해주면된다.

1
2
3
4
5
userRepository.save(User.builder().
                            username(userSignupRequest.getUsername()).
                            email(userSignupRequest.getEmail()).
                            password(madeHash(userSignupRequest.getPassword())).
                            image("https://api.realworld.io/images/smiley-cyrus.jpeg").build()

유저를 저장하는부분인데 save()함수는 리턴값을 따로 지정하지 않는다면 JPA를 상속받을때 지정한 엔티티 오브젝트 유형으로 리턴한다. 이는 User객체이고 우리는 password가 포함된 User객체를 그대로 응답값으로 넘기면 안되므로 convertUser()함수로 통해 응답 객체를 맞춰줘야한다.

그리고 madeHash()함수에 PasswordEncoder를 통해 패스워드를 암호화한다.

👍 PassowordEncder ?

스프링 시큐리티에서 제공하는 암호화하는 인터페이스다.

이를 사용하기 위해서는 먼저 Bean에 등록해줘야하는데, 앞에 포스팅에서 등록해줬다. 그것도 BCryptPasswordEncoder라는 클래스 유형으로 말이다.

BCryptPasswordEncoder는 기본 암호 해시함수를 통해 암호화하는 구현체다. 시큐리티에서 제공하는 PasswordEncoder의 문서를 보면 이 구현체를 선호한다는걸 알 수 있다.

1
2
//Doc
Service interface for encoding passwords. The preferred implementation is BCryptPasswordEncoder.

비크립트 인코더에 다루는건 좀 더 깊은 내용이 될 수 있으므로 비크립트라는 알고리즘을 통해 패스워드를 암호화한다는걸 알아두면 될 것 같다.

👍 UserRepository

JPA를 사용해서 구현하면 된다. 아직 save()말고는 구현할 게 없다.

save()의 리턴값을 직접명시해줘도 되지만 나는 명시하지 않았다.

1
2
public interface UserRepository extends JpaRepository<User, Long> {
}

기본제공되는 save()함수를 사용할 것이고 리턴값은 Jpa제너릭으로 지정한 첫번째 값 User객체가 될 것이다.

다시 Service함수로 돌아가서 save()의 리턴을 받았다 치고 이를 real-world에서 요구하는 리턴 객체로 맞춰줘야한다.

👍 UserSingup Response DTO 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
@Builder
@Getter
@JsonTypeName("user")
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
public class UserResponse {

    private String email;
    private String token;
    private String username;
    private String bio;
    private String image;
}

Request DTO를 만들면서 어노테이션에 대한 설명을 했으므로 따로 리뷰할건 없다. 주목점은 password가 포함되어있지 않고 token을 리턴한다는 것이다.

👍 JwtService 만들기

convertUser()함수 안에 있는 jwtService.createToken(user.getEmail())를 리뷰해야한다.

JwtService 클래스는 다음처럼 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
@AllArgsConstructor
public class JwtService {

    private final JwtConfig jwtConfig;

    private Key getSignKey(String secretKey){
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String createToken(String email){
        Claims claims = Jwts.claims();
        claims.put("email",email);
        
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + jwtConfig.getExpiry() * 1000))
                .signWith(getSignKey(jwtConfig.getKey()))
                .compact();
    }
}

Jwt 인증, 데이터 파싱 부분은 뺐습니다. 현재는 이 코드만 보면 될 것 같아서입니다.

먼저 서비스에서 호출하는 createToken()을 봐야한다.

Claims객체를 만들기 위해 인스턴스를 생성하는데 Jwt 및 Claims에 대해서는 이 글에서 설명했다.

jwt에 필요한 issue, expiry을 지정하고 signWith()는 두번째 인자를 통해 암호화 방식도 지정해준다. 이 코드에 signWith()는 두번째 인자값이 없어서 암호화 방식이 없는데, 이도 받는다. 다만 암호화 방식을 통해 암호화된 값을 인자로 받길 원한다.

1
2
3
4
5
6
7
    /**
     * ~~~~
     * recommended signature algorithm isn't sufficient for your needs, consider using
     * {@link #signWith(Key, SignatureAlgorithm)} instead.
     * ~~~~~
     */
    JwtBuilder signWith(Key key) throws InvalidKeyException;

하지만 getSignKey() 함수의 리턴값을 보면 Keys.hmacShaKeyFor()를 통해 암호화해줬다는걸 알 수 있다. hmacShaKeyFor()함수는 HMAC-SHA 암호 알고리즘을 통해서 들어온 값을 암호화 시켜버린다.

코드에서는 claims에 email값을 집어넣었는데, 이는 잘 선택해야한다. 개인정보를 최대한 담아놓지 않아야한다. 민감한 정보를 저장해 놓으면 토큰을 탈취당했을대 피해가 더 클 위험이 있기 때문이다.

compact()함수는 Jwt토큰을 빌더패턴을 통해 생성하고, 직렬화하여 외부에서도 쓰일 수 있게 한다.

또한, 이 코드에 보면 jwtConfig이라는게 나오는데, 다음과 같이 구현시켜놨다.

👍 JwtConfig.class

1
2
3
4
5
6
7
8
9
10
11
@Component
@Getter
@PropertySource("classpath:application.properties")
public class JwtConfig {

    @Value("${real-world.token.expiry}")
    private Long expiry;

    @Value("${real-world.token.key}")
    private String key;
}

@PropertySource어노테이션을 통해 application.properties에 저장되어있는 환경변수 값들을 가져온다.

나 같은 경우

1
2
real-world.token.expiry=3000000
real-world.token.key=rea12312412412SQL0132474654564564213131d31vfxvjfijkjdks

이렇게 저장해놨으며 개발자의 입맛에 따라 바꾸면된다. expiry는 토큰 만료시간, key는 개발자가 정의할 비밀키값이다.

여기까지가 token을 생성하고 응답값을 지정해준것이다.

여기까지가 회원가입 컨트롤러, 서비스, 레포지토리의 구현이다.

만약 코드가 제대로 동작하지 않는다면 댓글을 남겨주세요. 이는 전체코드 중 리뷰에 필요한 부분만 절삭하여 보여준것이므로 해결해드리겠습니다.

현재 이 프로젝트는 vue.js + vite + vuex + Spring boot, Spring Data JPA + Spring Security 를 이용하여 real-world 데모버전을 제작완료하였습니다. https://github.com/kkminseok/real-world-springboot

This post is licensed under CC BY 4.0 by the author.