[BE] ERD 설계 - 100-hours-a-week/6-nemo-wiki GitHub Wiki

ERD CLOUD

https://www.erdcloud.com/d/S8syM7aTHQCJK3qRm

테이블 정의서 (Notion)

https://www.notion.so/ERD-1dbe6294970680fc85e6db17be148dd6

ERD 설계 (4.22)

image

테이블 명세서 (Nemo 서비스)

추후 MSA 설계를 위해 Domain별로 분리해보았습니다.

공통 고려사항

MySQL 8.0.36 사용

  • DATETIME VS TIMESTAMP

    • DATETIME
      • 로컬 시간 저장
      • 자동 초기값을 직접 설정해줘야함.
      • 지원 범위가 9999년까지임 → 8byte이기 때문에
      • 타임존 영향을 받지 않기 때문에, 글로벌 서비스에서 예약 시스템이나 이벤트 스케줄 관리 등에 사용하기 적합하다.
    • TIMESTAMP
      • UTC로 저장
      • 1970년 1월 1일 자정을 기준으로 몇 초가 지났는지를 저장한다.
      • 2038 문제 존재 (4byte 밖에 쓰지 못하기 때문에, MySQL에서 7byte도 가능)
        • 하지만 3byte는 마이크로초를 위한 자리이므로 2038년 문제는 해소x
      • 로그 데이터(예: 사용자의 마지막 로그인 시간)나 변경 추적 시스템에서 유용하며, 자동으로 갱신되는 기능을 활용할 수 있다.
  • TEXT VS VARCHAR

    • TEXT
      • 실제 값은 외부에 저장하고 포인터만 저장한다.
      • 인덱싱이 불가능하다.
    • VARCHAR
      • 인덱싱에 더 유용하다.
      • 255자까지 저장가능하다.

    → TEXT

  • PK 단일키 VS FK 복합키

    • PK 단일키
    • FK 복합키
  • ENUM VS VARCHAR

    상황 쓰는 방식 이유
    값이 고정됨 (성별, 회원상태) ENUM ('MALE','FEMALE') 같은 건 변경 없음
    값이 확장 가능성 있다면? VARCHAR 값 추가할 때마다 DDL은 번거롭고 위험

    그럼 제약이 없는 VARCHAR가 무조건적으로 좋은 거 아닌가?

    → 고정된 값들은 DB에서 관리하여 값을 강제할 수 있고, 실수를 방지할 수 있다.

  • 알림 템플릿 DB VS HTML 파일 관리

    DB에 저장 시

    • 관리자가 직접 템플릿을 계속 바꾸고 싶을 때

    HTML의 장점 디스크 캐시나 classpath 템플릿이 더 빠름 (초기화 속도 빠름)

1️⃣ User Domain

users 테이블

컬럼명 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id INT PK, NOT NULL, UNSIGNED, AUTO_INCREMENT - 회원 고유 ID 회원이 84억 이상이 되기 어렵기 때문에 INT 사용
provider VARCHAR(20) NOT NULL - OAuth 제공자 (ex: kakao, apple, google 등) 20자 이내로 충분히 표현 가능
provider_id VARCHAR(50) NOT NULL - OAuth 제공자의 회원 고유 ID 예: Apple OAuth ID 36자, 50자면 충분
email VARCHAR(255) NOT NULL - 회원 이메일 주소 이메일은 표준적으로 최대 255자
nickname VARCHAR(20) NOT NULL - 서비스 내 활동명 카카오 닉네임 최대 길이 20자에 맞춤
profile_image_url VARCHAR(512) NOT NULL - 프로필 이미지 URL URL 길이를 고려하여 512자 설정
status ENUM('ACTIVE', 'WITHDRAWN') NOT NULL 'ACTIVE' 회원 상태 (활성화/탈퇴) 두 개의 고정 상태값이므로 ENUM 사용
created_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP 계정 생성일시 생성시 자동 기록을 위해 TIMESTAMP 사용
updated_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 계정 정보 수정일시 수정 시점 자동 기록을 위해 TIMESTAMP 사용
deleted_at DATETIME NULL - 회원 탈퇴일시 사람이 인식하는 일시 기준, 2038년 문제 없음

INDEX

  • UNIQUE (email)
    • 이메일 중복 방지용 (닉네임은 중복 허용)
  • INDEX (status, created_at)

고려 사항

  • idx_users_created_at 인덱스 생성
    • 매월 1일 개인정보이용내역 알림톡 발송 예정
    • 알림톡 발송을 위해 매월 25일 user의 가입한 달을 기준으로 배치를 돌릴 예정입니다. (다음 달에 알림톡 보낼 대상을 테이블에 INSERT하는 작업의 성능을 위해 인덱스 생성하였습니다.)

user_tokens 테이블

컬럼명 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id BIGINT PK, NOT NULL, UNSIGNED, AUTO_INCREMENT - 토큰 고유 ID 토큰은 한 사용자당 여러 개 발급될 수 있어 BIGINT 사용
user_id BIGINT FK, NOT NULL - 회원 ID (User 테이블 참조) 대규모 회원 수 대비 BIGINT 선택
refresh_token VARCHAR(255) NOT NULL - Refresh Token 값 토큰 문자열 길이를 고려해 255자 설정
device_info VARCHAR(255) NOT NULL - 로그인한 디바이스 정보 (브라우저, OS 등) 일반적인 User-Agent 정보 저장을 위해 255자 확보
is_revoked BOOLEAN NOT NULL FALSE 토큰 무효화 여부 (로그아웃 시 TRUE) 간단한 상태 관리용 BOOLEAN 사용
expired_at DATETIME NOT NULL - Refresh Token 만료 일시 특정 시점을 기록해야 하므로 DATETIME 사용
created_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP 토큰 생성일시 생성 시점 자동 기록용
updated_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 토큰 수정일시 수정 시점 자동 기록용

INDEX

  • UNIQUE (refresh_token)
    • 해당 토큰이 있는지 없는지 파악 / 중복 X
  • INDEX (status, expired_at)
    • 배치로 만료된 토큰 삭제하기 위함.

고려 사항

  • 추후 Redis로 토큰 관리 (MySQL VS Redis)

    MVP 단계에서는 MySQL을 사용하는 이유

    이유 MySQL Redis
    개발이 빠름 처음부터 Redis 연동 없이 JPA/쿼리로 빠르게 개발 가능
    로직 테스트 용이 로그아웃, 만료 로직 등을 쿼리로 직접 검증 가능 SELECT 쿼리가 없어서 KEYS *로 보아야하는데 위험하다
    토큰 이력 관리가 가능 누가 언제 로그인했는지, 어떤 기기에서 로그인했는지 파악 가능 메모리 기반이기 때문에 휘발성이다. RDB/AOF 설정

    Redis를 사용하는 이유

    1. 속도차이 - 인증은 매번 일어나므로 조회 속도와 부하처리가 빠른 Redis 사용
    2. JWT는 Stateless 하기 때문에 서버가 토큰 상태를 기억하지 않는다.
      1. 로그아웃을 해도 서버는 모르기 때문에 redis로 그 상태를 기억할 수 있다.
    3. TTL (만료 처리)에 유용하다.
      1. MySQL은 쿼리로 직접 관리해야하지만, Redis는 EXPIRE로 자동 삭제된다. → 유지보수 비용이 낮다.
    4. 서버 간 세션 공유가 쉽다. (분산 인증에 유리)
      1. 서버가 여러 대일 경우 Redis를 공통 저장소로 쓰면 어디서 로그인해도 동기화 가능하다.
      2. MySQL은 락 문제, 부하 문제가 발생할 수 있다. (동시성 문제 해결)
  • UNIQUE 와 INDEX의 차이

    UNIQUE중복 방지 + 인덱싱, INDEX검색 성능 향상만

    항목 INDEX UNIQUE INDEX
    중복 허용 가능 중복 불가
    목적 성능 향상 (검색 빠르게) 성능 + 유일성 보장
    예시 사용처 user_id, expired_at email, refresh_token, username

2️⃣ Group Domain

groups 테이블

컬럼명 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id INT PK, UNSIGNED, AUTO_INCREMENT - 모임 고유 ID 그룹이 84억 개 이상 생성될 가능성이 낮아 INT 사용
owner_id INT FK, NOT NULL - 모임장 회원 ID (방장 위임 없음) 방장이 변경될 가능성이 없으므로 안정적인 FK 설정
name VARCHAR(64) NOT NULL - 모임 이름 모임 이름은 최대 64자까지 입력 가능
summary VARCHAR(100) NULL - 모임 간단 소개 최대 100자 이내로 요약 작성 가능
description TEXT NULL - 모임 상세 설명 상세 설명은 최대 약 1000자 기준
category VARCHAR(30) NOT NULL - 모임 카테고리 (학습, 운동 등) 카테고리 확장성을 고려해 VARCHAR 선택
location VARCHAR(100) NULL - 주요 활동 지역 지역명 저장 (ex: 서울 강남구)
image_url VARCHAR(512) NOT NULL - 모임 대표 사진 URL URL 길이를 고려해 512자 설정
current_user TINYINT NOT NULL, UNSIGNED 1 현재 참여 인원 수 모임 생성 시 1명(모임장) default 설정
max_user TINYINT NULL, UNSIGNED NULL 최대 참여 가능 인원 (제한 없으면 NULL) 최대 인원 100명 이내로 가정해 TINYINT 사용
status VARCHAR(10) NOT NULL 'ACTIVE' 모임 상태 (ACTIVE: 운영중, DISBANDED: 해체됨) CHECK (status IN ('ACTIVE', 'DISBANDED')) 적용 예정
created_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP 모임 생성 일시 생성 시 자동 기록
deleted_at TIMESTAMP NULL NULL 모임 해체 일시 해체 시점 기록 (NULL 가능)

INDEX

  • INDEX(status, category, created_at DESC)
    • group의 list를 출력할 때 카테고리별로 정렬할 때 사용 (해체된 모임 제외)
  • INDEX (status, created_at, owner_id DESC)
    • 최신순 조회 (해체된 모임 빼고)

고려사항

  • current_user

    • 추후 가입 승인을 할 때마다 +1을 하여 그룹 참여자 테이블 조회(모임에 가입한 사용자를 count하는 작업)를 줄이고자 함.
    • 최대 인원이 존재하기 때문에 동시성 문제도 고려할 예정
  • cursor 기반 무한스크롤

    • cursor VS offset

      항목 Offset 방식 (OFFSET, LIMIT) Cursor 방식 (WHERE 기준 < cursor)
      기본 원리 전체 데이터 중 앞에서부터 건너뛰고 일부만 조회 특정 기준값(시간, ID) 이후(or 이전)의 데이터 조회
      페이지 번호 지원 page=1, page=2 등 명확함 페이지 번호 개념 없음
      중간 데이터 변경 시 정합성 삽입/삭제로 인해 중복/누락 발생 가능 기준점 이후부터 조회→ 중복/누락 없음
      성능 (데이터 많을수록) OFFSET N이 커질수록 느려짐 ``(앞에서부터 모두 읽고 잘라냄) 성능 매우 우수 (인덱스 타고 빠르게 탐색)
      쿼리 인덱스 활용도 낮음 (OFFSET 자체는 인덱스 안 탐) 높음 (인덱스가 있으면 빠름)
      데이터 정확성 우선 불리 (순서 꼬임 발생 가능) 유리 (항상 커서 이후 데이터만 조회)
      검색, 필터 조합 유연하게 사용 가능 정렬 기준 컬럼 하나로 고정되어야 함 (ORDER BY created_at)
    • 우리 서비스에는 cursor가 적합!

      • 무한 스크롤 사용 → 전체 페이지 갯수를 파악할 필요 X
      • 필터/검색 기능보단 인기순(회원이 많은 모임순)/최신순 정렬이 더 필요하다.
      • 삽입/삭제가 빈번하며, 피드 서비스이므로 정렬 중심임.
  • 모임 검색창

    • Elastisearch 사용 고려
  • views → 동시성 문제/DB 성능 최적화 고려

    추후 저희 서비스에서는 "모임 상세 페이지" 또는 "모임 카드"를 열람한 횟수를 기반으로

    모임의 인기순, 탐색률, 노출 우선순위 등을 판단하고자 합니다.

    단순히 매 요청마다 UPDATE group SET view_count = view_count + 1 식으로 처리하면

    • DB에 부하가 매우 큽니다.
    • 다수 사용자가 동시에 열람할 경우 동시성 이슈락 경쟁이 발생할 수 있습니다.

    조회수 누적은 단순한 기능처럼 보이지만, 트래픽이 몰릴 경우에는 동시성 문제가 발생할 수 있습니다.

    Kafka를 사용하는 것은 안정적인 비동기 처리와 확장성을 위한 선택이지만,

    서비스 초기에는 Redis 기반 누적 + 주기적 DB 반영 방식으로 충분하며,

    점진적으로 메시지 브로커 기반 구조로 확장해갈 수 있을 것 같습니다.

    → MVP 기준에선 DB+1을 사용하고 단기적으로는 Redis를, 장기적으로는 Kafka 기반 비동기 이벤트 처리 + Redis 누적 + 주기적 DB 업데이트를 사용하는 방식으로 동시성 문제 처리와 DB 성능 최적화를 해보고 싶습니다.

      1. Redis 처리흐름

      [1] 사용자 A가 게시글 상세 페이지 열람

      [2] Spring 서버에서 Redis에 INCR 요청 → view_count:post:{id}

      [3] Redis는 즉시 +1 수행 (메모리 연산 → 빠름)

      [4] 서버에서는 즉시 응답, DB는 건드리지 않음

      [5] 별도 배치 / Scheduled 작업이 일정 주기마다 Redis 누적값 → DB 반영

      1. Kafka 처리흐름

      [1] 사용자가 모임 상세 보기 → Kafka Topic("group-view")에 view 이벤트 발행

      [2] Kafka Consumer가 메시지를 수신하고, Redis에 모임별 누적 카운트 증가

      [3] 일정 주기마다 Redis에서 누적된 view 수를 읽어와 DB에 일괄 반영

      (e.g. UPDATE groups SET view_count = view_count + 57 WHERE id = ?)

hashtags 테이블

여보 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id INT PK,
UNSIGNED - 태그 식별 ID hashtag가 84억개 이상 생성되지 않는다고 생각했습니다.
tag VARCHAR(20) NOT NULL - 20글자 이내로 생성

AI가 직접 생성해주는 tag를 저장


group_hashtags 테이블

여보 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id BIGINT PK, UNSIGNED - 식별 ID id값의 제한을 두지 X
group_id INT FK - 모임 ID
tag_id INT FK - 태그 ID

INDEX

  • INDEX(group_id)
    • 그룹 id의 hashtag 조회

3️⃣ Group Participant Domain

group_participants 테이블

여보 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id BIGINT PK, UNSIGNED - 식별 ID 한 명이 다양한 모임을 참여할 수 있기 때문에 충분한 저장공간이 필요합니다.
user_id INT FK -
group_id INT FK -
role ENUM NOT NULL - 역할: LEADER: 모임장, MEMBER: 일반회원 두 가지의 경우밖에 존재하지 않기 때문에 ENUM으로 고정하였습니다.
status VARCHAR(10) NOT NULL - PENDING: 가입 신청 중, ACCEPTED: 가입 승인, REJECTED: 가입 거부, WITHDRAWN: 탈퇴한 회원, KICKED: 추방된 회원 기본값 X (하단에 자세히 설명), CHECK() 사용 예정
applied_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP 가입 신청 시각
joined_at TIMESTAMP NULL - 모임 가입 시각
deleted_at TIMESTAMP NULL - 탈퇴한 시각

INDEX

  • INDEX(group_id, status, joined_at)
    • 상황 1. 특정 모임에 가입한 사용자의 리스트를 조회
      • ex) B모임에 가입된 사람들의 리스트
    • 상황 2. 모임 가입 신청자의 정렬
      • ex) A모임의 가입 신청한 사람들의 리스트 (신청 순서 정렬은 id가 Auto Increament이므로 applied_at을 정렬로 사용하지 않아도 된다고 판단하였습니다.)
  • INDEX(user_id, status, role)
    • A 유저가 참여한 모임의 리스트 조회
    • 내가 모임장인 모임의 리스트

고려사항

  • status 기본값 X
    • 일반 회원들은 모두 PENDING으로 시작하지만, 모임장은 즉시 JOINED 상태가 되기 때문에
  • status로 관리
    • DELETE와 INSERT보다 UPDATE를 지향하기 때문에 사용자가 모임을 탈퇴했다가 다시 재가입신청을 하여도 튜플을 추가하는 것이 아닌, 상태값만 변경하면 된다.

4️⃣ Schedule Domain

schedules 테이블

여보 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id BIGINT PK - 일정 고유 ID 하나의 모임은 여러 일정을 가질 수 있습니다.
owner_id INT FK - 일정 생성자 ID
group_id INT FK -
title VARCHAR(100) NOT NULL - 일정 제목 일정 제목은 최대 100자까지 입력 가능합니다.
description VARCHAR(255) NOT NULL - 상세 설명 일정 상세는 최대 255자까지 입력 가능합니다.
address VARCHAR(255) NULL - 장소 명 온라인 일정일 경우 NULL일 수 있습니다.
current_member INT NOT NULL 1 참여중인 인원 - 일정 생성시 1명은 기본적으로 존재 (일정을 생성한 사람)
max_participants INT NULL - 최대 참여 인원
status VARCHAR(20) NOT NULL - 일정의 상태

RECRUITING: 모집중 CLOSED: 모집 마감 IN_PROGRESS: 일정 진행 중 COMPLETED: 종료됨 CANCELED: 취소됨 | 상태값이 확장되거나 축소될 수 있으므로 VARCHAR로 관리합니다.

DB에선 CHECK를 사용하여 옳바른 값인지를 확인하는 과정을 거칠 예정입니다. | | start_date | DATETIME | NOT NULL | - | 일정 시작 시간 | | | created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 생성일 | | | updated_at | TIMESTAMP | NOT NULL | - | 업데이트일 | | | delete_at | DATETIME | NULL | - | 취소일 | |

INDEX

  • INDEX(group_id, status, start_at)
    • 특정 모임의 일정을 시작시간을 기준으로 정렬
    • 상태값을 기준으로 종료된 일정, 다가오는 일정도 구분 가능)

5️⃣ Schedule Participant Domain

schedule_participants 테이블

여보 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id BIGINT PK - 참여 내역 ID
schedule_id BIGINT FK - 일정 참조
user_id INT FK - 회원 참조
status VARCHAR(20) NOT NULL JOINED 응답상태 PENDING: 대기중, ACCEPTED: 참석, REJECTED: 불참
created_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP 생성일
updated_at TIMESTAMP NULL - 업데이트일

INDEX

  • INDEX(user_id, status, joined_at)
    • 사용자가 참여중인 일정을 정렬한다.

고려 사항

  • SSE
    • 실시간으로 일정에 참가중인 사용자의 수가 올라가는 것을 보여주기 위해
  • 내가 참석한 일정은 Redis Set 사용 예정입니다.

6️⃣ 외부 Notices Domain

external_notices_tasks 테이블 (외부 알림 발송 예정)

컬럼명 데이터 타입 제약조건 기본값 설명 데이터 타입 선정 이유
id BIGINT PK - 메시지 고유 ID
user_id INT FK - 수신자 ID
content TEXT NOT NULL - 메시지 본문 내용
type VARCHAR(20) NOT NULL - 발송 유형 KAKAO: 카카오톡, EMAIL: 이메일
status ENUM NOT NULL PENDING 전송 상태 PENDING: 전송 대기, SUCCESS: 전송 성공, FAILED: 전송 실패
current_retry_count INT NOT NULL, UNSIGNED 0 현재 재전송 횟수
max_retry_count INT NOT NULL, UNSIGNED 3 최대 재전송 허용 횟수
scheduled_time DATETIME NULL - 실제 발송 예정 시각
created_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP 생성 시각

external_notices_logs 테이블 (외부 알림 로그)

컬럼명 데이터 타입 제약조건 기본값 설명 비고
id BIGINT PK - 로그 ID
message_id BIGINT FK - 원본 메시지 참조
user_id INT NOT NULL - 수신자 ID
content TEXT NOT NULL - 전송된 메시지 내용
channel ENUM NOT NULL - 발송 경로 KAKAO: 카카오톡, EMAIL: 이메일
is_success BOOLEAN NOT NULL - 전송 성공 여부 TRUE: 성공, FALSE: 실패
error_code VARCHAR(20) NULL - 오류 코드
error_message TEXT NULL - 오류 메시지
sent_at DATETIME NULL - 발송된 시각
created_at TIMESTAMP NOT NULL CURRENT_TIMESTAMP 로그 생성 시각

외부 알림 종류

1. privacy_notices_queue (개인정보 알림톡 발송 예정)

개인정보처리방침 고지를 위한 큐

추가설명

  • 구조는 schedule_notification_queue와 동일하며, 발송 목적과 템플릿이 다름

  • 개인정보 알림톡의 경우 서비스 가입일(해당 달)을 기준으로 1년에 한 번 전송할 예정입니다.

    • 매월 25일 배치를 돌릴 예정입니다. (다음 달에 알림톡 보낼 대상을 테이블에 INSERT하는 작업)
    • 실제 알림톡은 매월 1일에 발송.
    • ex) 2025.4.18일 가입자는 매년 4월에 전송

    알림톡 - 메세지 브로커 사용

    [1] 사용자 특정 행동 발생 (ex. 모임 신청, 일정 취소) [2] Spring Boot 서버가 Kafka에 "알림 이벤트" 전송 [3] Kafka 토픽에 메시지 저장됨 (ex. topic: notification-events) [4] 알림톡 Consumer 서버가 Kafka 구독하여 메시지 수신 [5] 외부 API (카카오 알림톡 등) 호출로 알림 전송 [6] 전송 성공 여부를 DB에 저장 or 실패 시 재시도

7️⃣ 내부 Notices Domain

internal_notices 테이블

여보 데이터 타입 제약조건 기본값 설명 비고
id BIGINT PK - 알림 ID 자동증가 PK 사용
user_id INT FK -
title VARCHAR(100) NOT NULL - 알림 제목 100자 이내
content TEXT NOT NULL - 알림 내용
is_read BOOLEAN NOT NULL ‘F’ 알림 읽음 여부 TRUE: 읽음, FALSE: 읽지 않음
type VARCHAR(20) NOT NULL - 알림 분류
created_at TIMESTAMP NOT NULL - 발송시간

INDEX

  • INDEX(user_id, created_at)
    • 사용자별 알림을 최신순으로 조회