컴퓨터 프로그래밍/CS

[CS] Hexagonal Architecture

한33 2025. 3. 18. 23:50

✅  3-Layer Architecture & Hexagonal Architecture

더보기

1. 3계층 아키텍처 (3-Layer Architecture)

🏗 구조

3계층 아키텍처는 애플리케이션을 세 개의 논리적 계층으로 나누는 전통적인 설계 방식입니다.

  1. Presentation Layer (UI Layer)
    • 사용자 인터페이스 담당 (웹, 모바일, API 컨트롤러 등)
    • HTTP 요청을 받아 비즈니스 로직을 호출하고, 응답을 반환
  2. Business Layer (Service Layer)
    • 핵심 비즈니스 로직 담당
    • 데이터 처리 및 도메인 규칙 적용
  3. Data Access Layer (Repository Layer)
    • 데이터베이스 및 외부 저장소와의 상호작용
    • ORM (JPA, MyBatis 등) 또는 직접 SQL 쿼리를 사용

장점

  • 이해하기 쉽고, 대부분의 프로젝트에서 사용
  • 각 계층이 명확히 분리되어 있어 유지보수 용이

단점

  • UI/비즈니스 로직/데이터 계층 간 의존성이 강함
  • 데이터베이스 중심 설계가 되기 쉬움 (DB 변경 시 영향이 큼)
  • 도메인 로직이 Service Layer에 집중되면서 도메인 모델이 빈약해지는 문제 발생

2. 헥사고날 아키텍처 (Hexagonal Architecture, Ports & Adapters)

🏗 구조

헥사고날 아키텍처는 비즈니스 로직을 외부 인터페이스(DB, API, UI 등)로부터 독립적으로 유지하는 것을 목표로 합니다.

  1. Core (도메인 + 애플리케이션)
    • 비즈니스 로직과 도메인 모델이 위치
    • UI, 데이터베이스, 메시지 큐 등의 기술적인 요소에 의존하지 않음
    • 인터페이스를 통해 외부와 통신 (예: TodoRepository 인터페이스만 정의)
  2. Ports (입출력 포트)
    • 핵심 도메인 로직을 외부와 연결하는 인터페이스
    • Service 계층에서 정의된 인터페이스 (예: UserRepository, PaymentService)
  3. Adapters (어댑터)
    • 실제 외부 시스템과의 연결을 담당
    • 데이터베이스, REST API, 메시지 큐 등의 구현체
    • 인터페이스(Ports)를 구현하는 클래스 (예: JpaUserRepository)

장점

  • 비즈니스 로직이 외부 기술에 의존하지 않음
  • 테스트 용이 (DB 없이 단위 테스트 가능)
  • 유연한 변경 가능 (DB, API 변경 시 영향 최소화)

단점

  • 설계가 복잡해지고, 작은 프로젝트에서는 과할 수 있음
  • 인터페이스가 많아지면서 오버헤드 증가

회원가입 예시로 비교

  • 3계층 아키텍처:
    • 프레젠테이션: 모바일에서 "가입" 버튼 클릭 → 회원가입 폼 데이터 전달.
    • 비즈니스 로직: 중복 체크 → 사용자 생성 → DB 저장 요청.
    • 데이터: SQL 쿼리로 DB에 저장.
    • 문제: 모바일 대신 웹 앱이 추가되면 프레젠테이션 계층을 수정해야 함.
  • 헥사고날 아키텍처:
    • 도메인: 회원가입 로직(중복 체크, 사용자 생성).
    • 입력 어댑터: 모바일 API → 웹 API 추가.
    • 출력 어댑터: MySQL → MongoDB로 교체.
    • 장점: 도메인 로직은 그대로 두고, 어댑터만 수정/추가로 유연하게 대응.

헥사고날 디렉토리 구조


어떤 걸 써야 할까?

  • 3계층 아키텍처: 프로젝트가 작거나, 단순한 CRUD 작업이 주를 이루고, 외부 시스템이 많이 변하지 않을 때 적합.
  • 헥사고날 아키텍처: 여러 외부 시스템(모바일, 웹, 여러 DB 등)과 통합해야 하거나, 미래에 확장성을 고려해야 할 때 유용.

 

✅  회원가입 데이터 흐름 정리

더보기

1. 모바일 앱 → API (입력 어댑터)

  • 상황: 모바일 앱에서 회원가입 요청이 HTTP POST 요청으로 전송됨.
  • 위치: user/adapter/input/api (예: user_router.py).
  • 데이터: JSON 형식(예: {"email": "user@example.com", "social_id": "123", "name": "John", "social_provider": "google"}).
  • 처리:
    • FastAPI 라우터(@user_router.post)가 요청을 수신.
    • 요청 데이터가 CreateUserRequest 객체로 매핑됨.
    • CreateUserRequest CreateUserCommand로 변환(예: CreateUserCommand(email="user@example.com", social_id="123", ...)).
    • UserUseCase.create_user 메서드 호출(의존성 주입된 UserService로 전달).
  • 결과: CreateUserCommand가 서비스 계층으로 전달.

2. API → Service (애플리케이션 계층)

  • 상황: 비즈니스 로직 처리 및 도메인 계층과의 조율.
  • 위치: application/service/user.py (예: UserService 클래스).
  • 데이터: CreateUserCommand 객체.
  • 처리:
    1. 기존 사용자 확인:
      • repository.get_user_by_provider_and_social_id(provider="google", social_id="123") 호출.
      • 기존 사용자가 있으면:
        • access_token 생성(예: TokenHelper.encode({"user_id": user.id, "merchant_id": user.merchant.id})).
        • 사용자 상태 업데이트(예: WITHDRAWAL_USER  ACTIVE, is_new_user 설정).
        • repository.save(user) 호출.
        • 업데이트된 User 객체 반환.
    2. 신규 사용자 생성:
      • User.create로 새 사용자 생성(예: User(email="user@example.com", social_id="123", name="John", user_status=UserStatus.ACTIVE, ...)).
      • repository.save(user) 호출로 저장.
      • merchant_id가 없으면 Merchant 생성(예: Merchant(email="user@example.com", merchant_name="John's Store", user_id=user.id)).
      • merchant_repository.save(merchant) 호출.
      • User Merchant 연관 설정(user.merchant = merchant).
      • access_token 생성 및 User에 추가.
      • 다시 repository.save(user) 호출.
      • 마지막으로 get_user_by_provider_and_social_id 호출 후 is_new_user=True 설정.
  • 결과: 저장된 User 객체(예: User(id=1, email="user@example.com", access_token="jwt_token", ...)).

3. Service → Repository (도메인 계층)

  • 상황: 도메인 로직과 데이터베이스 작업을 위한 인터페이스 호출.
  • 위치: domain/repository  user/output/persistence/user_repository.py (예: UserRepository 클래스).
  • 데이터: User 객체 또는 조회 조건(provider, social_id).
  • 처리:
    • UserRepository.get_user_by_provider_and_social_id:
      • self.user_repo.get_user_by_provider_and_social_id로 위임.
    • UserRepository.save:
      • self.user_repo.save(user)로 위임.
  • 결과: UserRepo로 요청 전달.

4. Repository → Persistence (출력 어댑터)

  • 상황: 실제 데이터베이스 작업 처리.
  • 위치: user/output/persistence/user_repo.py (예: UserRepo 클래스).
  • 데이터: User 객체.
  • 처리:
    • UserRepo.save:
      • 비동기 세션 생성(async with session_factory() as session).
      • session.add(user) User 객체를 세션에 추가(예: INSERT INTO users (email, social_id, ...) VALUES ("user@example.com", "123", ...)).
      • await session.commit()으로 트랜잭션 커밋.
      • await session.refresh(user)로 최신 데이터 새로고침(예: SELECT * FROM users WHERE id = 1).
      • select(User).where(User.id == user.id).options(selectinload(User.merchant)) Merchant 데이터 포함 조회.
      • 조회된 User 객체 반환(예: User(id=1, email="user@example.com", merchant=Merchant(id=1, ...), ...)).
  • 결과: DB에 데이터 저장 및 User 객체 반환.

5. DB → Persistence → Repository → Service → API → 클라이언트

  • 상황: 저장된 데이터가 역방향으로 반환되어 클라이언트에 응답.
  • 데이터: User 객체.
  • 처리:
    • UserRepo  UserRepository  UserService  user_router User 객체 반환.
    • user_router에서 JSON 응답 생성(예: {"status": "success", "message": "사용자가 성공적으로 생성되었습니다", "data": {"id": 1, "email": "user@example.com", ...}}).
  • 결과: 모바일 앱에 응답 전송.

✅  데이터 흐름 요약 (단계별 예시)

  1. 모바일 → API:
    • 입력: {"email": "user@example.com", "social_id": "123", "name": "John", "social_provider": "google"}.
    • 출력: CreateUserCommand(email="user@example.com", social_id="123", ...).
  2. API → Service:
    • 입력: CreateUserCommand.
    • 처리: 기존 사용자 확인 → 없으면 User.create  Merchant 생성 → access_token 추가.
    • 출력: User(id=1, email="user@example.com", access_token="jwt_token", ...).
  3. Service → Repository:
    • 입력: User 객체 또는 조회 조건.
    • 출력: self.user_repo로 요청 전달.
  4. Repository → Persistence → DB:
    • 입력: User 객체.
    • 처리: INSERT INTO users ...  SELECT * FROM users JOIN merchants ....
    • 출력: User(id=1, email="user@example.com", merchant=Merchant(id=1, ...), ...).
  5. DB → 클라이언트:
    • 출력: {"status": "success", "data": {"id": 1, "email": "user@example.com", ...}}.

 

✅  헥사고날 아키텍처에서는 왜 DTO 를 Command 로 바꿀까?

더보기

왜 DTO를 Command로 바꾸는 걸까?

  • 비유: 편지를 우체국에 맡기면(DTO), 우체국원이 편지 내용을 정리해서 배달원(Command)에게 주잖아요. 편지는 외부에서 온 데이터이고, 배달원은 내부에서 처리할 수 있는 형태예요.
  • 이유:
    1. 결합성 낮추기: DTO는 외부(예: 모바일 앱)에서 온 데이터를 담는 용도로만 쓰이고, Command는 내부 비즈니스 로직(예: UserService)에서 사용할 목적으로 설계돼요. 이렇게 하면 외부 형식(예: JSON 구조)이 내부 로직에 영향을 주지 않아요.
    2. 역할 분리: DTO는 "데이터 운반"에 집중하고, Command는 "어떤 작업을 할지"를 정의해요. 예를 들어, DTO는 {"email": "user@example.com"}만 담고, Command는 create_user(email="user@example.com") 같은 실행 가능한 명령이 돼요.
    3. 유연성: 외부 데이터 형식이 바뀌어도(예: 모바일이 새 필드 추가), Command를 수정하지 않고 어댑터에서 DTO만 조정하면 돼요.
  • 예시:
    • DTO(CreateUserRequest): 모바일에서 온 {"email": "user@example.com", "social_id": "123"}.
    • Command(CreateUserCommand): create_user(email="user@example.com", social_id="123")로 변환.
    • 결과: UserService는 Command만 보고 DTO 구조를 몰라도 됨.

어떻게 의존성을 낮추는 걸까?

  • 비유: 레고 블록을 서로 끼우는 대신, 마그넷으로 살짝 붙이면 떼기 쉬운 것처럼, 시스템도 약하게 연결해요.
  • 방법:
    1. 인터페이스 사용: UserUseCaseUserRepository처럼 인터페이스를 만들어 구체적인 구현(예: UserService, UserRepo)과 분리해요. 그래서 구현을 바꾸면 인터페이스만 맞추면 돼요.
    2. 의존성 주입(DI): Container.user_service처럼 외부에서 의존성을 주입해줘요. 코드 안에서 직접 객체를 만들지 않으니까, 테스트할 때 가짜 객체(Mock)를 쉽게 넣을 수 있음.
    3. DTO와 Command 분리: 외부 데이터(DTO)가 내부 로직(Command)에 직접 들어가지 않게 막아서, 계층 간 의존성을 줄임.
  • 어디서 차이 발생?
    • DTO 단계: user/adapter/input/api에서 모바일 데이터가 CreateUserRequest로 들어와요. 여기서는 외부 형식에 맞춰져 있고, 내부 로직과는 독립적.
    • Command 단계: UserService로 넘어갈 때 CreateUserCommand로 변환돼요. 여기서 비즈니스 로직에 맞춘 형태로 바뀌며, 외부 데이터 구조와 분리됨.
    • 차이 발생 지점: 변환 과정(request.model_dump() → CreateUserCommand)에서 외부(DTO)와 내부(Command)의 경계가 생겨요. 이 경계 덕분에 UserService가 모바일의 JSON 구조를 몰라도 작동해요.

쉽게 비교해 보기

  • DTO만 쓰면?
    • 모바일에서 필드(예: age)가 추가되면 UserService도 수정해야 해요. 의존성이 높아져요.
    • 예: UserServiceCreateUserRequest.age를 직접 쓰면, age가 없어지면 오류 발생.
  • DTO → Command로 변환하면?
    • 모바일이 age를 추가해도 CreateUserCommand에 반영 안 하면 UserService는 영향을 안 받아요.
    • 예: CreateUserCommandage를 추가하지 않으면, UserService는 여전히 잘 돌아감. 어댑터에서만 수정.