설문 응답 제출 API
설문 응답 제출 API는 사용자가 특정 설문의 모든 질문에 대한 답변을 완료한 후, 해당 설문의 응답을 제출하는 기능을 제공합니다. 제출된 응답은 검증을 거쳐 저장되며, 점수 시스템이 있는 설문의 경우 자동으로 점수가 계산됩니다.
설문 개별 응답 제출
사용자가 작성한 설문 응답을 제출하고, 점수 계산 결과를 반환합니다.
- HTTP Method:
POST - Path:
/questionnaires/:questionnaireId/rounds/:roundId/responses - 인증: Access Token 필요 (Authorization: Bearer
{accessToken})
Headers
| Header | Type | Description | Required |
|---|---|---|---|
Content-Type | application/json | 요청 본문 형식을 지정합니다. | Yes |
Authorization | string | Bearer 토큰 형식의 JWT 액세스 토큰이 필요합니다. | Yes |
Accept-Language | string | 언어 설정을 지정합니다. (예: ko, en) | Yes |
Path Parameters
| Parameter | Type | Description | Example |
|---|---|---|---|
questionnaireId | string | 응답을 제출할 설문의 ID | 19bc1965-3b9c-4132-adba-f1f42a95bb98 |
roundId | string | 응답을 제출할 설문 회차의 ID | 8f777de6-fe64-4074-9e7e-c5ee77f10e6b |
Request Body (SubmitQuestionnaireResponseRequestDto)
설문 응답 데이터를 포함합니다. 각 질문 타입에 따라 적절한 형식으로 답변을 제출해야 합니다.
{
"questionResponses": [
{
"id": "2fae3e99-71d8-44d8-94b0-0f4e88b4e5ef",
"type": "SINGLE_CHOICE",
"responses": [
{
"value": "0",
"optionId": "a5757dd4-361b-42f1-9ba9-0c062fa3d410",
"userInputText": "my_drug_name"
}
],
"decisionTimesMs": [7377]
},
{
"id": "b36119b6-d2e1-4bb8-ad58-9af110379daf",
"type": "SINGLE_CHOICE",
"responses": [
{
"value": "2",
"optionId": "3d4eec5c-5424-407e-9b56-4d0435fa567a"
}
],
"decisionTimesMs": [4438]
},
{
"id": "b9222365-0d7f-4992-9c5f-5a245af8ac75",
"type": "SINGLE_CHOICE",
"responses": [
{
"value": "4",
"optionId": "94ce8547-6dd9-403d-bc7f-de6553f3e85c"
}
],
"decisionTimesMs": [5476]
}
]
}
| 필드 | 타입 | 설명 | 필수 (Yes/No) |
|---|---|---|---|
questionResponses | array | 질문별 응답 목록 (QuestionResponseDto[]) | Yes |
questionResponses[].id | string | 질문 ID | Yes |
questionResponses[].type | string | 질문 타입 (QuestionType 참조) | Yes |
questionResponses[].responses | array | 응답 선택지 목록 (QuestionResponseOptionDto[]) | Yes |
questionResponses[].responses[].optionId | string | 선택지 ID (옵션 기반 질문의 경우 필수) | No |
questionResponses[].responses[].value | string | 답변 값 | Yes |
questionResponses[].responses[].userInputText | string | 사용자 텍스트 입력 값 (선택사항, 약물명 등) | No |
questionResponses[].textValue | string | 텍스트 응답 (선택사항) | No |
questionResponses[].decisionTimesMs | array | 답변 완료까지 총 소요 시간 배열 (밀리초) - 숫자 배열 형태 | Yes |
지원하는 질문 타입 (QuestionType 참조)
| 질문 타입 | 설명 | optionId 필요 여부 | value 형식 |
|---|---|---|---|
MULTI_CHOICE | 복수 선택형 | Yes | 선택한 옵션 값 |
SINGLE_CHOICE | 단일 선택형 | Yes | 선택한 옵션 값 |
LINEAR_SCALE | 선형 척도 | No | 숫자 값 |
TIME | 시간 입력 | No | 시간 값 |
DURATION_MINUTES | 소요 시간(분) | No | 분 단위 값 |
TEXT | 텍스트 입력 | No | 텍스트 값 |
Responses
| HTTP Status Code | 설명 | Error Code(s) |
|---|---|---|
200 OK | 응답 제출 성공 | - |
400 Bad Request | 잘못된 요청 (검증 오류) | 9040, 9043, 9041, 9042, 9045, 9044 |
401 Unauthorized | 인증 실패 | 2051 |
404 Not Found | 리소스 없음 (설문지, 회차, 사용자 주기, 사용자 정보) | 9030, 9031, 7003, 7008, 9010 |
409 Conflict | 중복 응답 제출 | 9046 |
410 Gone | 라운드 기간 만료 | 9055 |
412 Precondition Failed | 라운드 진행 조건 미충족 | 9054 |
500 Internal Server Error | 서버 내부 오류 | 9000, 9500 |
200 OK - 응답 제출 성공
성공적으로 설문 응답을 제출하면 저장된 응답 정보와 함께 점수 계산 결과가 반환됩니다.
{
"id": "resp_new_123",
"userId": "user_sample_123",
"questionnaireId": "9a0751c8-4c4c-41c2-ba0f-88018f26b014",
"roundId": "2077951e-b4d7-4b59-92d7-0d4bfb482d76",
"score": {
"calculationType": "SUM",
"userScore": 18,
"range": {
"min": {
"value": 0
},
"max": {
"value": 28
}
},
"level": {
"id": "sl_isi3",
"range": {
"min": {
"value": 15
},
"max": {
"value": 21
}
},
"label": "중간 정도의 불면증",
"chartLabel": "15-21 점",
"description": "중등도의 불면 증상이 나타날 수 있습니다.",
"feedback": "중등도의 불면 증상이 나타날 수 있습니다. 의료 전문가의 도움이 필요한 상황입니다."
}
},
"createdAt": 1716800000000,
"completedDayIndex": 1
}
| 필드 | 타입 | 설명 | 예시 | 필수 (Yes/No) |
|---|---|---|---|---|
id | string | 새로 생성된 QuestionnaireResponse의 ID | resp_new_123 | Yes |
userId | string | 사용자 ID | user_sample_123 | Yes |
questionnaireId | string | 설문 ID | 9a0751c8-4c4c-41c2-ba0f-88018f26b014 | Yes |
roundId | string | 회차 ID | 2077951e-b4d7-4b59-92d7-0d4bfb482d76 | Yes |
score | object | 점수 정보 (ResponseScoreSectionDto) - 점수 시스템이 있는 설문만 | No | |
score.calculationType | string | 점수 계산 방식 | SUM | Yes (score가 있는 경우) |
score.userScore | float | 사용자 점수 | 18 | Yes (score가 있는 경우) |
score.range | object | 점수 범위 (ScoreRangeDto) | Yes (score가 있는 경우) | |
score.range.min | object | 최소 점수 (ScoreRangeValueDto) | Yes | |
score.range.min.value | float | 최소 점수 값 | 0 | Yes |
score.range.max | object | 최대 점수 (ScoreRangeValueDto) | Yes | |
score.range.max.value | float | 최대 점수 값 | 28 | Yes |
score.level | object | 점수 레벨 정보 (ResponseScoreLevelDto) | No (score가 있는 경우) | |
score.level.id | string | 점수 레벨 ID | sl_isi3 | Yes |
score.level.range | object | 점수 레벨 범위 (ScoreRangeDto) | Yes | |
score.level.range.min.value | float | 점수 레벨 최소값 | 15 | Yes |
score.level.range.max.value | float | 점수 레벨 최대값 | 21 | Yes |
score.level.label | string | 점수 레벨 라벨 | 중간 정도의 불면증 | Yes |
score.level.chartLabel | string | 차트 라벨 | 15-21 점 | Yes |
score.level.description | string | 설명 | 중등도의 불면 증상이... | Yes |
score.level.feedback | string | 피드백 메시지 | 중등도의 불면 증상이... | Yes |
createdAt | integer | 생성 시각 (Unix timestamp in milliseconds, Kotlin: Long, Swift: Int64) | 1716800000000 | Yes |
completedDayIndex | integer | 완료된 날짜 인덱스 | 1 | Yes |
참고: WIS(Wellness Insight Survey) 등 점수 시스템이 없는 설문의 경우 score 필드가 null로 반환됩니다.
400 Bad Request - 잘못된 요청
예시: 필수 질문 미응답 (9040)
{
"code": 9040,
"message": "REQUIRED_QUESTION_NOT_ANSWERED",
"detail": "필수 질문에 응답하지 않았습니다",
"metadata": {
"questionId": "question_abc_001"
}
}
예시: 필수 질문 빈 값 (9043)
{
"code": 9043,
"message": "REQUIRED_QUESTION_EMPTY_VALUE",
"detail": "필수 질문에 빈 값을 입력할 수 없습니다",
"metadata": {
"questionId": "question_abc_002"
}
}
예시: 질문 타입 불일치 (9041)
{
"code": 9041,
"message": "QUESTION_TYPE_MISMATCH",
"detail": "질문 타입이 일치하지 않습니다",
"metadata": {
"questionId": "question_abc_003",
"expectedType": "SINGLE_CHOICE",
"receivedType": "MULTI_CHOICE"
}
}
예시: 단일 선택 질문에 다중 답변 (9042)
{
"code": 9042,
"message": "SINGLE_CHOICE_MULTIPLE_ANSWERS",
"detail": "단일 선택 질문에는 하나의 답변만 가능합니다",
"metadata": {
"questionId": "question_abc_004",
"answersCount": 2
}
}
예시: 옵션 값 불일치 (9045)
{
"code": 9045,
"message": "OPTION_VALUE_MISMATCH",
"detail": "선택된 옵션의 값이 일치하지 않습니다",
"metadata": {
"questionId": "question_abc_005",
"optionId": "option_123",
"expectedValue": "1",
"receivedValue": "2"
}
}
예시: 지원하지 않는 질문 타입 (9044)
{
"code": 9044,
"message": "UNSUPPORTED_QUESTION_TYPE",
"detail": "지원하지 않는 질문 타입입니다",
"metadata": {
"questionType": "UNKNOWN_TYPE"
}
}
401 Unauthorized - 인증 실패
{
"code": 2051,
"message": "INVALID_TOKEN",
"detail": "토큰이 유효하지 않습니다"
}
404 Not Found - 리소스 없음
예시: 설문지를 찾을 수 없음 (9030)
{
"code": 9030,
"message": "QUESTIONNAIRE_NOT_FOUND",
"detail": "설문지를 찾을 수 없습니다",
"metadata": {
"questionnaireId": "invalid-questionnaire-id"
}
}
예시: 설문 라운드를 찾을 수 없음 (9031)
{
"code": 9031,
"message": "QUESTIONNAIRE_ROUND_NOT_FOUND",
"detail": "설문지 회차를 찾을 수 없습니다",
"metadata": {
"roundId": "invalid-round-id"
}
}
예시: 사용자 활성 주기를 찾을 수 없음 (7003)
{
"code": 7003,
"message": "USER_CYCLE_NOT_FOUND",
"detail": "사용자의 활성 주기를 찾을 수 없습니다",
"metadata": {
"userId": "user_sample_123"
}
}
예시: 사용자 정보를 찾을 수 없음 (7008)
{
"code": 7008,
"message": "USER_STATE_NOT_FOUND",
"detail": "사용자의 정보를 찾을 수 없습니다",
"metadata": {
"userId": "user_sample_123"
}
}
예시: 설문 번들을 찾을 수 없음 (9010)
{
"code": 9010,
"message": "QUESTIONNAIRE_BUNDLE_NOT_FOUND",
"detail": "설문지 번들을 찾을 수 없습니다",
"metadata": {
"userId": "user_sample_123"
}
}
409 Conflict - 중복 응답 제출
{
"code": 9046,
"message": "QUESTIONNAIRE_RESPONSE_ALREADY_EXISTS",
"detail": "해당 설문에 대한 응답이 이미 존재합니다"
}
410 Gone - 라운드 기간 만료
예시: 라운드 진행 기간 만료 (9055)
{
"code": 9055,
"message": "ROUND_EXPIRED",
"detail": "해당 회차 진행 기간이 만료되었습니다"
}
412 Precondition Failed - 라운드 진행 조건 미충족
예시: 라운드 진행 기간 미시작 (9054)
{
"code": 9054,
"message": "ROUND_NOT_STARTED",
"detail": "아직 해당 회차 진행 기간이 시작되지 않았습니다"
}
500 Internal Server Error - 서버 내부 오류
일반적인 서버 오류 (9000)
{
"code": 9000,
"message": "SERVER_ERROR",
"detail": "서버 내부 오류"
}
데이터베이스 접근 오류 (9500)
{
"code": 9500,
"message": "REPOSITORY_ERROR",
"detail": "데이터베이스 접근 중 오류가 발생했습니다"
}
이 오류들은 처리 중 예기치 않은 서버 내부 문제나 데이터베이스 접근 문제가 발생했을 때 반환됩니다.
설명
- 이 API는
apps/dta-wide-api/src/app/questionnaire/services/questionnaire-response.service.ts의submitQuestionnaireResponse메서드의 로직을 기반으로 합니다. - 주요 검증 단계 (서비스 로직 내):
- 사용자의 활성 주기 ID 조회 (
getUserCycleId) - 설문지 및 회차 유효성 검증 (CQRS Query를 통해)
- 필수 질문 응답 여부 확인
- 질문 타입과 답변 형식 일치 여부 검증
- 선택지 기반 질문의 경우 optionId와 value 일치 여부 확인
- 사용자의 활성 주기 ID 조회 (
- JWT 토큰에서 사용자 ID가 자동으로 추출되어 사용됩니다 (
req.user). Request Body에서userId필드는 더 이상 필요하지 않습니다. - 점수 시스템이 있는 설문 (예: ISI, PHQ-9)의 경우 자동으로 점수가 계산되고 적절한 점수 레벨이 할당됩니다.
- 점수 시스템이 없는 설문 (예: WIS)의 경우
score필드가 null로 반환됩니다. - 제출된 응답은
QuestionnaireResponse엔티티로 저장되며, 각 답변은QuestionAnswer엔티티로 저장됩니다.
QuestionResponseDto 상세
질문별 응답 정보를 담는 DTO에 대한 상세 설명입니다.
| 필드 | 타입 | 설명 | 예시 | 필수 (Yes/No) |
|---|---|---|---|---|
id | string | 질문 ID | question_abc_001 | Yes |
type | string | 질문 타입 (QuestionType enum) | SINGLE_CHOICE | Yes |
responses | QuestionResponseOptionDto[] | 응답 선택지 목록 | (아래 상세 참조) | Yes |
textValue | string | 텍스트 응답 (선택사항) | 추가 의견입니다 | No |
decisionTimesMs | array | 답변 완료까지 총 소요 시간 배열 (밀리초) | [5000] | No |
QuestionResponseOptionDto 상세
각 응답 선택지에 대한 상세 설명입니다.
| 필드 | 타입 | 설명 | 예시 | 필수 (Yes/No) |
|---|---|---|---|---|
optionId | string | 선택지 ID (옵션 기반 질문의 경우 필수) | q_opt_001a | No |
value | string | 답변 값 | 0 | Yes |
userInputText | string | 사용자 텍스트 입력 값 (선택사항, 약물명, 기타 의견 등) | my_drug_name | No |
ResponseScoreLevelDto 상세
점수에 따른 레벨 정보를 담는 DTO입니다. 점수 시스템이 있는 설문에만 포함됩니다.
| 필드 | 타입 | 설명 | 예시 | 필수 (Yes/No) |
|---|---|---|---|---|
id | string | 점수 레벨 ID | sl_isi3 | Yes |
range | object | 점수 범위 | Yes | |
range.min | object | 최소 점수 객체 | Yes | |
range.min.value | float | 최소 점수 값 | 15 | Yes |
range.max | object | 최대 점수 객체 | Yes | |
range.max.value | float | 최대 점수 값 | 21 | Yes |
label | string | 점수 레벨 라벨 | 중간 정도의 불면증 | Yes |
chartLabel | string | 차트 라벨 | 15-21 점 | Yes |
description | string | 설명 | 중등도의 불면 증상이 나타날 수 있습니다. | Yes |
feedback | string | 피드백 메시지 | 중등도의 불면 증상이 나타날 수 있습니다. 의료 전문가의 도움이 필요한 상황입니다. | Yes |
SubmitQuestionnaireResponseResponseDto 상세
설문 응답 제출 성공 시 반환되는 최상위 응답 DTO입니다.
| 필드 | 타입 | 설명 | 예시 | 필수 (Yes/No) |
|---|---|---|---|---|
id | string | 새로 생성된 QuestionnaireResponse의 ID | resp_new_123 | Yes |
userId | string | 사용자 ID | user_sample_123 | Yes |
questionnaireId | string | 설문 ID | 9a0751c8-4c4c-41c2-ba0f-88018f26b014 | Yes |
roundId | string | 회차 ID | 2077951e-b4d7-4b59-92d7-0d4bfb482d76 | Yes |
score | ResponseScoreSectionDto | 점수 정보 (점수 시스템이 있는 설문만) | (ResponseScoreSectionDto 참조) | No |
createdAt | integer | 생성 시각 (Unix timestamp in milliseconds, Kotlin: Long, Swift: Int64) | 1716800000000 | Yes |
completedDayIndex | integer | 완료된 날짜 인덱스 | 1 | Yes |
변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-06-12 | elizabeth@weltcorp.com | 최초 문서 작성 |
| 0.2.1 | 2025-06-20 | elizabeth@weltcorp.com | DTO 구조 수정: score 객체를 별도 필드로 분리, 실제 구현과 문서 일치 |
| 0.2.2 | 2025-06-20 | elizabeth@weltcorp.com | UserStateNotFoundError (7008) 에러 처리 추가 |
| 0.2.3 | 2025-06-20 | elizabeth@weltcorp.com | ResponseScoreLevelDto에 minScore, maxScore 필드 추가 (bundle DTO와 구조 일치) |
| 0.2.4 | 2025-06-20 | elizabeth@weltcorp.com | 필드명 변경: questionId→id, questionType→type, optionId null 제거, minScore/maxScore optional 처리 |
| 0.2.5 | 2025-06-20 | elizabeth@weltcorp.com | 필드명 및 DTO명 변경: questionAnswers→questionResponses, QuestionAnswerDto→QuestionResponseDto |
| 0.2.6 | 2025-06-21 | elizabeth@weltcorp.com | 점수 구조 개편: ResponseScoreDto 도입, decisionTimeMs 배열 타입으로 변경, 새로운 점수 레벨 구조 적용 |
| 0.2.7 | 2025-06-22 | elizabeth@weltcorp.com | DTO 변경사항 반영: userInputText 필드 추가, decisionTimeMs 배열 타입 명시, userInputText 예시 추가 |
| 0.2.8 | 2025-06-23 | elizabeth@weltcorp.com | JWT 토큰 기반 인증으로 변경: userId 필드 제거, decisionTimeMs → decisionTimesMs 필드명 변경 |
| 0.2.9 | 2025-06-23 | elizabeth@weltcorp.com | 라운드 진행 기간 검증 에러 추가: RoundNotStartedError (9054), RoundExpiredError (9055) 에러 처리 추가 |
| 0.3.0 | 2025-07-01 | elizabeth@weltcorp.com | 누락된 에러 코드 추가: QUESTIONNAIRE_BUNDLE_NOT_FOUND (9010), QUESTIONNAIRE_RESPONSE_ALREADY_EXISTS (9046) 에러 처리 추가, 중복 응답 제출 시 409 Conflict 상태 코드 추가 |