ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이코에코(Eco²) Clean Architecture #6: My->Users 리팩토링.MD
    이코에코(Eco²)/Clean Architecture Migration 2026. 1. 1. 01:38

     

    작성일: 2025-12-31
    Opus 4.5와의 문답을 거치며 작성한 문서이며 My->Users 리팩토링 과정의 초안이 됩니다.

    핵심 변경 사항

    변경 AS-IS TO-BE
    도메인명 my users
    라우팅 /api/v1/user/me /api/v1/users/me
    User 소유권 auth + my (분산) users (단일)
    auth -> 회원가입 직접 DB 접근, Auth에서 수행 gRPC, Users에서 수행

    마이그레이션 목표

    목표 설명
    계층 분리 Presentation, Application, Domain, Infrastructure 분리
    의존성 역전 Port/Adapter 패턴으로 외부 의존성 추상화
    gRPC 통합 auth ↔ users 도메인 간 gRPC 통신, 도메인 명확히 분리
    CQRS 적용 읽기(Query)와 쓰기(Command) 분리
    데이터 소유권 명확화 중복 테이블 통합, 단일 소유

     


    현재 구조 분석

    데이터 흐름

    데이터 흐름 설명:

    • HTTP API: 클라이언트에서 프로필 조회/수정/삭제, 캐릭터 목록 조회
    • gRPC Server: character 도메인에서 캐릭터 지급 시 GrantCharacter RPC 호출 (sync scan, deprecated)
    • Celery Worker: scan 도메인에서 보상 처리 시 my.save_character 태스크 발행 → 배치로 DB 저장 (async scan)
    • Auth 참조: auth 도메인의 users, user_social_accounts 테이블을 읽기 전용으로 참조

    핵심 구성요소

    구성요소 역할 프로토콜
    my-api 프로필 CRUD + 캐릭터 조회 HTTP
    my-grpc 캐릭터 지급 gRPC
    my-worker 캐릭터 배치 저장 Celery

    현재 디렉토리 구조

    domains/my/
    ├── main.py                      # FastAPI 앱
    ├── api/v1/endpoints/            # HTTP 엔드포인트
    │   ├── profile.py
    │   └── characters.py
    ├── services/                    # 비즈니스 로직
    │   ├── my.py                    # 프로필 서비스
    │   └── characters.py            # 캐릭터 서비스
    ├── repositories/                # 데이터 액세스
    │   ├── user_repository.py
    │   ├── user_character_repository.py
    │   └── user_social_account_repository.py
    ├── models/                      # SQLAlchemy ORM
    │   ├── user.py
    │   ├── user_character.py
    │   ├── auth_user.py             # auth 참조
    │   └── auth_user_social_account.py
    ├── rpc/                         # gRPC
    │   ├── server.py                # gRPC 서버
    │   ├── character_client.py      # character gRPC 클라이언트
    │   └── v1/
    │       └── user_character_servicer.py
    ├── tasks/                       # Celery 태스크
    │   └── sync_character.py
    ├── schemas/                     # Pydantic 스키마
    └── proto/                       # Protobuf 정의

    스키마 분리의 배경과 문제점

    원래 설계 의도

    초기 설계에서는 마이크로서비스 지향 아키텍처를 염두에 두고 스키마를 분리했다.

    원칙 의도
    Bounded Context 각 도메인이 자신의 데이터를 완전히 소유
    스키마 분리 나중에 DB 분리를 쉽게 하기 위해
    느슨한 결합 user_profile.users.auth_user_id로 논리적 참조만

    원래 의도한 데이터 분리:

    • auth.users: 최소한의 인증 정보 (id, 소셜 계정 연결용)
    • user_profile.users: 확장된 프로필 정보 (닉네임, 전화번호, 프로필 이미지)

    실제로 발생한 문제

    문제 1: 데이터 중복

    중복 발생 원인

    시점 상황 결과
    OAuth 구현 OAuth에서 닉네임/이미지 제공 → auth.users에 저장 auth에 프로필 정보 추가
    프로필 기능 my 도메인도 프로필 필요 → user_profile.users 생성 같은 정보 중복 저장

    문제 2: 애플리케이션 레벨 동기화 필요

    # my/services/my.py — 프로필 수정 시 양쪽 자동 업데이트
    async def _apply_update(self, user: User, payload: UserUpdate) -> User:
        # 1. user_profile.users 업데이트
        updated = await self.repo.update_user(user, update_data)
    
        # 2. auth.users도 자동 업데이트 (애플리케이션에서 처리)
        if "phone_number" in update_data:
            await self.repo.update_auth_user_phone(
                user.auth_user_id, update_data.get("phone_number")
            )
        await self.session.commit()

    문제

    • 사용자 입장에선 자동이지만, 명시적으로 구현해 유지보수 부담 상승
    • 새 필드 추가 시 동기화 로직도 함께 추가해야 함
    • 다른 도메인(예: admin)에서 수정 시 동기화 누락 위험
    • 트랜잭션 범위가 두 스키마에 걸쳐있음

    문제 3: 불분명한 데이터 소유권

    질문 답변 불가
    닉네임은 누구 책임? auth.users.nickname vs user_profile.users.nickname
    프로필 수정 시 어디를 업데이트? 둘 다?
    두 값이 다르면 어느 게 진짜? 명확하지 않음

    적용 방향

    • users 도메인이 User 전체 소유
    • auth는 인증/토큰만, auth.user 스키마 삭제
    • gRPC로 통신, DB 직접 참조 X

    현재 스키마 구조 (ER Diagram)

    ER 다이어그램 설명:

    • auth.users: 인증 도메인의 핵심 사용자 테이블 — OAuth 로그인 시 생성
    • auth.user_social_accounts: 소셜 로그인 계정 — 1:N 관계로 다중 소셜 계정 지원
    • user_profile.users: 프로필 확장 테이블 — auth_user_id로 auth.users 참조
    • user_profile.user_characters: 캐릭터 인벤토리 — FK 없이 user_id로 논리적 참조

    중복 필드 테이블

    필드 auth.users user_profile.users 문제
    username 중복
    nickname 중복
    phone_number 중복
    profile_image_url 중복
    name 확장 필드
    email ❌ (소셜 계정에 있음) ✅vice 확장 필드

     

    현재 동기화 로직 (MyService._apply_update):

    # phone_number 수정 시 양쪽 테이블 자동 동기화 (애플리케이션 레벨)
    if "phone_number" in update_data:
        await self.repo.update_auth_user_phone(
            user.auth_user_id, update_data.get("phone_number")
        )
    await self.session.commit()

     

    → 애플리케이션 레벨 동기화로 인한 유지보수 부담불일치 위험


    gRPC 기반 도메인 통합 설계

    목표 아키텍처

    아키텍처 설명

    • auth 도메인: 인증/토큰만 담당, User 데이터 직접 접근 ❌
    • users 도메인: User 전체 소유, gRPC 서버 제공
    • 통신 방식: auth → users는 gRPC 동기 호출 (OAuth 콜백은 이벤트/비동기 처리 불가, 즉시 응답이 가능한 강결합)

    OAuth 콜백 플로우 (gRPC)

    동기 호출 근거 설명
    토큰 발급 필요 JWT에 user_id 클레임 포함 필수
    즉시 응답 필요 사용자에게 토큰과 함께 리다이렉트
    롤백 필요 사용자 생성 실패 시 로그인 실패

     


    최종 Users 스키마

    profile_images는 확장성을 위한 필드, 현재 이미지 서버와 로직, S3 prefix 존재

    ERD 설명

    관계 유형 설명
    users ↔ user_social_accounts has (1:N, FK) 실제 FK, CASCADE 삭제
    users ↔ user_characters owns (1:N, FK) 실제 FK, CASCADE 삭제
    user_characters ↔ characters 논리 FK FK 없음, 도메인 독립성

    도메인별 책임 정리

    도메인 역할 테이블 접근
    users User 전체 소유, CRUD, 프로필, 캐릭터 Read/Write
    auth 인증, 토큰 발급 gRPC 통해서만
    character 캐릭터 지급 요청 gRPC 통해서만
    scan 보상 처리 후 캐릭터 지급 Celery/gRPC

    제약조건 요약

    테이블 제약조건 컬럼 목적
    users uq_users_phone phone_number 전화번호 중복 방지
    user_social_accounts uq_social_identity (provider, provider_user_id) 소셜 계정 중복 방지
    user_social_accounts fk_social_user user_id CASCADE 삭제
    user_characters uq_user_character (user_id, character_code) 캐릭터 중복 소유 방지
    user_characters fk_character_user user_id CASCADE 삭제
    user_characters chk_status status 값 검증 (owned/burned)

    스키마 통합 마이그레이션 계획

    AS-IS vs TO-BE

    마이그레이션 단계

    Phase 작업 테이블 변경 다운타임
    1 코드 이동 (auth → users) + gRPC 추가
    2 스키마 생성 및 테이블 이동 짧음
    3 중복 데이터 병합 짧음
    4 이전 테이블/스키마 삭제

    Phase 2: 스키마 마이그레이션 SQL

    -- Step 1: 새 스키마 생성
    CREATE SCHEMA IF NOT EXISTS users;
    
    -- Step 2: auth 테이블 이동
    ALTER TABLE auth.users SET SCHEMA users;
    ALTER TABLE auth.user_social_accounts SET SCHEMA users;
    
    -- Step 3: user_profile.users 데이터 병합
    UPDATE users.users u
    SET 
        name = up.name,
        email = COALESCE(u.email, up.email)
    FROM user_profile.users up
    WHERE u.id = up.auth_user_id;
    
    -- Step 4: user_characters 이동
    ALTER TABLE user_profile.user_characters SET SCHEMA users;
    
    -- Step 5: 중복 테이블 삭제
    DROP TABLE user_profile.users;
    DROP SCHEMA user_profile;
    
    -- Step 6 (선택): auth 스키마 삭제
    DROP SCHEMA auth;

    기능 분석

    API 엔드포인트

     

    엔드포인트 메서드 설명 서비스
    /user/me GET 현재 사용자 프로필 조회 UserService
    /user/me PATCH 닉네임, 전화번호 수정 UserService
    /user/me DELETE 계정 삭제 UserService
    /user/me/characters GET 소유 캐릭터 목록 UserCharacterService
    /user/me/characters/{name}/ownership GET 특정 캐릭터 소유 여부 UserCharacterService
    GrantCharacter (gRPC) - 캐릭터 지급 (character 도메인에서 호출) UserCharacterServicer

    캐릭터 저장 흐름 (Celery Batches)

    현재 async Scan 보상 로직, 현행 유지 (/api/v1/scan)

    Celery Batches 흐름 설명:

    1. 메시지 발행: scan 도메인에서 보상 처리 시 my.save_character 태스크 발행
    2. 배치 축적: flush_every=50 (50개 모이면) 또는 flush_interval=5s (5초 경과) 시 배치 처리
    3. BULK UPSERT: ON CONFLICT (user_id, character_code) DO UPDATE로 멱등성 보장
    4. Self-Healing: character_id 캐시 불일치 시에도 character_code 기준으로 정확히 저장

    gRPC 서버 (GrantCharacter, deprecated)

    • character 도메인에서 동기 스캔 처리 시 GrantCharacter RPC 호출
    • Optimistic Locking으로 동시 요청 시에도 중복 지급 방지
    • IntegrityError 발생 시 already_owned=true 반환

    외부 도메인 참조

    동기 Scan 보상 로직, deprecated (/api/v1/scan/classify)

    외부 참조 설명:

    참조 대상 용도 방식
    auth.users 사용자 기본 정보 (닉네임, 이메일) PostgreSQL 직접 조회 (읽기 전용)
    auth.user_social_accounts 소셜 계정 정보 (provider, last_login) PostgreSQL 직접 조회 (읽기 전용)
    character 도메인 기본 캐릭터 정보 조회 gRPC 클라이언트 (Circuit Breaker 적용)

    Clean Architecture 마이그레이션

    목표 아키텍처

    아키텍처 설명:

    • Presentation: HTTP Controller와 gRPC Servicer가 공존 — 프로토콜 추상화
    • Application: CQRS 패턴 적용 — Commands(쓰기), Queries(읽기) 분리
    • Domain: 순수 비즈니스 로직 — 외부 의존성 없음
    • Infrastructure: Port 구현체 — SQLAlchemy, gRPC 클라이언트

    의존성 방향

    규칙 설명
    안쪽으로만 모든 의존성은 Domain을 향함
    Port/Adapter Application이 Port 정의, Infrastructure가 구현
    DIP 적용 상위 모듈이 하위 모듈에 의존하지 않음

    계층별 상세 설계

    Presentation Layer

    HTTP Controllers

    Legacy Clean Architecture 역할
    api/v1/endpoints/profile.py presentation/http/controllers/profile.py 프로필 CRUD
    api/v1/endpoints/characters.py presentation/http/controllers/characters.py 캐릭터 조회
    # presentation/http/controllers/profile.py
    @router.get("/me", response_model=ProfileResponse)
    async def get_profile(
        user: UserInfo = Depends(get_current_user),
        query: GetProfileQuery = Depends(),
    ) -> ProfileResponse:
        dto = await query.execute(
            GetProfileRequest(user_id=user.user_id, provider=user.provider)
        )
        return ProfileResponse.from_dto(dto)

    gRPC Servicer

    Legacy Clean Architecture 역할
    rpc/v1/user_character_servicer.py presentation/grpc/user_character_servicer.py 캐릭터 지급 RPC
    # presentation/grpc/user_character_servicer.py
    class UserCharacterServicer(user_character_pb2_grpc.UserCharacterServiceServicer):
        def __init__(self, grant_command: GrantCharacterCommand):
            self._command = grant_command
    
        async def GrantCharacter(self, request, context) -> GrantCharacterResponse:
            dto = GrantCharacterRequest.from_proto(request)
            result = await self._command.execute(dto)
            return result.to_proto()

    Application Layer

    Commands (쓰기)

    Use Case 설명 의존 Port
    UpdateProfileCommand 프로필 수정 UserGateway
    DeleteUserCommand 계정 삭제 UserGateway
    GrantCharacterCommand 캐릭터 지급 (gRPC) UserCharacterGateway
    # application/commands/grant_character.py
    @dataclass
    class GrantCharacterRequest:
        user_id: UUID
        character_id: UUID
        character_code: str
        character_name: str
        character_type: str | None
        character_dialog: str | None
        source: str | None
    
    class GrantCharacterCommand:
        def __init__(self, gateway: UserCharacterGateway):
            self._gateway = gateway
    
        async def execute(self, request: GrantCharacterRequest) -> GrantCharacterResult:
            result = await self._gateway.upsert(
                user_id=request.user_id,
                character_id=request.character_id,
                character_code=request.character_code,
                # ...
            )
            return GrantCharacterResult(
                success=True,
                already_owned=result.was_existing,
            )

    Queries (읽기)

    Use Case 설명 의존 Port
    GetProfileQuery 프로필 조회 UserGateway, AuthUserReader
    ListCharactersQuery 캐릭터 목록 UserCharacterGateway
    CheckOwnershipQuery 소유 확인 UserCharacterGateway

    Ports (인터페이스)

    # application/common/ports/user_character_gateway.py
    class UserCharacterGateway(Protocol):
        async def upsert(
            self,
            *,
            user_id: UUID,
            character_id: UUID,
            character_code: str,
            character_name: str,
            character_type: str | None,
            character_dialog: str | None,
            source: str | None,
        ) -> UpsertResult:
            """멱등 UPSERT (character_code 기준)"""
            ...
    
        async def list_by_user(self, user_id: UUID) -> list[UserCharacterDTO]:
            ...
    
        async def check_ownership_by_name(self, user_id: UUID, name: str) -> bool:
            ...
    # application/common/ports/character_client.py
    class CharacterClient(Protocol):
        async def get_default_character(self) -> DefaultCharacterDTO | None:
            """기본 캐릭터 조회 (Circuit Breaker 적용)"""
            ...

    Domain Layer

    Entity

    # domain/entities/user.py
    @dataclass
    class User:
        id: int | None
        auth_user_id: UUID
        username: str | None
        nickname: str | None
        phone_number: PhoneNumber | None  # Value Object
        profile_image_url: str | None
        created_at: datetime | None
    
        def update_profile(
            self,
            nickname: str | None = None,
            phone_number: PhoneNumber | None = None,
        ) -> None:
            if nickname is not None:
                self.nickname = nickname
            if phone_number is not None:
                self.phone_number = phone_number

    Value Object

    # domain/value_objects/phone_number.py
    @dataclass(frozen=True, slots=True)
    class PhoneNumber:
        value: str
    
        def __post_init__(self):
            normalized = self._normalize(self.value)
            if not self._is_valid(normalized):
                raise InvalidPhoneNumberError(self.value)
            object.__setattr__(self, 'value', normalized)
    
        @staticmethod
        def _normalize(raw: str) -> str:
            digits = re.sub(r"\D+", "", raw)
            if digits.startswith("82") and len(digits) >= 11:
                digits = "0" + digits[2:]
            return digits
    
        def formatted(self) -> str:
            """010-1234-5678 형식"""
            return f"{self.value[:3]}-{self.value[3:7]}-{self.value[7:]}"

    Value Object 설명:

    • 불변(Immutable): frozen=True로 생성 후 변경 불가
    • 자가 검증: 생성 시점에 유효성 검사
    • 정규화: 국제 형식(+82) → 국내 형식(010) 자동 변환

    Domain Service

    # domain/services/profile_service.py
    class ProfileService:
        def build_profile(
            self,
            user: User,
            social_accounts: list[SocialAccountInfo],
            current_provider: str,
        ) -> ProfileDTO:
            account = self._select_social_account(social_accounts, current_provider)
            username = self._resolve_username(user, account)
            nickname = self._resolve_nickname(user, account, username)
    
            return ProfileDTO(
                username=username,
                nickname=nickname,
                phone_number=user.phone_number.formatted() if user.phone_number else None,
                provider=account.provider if account else current_provider,
                last_login_at=account.last_login_at if account else None,
            )

    Infrastructure Layer

    Adapters

    Adapter Port 역할
    SqlaUserMapper UserGateway User CRUD
    SqlaUserCharacterMapper UserCharacterGateway 캐릭터 인벤토리
    SqlaAuthUserReader AuthUserReader auth 읽기 전용
    GrpcCharacterClient CharacterClient character gRPC 호출
    # infrastructure/adapters/user_character_mapper_sqla.py
    class SqlaUserCharacterMapper(UserCharacterGateway):
        async def upsert(self, **kwargs) -> UpsertResult:
            stmt = (
                insert(UserCharacterModel)
                .values(**kwargs)
                .on_conflict_do_update(
                    constraint="uq_user_character_code",
                    set_={
                        "character_id": kwargs["character_id"],
                        "character_name": kwargs["character_name"],
                        # ...
                    },
                )
                .returning(UserCharacterModel)
            )
            result = await self._session.execute(stmt)
            row = result.scalar_one()
            return UpsertResult(
                was_existing=(row.updated_at != row.acquired_at),
            )
    # infrastructure/grpc_client/character_client_grpc.py
    class GrpcCharacterClient(CharacterClient):
        def __init__(self, settings: Settings):
            self._circuit_breaker = CircuitBreaker(
                name="character-grpc",
                fail_max=settings.circuit_fail_max,
                timeout_duration=settings.circuit_timeout_duration,
            )
    
        async def get_default_character(self) -> DefaultCharacterDTO | None:
            try:
                return await self._circuit_breaker.call_async(self._call_impl)
            except CircuitBreakerError:
                logger.warning("Circuit breaker OPEN")
                return None

    디렉토리 구조

    apps/users (API + gRPC Server)

    apps/users/
    ├── main.py                                      # FastAPI + gRPC 서버
    ├── requirements.txt
    ├── Dockerfile
    │
    ├── setup/
    │   ├── config/settings.py
    │   └── dependencies.py                          # DI Container
    │
    ├── presentation/
    │   ├── http/
    │   │   ├── controllers/
    │   │   │   ├── profile.py                       # GET/PATCH/DELETE /user/me
    │   │   │   └── characters.py                    # GET /user/me/characters
    │   │   └── schemas/
    │   │       ├── profile.py
    │   │       └── character.py
    │   └── grpc/
    │       ├── user_character_servicer.py           # GrantCharacter RPC
    │       └── schemas.py                           # Proto ↔ DTO
    │
    ├── application/
    │   ├── commands/
    │   │   ├── update_profile.py
    │   │   ├── delete_user.py
    │   │   └── grant_character.py
    │   ├── queries/
    │   │   ├── get_profile.py
    │   │   ├── list_characters.py
    │   │   └── check_ownership.py
    │   └── common/
    │       ├── dto/
    │       │   ├── profile.py
    │       │   └── character.py
    │       ├── ports/
    │       │   ├── user_gateway.py
    │       │   ├── user_character_gateway.py
    │       │   ├── auth_user_reader.py
    │       │   └── character_client.py
    │       └── exceptions/
    │
    ├── domain/
    │   ├── entities/
    │   │   ├── user.py
    │   │   └── user_character.py
    │   ├── value_objects/
    │   │   ├── phone_number.py
    │   │   └── ownership_status.py
    │   ├── services/
    │   │   └── profile_service.py
    │   └── exceptions/
    │
    ├── infrastructure/
    │   ├── adapters/
    │   │   ├── user_mapper_sqla.py
    │   │   ├── user_character_mapper_sqla.py
    │   │   └── auth_user_reader_sqla.py
    │   ├── grpc_client/
    │   │   └── character_client_grpc.py
    │   └── persistence_postgres/
    │       └── models/
    │
    └── proto/
        ├── user_character.proto
        └── character/
            └── character.proto

    apps/users_worker (Celery 배치)

    apps/users_worker/
    ├── main.py                                      # Celery 앱
    ├── requirements.txt
    ├── Dockerfile
    ├── application/
    │   └── commands/
    │       └── save_character_batch.py              # 배치 저장 UseCase
    └── infrastructure/
        └── persistence_postgres/
            └── user_character_bulk_store.py         # BULK UPSERT

    파일별 매핑 테이블

    Services → Commands/Queries

    Legacy Clean Architecture 분류
    services/my.py::get_current_user() queries/get_profile.py Query
    services/my.py::update_current_user() commands/update_profile.py Command
    services/my.py::delete_current_user() commands/delete_user.py Command
    services/characters.py::list_owned() queries/list_characters.py Query
    services/characters.py::owns_character() queries/check_ownership.py Query
    rpc/v1/user_character_servicer.py::GrantCharacter() commands/grant_character.py Command

    Domain Logic 추출

    Legacy Clean Architecture 역할
    services/my.py::_resolve_username() domain/services/profile_service.py 사용자명 결정
    services/my.py::_resolve_nickname() domain/services/profile_service.py 닉네임 결정
    services/my.py::_select_social_account() domain/services/profile_service.py 소셜 계정 선택
    services/my.py::_normalize_phone_number() domain/value_objects/phone_number.py 전화번호 정규화

    Repositories → Adapters

    Legacy Clean Architecture Port
    repositories/user_repository.py adapters/user_mapper_sqla.py UserGateway
    repositories/user_character_repository.py adapters/user_character_mapper_sqla.py UserCharacterGateway
    repositories/user_social_account_repository.py adapters/auth_user_reader_sqla.py AuthUserReader

    gRPC 관련

    Legacy Clean Architecture 역할
    rpc/character_client.py infrastructure/grpc_client/character_client_grpc.py character gRPC 클라이언트
    rpc/v1/user_character_servicer.py presentation/grpc/user_character_servicer.py gRPC Servicer
    proto/* proto/* 그대로 유지

    마이그레이션 단계

    Phase 1: Domain Layer

    1. Entity 정의 (User, UserCharacter)
    2. Value Object 정의 (PhoneNumber, OwnershipStatus)
    3. Domain Service 추출 (ProfileService)

    Phase 2: Application Layer

    1. Port 정의 (UserGateway, UserCharacterGateway, AuthUserReader, CharacterClient)
    2. Commands 구현 (UpdateProfile, DeleteUser, GrantCharacter)
    3. Queries 구현 (GetProfile, ListCharacters, CheckOwnership)

    Phase 3: Infrastructure Layer

    1. SQLAlchemy Adapters 구현
    2. gRPC Client 구현 (Circuit Breaker 포함)
    3. ORM Models 분리

    Phase 4: Presentation Layer

    1. HTTP Controllers 구현
    2. gRPC Servicer 구현
    3. DI Container 구성 (dependencies.py)

    Phase 5: Worker 분리

    1. apps/users_worker 디렉토리 생성
    2. 배치 저장 UseCase 구현
    3. Dockerfile 및 CI 설정

    References

    댓글

ABOUT ME

🎓 부산대학교 정보컴퓨터공학과 학사: 2017.03 - 2023.08
☁️ Rakuten Symphony Jr. Cloud Engineer: 2024.12.09 - 2025.08.31
🏆 2025 AI 새싹톤 우수상 수상: 2025.10.30 - 2025.12.02
🌏 이코에코(Eco²) 백엔드/인프라 고도화 중: 2025.12 - Present

Designed by Mango