brunch

Canonical 채용 면접후기

5차 Juju Engineer Screening Task

by youngstone

이전 글: 4차 기술 면접


4차 기술면접을 마치고 나면 Hands-on 과제로 실제 프로그램을 Golang으로 개발해서 제출할 것을 요한다. 필자는 안타깝게도 현재 글에서 다루는 것처럼 상세하게 요건을 파악하여 코드에 다 녹여내지 못했다. 변명이지만 다소 현업 업무와 가정의 일로 인해 당시 과제 스펙을 읽고 개발할 수 있는 시간은 5시간 남짓이었다. 지금에서야 요건을 상세히 읽으며 내용을 상세히 정리할 수 있음에 감사하며 이 글을 읽는 독자들의 Canonical 면접에 도움이 될 수 있었으면 좋겠다.

Screen Shot 2024-10-28 at 5.02.16 PM.png 프로그램 개요

내용을 구체적으로 들여보자면, Juju 개발팀에서는

기본적으로 SRP(단일책임원칙)에 따라 개발할 것을 철저히 준수하려고 노력하고 있다고 한다.

단일 책임 원칙이 익숙하지 않은 분들은 SOLID 개발 패턴에 대해서 구글검색을 해보시길 바란다.

쉽게 말하면 하나의 모듈은 하나의 책임/기능만을 해야 한다는 원칙이다.


Juju의 코드는 독립적으로 실행될 수 있는 런타임 컴포넌트들로 구성이 되어있다고 한다.

그 이유는 서로 의존적인 컴포넌트들에게 서비스를 잘 제공하기 위함이라고 한다.

그에 대한 예시로, "API 연결 컴포넌트"를 들었다. 해당 컴포넌트의 책임은 다른

클라이언트 프로그램이 API 통신을 할 수 있도록 연결의 가용성을 보장하는 역할을 맡고 있다.

어찌 보면 API Gateway 나 Reverse Proxy와 같은 개념인 거 같은데 아무튼 여기에서는 API Connection Component라고 예시를 들어서 설명을 했다.


또 다른 예시로는 "락 관리 컴포넌트" 이야기를 했다. "락 관리 컴포넌트"는 그 안의 구현사항을 외부에 노출하지 않지만, 클라이언트 프로그램에서는 그러한 정보 없이 사용할 수 있어야 한다고 말하고 있다.

아무래도 구현사항을 노출하지 않는다는 점에서 추상화를 사용하기를 원한다고 판단할 수 있다.

Golang에서는 interface 타입 선언을 통해 추상화된 기능 명세(spec)를 정의하고

struct 타입을 통해 interface 타입( super type)을 구현하는 구현체(subtype)를 통해 원하는 종속성을

클라이언트 프로그램에 주입(Dependency Injection )시킬 수 있다.

흔히, Liskov Substition Principle(LSP)라고 부르는 원칙이다.


세 번째로는 각 컴포넌트들이 서로의 서비스들로부터 De-coupled 된 상태로 동작가능해야 하며 각자의 Lifecycle(생명주기) 가져야 한다고 요구하고 있다. 아키텍처에 대한 요구사항이라고 볼 수 있다.

A라는 서비스가 일시적 혹은 비일시적인 이유로 중단되었을 때, 그에 의존하고 있는 B 서비스는 연결을 재시도한다든지 A 서비스가 복구되었을 때 반응할 줄 알아야 한다는 점이다.

이 부분은 아키텍처적으로 fault tolerant 한 구성을 원한다고 볼 수 있다.


아키텍처 구상은 어떤 서비스를 구현하느냐에 따라 달라지겠지만,

본 태스크 설명을 더 보다 보면 데이터를 프로세싱하는 파이프라인 서비스이다.

때문에 데이터 프로세싱 관련해서 가장 일반적으로 사용하는 De-couple 방법은 Queue를 사용하는 방법이다. Queue를 사용하게 되면 각자 파이프라인을 구성하는 컴포넌트는 독립적인 생명주기를 가져갈 수 있고

각자 서비스의 가용성을 최대한 높일 수 있다.



Screen Shot 2025-02-07 at 11.00.24 AM.png 개발 태스크 설명

태스크에 대한 설명을 보면 크게 1) Data Ingestion, 2) Dispatcher, 3) Payload Handler 컴포넌트로 나뉘는 것을 볼 수 있다. 아쉽게도 필자는 당시 시간이 없어 별도의 독립적인 프로세스가 아니라, 하나의 프로세스 안에서 개별 컴포넌트를 여러 개의 고 루틴으로 실행하는 프로그램으로 만들어져 제출했다. 독자분들은 각각 개별 런타임을 가지는 모듈로 설계하시길 권장한다.


Screen Shot 2025-02-07 at 11.02.19 AM.png Data Ingestion Compoent 설명

Data Ingestion 컴포넌트는 기본적으로 juju에서 제공하는 demoware라는 Upstream 서비스로부터 데이터를 소비하여 Dispatcher에게 넘기는 역할을 하고 있다. Metric 정보를 담은 Response 스키마 정보를 제공하고 있다.

Screen Shot 2025-02-07 at 11.02.33 AM.png
Screen Shot 2025-02-07 at 11.02.38 AM.png Dispatcher, Payload Handler 설명

Dispatcher 컴포넌트는 Ingestion 컴포넌트로부터 전달받은 데이터를 소비하고자 하는 Consumer에게 전달하는 역할을 하고 있다. 여기에서 Consumer는 Payload handler가 될 수 있을 것 같다. Payload Handler 컴포넌트는 특정 타입의 Payload를 소비한 뒤에 내부 상태를 저장해야 한다.


load, cpu_usage, last_kernel_upgrade와 같은 payload 타입에 따라 compute 해야 하는 데이터가 다르고 이를 어느 곳에 저장해야 할지를 결정해야 한다.

Screen Shot 2025-02-07 at 11.02.44 AM.png 요구 사항

요구사항 명세에 대해서 보겠다.

1) 모든 컴포넌트는 독립적으로 실행되어야 하며 예상 가능한 범위 내에서의 장애로부터 복구가 가능해야 한다.

> 독립적 실행에 대해서는 각 컴포넌트별로 main.go를 별도로 가지는 패키지 형태로 구조를 잡고, 가능하면 container와 하며 분산환경에서 K8S와 같은 orchestrator를 사용하면 좋을 듯싶다. 이를 통해 독립 배포 실행과 서비스의 상태에 따른 복구를 달성해 낼 수 있을 것이다.


2) Dispatcher는 Ingestion 컴포넌트가, Payload Handler 컴포넌트는 Dispatcher 컴포넌트가 시작된 뒤에 시작되어야 한다.

> 프로세스의 시작단계에서 Upstream 컴포넌트의 Health Check endpoint를 호출하여 성공적으로 사전에 약속된 응답값을 받은 경우에 메인 스레드에서 그다음 의존되고 있는 컴포넌트로부터 Queue 데이터를 Consume 하는 코드를 실행하도록 코드를 구현하면 될 것 같다. Loop을 돌면서 계속 Health Check를 할 때, Exponential Backoff Retry로 일정 지연시간을 두고 체크하도록 하며 최대 Timeout을 지정하여 결국 성공하지 못했을 때에는 메인스레드를 비정상 종료하여 Container가 재시작되도록 하면 좋을 것 같다.

하지만, Queue를 사용하기로 마음먹었을 때에는 굳이 Upstream 컴포넌트에 의존한 코드를 구현할 필요가 있을지 의문이 들긴 한다.


3) Payload 들을 처리할 때 유용할 것 같은 구조에 대해서 고민해 보라.

> 아무래도 데이터를 전송/수신할 때 사용할 데이터 구조에 대해서 이야기하는 것 같은데 일반적으로 많이 사용하는 것이 JSON 포맷이다. 하지만, load_avg, cpu_usage, last_kernel_upgrade와 같은 메트릭 데이터의 발생주기가 짧고 생성되는 양이 많고 이후에 새로운 형태의 메트릭 데이터를 수집해야 하는 경우나 기존 스키마의 변경될 여지가 많다면 Avro와 같은 직렬화 프레임워크를 사용하는 것이 좋을 수도 있겠다.


4) 에러 핸들링을 포함한 e2e 테스팅과 개별 컴포넌트에 대한 적절한 수준의 테스트 커버리지를 제공해라.

> 각 함수별 unit testing은 쉽게 구현할 수 있을 것 같으나 e2e 테스팅이 좀 까다로울 것으로 생각이 든다. Upstream 서비스의 Health Check Endpoint에 따라 서비스의 스타트 여부를 결정하는 부분은 Endpoint를 Mocking 해서 실패 시나리오 별로 Downstream 서비스 상태를 결정하는 테스트 정도는 테스트 코드로 작성할 수 있을 것 같고 e2e 테스팅은 docker compose를 이용해서 런타임시 실패 상황에 따라 어떻게 동작하는지 확인하는 로그나 메트릭을 남기는 방법으로 테스트를 진행할 수 있을 것 같다.



보너스 포인트에 대해서 들여다본다면

1) 락의 사용을 최소화하라

> 락을 사용해야 하는 경우는 아무래도 상태를 저장하고 변경할 때를 제외하고는 없어 보인다.


2) back-pressure 핸들링

> Downstream(Dispatcher/ Payload Handler)의 프로세싱 속도에 맞춰서 Data Ingestion 컴포넌트에서 Polling Rate를 조절하도록 해야 한다는데, 이를 위해서는 Queue의 Length를 체크한다든지 Producer/Consumer의 Throughput을 수집하는 Prometheus Metric을 사용하여 동적으로 Poll rate를 조절하는 것을 생각해 볼 수 있겠다.


3) 분산환경 최적화

> 독립적 실행 가능하도록 main func를 분리하여 별도 컨테이너 이미지를 만들고, queue 나 API를 통해서 연동할 수 있는 stateless 한 구조로 가는 구조로 설계하면 될 것 같다. 영구저장소에 대한 클라이언트 또한 주입된 환경변수나 설정파일에 따라서 동적으로 사용할 구현체를 런타임시 정할 수 있도록 유연하게 설정하면 분산환경에서도 최적화될 수 있을 것 같다.


4) 내부 상태 메트릭

> opentelemtry나 prometheus instrumentation을 통해서 특정 상태에 대한 지표 추적을 할 수 있도록 하면 될 것이다.



Engineer Screening Task에 대한 과제 내용을 알아보았다.

당시 과제 진행 시 아래와 같은 안내 이메일을 받았는데

숙련된 Go 엔지니어는 2시간에서 4시간 안에 마칠 수 있는 과제로 안내받았다.

Screen Shot 2025-02-12 at 11.33.48 AM.png

아무래도 필자는 Golang 개발 경험이 1년이 안되었기에 그 정도로 숙련되진 않았던 것 같고

시간에 대한 압박이 있는 상태에서 요구사항을 면밀히 드려다보지 못하고

어느정도 할수 있는 한에서 완료한뒤 제출하였다.

스스로는 도전과제를 마칠 수 있어서 만족했으나

역시나 좀더 해당 포지션에 맞는 경험을 갖춘 사람과 더 진행하기로 했다고 안내를 맞게 되었다.

Screen Shot 2025-02-12 at 11.39.09 AM.png

면접후기를 작성하는데 까지는 오랜 시간이 걸렸으나

이렇게 회고를 남김으로서 무엇이 부족한지 깨닫게 되었고

이를 발판삼아 좀더 도약할 수 있기를 소망한다.


keyword
작가의 이전글경력 바벨탑