헥사고날 아키텍처, Adapter만 바꾸면 될까

파일 저장소를 다른 클라우드로 옮겨보니, 손댈 것의 절반은 Adapter 밖에 있었습니다
파일 저장소를 AWS S3에서 다른 클라우드의 오브젝트 스토리지로 옮기는 작업을 했습니다. 저장 백엔드를 갈아 끼우는 일이니, 헥사고날 아키텍처를 설명할 때 늘 따라붙는 그 문장이 시험대에 오른 셈이었습니다.
"도메인은 Port에만 의존하고, 인프라가 바뀌면 Adapter만 교체하면 됩니다."
애플리케이션 코드에서는 실제로 그랬습니다. 새 Adapter를 하나 만들고 설정 프로퍼티 한 줄을 바꾸자 저장 백엔드가 통째로 갈렸고, 도메인 서비스는 한 줄도 손대지 않았습니다.
그런데 이전 작업이 끝나기까지 저희가 실제로 손댄 목록을 적어보니, 거기에 Adapter는 한 항목뿐이었습니다. 인프라 정의를 다시 쓰고, 배포 파이프라인을 고치고, 네트워크를 새로 설계하고, 이미 쌓인 데이터를 옮겼습니다. 그 문장은 틀리지 않았는데, 문장이 말하지 않는 조건 하나를 저희가 흘려듣고 있었던 것입니다.
왜 애초에 클라우드를 옮겨야 했나
계기는 데이터를 국내 인프라에서 처리하고 저장해야 한다는 요건이었습니다. 저희는 HR SaaS라 급여, 인사평가, 근태 같은 민감한 개인정보를 다루고, 이 데이터의 국내 보관은 개인정보보호법과 B2B 고객 계약의 데이터 국내 보관 조항이 함께 걸리는 문제입니다. AWS 단독으로는 요건을 만족시킬 수 없어 국내 CSP를 함께 써야 했습니다. (이 배경은 본편 4화에서 다룬 멀티클라우드 이야기와 같은 뿌리입니다.)
옮겨야 할 대상은 파일 저장뿐이 아니었습니다. 비밀 관리, 메시지 큐, 데이터베이스까지 AWS에 기대고 있던 거의 전 영역이 후보였습니다. 이 글은 그중 파일 저장소 하나에 초점을 맞춥니다. 가장 단순해 보이는 이 하나에서도 경계 안과 밖이 뚜렷하게 갈렸기 때문입니다.
전제 하나. SDK가 도메인에 없어야 한다
"Adapter만 바꾸면 된다"가 성립하려면, 바꿀 Adapter 바깥의 코드에는 클라우드 SDK가 아예 없어야 합니다. 이게 첫 번째이자 가장 중요한 전제입니다.
저희 코드에서 AWS SDK 의존성은 최상위 조립 계층, 그러니까 애플리케이션을 실제로 기동하는 모듈에만 존재합니다. 도메인 모델도, 유스케이스를 담은 서비스 모듈도 AWS를 import하지 않습니다. 그 안에서는 com.amazonaws나 software.amazon.awssdk 같은 패키지 이름을 찾을 수 없습니다. 클라우드 벤더의 흔적이 도메인 방향으로 새어들지 않도록 물리적으로 막아둔 상태입니다.
이 상태는 저절로 생기지 않습니다. 저희는 SDK 버전을 한 곳에서 모아 관리하는 빌드 규칙을 두고 있어서, 수백 개 규모의 모듈이 같은 SDK 버전을 쓴다는 것이 구조적으로 보장됩니다. 모듈 수는 저희 내부 관찰이고, 중요한 건 정확한 개수가 아니라 버전이 한 곳에서 정해진다는 점입니다. 이 이야기의 뿌리는 본편 1화에서 다룬 빌드 가드레일입니다.
여기서 첫 번째 진실이 나옵니다. SDK가 조립 계층에만 격리돼 있으면, 클라우드 교체는 그 격리된 부분을 다른 것으로 갈아 끼우는 일이 됩니다. 만약 이 전제가 없었다면, 즉 서비스 코드 곳곳에서 S3 클라이언트를 직접 불렀다면, 클라우드 교체는 Adapter 교체가 아니라 사실상 서비스 재제작에 가까웠을 겁니다.

전제 둘. Port가 무엇을 계약하는가
두 번째 전제는 더 미묘하고, "절반의 거짓말"이 나오는 자리이기도 합니다. Port를 어떻게 그었느냐에 따라 Adapter 교체론은 성립하기도 하고 완전히 무너지기도 합니다.
저희의 파일 저장 Port는 올리고, 내려받고, 지우고, 서명된 접근 URL을 발급하는 동작을 계약합니다. 이때 주고받는 것은 전부 도메인 모델입니다. 파일을 가리키는 키, 파일의 내용, 발급된 URL. 이 계약 어디에도 S3, Bucket, Region 같은 AWS 쪽 이름이 없습니다. 계약은 "파일을 저장한다"는 것만 말하고, 그 파일이 어느 벤더의 어디에 담기는지는 말하지 않습니다.
반대의 경우를 상상해 보겠습니다. Port가 버킷 이름을 문자열로 받고, S3 객체 타입을 그대로 반환하고, AWS SDK의 응답 객체를 노출한다면 어떻게 될까요. 이 Port에 국내 CSP용 Adapter를 끼우려면, 버킷이라는 개념을 억지로 매핑하거나 Port 자체를 뜯어고쳐야 합니다. 그런데 Port를 수정하는 순간, 기존 AWS Adapter도 같이 고쳐야 하고, 그 Port에 의존하던 도메인 서비스가 전부 영향을 받습니다. "Adapter만 바꾸면 된다"가 여기서 정확히 깨집니다.
즉 그 문장은 Port가 특정 클라우드에 기울지 않게 그어져 있을 때만 성립합니다. Port 계약에 벤더 쪽 타입이나 이름이 한 번 들어오면, 교체는 그 한 지점에서 끝나지 않고 그 Port에 의존하는 곳 전체로 번집니다. 교체가 한 지점에서 끝나는지 아닌지를 가르는 선은 Adapter가 아니라 Port에 있습니다.

전제 셋. 클라우드의 특수성은 Adapter 안에 갇힌다
앞의 두 전제가 갖춰져 있어도, 현실의 클라우드는 서로 완전히 같지 않습니다. 국내 CSP의 오브젝트 스토리지는 S3 호환 API를 제공하지만, 'S3 호환'이 'S3 동일'을 뜻하지는 않습니다. 세 번째 전제는 이 미세한 차이들을 Adapter 안에 가두는 것입니다.
저희가 실제로 흡수해야 했던 차이는 크게 세 가지였습니다.
첫째, 메타데이터 키 처리입니다. AWS는 사용자 메타데이터에 특정 접두사를 자동으로 붙여 주지만, 국내 CSP는 그렇지 않았습니다. 그래서 Adapter가 키에 접두사를 직접 붙여 주는 변환을 맡습니다.
둘째, 서명된 접근 URL의 유효기간 상한입니다. 클라우드마다 이 URL을 얼마나 오래 유효하게 발급할 수 있는지의 상한이 다를 수 있습니다. Adapter는 요청받은 유효기간을 벤더의 상한선으로 잘라내는 검증을 품고 있어서, 도메인 서비스는 상한이 며칠인지 알 필요가 없습니다.
셋째, 엔드포인트와 인증 방식입니다. AWS는 리전만 지정하면 되지만, 국내 CSP는 엔드포인트 주소를 따로 겨눠야 합니다(예: https://object.example-csp.kr 형태의 주소와 별도 리전 값). 인증 방식도 다릅니다. 이 차이들은 전부 설정과 Adapter 내부의 분기로 처리되고, 어떤 Adapter를 꽂을지는 설정 프로퍼티 하나(app.storage.provider=aws 또는 국내 CSP)로 결정됩니다. 도메인 서비스는 Port 하나만 바라보고 있으니, 설정 파일 한 줄을 바꾸면 저장 백엔드가 통째로 갈립니다.
파일 저장 말고 다른 영역은 어땠는지도 함께 놓고 보면 이 격리가 어디까지 통하는지 보입니다. 아래 등급은 측정치가 아니라 저희가 실제로 옮겨보며 매긴 자체 평가입니다.
| 옮길 대상 | 개념 난이도 | 이유 |
|---|---|---|
| 파일 저장(S3에서 오브젝트 스토리지로) | 낮음 | S3 호환 API로 같은 클라이언트를 쓸 수 있음 |
| 관계형 DB, 캐시 | 낮음 | 대응 서비스가 존재하고 호환성이 높음 |
| 비밀 관리 | 중간 | API 형태가 달라 변환이 필요 |
| 메시지 큐 | 높음 | 대응 서비스가 없어 Kafka로 통합 |
| 관측/모니터링 | 중간 | 표준 계측으로 추상화 필요 |
파일 저장이 가장 쉬운 축인 건 S3 호환 API 덕분입니다. 메시지 큐가 가장 어려운 건 대응 서비스가 없어 아예 다른 방식으로 통합해야 하기 때문인데, 이 경우에는 Adapter를 갈아 끼우는 것으로 끝나지 않고 Port의 계약 자체를 다시 봐야 합니다. 이 글이 파일 저장을 고른 것은 그것이 대표적이어서가 아니라, 경계가 제대로 작동했을 때의 모습을 가장 깔끔하게 보여주기 때문입니다.

그래서 헥사고날이 지켜주지 못하는 것
세 전제가 다 갖춰져 있어도 헥사고날이 보호하는 범위는 애플리케이션 코드까지입니다. 파일 저장소 하나만 봐도, 코드 바깥에서 손봐야 하는 것이 훨씬 많았습니다. 인프라를 코드로 정의한 부분은 AWS용에서 국내 CSP용으로 다시 써야 했습니다. 배포 파이프라인은 레지스트리와 클러스터가 바뀌니 함께 손봐야 했습니다. 모니터링 대시보드는 벤더가 다르니 표준 계측 위에서 다시 구성해야 했고, 네트워크 구성은 VPC와 서브넷, 보안 그룹을 새로 설계해야 했습니다. 무엇보다 이미 AWS에 쌓여 있던 기존 데이터를 국내로 옮기는 마이그레이션이 남아 있었습니다.
이 목록에는 Adapter가 없습니다. Port와 Adapter가 아무리 깔끔해도 이 영역들은 경계 바깥이라 별도로 대응해야 하고, 실제 이전 작업의 일정 대부분이 여기서 나왔습니다.
그런데 이 목록을 다 적고 나면 경계 안쪽에서 무엇을 얻었는지가 오히려 분명해집니다. 어느 클라우드를 향하든 올라가는 애플리케이션 코드는 한 벌이고, 갈리는 것은 설정뿐입니다. 저장 백엔드가 다른 벤더로 바뀌는데도 빌드 산출물이 같다는 것, 이것이 Port와 Adapter 분리로 실제로 얻은 것이었습니다.

그 문장이 생략하고 있던 조건
처음에 적었던 목록으로 돌아가 보겠습니다. Adapter는 한 항목이었고, 나머지는 인프라 정의와 배포 파이프라인과 네트워크와 데이터 이전이었습니다. 그런데 그 한 항목이 정말로 한 항목으로 끝났다는 것도 같이 봐야 합니다. 도메인 서비스는 손대지 않았고, 저장 백엔드가 바뀌어도 빌드 산출물은 같았습니다.
그러니 "Adapter만 바꾸면 됩니다"는 거짓이 아니라, 앞에 조건이 붙는 문장이었습니다. 애플리케이션 코드 안에서는, 그리고 SDK가 조립 계층에만 있고 Port 계약에 벤더 쪽 타입이 들어와 있지 않다면입니다. 이 조건들은 저절로 생기지 않고, 클라우드를 옮길 일이 생기기 훨씬 전에 만들어져 있어야 합니다. 저희는 이걸 이전 요건을 예상해서 준비한 게 아니라 헥사고날 원칙을 일관되게 적용한 결과로 갖고 있었는데, 그래서 이번 작업이 서비스 재제작이 아니라 Adapter 추가로 끝났습니다.
남은 숙제도 그 조건 밖에 있습니다. 데이터 이전은 여전히 진행 중이고, 그건 Port를 아무리 잘 그어도 줄어들지 않는 몫입니다. 다음에 누가 "Adapter만 바꾸면 된다"고 말하면 되물어 볼 것은 두 가지입니다. 그 Adapter 바깥으로 SDK가 새어 있지 않은지, 그리고 애플리케이션 코드 바깥의 목록을 이미 세어 봤는지.
🚀플렉스팀 채용페이지 바로가기
☕flex Private Talk 신청하기