Home [soldout] 유연한 로그인 인증 방식 변경 설계
Post
Cancel

[soldout] 유연한 로그인 인증 방식 변경 설계

# 요구사항


로그인 인증 방식으로 선택한 세션 방식Jwt 토큰 방식을 함께 구현하더라도 객체지향 스러운 설계로 구성한다면 두 방식을 다른 코드의 영향을 주지않고 유연하게 변경할 수 있는 설계가 가능할 것이라 생각했습니다.

자바에선 이와 같은 유연한 설계를 구현할 다형성의 장점을 활용한 디자인 패턴인 전략 패턴을 적용해보자 했습니다.

# 해결방안 1


다형성

다형성이란 하나의 상위 클래스 타입의 객체에 여러 하위 클래스 타입의 객체를 선언함으로써 다양한 형태로 활용할 수 있는 성질을 의미합니다.

즉, 하나의 인터페이스 타입의 참조 변수에 선언할 수 있는 클래스는 해당 인터페이스를 구현한 클래스들 중 어느 것이라도 담을 수 있는 것을 뜻합니다.

전략 패턴

위와 같은 다형성을 활용하기 위해 하나의 인터페이스만을 바라보도록 설계하는 것을 디자인 패턴 중 전략 패턴이라 할 수 있습니다.

위 그림처럼 세션 인증 방식을 구현한 클래스와 토큰 인증 방식을 구현한 클래스를 하나의 인터페이스에 대한 구현체로 정의하고, 이를 사용할 클라이언트 코드에는 인터페이스 타입의 참조 변수만 선언해둔다면 목적과 상황에 맞게 인증 방식의 변경이 가능할 것이라 판단했습니다.

적용 코드

SessionSecurityService

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
44
45
46
47
48
@Service
@RequiredArgsConstructor
public class SessionSecurityService {

  @Value("${session.interval}")
  private int sessionInterval;

  // 로그인 기능
  public void signIn(String email, HttpServletRequest request) {

    HttpSession session = request.getSession();

    if (isAlreadySignInBrowser(session)) {

      throw new AlreadySignInBrowserException("이미 로그인 되어있는 브라우저입니다.");

    }

    String sessionId = UUID.randomUUID().toString();

    sessionDataBase.put(sessionId, email);

    session.setAttribute(SESSION_ID, sessionId);

    session.setMaxInactiveInterval(sessionInterval);
    
      }
      
  private boolean isAlreadySignInBrowser(HttpSession session) {

    String sessionId = (String) session.getAttribute(SESSION_ID);

    if (sessionId != null) {

      return true;

    }

    if (sessionDataBase.containsKey(sessionId)) {

      return true;

    }

    return false;

  }
}

JwtSecurityService

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
44
45
@Service
public class JwtSecurityService {

  @Value("${jwt.secretKey}")
  private String secretKey;

  @Value("${jwt.ttlMillis}")
  private int ttlMillis;
  
  // 로그인 기능
  public void signIn(String email, HttpServletRequest request, HttpServletResponse response) {

    if (request.getHeader(TOKEN_ID) != null) {

      throw new AlreadySignInBrowserException("이미 로그인된 회원입니다");

    }

    String token = createToken(email);

    response.setHeader(TOKEN_ID, token);

  }

  private String createToken(String email) {

    if (ttlMillis <= 0) {

      throw new RuntimeException("Expiry time must be greater than Zero : [" + ttlMillis + "] ");

    }

    SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(secretKey);

    Key signingKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName());

    return Jwts.builder()
        .setSubject(email)
        .signWith(signingKey, signatureAlgorithm)
        .setExpiration(new Date(System.currentTimeMillis() + ttlMillis))
        .compact();
  }
}

코드의 가독성을 높히기 위해 로그인 기능을 수행하는 메소드만 작성했습니다.

# 구현 중 발견된 문제점


두 인증 방식에 맞는 클래스를 따로 구현하려 했지만 이를 하나의 인터페이스로 묶기 애매한 문제가 발생했습니다.

세션 인증 방식의 경우 HttpServletResponse 객체까지 매개변수로 받아오지 않아도 기능 구현이 가능했지만,

토큰 인증 방식의 경우 발행한 토큰을 응답 헤더에 직접 넣어줘야 하는 소요가 있어 HttpServletResponse 객체를 필요로 합니다.

이러한 차이점이 발생하는 현재 설계에선 전략패턴을 통한 다형성의 장점을 활용할 수 없고 이를 해결하기 위해선 두 매개변수를 동일하게 할 수 있는 방법이 필요했습니다.

# 해결방안 2


1. HttpSession 클래스 DI(의존성 주입)

우선 매개변수로 전달되는 HttpServletRequest 클래스 객체는 Request 객체에 담긴 세션에 대한 정보를 확인하고 HttpSession 클래스 객체를 가져오기 위함인데 이를 굳이 매개변수로 전달받아 활용할 필요가 없습니다.

Spring에서 HttpSession 객체는 HttpSession을 주입해야 할때 내부적으로 서블릿컨테이너에게 Session을 요청하게 됩니다.

그러므로 Session 객체는 아래와 같이 2가지 방법으로 생성할 수 있습니다.

1.@Autowired나 @Inject 같은 의존성 주입으로 HttpSession을 주입받으면, 서블릿 컨테이너에게 session을 달라고 요청하지 않고, set이나 getAttribute같은 api를 호출하는 시점에 요청과 생성이 일어난다.

2.매개변수를 통해 HttpSession을 받으면, 선언시 sessoin을 요청한다.

이를 미루어봤을 때 SessionSecurityService 클래스에 직접 HttpSession 클래스를 주입해준다 하더라도 매개변수로 Request 객체를 넘겨받지 않아도 스프링 내부적으론 동일한 동작을 합니다.

수정된 SessionSecurityService

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
44
45
46
47
48
49
50
@Service
@RequiredArgsConstructor
public class SessionSecurityService implements SecurityService {

  @Value("${session.interval}")
  private int interval;

  @Value("${session.db.expiration}")
  private long expiration;

  // 직접 주입 받은 HttpSession 객체
  private final HttpSession session;

  public void signIn(String email) {

    if (isAlreadySignInBrowser(getCurrentSessionId())) {

      throw new AlreadySignInBrowserException("이미 로그인한 상태입니다.");

    }

    String sessionId = UUID.randomUUID().toString();

    sessionDataBase.put(sessionId, email, ExpirationPolicy.CREATED, expiration, TimeUnit.SECONDS);

    // 동일하게 session을 활용할 수 있다.
    session.setAttribute(SESSION_ID, sessionId);

    session.setMaxInactiveInterval(interval);

  }

  private boolean isAlreadySignInBrowser(String sessionId) {

    if (sessionId != null) {

      return true;

    }

    if (sessionDataBase.containsKey(sessionId)) {

      return true;

    }

    return false;

  }

2. RequestContextHolder 클래스

RequestContextHolder 클래스는 어느 위치에서든 Request, Response 객체를 호출해올 수 있도록 기능을 지원해주는 유틸 클래스입니다.

이 클래스를 활용한다면 굳에 메소드의 매개변수로 Reqeust, Response 객체를 전달받지 않아도 세션이나 헤더의 정보를 가져오거나 담을 수 있습니다.

그렇기에 토큰 인증방식에서 Response 객체에 토큰을 담아줘야 하는 로직도 매개변수로 Response 객체를 전달받지 않고 내부적으로 호출할 수 있습니다.

수정된 JwtSecurityService

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@Slf4j
public class JwtSecurityService implements SecurityService {

  @Value("${jwt.secretKey}")
  private String secretKey;

  @Value("${jwt.ttlMillis}")
  private int ttlMillis;
  
  public void signIn(String email) {
  
    // 내부에서 직접 Request, Response 객체 호출
    HttpServletRequest request = getCurrentRequest();

    HttpServletResponse response = getCurrentResponse();

    if (request.getHeader(TOKEN_ID) != null) {

      throw new AlreadySignInBrowserException("이미 로그인된 회원입니다");

    }

    String token = createToken(email);

    response.setHeader(TOKEN_ID, token);

  }

  private String createToken(String email) {

    if (ttlMillis <= 0) {

      throw new RuntimeException("토큰 유효시간을 다시 설정하세요.");

    }

    byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(secretKey);

    Key signingKey = new SecretKeySpec(secretKeyBytes, SignatureAlgorithm.HS256.getJcaName());

    return Jwts.builder()
        .setSubject(email)
        .signWith(signingKey, SignatureAlgorithm.HS256)
        .setExpiration(new Date(System.currentTimeMillis() + ttlMillis))
        .compact();
  }
  
  // RequestContextHolder 클래스를 통해 ServletRequestAttributes 클래스 객체 호출 가능
  private ServletRequestAttributes getRequestAttributes() {

    return (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();

  }

  // Request 객체 호출
  private HttpServletRequest getCurrentRequest() {

    return getRequestAttributes().getRequest();

  }
  // Response 객체 호출
  private HttpServletResponse getCurrentResponse() {

    return getRequestAttributes().getResponse();

  }
}

수정된 두 인증 방식 구현체의 상위 모듈인 SecurityService의 모습은 다음과 같습니다.

SecurityService(인터페이스)

1
2
3
4
5
6
7
public interface SecurityService {
  // 동일한 매개변수로 다른 인증 방식을 채택한 구현체 생성 가능
  void signIn(String email);

  ...
  
}

# 마무리


위 두 방법을 통해 전략 패턴을 활용해 인증 구현 방식의 변경이 유연한 코드 구현이 가능해졌습니다.

Controller 단에선 SecurityService 타입의 객체만을 의존성 주입받아 동일한 이름의 메소드를 호출하기만 하면 되며, Configuration 파일 또는 어노테이션을 통해 인터페이스 구현 클래스로 선정된 객체의 메소드가 호출되도록 구현이 가능해졌습니다.

# 참고 자료


  • 전략 패턴 : https://victorydntmd.tistory.com/292
  • 세션 생성 주기 : https://kimfk567.tistory.com/51
  • RequestContextHolder : https://gompangs.tistory.com/entry/Spring-RequestContextHolder
This post is licensed under CC BY 4.0 by the author.

[soldout] 로그인 인증 방식 선정

[soldout] Layer 의존성 방향에 대한 고민