[프로젝트] 서비스 자체 에러 모니터링 시스템 구축

조찬영·2023년 11월 9일
0

들어가기 앞서

개발의 업(development process)을 절반 잘라서 나눈다고 한다면,
개발(development)운영(Operations) 2가지 키워드로 분류할 수 있을 것 같습니다.

그리고 이 2가지의 차이를 결정짓는 큰 요소는 바로 유저(user)의 개입 여부인 것 같습니다.

개발 단계에서는 유저의 행동에 따른 결과를 예측한다면,
운영 단계에서는 결과에 따른 책임을 수행하는 것으로 저는 저만의 개념을
확립(Re - Creation)했는데요.

(존경하는 개발자 선배님들께서 여러 힌트와 조언을 가감없이 해주신 덕분)

소중한 지식을 체득시키기 위한 가장 효율적인 방법은 행동이라고 생각하는지라
제 프로젝트에서 운영적인 부분을 고려하지 않을 수가 없었던 것 같습니다.

그래서 현재 저의 스킬과 역량 그리고 프로젝트의 규모를 고려하여 수행하기로 마음 먹은 타협점이 바로 오늘 알아볼 서비스 자체 에러 모니터링 시스템 구축이 될 것 같습니다.

사실 이 전의 [Rate limit 포스팅] 에서 어느정도 언급되고 작성되었던 부분이 있지만
모니터링이라는 관점에서는 생략된 부분들이 있었습니다.
그래서 모니터링이라는 관점에 맞춘 정리를 기록하기로 결정했습니다.

그래서 어떻게 구현할건데?

(우선 실제 서비스를 운영하는 것은 아니였기 때문에 스스로 서비스에 대한 가정을 세우고 이에 대한 방지책을 마련해야 했습니다.)

기본적으로 서비스 내에서는 이미 예측 가능한 범위 내의 예외 처리(Exception Handling)가 이루어 지고 있기 때문에

과정속에서 신경쓰였던 부분은 역시나 유저의 변수(something on variables)였습니다.

(참고) Error code Enum 클래스를 통한 Exception Handling 일부

@Getter
@AllArgsConstructor
public enum ErrorCode {
  
    //translate
    TRANSLATION_PARSE_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,
        "error occurred while translating the text."),
    TRANSLATION_BUILD_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,
        "translate build processing failed"),
    TRANSLATION_PARSE_NO_TEXT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR,
        "cannot parse the text for translate"),

    // smtp email
    EXPIRED_VERIFICATION(HttpStatus.UNAUTHORIZED, "expired email-verification"),
    INVALID_SECURITY_CODE(HttpStatus.UNAUTHORIZED, "invalid security code"),

    // jwt
    INVALID_TOKEN_FORMED(HttpStatus.UNAUTHORIZED, "not support to this token`s formed"),
    EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "token is expired"),
    INVALID_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED, "token signature is invalid"),
    NOT_BEARER_TOKEN(HttpStatus.UNAUTHORIZED, "token is not start with BEARER or null"),

    //application
    DUPLICATED(HttpStatus.CONFLICT, "duplicated code"),
    INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "invalid password"),
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "user not found"),
    NOTHING_FOUND(HttpStatus.NOT_FOUND,"nothing found"),
    ...(생략)

유저의 변수적인 부분은 다르게 말한다면 예외가 처리되지 않은 것들의 집합체라고 정의할 수 있습니다.

그렇다면 그런 집합체들을 하나의 공통 분모로 묶어서 일괄적으로 관리해준다면 어느 정도 이 부분에서 탈피할 수도 있지 않을까 생각했습니다.


STEP 1. 에러 정의

저는 그 공통 분모를 정의되지 않은 Http status 500 에러 발생으로 설정하였고 이에 따른 Handling 작업을 추가해주었습니다.

Error Code

public enum ErrorCode {
    //internal server error
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "status code 500 is occurred"),

    DATABASE_ACCESS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "database error"),

    TELEGRAM_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,
        "error occurred while sending a message on telegram"),

GlobalExceptionHandler

RestControllerAdvice
@RequiredArgsConstructor
@Slf4j
/**
 * @ExceptionHandling
 * @apiNote Internal_server_Error (HttpStatus: 500) => return ResponseError and send telegram message.
 **/
public class GlobalExceptionHandler {

    private final NotificationService notificationService;

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ResponseError> globalExceptionHandler(Exception e) {
        notificationService.sendMessage(ErrorCode.INTERNAL_SERVER_ERROR.getMessage());
        log.error("[InternalServerError Occurs] error: {}", e.getMessage());
        return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus())
            .body(ResponseError.response(ErrorCode.INTERNAL_SERVER_ERROR));
    }
    
    ...(생략)

스킬적으로 어려운 부분은 없지만 무엇을 어떻게 정의할 것 인가에 대한 (조금 거창한 표현으로는 본질적인)고민을 해야했기 때문에 꽤나 어려움이 있었습니다.

조금 더 첨언한다면 복사+붙여넣기 또는 chat gpt로는 해결할 수 없는 부분이 아닐까 생각들었습니다.

STEP 02. 에러 파악

에러가 발생했다는 것은 시스템에서 이를 인식했다는 것입니다.
반대로 말하면 시스템에서만 이를 인식했다는 것입니다.

에러가 발생한다면 개발자가 이에 대해 빠르게 대응할 수 있어야 하기에
그 중간다리 역할을 수행해야 하는 단계는 에러 파악이 될 것 같습니다.

저는 에러 파악을 위해 Third - party를 활용한 Notification service를 구현했습니다.

추상화

public interface NotificationService {

    void sendMessage(String message);
}

구현체인 telegram

@Slf4j
@Service
@RequiredArgsConstructor
public class TelegramService implements NotificationService {

    private final TelegramProperties properties;
    private final Environment environment;
    private final RestTemplate restTemplate;

    @Override
    public void sendMessage(String message) {
        message = environment.getProperty("spring.config.activate.on-profile") + message;

        try {
            sendTelegram(properties, message);
            log.info(message);

        } catch (Exception e) {
            throw new TelegramException(ErrorCode.NOTIFICATION_SEND_ERROR);
        }
    }

    private void sendTelegram(TelegramProperties properties, String message) {
        try {
            final String url = properties.getUrl();
            final String chatId = properties.getChatId();
            final HttpHeaders headers = new HttpHeaders();

            HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();

            CloseableHttpClient httpClient = getHttpClient();
            requestFactory.setHttpClient(httpClient);

            headers.set("Accept", MediaType.APPLICATION_JSON_VALUE);
            UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url)
                .queryParam("chat_id", chatId)
                .queryParam("parse_mode", "HTML")
                .queryParam("disable_web_page_preview", "true")
                .queryParam("text", message);
            final HttpEntity<?> entity = new HttpEntity<>(headers);

            restTemplate.exchange(
                builder.build()
                    .encode()
                    .toUri(), HttpMethod.GET, entity, String.class);

        } catch (Exception e) {
            throw new TelegramException(ErrorCode.NOTIFICATION_SEND_ERROR);
        }
    }

    private CloseableHttpClient getHttpClient() {
        CloseableHttpClient httpClient = HttpClients.custom()
            .setSSLHostnameVerifier(
                new NoopHostnameVerifier())
            .build();
        return httpClient;
    }

}

이제 500 에러가 발생한다면 지정된 메신저로 알림이 울리게 됩니다.


에러 알림 캡쳐본

(에러 메시지는 보완이 필요해 보입니다.)

위 캡쳐본과 같이 메신저가 울리게 될 경우 개발자는 이를 확인하고 그에 맞는 대응을 할 수 있을거라 기대해봅니다 :)

하지만 아직 끝이 아니다

요리로 치면 불과 이제 기본적인 반죽이 끝난 상태입니다.
이를 기반으로 더 솔리드한 시스템을 구축해야 합니다.

500 에러 뿐만이 아닌 수많은 잠재적인 에러들이 발생할 여지가 아직 많이 존재하기 떄문이죠.

일단 우선적으로 저는 지난 포스팅을 통해 rate limit악의적인 로그인 시도에 대한 부분들을 구현했습니다.

하지만 나아가야 할 길이 아직 수도 없이 남아 있습니다.
그렇지만 한 발을 내딛었다는 것에 일단은 충분히 만족해두고 다음 스텝을 밟으려고 준비중입니다.


더 중요한 것을 놓치지 말자

사실 구현을 했다라는것에서 오는 만족감보다는 후에 오는 꺠달음에 대한 만족감이 더 크다고 느꼈는데요.

이를 한 줄로 요약한다면 개발에서 유저란 무슨 의미이며 어떤 가치가 있는지를 느꼈던 것 같습니다.

개발을 하면서 한 줄의 코드에서 오는 side - effect 를 고민할 때가 참 많은데
이를 넘어서 유저에게 도달하는 side - effect를 고려한 코드
는 잘 생각하지 않게 되는 것 같습니다.

즉, 2가지 키워드(개발, 운영)로 나누어진 개발의 업을 하나로 묶어낼 수 있는 해답은 유저가 될 수 있다는 것이고 이를 깨닫고 그에 따른 역할을 꾸준히 수행하다보면 어느새 훌륭한 개발자로 거듭날 수 있지 않을까하는 기대를 하게 된 것 같습니다.😃

긴 글 읽어주셔서 감사합니다 :)

profile
보안/응용 소프트웨어 개발자

0개의 댓글