
✅ 3-Layer Architecture & Hexagonal Architecture
더보기
1. 3계층 아키텍처 (3-Layer Architecture)
🏗 구조
3계층 아키텍처는 애플리케이션을 세 개의 논리적 계층으로 나누는 전통적인 설계 방식입니다.
- Presentation Layer (UI Layer)
- 사용자 인터페이스 담당 (웹, 모바일, API 컨트롤러 등)
- HTTP 요청을 받아 비즈니스 로직을 호출하고, 응답을 반환
- Business Layer (Service Layer)
- 핵심 비즈니스 로직 담당
- 데이터 처리 및 도메인 규칙 적용
- Data Access Layer (Repository Layer)
- 데이터베이스 및 외부 저장소와의 상호작용
- ORM (JPA, MyBatis 등) 또는 직접 SQL 쿼리를 사용
✅ 장점
- 이해하기 쉽고, 대부분의 프로젝트에서 사용
- 각 계층이 명확히 분리되어 있어 유지보수 용이
❌ 단점
- UI/비즈니스 로직/데이터 계층 간 의존성이 강함
- 데이터베이스 중심 설계가 되기 쉬움 (DB 변경 시 영향이 큼)
- 도메인 로직이 Service Layer에 집중되면서 도메인 모델이 빈약해지는 문제 발생
2. 헥사고날 아키텍처 (Hexagonal Architecture, Ports & Adapters)
🏗 구조
헥사고날 아키텍처는 비즈니스 로직을 외부 인터페이스(DB, API, UI 등)로부터 독립적으로 유지하는 것을 목표로 합니다.
- Core (도메인 + 애플리케이션)
- 비즈니스 로직과 도메인 모델이 위치
- UI, 데이터베이스, 메시지 큐 등의 기술적인 요소에 의존하지 않음
- 인터페이스를 통해 외부와 통신 (예: TodoRepository 인터페이스만 정의)
- Ports (입출력 포트)
- 핵심 도메인 로직을 외부와 연결하는 인터페이스
- Service 계층에서 정의된 인터페이스 (예: UserRepository, PaymentService)
- 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 객체.
- 처리:
- 기존 사용자 확인:
- 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 객체 반환.
- 신규 사용자 생성:
- 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)로 위임.
- UserRepository.get_user_by_provider_and_social_id:
- 결과: 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, ...), ...)).
- UserRepo.save:
- 결과: 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", ...}}).
- 결과: 모바일 앱에 응답 전송.
✅ 데이터 흐름 요약 (단계별 예시)
- 모바일 → API:
- 입력: {"email": "user@example.com", "social_id": "123", "name": "John", "social_provider": "google"}.
- 출력: CreateUserCommand(email="user@example.com", social_id="123", ...).
- API → Service:
- 입력: CreateUserCommand.
- 처리: 기존 사용자 확인 → 없으면 User.create → Merchant 생성 → access_token 추가.
- 출력: User(id=1, email="user@example.com", access_token="jwt_token", ...).
- Service → Repository:
- 입력: User 객체 또는 조회 조건.
- 출력: self.user_repo로 요청 전달.
- Repository → Persistence → DB:
- 입력: User 객체.
- 처리: INSERT INTO users ... → SELECT * FROM users JOIN merchants ....
- 출력: User(id=1, email="user@example.com", merchant=Merchant(id=1, ...), ...).
- DB → 클라이언트:
- 출력: {"status": "success", "data": {"id": 1, "email": "user@example.com", ...}}.
✅ 헥사고날 아키텍처에서는 왜 DTO 를 Command 로 바꿀까?
더보기
왜 DTO를 Command로 바꾸는 걸까?
- 비유: 편지를 우체국에 맡기면(DTO), 우체국원이 편지 내용을 정리해서 배달원(Command)에게 주잖아요. 편지는 외부에서 온 데이터이고, 배달원은 내부에서 처리할 수 있는 형태예요.
- 이유:
- 결합성 낮추기: DTO는 외부(예: 모바일 앱)에서 온 데이터를 담는 용도로만 쓰이고, Command는 내부 비즈니스 로직(예: UserService)에서 사용할 목적으로 설계돼요. 이렇게 하면 외부 형식(예: JSON 구조)이 내부 로직에 영향을 주지 않아요.
- 역할 분리: DTO는 "데이터 운반"에 집중하고, Command는 "어떤 작업을 할지"를 정의해요. 예를 들어, DTO는 {"email": "user@example.com"}만 담고, Command는 create_user(email="user@example.com") 같은 실행 가능한 명령이 돼요.
- 유연성: 외부 데이터 형식이 바뀌어도(예: 모바일이 새 필드 추가), 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 구조를 몰라도 됨.
어떻게 의존성을 낮추는 걸까?
- 비유: 레고 블록을 서로 끼우는 대신, 마그넷으로 살짝 붙이면 떼기 쉬운 것처럼, 시스템도 약하게 연결해요.
- 방법:
- 인터페이스 사용: UserUseCase나 UserRepository처럼 인터페이스를 만들어 구체적인 구현(예: UserService, UserRepo)과 분리해요. 그래서 구현을 바꾸면 인터페이스만 맞추면 돼요.
- 의존성 주입(DI): Container.user_service처럼 외부에서 의존성을 주입해줘요. 코드 안에서 직접 객체를 만들지 않으니까, 테스트할 때 가짜 객체(Mock)를 쉽게 넣을 수 있음.
- DTO와 Command 분리: 외부 데이터(DTO)가 내부 로직(Command)에 직접 들어가지 않게 막아서, 계층 간 의존성을 줄임.
- 어디서 차이 발생?
- DTO 단계: user/adapter/input/api에서 모바일 데이터가 CreateUserRequest로 들어와요. 여기서는 외부 형식에 맞춰져 있고, 내부 로직과는 독립적.
- Command 단계: UserService로 넘어갈 때 CreateUserCommand로 변환돼요. 여기서 비즈니스 로직에 맞춘 형태로 바뀌며, 외부 데이터 구조와 분리됨.
- 차이 발생 지점: 변환 과정(request.model_dump() → CreateUserCommand)에서 외부(DTO)와 내부(Command)의 경계가 생겨요. 이 경계 덕분에 UserService가 모바일의 JSON 구조를 몰라도 작동해요.
쉽게 비교해 보기
- DTO만 쓰면?
- 모바일에서 필드(예: age)가 추가되면 UserService도 수정해야 해요. 의존성이 높아져요.
- 예: UserService가 CreateUserRequest.age를 직접 쓰면, age가 없어지면 오류 발생.
- DTO → Command로 변환하면?
- 모바일이 age를 추가해도 CreateUserCommand에 반영 안 하면 UserService는 영향을 안 받아요.
- 예: CreateUserCommand에 age를 추가하지 않으면, UserService는 여전히 잘 돌아감. 어댑터에서만 수정.
'컴퓨터 프로그래밍 > CS' 카테고리의 다른 글
Feign Client 를 만든 Netflix (0) | 2025.04.07 |
---|---|
RabbitMQ vs Kafka 과 알림 서버 분리에 따른 메세지 큐 반영 고민 (2) | 2025.04.06 |
[CS] 3. 데이터베이스 (0) | 2024.12.26 |
[CS] 2. 데이터 구조와 알고리즘 (0) | 2024.12.24 |
[CS] 1. 객체지향 프로그래밍 ( OOP ) (0) | 2024.12.12 |