아키텍처에 대한 고민은 처음이라: 계층형과 육각형 아키텍처 적용 사례

weekwith.me
당근 테크 블로그
13 min readJul 25, 2024

들어가며

안녕하세요. 저는 공통 서비스 개발팀에서 소프트웨어 엔지니어로 근무 중인 Walter예요. 공통 서비스 개발팀은 사내 개발자분들의 개발자 경험(Development Experiecne, DX)을 향상시키기 위해 서비스에서 공통으로 사용하는 기능을 하나의 플랫폼으로 만들어 제공해요.

저는 작년 1월 당근 인터널 프로덕트팀에 입사하여 사내 구성원분들의 업무 효율성을 높이는 도구를 개발했어요. 인터널 프로덕트팀에선 설계(Design)와 아키텍처(Architecture)를 깊게 고민해 본 적이 없었는데, 올해 4월 이동하게 된 공통 서비스 개발팀에서는 업무 특성으로 인해 고민할 일이 많았죠. 이 글에선 그 경험과 제 인사이트를 공유드리려 해요.

기능적 건물과 소프트웨어

개발과 건축의 아키텍처는 닮은 점이 많은데요. 본격적으로 개발 이야기를 하기 전에 건물을 한번 생각해 볼까요? 우체국을 떠올려 봅시다.

출처: Pixabay

건물은 보통 각자의 역할이 있죠? 우체국은 소포를 전달받아 보관하는 역할을 해요. 일반적으로 사람들은 우체국 건물에, 집이 제공하는 거주의 기능(Function)을 기대하지는 않아요. 건물의 아름다움보다도 소포를 잘 보관하고 전달할 수 있는 환경을 설계하는 것을 우선시하죠. 이처럼 어떤 건축물은 정해진 기능에 충실한 것만으로도 소임을 다해요.

개발에서도 마찬가지예요. 어떤 소프트웨어는 목표한 기능을 수행하는 걸 최우선 과제로 삼아요. 사내 구성원 계정을 관리하는 시스템을 가정해 볼까요? 해당 시스템은 다음 두 요구 사항을 충족해야 해요. 첫째로 5회 이상 로그인에 실패하면 자동으로 계정이 잠겨야 하고, 둘째로 그 사실을 사내 메신저를 통해서 당사자에게 알려야 하죠. 이 소프트웨어의 아키텍처를 어떻게 구성하면 좋을까요? 간단하게 아래 이미지처럼 구성할 수 있어요.

소프트웨어의 목적과 장기적인 방향성을 고려한다면, 사내 계정 시스템과 메신저 시스템 모두 쉽게 변하지 않을 거예요. 두 시스템 모두 변경하려면 많은 비용을 치러야 하니까요. 따라서 해당 알림 소프트웨어는 변하지 않는 인프라 위에서 알림 전송의 기능에만 충실하면 돼요.

우체국이 우편물 전달의 기능에만 충실하다면 외관의 아름다움은 크게 중요하지 않듯이, 해당 소프트웨어도 메시지를 전송한다는 기능(Function)에만 충실한다면 설계와 아키텍처 등은 부수적인 논의가 될 수 있다는 의미예요. 조금 더 프로그래밍 이야기를 해보면, 단일 진입점(Entrypoint), 다시 말해 main.py 또는 main.go 같은 파일에 필요한 코드를 모두 작성해도 큰 문제가 없을 거예요.

기능적 건물과 소프트웨어의 한계

그러나 세상의 모든 건물이 처음 지어진 목적을 쭉 유지할 수 있는 건 아니에요. 경복궁을 한 번 떠올려볼까요?

출처: Pixabay

조선 왕조의 생활공간이었던 경복궁은 시간이 지나 우리나라의 대표 관광명소가 됐어요. 그리고 그 내부에는 안내소와 같이 기존에는 없던 건물이 들어서기도 했죠. 환경 변화에 맞춰 건물의 역할과 기능 또한 변화한 거예요.

소프트웨어는 어떨까요? 소프트웨어는 건축물보다 더 빠른 속도로 주변 변화에 대응해야 해요. 소프트웨어의 궁극적 목표는 사용자의 요구를 충족하는 건데, 어떤 도메인에서는 사용자의 요구 사항이 시시각각 변할 수 있기 때문이죠. 그래서 이런 환경에 쉽게 대응할 수 있는 구조(Architecture)가 필요하게 되었어요. 오늘은 그중에서도 대표적인 아키텍처인 계층형 아키텍처(Layered Architecture)와 육각형 아키텍처(Hexagonal Architecture)에 대해 이야기해 볼게요.

인터널 프로덕트팀의 업무 특성과 계층형 아키텍처(Layered Architecture)

저는 올해 3월까지 인터널 프로덕트팀에서 업무를 진행했고, 당시 사내 메신저 시스템에서 작동하는 다국어 번역 봇을 개발했어요. 먼저 번역 봇의 요구 사항을 함께 살펴볼까요?

특정 채널에 메시지가 전송되면 가장 먼저 해당 채널의 번역 설정을 데이터베이스로부터 확인해요. 이를 바탕으로 메시지를 번역하고, 그 결과가 원본 메시지에 합쳐져 보이게 하죠. 이때 메시지를 번역하는 도구는 여러 외부 API를 사용하며 번역의 품질을 따져보고 결정할 수 있어야 해요. 간단하게 이 구조를 그림으로 표현하면 아래와 같아요.

이 구조에서 다음 두 요소는 쉽게 변하지 않으리라고 예상할 수 있어요. 첫째는 사내 메신저와 데이터를 주고받는 인터페이스인 HTTP 통신이고, 둘째는 번역 설정이 저장될 데이터베이스죠. 다만 앞서 살펴보았던 로그인 실패 알림 소프트웨어와 달리 이용해야 할 인프라의 개수도 늘었고 메시지 구조 자체를 해석해야 하기 때문에 비즈니스 로직도 복잡할 거예요. 이럴 때 계층형 아키텍처를 사용하면 좋아요. 먼저 계층형 아키텍처의 구조를 한 번 살펴볼까요?

웹, 도메인, 그리고 인프라 세 개의 계층을 구분하여 역할에 따른 응집도(Cohesion)를 높였어요. 이를 통해 우리는 각각의 계층에 알맞은 책임을 부여하여 효율적으로 개발할 수 있게 돼요. 이를 번역 봇에 대입하면 아래와 같아요.

계층형 아키텍처의 장단점

복잡한 구조가 각각의 역할에 맞게 나누어져 이해하기 쉬운 구조가 되었어요. 새로운 개발자가 합류하여 새로운 번역 API를 도입한다고 가정해 볼게요. 계층형 아키텍처를 사용하면 아래 그림처럼 기존 구조에서 어떤 계층에 작업을 해야 할지 직관적으로 이해할 수 있어요.

그러나 비즈니스 로직이 인프라 계층으로 계속 몰리게 되며, 시간이 흐를수록 계층별 책임을 깔끔하게 분리하기 어렵다는 단점이 있어요. 모든 계층이 결국 인프라 계층을 바라보는 구조가 되어 한 계층에 대한 의존도가 커져요. 번역 API가 하나 추가되었을 뿐인데 인프라 계층의 변화에 대응하여 도메인 계층에서도 코드를 수정해야 하고요.

이러한 단점을 개선하기 위해 의존성의 방향을 반대로 바꾸는 시도가 이루어졌고, 그 대표적인 사례가 바로 육각형 아키텍처예요.

공통 서비스 개발팀의 업무 특성과 육각형 아키텍처(Hexagonal Architecture)

저는 올해 4월에 합류한 공통 서비스 개발팀에서 영상 플랫폼을 개발하고 있어요. 당근은 현재 짧은 길이의 영상 컨텐츠를 올릴 수 있는 당근 스토리를 서울 전 지역 대상으로 운영하고 있죠. 당근 스토리 외 부동산 등의 서비스에서도 효과적으로 영상이 업로드 및 재생될 수 있도록 공통된 인터페이스를 도출하여 영상 플랫폼을 개발하게 됐어요.

이때 당근의 서비스는 영상 플랫폼과 통신할 때 gRPC, HTTP, Kafka 등 여러 인터페이스를 통해 통신하며, 영상 플랫폼은 비용과 사용자 경험 등을 고려하여 여러 외부 프로바이더를 도입 및 검증하고 있어요. 이를 간단하게 그림으로 표현하면 아래와 같아요.

여기서 중요한 점은 당근의 서비스가 외부 프로바이더의 정보를 인지하지 않고 영상 플랫폼만 알게 해야 하며, 영상 플랫폼은 언제든 다른 외부 프로바이더로 쉽게 교체할 수 있는 구조여야 한다는 점이에요. 이러한 특징을 고려하여 영상 플랫폼에 육각형 아키텍처(Hexagonal Architecture)를 도입했어요. 먼저 육각형 아키텍처를 간단하게 그림으로 표현해 보면 다음과 같아요.

포트(Port)가 육각형(Hexagon) 모양으로 도메인과 관련된 가장 중요한 비즈니스 로직을 둘러싼 형태죠. 도메인은 포트만 인지하고 사용하기 때문에 어댑터(Adapter)와의 의존성이 역전돼요. 다시 말해, 포트라는 추상화(Interface)를 통해 소통이 이루어져 의존성 역전이 되고, 포트에 대한 구현(Implementation)은 어댑터가 포트를 상속받아 하게 되는 거죠.

쉽게 콘센트를 생각하면 편해요. 콘센트에 동일한 모양(Interface)의 여러 구멍(Port)이 존재하기 때문에 청소기, 노트북 등의 다양한 전자제품(Adapter)이 연결될 수 있는 거죠. 실제 콘센트를 통해 흐르게 되는 전기(Domain)는 어떤 전자제품이 꽂아졌는지와 무관해요. 콘센트 구멍을 통해 전류가 흐르는 것만 알면 되죠. 이를 한 번 영상 플랫폼에 대입해 볼까요?

육각형 아키텍처의 장단점

그렇다면 의존성 역전은 육각형 아키텍처에 어떤 장점을 가져다 줄까요? 그건 바로 외부의 변화가 핵심 도메인에 끼치는 영향도를 최소화할 수 있다는 점이에요. 만약 도메인, 다시 말해 서비스 계층에서 영상 업로드와 관련된 인프라 쪽 의존도가 생기면 어떤 단점이 있을까요? Kotlin 코드로 한 번 살펴볼게요.

class UploadService {
fun uploadVideo(uploadInformation: UploadInformation) {
when (uploadInformation.provider) {
// 프로바이더별 비즈니스 로직이 직접적으로 수행되고 있는 의존적 구조
"provider_a" -> { ... }
"provider_b" -> { ... }
}
}
}

보시는 바와 같이 전달받은 프로바이더에 따라 각각의 비즈니스 로직을 수행하고 있어요. 만약 provider_c 가 새로 추가되거나 provider_a 를 더 이상 사용하지 않는다면, 해당 프로바이더 관련된 코드는 물론 서비스 계층까지 건들게 되는 거죠.

단순히 업로드에 한정 지어 생각하면 그다지 귀찮은 일이 아닌 것처럼 보일 수 있어요. 하지만 영상 조회, 업로드, 삭제, 트랜스코딩 등 다양한 서비스 코드가 존재한다면 상황은 더 골치 아프겠죠? 그러면 이제 의존성 역전이 적용된 예시 코드를 살펴볼게요.

class UploadService(
private val uploadPorts: Map<String, UploadPort>
) {
fun uploadVideo(uploadInformation: UploadInformation) {
uploadPorts[uploadInformation.provider].uploadVideo(uploadInformation)
}
}

훨씬 깔끔해졌어요. 서비스 계층은 더 이상 외부 프로바이더에 의존하지 않게 되었고, 프로바이더 쪽의 코드 수정이나 프로바이더 삭제 혹은 추가 등이 도메인에 영향을 안 끼치게 됐죠. 그렇다면 포트의 구현체는 어떻게 만들어지는 걸까요?

// 인터페이스인 포트를 상속받아 구현하는 어댑터
class ProviderAAdapter : UploadPort {
override fun uploadVideo() { ... }
}

class ProviderBAdapter : UploadPort {
override fun uploadVideo() { ... }
}

class ProviderCAdapter : UploadPort {
override fun uploadVideo() { ... }
}

보시는 바와 같이 어댑터 계층에서 포트를 상속받아 실제 구현을 하게 돼요. 개별 프로바이더별로 상이한 비즈니스 로직은 이제 이 어댑터 계층에 위치하게 되는 거죠. 그렇다면 이 어댑터의 정보는 서비스 계층에 어떻게 전달되는 걸까요? 바로 의존성을 주입하면 돼요. Spring 프레임워크를 사용할 경우 개별 어댑터 객체를 Bean 객체로 만든 뒤 주입하면 아래와 같이 서비스 계층에서 가져다가 쓸 수 있게 돼요. 이러한 의존성 주입 라이브러리 혹은 도구가 부재한 다른 언어는 main.go 파일 같은 메인 파일에서 직접 주입해줄 수도 있고요.

class UploadService(
// Bean 객체를 통해 주입되는 어댑터 계층의 객체들
private val uploadPorts: Map<String, UploadPort>
) {
fun uploadVideo(uploadInformation: UploadInformation) {
uploadPorts[uploadInformation.provider].uploadVideo(uploadInformation)
}
}

결론적으로 육각형 아키텍처를 통해 우리는 여러 외부 프로바이더를 쉽게 도입 및 제거할 수 있게 됐어요. 그리고 이러한 구조는 객체 지향의 설계 원칙 중 개방 폐쇄 원칙(Open-Closed Principle, OCP)에도 해당해요. 새로운 프로바이더가 추가되거나 제거되어도 서비스 계층의 코드 변화는 없기 때문이죠.

그러나 긴 설명을 통해 알 수 있듯 해당 아키텍처를 처음 접한다면 계층 간 구조와 관계, 나아가 도메인을 이해하기 어렵다는 단점도 존재해요. 이로 인해 계층 간 침범을 통해서 복잡한 구조를 단순화하고 싶은 욕망이 들기도 해요. 계층끼리 주고받는 메시지가 동적으로 결정되어야 할 웹훅 이벤트 처리가 대표적인 예시예요. 외부 프로바이더에서 전달해주는 데이터는 필드가 제각기 다른데 이를 어떻게 포트 계층의 인터페이스로 표현할 수 있을까요? 추상화 방식의 난이도로 인해 각 프로바이더 웹훅 이벤트에 대한 정보를 서비스 계층에 포함하고 싶어지죠. 그러나 이는 명백한 계층 간 침범입니다.

공통 서비스 개발팀에서는 이러한 아키텍처 단위의 결정을 ADR(Architecture Decision Records)이라는 문서로 만들어 관리하고 있어요. 리팩터링 진행 방향성부터 새로운 프로바이더 도입에 따른 기존 인터페이스 하위호환성 등을 고려하는 모든 결정이 해당 문서를 통해 이루어지죠.

결론

육각형 아키텍처가 모든 상황을 해결하는 만능 해답은 아니에요. 앞서 살펴봤듯이 어떤 상황들은 단순한 역할만 수행하는 기능적 소프트웨어와 계층형 아키텍처로도 해결 가능하죠. 결국 만들고자 하는 소프트웨어의 도메인에 대한 분석과 이해가 우선되어야 하는 거예요. 불필요한 복잡도는 오히려 개발 생산성을 저해하고 빠른 변화에 대응하기 어렵게 만들죠.

공통 서비스 개발팀은 여러 외부 인프라를 쉽게 도입하고 제거해야 하며, 관련 기능을 통일된 인터페이스로 서비스에 제공해줘야 했어요. 이러한 요구사항들을 충족하기 위해선 복잡도가 높더라도 외부 환경의 변화에 유연한 대응이 가능한 육각형 아키텍처를 도입한 게 적절한 선택이었다고 판단해요. 이번 글에서는 육각형 아키텍처를 깊게 다루지 못했는데요. 다음 글에서는 육각형 아키텍처에서 못다한 이야기인 ADR 문서와 함께 계층 간 침범을 없앴던 경험을 공유 할게요.

당근 공통 서비스 개발팀은 여러 서비스에서 공통으로 사용하는 기능을 플랫폼으로 만들어 제공해요. 다양한 서비스의 DX를 높이는 기술적 도전에 함께하고 싶으신가요? 아래 당근 채용 공고를 통해 저희 팀에 합류할 수 있으니 많은 관심 가져주세요!

Software Engineer, Backend — 공통 서비스 개발 | 당근 팀 채용

--

--

weekwith.me
당근 테크 블로그

Software engineer who thrives on having excellent teamwork and understanding the product’s domain