
DB를 바꾸려다 지옥을 맛봤다: 헥사고날 아키텍처 생존기
서비스 초기, MongoDB를 쓰다가 RDB로 마이그레이션 해야 할 순간이 왔습니다. 하지만 비즈니스 로직과 DB 코드가 뒤엉켜 있어 지옥을 경험했죠. 헥사고날 아키텍처(포트와 어댑터)를 도입하여 비즈니스 로직을 순수하게 지켜내고, 기술 부채로부터 탈출한 경험을 공유합니다.

서비스 초기, MongoDB를 쓰다가 RDB로 마이그레이션 해야 할 순간이 왔습니다. 하지만 비즈니스 로직과 DB 코드가 뒤엉켜 있어 지옥을 경험했죠. 헥사고날 아키텍처(포트와 어댑터)를 도입하여 비즈니스 로직을 순수하게 지켜내고, 기술 부채로부터 탈출한 경험을 공유합니다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

서로 다른 인터페이스를 연결해주는 변환기. 레거시 시스템과 신규 시스템을 이어주는 가장 강력한 디자인 패턴.

프링글스 통(Stack)과 맛집 대기 줄(Queue). 가장 기초적인 자료구조지만, 이걸 모르면 재귀 함수도 메시지 큐도 이해할 수 없습니다.

서비스 초기, 저희 팀은 "빠르게, 더 빠르게!"를 외치며 개발을 달렸습니다. 데이터베이스로 MongoDB를 선택한 것도 그 때문이었습니다. 스키마(Schema)를 미리 정의할 필요가 없으니, 기획이 바뀔 때마다 필드를 맘대로 추가하고 삭제할 수 있어서 정말 편했죠.
하지만 1년 뒤, 서비스가 커지면서 데이터 간의 관계(Relation)가 중요해졌습니다. MongoDB의 비정규화(Denormalization) 전략으로는 데이터 무결성을 지키기가 점점 어려워졌고, 복잡한 조인(Join) 쿼리가 필요해지자 성능 이슈도 터져 나왔습니다. 결국 저는 PostgreSQL로 마이그레이션을 결정했습니다.
저는 속으로 자신 있게 말했습니다.
"걱정 마세요. 코드에서 db.collection.findOne 같은 것만 찾아서 SQL로 바꾸면 금방 끝나겠는데요? 일주일이면 될 것 같아요."
...그건 저의 가장 큰 오산이자, 지옥의 문을 여는 주문이었습니다. 막상 코드를 열어보니 상황은 생각보다 끔찍했습니다.
// ❌ 지옥의 코드 예시 (Legacy)
async function createOrder(userId, items) {
// 1. 비즈니스 로직 사이에 숨어있는 MongoDB의 망령
const user = await db.collection('users').findOne({ _id: new ObjectId(userId) });
if (!user) throw new Error('User not found');
// 2. 외부 API 호출도 섞여 있음 (환율 조회)
const exchangeRate = await axios.get('https://api.exchangerate.com/usd-krw');
// 3. 비즈니스 로직 (가격 계산)
const order = {
_id: new ObjectId(), // MongoDB ID 생성 로직이 여기 왜 있어?
userId: user._id,
amount: items.reduce((sum, item) => sum + item.price, 0) * exchangeRate.data.rate,
createdAt: new Date()
};
// 4. 다시 DB 저장 (MongoDB 문법)
await db.collection('orders').insertOne(order);
// 5. 이메일 발송까지...
await sendgrid.send({ to: user.email, text: '주문 완료' });
}
이 함수 하나에 비즈니스 로직, DB 접근(MongoDB 종속성), 외부 API(환율), 메일 발송이 비빔밥처럼 섞여 있었습니다.
ObjectId, findOne, insertOne 같은 MongoDB 전용 코드가 비즈니스 로직 깊숙이 박혀 있어서, DB를 바꾸려면 코드를 "처음부터 다시 짜는 수준"으로 갈아엎어야 했습니다.
테스트 코드는요? 당연히 DB에 직접 연결해서 짜 놓았기 때문에, DB가 바뀌면 테스트도 전멸입니다.
절망에 빠져 구글링을 하던 중, 헥사고날 아키텍처(Hexagonal Architecture), 다른 말로 포트와 어댑터(Ports and Adapters) 패턴을 만났습니다.
헥사고날 아키텍처의 핵심 철학은 아주 단순합니다. "소중한 것은 가운데에 두고, 변하기 쉬운 것은 바깥으로 밀어내라."
이걸 그림으로 그리면 육각형 모양이 되어서 헥사고날이라고 부르지만, 저는 "도넛"에 비유하는 걸 좋아합니다.
우리가 도넛을 먹는 이유는 맛있는 빵(비즈니스 로직) 때문이지, 겉에 묻은 설탕(DB) 때문이 아닙니다. 설탕을 초콜릿으로 바꾼다고 해서 도넛이 베이글이 되면 안 되잖아요? 그런데 제 코드는 설탕(MongoDB)이 빵 반죽(비즈니스 로직) 속에 섞여 들어가서, 설탕을 빼려니 빵이 다 부서지는 상황이었던 겁니다.
리팩토링의 첫 단계는 경계선 긋기였습니다. 가장 먼저 한 일은 비즈니스 로직이 외부와 대화하는 창구, 즉 인터페이스(Port)를 정의하는 것이었습니다.
"나는 네가 MongoDB인지 Postgres인지는 관심 없고, 그냥 save랑 findById 기능만 있으면 돼."라고 선언하는 거죠.
// core/ports/OrderRepository.ts (도넛 안쪽)
// interface는 순수한 TypeScript 문법이므로 특정 DB에 종속되지 않습니다.
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findAllByUserId(userId: string): Promise<Order[]>;
}
// core/ports/EmailPort.ts
export interface EmailPort {
sendConfirmation(email: string, message: string): Promise<void>;
}
이 인터페이스 파일들은 순수한 TypeScript 파일입니다. import mongo나 import pg 같은 건 눈 씻고 찾아봐도 없습니다.
이게 바로 의존성 역전(DIP, Dependency Inversion Principle)의 시작입니다.
비즈니스 로직은 이제 구체적인 DB 기술에 의존하지 않고, 추상적인 인터페이스에만 의존하게 됩니다.
이제 인터페이스를 구현(Implement)하는 어댑터(Adapter)를 만듭니다. 이건 도넛 바깥쪽, 즉 인프라스트럭처(Infrastructure) 영역입니다. 여기에 더러운(?) DB 연결 코드나 API 호출 코드를 몰아넣습니다.
// infrastructure/adapters/PostgresOrderRepository.ts (도넛 바깥쪽)
import { Pool } from 'pg'; // 여기서만 pg 라이브러리를 씁니다!
import { OrderRepository } from '../../core/ports/OrderRepository';
export class PostgresOrderRepository implements OrderRepository {
constructor(private db: Pool) {}
async save(order: Order): Promise<void> {
// 여기에 지저분한 SQL 쿼리가 들어갑니다.
// 도메인 객체(Order)를 DB 스키마에 맞게 변환하는 작업도 여기서 합니다.
const query = 'INSERT INTO orders (id, user_id, amount, created_at) VALUES ($1, $2, $3, $4)';
const values = [order.id, order.userId, order.amount, order.createdAt];
await this.db.query(query, values);
}
async findById(id: string): Promise<Order | null> {
const res = await this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
if (res.rows.length === 0) return null;
return this.mapToDomain(res.rows[0]); // DB 데이터를 도메인 객체로 변환
}
}
이제 비즈니스 로직은 PostgresOrderRepository의 존재조차 모릅니다. 그저 OrderRepository라는 껍데기와 대화할 뿐이죠.
나중에 또 DB를 바꾸고 싶으면요? MySqlOrderRepository를 만들어서 갈아 끼우기만 하면 됩니다. 비즈니스 로직은 한 줄도 수정할 필요가 없습니다.
헥사고날 아키텍처를 하다 보면 귀찮지만 꼭 필요한 작업이 있습니다. 바로 데이터 변환(Mapping)입니다. 외부에서 들어오는 요청(Web Request)이나 DB 데이터(DB Entity)가 도메인 영역(Core)으로 들어올 때는, 반드시 도메인이 이해할 수 있는 형태(DTO)로 옷을 갈아입어야 합니다.
src/
├── core/ # 🟢 도넛 안쪽 (비즈니스 로직)
│ ├── domain/ # 핵심 도메인 모델 (Order, User 등)
│ ├── ports/ # 인터페이스 정의 (OrderRepository, EmailPort)
│ └── service/ # 비즈니스 로직 구현 (OrderService)
│
└── infrastructure/ # 🔴 도넛 바깥쪽 (기술적 구현)
├── adapters/ # 포트 구현체 (PostgresRepo, SendGridAdapter)
├── database/ # DB 연결 설정
├── web/ # Express/NestJS 컨트롤러
└── dtos/ # 데이터 변환 객체
컨트롤러(Controller) 레벨에서도 사용자의 입력을 바로 도메인 객체로 넣지 않고, CreateOrderRequest 같은 DTO(Data Transfer Object)로 받아서 검증한 뒤, Service로 넘겨야 합니다.
이렇게 하면 "DB 테이블에 컬럼이 하나 추가됐다"고 해서 "비즈니스 로직 클래스"를 수정해야 하는 불상사를 막을 수 있습니다.
자, 포트도 만들고 어댑터도 만들었습니다. 그럼 이걸 누가 연결해줄까요?
OrderService는 OrderRepository 인터페이스가 필요하다고 소리치고 있고, PostgresOrderRepository는 준비되어 있습니다.
이 둘을 맺어주는 중매쟁이가 필요합니다. 이게 바로 의존성 주입(Dependency Injection)입니다.
NestJS 같은 프레임워크를 쓴다면 아주 쉽습니다.
// app.module.ts (NestJS 예시)
@Module({
providers: [
OrderService,
{
provide: 'OrderRepository', // 인터페이스 이름(토큰)
useClass: PostgresOrderRepository, // 실제 구현체
},
],
})
export class AppModule {}
만약 프레임워크 없이 바닐라 Node.js를 쓴다면, 메인 파일(main.ts)에서 수동으로 조립하면 됩니다.
// main.ts
const dbPool = new Pool(...);
const orderRepo = new PostgresOrderRepository(dbPool); // 어댑터 생성
const emailService = new SendGridEmailService(apiKey);
// 서비스에 어댑터 주입 (끼워 넣기)
const orderService = new OrderService(orderRepo, emailService);
// 이제 서버 시작
server.start(orderService);
이렇게 main.ts라는 최상단 진입점에서만 구체적인 클래스(Postgres...)를 언급하고, 나머지 코드에서는 전부 인터페이스만 바라보게 만드는 것이 헥사고날의 완성입니다.
이 구조의 진짜 매력은 테스트에서 폭발합니다. 예전에는 주문 생성 로직 하나 테스트하려면 로컬에 MongoDB 띄우고, 데이터 넣었다 뺐다... 테스트 실행하는 데 10초씩 걸렸습니다. 엄청 느리고 힘들었죠.
이제는 가짜 어댑터(Mock Adapter)를 만들어서 테스트하면 됩니다.
// tests/InMemoryOrderRepo.ts
// DB 없이 메모리에 배열로 저장하는 가짜 구현체
class InMemoryOrderRepo implements OrderRepository {
private orders: Order[] = [];
async save(order: Order) {
this.orders.push(order);
}
async findById(id: string) {
return this.orders.find(o => o.id === id) || null;
}
}
test('주문 생성 테스트', async () => {
const fakeRepo = new InMemoryOrderRepo();
const service = new OrderService(fakeRepo, new FakeEmailService());
await service.createOrder('user1', [item1]);
// DB 없이도 "저장"이 호출됐는지 확인 가능!
expect(fakeRepo.orders.length).toBe(1);
});
테스트 속도가 0.1초도 안 걸립니다. DB가 없으니까요. 네트워크도 안 탑니다. 이 덕분에 TDD(테스트 주도 개발)를 흉내가 아니라 진짜로 실천할 수 있게 되었습니다.
물론 헥사고날 아키텍처가 무조건 정답은 아닙니다. 제가 뼈저리게 느낀 단점은 이렇습니다.
db.save() 하면 되지 왜 port니 adapter니 복잡하게 해요?"라고 물어봅니다. 설계 의도를 설명하고 설득하는 비용(Onboarding Cost)이 듭니다.그래서 저는 "핵심 비즈니스 로직이 복잡하고, 오래 유지보수해야 하는 프로젝트"에만 이 아키텍처를 적용합니다. 단순한 관리자 페이지나, 일회성 이벤트 사이트 같은 건 그냥 MVC 패턴으로 빠르게 짭니다. (그게 정신건강에 좋습니다.)
데이터베이스 마이그레이션은 결국 성공했습니다. 기존 스파게티 코드의 의존성을 헥사고날 아키텍처로 끊어내고, 새 PostgreSQL 어댑터를 끼우는 방식으로 진행했더니 큰 사고 없이 부드럽게 넘어갈 수 있었습니다. 이제는 환율 API가 바뀌어도, 이메일 서비스가 바뀌어도 두렵지 않습니다. 그냥 어댑터 하나 더 만들어서 갈아 끼우면 되니까요.
우리는 종종 "빨리 개발해야 하니까"라는 핑계로 설계를 무시합니다. 하지만 그 "빠름"이 나중에는 발목을 잡는 거대한 '기술 부채'가 되어 돌아옵니다.
여러분의 프로젝트가 점점 스파게티가 되어가고 있다면, 혹은 외부 시스템 변경이 두렵다면, 잠시 멈추고 헥사고날 아키텍처 도입을 고민해 보세요. "외부 세상이 아무리 변해도, 우리의 핵심 로직은 흔들리지 않는다"는 그 단단한 안정감이 여러분을 야근에서 구원해 줄 것입니다.