소개

저는 신사업부문에서 Thiiing(띠잉)서비스를 만들고 있습니다.
띠잉 서버의 사례를 중심으로 커스텀 어노테이션을 사용하게 된 계기와 활용방식을 설명하겠습니다.


시의적절한가

properties

개인적으로 어노테이션이 덕지덕지 붙어있는 코드를 좋아하지 않습니다. 특히 ‘커스텀’인 경우는 더욱.
어노테이션의 의도는 숨어있기 때문에 내부적으로 어떤 동작을 하게 되는지 명확하지 않다면 로직 플로우를 이해하기 어렵게 되고, 코드정리가 덜 되어 현재 사용되지 않고 있는 어노테이션들이 있더라도 쉽사리 누군가가 손을 대기 부담스러워하는 경우도 왕왕 봐왔습니다. 하물며 ‘커스텀’ 어노테이션은 그 부담을 가중시킵니다. 무분별한 어노테이션 추가가 당장의 작업 속도를 끌어올릴 순 있지만, 긴 관점에서 시의적절한 것인지를 공감할 수 있어야 합니다.

그럼에도 어노테이션이 가진 가장 큰 장점은 역시 간결함입니다. 이는 로직 흐름에 대한 컨텍스트가 응축돼 있어 적재적소에 사용된다면 불필요한 반복코드가 줄고 개발자는 비지니스 로직에 더 집중 할 수 있도록 만들어 주기 때문입니다.

이처럼 양면성을 가진 도구가 시의적절한지는 구성원간의 이해와 공감대가 선행돼야 합니다. 또한 커스텀 어노테이션은 플로우에 대한 컨텍스트를 담고 있기 때문에 용도와 목적에 맞게 작성하는 것이 중요합니다. 다른 말로, 이것 저것 할 수 있는 다기능으로 만들게 되면 해석이 어려워진다는 뜻입니다.

아래부터는 띠잉 서버에서 사용하고 있는 커스텀 어노테이션 중 일부 사례를 공유하겠습니다.


비회원 미리보기

띠잉 서비스는 회원과 비회원이 사용할 수 있는 기능이 아래와 같은 집합 관계를 갖습니다.
properties

비회원이어도 서비스를 일부 경험할 수 있도록 하기 위함입니다. 이는 같은 API에 대해 회원정보에 따라 미리보기도 가능해야 한다는 말입니다. 이를 위해 비회원 전용 API를 별도로 두는 방법도 있겠습니다만, 그렇게 되면 동일한 기능을 하는 API를 클라이언트쪽에서 회원 정보에 따라 다른 분기 호출이 필요하기 때문에 서버 개발자로서 좋은 설계는 아니라고 생각합니다.

@PreviewAvailable

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface PreviewAvailable {
}

저희는 ControllerAspect라는 API 엔드포인트를 AOP로 감싼 클래스를 사용하고 있습니다. 여기에 아래와 같은 조건을 분기함으로써 인증 체크를 쉽게 회피할 수 있게 됩니다.

private boolean isPreviewApi(Class<?> clazz, Method method) {
    return AnnotationUtils.findAnnotation(clazz, PreviewAvailable.class) != null || 
           AnnotationUtils.findAnnotation(method, PreviewAvailable.class) != null;
}

이와 유사한 방식이지만 목적에 따라 아래와 같은 커스텀 어노테이션들을 추가로 만들어 사용하고 있습니다.

  • @SkipOAuth2 : 인증을 회피할 목적으로 사용
  • @InternalApi : 내부 서버간에 호출되는 API에서 사용
  • @ThirdPartyApi : 외부 소셜서비스로 호출되는 API에서 사용
  • @Web : 웹관련 API에서 사용

조건에 따른 분기처리는 AOP에 몰아넣고, 각 커스텀 어노테이션은 필요한 클래스, 메소드에 지정해주면 되는 간단한 방식입니다.

사용예

비회원 미리보기가 필요한 API엔드포인트에 어노테이션을 지정할 수 있습니다.

@PreviewAvailable
@GetMapping("/{contentsId}")
public ResponseEntity<Response> getContentsView(@PathVariable @Min(value = MIN_ID, message = CONTENTSID_MIN_CODE) long contentsId) {
    ...
}

CircuitBreaker

Hystrix를 사용하지 않은 이유

넷플릭스 히스트릭스와 Spring Cloud에서 한 번 감싼 히스트릭스에서 제공하는 서킷브레이커가 실무에서 많이 사용되고 있습니다. 그럼에도 이를 커스텀하게 관리해야겠다고 결정하게 된 이유는 아래와 같습니다.

  1. 우리가 잡고 싶은 실패 조건을 커스텀하기 어렵다.
    저희는 에러코드를 서버에서 직접 컨트롤하고 있습니다. 이 에러코드는 로직상 정상적인 에러도 포함됩니다. 예를들어 접근허용되지 않는 콘텐츠에 리액션(좋아요,이모지액션)하려는 경우 라든가 이미 팔로잉한 유저를 또다시 팔로잉하려는 경우 등 매우 다양한 상황에서 의도된 정상적인 예외가 발생할 수 있습니다. 그래서 정말 크리티컬 에러라고 정의한 것들에 대해서만 서킷브레이커가 발동되는 조건으로 사용돼야 합니다.
  2. 의존성
    서비스 오픈전부터 너무 많은 라이브러리 의존성이 걸리는 것을 경계하고 있었습니다. 추후 변경에 의해 더이상 사용하지 않게 되는 의존성을 정리하지 못하는 실수가 간혹 발생하기도 하며 운영중에 정책이나 요구사항이 어떻게 변할지 모르기 때문에 처음부터 라이브러리 의존성이 무거워지는 것을 꺼렸기 때문입니다.
  3. 필요한 기능만 사용하고 싶다.
    초반에 필요로 하는 서킷브레이커의 기능은 간단하고 명확했습니다. 따라서 라이브러리에서 지원하는 다양한 설정과 기능이 모두 필요한 것은 아니었습니다.

이런 것들을 기회비용으로 따져봤을 때 우리에게 fit한 서킷브레이커를 만들어 쓰기로 했습니다.
물론 운영환경에서 더이상 커스텀 서킷브레이커의 효용성이 없다고 판단할 때 외부 라이브러리를 도입하는 것은 자연스러운 수순이라고 봅니다.

@CircuitBreaker

서킷브레이커를 등록할 때 디폴트값을 주로 사용하는 편입니다.
이 디폴트값은 저희 서비스내에서 적당한 값을 찾아 설정했기 때문에 대게의 경우 좀 더 간결한 어노테이션 설정이 가능합니다.
처음 서비스 오픈할때는 개별 서킷브레이커마다 세부적으로 값을 주기보다 디폴트 설정을 취하며, 운영을 하면서 기존보다 더 타이트한 개별 설정으로 조정하는 편입니다.

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface CircuitBreaker {

    CircuitBreakerName name(); // [1]

    long closeIntervalSecond() default 180L; // [2]

    int closeFailRateThreshold() default 100; // [3]

    int closeMinRequestCountThreshold() default 300; // [4]

    int closeMaxFailCountThreshold() default 10_000; // [5]

    long openIntervalSecond() default 180L; // [6]

    long halfOpenIntervalSecond() default 30L; // [7]

    int halfOpenSuccessRateThreshold() default 100; // [8]

    boolean ignore() default false; // [9]

    Class fallbackClass() default DefaultNothingFallback.class; // [10]
}

[1] 서킷브레이커의 구분을 위한 유일한 이름입니다.
[2] 서킷을 닫아놓는 시간입니다. 이 시간이 지나면 서킷을 OPEN/CLOSE할지 다시 판단합니다.
[3] 서킷이 닫혀있는 동안 응답실패(critical error) 비율을 지정합니다.
[4] 서킷이 닫혀있는 동안 최소 요청수를 지정합니다. 이 설정값보다 요청수가 적을 때는 서킷이 발동되지 않습니다.
[5] 서킷이 닫혀있는 동안 최대 실패수를 지정합니다. 이 설정값을 넘으면 응답실패 비율과 상관없이 서킷을 발동합니다.
[6] 서킷을 열어놓는 시간입니다. 이 시간이 지나면 서킷을 HALF-OPEN(서킷을 반만 열어놓은 상태)합니다.
[7] 서킷을 반만 열어놓는 시간입니다. 서킷을 OPEN하면 한 번에 CLOSE하지 않고 HALF-OPEN상태에서 일정 시간 체크하며 CLOSE할지를 판단합니다.
[8] 서킷이 반만 열린 상태에서 성공 비율을 지정합니다. 이 설정값을 넘었을 때 서킷이 온전히 닫힙니다.
[9] 서킷브레이커 설정을 임시로 해제하기 위한 설정입니다.
[10] 서킷이 열렸을 때 등록된 fallback응답을 주기 위한 설정입니다.

CircuitBreakerStatus

서킷브레이커 상태는 아래 세 가지가 있습니다.
서킷이 닫혀서 정상 동작하는 CLOSE, 서킷이 열려 fallback처리되는 OPEN, 서킷이 반만 열려 절반의 트래픽만 받게 되는 HALF_OPEN입니다.

public enum CircuitBreakerStatus implements IdentityComparable<CircuitBreakerStatus> {
    CLOSE, HALF_OPEN, OPEN
}

이들의 전이 관계는 아래와 같습니다.
properties

CircuitBreakerData

서킷브레이커에서 사용하는 데이터를 관리하는 클래스입니다.
@CircuitBreaker 어노테이션에 선언된 필드들 외에도 실제 발동 및 해제에 필요한 데이터들이 포함돼 있습니다.

@Getter
@Setter
public class CircuitBreakerData {
    private CircuitBreakerName name;
    private final long closeIntervalSecond;
    private final int closeFailRateThreshold;
    private final int closeMinRequestCountThreshold;
    private final int closeMaxFailCountThreshold;
    private final long openIntervalSecond;
    private final long halfOpenIntervalSecond;
    private final int halfOpenSuccessRateThreshold;
    private final boolean ignore;
    private final Class<?> fallbackClass;

    // [1]
    private int requestCount;
    private int failCount;
    private OffsetDateTime openTime;
    private OffsetDateTime halfOpenTime;
    private OffsetDateTime closeTime;
    private CircuitBreakerStatus status;
    private boolean drain;

    public CircuitBreakerData(CircuitBreaker circuitBreaker) {
        name = circuitBreaker.name();
        closeIntervalSecond = circuitBreaker.closeIntervalSecond();
        closeFailRateThreshold = circuitBreaker.closeFailRateThreshold();
        closeMinRequestCountThreshold = circuitBreaker.closeMinRequestCountThreshold();
        closeMaxFailCountThreshold = circuitBreaker.closeMaxFailCountThreshold();
        openIntervalSecond = circuitBreaker.openIntervalSecond();
        halfOpenIntervalSecond = circuitBreaker.halfOpenIntervalSecond();
        halfOpenSuccessRateThreshold = circuitBreaker.halfOpenSuccessRateThreshold();
        ignore = circuitBreaker.ignore();
        fallbackClass = circuitBreaker.fallbackClass();
        closeTime = DateTimeUtil.now();
        status = CircuitBreakerStatus.CLOSE;
    }

    public void switchDrain() { // [2]
        drain = !drain;
    }

    public void resetTo(CircuitBreakerStatus status) { // [3]
        this.status = status;
        this.requestCount = 0;
        this.failCount = 0;

        switch (status) {
            case CLOSE:
                closeTime = DateTimeUtil.now();
                openTime = null;
                halfOpenTime = null;
                break;
            case OPEN:
                closeTime = null;
                openTime = DateTimeUtil.now();
                halfOpenTime = null;
                break;
            case HALF_OPEN:
                closeTime = null;
                openTime = null;
                halfOpenTime = DateTimeUtil.now();
                break;
        }
    }

    public void increaseRequestCount() {
        requestCount++;
    }

    public void increaseFailCount() {
        failCount++;
    }

    public boolean isOverCloseFailRate() {
        return requestCount > 0 && closeFailRateThreshold <= (failCount / requestCount) * 100;
    }

    public boolean isOverCloseMinRequestCount() {
        return requestCount >= closeMinRequestCountThreshold;
    }

    public boolean isOverCloseMaxFailCount() {
        return failCount >= closeMaxFailCountThreshold;
    }

    public boolean isOverHalfSuccessRate() {
        return requestCount > 0 && halfOpenSuccessRateThreshold <= ((requestCount - failCount) / requestCount) * 100;
    }
}

[1] 여기 놓인 필드들은 서킷브레이커 동작을 제어하기 위해 추가로 정의됐습니다.
[2] 트래픽을 흘려보낼지 말지를 전환하기 위한 메소드입니다.
[3] 서킷브레이커의 상태에 따라 제어 필드들을 초기화하는 메소드입니다.

CircuitBreakerDataHolder

등록된 여러 서킷브레이커를 홀더에 담아서 관리합니다.

public class CircuitBreakerDataHolder {

    private static final CircuitBreakerDataHolder INSTANCE = new CircuitBreakerDataHolder();
    private final Map<CircuitBreakerName, CircuitBreakerData> holder = Maps.newConcurrentMap();

    private CircuitBreakerDataHolder() {
    }

    public static CircuitBreakerDataHolder getInstance() { // [1]
        return INSTANCE;
    }

    public void add(CircuitBreakerData data) {
        if (holder.get(data.getName()) != null) {
            throw new ThiiingException(ErrorCode.CIRCUIT_BREAKER_DUPLICATE_NAME, data.getName());
        }

        holder.put(data.getName(), data); // [2]
    }

    public CircuitBreakerData get(CircuitBreaker circuitBreaker) { // [3]
        return circuitBreaker != null ? holder.get(circuitBreaker.name()) : null;
    }
}

[1] 싱글톤으로 인스턴스를 만듭니다.
[2] add()호출시 홀더에 서킷브레이커 이름을 키로 data를 보관합니다.
[3] 서킷브레이커를 전달하면 홀더에서 해당 CircuitBreakerData를 전달합니다.

CircuitBreakerDataInitializer

서버 구동시 생성되는 빈에 서킷브레이커 어노테이션이 설정됐는지 확인하고 홀더에 등록해주는 작업을 합니다.

@Component
@ConditionalOnProperty(prefix = CIRCUIT_BREAKER_ASPECT_PREFIX, name = ENABLED, havingValue = HAVING_TRUE, matchIfMissing = true) // [1]
@RequiredArgsConstructor
public class CircuitBreakerDataInitializer extends InstantiationAwareBeanPostProcessorAdapter { // [2]
    ...

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        Class<?> clazz = bean.getClass();

        // [3]
        CircuitBreaker circuitBreaker = AnnotationUtils.findAnnotation(clazz, CircuitBreaker.class);
        if (circuitBreaker != null) {
            registerCircuitBreaker(circuitBreaker);
        }

        // [4]
        ReflectionUtils.doWithMethods(bean.getClass(),
                                      method -> registerCircuitBreaker(method.getAnnotation(CircuitBreaker.class)),
                                      method -> Modifier.isPublic(method.getModifiers()) && method.isAnnotationPresent(CircuitBreaker.class));

        return bean;
    }

    private void registerCircuitBreaker(CircuitBreaker circuitBreaker) { // [5]
        CircuitBreakerData data = new CircuitBreakerData(circuitBreaker);
        try {
            CircuitBreakerDataHolder.getInstance().add(data);
        } catch (ThiiingException e) { // [6]
            ...
        }
    }
}

[1] 띠잉 서버는 기능별로 모듈이 분리돼 있습니다. 그리고 각 모듈이 서버로 구동되는 시점에 서킷브레이커를 등록할 수 있도록 하기 위해 서킷브레이커관련 코드들은 공통 라이브러리로 분리돼 있습니다. 따라서 사용측 모듈에서 사용하고 싶지 않은 경우 yaml설정에서 프로퍼티를 false로 등록하도록 하고 있습니다. 사용하고 싶지 않은 빈을 굳이 등록할 필요없기 때문입니다.
[2] 서버를 구동하며 빈이 초기화 된 후, 생성된 빈에 대해 어떤 작업을 하고 싶을 때 ‘InstantiationAwareBeanPostProcessorAdapter’를 상속 받아 구현합니다.
여기서는 초기화된 빈이 서킷브레이커 어노테이션을 사용중인지를 확인하여 홀더에 등록하고 있습니다.
[3] 클래스 레벨에서 서킷브레이커 어노테이션이 설정됐는지 확인하고 홀더에 등록해줍니다.
[4] 메소드 레벨에서 서킷브레이커 어노테이션이 설정됐는지 확인하고 홀더에 등록해줍니다.
[5] 앞서 만들었던 홀더에 서킷브레이커를 추가합니다.
[6] ThiiingException은 띠잉 서버에서 정의된 커스텀 익셉션이며, throw시 서버내 정의된 에러코드를 포함할 수 있습니다.

CircuitBreakerProcessor

서킷브레이커의 동작을 제어하는 프로세서입니다.

@Component
@ConditionalOnProperty(prefix = CIRCUIT_BREAKER_ASPECT_PREFIX, name = ENABLED, havingValue = HAVING_TRUE, matchIfMissing = true)
public class CircuitBreakerProcessor {

    public void beforeProcess(Class<?> clazz, Method method) { // [1]
        CircuitBreakerData data = getData(clazz, method);
        if (data != null) {
            boolean isOver;
            switch (data.getStatus()) {
                case CLOSE: // [2]
                    isOver = isOverIntervalTime(data.getCloseTime(), data.getCloseIntervalSecond()) || isOverFailCount(data);
                    if (isOver) {
                        checkCircuitBreaker(data);
                    }
                    data.increaseRequestCount();
                    break;
                case OPEN: // [3]
                    isOver = isOverIntervalTime(data.getOpenTime(), data.getOpenIntervalSecond());
                    if (isOver) {
                        data.resetTo(HALF_OPEN);
                    }

                    throw new ThiiingException(ErrorCode.CIRCUIT_BREAKER_OPEN);
                case HALF_OPEN: // [4]
                    isOver = isOverIntervalTime(data.getHalfOpenTime(), data.getHalfOpenIntervalSecond());
                    if (isOver) {
                        checkCircuitBreaker(data);
                        throw new ThiiingException(ErrorCode.CIRCUIT_BREAKER_HALF_OPEN);
                    } else {
                        data.switchDrain();
                        if (data.isDrain()) {
                            data.increaseRequestCount();
                        } else {
                            throw new ThiiingException(ErrorCode.CIRCUIT_BREAKER_HALF_OPEN);
                        }
                    }
                    break;
            }
        }
    }

    public void afterThrowingProcess(Class<?> clazz, Method method, Throwable throwable) { 
        CircuitBreakerData data = getData(clazz, method);
        if (data != null && isCriticalException(throwable)) { // [5]
            data.increaseFailCount();
        }
    }

    public Object fallbackOrElseThrow(Class<?> clazz, Method method, Throwable throwable) throws Throwable {
        if (isCircuitBreakerOccurred(throwable)) {
            Map<LogField, Object> log = new HashMap<>(Map.of( /* 로그 데이터 */ ));

            CircuitBreakerData data = getData(clazz, method);
            if (data != null) {
                log.put(circuitBreakerName, data.getName());
                try {
                    Object fallbackResult = ((CircuitBreakerFallback) data.getFallbackClass().getDeclaredConstructor().newInstance()).fallback(); // [6]
                    if (fallbackResult != null) {
                        log.put(circuitBreakerFallback, true);
                        // 에러 로깅

                        return fallbackResult;
                    }
                } catch (Exception ignored) {
                }
            }
            // 에러 로깅
        }

        throw throwable;
    }

    private void checkCircuitBreaker(CircuitBreakerData data) {
        switch (data.getStatus()) {
            case CLOSE:
                if (data.isOverCloseMaxFailCount() || (data.isOverCloseMinRequestCount() && data.isOverCloseFailRate())) {
                    data.resetTo(OPEN);
                } else {
                    data.resetTo(CLOSE);
                }
                break;
            case HALF_OPEN:
                if (data.getRequestCount() == 0) {
                    data.resetTo(HALF_OPEN);
                } else if (data.isOverHalfSuccessRate()) {
                    data.resetTo(CLOSE);
                } else {
                    data.resetTo(OPEN);
                }
                break;
        }
    }

    private boolean isOverIntervalTime(OffsetDateTime time, long interval) {
        return DateTimeUtil.isPlusSecondBeforeNow(time, interval);
    }

    private boolean isOverFailCount(CircuitBreakerData data) {
        return data.getFailCount() >= data.getCloseMaxFailCountThreshold();
    }

    private CircuitBreakerData getData(Class<?> clazz, Method method) {
        CircuitBreakerData data = CircuitBreakerDataHolder.getInstance().get(method.getAnnotation(CircuitBreaker.class));
        if (data != null && !data.isIgnore()) {
            return data;
        }

        data = CircuitBreakerDataHolder.getInstance().get(clazz.getAnnotation(CircuitBreaker.class));
        if (data != null && !data.isIgnore()) {
            IgnoreCircuitBreaker ignore = method.getAnnotation(IgnoreCircuitBreaker.class);

            if (ignore == null || data.getName().isNot(ignore.name())) {
                return data;
            }
        }

        return null;
    }

    private boolean isCriticalException(Throwable throwable) {
        if (throwable instanceof ThiiingException) {
            int code = ((ThiiingException) throwable).getError().getServerResponseCode().code;
            return code <= CRITICAL_ERROR_CODE;
        }

        return true;
    }

    private boolean isCircuitBreakerOccurred(Throwable throwable) {
        if (throwable instanceof ThiiingException) {
            Error error = ((ThiiingException) throwable).getError();
            return error.getServerResponseCode().is(ServerResponseCode.CIRCUIT_BREAKER);
        }

        return false;
    }
}

[1] AOP(CircuitBreakerAspect)에서 ProceedingJoinPoint를 proceed하기 전에 호출하여 서킷 상태별 동작을 제어합니다.
[2] 지정된 시간(interval time)이 지났거나 실패 개수가 지정된 크기 이상이 되면 서킷 상태를 점검합니다.
[3] 서킷이 오픈상태이면 익셉션을 꾸준히 던지며, 지정된 시간이 지나면 서킷을 반만 오픈합니다.
[4] 서킷이 반만 오픈된 상태에서는 절반의 트래픽은 CLOSE상태처럼 동작하고, 나머지 절반의 트래픽은 OPEN상태처럼 동작하게 됩니다.
흘려준 트래픽이 HALF-OPEN설정 기준을 통과하면(정상적으로 응답을 내려주면), 비로소 서킷은 다시 닫히게 됩니다. 반대로 기준에 미치지 못하면 다시 OPEN상태가 됩니다. 이에 대한 상세 로직은 checkCircuitBreaker(..)를 참고하시면 됩니다.
HALF-OPEN상태를 중간단계로 두는 이유는, 몰려드는 트래픽을 완충시키는 역할을 하며 CLOSE와 OPEN으로 전이되는 과정을 자동화할 수 있기 때문입니다.
[5] 응답에 예외가 발생했을 때 실패 개수를 늘려줍니다. 이 때 중요한 것은 앞서도 얘기했었듯이, 우리가 critical error라고 정의한 에러에 대해서 카운팅을 한다는 것입니다.
[6] 서킷브레이커 등록 당시 지정된 fallback이 있으면 이를 응답으로 내려줍니다.

CircuitBreakerFallback

처리 실패에 대한 기본 응답을 @CircuitBreaker마다 정의해줄 수 있습니다.
장애 상황을 유저가 항상 느낄 수 있게 노출하는 것은 사용자 경험을 안좋게 만드는 요인이 될 수 있습니다.
한 화면은 여러 API를 통해 데이터가 모여 그려지는 경우가 많은데 이 때 하나의 장애 포인트가 전체 화면에 영향을 주는 것을 방지하는 것이 좋습니다. 이 때 적절한 기본 응답을 내려줄 수 있다면 유저는 장애 상황을 인지하지 못하고 넘어갈 수도 있습니다. 혹여 인지하더라도 다른 기능을 사용하는 것에는 영향을 주지 않기 때문에 어찌됐든 장애허용에 있어 유연한 대응이라고 생각합니다.

@FunctionalInterface
public interface CircuitBreakerFallback<T> {
    T fallback(); // [1]
}

[1] 앞서 CircuitBreakerProcessor.fallbackOrElseThrow(..)에서 fallback을 가져오는 방식을 통일하기 위해 인터페이스로 추상화합니다.

서킷브레이커에 아무 fallback도 등록되지 않았을 경우를 대비한 기본 fallback을 위해 DefaultNothingFallback을 만들어 둡니다.

public class DefaultNothingFallback implements CircuitBreakerFallback<Object> {

    @Override
    public Object fallback() {
        return null;
    }
}

하나의 사례를 보자면, 유저의 프로필에 노출되는 통계 정보를 가져오는 API에 서킷브레이커가 발동된 경우 아래와 같은 fallback으로 응답합니다.
여기서 클라이언트와 협업이 필요한 부분이 있습니다. ‘서버에서 내려준 -1을 그대로 노출할 것인가’입니다. 일반적으로 아래 통계정보는 결코 -1이 나오면 안되는 값입니다. 따라서 값이 음수로 내려간 경우 숫자를 노출하지 말고 하이픈(-)으로 대체한다든가 하면 음수가 노출되는 안좋은 경험을 회피할 수 있을 겁니다. fallback을 하는 것은 장애상황에서도 유저 경험을 해치는 요소를 최소화하는데 의의가 있다고 생각합니다. 그렇기에 클라이언트 개발자와 협업하여 이런 상황을 충분히 공유하고 대응할 수 있게 가이드하는 것도 중요합니다.

public class ProfileStatFallback implements CircuitBreakerFallback<UserStatEntity> {

    @Override
    public UserStatEntity fallback() {
        return UserStatEntity.builder()
                             .totalReactionCount(-1)
                             .currentReactionCount(-1)
                             .userFollowerCount(-1)
                             .userFollowingCount(-1)
                             .tagFollowingCount(-1)
                             .build();
    }
}

@IgnoreCircuitBreaker

임시로 서킷브레이커 등록을 해제할 수 있도록 ignore어노테이션도 만들어뒀습니다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface IgnoreCircuitBreaker {
    CircuitBreakerName name();
}

CircuitBreakerAspect

서킷브레이커 어노테이션이 붙은 곳들의 로직 흐름을 제어하기 위해서 AOP를 사용합니다.

@Component
@ConditionalOnProperty(prefix = CIRCUIT_BREAKER_ASPECT_PREFIX, name = ENABLED, havingValue = HAVING_TRUE, matchIfMissing = true)
@Aspect
@RequiredArgsConstructor
public class CircuitBreakerAspect {

    private final CircuitBreakerProcessor processor;

    @Around("@annotation(com.woowahan.thiiing.core.defense.CircuitBreaker) || @within(com.woowahan.thiiing.core.defense.CircuitBreaker)") // [1]
    public Object handler(ProceedingJoinPoint pjp) throws Throwable {
        Object result;

        try {
            processor.beforeProcess(getClass(pjp), getMethod(pjp)); // [2]
            result = pjp.proceed(); 
        } catch (Throwable throwable) {
            processor.afterThrowingProcess(getClass(pjp), getMethod(pjp), throwable); // [3]
            result = processor.fallbackOrElseThrow(getClass(pjp), getMethod(pjp), throwable); // [4]
        }

        return result;
    }

    private Class getClass(JoinPoint point) {
        return point.getSignature().getDeclaringType();
    }

    private Method getMethod(JoinPoint point) {
        MethodSignature signature = (MethodSignature) point.getSignature();
        return signature.getMethod();
    }
}

[1] 서킷브레이커 어노테이션이 붙은 클래스와 메소드에 Around를 걸어줍니다.
[2] proceed(..) 처리 전 서킷브레이커를 체크합니다.
[3] 응답 실패가 크리티컬하다면 실패 개수를 카운팅합니다.
[4] 정의된 fallback으로 응답을 대체합니다.

사용예

서킷브레이커 발동이 예상될만한 포인트에 설정과 함께 어노테이션을 지정할 수 있습니다.

@CircuitBreaker(name = CircuitBreakerName.PROFILE_STAT, fallbackClass = ProfileStatFallback.class)
public UserStatEntity getUserStat(long userId) {
    ...
}

DynamicRecorder

DynamicRecorder는 트래픽량에 따라 비동기 처리를 즉시(Immediate) 처리 혹은 일괄(Buffer) 처리 방식으로 자동 전환하는 커스텀 어노테이션입니다.
유저가 콘텐츠에 좋아요나 이모지 액션과 같은 리액션했을 때를 예로 들어보겠습니다. 해당 API는 리액션 정보를 DB에 기록하고 리액션받은 유저의 리액션 통계 데이터도 갱신하게 됩니다. 리액션을 한 유저의 입장에서는 리액션을 당한 유저의 통계가 갱신되는 것 까지는 관심이 없으므로 자신의 리액션 처리 여부만 완료되면 빠르게 응답을 주면 됩니다. 그리고 통계정보는 이벤트를 발생시켜 비동기로 처리하게 됩니다. 이와 같이 하나의 액션에 대해 곁가지로 생기는 추가 작업이 동기처리될 필요가 없는 경우 API 응답속도를 높이는데에도 비동기 처리가 유용하게 사용됩니다. 이러한 비동기 처리는 평상시 트래픽이 별로 없을 때는 이벤트를 받아 레코더(저희는 ThreadPool을 이용해 로컬 메모리에 임시로 이벤트를 모으는 단위를 레코더라고 부르고 있습니다.)를 바로 처리를 해줄 수 있고, 트래픽이 몰리는 상황에서는 과도한 즉시 처리가 과부하를 야기할 수 있으므로 일정량 레코더로 모았다가 일괄 처리하는 방식을 가질 수 있습니다.

커스텀 어노테이션의 구현은 앞서 CircuitBreaker를 조금 변형하여 풀었습니다.

@DynamicRecorder

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DynamicRecorder {

    DynamicRecorderName name();

    long intervalSecond() default 60L;

    int threshold() default 1000; // [1]

    boolean ignore() default false;
}

[1] threshold값은 지정된 시간(intervalSecond)동안 처리된 양에 대한 임계값입니다.

DynamicRecorderData

@Getter
public class DynamicRecorderData {

    private final DynamicRecorderName name;
    private final long intervalSecond;
    private final int threshold;
    private final boolean ignore;

    private int requestCount;
    private OffsetDateTime startTime;
    private DynamicRecorderStatus status;

    public DynamicRecorderData(DynamicRecorder dynamicRecorder) {
        ...
        status = DynamicRecorderStatus.IMMEDIATE;
    }

    public void reset() {
        ...
        this.status = isOverThreshold() ? BUFFER : IMMEDIATE; // [1]
    }

    ...
}

[1] 처리량이 threshold값보다 커지면 BUFFER 방식으로 전환합니다.

DynamicRecorderProcessor

@Component
@RequiredArgsConstructor
public class DynamicRecorderProcessor {

    private final ApplicationContext context;

    public void beforeProcess(Method method) {
        DynamicRecorderData data = getData(method);
        if (data != null) {
            if (data.isOver()) {
                data.reset(); 
            }
            data.increaseRequestCount();
        }
    }

    public void afterProcess(Method method) { // [1]
        DynamicRecorderData data = getData(method);
        if (data != null && data.getStatus().is(IMMEDIATE)) {
            context.getBean(data.getName().getBeanType()).callback(); // [2]
        }
    }

    ...
}

[1] AOP에 의한 후처리 작업입니다.
[2] 즉시처리 방식이면 레코더에 보관된 이벤트를 즉시 처리하게 됩니다.


TaskOccupy

유저의 API요청이 없어도 일정 시간마다 주기적으로 처리해야 할 작업들이 있습니다.
예를들어 인증토큰을 정리한다든지 탈퇴 프로세스를 단계적으로 진행하다든지의 작업들이 여기에 해당합니다.
이와 같은 스케줄링작업들은 현재 띠잉 서버내 40여개가 넘게 등록되어 처리되고 있습니다.
그런데 이 스케줄러 서버를 증설하게 되면 어떤 사이드이펙트가 발생할까요? 바로 이미 동작하고 있는 스케줄러가 중복으로 발생할 수 있는 문제가 생깁니다. 이러한 타이밍이슈를 잡기 위해 만들어진 커스텀 어노테이션이 @TaskOccupy입니다.
이 글에서는 코드까지 보여드리지는 않겠지만, 간략하게 말씀드리면 스케줄러의 task 점유(occupy)를 하나만 가질 수 있도록 하는 어노테이션입니다. 이 어노테이션은 저희팀 박상윤님이 작업해주셔서 그 이후로 만들어지는 스케줄러들에서도 매우 유용하게 사용되고 있습니다.


마무리

지금까지 띠잉 서버에서 만들어 쓰고 있는 어노테이션들에 대해 몇 가지 사례를 꼽아 설명드렸는데요.
글 서두에서도 말씀드렸듯이 커스텀 어노테이션은 의도와 목적을 명확히 하여 구성원간 공감대를 이룬 후 추가하는 것이 좋다고 생각합니다. 그런 과정을 거쳐 만들어진 어노테이션이야 말로 시의적절한게 아닐까요?

감사합니다.


광고

띠잉앱을 설치해서 사용해보세요 :)
IOS
Android

띠잉 공식 SNS도 운영하고 있어요.
Instagram
Facebook