ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이코에코(Eco²) Clean Architecture #10: Auth/Users 스키마 정규화
    이코에코(Eco²)/Clean Architecture Migration 2026. 1. 2. 18:22

    Auth와 Users 도메인의 스키마 통합 과정에서 정규화 수준을 결정하고, 데이터 타입을 개선한 과정을 기록합니다.


    1. 스키마 통합 배경

    AS-IS: 분산된 사용자 정보

    문제: auth.users.id와 user_profile.users.id 수동 동기화 필요

    문제점

    문제 설명
    ID 동기화 auth.users.id와 user_profile.users.id 수동 동기화 필요
    트랜잭션 분리 회원가입 시 두 스키마에 걸쳐 원자적 삽입 어려움
    조인 복잡성 프로필 조회 시 cross-schema JOIN 필요
    데이터 불일치 동기화 실패 시 orphan 레코드 발생 가능

    TO-BE: 통합 스키마

    통합 결과

    • auth.users + user_profile.usersusers.accounts
    • auth.user_social_accountsusers.social_accounts
    • social_accountsUNIQUE(provider, provider_user_id) 제약

    2. 정규화 수준 분석

    2.1 정규화 단계

    단계 조건 accounts social_accounts user_characters
    1NF 원자값, 중복 그룹 없음
    2NF 완전 함수 종속
    3NF 이행 종속 없음 ⚠️
    BCNF 모든 결정자가 후보키 ⚠️

    2.2 user_characters의 비정규화

    -- user_characters 테이블
    CREATE TABLE users.user_characters (
        id UUID PRIMARY KEY,
        user_id UUID NOT NULL,
        character_id UUID NOT NULL,          -- FK 역할 (실제 FK 없음)
    
        -- 비정규화된 캐릭터 정보 (스냅샷, TEXT 기본)
        character_code TEXT NOT NULL,
        character_name TEXT NOT NULL,
        character_type TEXT,
        character_dialog TEXT,
        source TEXT,
    
        status user_character_status NOT NULL,  -- ENUM
        acquired_at TIMESTAMPTZ NOT NULL,
        updated_at TIMESTAMPTZ NOT NULL
    );

     

    비정규화 이유

    관점 설명
    도메인 분리 users는 character 도메인에 의존하지 않음
    읽기 성능 character 테이블 JOIN 없이 조회 가능, 유저 캐릭터의 경우 홈화면에서 노출되는 정보로 읽기 성능이 주요
    히스토리 보존 획득 시점의 캐릭터 정보 스냅샷
    서비스 분리 대비 Database per Service 전환 용이

    3. 데이터 타입 개선

    3.1 ENUM 타입 도입

    AS-IS (VARCHAR):

    Column("provider", String(32))  # 'google', 'kakao', 'naver'
    Column("status", String(20))    # 'owned', 'burned', 'traded'

    TO-BE (ENUM):

    class OAuthProvider(str, Enum):
        GOOGLE = "google"
        KAKAO = "kakao"
        NAVER = "naver"
    
    class UserCharacterStatus(str, Enum):
        OWNED = "owned"
        BURNED = "burned"
        TRADED = "traded"
    
    Column("provider", Enum(OAuthProvider, native_enum=True))
    Column("status", Enum(UserCharacterStatus, native_enum=True))

    ENUM 적용 기준:

    조건 적용 여부
    값의 집합이 고정됨 ✅ 적용
    값 추가가 드묾 ✅ 적용
    외부에서 동적으로 값이 들어옴 ❌ VARCHAR 유지

    적용 결과:

    필드 AS-IS TO-BE 이유
    provider VARCHAR(32) ENUM 고정 값 (google, kakao, naver)
    status VARCHAR(20) ENUM 고정 값 (owned, burned, traded)
    character_type VARCHAR(64) VARCHAR 유지 CSV에서 동적 값

    3.2 TEXT vs VARCHAR

    PostgreSQL의 특성

    PostgreSQL에서 VARCHAR(n)TEXT성능상 차이가 거의 없다.

    항목 VARCHAR(n) TEXT
    저장 방식 동일 (varlena) 동일 (varlena)
    성능 동일 동일
    길이 검증 DB 레벨에서 체크 없음
    스키마 변경 길이 변경 시 ALTER 필요 유연함

    두 가지 전략 비교

    전략 A: 명시적 길이 제한

    장점 단점
    DB 레벨 데이터 무결성 스키마 변경 비용
    문서화 효과 (의도 명시) 예상치 못한 길이 초과 에러
    표준 규격 명시 가능 관리 포인트 증가

     

    Column("email", String(320))        # RFC 5321 표준
    Column("phone_number", String(20))  # E.164 표준
    Column("nickname", String(120))

     

    전략 B: Unbounded String (현재 선택)

    PostgreSQL의 특성을 활용하여 길이 제한 없는 TEXT를 기본으로 사용해 길이 제한 관리를 피하고 스키마 유연성을 확보.

    장점 단점
    스키마 관리 유연성 DB 레벨 검증 없음
    ALTER TABLE 불필요 애플리케이션 레벨 검증 필요
    예상치 못한 에러 감소 의도가 코드에서만 보임
    # Unbounded String 스타일
    Column("nickname", Text)            # 길이 제한 없음
    Column("name", Text)                # 길이 제한 없음
    Column("character_code", Text)      # 길이 제한 없음

    추가 기법: CITEXT

    대소문자 구분 없는 식별자에 PostgreSQL 확장 타입 CITEXT 사용

    -- 애플리케이션에서 lower() 처리 불필요
    name CITEXT NOT NULL UNIQUE
    필드 일반 TEXT CITEXT
    'Admin' vs 'admin' 다른 값 같은 값
    UNIQUE 제약 대소문자 구분 대소문자 무시
    검색 WHERE lower(name) = lower(?) WHERE name = ?

    현재 프로젝트의 선택

    TEXT 기본 전략: 표준 규격이 명확한 필드만 VARCHAR, 나머지는 TEXT

    필드 타입 이유
    email VARCHAR(320) RFC 5321 표준
    phone_number VARCHAR(20) E.164 표준
    nickname, name TEXT 길이 제한 불필요
    profile_image_url TEXT URL 길이 다양
    character_* TEXT 스냅샷, 길이 예측 어려움
    provider_user_id TEXT OAuth 제공자별 상이

    향후 고려사항

    • nickname에 CITEXT 적용 검토 (대소문자 무시 유일성)

    3.3 TIMESTAMPTZ 설계

    TIMESTAMP vs TIMESTAMPTZ

    타입 저장 방식 조회 시 권장
    TIMESTAMP 입력값 그대로 그대로 반환
    TIMESTAMPTZ 타임존 정보 포함 저장 세션 타임존으로 변환

    TIMESTAMPTZ 선택 이유:

    • 타임존 정보 보존
    • 서버/클라이언트 타임존 독립적
    • 정확한 시간 비교 가능

    타임존 설정 (KST)

    -- PostgreSQL 세션 타임존 설정
    SET timezone = 'Asia/Seoul';
    
    -- 또는 postgresql.conf
    timezone = 'Asia/Seoul'
    # SQLAlchemy 연결 시 타임존 설정
    engine = create_async_engine(
        DATABASE_URL,
        connect_args={"options": "-c timezone=Asia/Seoul"}
    )

    updated_at 자동 갱신

    옵션 A: 애플리케이션 수동 갱신

    # SQLAlchemy onupdate
    Column("updated_at", DateTime(timezone=True), onupdate=func.now())

    옵션 B: PostgreSQL 트리거 자동 갱신

    -- 트리거 함수 생성
    CREATE OR REPLACE FUNCTION update_updated_at_column()
    RETURNS TRIGGER AS $$
    BEGIN
        NEW.updated_at = NOW();
        RETURN NEW;
    END;
    $$ language 'plpgsql';
    
    -- 각 테이블에 트리거 적용
    CREATE TRIGGER update_accounts_updated_at
        BEFORE UPDATE ON users.accounts
        FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
    방식 장점 단점
    애플리케이션 명시적, 제어 가능 누락 가능성
    트리거 자동화, 누락 없음 DB 의존성

    현재 선택: 애플리케이션 (SQLAlchemy onupdate)

    시간 필드 인덱스 고려

    필드 인덱스 필요성 사용 사례
    created_at ⚠️ 선택적 최신 가입자 조회, 기간별 통계
    updated_at ❌ 불필요 거의 조회 안 함
    last_login_at ⚠️ 선택적 휴면 계정 탐지, 활성 사용자 통계
    acquired_at ⚠️ 선택적 최근 획득 캐릭터 조회

    현재 결정: 인덱스 미추가

    • 이유: 초기 데이터 적음, 필요 시 추가 (premature optimization 방지)
    • 향후: 쿼리 패턴 분석 후 추가 검토

    3.4 최종 타입 규칙

    # 기본: TEXT (Unbounded String)
    Column("nickname", Text)
    Column("name", Text)
    Column("profile_image_url", Text)
    Column("character_code", Text)
    Column("character_name", Text)
    Column("character_type", Text)
    Column("character_dialog", Text)
    Column("source", Text)
    Column("provider_user_id", Text)
    
    # 예외: VARCHAR (표준 규격 존재)
    Column("email", String(320))        # RFC 5321: 64(local) + 1(@) + 255(domain)
    Column("phone_number", String(20))  # E.164: +국가코드 + 최대 15자리
    
    # ENUM (고정 값)
    Column("provider", Enum(OAuthProvider))
    Column("status", Enum(UserCharacterStatus))
    
    # 향후 CITEXT 고려
    # Column("nickname", CITEXT)  # 대소문자 무시 유일성

    4. 서비스 분리 시나리오

    4.1 현재: Shared Database + gRPC

    핵심: Auth는 users 스키마에 직접 접근하지 않고 gRPC로 Users 서비스 호출

    장점 단점
    서비스 간 명확한 경계 gRPC 호출 오버헤드
    users 스키마 캡슐화 네트워크 장애 고려 필요
    Users 서비스 독립 배포 가능 트랜잭션 분리

    4.2 Database per Service로 분리 시

    * user_characters: 캐릭터 스냅샷 데이터로, character 도메인 분리 후에도 독립적 조회 가능

    4.3 user_characters가 이미 분리 대비된 이유

    # user_characters 테이블 설계
    user_characters_table = Table(
        "user_characters",
        metadata,
        Column("user_id", UUID),          # 내부 FK
        Column("character_id", UUID),     # 외부 참조 (FK 없음!)
    
        # 스냅샷 데이터 (character 서비스 분리 대비, TEXT 기본)
        Column("character_code", Text),
        Column("character_name", Text),
        Column("character_type", Text),
        Column("character_dialog", Text),
    )

    character_id에 FK가 없는 이유

    1. 도메인 독립성: users 스키마는 character 스키마를 참조하지 않음
    2. 서비스 분리 대비: character 서비스가 분리되어도 스키마 변경 없음
    3. 데이터 완결성: 스냅샷으로 독립적 조회 가능

    4.4 분리 시 데이터 동기화 패턴

    캐릭터 획득 플로우:

    현재 (Shared DB)

    분리 후 (Database per Service)

    동기화 옵션

    패턴 설명 사용 시점
    Event Sourcing CharacterUpdated 이벤트 구독 비동기 동기화
    API Call 조회 시 Character 서비스 호출 실시간 데이터 필요
    Materialized View Read Model 별도 구성 CQRS 패턴
    Snapshot (현재) 획득 시점 데이터 저장 히스토리 보존

    5. 선택 근거 요약

    5.1 왜 users 스키마로 통합했는가?

    기준 결정
    데이터 경계 사용자 정보는 users 도메인
    트랜잭션 회원가입/프로필 업데이트 단일 트랜잭션
    JOIN 최소화 프로필 조회 시 cross-schema JOIN 제거 (향후 도메인별 DB 분리 고려)
    마이그레이션 auth.users → users.accounts 데이터 이관

    5.2 왜 3NF를 완전히 적용하지 않았는가?

    테이블 정규화 수준 이유
    accounts 3NF 핵심 엔티티, 완전 정규화
    social_accounts 3NF 1:N 관계, FK로 참조
    user_characters 2NF 도메인 분리, 스냅샷 패턴

    user_characters 비정규화 트레이드오프:

    장점 단점
    조회 성능 데이터 중복
    도메인 독립성 동기화 비용 (선택적)
    서비스 분리 용이 저장 공간

    5.3 왜 ENUM을 선택적으로 적용했는가?

    필드 ENUM 적용 이유
    provider OAuth 제공자 고정 (google, kakao, naver)
    status 캐릭터 상태 고정 (owned, burned, traded)
    character_type CSV 카탈로그에서 동적으로 정의

    6. 마이그레이션 전략

    6.1 Parallel Running (공존) 전략

    기존 auth 스키마와 새로운 users 스키마가 마이그레이션 기간 동안 공존한다.

    6.2 마이그레이션 단계

    Phase 상태 auth 스키마 users 스키마 애플리케이션
    1. 공존 현재 읽기/쓰기 읽기/쓰기 양쪽 모두 접근
    2. 전환 목표 읽기 전용 주 스키마 users 우선 접근
    3. 정리 완료 DROP 단일 스키마 users만 접근

    6.3 공존 기간 데이터 동기화

    -- V003 마이그레이션: auth → users 데이터 복제
    INSERT INTO users.accounts (...)
    SELECT ... FROM auth.users
    LEFT JOIN user_profile.users ON ...
    ON CONFLICT (id) DO NOTHING;
    
    INSERT INTO users.social_accounts (...)
    SELECT ... FROM auth.user_social_accounts
    ON CONFLICT (provider, provider_user_id) DO NOTHING;

    6.4 공존 전략의 장점

    장점 설명
    무중단 배포 기존 서비스 영향 없이 점진적 전환
    롤백 가능 문제 발생 시 auth 스키마로 복귀 가능
    검증 기간 신규 스키마 안정성 확인 후 전환
    점진적 코드 마이그레이션 애플리케이션 코드도 단계적 변경

    6.5 스키마 파일 구성

    migrations/
    ├── schemas/
    │   ├── auth_schema.sql       # 기존 스키마 (deprecated 예정)
    │   └── users_schema.sql      # 신규 통합 스키마
    └── V003__create_users_schema.sql  # 마이그레이션 스크립트

    Note: auth_schema.sql은 마이그레이션 완료 후 DROP 예정이며, 현재는 롤백 및 레퍼런스 용도로 유지한다.


    7. 최종 스키마

    ERD

    DDL

    -- users.accounts (3NF)
    CREATE TABLE users.accounts (
        id UUID PRIMARY KEY,
        nickname TEXT,                          -- Unbounded
        name TEXT,                              -- Unbounded
        email VARCHAR(320),                     -- RFC 5321
        phone_number VARCHAR(20) UNIQUE,        -- E.164
        profile_image_url TEXT,                 -- Unbounded
        created_at TIMESTAMPTZ DEFAULT NOW(),
        updated_at TIMESTAMPTZ DEFAULT NOW(),
        last_login_at TIMESTAMPTZ
    );
    
    -- users.social_accounts (3NF)
    CREATE TABLE users.social_accounts (
        id UUID PRIMARY KEY,
        user_id UUID NOT NULL REFERENCES users.accounts(id) ON DELETE CASCADE,
        provider oauth_provider NOT NULL,       -- ENUM
        provider_user_id TEXT NOT NULL,         -- Unbounded (OAuth 제공자별 상이)
        email VARCHAR(320),                     -- RFC 5321
        last_login_at TIMESTAMPTZ,
        created_at TIMESTAMPTZ DEFAULT NOW(),
        updated_at TIMESTAMPTZ DEFAULT NOW(),
        UNIQUE(provider, provider_user_id)
    );
    
    -- users.user_characters (2NF, 의도적 비정규화)
    CREATE TABLE users.user_characters (
        id UUID PRIMARY KEY,
        user_id UUID NOT NULL,                  -- FK 가능하나 현재 미설정
        character_id UUID NOT NULL,             -- FK 없음 (도메인 분리)
    
        -- 캐릭터 스냅샷 (비정규화, 모두 TEXT)
        character_code TEXT NOT NULL,
        character_name TEXT NOT NULL,
        character_type TEXT,
        character_dialog TEXT,
        source TEXT,
    
        status user_character_status NOT NULL DEFAULT 'owned',  -- ENUM
        acquired_at TIMESTAMPTZ DEFAULT NOW(),
        updated_at TIMESTAMPTZ DEFAULT NOW()
    );
    
    -- ENUM 정의
    CREATE TYPE oauth_provider AS ENUM ('google', 'kakao', 'naver');
    CREATE TYPE user_character_status AS ENUM ('owned', 'burned', 'traded');

    인덱스 전략

    테이블 인덱스 선택 이유
    accounts phone_number (UNIQUE) 제약조건으로 자동 생성, 전화번호 중복 방지
    accounts nickname (Partial) 닉네임 검색, NULL 제외로 불필요한 인덱스 크기 감소
    accounts email (Partial) 이메일 조회, NULL 제외
    social_accounts user_id 사용자별 연동 계정 조회 (1:N 관계)
    social_accounts provider 제공자별 통계/필터링
    social_accounts (provider, provider_user_id) (UNIQUE) 복합 유니크 제약, OAuth 로그인 시 조회
    user_characters user_id 사용자별 보유 캐릭터 조회 (메인 쿼리)
    user_characters character_id 특정 캐릭터 보유자 조회
    user_characters character_code 코드 기반 캐릭터 검색
    -- accounts 인덱스 (Partial Index 활용)
    CREATE INDEX idx_accounts_nickname ON users.accounts(nickname) 
        WHERE nickname IS NOT NULL;  -- NULL 제외로 인덱스 크기 최적화
    CREATE INDEX idx_accounts_email ON users.accounts(email) 
        WHERE email IS NOT NULL;
    
    -- social_accounts 인덱스
    CREATE INDEX idx_social_user_id ON users.social_accounts(user_id);
        -- 이유: 사용자별 연동 계정 조회 (프로필 페이지)
    CREATE INDEX idx_social_provider ON users.social_accounts(provider);
        -- 이유: OAuth 제공자별 통계, 관리자 대시보드
    CREATE INDEX idx_social_provider_user ON users.social_accounts(provider, provider_user_id);
        -- 이유: OAuth 로그인 시 기존 계정 조회 (복합 키 커버링)
    
    -- user_characters 인덱스
    CREATE INDEX idx_characters_user_id ON users.user_characters(user_id);
        -- 이유: "내 캐릭터 목록" 조회 (가장 빈번한 쿼리)
    CREATE INDEX idx_characters_character_id ON users.user_characters(character_id);
        -- 이유: "이 캐릭터를 가진 사용자" 조회 (어드민, 통계)
    CREATE INDEX idx_characters_code ON users.user_characters(character_code);
        -- 이유: 캐릭터 코드 기반 검색 (CSV 연동)

    8. 요약

    8.1 주요 결정

    결정 선택 이유
    스키마 통합 auth + user_profile → users 데이터 주인, 트랜잭션 단순화
    accounts 정규화 3NF 핵심 엔티티, 일관성
    user_characters 정규화 2NF (비정규화) 도메인 분리, 서비스 분리 대비
    provider, status ENUM 고정 값, 타입 안전성
    문자열 기본 타입 TEXT Unbounded String 전략
    email, phone VARCHAR (표준 길이) RFC 5321, E.164

    8.2 String 타입 전략

    현재 프로젝트 선택: TEXT 기본 전략

    타입 적용 대상 이유
    TEXT nickname, name, character_*, source 등 기본 (Unbounded)
    VARCHAR(n) email, phone_number 표준 규격 존재 (RFC/E.164)
    ENUM provider, status 고정 값, 타입 안전성

    PostgreSQL에서 VARCHAR(n)과 TEXT는 성능 동일 (varlena 저장).
    TEXT 기본 전략으로 스키마 관리 유연성 확보, ALTER TABLE 비용 제거.

    댓글

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