회고

웨이팅 프로젝트 회고

임요환 2023. 6. 13. 15:04

1. JwtProvider의 책임을 강화하라

public class JwtProvider {
    private final JwtProperties jwtProperties;

    public JwtDto createTokenDto(PrincipalDetails principalDetails){
        // 생략
        return new JwtDto();
    }

    public String createRefreshToken() {
        // 생략
        return refreshToken;
    }

    public String createAccessToken(String username, Long id, String roleType){
        // 생략
        return accessToken;
    }

    public Map<String, Claim> decodeJwt(String jwt){
        return JWT.decode(jwt).getClaims();
    }

    public void validateJwt(String jwt){
        JWT.require(Algorithm.HMAC512(jwtProperties.getSecret())).build().verify(jwt);
    }

    public Map<String, Claim> getClaims(String jwt){
        // 생략
        return claims;
    }
}
  • 위와 같이 초기에 구성하였다. 사실 내가 잘 몰랐기 때문에 github에서 괜찮다고 생각하는 사람이 만든 코드를 가져다가 썻던 부분도 있었다. 하지만 공부를 해가면서 객체의 책임에 대하여 생각하게 되었다.
  • 위 코드의 문제점
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    //생략
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        // 문제점 1
        String jwt= getJwtByHeader(request);

        try{
            // 문제점 2
            validateJwt(jwt);
            // 문제점 3
            Map<String, Claim> claims = jwtProvider.getClaims(jwt);
            String username = claims.get("username").asString();
            setSecurityContext(username);
        }

        filterChain.doFilter(request,response);
    }

    private void validateJwt(String jwt) {
        if(StringUtils.hasText(jwt)){
            jwtProvider.validateJwt(jwt);
        }else{
            throw new AuthException("토큰형식이 옳바르지않습니다.");
        }
    }

    private String getJwtByHeader(HttpServletRequest request) {
        String jwt = request.getHeader("Authorization");
        if (StringUtils.hasText(jwt) && jwt.startsWith("Bearer ")) {
            jwt = jwt.replace("Bearer ", "");
            return jwt;
        }
        return null;
    }
}

public class AuthService {
    //생략

    public JwtDto reissue(JwtDto jwtDto){
        // 위 문제점들과 중복
        jwtProvider.validateJwt(jwtDto.getRefreshToken());

        Map<String, Claim> claims = getJwtAttribute(jwtDto.getAccessToken());
        Long id = claims.get("id").asLong();
       //생략
    }

    // 위 문제점들과 중복
    private Map<String, Claim> getJwtAttribute(String accessToken) {
        if(StringUtils.hasText(accessToken) && accessToken.startsWith("Bearer ")){
            Map<String, Claim> claims = jwtProvider.decodeJwt(accessToken.replace("Bearer ", ""));
            return claims;
        }else{
            return null;
        }
    }
}
  • JwtProvider로써는 책임을 다하였지만 jwt를 관리하기에는 자율성과 책임이 너무 부족하다고 생각했다.
  • Scheme token(bearer access token)울 검증하는 로직, 이것들을 검증하고 에러를 보내는 로직도 각각의 클래스에 개별로 생성 되었다.
  • 또한, com.auth0.jwt.interfaces.Claim의 클래스 auth0 패키지에 속해있는 클래스가 내가만든 클래스에 의존성이 침투되었고 추후 내가 다른 라이브러리를 사용하게되면 이 클래스도 수정해야되게 된다.
  • 그렇기 때문에 나는 이러한 의존성들을 최소화하고 객체의 자율적인 책임을 올리기 위해 이름을 JwtProvider에서 JwtManager라는 이름으로 변경하였고 다음과 같이 코드를 수정하였다.
public class JwtManager {
    //생략
    public JwtDto createJwtDto(User user){
        return new JwtDto(jwtProperties.getType(), accessToken, refreshToken);
    }

    public String createAccessToken(String userId, String name, String roleType){
        return accessToken;
    }

    public String createRefreshToken(User user) {
        String refreshToken = JWT.create()
                .withSubject(user.getId())
                .withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getRefreshExpiredAt()))
                .sign(Algorithm.HMAC512(jwtProperties.getSecret()));

        refreshTokenRepository.save(RefreshToken.builder()
                .token(refreshToken)
                .user(user)
                .build());

        return refreshToken;
    }

    public JwtDto reissue(String bearerToken, String refreshToken){
        String accessToken = getAccessTokenFromBearerToken(bearerToken);
        String userId = getUserIdFromAccessToken(accessToken);
        RefreshToken findRefreshToken = refreshTokenRepository.findByTokenAndUserId(refreshToken, userId)
                .orElseThrow(() -> new TokenNotFoundException(ErrorCode.REFRESH_TOKEN_NOT_FOUND));

        try{
            validateJwt(findRefreshToken.getToken());
        }

        String name = getNameFromAccessToken(accessToken);
        String roleType = getRoleTypeFromAccessToken(accessToken);
        return new JwtDto(getTokenType(), createAccessToken(userId, name, roleType), refreshToken);
    }

    public void validateJwt(String jwt){
        JWT.require(Algorithm.HMAC512(jwtProperties.getSecret())).build()
                .verify(jwt);
    }

    public String getAccessTokenFromBearerToken(String bearerToken){
        if(isBearerToken(bearerToken)){
            return bearerToken.substring(getTokenType().length()+1);
        } else {
            throw new TokenFormatNotAllowedException();
        }
    }

    public String getUserIdFromAccessToken(String accessToken){
        return JWT.decode(accessToken).getClaims().get("userId").asString();
    }

    private String getNameFromAccessToken(String accessToken){
        return JWT.decode(accessToken).getClaims().get("name").asString();
    }

    private String getRoleTypeFromAccessToken(String accessToken){
        return JWT.decode(accessToken).getClaims().get("roleType").asString();
    }

    private String getTokenType(){
        return jwtProperties.getType();
    }

    private boolean isBearerToken(String bearerToken){
        return StringUtils.hasText(bearerToken) && bearerToken.startsWith(getTokenType() + " ");
    }
}
  • 차후 라이브러리 변경시 의존성이 빠져나가지 않도록 JwtManager안에서 decode된 값을 개별로 받을 수 있도록 변경하였다
  • 또한 토큰 형식을 검증하는 코드도 함께 추가하였다.
  • 토큰을 재발급하는 로직같은경우도 JwtManager의 책임으로 보았고 JwtManager안에서 토큰을 재발급하고 검증하는 로직 또한 추가하였다.
  • 좀 더 객체지향스럽게 코드를 작성할 수 있도록 하였다.

2. 쉽게 바뀔 수 있는 서비스는 interface로 설계하라(MessageSender의 주체는 쉽게 바뀔 수 있다)

public class MessageSender {
    public void sendMessage(String phoneNumber, int type, HashMap<String, String> variables){
        //생략
    }
}
  • 대기순서가 왔을 때 알람을 보내는 것은 카카오톡으로 하기로 하였다.
  • 외부 라이브러리를 사용하여 메시지를 보내였고 카카오톡이 실패하면 저절로 sms로 발송되는 서비스였다.
  • 하지만 문제는 내가 이 프로젝트를 개인적으로 좀 더 해보기위해 대기번호 보내는 것을 하여야했는데 외부 라이브러리 서비스를 사용하려면 사업자번호가 필요하였다
  • 그래서 Mail로 보내려 하니 코드의 많은 수정이 필요하였다.
public interface MessageSender {
    void sendMessage();
}

public class KakaoSender implements MesseageSender {
    public void sendMessage() {
        // 생략
    }
}

@Primary
public class MailSender implements MessageSender {
    public void sendMessage() {
        // 생략
    }
}
  • 처음부터 인터페이스로 설계되었다면 MailSender 클래스 작성후 @Primary 어노테이션을 이용하여 쉽게 해결할 수 있었을 것이다.

3. 대기 번호를 발급하라

  • 오늘의 대기번호를 발급하는 로직을 작성하는 방법에 대해 고민을 하였고 여러가지 고민이 있었다.
  1. mysql의 auto increment + 스케줄러를 사용하여 저절로 1씩 증가하고 하루마다 초기화 해주는 방법을 생각했지만 해당 로직 대신 리스크가 너무 큰거같았다.
  2. 그래서 생각한것이 오늘 날짜의 대기인원을 구한 후 +1하여 대기 번호를 발급해주는 것이였다.
select count(id) from visitor where createdDate between A and B
  • 이렇게하여 해결할려고 했지만 한가지 이슈가 생겼다.
  • 바로 동시성 이슈이다.
  • 만약 위의 메소드를 실행하는 시점에 두개 이상의 쓰레드가 동시에 해당쿼리를 호출하면 똑같은 번호를 발급받게된다.
  • 이렇게 이 문제를 해결하기 위해 여러 고민을 하였다.
  1. 처음에는 데이터베이스 락을 활용해보기위해 비관적락, 낙관적락에 대해 알아보았다.
  • 하지만 두개의 락은 집계쿼리에 락을 걸 수가 없었다.
  • 이부분에 대해 정정하겠다.
  • select count(id) from visitor where createdDate between A and B for update를 하게 되면 해당 조건에 해당하는 로우들만 락이 걸리게 되므로 이 쿼리를 사용하여 해결할 수 있었을 것 같다.
  1. 테이블락
  • 테이블락을 걸면 조회를 일시적으로 할 수 없기 때문에 안된다고 생각했다.
  • 왜냐하면 조회를 하는 부분이 대기번호 발급하는 부분보다 훨씬 중요한 부분이라고 생각했다.
  1. insert select 서브쿼리
    insert into visitor (wait_number) values ((select count(*) + 1 from visitor ALIAS_FOR_SUBQUERY where created_date between A and B))
  • 이렇게 쿼리를 작성하였을 때 동시성이슈에 문제가 없었다
  • 하지만 JPA를 사용하였고 JPA에서 insert subquery는 존재하지 않아 native query로 작성해야 했지만 native query는 엔티티를 반환해주지 않았다.
  • 엔티티를 반환해주지 않아 내가 어떤 번호를 발급받았는지를 다시 select하고 그 번호를 발급해줘야하며 내가 insert하고 난 후 바로 select한 값을 받는 쿼리를 작성하기에는 또 다른 동시성 이슈가 있을것 같았다.
  1. synchronzied 키워드
  • 최종적으로는 synchronized 키워드를 사용하게 되었다.
  • synchronized 키워드를 사용한 이유는 이 애플리케이션 서버가 2대 이상으로 늘어날 일이 전혀 없을 것이라는 것을 확신하였고 synchronized가 서비스 로직의 수정없이 제일 깔끔하게 코드 수정이 가능하였기 때문에 최종적으로는 사용하게 되었다.
  • 하지만 synchronized 키워드를 사용하고도 나는 찝찝함을 벗어날 수 없었다.
  • 왜냐하면 이는 결국 완벽한 해결책이 아니라고 생각했기 때문이다.
  • 이 애플리케이션이 확장되어 다른 회사들을 type으로 구분하여 각자의 대기자들을 받을 시에 서로 상관없는 회사들에서 모두 synchronized 키워드 메서드를 호출하게 되고 이는 성능을 매우 저하시킨다.
  • 그래서 내가 생각한 다른 두가지 방법을 마지막으로 소개하며 마무리 하겠다
  1. wait_number 엔티티 생성
  • 대기번호만을 담당하는 wait_number라는 엔티티를 추가로 생성하여 데이터베이스 락과 함께 활용하는 방법이다.
  • 또한, unique 제약조건을 통해 하루에 두개 이상의 대기번호가 생성되지 않게 방어하고 select for update 혹은 version을 이용해 처리하는 방법이다
  • wait_number라는 엔티티를 추가로 생성하는 법이 좀 귀찮을 수 있지만 이 방법은 추가로 언제 처음 대기번호가 발급되었고(바빠진 시기) 언제 마지막으로 발급되었는지(널럴해진 시기) 등 추가 정보도 얻을 수 있다.
  1. 분산락
  • mysql의 네임드락과 redis의 분산락을 이용한 처리이다.
  • 분산락은 간략하게 말하면 락을 제 3자에게 제어를 맡긴다라고 생각하면 된다
  • 내가 mysql에게 현재날짜+회사타입의 키로 락을 걸어달라고 부탁하면 mysql은 해당 키로 오는 요청은 락을걸고 만약 현재날짜+다른회사로 걸면 다른회사는 락에 걸리지 않는다.
  • redis를 이용한 분산락도 이와 유사한 방법이다
  • 하지만 redis를 이용하기 위해서는 인프라 쪽과도 연관이 있어서 추가적인 조치가 필요하다