Instant.now() 대신 Instant.now(Clock)를 사용하자.
요약
static 메서드를 mockito-inline과 PowerMockito 등으로 mocking을 할 수 있다. 하지만 static 메서드를 mocking하기 전에 애초에 static 메서드가 잘 설계 되어 있는지 고려해야 한다. Instant.now(Clock)과 같이 static 메서드가 순수 함수로 잘 설계되었다면 static 메서드의 mocking은 필요없다. 하지만 Instant.now()와 같이 static 메서드를 mocking해야 한다면 그 static 메서드는 사용을 피하는 방향으로 가야 한다.
Issue
문제 상황
Jwt 토큰을 생성할 때 토큰의 만료 시간을 토큰 생성 시간부터 30분 후로 설정하였다. 이 때 30분 지나면 진짜로 만료가 되는지 여부를 어떻게 테스트해야 할까? 이 문제와 별개로 Instant.now()를 이용해서 만료 시각을 설정하는데 Instant.now(), LocalDateTime.now()와 같은 코드는 테스트할 때마다 다른 값을 얻기 때문에 테스트가 항상 성공한다고 보장할 수 없는 문제도 있어 이를 해결해야 했다. 문제 해결을 위해 static 메서드를 mocking하는 방법을 찾던 중 이 설계가 잘못되었다는 내용을 찾을 수 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//발급
Instant expiredInstant = Instant.now().plusSeconds(1800L);
Claims claims = Jwts.claims()
.setExpiration(Date.from(expiredInstant));
...
//검증
claims = Jwts.parserBuilder()
.setSigningKey(key) //서명 검증을 위한 SecretKey 입력
.build()
.parseClaimsJws(parsingJwt) //토큰이 유효한지 검사. 유효하지 않으면 여러 종류 예외 발생
.getBody();
//ExpiredJwtException을 발생시키고 싶은데 30분을 기다릴 수는 없으므로
//그리고 테스트시 발급 시간이 계속 달라지는 문제
문제 원인
Instant.now()가 코드로 사용하기 그리 좋은 코드는 아니라는 것을 공부하면서 알게 되었다. Instant.now()는 동일한 입력에 다른 출력을 반환하므로 static 메서드로 사용하기 적합한 순수 함수가 아니다. 따라서 이를 해결하기 위해 자연히 Instant.now()를 mocking하는 방법을 찾게 되고 이 과정에서 테스트하기 어려워 진다는 것이다.
왜 static 메서드의 Mocking을 권장하지 않는가?
static 메서드 자체가 적절하게 사용하지 않으면 안티 패턴이 될 수 있다고 한다. java.lang.Math와 같은 순수 함수적인 특성(“purely functional”)을 가진 static 메서드는 적절히 사용한 예시이다.
반면 static 메서드가 의존성을 가지고 다른 객체와 상호작용하는 시점부터 안티 패턴이 된다고 한다. 다른 객체로부터 영향을 받기 때문에 동일한 입력이 다른 결과를 낳을 수 있는 것이다. 이런 static 메서드는 재정의를 할 수 없어 다형성을 위반하므로 객체 지향 측면에서도 좋지 않은 코드가 된다.
static 메서드가 동일한 입력에 동일한 출력을 반환하는 순수 함수였다면 내부 동작을 mocking으로 강제할 이유가 없다. 그러니까 static 메서드를 mocking해야 할 일이 있다면 이 static 메서드가 다른 객체에 영향을 받고 있는 것은 아닐까?라고 먼저 생각해보아야 한다. 순수 함수의 mocking과 관련하여 구글링하던 중 아주 심플한 글이 있었다.
If it’s a pure function, why are you mocking the type? Do you also mock arrays or strings when your functions depend on their methods, or do you just pass a real array or a real string? If it’s a pure function, you should learn everything you need from making assertions on the return value.
순수 함수로 접근하는 Instant.now()와 Instant.now(Clock)
이러한 관점에서 Instance.now()는 순수 함수가 아니므로 Instance.now()를 Mocking할 수 밖에 없는 것이다. Instant.now()는 동일한 입력(null 입력)에 항상 다른 결과가 나온다. 함수 내부에서 시스템의 Clock에 영향을 받는 것이다. 이렇게 순수 함수가 아니고 다른 객체에 계속 영향을 받기 때문에 우리는 이를 테스트하기 위해 mocking을 해야 하는지 고민하는 것이다.
Instant.now(Clock)은 동일한 Clock 입력이 들어오면 항상 동일한 Instant를 반환한다. 입력이 동일하면 출력도 동일한 순수 함수인 것이다. 따라서 static 메서드를 사용한 적절한 예시이다. 이 경우 테스트도 간편하여 Instant.now(Clock) static 함수를 mocking할 필요 없이 테스트할 Clock 객체를 생성하여 이 static 함수에 전달하면 된다. Production 단계에서는 Instant.now에 현재 시간 정보를 가지고 있는 Clock 객체를 전달하면 되는 것이다.
해결 방법
Clock 객체를 빈으로 등록하여 테스트 시 이 빈의 Mock을 정의하고 Instance.now(Clock)을 호출하는 방법으로 해결할 수 있었다. Clock 객체의 instant 메서드는 static 메서드가 아니므로 Mockito를 사용하여 편리하게 원하는 시간을 Mocking할 수 있다.
production 코드 변경하기
TimeConfig.java
1
2
3
4
5
6
7
8
//테스트 시 Clock 빈을 Mock으로 변경
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
}
JwtTokenAdministrator.java
1
2
3
4
5
6
7
8
9
10
11
12
Instant expiredInstant = Instant.now(clock).plusSeconds(1800L);
Claims claims = Jwts.claims()
.setExpiration(Date.from(expiredInstant));
...
//검증
claims = Jwts.parserBuilder()
.setSigningKey(key) //서명 검증을 위한 SecretKey 입력
.setClock(() -> Date.from(clock.instant()))
.build()
.parseClaimsJws(parsingJwt) //토큰이 유효한지 검사. 유효하지 않으면 여러 종류 예외 발생
.getBody();
이때 Jwts의 parserBuilder에서 주의해야 할 점이 있다. setClock에서 Clock을 매개 변수로 받는데 java.time.Clock클래스가 아닌 io.jsonwebtoken.Clock 인터페이스이다. 이 인터페이스는 Date를 반환하는 now() 메서드를 가진 함수형 인터페이스이다. 따라서 람다 함수로 검증 당시 시각의 정보를 Date 클래스로 반환하면 된다. 이름이 동일하므로 java.time.Clock을 바로 넣는 실수를 할 수 있으니 주의하자.
테스트 코드 변경하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@BeforeEach
void setup() {
Mockito.mock(Clock.class);
...
}
@Test
@DisplayName("토큰 만료 실패 테스트")
void verify_token_expired_fail_test() throws ServletException {
//given
//현재 시간 변경(생성 시간은 2024-01-01T10:00:00Z 현재 시간은 이로부터 한시간 뒤)
Mockito.when(clock.instant()).thenReturn(Instant.parse("2024-01-01T11:00:00Z"));
//when
//then
Assertions.assertThrows(ServletException.class, () -> jwtTokenAdministrator.verifyToken(token));
}
clock.instant()는 static 메서드가 아니므로 mock을 편리하게 적용할 수 있다.
결론
static 메서드를 mocking 해야 한다면 애초에 static 메서드에 어떠한 문제가 있다는 것을 명심해야겠다. 순수 함수가 아닐 때는 static 메서드로 구현하지 않는 것이 좋다. 실제로 Instance.now()를 mocking하는 일보다 코드를 Instance.now(Clock)으로 바꾸는 것이 테스트하기에도 훨씬 좋았다. LocalDate, LocalDateTime의 경우도 마찬가지이므로 평소에 LocalDateTime.now(Clock)을 이용하자.