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