모니터링은 마틴 파울러처럼: Domain-Oriented Observability 도입기

모니터링은 마틴 파울러처럼: Domain-Oriented Observability 도입기

빠르게 변하는 환경에서도 모니터링이 코드를 더럽히지 않게 만들기

Youngmin Shin — 신영민 @holyachon

1. 도입 배경

모니터링은 개발자에게 중요한 요소 중 하나입니다. 대부분의 엔지니어는 크게 두가지 항목에 대해 모니터링을 고려합니다. 먼저 CPU, Memory Utilization 과 같이 시스템의 인프라와 관련된 메트릭들을 수집하고, 이에 대한 알럿을 설정하여 문제를 인지합니다. 또한 개발하는 Application 내에서 발생할 수 있는 다양한 로그나 메트릭을 수집하고, 운영 과정에서 참고할 수 있는 데이터로 만들거나 이 역시 알럿을 설정하여 문제를 파악하는 데에도 쓰입니다. 이번 글에서는 두번째 케이스와 같이 Application에서 로그나 메트릭을 더 잘 남길 수 있는 방법에 대해 알아보려고 합니다.
과거에 에어브릿지 비용연동 시스템을 개선하면서 운영상 필요한 메트릭들을 추가하게 되었습니다. 비용연동 시스템은 매체로부터 API를 통해 비용 관련 데이터를 가져와 고객에게 제공하는데, 다양한 외부 소스들과 연동되어 있어 언제든지 에러가 발생할 수 있기에 이를 적절히 핸들링하고 모니터링하는것이 필요합니다. 또한 에러에 대한 메트릭들은 매우 중요하기 때문에 이를 남기는 코드가 잘 호출되었는지도 테스트 코드를 통해 확인하고 싶은 니즈가 있었습니다. 이후 예시를 통해 자세히 살펴보겠지만, 처음 메트릭 로깅을 추가할 당시 주요 비즈니스 로직에 대한 테스트코드에 다양한 로깅 함수 호출에 관련된 assertion 이 포함되어 있는 것이 썩 좋다고 생각되진 않았으나, 테스트를 통해 안정성을 얻는 것이 더 중요하므로 이정도는 감수하겠다는 판단을 하고 넘어갔었습니다.
이후 PR 리뷰 과정에서 같은 팀 병훈님께서 Domain-Oriented Observability (이하 DOO) 라는 개념을 소개하며 도입을 제안하셨습니다. 이름은 생소했으나 해당 아티클을 모두 읽고 나서 기존에 모니터링 관련 코드를 작성하며 느꼈던 아쉬움들을 해결할 수 있는 솔루션임을 알게 되었습니다.
그럼 지금부터 DOO를 도입하여 비즈니스 로직을 방해하지 않으면서, 테스트 하기 좋은 코드로도 개선한 경험에 대해 같이 살펴보도록 하겠습니다.

2. 문제 인식

다음은 비용 연동 시스템 코드 일부를 각색한 예시 코드입니다.
class DummyCostDataFetcher: ... def fetch(self, event) -> Result: try: self._processor.validate_token(...) result = self._processor.process(event) self._remote_storage.save( result=result, ) ... except InvalidTokenException: return Result(status=ERROR_AUTH) except Exception as e: ...
Python
복사
예시 코드는 다음과 같은 작업을 수행합니다.
1.
토큰을 검증하고
2.
이벤트를 처리한 후
3.
처리 결과를 원격 저장소에 저장하며
4.
그 과정에서 Exception 이 발생할 경우 적절히 에러를 처리합니다
이 코드에 아래와 같이 모니터링을 추가하는 요구사항이 들어왔다고 가정하겠습니다.
1.
처리 결과의 사이즈를 DummySize 라는 이름의 메트릭으로 저장해주세요.
2.
에러가 발생했을 경우 에러 타입에 따라 APM과 로깅 시스템에 에러를 로깅하고, 에러와 관련된 메트릭을 남겨주세요.
3.
메트릭이나 로그를 남기는 destination 은 여러 곳입니다. (ex. 한번의 로그를 남길 때, 두곳 이상에 중복해서 남겨야 함)
이를 반영한 최초의 코드는 다음과 같습니다. (추가된 부분은 주석으로 표시)
class DummyCostDataFetcher: def __init__( self, ... metrics_logger: MetricsLogger, logger: Logger, ): ... self._metrics_logger = metrics_logger self._logger = logger def fetch(self, event) -> Result: try: self._processor.validate_token(...) result = self._processor.process(event) self._remote_storage.save( result=result, ) set_metric("DummySize", result.size) # 도메인 관련 메트릭 추가 self._metrics_logger.put_metric( "DummySize", result.size, ) # 도메인 관련 메트릭 추가 ... except InvalidTokenException as e: record_exception() # Exception 로깅 추가 self._logger.warning(exception) # Exception 로깅 추가 self._metrics_logger.put_metric("TokenError", 1) # 에러 메트릭 추가 set_metric("TokenError", 1) # 에러 메트릭 추가 return Result(status=ERROR_AUTH) except Exception as e: record_exception() # Exception 로깅 추가 self._logger.error(exception) # Exception 로깅 추가 self._metrics_logger.put_metric("CriticalError", 1) # 에러 메트릭 추가 set_metric("CriticalError", 1) # 에러 메트릭 추가 ...
Python
복사
조금 극단적인 예시를 작성해보았는데, code smell 이 어느정도 느껴지는 코드를 같이 살펴보겠습니다.
1.
set_metric, metrics_logger.put_metric 등을 이용해 주어진 요구사항에 맞게 메트릭을 저장했습니다.
2.
에러가 발생할 경우 record_exception(), logger.warning 등을 호출하여 exception 관련 정보들을 각기 다른 destination에 기록합니다.
앞서 말씀드렸듯이 메트릭, 로그가 잘 기록되고 있는지를 보장하는것은 중요하므로, InvalidTokenException 이 raise 되는 상황에서 metric이 잘 남고 있는지를 검증하는 테스트 코드를 예시로 작성해보겠습니다.
@patch("... set_metric") @patch("... record_exception") def test_fetch_cost_data_with_invalid_token_exception( mock_record_exception, mock_set_metric, ): # given given_exception = InvalidTokenException(...) mock_processor = Mock(validate_token=Mock(side_effect=given_exception)) mock_logger = MagicMock(warning=Mock(...)) mock_metrics_logger = MagicMock(...) # when fetcher = DummyCostDataFetcher( ..., processor=mock_processor, metrics_logger=mock_metrics_logger, logger=mock_logger, ) result = fetcher.fetch(event=...) # then assert result == Result(status=ERROR_AUTH) mock_record_exception.assert_called_once() mock_set_metric.assert_called_once_with("TokenError", 1) mock_logger.warning.assert_called_once_with(given_exception) mock_metrics_logger.put_metric.assert_called_once_with("TokenError", 1)
Python
복사
위 코드에서 로깅을 위해 전역 함수인 set_metricrecord_exception 을 호출했지만, python에서 제공하는 patchMock 등을 사용해서 이에 대한 assertion 은 충분히 가능합니다. 다만 이후 코드에 변경사항이 생기게 되면, 그에 대한 assertion 이 추가될 때 마다 테스트 작성을 위한 코드가 꽤나 verbose 해질 것을 예상할 수 있습니다.
문제점을 정리해보면 다음과 같습니다.
1.
주요 비즈니스 로직 사이에 모니터링을 위한 코드가 포함되어 있어 코드의 가독성이 떨어집니다.
a.
또한 어떻게 추상화 하냐에 따라 달라질 수 있으나, 예시 기준으로는 비즈니스 로직이 아니라 로깅을 위한 코드가 단순 라인 수 기준으로도 50% 넘게 존재합니다.
2.
메트릭 로깅을 위한 비슷한 코드가 중복해서 등장합니다.
3.
메트릭이 잘 남는지 테스트하려면 테스트코드에서 장황한 setUp과 assertion 이 필요합니다.

3. 리팩토링을 통한 코드 개선

이들 중 먼저 두번째 문제를 해결하기 위해 중복되는 코드 중 메트릭을 남기는 부분만 간단히 함수로 분리해보겠습니다.
class DummyCostDataFetcher: ... def fetch(self, event) -> Result: try: self._processor.validate_token(...) result = self._processor.process(event) self._remote_storage.save( result=result, ) self._log_metric(metric_name="DummySize", metric_value=result.size) ... except InvalidTokenException as e: record_exception() self._logger.warning(exception) self._log_metric(metric_name="TokenError", metric_value=1) return Result(status=ERROR_AUTH) except Exception as e: record_exception() self._logger.error(exception) self._log_metric(metric_name="CriticalError", metric_value=1) ... def _log_metric(self, metric_name: str, metric_value: int): self._metrics_logger.put_metric(metric_name, metric_value) set_metric(metric_name, metric_value)
Python
복사
중복 코드는 조금 줄어들었으나, 기계적으로 함수를 분리한 것이므로 결과물을 살펴보면 여전히 비즈니스 로직을 파악하기엔 아쉬움이 있습니다. 이번엔 각 로깅 코드들을 조금 더 구체적인 함수들로 나눠보겠습니다.
class DummyCostDataFetcher: ... def fetch(self, event) -> Result: try: self._processor.validate_token(...) result = self._processor.process(event) self._remote_storage.save( result=result, ) self._instrument_save_succeeded(size=result.size) ... except InvalidTokenException as e: self._instrument_validate_token_failed(exception=e) return Result(status=ERROR_AUTH) except Exception as e: self._instrument_unknown_error_occurred(exception=e) ... def _instrument_save_succeeded(self, size: int): self._log_metric(metric_name="DummySize", metric_value=size) def _instrument_validate_token_failed(self, exception: Exception): record_exception() self._logger.warning(exception) self._log_metric(metric_name="TokenError", metric_value=1) def _instrument_unknown_error_occurred(self, exception: Exception): record_exception() self._logger.error(exception) self._log_metric(metric_name="CriticalError", metric_value=1) def _log_metric(self, metric_name: str, metric_value: int): self._metrics_logger.put_metric(metric_name, metric_value) set_metric(metric_name, metric_value)
Python
복사
이번 개선도 단순히 로깅 관련 코드를 별도 함수로 분리한 것에 지나지 않으므로 로깅 코드가 사라진 것은 아니지만, 적어도 fetch 메소드만 보았을 때 기존보다는 비즈니스 로직을 파악하기에 더 좋은 개선된 코드가 되었습니다.
이 상황에서, 결과적으로 분리된 여러 함수들은 과연 CostDataFetcher 의 관심사라고 볼 수 있을까요? CostDataFetcher 의 이름으로 미루어 볼 때, 이 클래스의 관심사는 비용 데이터를 가져오는것과 관련된 행위로 한정해야 자연스러울 것 같습니다. 따라서 각 관심사에 맞게 분리한 함수들을 별도 클래스로 옮겨보겠습니다.
class DummyCostDataFetcher: def __init__( self, ... instrumentation: DummyCostDataFetcherInstrumentation, ): ... self._instrumentation = instrumentation def fetch(self, event) -> Result: try: self._processor.validate_token(...) result = self._processor.process(event) self._remote_storage.save( result=result, ) self._instrumentation.save_succeeded(size=result.size) ... except InvalidTokenException as e: self._instrumentation.validate_token_failed(exception=exception) return Result(status=ERROR_AUTH) except Exception as e: self._instrumentation.unknown_error_occurred(exception=exception) ... class DummyCostDataFetcherInstrumentation: def __init__( self, metrics_logger: MetricsLogger, logger: Logger, ): self._metrics_logger = metrics_logger self._logger = logger def save_succeeded(self, size: int): self._log_metric(metric_name="DummySize", metric_value=size) def validate_token_failed(self, exception: Exception): record_exception() self._logger.warning(exception) self._log_metric(metric_name="TokenError", metric_value=1) def unknown_error_occurred(self, exception: Exception): record_exception() self._logger.error(exception) self._log_metric(metric_name="CriticalError", metric_value=1) def _log_metric(self, metric_name: str, metric_value: int): self._metrics_logger.put_metric(metric_name, metric_value) set_metric(metric_name, metric_value)
Python
복사
이렇게 분리한다면, CostDataFetcher 는 비용 데이터를 가져오는것에만 집중할 수 있고, 새로 추가된 CostDataFetcherInstrumentation 은 그 과정에서 필요한 로깅을 수행하는 책임을 가지게 됩니다. 또한 여기서 Instrumentation 의 구현체와 Interface를 분리해보겠습니다.
class DummyCostDataFetcher: def __init__( self, ... instrumentation: CostDataFetcherInstrumentation, ): ... def fetch(self, event) -> Result: ... class CostDataFetcherInstrumentation(metaclass=meta.ABCMeta) @abstractmethod def save_succeeded(self, size: int): pass @abstractmethod def validate_token_failed(self, exception: Exception): pass @abstractmethod def unknown_error_occurred(self, exception: Exception): pass class DummyCostDataFetcherInstrumentation(CostDataFetcherInstrumentation): ... def save_succeeded(self, size: int): self._log_metric(metric_name="DummySize", metric_value=size) def validate_token_failed(self, exception: Exception): record_exception() self._logger.warning(exception) self._log_metric(metric_name="TokenError", metric_value=1) def unknown_error_occurred(self, exception: Exception): record_exception() self._logger.error(exception) self._log_metric(metric_name="CriticalError", metric_value=1) def _log_metric(self, metric_name: str, metric_value: int): self._metrics_logger.put_metric(metric_name, metric_value) set_metric(metric_name, metric_value)
Python
복사
이렇게 분리하게 되면 만약 kafka 와 같이 또다른 destination 에 정보를 전송하는 요구사항이 생기더라도, 기존 도메인 로직을 건드리지 않고 CostDataFetcherInstrumentation의 새 구현체를 만들어 주입해주기만 하면 될 것입니다.
개선된 코드에 대해 테스트를 다시 작성한다면 어떻게 달라질까요?
def test_fetch_cost_data_with_invalid_token_exception(): # given given_exception = InvalidTokenException(...) mock_processor = mock.Mock(validate_token=mock.Mock(side_effect=given_exception)) mock_instrumentation = mock.NonCallableMock(spec=CostDataFetcherInstrumentation) # when fetcher = DummyCostDataFetcher( ..., processor=mock_processor, instrumentation=mock_instrumentation, ) result = fetcher.fetch(event=...) # then assert result == Result(status=ERROR_AUTH) mock_instrumentation.validate_token_failed.assert_called_once_with(exception=given_exception)
Python
복사
CostDataFetcherInstrumentation 를 도입한 덕분에 기존 테스트에 있던 장황한 setUp이나 assertion 이 사라지고, instrumentation 에 대한 메소드 호출 여부만 확인함으로써 CostDataFetcher 입장에서는 모니터링을 보장할 수 있게 되었습니다.
앞서 이야기한 문제점들을 하나씩 살펴보면 자연스럽게 해결되었습니다.
1.
주요 비즈니스 로직 사이에 모니터링을 위한 코드가 포함되어 있어 코드의 가독성이 떨어집니다.
→ 기존 모니터링을 위한 코드를 CostDataFetcherInstrumentation의 메소드를 호출하는것으로 대체할 수 있어 가독성을 높였습니다.
2.
메트릭 로깅을 위한 비슷한 코드가 중복해서 등장합니다.
→ 최초 문제였던 CostDataFetcher 내에 반복된 코드는 제거되었으며, CostDataFetcherInstrumentation 내에 남은 코드도 충분히 개선할 수 있습니다.
3.
메트릭이 잘 남는지 테스트하려면 테스트코드에서 장황한 setUp과 assertion 이 필요합니다.
→ 마지막 테스트 코드에서 볼 수 있듯이 간결한 테스트가 가능해졌습니다.

4. 개념 정리

DOO를 소개한 마틴파울러의 원문에서는 위 예시에서 최종적으로 작성한 CostDataFetcherInstrumentation 를 Domain Probe 의 예로 정의합니다.
DiscountInstrumentation is an example of a pattern I call Domain Probe. A Domain Probe presents a high-level instrumentation API that is oriented around domain semantics, encapsulating the low-level instrumentation plumbing required to achieve Domain-Oriented Observability. This enables us to add observability to domain logic while still talking in the language of the domain, avoiding the distracting details of the instrumentation technology. In our preceding example, our ShoppingCart implemented observability by reporting Domain Observations—discount codes being applied and discount code lookups failing—to the DiscountInstrumentation probe rather than working directly in the technical domain of writing log entries or tracking analytics events. This might seem a subtle distinction, but keeping domain code focused on the domain pays rich dividends in terms of keeping a codebase readable, maintainable, and extensible.
이를 요약하면 다음과 같습니다.
Domain Probe는 도메인을 중심으로 하는 high level API를 제공하며, Instrumentation 과 관련된 구체적인(low level) 내용을 캡슐화합니다.
미묘한 차이로 보이지만 이를 통해 비즈니스 로직을 다루는 코드는 도메인 로직에 더 집중할 수 있게 하고 가독성, 유지 관리 및 확장성 측면에서 많은 이점을 얻게 됩니다.
Domain Probe에는 비즈니스 로직이 포함되지 않고, Instrumentation 에만 집중합니다.
Domain Oriented Observability, Domain Probe 모두 생소한 용어지만 예시를 토대로 생각해본다면 그렇게 어려운 방법론은 아닙니다. 결국 어떠한 도메인 로직에 observability 를 추가할 때, 도메인 로직은 이를 가능한 모르게 하고 observability 에 대한 구체적인 구현을 책임지는 별도의 객체를 도입하는 것이라 볼 수 있습니다.

5. 추가적인 적용 예시

Golang case

에어브릿지의 주력 언어 중 하나인 Golang에서의 예시도 보여드리겠습니다.
비즈니스 로직과 모니터링을 위한 코드가 혼재된 다음과 같은 코드를, DummyInstrumentation 이름의 Domain Probe 를 도입하여 개선했습니다.
Before
After
또한 같이 살펴본 Domain Probe 패턴 외에도 원문에서는 Event Based 시스템이라면 고려해볼 만 한 Event-Based Observability 를 소개하고 있습니다. 이는 Probe 대신 Announcer 를 활용하여 이벤트를 발행하는 방식으로 모니터링을 수행합니다.

6. 적용 과정에서 느낀 점

어려웠던 점

사실 개념 자체는 그렇게 어렵지 않기에 기술적으로 특별히 어려운 점은 없었습니다. 다만 Domain Probe 객체에 추가할 메소드들을 네이밍 할 때, 도메인 객체에서 호출하기에 적절한 이름들을 짓는 것이 처음엔 꽤나 어색했습니다. 고민 없이 이름을 짓다보면 구현과 관련된 구체적인 사항이 노출되거나, 도메인적으로 의미를 가지지 않는 이름이 될 수 있기 때문입니다. 작명은 원래도 어려운 작업중 하나인 만큼 기존 코드들에 DOO를 도입해보면서 이와 익숙해지는 것이 극복 방법이라 생각합니다.

좋았던 점

다양한 destination 에 로그를 남기기 위해서 도메인 코드를 수정할 필요가 없어졌습니다. 기존에는 도메인 로직을 수정해야 하므로 이 과정에서 실수가 발생할 여지가 있었고, 로그가 추가될 때 마다 기존 test case들에 추가 assertion에 대한 반복적인 작업이 필요했으나 이제는 Domain Probe 구현체를 수정함으로써 해결할 수 있게 되었습니다.
예를 들어 과거에 사용하던 APM을 교체했던 적이 있었는데 이때 모니터링 코드가 흩어져 있었기 때문에 방대한 코드를 하나씩 읽어가며 수정하는데 시간이 많이 소요되었었습니다. 만약 DOO가 적용되어 있었다면 두개의 APM에 데이터를 남기는 것도, APM을 교체하는것도 적은 노력으로 빠르게 해결할 수 있었을 것이라 생각합니다.
모니터링의 목적을 Domain Probe 함수 이름을 통해 짧은 문장으로 설명할 수 있어, 다른 사람이 이를 이해하는 데 도움을 주었습니다.
아래와 같은 코드를 예로 들면, 이벤트를 저장하면서 관련 기록을 남기는구나EventIssued 라는 네이밍 덕분에 바로 알 수 있으며 부가적으로 EventIssue 가 중요한 행위임을 같이 알 수 있게 됩니다.
// 비즈니스 로직 eventRepo.Issue(event) inst.EventIssued(event) ... func (inst i) EventIssued(event Event) { i.AddAttribute("event_uuid", event.UUID.String()) i.AddAttribute("user_id", event.UserID) ... }
Go
복사
DOO를 적용하면서 domain layer에 로깅이라는 명분으로 infra layer가 침투하던 코드들을 자연스럽게 리팩토링하게 되었고, 결과적으로 각 코드를 적절한 layer에 위치시키도록 개선할 수 있었습니다.
예시에서도 보았듯이 도메인 로직에 대한 테스트 코드를 작성할 때 도메인 로직 관심사 밖 코드들을 신경쓸 필요가 없어졌습니다. 이 덕분에 테스트 작성에 대한 부담을 줄여 개발자가 더 적극적으로 다양한 케이스들에 대한 테스트를 추가할 수 있는 환경을 만든다는 점에서 의미가 크다고 생각합니다.

7. 마치며

지금까지 Domain-Oriented Observability 란 무엇이고, 어떤 장점들을 가지는지를 에어브릿지에 도입한 경험을 통해 함께 살펴보았습니다. 조금만 시간을 내어 한번 잘 적용해둔다면 빠르게 변화하는 환경 속에서 그 가치를 꼭 확인하실 수 있으리라 생각합니다.
관련해서 더욱 상세한 내용이나, 다른 예시가 궁금하시다면 참고자료에 링크된 마틴파울러 블로그 원문 을 읽어보시는 것을 추천드리며 글을 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다

8. 참고자료

ᴡʀɪᴛᴇʀ
Youngmin Shin @holyachon Backend Engineer @AB180
유니콘부터 대기업까지 쓰는 제품. 같이 만들어볼래요? 에이비일팔공에서 함께 성장할 다양한 직군의 동료들을 찾고 있어요! → 더 알아보기