본문 바로가기
Spring

application warm up

by ybs 2021. 12. 5.
반응형

warm up 은 애플리케이션 로딩 후, 타 서비스 api 커넥션 연결, db 커넥션 연결, 캐시 등의 작업을 미리 수행하는 것을 의미한다. 만약 warm up 을 하지 않으면 초기 요청들은 실패할 위험이 있고, 순단에 가까운 장애까지 발생할 수 있다. 그래서 서비스 규모가 클수록 warm up 은 필수가 된다.

 

warm up 의 개념은 단순하다. 그래서 구현이 크게 어렵지 않다. 애플리케이션 배포 후 실제 사용자 요청을 받기 전에 미리 요청을 보내면 되기 때문에 스크립트로 구현하기도 한다. 하지만 이글에서 소개하는 방법은 spring-boot-actuator-health 를 이용한다.

cf) 팀에서 사용중인 warm up 기능 중 핵심부분만 따로 정리했다.

 

먼저 spring-boot-actuate-health 의존성을 추가한다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

 

그리고 application 설정 파일에 아래 두개를 추가하면 /actuator/health 요청에 health 관련 정보들이 나온다. show-details 는 필수가 아니고 새롭게 만든 health indicator 를 확인하기 위해 추가했다. 

management.endpoints.web.exposure.include: 'health'
management.endpoint.health.show-details: always

 

이제 기존 actuator health 를 활용해 새로운 확장 포인트에서 warm up 기능을 구현한다. 먼저 config 파일을 만든다.  MyHealthIndicatorConfig 에서 warmUp 빈을 생성하는데, MyHealthIndicator 객체가 spring에서 제공하는 확장포인트(AbstractHealthIndicator) 를 구현한다. 핵심이 되는 MyChecker 와 warmUpSupplier 는 뒤에서 설명하겠다. AtomicInteger 는 테스트를 위한 부가적인 기능이다.

@Configuration
public class MyHealthIndicatorConfig {

	@Bean("warmUp")
	MyHealthIndicator myHealthIndicator(ApplicationContext context) {
		AtomicInteger count = new AtomicInteger(1);
		MyChecker myChecker = new MyChecker(warmUpSupplier(context, count));
		return new MyHealthIndicator(myChecker);
	}
    
    ...
}

 

MyHealthIndicator 내부 구현을 보자. 생성자로 MyChecker 를 주입받고 doHealthCheck 메서드에서 새로운 룰로 health check 가 이뤄진다. 그 룰의 정책과 검사는 MyChecker 가 담당한다.

private static class MyHealthIndicator extends AbstractHealthIndicator {
	private final MyChecker checker;

	public MyHealthIndicator(MyChecker checker) {
		this.checker = checker;
	}

	@Override
	protected void doHealthCheck(Builder builder) {
		if (this.checker.isOk()) {
			builder.up();
		} else {
			builder.outOfService();
		}
	}
}

 

MyHealthIndicator 는 AbstractHealthIndicator 구현체이기 때문에 반드시 doHealthCheck 를 구현해야 한다(/actuator/health 요청 시 호출). 그리고 checker 에 구현된 isOk 결과에 따라 doHealthCheck 결과가 달라진다. 

 

이제 MyChecker 내부 구현을 보자. doHealthCheck 메서드의 핵심이 되는 isOk 메서드, 그리고 그안의 check 메서드를 보면 supplier.get 메서드의 True/False 결과로 판단한다.

private static class MyChecker {
	private final Supplier<Boolean> supplier;
	private boolean checked = false;

	public MyChecker(Supplier<Boolean> supplier) {
		this.supplier = supplier;
	}

	public boolean isOk() {
		check();
		return this.isAlreadyChecked();
	}

	private void check() {
		// 한번 warm up 성공하면 다시는 warm up 수행하지 않는다
		if (isAlreadyChecked()) {
			return;
		}

		try {
			if (this.supplier.get()) {
				this.checked = true;
				return;
			}
		} catch (Exception ex) {
			this.checked = false;
			return;
		}

		this.checked = false;
	}

	private boolean isAlreadyChecked() {
		return this.checked;
	}
}

 

아까 MyChecker 생성자로 넣은 warmUpSupplier 에서 warm up 을 수행하고 정상적으로 완료되면 true 를 리턴한다. DB 조회, 외부 API 호출 할 때 커넥션이 연결되는게 목적이기 때문에 DB 조회 결과나 외부 API 호출 결과는 중요하지 않다. 

private Supplier<Boolean> warmUpSupplier(ApplicationContext context, AtomicInteger count) {
  return () -> {
    // 테스트 properties 파일에서는 warmup.enabled 을 false 로 설정해서 warm up 을 안하게 한다.
    Boolean enabled = context.getEnvironment().getProperty("warmup.enabled", Boolean.class, false);
      if (!enabled) {
        return true;
      }

      if (count.getAndIncrement() <= 3)
        throw new RuntimeException("suddenly");

      // 1. DB 조회
      callDB();

      // 2. 외부 API 호출
      try {
        callExternalAPI()
          .toFuture()
          .get();
      } catch (Exception ex) {
        // just log and ignore
      }

    return true;
  };
}

 

테스트로 warm up 중간에 강제로 예외가 발생하게 했다. AtomicInteger 를 이용해 3번 이상 요청 시 문제가 해결되는 상황을 만들었다. 애플리케이션 로딩 시 처음 실행에는 실패 하고 그후 /actuator/health 요청을 계속 하면 warm up 결과가 성공하는것을 확인 할 수 있다.

{
  "status": "UP",
  "components": {
    "diskSpace": {
      "status": "UP",
      "details": {
        "total": 499963174912,
        "free": 392659177472,
        "threshold": 10485760,
        "exists": true
      }
    },
    "ping": {
      "status": "UP"
    },
    "warmUp": {
      "status": "UP"
    }
  }
}

 

하지만 위 코드로는 부족하다. 보통 애플리케이션이 로딩된 후에 warm up 이 수행되는걸 원한다. 위 코드는 myHealthIndicator 가 빈으로 등록되는 시점에 doHealthCheck 이 호출된다. 엄밀히 애플리케이션이 준비된 후라고 볼 수 없다. 그래서 ApplicationStartedEvent 나 ApplicationReadyEvent 이벤트를 받아 warm up을 수행하기 위해 myHealthIndicator 에 applicationContext 객체를 전달해 myChecker를 리스너로 등록한다.

@Bean("warmUp")
MyHealthIndicator myHealthIndicator(ApplicationContext context) {
	AtomicInteger count = new AtomicInteger(1);
	MyChecker myChecker = new MyChecker(warmUpSupplier(context, count));
	return new MyHealthIndicator(myChecker, (ConfigurableApplicationContext)context);
}

private static class MyHealthIndicator extends AbstractHealthIndicator {
	private final MyChecker checker;
	public MyHealthIndicator(MyChecker checker, ConfigurableApplicationContext context) {
		this.checker = checker;
		context.addApplicationListener(checker);
	}
}

 

MyChecker 에서는 ApplicationListener 인터페이스를 구현체로써 onApplicationEvent 메서드를 오버라이딩한다(여기서는 ApplicationStartedEvent 를 사용했다). 그런데 이렇게 되면 trigger 포인트가 두곳이다. onApplicationEvent 메서드와 /actuator/health 요청하면 호출되는 isOk 메서드다(doHealthCheck 메서드가 isOk 메서드를 호출하므로). 때에 따라서는 동시에 올 수 도 있어서 check 메서드를 synchronized 처리했다. 그리고 MyChecker에서 자체적인 ExecutorService 객체를 만들어 별도 스레드에서 check를 수행하도록 비동기처리도 했다. 

private static class MyChecker implements ApplicationListener<ApplicationStartedEvent>
   	private final Object lockObject = new Object();
	private final Supplier<Boolean> supplier;
	private final AtomicBoolean checked = new AtomicBoolean(false);
	private final ExecutorService executorService;

	public MyChecker(Supplier<Boolean> supplier) {
		this.supplier = supplier;
		this.executorService = Executors.newFixedThreadPool(5);
	}

	@Override
	public void onApplicationEvent(ApplicationStartedEvent event) {
		executorService.submit(this::check);
	}

	public boolean isOk() {
		executorService.submit(this::check);
		return this.isAlreadyChecked();
	}

	private void check() {
		synchronized (this.lockObject) {
			if (isAlreadyChecked()) {
				return;
			}

			try {
				if (this.supplier.get()) {
					this.checked.set(true);
					return;
				}
			} catch (Exception ex) {
				this.checked.set(false);
				return;
			}

			this.checked.set(false);
		}
	}

	private boolean isAlreadyChecked() {
		return this.checked.get();
	}
}

 

이렇게 만들면 애플리케이션이 로딩된 후 warm up 이 수행되고, 설령 실패하더라도 /actuator/health 호출을 통해 성공시킬 수 있다(일시적인 통신 문제라는 전제). 그리고 그후로도 /actuator/health 호출이 계속된다고 하더라도 checked 가 true 라서 warm up은 한번만 수행한다는것을 보장할 수 있다.


 

마지막으로 애플리케이션이 로딩되면 어떤 프로세스로 warm up 이 수행되는지 정리했다. 최초 HealthEndpointConfiguration 클래스 healthEndpoint 빈이 등록된다.

@Bean
@ConditionalOnMissingBean
HealthEndpoint healthEndpoint(HealthContributorRegistry registry, HealthEndpointGroups groups) {
	// 1. HealthEndpoint 객체 생성
	return new HealthEndpoint(registry, groups);
}

 

그리고 health 메서드가 실행되고 getHealth 내부 메서드들을 호출한다.

@Endpoint(id = "health")
public class HealthEndpoint extends HealthEndpointSupport<HealthContributor, HealthComponent> {

@ReadOperation
public HealthComponent health() {
	// 2. health 실행
	HealthComponent health = health(ApiVersion.V3, EMPTY_PATH);
	return (health != null) ? health : DEFAULT_HEALTH;
}

private HealthComponent health(ApiVersion apiVersion, String... path) {
	// 3. getHealth 실행
	HealthResult<HealthComponent> result = getHealth(apiVersion, SecurityContext.NONE, true, path);
	return (result != null) ? result.getHealth() : null;
}

@Override
protected HealthComponent getHealth(HealthContributor contributor, boolean includeDetails) {
	// 4. ((HealthIndicator) contributor) 는 MyHealthIndicator 다. getHealth 실행
	return ((HealthIndicator) contributor).getHealth(includeDetails);
}

 

그리고 HealthIndicator health(구현체는 MyHealthIndicator) 가 실행된다.

@FunctionalInterface
public interface HealthIndicator extends HealthContributor {
	default Health getHealth(boolean includeDetails) {
		// 5. health 실행
		Health health = health();
		return includeDetails ? health : health.withoutDetails();
	}

	// 6. 구현체 실행
	Health health();

}

 

MyHealthIndicator 는 AbstractHealthIndicator 를 확장했으므로 여기 health 메서드가 호출되고 최종적으로 doHealthCheck 메서드가 호출되는 구조다.

public abstract class AbstractHealthIndicator implements HealthIndicator {

	@Override
	public final Health health() {
		Health.Builder builder = new Health.Builder();
		try {
			// 7. doHealthCheck 실행
			doHealthCheck(builder);
		}
		catch (Exception ex) {
			if (this.logger.isWarnEnabled()) {
				String message = this.healthCheckFailedMessage.apply(ex);
				this.logger.warn(StringUtils.hasText(message) ? message : DEFAULT_MESSAGE, ex);
			}
			builder.down(ex);
		}
		return builder.build();
	}
private static class MyHealthIndicator extends AbstractHealthIndicator {

	// 8. MyHealthIndicator의 doHealthCheck 호출
	@Override
	protected void doHealthCheck(Builder builder) {
		if (this.checker.isOk()) {
			builder.up();
		} else {
			builder.outOfService();
		}
	}
반응형