Skip to content

[FEATURE] 팀 내 스쿼드(Squad) 기능 도입 #146

@ryuwldnjs

Description

@ryuwldnjs

배경

AS-IS

  • 팀 단위로 모든 팀원에게 동일한 문제 추천
  • 추천 설정(난이도, 요일, 태그, 문제수)이 팀 레벨에 존재

문제점

  • 팀원 간 실력 차이로 인해 난이도가 맞지 않는 경우 발생
  • 팀원마다 다른 난이도의 문제를 받고 싶지만 불가능

TO-BE

  • 팀 내 N개의 스쿼드를 구성하여 스쿼드별 추천 설정 가능
  • 실력별로 스쿼드를 나눠 각자에게 맞는 난이도의 문제 추천

설계 결정 사항

항목 결정 비고
명칭 Squad (스쿼드)
기본 스쿼드 팀 생성 시 자동 생성 삭제 불가
다중 소속 1인 1스쿼드 squad_id NOT NULL (Contract 배포 시 제약 추가)
미배정 허용 불허 팀 가입 시 기본 스쿼드 자동 배정
스쿼드 리더 없음 팀장이 모든 스쿼드 관리
멤버 관리 TeamMember에 squadId 추가 별도 SquadMember 테이블 X
추천 설정 Squad 독립 설정 Team 추천 설정 필드는 추후 별도 PR에서 제거
Team 추천 API deprecated 처리 응답에 deprecationMessage 추가, API 자체는 유지
TeamIncludeTag 일단 병렬 유지 SquadIncludeTag 신규 생성, TeamIncludeTag는 추후 제거

ERD

AS-IS

erDiagram
    Team ||--o{ TeamMember : has
    Team ||--o{ TeamIncludeTag : has
    Team ||--o{ Recommendation : generates
    TeamMember }o--|| Member : is
    Recommendation ||--o{ RecommendationProblem : contains
    Recommendation ||--o{ MemberRecommendation : tracks

    Team {
        Long id PK
        String name
        String description
        Boolean isPrivate
        RecommendationStatus recommendationStatus
        Integer recommendationDays
        ProblemDifficultyPreset problemDifficultyPreset
        Integer minProblemLevel
        Integer maxProblemLevel
        Integer problemCount
    }
Loading

TO-BE

erDiagram
    Team ||--o{ TeamMember : has
    Team ||--o{ Squad : contains
    Squad ||--o{ SquadIncludeTag : has
    Squad ||--o{ Recommendation : generates
    TeamMember }o--|| Member : is
    TeamMember }o--|| Squad : belongs
    Recommendation ||--o{ RecommendationProblem : contains
    Recommendation ||--o{ MemberRecommendation : tracks

    Team {
        Long id PK
        String name
        String description
        Boolean isPrivate
    }

    Squad {
        Long id PK
        Long teamId FK
        String name
        String description
        Boolean isDefault
        RecommendationStatus recommendationStatus
        Integer recommendationDays
        ProblemDifficultyPreset problemDifficultyPreset
        Integer minProblemLevel
        Integer maxProblemLevel
        Integer problemCount
    }

    TeamMember {
        Long id PK
        Long teamId FK
        Long memberId FK
        Long squadId FK
        TeamRole role
    }

    Recommendation {
        Long id PK
        Long teamId
        Long squadId
        RecommendationType type
    }

    MemberRecommendation {
        Long id PK
        Long memberId FK
        Long recommendationId FK
        Long teamId
        String teamName
        Long squadId
        EmailSendStatus emailSendStatus
    }
Loading

Recommendation/MemberRecommendation의 teamId, squadId: FK가 아닌 일반 Long — 팀/스쿼드 삭제 후에도 이력 보존


도메인 책임 분리

flowchart LR
    subgraph Team도메인["team/"]
        Team["Team\n팀원 관리"]
        Squad["Squad\n추천 설정"]
        TeamMember["TeamMember"]
    end

    subgraph Recommendation도메인["recommendation/"]
        Rec["Recommendation\n추천 생성/발송"]
    end

    Squad --> Rec
    TeamMember --> Squad
Loading

API 설계

v1 — Deprecated

Contract 배포 시 (프론트엔드 v2 전환 완료 이후) 완전 제거

Method URL 대체
GET /api/teams/{teamId} GET /api/v2/teams/{teamId}
GET /api/teams/public GET /api/v2/teams/public
GET /api/teams/my GET /api/v2/teams/my
GET /api/teams/{teamId}/recommendation-settings Squad 추천설정 API로 대체
PUT /api/teams/{teamId}/recommendation-settings Squad 추천설정 API로 대체
DELETE /api/teams/{teamId}/recommendation-settings Squad 추천설정 API로 대체
POST /api/recommendation/team/{teamId}/manual POST /api/teams/{teamId}/squads/{squadId}/recommendation/manual

v2 — 팀 페이지 (스쿼드 기반)

Method URL 설명
GET /api/v2/teams/{teamId} 팀 페이지 (스쿼드 포함)
GET /api/v2/teams/public 공개 팀 목록
GET /api/v2/teams/my 내 팀 목록

GET /api/v2/teams/{teamId} 응답 구조:

TeamPageResponse (v2)
├── TeamInfo (id, name, description, isPrivate, memberCount)
├── List<TeamMemberResponse> (memberId, handle, email, role, isMe, squadId, squadName)
├── List<SquadSummaryResponse>
│   └── squadId, name, description, isDefault, memberCount
│       isActive, recommendationDays, problemDifficultyPreset,
│       minProblemLevel, maxProblemLevel, problemCount, includeTags
└── TodayProblemResponse (로그인 유저의 스쿼드 기준)

Squad CRUD (팀 리더 전용)

Method URL 설명
POST /api/teams/{teamId}/squads 스쿼드 생성 (비활성 상태로 생성, 최대 5개)
GET /api/teams/{teamId}/squads 스쿼드 목록 조회
PUT /api/teams/{teamId}/squads/{squadId} 스쿼드 이름/설명 수정
DELETE /api/teams/{teamId}/squads/{squadId} 스쿼드 삭제 (기본 스쿼드 불가)
GET /api/teams/{teamId}/squads/{squadId}/recommendation-settings 추천 설정 조회
PUT /api/teams/{teamId}/squads/{squadId}/recommendation-settings 추천 설정 수정 (태그 포함 전체 교체)
DELETE /api/teams/{teamId}/squads/{squadId}/recommendation-settings 추천 비활성화
PUT /api/teams/{teamId}/members/{memberId}/squad 팀원 스쿼드 변경
POST /api/teams/{teamId}/squads/{squadId}/recommendation/manual 수동 추천 생성

비즈니스 규칙

  • 기본 스쿼드(isDefault=true)는 삭제 불가
  • 스쿼드는 팀당 최대 5개 (기본 스쿼드 포함)
  • 스쿼드 삭제 시 소속 멤버는 기본 스쿼드로 자동 이동
  • 스쿼드 삭제 시 해당 스쿼드의 기존 Recommendation은 그대로 유지
  • 팀 가입 승인 시 기본 스쿼드에 자동 배정
  • 추천 활성화: recommendationDays 1개 이상 → ACTIVE, 빈 배열 → INACTIVE
  • 추천 중복 체크: 팀 기준 → 스쿼드 기준 (같은 팀 내 다른 스쿼드는 별도 추천 가능)

API 접근 권한

  • 스쿼드 목록 조회: 팀원 전체 가능
  • 스쿼드 생성/수정/삭제/추천설정 변경: 팀장 전용
  • 팀원 스쿼드 변경: 팀장 전용

배포 전략 (Expand-Contract 패턴)

블루-그린 배포 환경에서 무중단으로 마이그레이션하기 위해 Expand-Contract 패턴을 적용한다.

배포 단계별 흐름

flowchart TD
    A[1차 DB 배포\nExpand: 스키마 추가만] --> B[1차 스프링 배포\nDual Write + 구 스키마 기준 동작]
    B --> C[2차 DB 배포\nBackfill: 기존 데이터 보정]
    C --> D[2차 스프링 배포\nDual Write 제거 + 신규 스키마 기준 동작]
    D --> E[Contract 배포 - 추후 별도 PR\n구 스키마 제거]
Loading

1차 DB 배포 — Expand (스키마 추가만)

기존 스프링 서버가 운영 중인 상태에서 실행. 모든 변경이 nullable이므로 기존 코드에 영향 없음.

마이그레이션 파일: V2__add_squad_schema.sql

-- Squad 테이블 생성
CREATE TABLE squad ( ... );

-- SquadIncludeTag 테이블 생성
CREATE TABLE squad_include_tag ( ... );

-- TeamMember에 squad_id 추가 (nullable)
ALTER TABLE team_member ADD COLUMN squad_id BIGINT NULL;

-- Recommendation에 squad_id 추가 (nullable)
ALTER TABLE recommendation
    ADD COLUMN squad_id BIGINT NULL;

-- MemberRecommendation에 squad_id 추가 (nullable)
ALTER TABLE member_recommendation
    ADD COLUMN squad_id BIGINT NULL;

⚠️ squad_id NOT NULL 제약은 이 단계에서 추가하지 않는다. 기존 서버가 squad_id를 모르는 상태에서 NOT NULL을 걸면 TeamMember INSERT 시 에러 발생.


1차 스프링 배포 — Dual Write (구 스키마 기준 동작)

새 스키마에 동시에 쓰되, 읽기/동작은 여전히 Team 기준 유지.

코드 변경:

  • Squad 도메인 전체 구현 (Entity, Repository, Service, Controller)
  • Squad CRUD API + 팀원 스쿼드 변경 API
  • 팀 생성 시 → Team 저장 + 기본 Squad 동시 생성
  • 추천 설정 변경 시 → Team + Squad 동시 업데이트 (Dual Write)
  • 팀 가입 승인 시 → TeamMember 저장 + squad_id 기본 스쿼드로 설정
  • 스쿼드 삭제 시 → 소속 멤버 기본 스쿼드로 이동
  • 스케줄러/읽기: Team 기준 유지 (동작 변경 없음)

블루-그린 전환 안전성:

  • 구 Blue 서버: Squad 테이블/컬럼 미사용 → nullable이므로 INSERT 정상
  • 신 Green 서버: Dual Write로 두 스키마 동시 유지
  • 전환 구간에 팀 추천설정이 변경되어도 Green이 Squad까지 동기화

2차 DB 배포 — Backfill (기존 데이터 보정)

1차 스프링 배포 이전에 생성된 데이터(Dual Write 이전) 보정. 1차 스프링이 운영 중인 상태에서 실행.

마이그레이션 파일: V3__backfill_squad_data.sql

-- 기존 팀마다 기본 Squad 생성 (Dual Write로 이미 생성된 팀은 스킵)
INSERT INTO squad (team_id, name, is_default, recommendation_status, ...)
SELECT id, '기본 스쿼드', true, recommendation_status, ...
FROM team
WHERE NOT EXISTS (
    SELECT 1 FROM squad WHERE squad.team_id = team.id AND is_default = true
);

-- TeamIncludeTag → SquadIncludeTag 복사 (미복사분만)
INSERT INTO squad_include_tag (squad_id, tag)
SELECT s.id, t.tag
FROM team_include_tag t
JOIN squad s ON s.team_id = t.team_id AND s.is_default = true
WHERE NOT EXISTS (
    SELECT 1 FROM squad_include_tag sit
    WHERE sit.squad_id = s.id AND sit.tag = t.tag
);

-- TeamMember.squad_id 보정 (squad_id IS NULL인 것만)
UPDATE team_member tm
JOIN squad s ON s.team_id = tm.team_id AND s.is_default = true
SET tm.squad_id = s.id
WHERE tm.squad_id IS NULL;

WHERE ... IS NULL / NOT EXISTS: Dual Write로 이미 채워진 데이터는 덮어쓰지 않음. 1차 스프링이 운영 중에 실행되어도 안전.


2차 스프링 배포 — Contract 1단계 (신규 스키마 기준 전환)

Dual Write 제거, Squad 기준으로 전환.

코드 변경:

  • Dual Write 코드 제거 (Squad만 업데이트)
  • 스케줄러: Team → Squad 기준으로 전환
  • 추천 중복 체크: 팀 기준 → 스쿼드 기준
  • 수동 추천 API: SquadController로 이동
  • Team 추천 API: deprecationMessage 필드 추가

배포 시간대 제한:

  • 스케줄러 실행 시간(새벽 6시, 오전 9시) 전후 배포 금지
  • 블루-그린 전환 구간에 스케줄러 중복 실행 방지

Contract 배포 — 추후 별도 PR

작업 내용
squad_id NOT NULL 제약 추가 이 시점엔 모든 서버가 squad_id를 씀
Team 추천 설정 컬럼 제거
TeamIncludeTag 테이블 제거
Team 추천 API 완전 제거

추후 개선 사항

스쿼드 중간 합류 정책

현재 (1차 구현):

  • 스쿼드 변경 즉시 반영
  • 중간 합류자도 기존 로직대로 미션 참여 및 해결 인증 가능

문제점:

  • 합류 전에 이미 푼 문제가 미션에 있을 경우 공정성 이슈
  • solved.ac에서는 풀이 시점을 알 수 없어 합류 후 풀었는지 검증 불가

개선 방향 (추후):

스쿼드 변경 시:
1. TeamMember.squadId 즉시 업데이트
2. 해당 미션 사이클: 조회 불가 + 해결 인증 불가
3. 다음 사이클(06:00)부터 새 스쿼드 미션 참여

구현 방식:

  • MemberRecommendation에 squadId 스냅샷 저장 (현재 teamId처럼)
  • 미션 조회/인증 시: MemberRecommendation.squadId == 현재 squadId 체크
  • 불일치 시 "다음 미션부터 참여 가능" 안내

결정 완료 항목

  • 스쿼드 설정 항목 → Squad 독립 설정 (요일, 난이도, 문제 수, 태그)
  • 기본 스쿼드 삭제 → 불가
  • 스쿼드 최대 개수 제한 → 팀당 최대 5개 (기본 스쿼드 포함)
  • 스쿼드 없는 팀원 허용 → 불허 (squad_id NOT NULL, Contract 배포 시 제약 추가)
  • 명칭 → Squad 확정
  • Team 추천 설정 API → deprecated 필드 추가로 유지, 실제 제거는 추후 PR
  • 수동 추천 API → POST /api/teams/{teamId}/squads/{squadId}/recommendation/manual 으로 변경
  • 이메일 → 팀명만 유지 (squad명 추가 없음)
  • 신규 스쿼드 초기 상태 → 비활성 (추천 설정 후 직접 활성화)
  • Contract 배포 시점 → 프론트엔드 deprecated API 사용 완전 제거 이후
  • 배포 전략 → Expand-Contract 패턴 (1차 DB → 1차 스프링 → 2차 DB Backfill → 2차 스프링)
  • 마이그레이션 SQL → 배포당 파일 하나 (V2__add_squad_schema.sql, V3__backfill_squad_data.sql)
  • API 접근 권한 → 스쿼드 목록 조회는 팀원 가능, 나머지 변경 작업은 팀장 전용
  • 스쿼드 삭제 시 기존 Recommendation → 그대로 유지

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions