Base URL:
https://api.readb.io(개발:http://localhost:8080)
버전:v1| 문서 버전: v2.9 (2026-06-15)
인증: JWT Bearer Token (Authorization: Bearer <token>)
응답 형식: 모든 응답은ApiResponse<T>래퍼 사용
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": { ... }
}{
"success": false,
"code": "USER_NOT_FOUND",
"message": "사용자를 찾을 수 없습니다.",
"data": null
}| 코드 | 의미 |
|---|---|
| 200 | OK |
| 201 | Created |
| 202 | Accepted (비동기 작업 시작) |
| 400 | Bad Request (입력 오류) |
| 401 | Unauthorized (토큰 없음/만료) |
| 403 | Forbidden (권한 없음) |
| 404 | Not Found |
| 409 | Conflict (중복) |
| 413 | Payload Too Large (파일 크기 초과) |
| 500 | Internal Server Error |
POST /api/v1/auth/signup
인증 필요: X
Request Body
{
"email": "kang@company.com",
"password": "password123!",
"name": "강다은",
"role": "MEMBER",
"jobTitle": "시니어 FE 엔지니어"
}| 필드 | 타입 | 필수 | 검증 규칙 |
|---|---|---|---|
| string | O | 이메일 형식, 중복 불가 | |
| password | string | O | 8자 이상, 영문+숫자+특수문자 포함 |
| name | string | O | 2~20자 |
| role | string | O | LEADER 또는 MEMBER |
| jobTitle | string | X | 최대 50자 |
Response 201
{
"success": true,
"code": "SUCCESS",
"message": "회원가입이 완료되었습니다.",
"data": {
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci...",
"user": {
"id": 1,
"email": "kang@company.com",
"name": "강다은",
"role": "MEMBER",
"jobTitle": "시니어 FE 엔지니어",
"teamId": null
}
}
}회원가입 즉시 토큰을 발급하여 로그인 없이 바로 서비스 진입 가능
에러
| 코드 | HTTP | 상황 |
|---|---|---|
| EMAIL_ALREADY_EXISTS | 409 | 이미 가입된 이메일 |
| INVALID_INPUT | 400 | 필수 필드 누락 또는 형식 오류 |
POST /api/v1/auth/login
인증 필요: X
Request Body
{
"email": "lee@company.com",
"password": "password123!"
}Response 200
{
"success": true,
"code": "SUCCESS",
"message": "로그인 성공",
"data": {
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci...",
"user": {
"id": 2,
"email": "lee@company.com",
"name": "이준혁",
"role": "LEADER",
"jobTitle": "팀장",
"teamId": 1
}
}
}| 토큰 | 만료 |
|---|---|
| accessToken | 1시간 |
| refreshToken | 14일 |
에러
| 코드 | HTTP | 상황 |
|---|---|---|
| INVALID_CREDENTIALS | 401 | 이메일 또는 비밀번호 불일치 |
POST /api/v1/auth/refresh
인증 필요: X
Request Body
{
"refreshToken": "eyJhbGci..."
}Response 200
{
"success": true,
"code": "SUCCESS",
"message": "토큰이 갱신되었습니다.",
"data": {
"accessToken": "eyJhbGci...(새 토큰)",
"refreshToken": "eyJhbGci...(새 토큰)"
}
}에러
| 코드 | HTTP | 상황 |
|---|---|---|
| EXPIRED_TOKEN | 401 | refreshToken 만료 |
| INVALID_TOKEN | 401 | 유효하지 않은 토큰 |
GET /api/v1/users/me
Authorization: Bearer <token>
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"id": 1,
"email": "kang@company.com",
"name": "강다은",
"role": "MEMBER",
"jobTitle": "시니어 FE 엔지니어",
"teamId": 1,
"teamName": "Product A팀"
}
}PUT /api/v1/users/me
Authorization: Bearer <token>
Request Body
{
"name": "강다은",
"jobTitle": "시니어 FE 엔지니어"
}| 필드 | 타입 | 필수 | 검증 규칙 |
|---|---|---|---|
| name | string | O | 2~20자 (공백 불가) |
| jobTitle | string | X | 최대 50자. null 또는 빈 문자열 전송 시 직함 제거 |
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "프로필이 수정되었습니다.",
"data": {
"id": 1,
"email": "kang@company.com",
"name": "강다은",
"role": "MEMBER",
"jobTitle": "시니어 FE 엔지니어",
"teamId": 1,
"teamName": "Product A팀"
}
}회원가입 시 입력한 이름/직함을 멤버가 직접 수정할 수 있는 엔드포인트.
변경 후 FE는authStore와localStorage토큰을 즉시 갱신하여 화면에 반영.
에러
| 코드 | HTTP | 상황 |
|---|---|---|
| INVALID_INPUT | 400 | name 빈 값 |
| UNAUTHORIZED | 401 | 토큰 없음/만료 |
POST /api/v1/teams
Authorization: Bearer <token> (LEADER)
Request Body
{
"name": "Product A팀"
}| 필드 | 타입 | 필수 | 검증 규칙 |
|---|---|---|---|
| name | string | O | 2~30자 |
Response 201
{
"success": true,
"code": "SUCCESS",
"message": "팀이 생성되었습니다.",
"data": {
"id": 1,
"name": "Product A팀",
"leaderId": 2,
"leaderName": "이준혁",
"inviteCode": "READB-A1B2C3"
}
}POST /api/v1/teams/join
Authorization: Bearer <token> (MEMBER)
Request Body
{
"inviteCode": "READB-A1B2C3"
}Response 200
{
"success": true,
"code": "SUCCESS",
"message": "팀에 참여했습니다.",
"data": {
"teamId": 1,
"teamName": "Product A팀"
}
}에러
| 코드 | HTTP | 상황 |
|---|---|---|
| TEAM_NOT_FOUND | 404 | 유효하지 않은 초대 코드 |
| ALREADY_IN_TEAM | 409 | 이미 팀에 소속된 멤버 |
GET /api/v1/teams/{teamId}/members
Authorization: Bearer <token>
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": [
{
"id": 1,
"name": "강다은",
"jobTitle": "시니어 FE 엔지니어",
"role": "MEMBER"
},
{
"id": 3,
"name": "김민준",
"jobTitle": "FE 엔지니어",
"role": "MEMBER"
}
]
}GET /api/v1/teams/{teamId}/dashboard
Authorization: Bearer <token> (LEADER)
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"teamId": 1,
"teamHealthScore": 74.0,
"trendDelta": 5.2,
"statusNote": "이번 달 팀 소통 참여도가 지난 3개월 평균보다 높아졌습니다.",
"alerts": [
"강다은 — Initiative 최근 3회 평균 대비 100% 감소",
"윤재원 — Safety Score 기준 이하 (Silent Risk)"
]
}
}| 필드 | 타입 | 설명 |
|---|---|---|
teamHealthScore |
number | safetyScore_avg × 0.6 + surveyScore_avg × 0.4 |
trendDelta |
number | null | 이번 달 평균 − 직전 3개월 월별 평균의 평균. 양수: 개선, 음수: 하락. 데이터 부족 시 null |
statusNote |
string | null | 규칙 기반 평문 요약. 내부 지표 용어 미포함. 데이터 부족 시 null |
alerts |
string[] | 30% 이상 하락한 멤버 알림. Fact-Based 문구, AI 해석 라벨 없음 |
GET /api/v1/teams/{teamId}/blocker-pyramid
Authorization: Bearer <token> (LEADER)
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"blockerKeywords": [
{ "keyword": "QA 리소스 부족", "count": 5, "mentionedBy": 3 },
{ "keyword": "프론트엔드 소통 지연", "count": 4, "mentionedBy": 2 },
{ "keyword": "코드 리뷰 병목", "count": 4, "mentionedBy": 3 },
{ "keyword": "기획 변경 잦음", "count": 3, "mentionedBy": 2 },
{ "keyword": "API 스펙 불명확", "count": 3, "mentionedBy": 1 }
],
"actionPrescriptions": [
{
"severity": "ERROR",
"title": "QA 리소스 부족 반복 언급",
"dataSummary": "3명의 멤버가 최근 3회 미팅에서 총 5회 언급",
"actionGuide": "이번 주 주간 회의에서 배포 일정 조정 또는 QA 인력 충원 안건을 상정하세요."
},
{
"severity": "WARNING",
"title": "Speech Act 급감 멤버 감지",
"dataSummary": "강다은, 윤재원 — Initiative 최근 3회 평균 대비 60% 이상 감소",
"actionGuide": "이번 주 안에 비공식 1on1을 먼저 배정하세요."
},
{
"severity": "INFO",
"title": "Alignment Gap 개선 가능",
"dataSummary": "팀 평균 Alignment Gap 35점 (논의했으나 결론 없음 수준)",
"actionGuide": "스프린트 킥오프 전 30분 내 의제 사전 공유를 도입하세요."
}
]
}
}Fact-Based 원칙 적용:
aiDiagnosis(AI 종합 진단) 제거 → 대신 각 처방전에dataSummary(수치 근거) +actionGuide(행동 처방) 분리
GET /api/v1/teams/{teamId}/talk-ratio-ranking
Authorization: Bearer <token> (LEADER)
팀 대시보드 '1on1 소통 균형' 패널에 사용. 각 멤버의 최신 1on1 발화 비율을 leaderRatio 내림차순으로 반환.
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": [
{ "memberId": 3, "name": "김민준", "leaderRatio": 72, "memberRatio": 28, "status": "위험" },
{ "memberId": 1, "name": "강다은", "leaderRatio": 55, "memberRatio": 45, "status": "관찰" },
{ "memberId": 2, "name": "박지호", "leaderRatio": 38, "memberRatio": 62, "status": "적정" }
]
}| 필드 | 설명 |
|---|---|
leaderRatio |
GPT 분석 기준 리더 발화 비율 (%) |
memberRatio |
GPT 분석 기준 멤버 발화 비율 (%) |
status |
위험 (leaderRatio ≥ 70) / 관찰 (50–69) / 적정 (< 50) |
분석 완료(COMPLETED) 미팅이 없는 멤버는 결과에서 제외됩니다. data가 빈 배열이면 팀 내 완료 미팅 없음.
GET /api/v1/teams/{teamId}/quadrant
Authorization: Bearer <token> (LEADER)
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": [
{
"memberId": 1,
"memberName": "강다은",
"surveyScore": 87.0,
"safetyScore": 31.0,
"honestyGap": 56.0,
"direction": "OVERREPORT",
"riskLevel": "DANGER"
},
{
"memberId": 3,
"memberName": "김민준",
"surveyScore": 80.0,
"safetyScore": 82.0,
"honestyGap": -2.0,
"direction": "UNDERREPORT",
"riskLevel": "SAFE"
}
]
}응답은
List<RadarDataPoint>(배열). quadrant 문자열 대신direction+riskLevel로 분리.
FE에서 사분면 판별:surveyScore ≥ 45 && safetyScore ≥ 45→ STABLE 등 (PRD 3.3 참조).
사분면 기준 (X축: surveyScore, Y축: safetyScore, 임계값 45)
사분면 조건 색상 의미 STABLE survey ≥ 45 && safety ≥ 45 green 안정 (표면+행동 모두 양호) SILENT_RISK survey ≥ 45 && safety < 45 amber 조용한 위험 (괜찮다고 하지만 행동 지표 낮음) EXPLICIT_RISK survey < 45 && safety < 45 red 명시적 위험 (표면+행동 모두 부정) CONSERVATIVE survey < 45 && safety ≥ 45 indigo 보수적 응답 (서베이는 부정이지만 행동은 활발) 임계값 변경 이유: Safety Score 기준선 40 도입 후 모든 멤버가 45+ 구간으로 이동하여 50 기준은 의미 없음. 45로 하향하여 판별력 회복.
POST /api/v1/meetings
Authorization: Bearer <token> (LEADER)
Request Body
{
"memberId": 1,
"scheduledAt": "2026-05-08T14:00:00"
}| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| memberId | number | O | 1on1 대상 멤버 ID |
| scheduledAt | string | X | 예정 일시 (ISO-8601). 생략 시 현재 시각 |
Response 201
{
"success": true,
"code": "SUCCESS",
"message": "미팅이 생성되었습니다.",
"data": {
"meetingId": 10,
"round": 12,
"memberId": 1,
"memberName": "강다은",
"scheduledAt": "2026-05-08T14:00:00",
"status": "PENDING",
"overduePromises": [
{
"promiseId": 2,
"content": "AWS 프로덕션 접근 권한 부여",
"dueDate": "2026-04-20"
}
]
}
}
round: 해당 리더-멤버 페어의 누적 회차 (서버 자동 산출)
overduePromises: 미이행 약속 자동 포함 (F-63 요구사항)
GET /api/v1/meetings?memberId={memberId}
Authorization: Bearer <token>
Query Params
| 파라미터 | 필수 | 설명 |
|---|---|---|
| memberId | X | 특정 멤버 필터 (리더 전용). 생략 시 역할에 따라: 리더는 본인이 진행한 1on1 전체, 멤버는 본인이 멤버로 참여한 리더와의 1on1만 (leaderId == memberId인 셀프 미팅 제외) |
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": [
{
"meetingId": 10,
"round": 12,
"partnerName": "강다은",
"scheduledAt": "2026-05-08T14:00:00",
"durationSec": 2400,
"status": "COMPLETED"
},
{
"meetingId": 11,
"round": 1,
"partnerName": "김민준",
"scheduledAt": "2026-05-09T10:00:00",
"durationSec": null,
"status": "CREATED"
}
]
}GET /api/v1/meetings/{meetingId}
Authorization: Bearer <token>
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"meetingId": 10,
"memberId": 1,
"round": 12,
"scheduledAt": "2026-05-08T14:00:00",
"durationSec": 2400,
"status": "CREATED",
"leaderName": "이준혁",
"memberName": "강다은",
"surveySubmitted": true
}
}
surveySubmitted: 멤버의 사전 서베이 제출 여부. 리더가 미팅 시작 전 확인용.memberId: 해당 미팅 멤버 ID. 리더 미팅 진행 화면이 이 값으로 멤버의 액션 플랜·약속 체크리스트(/members/{memberId}/insight)를 로드한다.
DELETE /api/v1/meetings/{meetingId}
Authorization: Bearer <token>
인증 필요: O (리더 전용)
예정 시각에 진행되지 않은 미팅을 취소(삭제)한다. 회차는 저장값이 아니라 동적으로 계산되므로, 취소 시 이후 미팅들의 회차 번호가 자동으로 1씩 감소한다. 멤버가 미리 제출한 서베이가 있으면 함께 삭제된다.
제약
- 미팅의 리더 본인만 호출 가능 (아니면
403 FORBIDDEN) CREATED(진행 전) 상태만 취소 가능. 진행 중/완료된 미팅은409 MEETING_NOT_CANCELABLE
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": null
}| 에러 코드 | 상태 | 의미 |
|---|---|---|
| MEETING_NOT_FOUND | 404 | 미팅 없음 |
| FORBIDDEN | 403 | 리더 본인 미팅 아님 |
| MEETING_NOT_CANCELABLE | 409 | 이미 진행되었거나 완료된 미팅 |
POST /api/v1/surveys
Authorization: Bearer <token> (MEMBER)
Request Body
{
"meetingId": 10,
"issues": ["업무 블로커", "커리어 성장"],
"energyLevel": 3,
"desiredRoles": ["방향성 코칭"]
}| 필드 | 타입 | 필수 | 검증 규칙 |
|---|---|---|---|
| meetingId | number | O | 존재하는 미팅 + 요청자가 해당 미팅의 멤버 본인이어야 함 (리더 등 타인이 제출하면 403). 같은 미팅에 서베이 1건만 허용 |
| issues | string[] | O | 1개 이상 선택 |
| energyLevel | number | O | 1~5 정수 |
| desiredRoles | string[] | O | 1개 이상 선택 |
이슈 선택지 (FE 체크박스 렌더링)
| 값 | 표시 텍스트 | 점수 영향 |
|---|---|---|
업무 블로커 |
업무 진행을 막는 이슈가 있어요 | -10 |
리소스 요청 |
인력/도구 등 리소스가 부족해요 | -5 |
커리어 성장 |
커리어 방향에 대해 이야기하고 싶어요 | 0 |
팀 분위기 |
팀 분위기/협업에 대해 이야기하고 싶어요 | -3 |
프로세스 개선 |
프로세스 개선이 필요해요 | -3 |
인정/피드백 |
인정이나 피드백을 받고 싶어요 | 0 |
기타 |
기타 주제 | 0 |
기대 역할 선택지
| 값 | 표시 텍스트 |
|---|---|
방향성 코칭 |
방향성을 잡아주세요 |
의사결정 도움 |
의사결정을 도와주세요 |
리소스 지원 |
리소스를 확보해 주세요 |
경청/공감 |
들어주시고 공감해 주세요 |
기술적 멘토링 |
기술적 조언이 필요해요 |
Response 201
{
"success": true,
"code": "SUCCESS",
"message": "서베이가 제출되었습니다.",
"data": {
"surveyId": 5,
"surveyScore": 50.0,
"submittedAt": "2026-05-08T13:30:00"
}
}surveyScore 산출 로직 (서버 자동 계산)
기본점 =energyLevel × 20(20100)100 클램핑
이슈 가감점 적용 → 0
에러
| 코드 | HTTP | 상황 |
|---|---|---|
| SURVEY_ALREADY_SUBMITTED | 409 | 이미 서베이를 제출한 미팅 |
| FORBIDDEN | 403 | 해당 미팅의 멤버 본인이 아님 (리더 포함 타인 제출 차단) |
| MEETING_NOT_FOUND | 404 | 존재하지 않는 미팅 |
GET /api/v1/surveys/{meetingId}
Authorization: Bearer <token>
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"surveyId": 5,
"issues": ["업무 블로커", "커리어 성장"],
"energyLevel": 3,
"desiredRoles": ["방향성 코칭"],
"surveyScore": 50.0,
"submittedAt": "2026-05-08T13:30:00"
}
}GET /api/v1/surveys/history
Authorization: Bearer <token> (MEMBER)
Query Params (Spring Pageable)
| 파라미터 | 필수 | 기본값 | 설명 |
|---|---|---|---|
| page | X | 0 | 페이지 번호 (0-indexed) |
| size | X | 20 | 페이지 크기 |
| sort | X | submittedAt,desc | 정렬 기준 |
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"content": [
{
"meetingId": 10,
"submittedAt": "2026-05-08T13:30:00",
"scores": {
"energyLevel": 3,
"issues": ["업무 블로커", "커리어 성장"],
"desiredRoles": ["방향성 코칭"],
"surveyScore": 50.0
}
}
],
"totalElements": 12,
"totalPages": 1,
"size": 20,
"number": 0
}
}본인의 과거 서베이 응답 이력을 최신순으로 조회.
scores필드는 JSONB 그대로 반환.
POST /api/v1/meetings/{meetingId}/recording
Authorization: Bearer <token>
Content-Type: multipart/form-data
Request
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| file | File | O | WebM 파일 (최대 25MB) |
| durationSec | number | X | 녹음 길이(초). FE에서 MediaRecorder로 측정하여 전송 |
FE 구현 예시
// features/meeting/useUploadRecording.ts
const uploadRecording = async (meetingId: number, blob: Blob, durationSec: number) => {
const formData = new FormData();
formData.append('file', blob, 'recording.webm');
formData.append('durationSec', String(durationSec));
const response = await client.post(
`/meetings/${meetingId}/recording`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
return response.data;
};Response 202 (즉시 반환, 비동기 분석 시작)
{
"success": true,
"code": "SUCCESS",
"message": "분석이 시작되었습니다.",
"data": {
"meetingId": 10,
"status": "ANALYZING",
"message": "/api/v1/analysis/{meetingId}/status 에서 진행률을 확인하세요."
}
}에러
| 코드 | HTTP | 상황 |
|---|---|---|
| INVALID_FILE_FORMAT | 400 | WebM이 아닌 파일 |
| FILE_TOO_LARGE | 413 | 25MB 초과 |
| MEETING_NOT_FOUND | 404 | 존재하지 않는 미팅 |
| ANALYSIS_IN_PROGRESS | 409 | 이미 분석이 진행 중인 미팅 |
GET /api/v1/meetings/{meetingId}/status
Authorization: Bearer <token>
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"meetingId": 10,
"step": "NLP",
"stepNumber": 2,
"totalSteps": 4,
"progress": 45,
"stepLabel": "행간의 의미를 읽는 중...",
"wittyMessage": {
"leader": "행간의 한숨까지 읽는 중...",
"member": "대화 속 나의 패턴을 분석 중..."
}
}
}step 값 목록
| step | stepNumber | progress 범위 | stepLabel |
|---|---|---|---|
| PENDING | 0 | 0 | 분석 준비 중... |
| STT | 1 | 1~25 | 대화를 텍스트로 바꾸는 중... |
| NLP | 2 | 26~50 | 행간의 의미를 읽는 중... |
| SCORING | 3 | 51~75 | 3대 Gap을 계산하는 중... |
| FEEDBACK | 4 | 76~99 | 피드백을 다듬는 중... |
| COMPLETED | 4 | 100 | 분석 완료 |
| FAILED | - | - | 분석 실패 |
위트 카피 (step별)
| Step | 리더용 | 멤버용 |
|---|---|---|
| STT | "팀원이 '괜찮다'고 했을 때, 진짜 괜찮은 건지 확인 중..." | "오늘 내가 얼마나 솔직했는지 들여다보는 중..." |
| NLP | "행간의 한숨까지 읽는 중..." | "대화 속 나의 패턴을 분석 중..." |
| SCORING | "점수로 환산 불가한 감정을 억지로 환산 중..." | "오늘 대화의 진짜 무게를 재는 중..." |
| FEEDBACK | "어떻게 말해야 덜 아플지 고민 중..." | "다음엔 어떻게 말하면 좋을지 정리 중..." |
FE 폴링 구현 규칙
| 항목 | 값 |
|---|---|
| 폴링 간격 | 3초 (setInterval 3000ms) |
| 타임아웃 | 3분 (초과 시 에러 화면 + 재업로드 유도) |
| 종료 조건 | step === "COMPLETED" → 분석 결과 페이지 이동 |
| 실패 처리 | step === "FAILED" → 에러 메시지 표시 |
GET /api/v1/meetings/{meetingId}/pre-briefing
Authorization: Bearer <token> (LEADER)
미팅 시작 전 리더에게 멤버 상태를 요약 제공. 미팅 pending 상태에서만 의미 있음.
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"meetingId": 10,
"round": 12,
"memberName": "강다은",
"memberJobTitle": "시니어 FE 엔지니어",
"scheduledAt": "2026-06-05T14:00:00",
"survey": {
"submitted": true,
"energyLevel": 3,
"issues": ["업무 블로커", "커리어 성장"],
"desiredRoles": ["방향성 코칭"],
"surveyScore": 50.0
},
"lastMeeting": {
"safetyScore": 31.0,
"safetyScoreChange": -15.0,
"quadrant": "SILENT_RISK",
"honestyGap": {
"direction": "OVERREPORT",
"riskLevel": "DANGER"
},
"speechActAlerts": [
"Initiative 0회 (이전 평균 2.8회)",
"Vulnerability 0회 (이전 평균 2.3회)"
],
"blockerKeywords": ["QA 리소스 부족", "코드 리뷰 병목"]
},
"pendingPromises": [
{
"promiseId": 2,
"content": "AWS 프로덕션 접근 권한 부여",
"dueDate": "2026-04-20",
"overdue": true
}
],
"recommendedTopics": [
"약속 팔로업: AWS 프로덕션 접근 권한 부여",
"멤버 발화가 줄었습니다 — 편하게 이야기할 수 있는지 확인해보세요",
"서베이 이슈 확인: 업무 블로커"
]
}
}| 필드 | 설명 |
|---|---|
survey.submitted |
이번 미팅 사전 서베이 제출 여부 |
lastMeeting.safetyScoreChange |
직전 미팅 Safety Score - 이전 3회 평균 (양수 = 개선) |
lastMeeting.quadrant |
직전 미팅 기준 사분면 (STABLE/SILENT_RISK/EXPLICIT_RISK/CONSERVATIVE) |
lastMeeting.speechActAlerts |
Fact-Based 관찰 사실만 — AI 해석 라벨 없음 |
pendingPromises.overdue |
deadline이 오늘 이전이면 true |
recommendedTopics |
미이행 약속 팔로업 항상 최우선, 이후 상태 기반 제안 |
FE 구현 참고: survey.submitted가 false이면 "서베이 미제출" 상태 표시.
lastMeeting이 null이면 첫 미팅(이전 데이터 없음).
GET /api/v1/meetings/{meetingId}/leader-report
Authorization: Bearer <token> (LEADER)
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"meetingId": 10,
"round": 12,
"memberName": "강다은",
"memberJobTitle": "시니어 FE 엔지니어",
"meetingDate": "2026-05-08",
"durationSec": 2400,
"gaps": {
"alignmentGap": {
"score": 35,
"detail": "서베이에서 선택한 '커리어 성장' 주제 — 언급은 되었으나 구체적 결론 없음"
},
"honestyGap": {
"surveyScore": 87,
"safetyScore": 31,
"gap": 56,
"direction": "OVERREPORT",
"riskLevel": "DANGER"
},
"executionGap": {
"score": 40,
"totalPromises": 3,
"fulfilled": 1,
"missed": 2
}
},
"safetyScore": 31,
"speechActs": {
"vulnerability": {
"count": 1,
"baselineAvg": 2.3,
"changeRate": -57,
"instances": [
{ "text": "사실 그 부분은 잘 모르겠습니다", "timestamp": "02:15" }
]
},
"constructiveDissent": {
"count": 1,
"baselineAvg": 1.0,
"changeRate": 0,
"instances": [
{ "text": "이번 스프린트도 QA가 부족해서 배포를 또 미뤘어요", "timestamp": "08:42" }
]
},
"initiative": {
"count": 0,
"baselineAvg": 2.8,
"changeRate": -100,
"instances": []
}
},
"talkRatio": {
"leaderRatio": 70,
"memberRatio": 30,
"recommendedLeaderRatio": 40
},
"feedbacks": [
{
"feedbackId": 1,
"severity": "ERROR",
"title": "Initiative 0회 — 베이스라인 대비 100% 하락",
"evidenceQuote": "\"제가 한번 맡아볼게요\" 류의 발화 없음 (최근 3회 평균 2.8회)",
"dataSummary": "Vulnerability 1회(-57%) / Dissent 1회(변동없음) / Initiative 0회(-100%). Safety Score 31점.",
"actionGuide": "다음 미팅에서 \"요즘 새로 시도해보고 싶은 거 있어?\" 같은 개방형 질문으로 시작해 보세요."
},
{
"feedbackId": 2,
"severity": "WARNING",
"title": "멤버 발언 비율 30% — 권장(60%) 미달",
"evidenceQuote": "리더 연속 발화 구간 7턴 × 2회 감지",
"dataSummary": "리더 발화 70%, 멤버 30%. 권장 비율 40/60 대비 리더 과다 발화.",
"actionGuide": "다음 1on1 시작 5분은 질문만 하세요. \"3초 pause\" 기법을 연습해 보세요."
},
{
"feedbackId": 3,
"severity": "SUCCESS",
"title": "QA 리소스 이슈를 명확하게 제기했습니다",
"evidenceQuote": "\"이번 스프린트도 QA가 부족해서 배포를 또 미뤘어요\" (08:42)",
"dataSummary": "Constructive Dissent 1회. 구체적 상황+의견 제시 — 고품질 발화.",
"actionGuide": "\"그 외에 팀 차원에서 바꿨으면 하는 게 있어?\" 로 더 꺼내도록 유도하세요."
}
],
"nextActionPlans": [
{ "planId": 1, "content": "다음 1on1 시작 5분은 질문만 합니다.", "isCompleted": false },
{ "planId": 2, "content": "QA 리소스 이슈에 대해 이번 주 목요일까지 구체적 답변을 전달합니다.", "isCompleted": false },
{ "planId": 3, "content": "발화 비율을 40% 이하로 줄이기 위해 '3초 pause' 기법을 연습합니다.", "isCompleted": true },
{ "planId": 4, "content": "강다은 님의 MSA 전환 성과를 5월 올핸즈 미팅에서 공개 발표합니다.", "isCompleted": false }
],
"promises": {
"previous": [
{ "promiseId": 1, "content": "기술 블로그 주제 함께 정하기", "status": "DONE" },
{ "promiseId": 2, "content": "AWS 프로덕션 접근 권한 부여", "status": "MISSED" },
{ "promiseId": 3, "content": "QA 리소스 이슈 에스컬레이션", "status": "MISSED" }
],
"new": [
{ "promiseId": 4, "content": "AWS 프로덕션 접근 권한 부여", "category": "RESOURCE", "dueDate": "2026-05-15", "status": "PENDING" },
{ "promiseId": 5, "content": "QA 충원 안건 전사 회의 상정", "category": "TEAM_BUILDING", "dueDate": "2026-05-12", "status": "PENDING" },
{ "promiseId": 6, "content": "강다은 성과 올핸즈 발표", "category": "RECOGNITION", "dueDate": "2026-05-20", "status": "PENDING" }
]
}
}
}핵심 변경점 (피봇 반영)
aiScore→safetyScore(Speech Act 카운팅 기반)behaviorCounts(5개) →speechActs(3개: vulnerability, constructiveDissent, initiative)aiPatterns(AI 해석 라벨) 제거 →speechActs.instances(원문 인용+타임스탬프)만 제공- 각 speechAct에
baselineAvg+changeRate포함 (Fact-Based Output 원칙)
Honesty Gap 산출 (방향성 기반)
gap = surveyScore − safetyScore (부호 있는 값)
| gap 값 | direction | riskLevel | 의미 |
|---|---|---|---|
| gap ≤ 0 | UNDERREPORT | SAFE | 보수적 응답 (행동이 서베이보다 양호) — 위험 아님 |
| 1 ~ 20 | OVERREPORT | SAFE | 표면과 행동이 대체로 일치 |
| 21 ~ 40 | OVERREPORT | CAUTION | 약간의 과장 보고 가능성 |
| 41 ~ 60 | OVERREPORT | WARNING | 상당한 괴리 — "괜찮다"고 하지만 행동이 안 따라감 |
| 61+ | OVERREPORT | DANGER | 심각한 괴리 — 즉시 관심 필요 |
설계 근거: 현업 종사자들은 에너지 레벨을 보수적으로 응답하는 경향이 있음 (2~3 선택 빈도 높음).
절대값 기반 Gap은 "겸손한 응답"도 위험으로 오분류하므로, 부호 있는 방향성 기반으로 설계.
OVERREPORT 방향만 리스크 알림 대상 — UNDERREPORT는 CONSERVATIVE 사분면 표시만 하고 알림 없음.
GET /api/v1/meetings/{meetingId}/member-report
Authorization: Bearer <token> (MEMBER)
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"meetingId": 10,
"round": 12,
"leaderName": "이준혁 팀장",
"meetingDate": "2026-05-08",
"durationSec": 2400,
"confirmedAchievements": [
{
"careerEventId": 15,
"type": "ACHIEVEMENT",
"title": "스프린트 3 FE 리드 완주",
"description": "API 지연 상황에서 목 서버를 직접 구축해 팀 전체 개발을 무중단으로 유지했습니다.",
"impactMetric": "출시 지연 0일",
"leaderQuote": "위기 상황에서의 문제해결 능력과 팀 전체를 이끄는 자세가 탁월했습니다.",
"leaderName": "이준혁 팀장"
},
{
"careerEventId": 16,
"type": "PROPOSAL_ADOPTED",
"title": "공통 컴포넌트 라이브러리 기여",
"description": "DatePicker 공통화로 3개 팀 총 60시간 절감.",
"impactMetric": "공수 -60h",
"leaderQuote": "혼자 해결하지 않고 팀 전체를 끌어올리는 기여였습니다.",
"leaderName": "이준혁 팀장"
}
],
"leaderPromises": [
{ "promiseId": 4, "content": "AWS 프로덕션 접근 권한 부여", "category": "RESOURCE", "dueDate": "2026-05-15", "status": "PENDING" },
{ "promiseId": 5, "content": "QA 충원 안건 전사 회의 상정", "category": "TEAM_BUILDING", "dueDate": "2026-05-12", "status": "PENDING" },
{ "promiseId": 6, "content": "MSA 전환 성과 5월 올핸즈 발표", "category": "RECOGNITION", "dueDate": "2026-05-20", "status": "PENDING" },
{ "promiseId": 7, "content": "FE 코드 리뷰 SLA 기준 문서화", "category": "PROCESS", "dueDate": "2026-04-30", "status": "DONE" }
]
}
}멤버 리포트에는 Gap 점수, Speech Act 상세가 노출되지 않습니다.
멤버에게는 자산화된 성과와 리더의 약속 이행 현황만 보여줍니다.
POST /api/v1/promises
Authorization: Bearer <token> (LEADER)
Request Body
{
"meetingId": 10,
"content": "AWS 프로덕션 접근 권한 부여",
"category": "RESOURCE",
"dueDate": "2026-05-15"
}| 필드 | 타입 | 필수 | 검증 규칙 |
|---|---|---|---|
| meetingId | number | O | 존재하는 미팅 |
| content | string | O | 최대 200자 |
| category | string | O | RESOURCE / TEAM_BUILDING / RECOGNITION / PROCESS |
| dueDate | string | O | ISO date (미래 날짜) |
Response 201
{
"success": true,
"code": "SUCCESS",
"message": "약속이 등록되었습니다.",
"data": {
"promiseId": 4,
"content": "AWS 프로덕션 접근 권한 부여",
"category": "RESOURCE",
"dueDate": "2026-05-15",
"status": "PENDING"
}
}PATCH /api/v1/promises/{promiseId}/status
Authorization: Bearer <token> (LEADER)
Request Body
{
"status": "DONE"
}| status 값 | 설명 |
|---|---|
| DONE | 이행 완료 |
| MISSED | 미이행 (기한 초과 시 자동 전환 가능) |
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "약속 상태가 업데이트되었습니다.",
"data": {
"promiseId": 4,
"status": "DONE"
}
}PATCH /api/v1/promises/{promiseId}/complete
Authorization: Bearer <token>
인증/권한: 해당 약속이 속한 1on1의 참여자(리더 또는 멤버) 모두 호출 가능 (아니면 403 FORBIDDEN). 리더는 팀 약속 요약 패널·미팅 진행 체크리스트에서, 멤버는 Career Memory 회차별 약속에서 사용.
체크박스 클릭 시 호출. 상태를
DONE으로 변경하고completedAt기록.
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": null
}| 에러 코드 | 상태 | 의미 |
|---|---|---|
| PROMISE_NOT_FOUND | 404 | 약속 없음 |
| MEETING_NOT_FOUND | 404 | 약속이 속한 미팅 없음 |
| FORBIDDEN | 403 | 해당 1on1 참여자(리더/멤버)가 아님 |
PATCH /api/v1/promises/{promiseId}/incomplete
Authorization: Bearer <token>
인증/권한: 9.3과 동일 (1on1 참여자만).
체크 해제 시 호출. 상태를 PENDING으로 되돌리고 completedAt을 null로 초기화한다. 체크박스 on/off 토글을 지원하기 위한 짝 엔드포인트.
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": null
}GET /api/v1/promises/overdue?memberId={memberId}
Authorization: Bearer <token> (LEADER)
Query Params
| 파라미터 | 필수 | 설명 |
|---|---|---|
| memberId | X | 특정 멤버 필터. 생략 시 팀 전체 |
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": [
{
"promiseId": 2,
"content": "AWS 프로덕션 접근 권한 부여",
"category": "RESOURCE",
"dueDate": "2026-04-20",
"status": "MISSED",
"fromMeetingRound": 11,
"memberName": "강다은"
}
]
}GET /api/v1/promises/fulfillment-rate
Authorization: Bearer <token> (LEADER)
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"total": 10,
"doneCount": 6,
"missedCount": 2,
"pendingCount": 2,
"doneRate": 0.6,
"missedRate": 0.2,
"pendingRate": 0.2
}
}요청자(리더)가 등록한 전체 약속에 대한 이행률 통계. 리더 대시보드 Promise Ledger 수치에 활용.
GET /api/v1/teams/{teamId}/promises/summary
Authorization: Bearer <token> (LEADER)
팀 대시보드의 "미이행 약속" 패널 데이터 소스. 리더 약속이 최상단에 종합 표시되고, 멤버 약속은 멤버별로 섹션 구성.
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"leaderPromises": {
"memberId": null,
"memberName": "리더 (나)",
"promises": [
{
"promiseId": "4",
"content": "AWS 프로덕션 접근 권한 부여",
"context": "강다은님과의 5회차 미팅",
"status": "OVERDUE",
"createdAt": "2026-05-08",
"round": 5,
"isCompleted": false,
"partnerName": "강다은"
}
],
"stats": {
"total": 3,
"completed": 1,
"pending": 1,
"overdue": 1
}
},
"memberPromises": [
{
"memberId": "1",
"memberName": "강다은",
"promises": [
{
"promiseId": "3",
"content": "다음 스프린트까지 MSA 전환 완료",
"context": "4월 1on1",
"status": "OVERDUE",
"createdAt": "2026-04-20",
"round": 4,
"isCompleted": false,
"partnerName": null
}
],
"stats": {
"total": 2,
"completed": 1,
"pending": 0,
"overdue": 1
}
}
]
}
}| 필드 | 타입 | 설명 |
|---|---|---|
leaderPromises |
object | null | 리더가 멤버들과의 미팅에서 한 약속 종합. 없으면 null |
leaderPromises.promises[].partnerName |
string | 어느 멤버와의 미팅에서 한 약속인지 (리더 약속 전용 필드) |
memberPromises |
array | 멤버별 약속 배열. 미이행(PENDING/OVERDUE) 약속만 포함 |
status |
string | PENDING (기한 이내) / OVERDUE (기한 초과) |
FE 렌더링 규칙: leaderPromises가 null이 아니면 최상단에 "리더" 태그로 표시. memberPromises는 "멤버" 태그로 하단에 표시. 체크박스 체크 시
PATCH /promises/{id}/complete낙관적 업데이트.
GET /api/v1/promises/reminders
Authorization: Bearer <token> (LEADER)
팀 대시보드 상단 리마인더 배너에 사용. 기한 초과 약속과 7일 이내 도래 약속을 분리 반환.
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"overdue": [
{
"promiseId": 2,
"content": "AWS 프로덕션 접근 권한 부여",
"dueDate": "2026-04-20",
"daysLeft": -53,
"memberName": "강다은",
"meetingTitle": "4월 1on1"
}
],
"dueSoon": [
{
"promiseId": 7,
"content": "코드 리뷰 SLA 기준 문서화",
"dueDate": "2026-06-15",
"daysLeft": 3,
"memberName": "김민준",
"meetingTitle": "6월 1on1"
}
]
}
}| 필드 | 타입 | 설명 |
|---|---|---|
overdue |
array | 기한 초과 약속. daysLeft 음수 |
dueSoon |
array | 7일 이내 기한 도래 약속. daysLeft 0~7 |
daysLeft |
number | 오늘 기준 남은 일수 (음수: 초과) |
PATCH /api/v1/action-plans/{planId}/complete
Authorization: Bearer <token> (LEADER)
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "액션 플랜이 완료 처리되었습니다.",
"data": {
"planId": 3,
"isCompleted": true
}
}GET /api/v1/members/{memberId}/career-stats
Authorization: Bearer <token>
본인 또는 리더가 조회 가능
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"memberId": 1,
"name": "강다은",
"jobTitle": "시니어 프론트엔드 엔지니어",
"teamName": "Product A팀",
"totalMeetings": 12,
"achievementCount": 6,
"promiseFulfillmentRate": 75.0,
"completedActionCount": 8,
"aiSummary": "결제 MSA 전환, 검색 성능 개선, 팀 DX 리드 — 기술적 역량과 팀 임팩트를 동시에 만드는 엔지니어입니다."
}
}| 필드 | 타입 | 설명 |
|---|---|---|
achievementCount |
int | Career Event 중 ACHIEVEMENT 타입 건수 |
promiseFulfillmentRate |
double | 멤버 약속(MEMBER ownerType) 이행률 — DONE건수 / 전체건수 × 100, 소수점 1자리. 약속 없으면 0.0 |
completedActionCount |
int | 해당 멤버의 모든 미팅 액션 플랜 중 isCompleted = true 건수 |
제거된 필드:
leaderEndorsementCount(LEADER_ENDORSEMENT 이벤트 수 — 추출률이 낮아 의미 없음),contributionPercentile(팀 내 상대 순위 — 소규모 팀에서 의미 없음)
GET /api/v1/members/{memberId}/career-timeline?type={type}
Authorization: Bearer <token>
Query Params
| 파라미터 | 필수 | 설명 |
|---|---|---|
| type | X | ACHIEVEMENT / LEARNING / BLOCKER / PROPOSAL_ADOPTED. 생략 시 전체 |
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": [
{
"careerEventId": 15,
"type": "ACHIEVEMENT",
"title": "결제 시스템 MSA 전환 완료",
"description": "모놀리스 결제를 3개 마이크로서비스로 분리. 다운타임 0분, 배포 주기 3배 단축.",
"impactMetric": "다운타임 0분",
"eventDate": "2026-04-25",
"meetingRound": 12
},
{
"careerEventId": 14,
"type": "PROPOSAL_ADOPTED",
"title": "공통 DatePicker 컴포넌트 제안 → 팀 도입",
"description": "3개 팀이 중복 개발 중인 DatePicker를 공통화하여 약 60h 절감.",
"impactMetric": "공수 -60h",
"eventDate": "2026-04-10",
"meetingRound": 11
}
]
}GET /api/v1/members/{memberId}/career-showcase
Authorization: Bearer <token>
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": [
{
"careerEventId": 15,
"type": "ACHIEVEMENT",
"title": "결제 시스템 MSA 전환 완료",
"description": "모놀리스 결제를 3개 마이크로서비스로 분리. 다운타임 0분, 배포 주기 3배 단축.",
"impactMetric": "다운타임 0분",
"eventDate": "2026-04-25",
"meetingRound": 12
}
]
}상위 5건은
impactMetric이 있는 이벤트를 최신순으로 정렬
GET /api/v1/members/{memberId}/insight
Authorization: Bearer <token> (LEADER)
리더가 특정 멤버의 전체 히스토리(약속, 소통 추세, 액션플랜)를 조회하는 전용 엔드포인트.
멤버 상세 페이지(아코디언 UI)의 데이터 소스.
Path Params
| 파라미터 | 타입 | 설명 |
|---|---|---|
| memberId | number | 조회 대상 멤버 ID |
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"memberId": 1,
"memberName": "강다은",
"jobTitle": "시니어 FE 엔지니어",
"promises": [
{
"promiseId": 4,
"content": "AWS 프로덕션 접근 권한 부여",
"status": "PENDING",
"date": "2026-05-08",
"round": 12,
"meetingTitle": "5월 2차 1on1",
"ownerType": "LEADER"
},
{
"promiseId": 3,
"content": "다음 스프린트까지 MSA 전환 완료",
"status": "DONE",
"date": "2026-04-20",
"round": 11,
"meetingTitle": "4월 1on1",
"ownerType": "MEMBER"
}
],
"statusTrend": [
{
"round": 12,
"date": "2026-05-08",
"healthScore": 62.0,
"level": "보통",
"meetingTitle": "5월 2차 1on1"
},
{
"round": 11,
"date": "2026-04-20",
"healthScore": 74.0,
"level": "좋음",
"meetingTitle": "4월 1on1"
}
],
"actionPlans": [
{
"planId": 1,
"content": "다음 1on1 시작 5분은 질문만 합니다.",
"isCompleted": false,
"date": "2026-05-08",
"round": 12,
"meetingTitle": "5월 2차 1on1"
}
]
}
}응답 필드 상세
promises[]
| 필드 | 타입 | 설명 |
|---|---|---|
promiseId |
number | 약속 ID |
content |
string | 약속 내용 |
status |
string | DONE / PENDING / OVERDUE |
date |
string | null | 약속이 생성된 미팅 일자 (ISO date) |
round |
int | 해당 리더-멤버 미팅 회차 |
meetingTitle |
string | 미팅 제목. 제목 없으면 "1:1 미팅" |
ownerType |
string | MEMBER (멤버의 약속) / LEADER (리더의 약속) |
statusTrend[]
| 필드 | 타입 | 설명 |
|---|---|---|
round |
int | 미팅 회차 |
date |
string | null | 미팅 일자 |
healthScore |
number | 해당 회차 팀 헬스 스코어 (safetyScore × 0.6 + surveyScore × 0.4) |
level |
string | 좋음 (≥65) / 보통 (≥45) / 낮음 (<45) |
meetingTitle |
string | 미팅 제목 |
actionPlans[]
| 필드 | 타입 | 설명 |
|---|---|---|
planId |
number | 액션 플랜 ID |
content |
string | 액션 플랜 내용 |
isCompleted |
boolean | 완료 여부 |
date |
string | null | 미팅 일자 |
round |
int | 미팅 회차 |
meetingTitle |
string | 미팅 제목 |
에러
| 코드 | HTTP | 상황 |
|---|---|---|
| MEMBER_NOT_FOUND | 404 | 존재하지 않는 멤버 |
| FORBIDDEN | 403 | 해당 팀의 리더가 아닌 경우 |
GET /api/v1/leaders/me/growth
Authorization: Bearer <token> (LEADER)
리더 본인의 최근 6개월 1on1 행동 변화 지표. HR 관점의 규칙성(cadence), 코칭 실행률, 행동-결과 상관 인사이트를 제공.
Response 200
{
"success": true,
"code": "SUCCESS",
"message": "요청이 성공적으로 처리되었습니다.",
"data": {
"keyInsight": "발화 비율을 낮춘 달에 팀 안전감 신호가 평균 8.3점 상승했습니다.",
"talkRatioTrend": [
{ "month": "2026-01", "value": 68.0, "meetingCount": 4 },
{ "month": "2026-02", "value": 62.0, "meetingCount": 3 },
{ "month": "2026-03", "value": 55.0, "meetingCount": 5 }
],
"teamSafetyTrend": [
{ "month": "2026-01", "value": 54.2, "meetingCount": 4 },
{ "month": "2026-02", "value": 58.7, "meetingCount": 3 },
{ "month": "2026-03", "value": 64.1, "meetingCount": 5 }
],
"monthlyMeetings": [
{ "month": "2026-01", "value": 4, "meetingCount": 4 },
{ "month": "2026-02", "value": 3, "meetingCount": 3 },
{ "month": "2026-03", "value": 5, "meetingCount": 5 }
],
"promiseStats": {
"total": 12,
"done": 8,
"missed": 2,
"pending": 2,
"doneRate": 66.7
},
"coachingExecution": {
"total": 15,
"completed": 11,
"executionRate": 73.3
},
"memberCadence": [
{ "memberId": 1, "memberName": "강다은", "lastMeetingDate": "2026-06-05", "daysSinceLastMeeting": 7 },
{ "memberId": 2, "memberName": "김민준", "lastMeetingDate": null, "daysSinceLastMeeting": null }
],
"highlights": [
"가장 많은 1on1을 진행한 달: 2026-03 (5회)",
"코칭 실행률 73% — 제안된 액션 플랜 15건 중 11건 완료"
]
}
}응답 필드 상세
| 필드 | 타입 | 설명 |
|---|---|---|
keyInsight |
string | null | 발화비율-안전감 상관 인사이트 문장. talkRatioTrend와 teamSafetyTrend 모두 2개월 이상 데이터가 있을 때만 생성. 없으면 null |
talkRatioTrend |
array | 월별 평균 리더 발화 비율(%). value = 평균 leaderRatio |
teamSafetyTrend |
array | 리더가 진행한 1on1의 월별 평균 Safety Score |
monthlyMeetings |
array | 월별 진행한 1on1 건수. value = meetingCount (두 필드가 동일) |
promiseStats.doneRate |
number | DONE건수 / (DONE+MISSED) × 100, 소수점 1자리. PENDING 제외 |
coachingExecution.executionRate |
number | completed / total × 100, 소수점 1자리 |
memberCadence |
array | 팀 멤버별 마지막 1on1 날짜. daysSinceLastMeeting 내림차순 (오래된 순), null은 최상단 |
memberCadence[].daysSinceLastMeeting |
number | null | 오늘 기준 경과 일수. 1on1 미진행 시 null |
MonthlyTrendPoint 구조:
| 필드 | 타입 | 설명 |
|---|---|---|
month |
string | YYYY-MM 형식 |
value |
number | 해당 월의 지표값 |
meetingCount |
number | 해당 월의 미팅 건수 |
FE 시각화 참고
talkRatioTrend: 권장 40% 이하 → 초과 시 빨간색, 50~69% 주황색, 40% 미만 초록색teamSafetyTrend: 고정 인디고 색상monthlyMeetings: max값 기준으로 바 너비 스케일 조정memberCadence: daysSinceLastMeeting ≥ 30 → 빨간색, ≥ 14 → 주황색, < 14 → 회색
에러
| 코드 | HTTP | 상황 |
|---|---|---|
| FORBIDDEN | 403 | LEADER 권한 없음 |
| 코드 | HTTP | 설명 |
|---|---|---|
EMAIL_ALREADY_EXISTS |
409 | 이미 사용 중인 이메일 |
INVALID_CREDENTIALS |
401 | 이메일 또는 비밀번호 불일치 |
EXPIRED_TOKEN |
401 | 액세스/리프레시 토큰 만료 |
INVALID_TOKEN |
401 | 유효하지 않은 토큰 |
UNAUTHORIZED |
401 | 인증이 필요합니다 |
FORBIDDEN |
403 | 접근 권한 없음 (역할 불일치) |
ALREADY_IN_TEAM |
409 | 이미 팀에 소속 |
USER_NOT_FOUND |
404 | 사용자 없음 |
TEAM_NOT_FOUND |
404 | 팀 없음 (초대코드 무효 포함) |
MEETING_NOT_FOUND |
404 | 미팅 없음 |
MEETING_NOT_CANCELABLE |
409 | 이미 진행/완료된 미팅은 취소 불가 (CREATED 상태만 취소 가능) |
PROMISE_NOT_FOUND |
404 | 약속 없음 |
SURVEY_NOT_FOUND |
404 | 서베이 없음 |
SURVEY_ALREADY_SUBMITTED |
409 | 이미 제출된 서베이 |
ANALYSIS_NOT_FOUND |
404 | 분석 결과 없음 |
ANALYSIS_IN_PROGRESS |
409 | 이미 분석 진행 중 |
ANALYSIS_FAILED |
500 | 분석 처리 중 오류 |
FILE_TOO_LARGE |
413 | 파일 25MB 초과 |
INVALID_FILE_FORMAT |
400 | 지원하지 않는 파일 형식 |
LLM_PARSE_FAILED |
500 | AI 응답 파싱 실패 |
INVALID_INPUT |
400 | 잘못된 입력값 |
INTERNAL_SERVER_ERROR |
500 | 서버 내부 오류 |
// api/client.ts
import axios from 'axios';
import { useAuthStore } from '@/stores/authStore';
const client = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1',
});
// 요청 인터셉터: accessToken 자동 부착
client.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 응답 인터셉터: 401 시 토큰 갱신 시도
client.interceptors.response.use(
(res) => res,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const refreshToken = useAuthStore.getState().refreshToken;
if (refreshToken) {
try {
const { data } = await axios.post(
`${client.defaults.baseURL}/auth/refresh`,
{ refreshToken }
);
const { accessToken, refreshToken: newRefresh } = data.data;
useAuthStore.getState().setTokens(accessToken, newRefresh);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return client(originalRequest);
} catch {
useAuthStore.getState().logout();
window.location.href = '/login';
}
}
}
return Promise.reject(error);
}
);
export default client;// features/meeting/useRecorder.ts
import { useRef, useState, useCallback } from 'react';
import client from '@/api/client';
export function useRecorder(meetingId: number) {
const [isRecording, setIsRecording] = useState(false);
const [duration, setDuration] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const start = useCallback(async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus',
});
chunksRef.current = [];
recorder.ondataavailable = (e) => chunksRef.current.push(e.data);
recorder.start();
startTimeRef.current = Date.now();
mediaRecorderRef.current = recorder;
setIsRecording(true);
}, []);
const stop = useCallback(async () => {
return new Promise<void>((resolve) => {
const recorder = mediaRecorderRef.current!;
recorder.onstop = async () => {
const durationSec = Math.round((Date.now() - startTimeRef.current) / 1000);
setDuration(durationSec);
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const formData = new FormData();
formData.append('file', blob, 'recording.webm');
formData.append('durationSec', String(durationSec));
await client.post(`/meetings/${meetingId}/recording`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setIsRecording(false);
resolve();
};
recorder.stop();
recorder.stream.getTracks().forEach((t) => t.stop());
});
}, [meetingId]);
return { isRecording, duration, start, stop };
}// features/meeting/useMeetingStatus.ts
import { useState, useEffect, useRef } from 'react';
import client from '@/api/client';
interface StatusData {
meetingId: number;
step: string;
stepNumber: number;
totalSteps: number;
progress: number;
stepLabel: string;
wittyMessage: { leader: string; member: string };
}
export function useMeetingStatus(meetingId: number, enabled: boolean) {
const [status, setStatus] = useState<StatusData | null>(null);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout>();
const startRef = useRef(Date.now());
useEffect(() => {
if (!enabled) return;
startRef.current = Date.now();
const poll = async () => {
// 3분 타임아웃
if (Date.now() - startRef.current > 180_000) {
setError('분석 시간이 초과되었습니다. 다시 시도해 주세요.');
clearInterval(intervalRef.current);
return;
}
try {
const { data } = await client.get(`/meetings/${meetingId}/status`);
setStatus(data.data);
if (data.data.step === 'COMPLETED' || data.data.step === 'FAILED') {
clearInterval(intervalRef.current);
if (data.data.step === 'FAILED') {
setError('분석에 실패했습니다. 녹음을 다시 업로드해 주세요.');
}
}
} catch {
setError('상태 조회에 실패했습니다.');
clearInterval(intervalRef.current);
}
};
poll(); // 즉시 1회 실행
intervalRef.current = setInterval(poll, 3000);
return () => clearInterval(intervalRef.current);
}, [meetingId, enabled]);
return { status, error };
}이 섹션은 BE 구현 및 FE 시각화의 기준 문서입니다. 모든 점수 산출은 이 공식에 따릅니다.
파이프라인 구성 (v2.6 기준)
Whisper STT
→ GPT-mini (Step 2) — Speech Act 분류 + 주제/블로커/약속 추출 + 발화 비율
→ GPT-mini (Step 3) — 3-Gap 스코어링 + 코칭 피드백 + Career 태그
Step 2 프롬프트 설계 원칙
| 항목 | 내용 |
|---|---|
| 이론 근거 | Searle(1969) Speech Act Theory, Edmondson(1999) Psychological Safety |
| 추론 방식 | CoT (Chain-of-Thought): Step A 화자 판별 → B 멤버 필터링 → C 의도 판단 → D 발화 비율 → E 기타 추출 |
| 분류 원칙 | recall-first (애매하면 포함). 기준선 40 도입으로 과분류 시 점수 왜곡이 작음. 원문 그대로 인용 |
| Few-shot | 포함 예시 3개 + 제외 예시 2개 (사교적 겸손, 단순 수락 경계 사례) |
| 발화 비율 | 문자수 기준 정수 산출, leaderRatio + memberRatio = 100 보장 |
Step 3 프롬프트 — Meeting RAG (이전 미팅 컨텍스트 주입)
분석 시 최근 3회 미팅의 Rolling Baseline을 step3 프롬프트에 자동 주입:
[이전 미팅 컨텍스트 — Rolling Baseline]
최근 N회 미팅 평균:
- Safety Score 평균: X.X
- Vulnerability 평균: X.X회
- Constructive Dissent 평균: X.X회
- Initiative 평균: X.X회
- 이전 블로커: 키워드1, 키워드2
→ 피드백에 변화량 자동 포함: "Vulnerability 발화가 이전 3회 평균 2.3건에서 0건으로 감소했습니다"
첫 미팅이거나 이전 분석 데이터가 없는 경우 컨텍스트 주입 없이 절대값 기반 분석.
safetyScore = 40 + V_score + D_score + I_score
// 최솟값 40 (발화 없어도 침묵 ≠ 위험), 최댓값 100
중립 기준선 40: Speech Act 발화가 전혀 없어도 40점 시작. 이전 버전(0에서 시작)은 완전한 침묵도 극단적 위험으로 과분류하는 문제가 있었음.
30분 기준 발화 횟수 → 보너스 변환표
| 횟수 | Vulnerability (+24) | Constructive Dissent (+21) | Initiative (+15) |
|---|---|---|---|
| 0회 | 0 | 0 | 0 |
| 1회 | +12 | +11 | +8 |
| 2회 | +18 | +17 | +12 |
| 3회 | +22 | +20 | +14 |
| 4회+ | +24 | +21 | +15 |
미팅 시간 정규화
adjusted_count = round(raw_count × 30 ÷ actual_duration_minutes)
가중치 근거
- V 40% (24/60): 가장 어려운 행위. 심리적 안전감 연구에서 "실수를 인정할 수 있는가"가 1순위 지표
- D 35% (21/60): 건설적 반대는 의사결정 품질 지표. 반대 없는 팀은 groupthink 위험
- I 25% (15/60): 자발적 제안은 engagement 지표. V/D보다 표현 진입 장벽이 낮음
체감 체계: 0→1회 첫 발화가 보너스의 ~50%를 차지 (첫 신뢰 신호가 가장 중요). 4회+ 이후는 만점 수렴.
Cold Start: 1~2회차 미팅은 베이스라인 미형성. safetyScore 절대값만 사용, changeRate 미표시.
surveyScore = clamp(0, 100, energyLevel × 20 + Σ issue_adjustments)
| energyLevel | 기본점 |
|---|---|
| 1 (매우 낮음) | 20 |
| 2 (낮음) | 40 |
| 3 (보통) | 60 |
| 4 (높음) | 80 |
| 5 (매우 높음) | 100 |
| 이슈 선택 | 가감점 |
|---|---|
| 업무 블로커 | −10 |
| 리소스 요청 | −5 |
| 팀 분위기 | −3 |
| 프로세스 개선 | −3 |
| 커리어 성장 / 인정·피드백 / 기타 | 0 |
gap = surveyScore − safetyScore // 부호 있는 값
direction = gap > 0 ? "OVERREPORT" : "UNDERREPORT"
| gap 값 | direction | riskLevel | 의미 |
|---|---|---|---|
| ≤ 0 | UNDERREPORT | SAFE | 보수적 응답 — 알림 없음 |
| 1 ~ 20 | OVERREPORT | SAFE | 표면과 행동 대체로 일치 |
| 21 ~ 40 | OVERREPORT | CAUTION | 약간의 과장 보고 가능성 |
| 41 ~ 60 | OVERREPORT | WARNING | "괜찮다"고 하지만 행동이 안 따라감 |
| 61+ | OVERREPORT | DANGER | 심각한 괴리 — 즉시 관심 |
OVERREPORT 방향만 리스크 알림 대상. 현업 종사자들의 보수적 에너지 레벨 응답 패턴(2~3 빈도 높음)을 고려하여, UNDERREPORT 방향은 CONSERVATIVE 사분면 표시만 하고 알림하지 않음.
임계값: 45 (v2.7 재보정 — Safety Score 기준선 40 도입으로 50 기준은 의미 없음)
| 사분면 | 조건 | 색상 | 의미 |
|---|---|---|---|
| STABLE | survey ≥ 45 && safety ≥ 45 | green | 표면+행동 모두 양호 |
| SILENT_RISK | survey ≥ 45 && safety < 45 | amber | "괜찮다"고 하지만 행동 지표 낮음 |
| EXPLICIT_RISK | survey < 45 && safety < 45 | red | 표면+행동 모두 부정 |
| CONSERVATIVE | survey < 45 && safety ≥ 45 | indigo | 보수적 응답 (위험 아님) |
데이터 축적 후 팀 중앙값 기반 동적 임계값 조정 가능하나 현재는 45 고정.
teamHealthScore = safetyScore_avg × 0.6 + surveyScore_avg × 0.4
trendDelta = thisMonth.avg - avg(prior3MonthMeans)
| 필드 | 공식 | 설명 |
|---|---|---|
teamHealthScore |
safetyAvg × 0.6 + surveyAvg × 0.4 |
해당 월 팀 헬스 평균 |
trendDelta |
thisMonth.avg − avg(prior3MonthMeans) |
이번 달 vs 직전 3개월 월별 평균의 평균. 양수: 개선, 음수: 하락 |
statusNote |
규칙 기반 평문 | trendDelta 구간에 따라 자동 생성. 내부 지표명 미노출 |
statusNote 생성 규칙 예시:
| trendDelta 구간 | statusNote 예시 |
|---|---|
| ≥ 5 | "이번 달 팀 소통 참여도가 지난 3개월 평균보다 높아졌습니다." |
| −5 ~ 5 | "이번 달 팀 소통 흐름이 안정적으로 유지되고 있습니다." |
| ≤ −5 | "최근 몇몇 멤버의 대화 참여가 줄어들었습니다. 개별 체크인을 고려해보세요." |
| null | "아직 비교할 이전 달 데이터가 부족합니다." |
| 날짜 | 버전 | 변경 내용 |
|---|---|---|
| 2026-05-03 | v1.0 | 준영 초안 작성 |
| 2026-05-06 | v2.0 | 피봇 반영 (aiScore→safetyScore, aiPatterns 제거, speechActs 3종 전환, Fact-Based Output 적용), FE 구현 가이드 추가, 검증 규칙 보강, 서베이 선택지 상세화 |
| 2026-05-12 | v2.1 | Scoring 공식 확정 (Safety Score 변환표, Survey Score 산출, Honesty Gap 방향성 기반 전환, 사분면 정의), APPENDIX 추가 |
| 2026-05-15 | v2.2 | 블로커 보드 → 블로커 피라미드 명칭 변경 (blocker-board → blocker-pyramid), 5/15 회의 결정사항 반영 |
| 2026-05-28 | v2.3 | 실코드 기준 동기화 — URL 불일치 수정 (health-score→dashboard, analysis/{id}/status→meetings/{id}/status), quadrant 응답 구조 변경 (honestyGap/direction/riskLevel 추가), 신규 API 추가 (6.3 surveys/history, 9.4 promises/fulfillment-rate, 12. Member Analytics) |
| 2026-05-29 | v2.4 | 12절(MEMBER ANALYTICS) 제거 — speech-trend/portfolio/career-memory는 MVP 범위 외 또는 11절로 커버. PRD 멤버 화면 기준 정렬 |
| 2026-06-02 | v2.5 | 8절 Pre-Meeting Briefing 추가 (GET /meetings/{id}/pre-briefing) — 브리핑 카드: survey, lastMeeting 요약, pendingPromises, recommendedTopics. 기존 8~11절 번호 순서 변경 |
| 2026-06-03 | v2.6 | 4.4절 팀 발화 비율 랭킹 추가 (GET /teams/{id}/talk-ratio-ranking). GPT 발화 비율 항상 40 고정 버그 수정. A.0절 AI 파이프라인 문서화 (CoT + Few-shot + Meeting RAG) |
| 2026-06-11 | v2.7 | Safety Score 재보정 (A.1: 기준선 40, 보너스 변환표 V+24/D+21/I+15, recall-first 분류), 사분면 임계값 50→45 (A.4, 4.3절), 팀 헬스 trendDelta+statusNote 추가 (4.1, A.5), PUT /users/me jobTitle 추가 (2.2절 신규), Career Stats 필드 교체 leaderEndorsementCount+contributionPercentile → promiseFulfillmentRate+completedActionCount (11.1절), 13절 신규: GET /members/{memberId}/insight — promises(ownerType+meetingTitle), statusTrend(meetingTitle), actionPlans(meetingTitle) |
| 2026-06-12 | v2.8 | 14절 신규: GET /leaders/me/growth — keyInsight, talkRatioTrend, teamSafetyTrend, monthlyMeetings, promiseStats, coachingExecution, memberCadence, highlights. 9절 약속 엔드포인트 추가: PATCH /promises/{id}/complete (9.3), GET /promises/reminders (9.7), GET /teams/{teamId}/promises/summary (9.6 — leaderPromises+memberPromises 분리 구조, partnerName 필드). Safety Score 결정론적 계산 전환 (LLM 숫자 미사용, 서버가 speech act 카운트로 직접 산출) 명시. |
| 2026-06-15 | v2.9 | 5.4절 신규: DELETE /meetings/{id} 미팅 취소 (CREATED 상태만, 리더 전용, 회차 자동 재계산, MEETING_NOT_CANCELABLE 409). 5.3절: 단일 미팅 응답에 memberId 추가 (미팅 진행 체크리스트 로드용). 5.2절: 미팅 목록 역할별 스코프 명시 — 멤버는 리더와의 1on1만(셀프 미팅 제외). 6.1절: 서베이 제출을 해당 미팅의 멤버 본인으로 제한(타인 제출 403 명시). 9.3절: 약속 완료 권한을 1on1 참여자(리더 또는 멤버)로 확대. 9.3b절 신규: PATCH /promises/{id}/incomplete 완료 해제(체크박스 off). 에러 코드 목록에 MEETING_NOT_CANCELABLE, PROMISE_NOT_FOUND 추가. |