diff --git a/src/main/java/com/dduru/gildongmu/auth/service/OauthAuthService.java b/src/main/java/com/dduru/gildongmu/auth/service/OauthAuthService.java
index fdeb3217..5aa82fd4 100644
--- a/src/main/java/com/dduru/gildongmu/auth/service/OauthAuthService.java
+++ b/src/main/java/com/dduru/gildongmu/auth/service/OauthAuthService.java
@@ -11,6 +11,8 @@
import com.dduru.gildongmu.common.exception.BusinessException;
import com.dduru.gildongmu.common.exception.ErrorCode;
import com.dduru.gildongmu.common.jwt.JwtTokenProvider;
+import com.dduru.gildongmu.profile.domain.Profile;
+import com.dduru.gildongmu.profile.repository.ProfileRepository;
import com.dduru.gildongmu.user.domain.User;
import com.dduru.gildongmu.user.enums.OauthType;
import com.dduru.gildongmu.user.enums.Role;
@@ -30,6 +32,7 @@ public class OauthAuthService {
private final OauthFactory oauthFactory;
private final UserRepository userRepository;
+ private final ProfileRepository profileRepository;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenService refreshTokenService;
@@ -145,22 +148,27 @@ private record UserCreationResult(User user, boolean isNewUser) {
}
private User createNewUser(OauthUserInfo oauthUserInfo) {
- // 추가 정보는 회원가입 이후 별도 입력으로 변경
- // 닉네임은 온보딩에서 설정하므로 null로 초기화
-
User newUser = User.builder()
.email(oauthUserInfo.email())
.name(oauthUserInfo.name())
- .nickname(null)
- .profileImage(oauthUserInfo.profileImage())
.oauthId(oauthUserInfo.oauthId())
.oauthType(oauthUserInfo.loginType())
.role(Role.USER)
+ .build();
+
+ User savedUser = userRepository.save(newUser);
+
+ Profile profile = Profile.builder()
+ .user(savedUser)
+ .profileImage(null)
+ .nickname(null)
.gender(null)
.phoneNumber(null)
.birthday(null)
.build();
- return userRepository.save(newUser);
+ profileRepository.save(profile);
+
+ return savedUser;
}
}
diff --git a/src/main/java/com/dduru/gildongmu/comment/dto/CommentResponse.java b/src/main/java/com/dduru/gildongmu/comment/dto/CommentResponse.java
index 20486c6f..1ec2383c 100644
--- a/src/main/java/com/dduru/gildongmu/comment/dto/CommentResponse.java
+++ b/src/main/java/com/dduru/gildongmu/comment/dto/CommentResponse.java
@@ -34,8 +34,8 @@ public static CommentResponse from(Comment comment) {
authorProfileImage = null;
} else {
content = comment.getContent();
- author = comment.getUser().getNickname();
- authorProfileImage = comment.getUser().getProfileImage();
+ author = comment.getUser().getProfile().getNickname();
+ authorProfileImage = comment.getUser().getProfile().getProfileImage();
}
return new CommentResponse(
diff --git a/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java b/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java
new file mode 100644
index 00000000..99dcebf2
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java
@@ -0,0 +1,28 @@
+package com.dduru.gildongmu.common.annotation;
+
+import com.dduru.gildongmu.common.exception.ErrorCode;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * API 엔드포인트에 자동으로 에러 응답을 추가하기 위한 어노테이션
+ * ErrorCode enum을 지정하면 해당 ErrorCode의 HTTP 상태 코드가 자동으로 사용됩니다.
+ *
+ * 사용 예시:
+ *
+ * {@code @ApiErrorResponses({ErrorCode.NICKNAME_ALREADY_TAKEN, ErrorCode.PROFILE_NOT_FOUND})}
+ * ResponseEntity> method();
+ *
+ */
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ApiErrorResponses {
+ /**
+ * 추가할 ErrorCode enum 목록 (예: ErrorCode.NICKNAME_ALREADY_TAKEN, ErrorCode.PROFILE_NOT_FOUND)
+ * 각 ErrorCode의 HTTP 상태 코드가 자동으로 사용됩니다.
+ */
+ ErrorCode[] value();
+}
diff --git a/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java b/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java
deleted file mode 100644
index df4e5922..00000000
--- a/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.dduru.gildongmu.common.annotation;
-
-import com.dduru.gildongmu.common.exception.ErrorResponse;
-import io.swagger.v3.oas.annotations.media.Content;
-import io.swagger.v3.oas.annotations.media.ExampleObject;
-import io.swagger.v3.oas.annotations.media.Schema;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.responses.ApiResponses;
-
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-@Target({ElementType.METHOD, ElementType.TYPE})
-@Retention(RetentionPolicy.RUNTIME)
-@ApiResponses({
- @ApiResponse(
- responseCode = "400",
- description = "잘못된 요청",
- content = @Content(
- mediaType = "application/json",
- schema = @Schema(implementation = ErrorResponse.class),
- examples = @ExampleObject(
- name = "InvalidInput",
- value = """
- {
- "status": 400,
- "data": {
- "errorCode": "INVALID_INPUT_VALUE",
- "field": null,
- "message": "잘못된 입력 값입니다."
- }
- }
- """
- )
- )
- ),
- @ApiResponse(
- responseCode = "401",
- description = "인증 실패",
- content = @Content(
- mediaType = "application/json",
- schema = @Schema(implementation = ErrorResponse.class),
- examples = @ExampleObject(
- name = "Unauthorized",
- value = """
- {
- "status": 401,
- "data": {
- "errorCode": "UNAUTHORIZED",
- "field": null,
- "message": "인증되지 않은 사용자입니다."
- }
- }
- """
- )
- )
- ),
- @ApiResponse(
- responseCode = "404",
- description = "리소스를 찾을 수 없음",
- content = @Content(
- mediaType = "application/json",
- schema = @Schema(implementation = ErrorResponse.class),
- examples = @ExampleObject(
- name = "NotFound",
- value = """
- {
- "status": 404,
- "data": {
- "errorCode": "NOT_FOUND",
- "field": null,
- "message": "요청한 리소스를 찾을 수 없습니다."
- }
- }
- """
- )
- )
- ),
- @ApiResponse(
- responseCode = "500",
- description = "서버 오류",
- content = @Content(
- mediaType = "application/json",
- schema = @Schema(implementation = ErrorResponse.class),
- examples = @ExampleObject(
- name = "InternalServerError",
- value = """
- {
- "status": 500,
- "data": {
- "errorCode": "INTERNAL_SERVER_ERROR",
- "field": null,
- "message": "서버 오류가 발생했습니다."
- }
- }
- """
- )
- )
- )
-})
-public @interface CommonApiResponses {
-}
diff --git a/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java b/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java
new file mode 100644
index 00000000..5e7e8bfa
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java
@@ -0,0 +1,175 @@
+package com.dduru.gildongmu.common.exception;
+
+import com.dduru.gildongmu.common.annotation.ApiErrorResponses;
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.examples.Example;
+import io.swagger.v3.oas.models.media.Content;
+import io.swagger.v3.oas.models.media.MediaType;
+import io.swagger.v3.oas.models.media.Schema;
+import io.swagger.v3.oas.models.responses.ApiResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springdoc.core.customizers.OperationCustomizer;
+import org.springdoc.core.customizers.OpenApiCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * OpenAPI 에러 응답 자동 추가 커스터마이저
+ *
+ * @ApiErrorResponses 어노테이션에 ErrorCode enum을 지정하면,
+ * 해당 ErrorCode의 HTTP 상태 코드와 메시지가 자동으로 사용됩니다.
+ */
+@Slf4j
+@Configuration
+public class ApiErrorResponseDocsCustomizer {
+
+ /**
+ * ErrorResponse 스키마를 Components에 등록
+ */
+ @Bean
+ public OpenApiCustomizer errorResponseSchemaCustomizer() {
+ return openApi -> {
+ Components components = openApi.getComponents();
+ if (components == null) {
+ components = new Components();
+ openApi.setComponents(components);
+ }
+
+ // ErrorResponse 스키마 정의
+ Schema> errorResponseSchema = new Schema<>()
+ .type("object")
+ .addProperty("status", new Schema<>().type("integer").format("int32").example(400))
+ .addProperty("data", new Schema<>()
+ .type("object")
+ .addProperty("errorCode", new Schema<>().type("string").example("INVALID_INPUT_VALUE"))
+ .addProperty("field", new Schema<>().type("string").nullable(true))
+ .addProperty("message", new Schema<>().type("string").example("잘못된 입력 값입니다.")));
+
+ components.addSchemas("ErrorResponse", errorResponseSchema);
+ };
+ }
+
+ /**
+ * @ApiErrorResponses 어노테이션에 지정된 ErrorCode enum을 기반으로 에러 응답 자동 추가
+ */
+ @Bean
+ public OperationCustomizer operationCustomizer() {
+ return (operation, handlerMethod) -> {
+ ErrorCode[] errorCodes = new ErrorCode[0];
+
+ Method method = handlerMethod.getMethod();
+ Class> declaringClass = method.getDeclaringClass();
+ String methodName = method.getName();
+
+ // 모든 인터페이스에서 메서드를 찾아 어노테이션 확인
+ for (Class> iface : declaringClass.getInterfaces()) {
+ // 인터페이스의 모든 메서드 검색 (파라미터 타입이 다를 수 있으므로)
+ for (Method interfaceMethod : iface.getMethods()) {
+ // 메서드 이름이 같고 어노테이션이 있으면 사용
+ if (interfaceMethod.getName().equals(methodName)
+ && interfaceMethod.isAnnotationPresent(ApiErrorResponses.class)) {
+ errorCodes = interfaceMethod.getAnnotation(ApiErrorResponses.class).value();
+ break;
+ }
+ }
+ if (errorCodes.length > 0) {
+ break;
+ }
+
+ // 인터페이스 레벨 어노테이션 확인
+ if (iface.isAnnotationPresent(ApiErrorResponses.class)) {
+ errorCodes = iface.getAnnotation(ApiErrorResponses.class).value();
+ break;
+ }
+ }
+
+ // 클래스 레벨 어노테이션 확인
+ if (errorCodes.length == 0 && declaringClass.isAnnotationPresent(ApiErrorResponses.class)) {
+ errorCodes = declaringClass.getAnnotation(ApiErrorResponses.class).value();
+ }
+
+ // 메서드 레벨 어노테이션 확인 (메서드 레벨이 우선)
+ if (method.isAnnotationPresent(ApiErrorResponses.class)) {
+ errorCodes = method.getAnnotation(ApiErrorResponses.class).value();
+ }
+
+ if (errorCodes.length > 0) {
+ addErrorResponses(operation, errorCodes);
+ }
+
+ return operation;
+ };
+ }
+
+ /**
+ * ErrorCode enum 배열을 기반으로 에러 응답 추가
+ * 각 ErrorCode에 대해 해당하는 HTTP 상태 코드의 응답을 추가하고, 예시를 포함합니다.
+ * 같은 HTTP 상태 코드를 가진 여러 ErrorCode가 있으면 모두 examples에 추가합니다.
+ */
+ private void addErrorResponses(io.swagger.v3.oas.models.Operation operation, ErrorCode[] errorCodes) {
+ // ErrorResponse 스키마 참조
+ Schema> errorResponseSchema = new Schema<>()
+ .$ref("#/components/schemas/ErrorResponse");
+
+ // HTTP 상태 코드별로 ErrorCode들을 그룹화
+ Map> statusCodeToErrorCodes = new HashMap<>();
+
+ for (ErrorCode errorCode : errorCodes) {
+ String statusCode = String.valueOf(errorCode.getStatus());
+ statusCodeToErrorCodes.computeIfAbsent(statusCode, k -> new java.util.ArrayList<>()).add(errorCode);
+ }
+
+ // 각 상태 코드에 대해 에러 응답 추가
+ for (Map.Entry> entry : statusCodeToErrorCodes.entrySet()) {
+ String statusCode = entry.getKey();
+ java.util.List errorCodeList = entry.getValue();
+
+ // 이미 해당 상태 코드의 응답이 있으면 스킵
+ if (operation.getResponses().containsKey(statusCode)) {
+ continue;
+ }
+
+ // 첫 번째 ErrorCode를 기본으로 사용 (description)
+ ErrorCode primaryErrorCode = errorCodeList.get(0);
+
+ // 각 ErrorCode에 대한 예시 생성
+ Map examples = new HashMap<>();
+ for (ErrorCode errorCode : errorCodeList) {
+ Map errorResponseMap = new LinkedHashMap<>();
+ errorResponseMap.put("status", errorCode.getStatus());
+
+ Map dataMap = new LinkedHashMap<>();
+ dataMap.put("errorCode", errorCode.name());
+ dataMap.put("field", null);
+ dataMap.put("message", errorCode.getMessage());
+ errorResponseMap.put("data", dataMap);
+
+ Example example = new Example();
+ example.setValue(errorResponseMap);
+ example.setSummary(errorCode.getMessage());
+ examples.put(errorCode.name(), example);
+ }
+
+ // MediaType 생성 (여러 예시가 있으면 examples 사용, 하나만 있으면 example 사용)
+ MediaType mediaType = new MediaType().schema(errorResponseSchema);
+ if (examples.size() > 1) {
+ mediaType.setExamples(examples);
+ } else if (examples.size() == 1) {
+ Example singleExample = examples.values().iterator().next();
+ mediaType.setExample(singleExample.getValue());
+ }
+
+ // 에러 응답 생성
+ ApiResponse apiResponse = new ApiResponse()
+ .description(primaryErrorCode.getMessage())
+ .content(new Content().addMediaType("application/json", mediaType));
+
+ operation.getResponses().addApiResponse(statusCode, apiResponse);
+ }
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/common/exception/ErrorCode.java b/src/main/java/com/dduru/gildongmu/common/exception/ErrorCode.java
index b267e5bb..96a8b1ff 100644
--- a/src/main/java/com/dduru/gildongmu/common/exception/ErrorCode.java
+++ b/src/main/java/com/dduru/gildongmu/common/exception/ErrorCode.java
@@ -14,7 +14,7 @@ public enum ErrorCode {
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."),
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 다른 소셜 계정으로 가입된 이메일입니다."),
- // 닉네임 (NICKNAME) - 이전 '온보딩 (ONBD)'
+ // 닉네임 (NICKNAME)
NICKNAME_NOT_BLANK(HttpStatus.BAD_REQUEST, "닉네임은 공백일 수 없습니다."),
NICKNAME_INVALID_LENGTH(HttpStatus.BAD_REQUEST, "닉네임은 2자 이상 14자 이하로 입력해주세요."),
NICKNAME_INVALID_CHARACTERS(HttpStatus.BAD_REQUEST, "닉네임은 한글, 영어, 숫자만 사용 가능합니다."),
@@ -25,6 +25,7 @@ public enum ErrorCode {
// 사용자 (USER)
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."),
+ PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저의 프로필을 찾을 수 없습니다."),
// 게시글 (POST)
POST_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."),
@@ -61,7 +62,7 @@ public enum ErrorCode {
INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "허용되지 않는 파일 확장자입니다."),
// 설문 (SURVEY)
- UNKNOWN_SURVEY_ANSWER_CODE(HttpStatus.BAD_REQUEST, "알 수 없는 설문조사 답변 코드입니다."),
+ SURVEY_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "설문 결과를 찾을 수 없습니다."),
// 휴대폰 인증 (VERIFICATION)
SMS_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SMS 발송에 실패했습니다."),
diff --git a/src/main/java/com/dduru/gildongmu/common/jwt/JwtTokenProvider.java b/src/main/java/com/dduru/gildongmu/common/jwt/JwtTokenProvider.java
index 9263e0cd..9971b6cd 100644
--- a/src/main/java/com/dduru/gildongmu/common/jwt/JwtTokenProvider.java
+++ b/src/main/java/com/dduru/gildongmu/common/jwt/JwtTokenProvider.java
@@ -122,6 +122,50 @@ public String createVerificationToken(Long userId, String phoneNumber) {
.compact();
}
+ /**
+ * Verification Token 검증 및 전화번호 추출
+ *
+ * @param token verification token
+ * @param expectedPhoneNumber 검증할 전화번호
+ * @return 검증 성공 여부
+ */
+ public boolean validateVerificationToken(String token, String expectedPhoneNumber) {
+ try {
+ Claims claims = getClaims(token);
+ String tokenType = claims.get("type", String.class);
+ if (!"verification".equals(tokenType)) {
+ log.warn("Verification Token이 아닙니다 - {}", tokenType);
+ return false;
+ }
+
+ String phoneNumber = claims.get("phone_number", String.class);
+ if (phoneNumber == null || !phoneNumber.equals(expectedPhoneNumber)) {
+ log.warn("전화번호가 일치하지 않습니다. 토큰: {}, 요청: {}", phoneNumber, expectedPhoneNumber);
+ return false;
+ }
+
+ return true;
+ } catch (ExpiredJwtException e) {
+ log.warn("Verification Token이 만료되었습니다");
+ return false;
+ } catch (UnsupportedJwtException e) {
+ log.warn("JWT 형식이 틀렸습니다");
+ return false;
+ } catch (MalformedJwtException e) {
+ log.warn("JWT 구조가 잘못되었습니다");
+ return false;
+ } catch (SignatureException e) {
+ log.warn("JWT 서명 검증 실패하였습니다");
+ return false;
+ } catch (IllegalArgumentException e) {
+ log.warn("부적절한 값이 들어왔습니다");
+ return false;
+ } catch (JwtException e) {
+ log.warn("Verification Token이 유효하지 않습니다: {}", e.getMessage());
+ return false;
+ }
+ }
+
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtSecret)
diff --git a/src/main/java/com/dduru/gildongmu/post/domain/Post.java b/src/main/java/com/dduru/gildongmu/post/domain/Post.java
index d1285131..8db2c7e1 100644
--- a/src/main/java/com/dduru/gildongmu/post/domain/Post.java
+++ b/src/main/java/com/dduru/gildongmu/post/domain/Post.java
@@ -4,10 +4,15 @@
import com.dduru.gildongmu.destination.domain.Destination;
import com.dduru.gildongmu.participation.domain.Participation;
import com.dduru.gildongmu.post.enums.PostStatus;
-import com.dduru.gildongmu.post.exception.*;
+import com.dduru.gildongmu.post.exception.InvalidPostStatusException;
+import com.dduru.gildongmu.post.exception.InvalidRecruitCapacityException;
+import com.dduru.gildongmu.post.exception.RecruitCountBelowZeroException;
+import com.dduru.gildongmu.post.exception.RecruitCountExceedCapacityException;
+import com.dduru.gildongmu.post.exception.TravelAlreadyEndedException;
+import com.dduru.gildongmu.post.exception.TravelAlreadyStartedException;
+import com.dduru.gildongmu.profile.domain.enums.AgeRange;
+import com.dduru.gildongmu.profile.domain.enums.Gender;
import com.dduru.gildongmu.user.domain.User;
-import com.dduru.gildongmu.user.enums.AgeRange;
-import com.dduru.gildongmu.user.enums.Gender;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
diff --git a/src/main/java/com/dduru/gildongmu/post/repository/PostRepositoryImpl.java b/src/main/java/com/dduru/gildongmu/post/repository/PostRepositoryImpl.java
index a36d6cc8..744b57c1 100644
--- a/src/main/java/com/dduru/gildongmu/post/repository/PostRepositoryImpl.java
+++ b/src/main/java/com/dduru/gildongmu/post/repository/PostRepositoryImpl.java
@@ -1,11 +1,11 @@
package com.dduru.gildongmu.post.repository;
-import com.dduru.gildongmu.user.enums.AgeRange;
-import com.dduru.gildongmu.user.enums.Gender;
import com.dduru.gildongmu.post.domain.Post;
import com.dduru.gildongmu.post.dto.PostListRequest;
import com.dduru.gildongmu.post.enums.PostStatus;
-import com.dduru.gildongmu.user.exception.InvalidGenderException;
+import com.dduru.gildongmu.profile.domain.enums.AgeRange;
+import com.dduru.gildongmu.profile.domain.enums.Gender;
+import com.dduru.gildongmu.profile.exception.InvalidGenderException;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/dduru/gildongmu/post/service/PostService.java b/src/main/java/com/dduru/gildongmu/post/service/PostService.java
index d20bcdbb..9cd2bc81 100644
--- a/src/main/java/com/dduru/gildongmu/post/service/PostService.java
+++ b/src/main/java/com/dduru/gildongmu/post/service/PostService.java
@@ -14,9 +14,9 @@
import com.dduru.gildongmu.post.exception.InvalidPostDateException;
import com.dduru.gildongmu.post.exception.PostAccessDeniedException;
import com.dduru.gildongmu.post.repository.PostRepository;
+import com.dduru.gildongmu.profile.domain.enums.AgeRange;
+import com.dduru.gildongmu.profile.domain.enums.Gender;
import com.dduru.gildongmu.user.domain.User;
-import com.dduru.gildongmu.user.enums.AgeRange;
-import com.dduru.gildongmu.user.enums.Gender;
import com.dduru.gildongmu.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
diff --git a/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java
new file mode 100644
index 00000000..95efa4cc
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java
@@ -0,0 +1,111 @@
+package com.dduru.gildongmu.profile.controller;
+
+import com.dduru.gildongmu.common.annotation.ApiErrorResponses;
+import com.dduru.gildongmu.common.dto.ApiResult;
+import com.dduru.gildongmu.common.exception.ErrorCode;
+import com.dduru.gildongmu.profile.dto.NicknameRandomResponse;
+import com.dduru.gildongmu.profile.dto.NicknameUpdateRequest;
+import com.dduru.gildongmu.profile.dto.NicknameValidateResponse;
+import com.dduru.gildongmu.profile.dto.ProfileSetupRequest;
+import com.dduru.gildongmu.profile.validator.ValidNickname;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "Profiles", description = "사용자 프로필 관리 API")
+public interface ProfileApiDocs {
+
+ @Operation(summary = "닉네임 수정", description = "사용자의 닉네임을 수정합니다.")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "204",
+ description = "닉네임 수정 성공",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = ApiResult.class))
+ )
+ })
+ @ApiErrorResponses({
+ ErrorCode.NICKNAME_INVALID_LENGTH,
+ ErrorCode.NICKNAME_INVALID_CHARACTERS,
+ ErrorCode.NICKNAME_CONTAINS_BAD_WORD,
+ ErrorCode.UNAUTHORIZED,
+ ErrorCode.PROFILE_NOT_FOUND,
+ ErrorCode.NICKNAME_ALREADY_TAKEN
+ })
+ ResponseEntity> updateNickname(
+ @Parameter(hidden = true) Long id,
+ @Valid NicknameUpdateRequest request
+ );
+
+ @Operation(summary = "닉네임 유효성 확인", description = "닉네임의 사용 가능 여부를 확인합니다. 닉네임 형식 검증 및 중복 여부를 체크합니다.")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "닉네임 유효성 확인 성공 - 사용 가능한 닉네임임",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = NicknameValidateResponse.class))
+ )
+ })
+ @ApiErrorResponses({
+ ErrorCode.NICKNAME_INVALID_LENGTH,
+ ErrorCode.NICKNAME_INVALID_CHARACTERS,
+ ErrorCode.NICKNAME_CONTAINS_BAD_WORD,
+ ErrorCode.NICKNAME_ALREADY_TAKEN
+ })
+ ResponseEntity> checkNickname(
+ @Parameter(
+ description = "유효성 체크할 닉네임",
+ example = "길동무"
+ )
+ @ValidNickname String nickname
+ );
+
+ @Operation(summary = "랜덤 닉네임 생성", description = "랜덤으로 사용 가능한 닉네임을 생성합니다. 형용사와 명사 조합에 랜덤 숫자를 추가하여 고유성을 보장합니다.")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "랜덤 닉네임 생성 성공",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = NicknameRandomResponse.class))
+ )
+ })
+ @ApiErrorResponses({ErrorCode.UNAUTHORIZED})
+ ResponseEntity> generateRandomNickname();
+
+ @Operation(
+ summary = "프로필 초기 설정",
+ description = "온보딩 과정에서 사용자의 프로필 정보를 초기 설정합니다. 닉네임, 성별, 전화번호, 생년월일을 저장하며, 비관적 잠금을 사용하여 닉네임 중복을 방지합니다."
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "204",
+ description = "프로필 초기 설정 성공",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = ApiResult.class))
+ )
+ })
+ @ApiErrorResponses({
+ ErrorCode.INVALID_INPUT_VALUE,
+ ErrorCode.NICKNAME_INVALID_LENGTH,
+ ErrorCode.NICKNAME_INVALID_CHARACTERS,
+ ErrorCode.UNAUTHORIZED,
+ ErrorCode.INVALID_TOKEN,
+ ErrorCode.PROFILE_NOT_FOUND,
+ ErrorCode.NICKNAME_ALREADY_TAKEN,
+ ErrorCode.DUPLICATE_PHONE_NUMBER
+ })
+ ResponseEntity> setupInitialProfile(
+ @Parameter(hidden = true) Long userId,
+ @Parameter(
+ description = "프로필 초기 설정 요청 정보",
+ required = true
+ )
+ @Valid ProfileSetupRequest request
+ );
+}
diff --git a/src/main/java/com/dduru/gildongmu/user/controller/UserController.java b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileController.java
similarity index 53%
rename from src/main/java/com/dduru/gildongmu/user/controller/UserController.java
rename to src/main/java/com/dduru/gildongmu/profile/controller/ProfileController.java
index 1ffe97a5..2bada98e 100644
--- a/src/main/java/com/dduru/gildongmu/user/controller/UserController.java
+++ b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileController.java
@@ -1,12 +1,13 @@
-package com.dduru.gildongmu.user.controller;
+package com.dduru.gildongmu.profile.controller;
import com.dduru.gildongmu.common.annotation.CurrentUser;
import com.dduru.gildongmu.common.dto.ApiResult;
-import com.dduru.gildongmu.user.dto.NicknameRandomResponse;
-import com.dduru.gildongmu.user.dto.UserCheckNicknameResponse;
-import com.dduru.gildongmu.user.dto.UserUpdateNicknameRequest;
-import com.dduru.gildongmu.user.service.UserService;
-import com.dduru.gildongmu.user.validator.ValidNickname;
+import com.dduru.gildongmu.profile.dto.NicknameRandomResponse;
+import com.dduru.gildongmu.profile.dto.NicknameValidateResponse;
+import com.dduru.gildongmu.profile.dto.NicknameUpdateRequest;
+import com.dduru.gildongmu.profile.dto.ProfileSetupRequest;
+import com.dduru.gildongmu.profile.service.ProfileService;
+import com.dduru.gildongmu.profile.validator.ValidNickname;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@@ -23,33 +24,43 @@
@RequestMapping("/api/v1")
@RequiredArgsConstructor
@RestController
-public class UserController implements UserApiDocs {
+public class ProfileController implements ProfileApiDocs {
- private final UserService userService;
+ private final ProfileService profileService;
@Override
@PutMapping("/users/nickname")
public ResponseEntity> updateNickname(
@CurrentUser Long id,
- @Valid @RequestBody UserUpdateNicknameRequest request
+ @Valid @RequestBody NicknameUpdateRequest request
) {
- userService.updateNickname(id, request);
+ profileService.updateNickname(id, request);
return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ApiResult.noContent());
}
@Override
@GetMapping("/nicknames/{nickname}/availability")
- public ResponseEntity> checkNickname(
+ public ResponseEntity> checkNickname(
@PathVariable @ValidNickname String nickname
) {
- UserCheckNicknameResponse response = userService.checkNickname(nickname);
+ NicknameValidateResponse response = profileService.checkNickname(nickname);
return ResponseEntity.ok(ApiResult.ok(response));
}
@Override
@GetMapping("/nicknames/random")
public ResponseEntity> generateRandomNickname() {
- NicknameRandomResponse response = userService.generateRandomNickname();
+ NicknameRandomResponse response = profileService.generateRandomNickname();
return ResponseEntity.ok(ApiResult.ok(response));
}
+
+ @Override
+ @PutMapping("/me/profile")
+ public ResponseEntity> setupInitialProfile(
+ @CurrentUser Long userId,
+ @RequestBody @Valid ProfileSetupRequest request
+ ) {
+ profileService.setupInitialProfile(userId, request);
+ return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ApiResult.noContent());
+ }
}
diff --git a/src/main/java/com/dduru/gildongmu/profile/domain/Profile.java b/src/main/java/com/dduru/gildongmu/profile/domain/Profile.java
new file mode 100644
index 00000000..99c571d6
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/profile/domain/Profile.java
@@ -0,0 +1,75 @@
+package com.dduru.gildongmu.profile.domain;
+
+import com.dduru.gildongmu.common.entity.BaseTimeEntity;
+import com.dduru.gildongmu.profile.domain.enums.Gender;
+import com.dduru.gildongmu.user.domain.User;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+
+@Entity
+@Table(name = "profiles")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Profile extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false, unique = true)
+ private User user;
+
+ @Column(length = 14, unique = true)
+ private String nickname;
+
+ @Enumerated(EnumType.STRING)
+ private Gender gender;
+
+ @Column(name = "phone_number", length = 20)
+ private String phoneNumber;
+
+ @Column(name = "birthday")
+ private LocalDate birthday;
+
+ @Column(name = "profile_image", length = 500)
+ private String profileImage;
+
+ @Column(name = "self_introduction", length = 60)
+ private String selfIntroduction;
+
+ @Builder
+ public Profile(User user, String nickname, Gender gender, String phoneNumber, LocalDate birthday, String profileImage, String selfIntroduction) {
+ this.user = user;
+ this.nickname = nickname;
+ this.gender = gender;
+ this.phoneNumber = phoneNumber;
+ this.birthday = birthday;
+ this.profileImage = profileImage;
+ this.selfIntroduction = selfIntroduction;
+ }
+
+ public void updateNickname(String nickname) {
+ this.nickname = nickname;
+ }
+
+ public void setupInitialProfile(String nickname, Gender gender, String phoneNumber, LocalDate birthday) {
+ if (nickname != null) {
+ this.nickname = nickname;
+ }
+ if (gender != null) {
+ this.gender = gender;
+ }
+ if (phoneNumber != null) {
+ this.phoneNumber = phoneNumber;
+ }
+ if (birthday != null) {
+ this.birthday = birthday;
+ }
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/user/enums/AgeRange.java b/src/main/java/com/dduru/gildongmu/profile/domain/enums/AgeRange.java
similarity index 93%
rename from src/main/java/com/dduru/gildongmu/user/enums/AgeRange.java
rename to src/main/java/com/dduru/gildongmu/profile/domain/enums/AgeRange.java
index 761267e8..950497e0 100644
--- a/src/main/java/com/dduru/gildongmu/user/enums/AgeRange.java
+++ b/src/main/java/com/dduru/gildongmu/profile/domain/enums/AgeRange.java
@@ -1,4 +1,4 @@
-package com.dduru.gildongmu.user.enums;
+package com.dduru.gildongmu.profile.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/dduru/gildongmu/user/enums/Gender.java b/src/main/java/com/dduru/gildongmu/profile/domain/enums/Gender.java
similarity index 83%
rename from src/main/java/com/dduru/gildongmu/user/enums/Gender.java
rename to src/main/java/com/dduru/gildongmu/profile/domain/enums/Gender.java
index 7872ec88..9cb536bc 100644
--- a/src/main/java/com/dduru/gildongmu/user/enums/Gender.java
+++ b/src/main/java/com/dduru/gildongmu/profile/domain/enums/Gender.java
@@ -1,6 +1,6 @@
-package com.dduru.gildongmu.user.enums;
+package com.dduru.gildongmu.profile.domain.enums;
-import com.dduru.gildongmu.user.exception.InvalidGenderException;
+import com.dduru.gildongmu.profile.exception.InvalidGenderException;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/dduru/gildongmu/user/dto/NicknameRandomResponse.java b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameRandomResponse.java
similarity index 87%
rename from src/main/java/com/dduru/gildongmu/user/dto/NicknameRandomResponse.java
rename to src/main/java/com/dduru/gildongmu/profile/dto/NicknameRandomResponse.java
index 7701e88f..4891b8fa 100644
--- a/src/main/java/com/dduru/gildongmu/user/dto/NicknameRandomResponse.java
+++ b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameRandomResponse.java
@@ -1,4 +1,4 @@
-package com.dduru.gildongmu.user.dto;
+package com.dduru.gildongmu.profile.dto;
import lombok.Builder;
diff --git a/src/main/java/com/dduru/gildongmu/user/dto/UserUpdateNicknameRequest.java b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameUpdateRequest.java
similarity index 66%
rename from src/main/java/com/dduru/gildongmu/user/dto/UserUpdateNicknameRequest.java
rename to src/main/java/com/dduru/gildongmu/profile/dto/NicknameUpdateRequest.java
index da212368..21025f08 100644
--- a/src/main/java/com/dduru/gildongmu/user/dto/UserUpdateNicknameRequest.java
+++ b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameUpdateRequest.java
@@ -1,9 +1,9 @@
-package com.dduru.gildongmu.user.dto;
+package com.dduru.gildongmu.profile.dto;
-import com.dduru.gildongmu.user.validator.ValidNickname;
+import com.dduru.gildongmu.profile.validator.ValidNickname;
import io.swagger.v3.oas.annotations.media.Schema;
-public record UserUpdateNicknameRequest(
+public record NicknameUpdateRequest(
@Schema(
description = "닉네임",
example = "길동무",
diff --git a/src/main/java/com/dduru/gildongmu/profile/dto/NicknameValidateResponse.java b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameValidateResponse.java
new file mode 100644
index 00000000..a9e63b11
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameValidateResponse.java
@@ -0,0 +1,9 @@
+package com.dduru.gildongmu.profile.dto;
+
+import lombok.Builder;
+
+@Builder
+public record NicknameValidateResponse(
+ String sanitizedNickname
+) {
+}
diff --git a/src/main/java/com/dduru/gildongmu/profile/dto/ProfileSetupRequest.java b/src/main/java/com/dduru/gildongmu/profile/dto/ProfileSetupRequest.java
new file mode 100644
index 00000000..b3c5a26f
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/profile/dto/ProfileSetupRequest.java
@@ -0,0 +1,34 @@
+package com.dduru.gildongmu.profile.dto;
+
+import com.dduru.gildongmu.profile.validator.ValidNickname;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+
+
+public record ProfileSetupRequest(
+ @Schema(description = "닉네임", example = "용감한여행자1234")
+ @ValidNickname
+ @NotBlank
+ String nickname,
+
+ @Schema(description = "성별", example = "M", allowableValues = {"M", "F"})
+ @NotBlank
+ @Pattern(regexp = "^(M|F)$", message = "성별 형식이 올바르지 않습니다.")
+ String gender,
+
+ @Schema(description = "전화번호", example = "01012345678")
+ @NotBlank
+ @Pattern(regexp = "^010\\d{8}$", message = "전화번호는 010으로 시작하는 11자리 숫자여야 합니다.")
+ String phoneNumber,
+
+ @Schema(description = "생년월일", example = "2000-01-01")
+ @NotBlank
+ @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "생년월일 형식이 올바르지 않습니다. (예: 2000-01-01)")
+ String birthday,
+
+ @Schema(description = "전화번호 인증 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
+ @NotBlank
+ String verificationToken
+) {
+}
diff --git a/src/main/java/com/dduru/gildongmu/user/exception/InvalidGenderException.java b/src/main/java/com/dduru/gildongmu/profile/exception/InvalidGenderException.java
similarity index 92%
rename from src/main/java/com/dduru/gildongmu/user/exception/InvalidGenderException.java
rename to src/main/java/com/dduru/gildongmu/profile/exception/InvalidGenderException.java
index 0258cc0c..1e73b86a 100644
--- a/src/main/java/com/dduru/gildongmu/user/exception/InvalidGenderException.java
+++ b/src/main/java/com/dduru/gildongmu/profile/exception/InvalidGenderException.java
@@ -1,4 +1,4 @@
-package com.dduru.gildongmu.user.exception;
+package com.dduru.gildongmu.profile.exception;
import com.dduru.gildongmu.common.exception.BusinessException;
import com.dduru.gildongmu.common.exception.ErrorCode;
diff --git a/src/main/java/com/dduru/gildongmu/profile/repository/ProfileRepository.java b/src/main/java/com/dduru/gildongmu/profile/repository/ProfileRepository.java
new file mode 100644
index 00000000..3938ca10
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/profile/repository/ProfileRepository.java
@@ -0,0 +1,28 @@
+package com.dduru.gildongmu.profile.repository;
+
+import com.dduru.gildongmu.profile.domain.Profile;
+import com.dduru.gildongmu.user.domain.User;
+import jakarta.persistence.LockModeType;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface ProfileRepository extends JpaRepository {
+ Optional findByUser(User user);
+
+ Optional findByUser_Id(Long userId);
+
+ boolean existsByNickname(String nickname);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("SELECT COUNT(p) > 0 FROM Profile p WHERE p.nickname = :nickname")
+ boolean existsByNicknameWithLock(@Param("nickname") String nickname);
+
+ boolean existsByPhoneNumber(String phoneNumber);
+}
+
diff --git a/src/main/java/com/dduru/gildongmu/profile/service/ProfileService.java b/src/main/java/com/dduru/gildongmu/profile/service/ProfileService.java
new file mode 100644
index 00000000..d71f83fe
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/profile/service/ProfileService.java
@@ -0,0 +1,152 @@
+package com.dduru.gildongmu.profile.service;
+
+import com.dduru.gildongmu.common.exception.BusinessException;
+import com.dduru.gildongmu.common.exception.ErrorCode;
+import com.dduru.gildongmu.common.jwt.JwtTokenProvider;
+import com.dduru.gildongmu.profile.domain.Profile;
+import com.dduru.gildongmu.profile.domain.enums.Gender;
+import com.dduru.gildongmu.profile.dto.NicknameRandomResponse;
+import com.dduru.gildongmu.profile.dto.NicknameUpdateRequest;
+import com.dduru.gildongmu.profile.dto.NicknameValidateResponse;
+import com.dduru.gildongmu.profile.dto.ProfileSetupRequest;
+import com.dduru.gildongmu.profile.repository.ProfileRepository;
+import com.dduru.gildongmu.profile.utils.NicknameGenerator;
+import com.dduru.gildongmu.user.domain.User;
+import com.dduru.gildongmu.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class ProfileService {
+
+ private static final int NICKNAME_MAX_RETRY_ATTEMPTS = 10;
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ private final NicknameGenerator nicknameGenerator;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ private final ProfileRepository profileRepository;
+ private final UserRepository userRepository;
+
+ @Transactional
+ public void updateNickname(Long userId, NicknameUpdateRequest request) {
+ User user = userRepository.getByIdOrThrow(userId);
+ Profile profile = getProfileByUserId(user);
+ checkDuplicateNicknameWithLock(request.nickname());
+
+ profile.updateNickname(request.nickname());
+ profileRepository.save(profile);
+ }
+
+ @Transactional(readOnly = true)
+ public NicknameValidateResponse checkNickname(String nickname) {
+ checkDuplicateNickname(nickname);
+
+ return NicknameValidateResponse.builder()
+ .sanitizedNickname(nickname)
+ .build();
+ }
+
+ /**
+ * 중복되지 않는 유니크한 닉네임을 생성합니다.
+ * 형용사 + 명사 조합에 랜덤 숫자를 추가하여 고유성을 보장합니다.
+ * DB에 중복이 있으면 새로운 랜덤 숫자로 재시도합니다.
+ *
+ * @return 유니크한 닉네임 (예: "용감한여행자1234")
+ */
+ @Transactional(readOnly = true)
+ public NicknameRandomResponse generateRandomNickname() {
+
+ for (int attempt = 0; attempt < NICKNAME_MAX_RETRY_ATTEMPTS; attempt++) {
+ String baseNickname = nicknameGenerator.generateBaseNickname();
+ int randomNumber = nicknameGenerator.generateRandomNumber();
+ String nickname = baseNickname + randomNumber;
+
+ if (!profileRepository.existsByNickname(nickname)) {
+ log.info("랜덤 닉네임 생성: {}", nickname);
+ return NicknameRandomResponse.of(nickname);
+ }
+
+ log.debug("해당 닉네임이 이미 존재합니다: {}, 새로운 숫자를 부여하겠습니다.", nickname);
+ }
+
+ String fallbackNickname = "뚜비" + (System.currentTimeMillis() % 10000);
+ log.warn("유니크한 닉네임 생성에 {}회 실패하여 대체 닉네임 사용: {}", NICKNAME_MAX_RETRY_ATTEMPTS, fallbackNickname);
+ return NicknameRandomResponse.of(fallbackNickname);
+ }
+
+ @Transactional
+ public void setupInitialProfile(Long userId, ProfileSetupRequest request) {
+ User user = userRepository.getByIdOrThrow(userId);
+ Profile profile = getProfileByUserId(user);
+
+ // 비관적 잠금을 사용하여 닉네임 중복 체크 (race condition 방지)
+ checkDuplicateNicknameWithLock(request.nickname());
+
+ validateVerificationToken(request.verificationToken(), request.phoneNumber());
+
+ if (profileRepository.existsByPhoneNumber(request.phoneNumber())) {
+ throw new BusinessException(ErrorCode.DUPLICATE_PHONE_NUMBER);
+ }
+
+ LocalDate birthday = parseBirthDate(request.birthday());
+
+ profile.setupInitialProfile(
+ request.nickname(),
+ Gender.valueOf(request.gender()),
+ request.phoneNumber(),
+ birthday
+ );
+
+ profileRepository.save(profile);
+ log.info("프로필 초기 설정 완료: userId={}, nickname={}", userId, request.nickname());
+ }
+
+ private LocalDate parseBirthDate(String birthDateString) {
+ if (birthDateString == null || birthDateString.trim().isEmpty()) {
+ return null;
+ }
+
+ try {
+ return LocalDate.parse(birthDateString.trim(), DATE_FORMATTER);
+ } catch (DateTimeParseException e) {
+ log.warn("생년월일 파싱 실패: {}", birthDateString, e);
+ throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "생년월일 형식이 올바르지 않습니다. (yyyy-MM-dd 형식)");
+ }
+ }
+
+ private void validateVerificationToken(String verificationToken, String phoneNumber) {
+ if (verificationToken == null || verificationToken.trim().isEmpty()) {
+ throw new BusinessException(ErrorCode.INVALID_TOKEN, "인증 토큰이 필요합니다.");
+ }
+
+ if (!jwtTokenProvider.validateVerificationToken(verificationToken, phoneNumber)) {
+ throw new BusinessException(ErrorCode.INVALID_TOKEN, "유효하지 않거나 만료된 인증 토큰입니다.");
+ }
+ }
+
+ private Profile getProfileByUserId(User user) {
+ return profileRepository.findByUser(user)
+ .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND));
+ }
+
+ private void checkDuplicateNickname(String nickname) {
+ if (profileRepository.existsByNickname(nickname)) {
+ throw new BusinessException(ErrorCode.NICKNAME_ALREADY_TAKEN);
+ }
+ }
+
+ private void checkDuplicateNicknameWithLock(String nickname) {
+ if (profileRepository.existsByNicknameWithLock(nickname)) {
+ throw new BusinessException(ErrorCode.NICKNAME_ALREADY_TAKEN);
+ }
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/user/utils/NicknameAdjectiveProvider.java b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameAdjectiveProvider.java
similarity index 97%
rename from src/main/java/com/dduru/gildongmu/user/utils/NicknameAdjectiveProvider.java
rename to src/main/java/com/dduru/gildongmu/profile/utils/NicknameAdjectiveProvider.java
index 151cf559..5862af7d 100644
--- a/src/main/java/com/dduru/gildongmu/user/utils/NicknameAdjectiveProvider.java
+++ b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameAdjectiveProvider.java
@@ -1,4 +1,4 @@
-package com.dduru.gildongmu.user.utils;
+package com.dduru.gildongmu.profile.utils;
import org.springframework.stereotype.Component;
diff --git a/src/main/java/com/dduru/gildongmu/user/utils/NicknameGenerator.java b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameGenerator.java
similarity index 93%
rename from src/main/java/com/dduru/gildongmu/user/utils/NicknameGenerator.java
rename to src/main/java/com/dduru/gildongmu/profile/utils/NicknameGenerator.java
index f2efdb6c..2b957388 100644
--- a/src/main/java/com/dduru/gildongmu/user/utils/NicknameGenerator.java
+++ b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameGenerator.java
@@ -1,4 +1,4 @@
-package com.dduru.gildongmu.user.utils;
+package com.dduru.gildongmu.profile.utils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -36,6 +36,6 @@ public int generateRandomNumber() {
public String generateBaseNickname() {
String adjective = nicknameAdjectiveProvider.getRandomAdjective();
String noun = nicknameNounProvider.getRandomNoun();
- return adjective + " " + noun;
+ return adjective + noun;
}
}
diff --git a/src/main/java/com/dduru/gildongmu/user/utils/NicknameNounProvider.java b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameNounProvider.java
similarity index 96%
rename from src/main/java/com/dduru/gildongmu/user/utils/NicknameNounProvider.java
rename to src/main/java/com/dduru/gildongmu/profile/utils/NicknameNounProvider.java
index 6b4ae10d..0146b815 100644
--- a/src/main/java/com/dduru/gildongmu/user/utils/NicknameNounProvider.java
+++ b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameNounProvider.java
@@ -1,4 +1,4 @@
-package com.dduru.gildongmu.user.utils;
+package com.dduru.gildongmu.profile.utils;
import org.springframework.stereotype.Component;
diff --git a/src/main/java/com/dduru/gildongmu/user/validator/NicknameConstraintValidator.java b/src/main/java/com/dduru/gildongmu/profile/validator/NicknameConstraintValidator.java
similarity index 96%
rename from src/main/java/com/dduru/gildongmu/user/validator/NicknameConstraintValidator.java
rename to src/main/java/com/dduru/gildongmu/profile/validator/NicknameConstraintValidator.java
index 2cfe88dc..63a1d14d 100644
--- a/src/main/java/com/dduru/gildongmu/user/validator/NicknameConstraintValidator.java
+++ b/src/main/java/com/dduru/gildongmu/profile/validator/NicknameConstraintValidator.java
@@ -1,4 +1,4 @@
-package com.dduru.gildongmu.user.validator;
+package com.dduru.gildongmu.profile.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
diff --git a/src/main/java/com/dduru/gildongmu/user/validator/ValidNickname.java b/src/main/java/com/dduru/gildongmu/profile/validator/ValidNickname.java
similarity index 96%
rename from src/main/java/com/dduru/gildongmu/user/validator/ValidNickname.java
rename to src/main/java/com/dduru/gildongmu/profile/validator/ValidNickname.java
index 4931233c..b0e2ee50 100644
--- a/src/main/java/com/dduru/gildongmu/user/validator/ValidNickname.java
+++ b/src/main/java/com/dduru/gildongmu/profile/validator/ValidNickname.java
@@ -1,4 +1,4 @@
-package com.dduru.gildongmu.user.validator;
+package com.dduru.gildongmu.profile.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
diff --git a/src/main/java/com/dduru/gildongmu/survey/controller/SurveyApiDocs.java b/src/main/java/com/dduru/gildongmu/survey/controller/SurveyApiDocs.java
new file mode 100644
index 00000000..ac362fc5
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/controller/SurveyApiDocs.java
@@ -0,0 +1,123 @@
+package com.dduru.gildongmu.survey.controller;
+
+import com.dduru.gildongmu.common.dto.ApiResult;
+import com.dduru.gildongmu.common.exception.ErrorResponse;
+import com.dduru.gildongmu.survey.dto.SurveyRequest;
+import com.dduru.gildongmu.survey.dto.SurveyResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+
+@Tag(name = "Survey", description = "설문조사 API")
+@SecurityRequirement(name = "JWT")
+public interface SurveyApiDocs {
+
+ @Operation(summary = "설문조사 제출", description = "11개 질문의 선택지를 제출하고 성향 점수 및 아바타를 매칭합니다.")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "201",
+ description = "설문 제출 성공",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = SurveyResponse.class)
+ )
+ ),
+ @ApiResponse(
+ responseCode = "400",
+ description = "잘못된 요청 (유효하지 않은 답변 코드, 필수 질문 미응답, Q7 범위 초과 등)",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = ErrorResponse.class),
+ examples = {
+ @ExampleObject(
+ name = "InvalidAnswerCode",
+ value = """
+ {
+ "status": 400,
+ "data": {
+ "errorCode": "INVALID_INPUT_VALUE",
+ "field": null,
+ "message": "Q1 이동수단에 대한 유효하지 않은 답변 코드입니다: 99"
+ }
+ }
+ """
+ ),
+ @ExampleObject(
+ name = "ValidationError",
+ value = """
+ {
+ "status": 400,
+ "data": {
+ "errorCode": "INVALID_INPUT_VALUE",
+ "field": "q1",
+ "message": "Q1 이동수단은 필수입니다."
+ }
+ }
+ """
+ ),
+ @ExampleObject(
+ name = "InvalidQ7Size",
+ value = """
+ {
+ "status": 400,
+ "data": {
+ "errorCode": "INVALID_INPUT_VALUE",
+ "field": "q7",
+ "message": "Q7 선호활동은 최대 3개까지 선택할 수 있습니다."
+ }
+ }
+ """
+ )
+ }
+ )
+ )
+ })
+ ResponseEntity> submitSurvey(
+ @Parameter(hidden = true) Long userId,
+ @Valid SurveyRequest request
+ );
+
+ @Operation(summary = "내 설문 결과 조회", description = "현재 사용자의 설문 결과 및 매칭된 아바타를 조회합니다.")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "조회 성공",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = SurveyResponse.class)
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "설문 결과를 찾을 수 없음",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = ErrorResponse.class),
+ examples = @ExampleObject(
+ name = "SurveyResultNotFound",
+ value = """
+ {
+ "status": 404,
+ "data": {
+ "errorCode": "SURVEY_RESULT_NOT_FOUND",
+ "field": null,
+ "message": "설문 결과를 찾을 수 없습니다."
+ }
+ }
+ """
+ )
+ )
+ )
+ })
+ ResponseEntity> getMySurveyResult(
+ @Parameter(hidden = true) Long userId
+ );
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/controller/SurveyController.java b/src/main/java/com/dduru/gildongmu/survey/controller/SurveyController.java
new file mode 100644
index 00000000..d6e93c54
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/controller/SurveyController.java
@@ -0,0 +1,39 @@
+package com.dduru.gildongmu.survey.controller;
+
+import com.dduru.gildongmu.common.annotation.CurrentUser;
+import com.dduru.gildongmu.common.dto.ApiResult;
+import com.dduru.gildongmu.survey.dto.SurveyRequest;
+import com.dduru.gildongmu.survey.dto.SurveyResponse;
+import com.dduru.gildongmu.survey.service.SurveyService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/surveys")
+public class SurveyController implements SurveyApiDocs {
+
+ private final SurveyService surveyService;
+
+ @Override
+ @PostMapping
+ public ResponseEntity> submitSurvey(
+ @CurrentUser Long userId,
+ @RequestBody @Valid SurveyRequest request
+ ) {
+ SurveyResponse response = surveyService.submitSurvey(userId, request);
+ return ResponseEntity.status(HttpStatus.CREATED).body(ApiResult.ok(response));
+ }
+
+ @Override
+ @GetMapping("/me")
+ public ResponseEntity> getMySurveyResult(
+ @CurrentUser Long userId
+ ) {
+ SurveyResponse response = surveyService.getMySurveyResult(userId);
+ return ResponseEntity.status(HttpStatus.OK).body(ApiResult.ok(response));
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyApiDocs.java b/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyApiDocs.java
deleted file mode 100644
index 5d156adf..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyApiDocs.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.dduru.gildongmu.survey.controller;
-
-import com.dduru.gildongmu.common.dto.ApiResult;
-import com.dduru.gildongmu.survey.dto.TravelSurveyRequest;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.responses.ApiResponses;
-import io.swagger.v3.oas.annotations.security.SecurityRequirement;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import jakarta.validation.Valid;
-import org.springframework.http.ResponseEntity;
-
-@Tag(name = "TravelSurveyApi", description = "여행 취향 테스트 API")
-@SecurityRequirement(name = "JWT")
-public interface TravelSurveyApiDocs {
-
- @Operation(summary = "여행 취향 설문 저장", description = "사용자의 여행 취향 설문을 저장합니다.")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "201", description = "테스트 저장 성공"),
- })
- ResponseEntity> submitTravelSurvey(
- @Parameter(hidden = true) Long userId,
- @Valid TravelSurveyRequest request
- );
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyController.java b/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyController.java
deleted file mode 100644
index 42457776..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyController.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.dduru.gildongmu.survey.controller;
-
-import com.dduru.gildongmu.common.annotation.CurrentUser;
-import com.dduru.gildongmu.common.dto.ApiResult;
-import com.dduru.gildongmu.survey.dto.TravelSurveyRequest;
-import com.dduru.gildongmu.survey.service.TravelSurveyService;
-import jakarta.validation.Valid;
-import lombok.RequiredArgsConstructor;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
-@RestController
-@RequiredArgsConstructor
-@RequestMapping("/api/v1/travel-preferences")
-public class TravelSurveyController implements TravelSurveyApiDocs {
-
- private final TravelSurveyService travelSurveyService;
-
- @Override
- @PostMapping
- public ResponseEntity> submitTravelSurvey(
- @CurrentUser Long userId,
- @RequestBody @Valid TravelSurveyRequest request) {
- travelSurveyService.create(userId, request);
- return ResponseEntity.status(HttpStatus.CREATED).body(ApiResult.created(null));
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/converter/SurveyConverter.java b/src/main/java/com/dduru/gildongmu/survey/converter/SurveyConverter.java
new file mode 100644
index 00000000..03ab879e
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/converter/SurveyConverter.java
@@ -0,0 +1,67 @@
+package com.dduru.gildongmu.survey.converter;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import com.dduru.gildongmu.common.enums.EnumUtils;
+import com.dduru.gildongmu.survey.domain.Survey;
+import com.dduru.gildongmu.survey.domain.enums.*;
+import com.dduru.gildongmu.survey.dto.SurveyRequest;
+import com.dduru.gildongmu.survey.exception.InvalidSurveyAnswerCodeException;
+import com.dduru.gildongmu.user.domain.User;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Component
+public class SurveyConverter {
+
+ public Survey toEntity(User user, SurveyRequest request) {
+ ParsedSurveyData parsed = parseRequest(request);
+ return Survey.createSurvey(user, parsed.q1(), parsed.q2(), parsed.q3(), parsed.q4(), parsed.q5(),
+ parsed.q6(), parsed.q7(), parsed.q8(), parsed.q9(), parsed.q10(), parsed.q11());
+ }
+
+ public ParsedSurveyData parseRequest(SurveyRequest request) {
+ Question1Transport q1 = toEnum(Question1Transport.class, request.q1(), "Q1 이동수단");
+ Question2Waiting q2 = toEnum(Question2Waiting.class, request.q2(), "Q2 웨이팅");
+ Question3Stay q3 = toEnum(Question3Stay.class, request.q3(), "Q3 숙소");
+ Question4Wakeup q4 = toEnum(Question4Wakeup.class, request.q4(), "Q4 기상시간");
+ Question5Expense q5 = toEnum(Question5Expense.class, request.q5(), "Q5 경비관리");
+ Question6Spend q6 = toEnum(Question6Spend.class, request.q6(), "Q6 소비태도");
+ List q7 = toEnumList(Question7Interest.class, request.q7(), "Q7 선호활동");
+ Question8Planning q8 = toEnum(Question8Planning.class, request.q8(), "Q8 계획성");
+ Question9Menu q9 = toEnum(Question9Menu.class, request.q9(), "Q9 낯선메뉴");
+ Question10Companion q10 = toEnum(Question10Companion.class, request.q10(), "Q10 동행제안");
+ Question11Photo q11 = toEnum(Question11Photo.class, request.q11(), "Q11 사진");
+
+ return new ParsedSurveyData(q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11);
+ }
+
+ private & CodedEnum> E toEnum(
+ Class enumClass, Integer code, String questionName) {
+ return EnumUtils.fromCode(enumClass, code)
+ .orElseThrow(() -> InvalidSurveyAnswerCodeException.of(questionName, code));
+ }
+
+ private & CodedEnum> List toEnumList(
+ Class enumClass, List codes, String questionName) {
+ return codes.stream()
+ .map(code -> toEnum(enumClass, code, questionName))
+ .collect(Collectors.toList());
+ }
+
+ public record ParsedSurveyData(
+ Question1Transport q1,
+ Question2Waiting q2,
+ Question3Stay q3,
+ Question4Wakeup q4,
+ Question5Expense q5,
+ Question6Spend q6,
+ List q7,
+ Question8Planning q8,
+ Question9Menu q9,
+ Question10Companion q10,
+ Question11Photo q11
+ ) {
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyConverter.java b/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyConverter.java
deleted file mode 100644
index 8613a182..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyConverter.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.dduru.gildongmu.survey.converter;
-
-import com.dduru.gildongmu.survey.domain.TravelSurvey;
-import com.dduru.gildongmu.survey.domain.enums.CaptureStyle;
-import com.dduru.gildongmu.survey.domain.enums.ExpenseStyle;
-import com.dduru.gildongmu.survey.domain.enums.MoveStyle;
-import com.dduru.gildongmu.survey.domain.enums.PaceStyle;
-import com.dduru.gildongmu.survey.domain.enums.PlanStyle;
-import com.dduru.gildongmu.survey.domain.enums.SpendStyle;
-import com.dduru.gildongmu.survey.domain.enums.StayStyle;
-import com.dduru.gildongmu.survey.domain.enums.TastingStyle;
-import com.dduru.gildongmu.survey.dto.TravelSurveyRequest;
-import org.springframework.stereotype.Component;
-
-@Component
-public class TravelSurveyConverter {
-
- public TravelSurvey toEntity(Long userId, TravelSurveyRequest request) {
- return TravelSurvey.builder()
- .userId(userId)
- .planStyle(PlanStyle.valueOf(request.planStyle()))
- .tastingStyle(TastingStyle.valueOf(request.tastingStyle()))
- .stayStyle(StayStyle.valueOf(request.stayStyle()))
- .expenseStyle(ExpenseStyle.valueOf(request.expenseStyle()))
- .moveStyle(MoveStyle.valueOf(request.moveStyle()))
- .spendStyle(SpendStyle.valueOf(request.spendStyle()))
- .captureStyle(CaptureStyle.valueOf(request.captureStyle()))
- .paceStyle(PaceStyle.valueOf(request.paceStyle()))
- .build();
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyInterestConverter.java b/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyInterestConverter.java
deleted file mode 100644
index 80eb3ebd..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyInterestConverter.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.dduru.gildongmu.survey.converter;
-
-import com.dduru.gildongmu.survey.domain.TravelSurvey;
-import com.dduru.gildongmu.survey.domain.TravelSurveyInterest;
-import com.dduru.gildongmu.survey.domain.enums.Interest;
-import org.springframework.stereotype.Component;
-
-import java.util.List;
-
-@Component
-public class TravelSurveyInterestConverter {
-
- public List toEntity(TravelSurvey travelSurvey, List interests) {
- return interests.stream()
- .map(interest -> TravelSurveyInterest.create(
- travelSurvey,
- Interest.valueOf(interest)
- ))
- .toList();
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/Survey.java b/src/main/java/com/dduru/gildongmu/survey/domain/Survey.java
new file mode 100644
index 00000000..6cc14f37
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/Survey.java
@@ -0,0 +1,139 @@
+package com.dduru.gildongmu.survey.domain;
+
+import com.dduru.gildongmu.common.entity.BaseTimeEntity;
+import com.dduru.gildongmu.user.domain.User;
+import com.dduru.gildongmu.survey.domain.enums.*;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "surveys")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Survey extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false, unique = true)
+ private User user;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q1_transport", nullable = false)
+ private Question1Transport q1Transport;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q2_waiting", nullable = false)
+ private Question2Waiting q2Waiting;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q3_stay", nullable = false)
+ private Question3Stay q3Stay;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q4_wakeup", nullable = false)
+ private Question4Wakeup q4Wakeup;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q5_expense", nullable = false)
+ private Question5Expense q5Expense;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q6_spend", nullable = false)
+ private Question6Spend q6Spend;
+
+ @ElementCollection(fetch = FetchType.LAZY)
+ @CollectionTable(name = "survey_interests", joinColumns = @JoinColumn(name = "survey_id"))
+ @Enumerated(EnumType.STRING)
+ @Column(name = "interest", nullable = false)
+ private List q7Interests;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q8_planning", nullable = false)
+ private Question8Planning q8Planning;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q9_menu", nullable = false)
+ private Question9Menu q9Menu;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q10_companion", nullable = false)
+ private Question10Companion q10Companion;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "q11_photo", nullable = false)
+ private Question11Photo q11Photo;
+
+ @Builder
+ public Survey(User user, Question1Transport q1Transport, Question2Waiting q2Waiting,
+ Question3Stay q3Stay, Question4Wakeup q4Wakeup, Question5Expense q5Expense,
+ Question6Spend q6Spend, List q7Interests,
+ Question8Planning q8Planning, Question9Menu q9Menu,
+ Question10Companion q10Companion, Question11Photo q11Photo) {
+ this.user = user;
+ this.q1Transport = q1Transport;
+ this.q2Waiting = q2Waiting;
+ this.q3Stay = q3Stay;
+ this.q4Wakeup = q4Wakeup;
+ this.q5Expense = q5Expense;
+ this.q6Spend = q6Spend;
+ this.q7Interests = q7Interests != null ? new ArrayList<>(q7Interests) : null;
+ this.q8Planning = q8Planning;
+ this.q9Menu = q9Menu;
+ this.q10Companion = q10Companion;
+ this.q11Photo = q11Photo;
+ }
+
+ public static Survey createSurvey(User user, Question1Transport q1Transport, Question2Waiting q2Waiting,
+ Question3Stay q3Stay, Question4Wakeup q4Wakeup, Question5Expense q5Expense,
+ Question6Spend q6Spend, List q7Interests,
+ Question8Planning q8Planning, Question9Menu q9Menu,
+ Question10Companion q10Companion, Question11Photo q11Photo) {
+ return Survey.builder()
+ .user(user)
+ .q1Transport(q1Transport)
+ .q2Waiting(q2Waiting)
+ .q3Stay(q3Stay)
+ .q4Wakeup(q4Wakeup)
+ .q5Expense(q5Expense)
+ .q6Spend(q6Spend)
+ .q7Interests(q7Interests)
+ .q8Planning(q8Planning)
+ .q9Menu(q9Menu)
+ .q10Companion(q10Companion)
+ .q11Photo(q11Photo)
+ .build();
+ }
+
+ public void updateSurvey(Question1Transport q1Transport, Question2Waiting q2Waiting, Question3Stay q3Stay,
+ Question4Wakeup q4Wakeup, Question5Expense q5Expense, Question6Spend q6Spend,
+ List q7Interests, Question8Planning q8Planning,
+ Question9Menu q9Menu, Question10Companion q10Companion, Question11Photo q11Photo) {
+ this.q1Transport = q1Transport;
+ this.q2Waiting = q2Waiting;
+ this.q3Stay = q3Stay;
+ this.q4Wakeup = q4Wakeup;
+ this.q5Expense = q5Expense;
+ this.q6Spend = q6Spend;
+ if (this.q7Interests == null) {
+ this.q7Interests = q7Interests != null ? new ArrayList<>(q7Interests) : new ArrayList<>();
+ } else {
+ this.q7Interests.clear();
+ if (q7Interests != null) {
+ this.q7Interests.addAll(q7Interests);
+ }
+ }
+ this.q8Planning = q8Planning;
+ this.q9Menu = q9Menu;
+ this.q10Companion = q10Companion;
+ this.q11Photo = q11Photo;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurvey.java b/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurvey.java
deleted file mode 100644
index 8a70fd88..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurvey.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.dduru.gildongmu.survey.domain;
-
-import com.dduru.gildongmu.common.entity.BaseTimeEntity;
-import com.dduru.gildongmu.survey.domain.enums.CaptureStyle;
-import com.dduru.gildongmu.survey.domain.enums.ExpenseStyle;
-import com.dduru.gildongmu.survey.domain.enums.MoveStyle;
-import com.dduru.gildongmu.survey.domain.enums.PaceStyle;
-import com.dduru.gildongmu.survey.domain.enums.PlanStyle;
-import com.dduru.gildongmu.survey.domain.enums.SpendStyle;
-import com.dduru.gildongmu.survey.domain.enums.StayStyle;
-import com.dduru.gildongmu.survey.domain.enums.TastingStyle;
-import jakarta.persistence.Entity;
-import jakarta.persistence.EnumType;
-import jakarta.persistence.Enumerated;
-import jakarta.persistence.GeneratedValue;
-import jakarta.persistence.GenerationType;
-import jakarta.persistence.Id;
-import jakarta.persistence.Table;
-import lombok.AccessLevel;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-
-@Entity
-@Table(name = "travel_surveys")
-@Getter
-@Builder
-@AllArgsConstructor
-@NoArgsConstructor(access = AccessLevel.PROTECTED)
-public class TravelSurvey extends BaseTimeEntity {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- private Long userId;
-
- @Enumerated(EnumType.STRING)
- private PlanStyle planStyle;
-
- @Enumerated(EnumType.STRING)
- private TastingStyle tastingStyle;
-
- @Enumerated(EnumType.STRING)
- private StayStyle stayStyle;
-
- @Enumerated(EnumType.STRING)
- private ExpenseStyle expenseStyle;
-
- @Enumerated(EnumType.STRING)
- private MoveStyle moveStyle;
-
- @Enumerated(EnumType.STRING)
- private SpendStyle spendStyle;
-
- @Enumerated(EnumType.STRING)
- private CaptureStyle captureStyle;
-
- @Enumerated(EnumType.STRING)
- private PaceStyle paceStyle;
-
- public void updateStyles(
- PlanStyle planStyle, TastingStyle tastingStyle,
- StayStyle stayStyle, ExpenseStyle expenseStyle, MoveStyle moveStyle,
- SpendStyle spendStyle, CaptureStyle captureStyle, PaceStyle paceStyle
- ) {
- this.planStyle = planStyle;
- this.tastingStyle = tastingStyle;
- this.stayStyle = stayStyle;
- this.expenseStyle = expenseStyle;
- this.moveStyle = moveStyle;
- this.spendStyle = spendStyle;
- this.captureStyle = captureStyle;
- this.paceStyle = paceStyle;
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurveyInterest.java b/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurveyInterest.java
deleted file mode 100644
index 0e1b3f51..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurveyInterest.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.dduru.gildongmu.survey.domain;
-
-import com.dduru.gildongmu.common.entity.BaseTimeEntity;
-import com.dduru.gildongmu.survey.domain.enums.Interest;
-import jakarta.persistence.*;
-import lombok.AccessLevel;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-
-@Entity
-@Table(name = "travel_survey_interests")
-@Getter
-@Builder
-@AllArgsConstructor
-@NoArgsConstructor(access = AccessLevel.PROTECTED)
-public class TravelSurveyInterest extends BaseTimeEntity {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "travel_survey_id", nullable = false)
- private TravelSurvey travelSurvey;
-
- @Enumerated(EnumType.STRING)
- private Interest interest;
-
- public static TravelSurveyInterest create(TravelSurvey travelSurvey, Interest interest) {
- return TravelSurveyInterest.builder()
- .travelSurvey(travelSurvey)
- .interest(interest)
- .build();
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/TravelTendency.java b/src/main/java/com/dduru/gildongmu/survey/domain/TravelTendency.java
new file mode 100644
index 00000000..b058d2c0
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/TravelTendency.java
@@ -0,0 +1,72 @@
+package com.dduru.gildongmu.survey.domain;
+
+import com.dduru.gildongmu.common.entity.BaseTimeEntity;
+import com.dduru.gildongmu.user.domain.User;
+import com.dduru.gildongmu.survey.domain.enums.AvatarType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+@Entity
+@Table(name = "travel_tendencies")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class TravelTendency extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false, unique = true)
+ private User user;
+
+ @Column(nullable = false, precision = 3, scale = 1)
+ private BigDecimal r;
+
+ @Column(nullable = false, precision = 3, scale = 1)
+ private BigDecimal w;
+
+ @Column(nullable = false, precision = 3, scale = 1)
+ private BigDecimal s;
+
+ @Column(nullable = false, precision = 3, scale = 1)
+ private BigDecimal p;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "avatar_type", nullable = false)
+ private AvatarType avatarType;
+
+ @Builder
+ public TravelTendency(User user, BigDecimal r, BigDecimal w, BigDecimal s, BigDecimal p, AvatarType avatarType) {
+ this.user = user;
+ this.r = r;
+ this.w = w;
+ this.s = s;
+ this.p = p;
+ this.avatarType = avatarType;
+ }
+
+ public static TravelTendency create(User user, BigDecimal r, BigDecimal w, BigDecimal s, BigDecimal p, AvatarType avatarType) {
+ return TravelTendency.builder()
+ .user(user)
+ .r(r)
+ .w(w)
+ .s(s)
+ .p(p)
+ .avatarType(avatarType)
+ .build();
+ }
+
+ public void update(BigDecimal r, BigDecimal w, BigDecimal s, BigDecimal p, AvatarType avatarType) {
+ this.r = r;
+ this.w = w;
+ this.s = s;
+ this.p = p;
+ this.avatarType = avatarType;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/AvatarType.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/AvatarType.java
new file mode 100644
index 00000000..9c90fe24
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/AvatarType.java
@@ -0,0 +1,42 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum AvatarType implements CodedEnum {
+ TTUR_POGUNI(1, "뚜르 포근이"),
+ TTUR_MOOD(2, "뚜르 무드"),
+ TTUR_MALLANGI(3, "뚜르 말랑이"),
+ TTUR_SWEET(4, "뚜르 스윗"),
+ TTUR_POPO(5, "뚜르 포포"),
+ TTUR_SPARKLE(6, "뚜르 스파클"),
+ TTUR_GLIMMING(7, "뚜르 글리밍"),
+ TTUR_PADO(8, "뚜르 파도");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+
+ public String getDescription() {
+ return switch (this) {
+ case TTUR_POGUNI -> "안정형 가성비 조용러 (내 페이스대로 차분히)";
+ case TTUR_MOOD -> "안정형 플랙스 감성러 (분위기와 여유로운 힐링)";
+ case TTUR_MALLANGI -> "안정형 가성비 배려러 (함께 가며 챙겨주는 다정함)";
+ case TTUR_SWEET -> "안정형 플랙스 미식 힐링러 (다같이 맛있는 미식 힐링)";
+ case TTUR_POPO -> "모험형 가성비 실속러 (알뜰하고 영리한 실속 탐험)";
+ case TTUR_SPARKLE -> "모험형 플랙스 경험러 (즉흥적이고 화려한 경험)";
+ case TTUR_GLIMMING -> "모험형 가성비 미식 모험러 (북적이는 로컬 맛집 탐방)";
+ case TTUR_PADO -> "모험형 플랙스 인싸러 (에너지 넘치는 즉흥 끝판왕)";
+ };
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/CaptureStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/CaptureStyle.java
deleted file mode 100644
index a88d1019..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/CaptureStyle.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.dduru.gildongmu.survey.domain.enums;
-
-import com.dduru.gildongmu.common.enums.CodedEnum;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@Getter
-@AllArgsConstructor
-public enum CaptureStyle implements CodedEnum {
- PHOTO(1, "사진으로 기록"),
- EYES(2, "눈으로 담기");
-
- private final int code;
- private final String text;
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/ExpenseStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/ExpenseStyle.java
deleted file mode 100644
index 09c1b26e..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/ExpenseStyle.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.dduru.gildongmu.survey.domain.enums;
-
-import com.dduru.gildongmu.common.enums.CodedEnum;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@Getter
-@AllArgsConstructor
-public enum ExpenseStyle implements CodedEnum {
- EACH_PAYS(1, "각자 계산"),
- POOLED(2, "통장 관리");
-
- private final int code;
- private final String text;
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/MoveStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/MoveStyle.java
deleted file mode 100644
index 0cb4b8d3..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/MoveStyle.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.dduru.gildongmu.survey.domain.enums;
-
-import com.dduru.gildongmu.common.enums.CodedEnum;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@Getter
-@AllArgsConstructor
-public enum MoveStyle implements CodedEnum {
- WALK_BUS(1, "걷기/대중교통"),
- TAXI(2, "택시/렌터카");
-
- private final int code;
- private final String text;
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/PaceStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/PaceStyle.java
deleted file mode 100644
index 18bbbf5a..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/PaceStyle.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.dduru.gildongmu.survey.domain.enums;
-
-import com.dduru.gildongmu.common.enums.CodedEnum;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@Getter
-@AllArgsConstructor
-public enum PaceStyle implements CodedEnum {
- EARLY_FULL(1, "아침 일찍 꽉 채우기"),
- RELAXED(2, "여유롭게");
-
- private final int code;
- private final String text;
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/PlanStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/PlanStyle.java
deleted file mode 100644
index 499187d8..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/PlanStyle.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.dduru.gildongmu.survey.domain.enums;
-
-import com.dduru.gildongmu.common.enums.CodedEnum;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@Getter
-@AllArgsConstructor
-public enum PlanStyle implements CodedEnum {
- PLANNER(1, "경로/맛집/시간까지"),
- FREE(2, "무계획의 즐거움");
-
- private final int code;
- private final String text;
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question10Companion.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question10Companion.java
new file mode 100644
index 00000000..5d178ed2
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question10Companion.java
@@ -0,0 +1,24 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question10Companion implements CodedEnum {
+ WELCOME(1, "완전 환영"),
+ SITUATIONAL(2, "상황 봐서"),
+ US_ONLY(3, "우리끼리만");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question11Photo.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question11Photo.java
new file mode 100644
index 00000000..5c9e2fd2
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question11Photo.java
@@ -0,0 +1,24 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question11Photo implements CodedEnum {
+ LIFETIME_SHOT(1, "인생샷"),
+ MATCH_COMPANION(2, "동행자에 맞춤"),
+ EYES_ONLY(3, "눈으로 담기");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question1Transport.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question1Transport.java
new file mode 100644
index 00000000..47b638c4
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question1Transport.java
@@ -0,0 +1,23 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question1Transport implements CodedEnum {
+ WALK_BUS(1, "걷기/버스"),
+ TAXI(2, "택시");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question2Waiting.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question2Waiting.java
new file mode 100644
index 00000000..1eddbfa7
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question2Waiting.java
@@ -0,0 +1,23 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question2Waiting implements CodedEnum {
+ WAIT(1, "기다림"),
+ MOVE_ELSEWHERE(2, "다른 곳 이동");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question3Stay.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question3Stay.java
new file mode 100644
index 00000000..f4e202c7
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question3Stay.java
@@ -0,0 +1,23 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question3Stay implements CodedEnum {
+ HOTEL(1, "호텔/갖춰진 곳"),
+ JUST_SLEEP(2, "잠만 자면 OK");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question4Wakeup.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question4Wakeup.java
new file mode 100644
index 00000000..c60c01a3
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question4Wakeup.java
@@ -0,0 +1,23 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question4Wakeup implements CodedEnum {
+ EARLY(1, "일찍 기상"),
+ RELAXED(2, "느긋하게 기상");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question5Expense.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question5Expense.java
new file mode 100644
index 00000000..44341501
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question5Expense.java
@@ -0,0 +1,23 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question5Expense implements CodedEnum {
+ EACH_PAYS(1, "각자 결제"),
+ POOLED(2, "모아 쓰기");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question6Spend.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question6Spend.java
new file mode 100644
index 00000000..366d4e58
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question6Spend.java
@@ -0,0 +1,23 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question6Spend implements CodedEnum {
+ SPLURGE(1, "쓴다 (플랙스)"),
+ SAVE(2, "아낀다 (가성비)");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Interest.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question7Interest.java
similarity index 67%
rename from src/main/java/com/dduru/gildongmu/survey/domain/enums/Interest.java
rename to src/main/java/com/dduru/gildongmu/survey/domain/enums/Question7Interest.java
index 744bf86a..2208d994 100644
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Interest.java
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question7Interest.java
@@ -2,15 +2,13 @@
import com.dduru.gildongmu.common.enums.CodedEnum;
import lombok.AllArgsConstructor;
-import lombok.Getter;
-@Getter
@AllArgsConstructor
-public enum Interest implements CodedEnum {
+public enum Question7Interest implements CodedEnum {
SIGHTSEEING(1, "관광"),
EXHIBITION(2, "관람"),
NATURE(3, "자연 탐방"),
- FOOD(4, "먹방"),
+ FOOD(4, "맛집 탐방"),
SHOPPING(5, "쇼핑"),
RESORT(6, "휴양"),
ACTIVITY(7, "액티비티"),
@@ -19,4 +17,14 @@ public enum Interest implements CodedEnum {
private final int code;
private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question8Planning.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question8Planning.java
new file mode 100644
index 00000000..6a569065
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question8Planning.java
@@ -0,0 +1,24 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question8Planning implements CodedEnum {
+ DETAILED(1, "디테일한 계획"),
+ FLEXIBLE(2, "융통성 있는 계획"),
+ ON_SITE(3, "현장 결정");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question9Menu.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question9Menu.java
new file mode 100644
index 00000000..17f61c1b
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question9Menu.java
@@ -0,0 +1,24 @@
+package com.dduru.gildongmu.survey.domain.enums;
+
+import com.dduru.gildongmu.common.enums.CodedEnum;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Question9Menu implements CodedEnum {
+ SAFE(1, "안전한 메뉴"),
+ CHECK_REVIEW(2, "후기 확인 후 결정"),
+ CHALLENGE(3, "바로 도전");
+
+ private final int code;
+ private final String text;
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/SpendStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/SpendStyle.java
deleted file mode 100644
index a78933ec..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/SpendStyle.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.dduru.gildongmu.survey.domain.enums;
-
-import com.dduru.gildongmu.common.enums.CodedEnum;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@Getter
-@AllArgsConstructor
-public enum SpendStyle implements CodedEnum {
- SPLURGE(1, "과감한 지출"),
- SAVER(2, "알뜰한 소비");
-
- private final int code;
- private final String text;
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/StayStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/StayStyle.java
deleted file mode 100644
index abebeb79..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/StayStyle.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.dduru.gildongmu.survey.domain.enums;
-
-import com.dduru.gildongmu.common.enums.CodedEnum;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@Getter
-@AllArgsConstructor
-public enum StayStyle implements CodedEnum {
- HOTEL(1, "호텔"),
- JUST_SLEEP(2, "잠만 자기");
-
- private final int code;
- private final String text;
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/TastingStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/TastingStyle.java
deleted file mode 100644
index 781bce41..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/TastingStyle.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.dduru.gildongmu.survey.domain.enums;
-
-import com.dduru.gildongmu.common.enums.CodedEnum;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@Getter
-@AllArgsConstructor
-public enum TastingStyle implements CodedEnum {
- WAIT(1, "기다려서라도"),
- NEARBY(2, "주변에서 편하게");
-
- private final int code;
- private final String text;
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/dto/SurveyRequest.java b/src/main/java/com/dduru/gildongmu/survey/dto/SurveyRequest.java
new file mode 100644
index 00000000..a34c2b3f
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/dto/SurveyRequest.java
@@ -0,0 +1,43 @@
+package com.dduru.gildongmu.survey.dto;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+import java.util.List;
+
+public record SurveyRequest(
+ @NotNull(message = "Q1 이동수단은 필수입니다.")
+ Integer q1,
+
+ @NotNull(message = "Q2 웨이팅은 필수입니다.")
+ Integer q2,
+
+ @NotNull(message = "Q3 숙소는 필수입니다.")
+ Integer q3,
+
+ @NotNull(message = "Q4 기상시간은 필수입니다.")
+ Integer q4,
+
+ @NotNull(message = "Q5 경비관리는 필수입니다.")
+ Integer q5,
+
+ @NotNull(message = "Q6 소비태도는 필수입니다.")
+ Integer q6,
+
+ @NotNull(message = "Q7 선호활동은 필수입니다.")
+ @Size(min = 1, max = 3, message = "Q7 선호활동은 최대 3개까지 선택할 수 있습니다.")
+ List q7,
+
+ @NotNull(message = "Q8 계획성은 필수입니다.")
+ Integer q8,
+
+ @NotNull(message = "Q9 낯선메뉴는 필수입니다.")
+ Integer q9,
+
+ @NotNull(message = "Q10 동행제안은 필수입니다.")
+ Integer q10,
+
+ @NotNull(message = "Q11 사진은 필수입니다.")
+ Integer q11
+) {
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/dto/SurveyResponse.java b/src/main/java/com/dduru/gildongmu/survey/dto/SurveyResponse.java
new file mode 100644
index 00000000..0e7e807d
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/dto/SurveyResponse.java
@@ -0,0 +1,60 @@
+package com.dduru.gildongmu.survey.dto;
+
+import com.dduru.gildongmu.survey.domain.TravelTendency;
+import com.dduru.gildongmu.survey.domain.enums.AvatarType;
+import com.dduru.gildongmu.survey.service.AvatarProfileProvider;
+
+import java.util.List;
+
+public record SurveyResponse(
+ Double r,
+ Double w,
+ Double s,
+ Double p,
+ Integer avatarCode,
+ String avatarType,
+ String avatarName,
+ String avatarDescription,
+ String personality,
+ String strength,
+ String tip,
+ List tags
+) {
+ public static SurveyResponse from(TravelTendency travelTendency, AvatarProfileProvider avatarProfileProvider) {
+ AvatarType avatarType = travelTendency.getAvatarType();
+ AvatarProfileProvider.AvatarProfile profile = avatarProfileProvider.getProfile(avatarType);
+
+ return new SurveyResponse(
+ travelTendency.getR().doubleValue(),
+ travelTendency.getW().doubleValue(),
+ travelTendency.getS().doubleValue(),
+ travelTendency.getP().doubleValue(),
+ avatarType.getCode(),
+ avatarType.name(),
+ avatarType.getText(),
+ avatarType.getDescription(),
+ profile.personality(),
+ profile.strength(),
+ profile.tip(),
+ profile.tags()
+ );
+ }
+
+ public static SurveyResponse of(
+ double r, double w, double s, double p,
+ AvatarType avatarType,
+ AvatarProfileProvider.AvatarProfile profile
+ ) {
+ return new SurveyResponse(
+ r, w, s, p,
+ avatarType.getCode(),
+ avatarType.name(),
+ avatarType.getText(),
+ avatarType.getDescription(),
+ profile.personality(),
+ profile.strength(),
+ profile.tip(),
+ profile.tags()
+ );
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/dto/TravelSurveyRequest.java b/src/main/java/com/dduru/gildongmu/survey/dto/TravelSurveyRequest.java
deleted file mode 100644
index 353385c6..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/dto/TravelSurveyRequest.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.dduru.gildongmu.survey.dto;
-
-import com.dduru.gildongmu.survey.validation.ValidInterestList;
-import io.swagger.v3.oas.annotations.media.Schema;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.Pattern;
-
-import java.util.List;
-
-public record TravelSurveyRequest(
- @NotBlank
- @Pattern(regexp = "PLANNER|FREE", message = "유효하지 않은 계획 스타일입니다")
- String planStyle,
-
- @NotBlank
- @Pattern(regexp = "WAIT|NEARBY", message = "유효하지 않은 맛집 대기 스타일입니다")
- String tastingStyle,
-
- @NotBlank
- @Pattern(regexp = "HOTEL|JUST_SLEEP", message = "유효하지 않은 숙소 스타일입니다")
- String stayStyle,
-
- @NotBlank
- @Pattern(regexp = "EACH_PAYS|POOLED", message = "유효하지 않은 정산 스타일입니다")
- String expenseStyle,
-
- @NotBlank
- @Pattern(regexp = "WALK_BUS|TAXI", message = "유효하지 않은 이동 수단 스타일입니다")
- String moveStyle,
-
- @NotBlank
- @Pattern(regexp = "SPLURGE|SAVER", message = "유효하지 않은 소비 스타일입니다")
- String spendStyle,
-
- @NotBlank
- @Pattern(regexp = "PHOTO|EYES", message = "유효하지 않은 기록 스타일입니다")
- String captureStyle,
-
- @NotBlank
- @Pattern(regexp = "EARLY_FULL|RELAXED", message = "유효하지 않은 일정 템포 스타일입니다")
- String paceStyle,
-
- @Schema(description = "관심사 리스트", example = "[\"SIGHTSEEING\", \"EXHIBITION\", \"NATURE\"]")
- @ValidInterestList()
- List interests
-) {
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/exception/InvalidSurveyAnswerCodeException.java b/src/main/java/com/dduru/gildongmu/survey/exception/InvalidSurveyAnswerCodeException.java
new file mode 100644
index 00000000..62dee5f9
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/exception/InvalidSurveyAnswerCodeException.java
@@ -0,0 +1,18 @@
+package com.dduru.gildongmu.survey.exception;
+
+import com.dduru.gildongmu.common.exception.BusinessException;
+import com.dduru.gildongmu.common.exception.ErrorCode;
+
+public class InvalidSurveyAnswerCodeException extends BusinessException {
+ public InvalidSurveyAnswerCodeException() {
+ super(ErrorCode.INVALID_INPUT_VALUE, "유효하지 않은 설문 답변 코드입니다");
+ }
+
+ public InvalidSurveyAnswerCodeException(String message) {
+ super(ErrorCode.INVALID_INPUT_VALUE, message);
+ }
+
+ public static InvalidSurveyAnswerCodeException of(String question, Integer code) {
+ return new InvalidSurveyAnswerCodeException(question + "에 대한 유효하지 않은 답변 코드입니다: " + code);
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/exception/SurveyResultNotFoundException.java b/src/main/java/com/dduru/gildongmu/survey/exception/SurveyResultNotFoundException.java
new file mode 100644
index 00000000..c9dd645c
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/exception/SurveyResultNotFoundException.java
@@ -0,0 +1,18 @@
+package com.dduru.gildongmu.survey.exception;
+
+import com.dduru.gildongmu.common.exception.BusinessException;
+import com.dduru.gildongmu.common.exception.ErrorCode;
+
+public class SurveyResultNotFoundException extends BusinessException {
+ public SurveyResultNotFoundException() {
+ super(ErrorCode.SURVEY_RESULT_NOT_FOUND, "설문 결과를 찾을 수 없습니다");
+ }
+
+ public SurveyResultNotFoundException(String message) {
+ super(ErrorCode.SURVEY_RESULT_NOT_FOUND, message);
+ }
+
+ public static SurveyResultNotFoundException of() {
+ return new SurveyResultNotFoundException();
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/exception/UnknownSurveyAnswerException.java b/src/main/java/com/dduru/gildongmu/survey/exception/UnknownSurveyAnswerException.java
deleted file mode 100644
index 732aaa5d..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/exception/UnknownSurveyAnswerException.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.dduru.gildongmu.survey.exception;
-
-import com.dduru.gildongmu.common.exception.BusinessException;
-import com.dduru.gildongmu.common.exception.ErrorCode;
-
-public class UnknownSurveyAnswerException extends BusinessException {
- public UnknownSurveyAnswerException(Integer code) {
- super(ErrorCode.UNKNOWN_SURVEY_ANSWER_CODE, "알 수 없는 설문조사 답변 코드입니다: " + code);
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/repository/SurveyRepository.java b/src/main/java/com/dduru/gildongmu/survey/repository/SurveyRepository.java
new file mode 100644
index 00000000..21b4155b
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/repository/SurveyRepository.java
@@ -0,0 +1,13 @@
+package com.dduru.gildongmu.survey.repository;
+
+import com.dduru.gildongmu.survey.domain.Survey;
+import com.dduru.gildongmu.user.domain.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface SurveyRepository extends JpaRepository {
+ Optional findByUser(User user);
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyInterestRepository.java b/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyInterestRepository.java
deleted file mode 100644
index a9736718..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyInterestRepository.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.dduru.gildongmu.survey.repository;
-
-import com.dduru.gildongmu.survey.domain.TravelSurvey;
-import com.dduru.gildongmu.survey.domain.TravelSurveyInterest;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.Modifying;
-import org.springframework.data.jpa.repository.Query;
-
-public interface TravelSurveyInterestRepository extends JpaRepository {
- @Modifying(clearAutomatically = true)
- @Query("DELETE FROM TravelSurveyInterest i WHERE i.travelSurvey = :travelSurvey")
- void deleteAllByTravelSurvey(TravelSurvey travelSurvey);
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyRepository.java b/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyRepository.java
deleted file mode 100644
index 65fa8dfb..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyRepository.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.dduru.gildongmu.survey.repository;
-
-import com.dduru.gildongmu.survey.domain.TravelSurvey;
-import org.springframework.data.jpa.repository.JpaRepository;
-
-import java.util.Optional;
-
-public interface TravelSurveyRepository extends JpaRepository {
- Optional findByUserId(Long userId);
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/repository/TravelTendencyRepository.java b/src/main/java/com/dduru/gildongmu/survey/repository/TravelTendencyRepository.java
new file mode 100644
index 00000000..e5dea930
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/repository/TravelTendencyRepository.java
@@ -0,0 +1,13 @@
+package com.dduru.gildongmu.survey.repository;
+
+import com.dduru.gildongmu.survey.domain.TravelTendency;
+import com.dduru.gildongmu.user.domain.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface TravelTendencyRepository extends JpaRepository {
+ Optional findByUser(User user);
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/service/AvatarMatcher.java b/src/main/java/com/dduru/gildongmu/survey/service/AvatarMatcher.java
new file mode 100644
index 00000000..c1f38184
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/service/AvatarMatcher.java
@@ -0,0 +1,36 @@
+package com.dduru.gildongmu.survey.service;
+
+import com.dduru.gildongmu.survey.domain.enums.AvatarType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class AvatarMatcher {
+
+ private static final double THRESHOLD = 5.0;
+
+ public AvatarType match(double r, double w, double s) {
+ boolean isStable = r < THRESHOLD;
+ boolean isCostEffective = w < THRESHOLD;
+ boolean isSocial = s >= THRESHOLD;
+
+ if (isStable && !isSocial && isCostEffective) {
+ return AvatarType.TTUR_POGUNI;
+ } else if (isStable && !isSocial) {
+ return AvatarType.TTUR_MOOD;
+ } else if (isStable && isCostEffective) {
+ return AvatarType.TTUR_MALLANGI;
+ } else if (isStable) {
+ return AvatarType.TTUR_SWEET;
+ } else if (!isSocial && isCostEffective) {
+ return AvatarType.TTUR_POPO;
+ } else if (!isSocial) {
+ return AvatarType.TTUR_SPARKLE;
+ } else if (isCostEffective) {
+ return AvatarType.TTUR_GLIMMING;
+ } else {
+ return AvatarType.TTUR_PADO;
+ }
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/service/AvatarProfileProvider.java b/src/main/java/com/dduru/gildongmu/survey/service/AvatarProfileProvider.java
new file mode 100644
index 00000000..09d0d755
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/service/AvatarProfileProvider.java
@@ -0,0 +1,69 @@
+package com.dduru.gildongmu.survey.service;
+
+import com.dduru.gildongmu.survey.domain.enums.AvatarType;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+
+@Component
+public class AvatarProfileProvider {
+
+ private static final Map PROFILES = Map.of(
+ AvatarType.TTUR_POGUNI, new AvatarProfile(
+ "느긋하고 신중한 스타일. 계획은 최소한으로 세우되 자신의 리듬은 확실히 지킴.",
+ "조용한 명소 찾기, 가성비 좋은 맛집 발굴, 일정 중 여유 시간 확보.",
+ "일정에 자유시간을 조금만 넣어도 만족도가 올라가요.",
+ List.of("안정", "독립", "가성비")
+ ),
+ AvatarType.TTUR_MOOD, new AvatarProfile(
+ "카페, 전경, 감성 스팟을 선호하며 여행의 '분위기'를 가장 중요하게 생각함.",
+ "예쁜 코스 및 뷰 포인트 찾기, 사진 찍기 좋은 장소 안내.",
+ "기록에 집중하느라 현재를 놓치지 않도록 '5분 기록, 5분 즐기기' 규칙을 세워보세요.",
+ List.of("안정", "독립", "플랙스")
+ ),
+ AvatarType.TTUR_MALLANGI, new AvatarProfile(
+ "여행 중 동행자의 분위기를 살피고 잘 맞춰주는 따뜻한 스타일. 검소한 여행 선호.",
+ "인원 조율 및 감정 관리, 합리적인 식당 및 코스 추천.",
+ "너무 배려만 하지 말고 '하루 한 번은 내 선택!' 규칙을 실천해 보세요.",
+ List.of("안정", "사교", "가성비")
+ ),
+ AvatarType.TTUR_SWEET, new AvatarProfile(
+ "사람을 편안하게 해주며, 맛있는 것과 좋은 분위기만 있다면 행복한 미식 힐링러.",
+ "단체 일정 코디, 실패 없는 맛집 픽, 여행 중 쉼표 만들어주기.",
+ "활동적인 친구와 여행할 때는 '식사 후 자유시간'으로 완급을 조절하세요.",
+ List.of("안정", "사교", "플랙스")
+ ),
+ AvatarType.TTUR_POPO, new AvatarProfile(
+ "새로움을 추구하지만 위험은 피하는 실속파. 효율과 재미의 밸런스를 잘 잡음.",
+ "정보 탐색 및 길 찾기 능력 우수, 혼자서도 뛰어난 문제 해결 능력.",
+ "하루 한 번 정도는 예산을 신경 쓰지 않는 '플랙스 타임'을 가져보세요.",
+ List.of("모험", "독립", "가성비")
+ ),
+ AvatarType.TTUR_SPARKLE, new AvatarProfile(
+ "즉흥적으로 떠나는 것을 즐기며 비용보다 긍정적인 경험의 가치를 높게 평가함.",
+ "숨겨진 스팟 개척, 급격한 일정 변경에도 빠른 적응.",
+ "예산 관리가 어려울 수 있으니 알뜰한 친구와 일정을 미리 의논해 보세요.",
+ List.of("모험", "독립", "플랙스")
+ ),
+ AvatarType.TTUR_GLIMMING, new AvatarProfile(
+ "새로운 자극과 인생샷에 진심인 미식 모험가. 즉흥적인 계획도 긍정적으로 수용.",
+ "현지인 추천 코스 실행력, 인생샷 스팟 캐치, 북적이는 에너지 유지.",
+ "일정이 사람 중심으로 흐를 수 있으니 하루 30분은 온전한 개인 시간을 가져보세요.",
+ List.of("모험", "사교", "가성비")
+ ),
+ AvatarType.TTUR_PADO, new AvatarProfile(
+ "어디서든 사람들과 잘 어울리며 분위기를 살리는 흥부자. 지출에 쿨한 인싸 유형.",
+ "현지 액티비티 빠른 예약, SNS 기록 담당, 높은 여행 에너지 유지.",
+ "체력과 예산 소모가 클 수 있으니 하루 1~2개의 핵심 활동에 집중해 보세요.",
+ List.of("모험", "사교", "플랙스")
+ )
+ );
+
+ public AvatarProfile getProfile(AvatarType avatarType) {
+ return PROFILES.get(avatarType);
+ }
+
+ public record AvatarProfile(String personality, String strength, String tip, List tags) {
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/service/SurveyService.java b/src/main/java/com/dduru/gildongmu/survey/service/SurveyService.java
new file mode 100644
index 00000000..d89ad23a
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/service/SurveyService.java
@@ -0,0 +1,96 @@
+package com.dduru.gildongmu.survey.service;
+
+import com.dduru.gildongmu.survey.converter.SurveyConverter;
+import com.dduru.gildongmu.survey.domain.Survey;
+import com.dduru.gildongmu.survey.domain.TravelTendency;
+import com.dduru.gildongmu.survey.domain.enums.AvatarType;
+import com.dduru.gildongmu.survey.dto.SurveyRequest;
+import com.dduru.gildongmu.survey.dto.SurveyResponse;
+import com.dduru.gildongmu.survey.exception.SurveyResultNotFoundException;
+import com.dduru.gildongmu.survey.repository.SurveyRepository;
+import com.dduru.gildongmu.survey.repository.TravelTendencyRepository;
+import com.dduru.gildongmu.user.domain.User;
+import com.dduru.gildongmu.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+@Slf4j
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class SurveyService {
+
+ private final SurveyRepository surveyRepository;
+ private final TravelTendencyRepository travelTendencyRepository;
+ private final SurveyConverter surveyConverter;
+ private final TravelTendencyCalculator tendencyCalculator;
+ private final AvatarMatcher avatarMatcher;
+ private final AvatarProfileProvider avatarProfileProvider;
+ private final UserRepository userRepository;
+
+ public SurveyResponse submitSurvey(Long userId, SurveyRequest request) {
+ log.debug("설문조사 제출 시작 - userId: {}", userId);
+
+ User user = userRepository.getByIdOrThrow(userId);
+ Survey survey = saveOrUpdateSurvey(user, request);
+
+ TravelTendencyCalculator.TendencyScores scores = tendencyCalculator.calculate(survey);
+ AvatarType avatarType = avatarMatcher.match(scores.r(), scores.w(), scores.s());
+
+ saveOrUpdateTravelTendency(user, scores, avatarType);
+
+ AvatarProfileProvider.AvatarProfile profile = avatarProfileProvider.getProfile(avatarType);
+
+ log.info("설문조사 제출 완료 - userId: {}, avatarType: {}, 점수: R={}, W={}, S={}, P={}",
+ userId, avatarType, scores.r(), scores.w(), scores.s(), scores.p());
+ return SurveyResponse.of(scores.r(), scores.w(), scores.s(), scores.p(), avatarType, profile);
+ }
+
+ @Transactional(readOnly = true)
+ public SurveyResponse getMySurveyResult(Long userId) {
+ log.debug("설문 결과 조회 시작 - userId: {}", userId);
+
+ User user = userRepository.getByIdOrThrow(userId);
+ TravelTendency travelTendency = travelTendencyRepository.findByUser(user)
+ .orElseThrow(SurveyResultNotFoundException::of);
+
+ log.info("설문 결과 조회 완료 - userId: {}, avatarType: {}", userId, travelTendency.getAvatarType());
+ return SurveyResponse.from(travelTendency, avatarProfileProvider);
+ }
+
+ private Survey saveOrUpdateSurvey(User user, SurveyRequest request) {
+ SurveyConverter.ParsedSurveyData parsed = surveyConverter.parseRequest(request);
+
+ return surveyRepository.findByUser(user)
+ .map(existing -> {
+ existing.updateSurvey(parsed.q1(), parsed.q2(), parsed.q3(), parsed.q4(), parsed.q5(),
+ parsed.q6(), parsed.q7(), parsed.q8(), parsed.q9(), parsed.q10(), parsed.q11());
+ return existing;
+ })
+ .orElseGet(() -> surveyRepository.save(surveyConverter.toEntity(user, request)));
+ }
+
+ private void saveOrUpdateTravelTendency(User user, TravelTendencyCalculator.TendencyScores scores, AvatarType avatarType) {
+ BigDecimal rDecimal = toBigDecimal(scores.r());
+ BigDecimal wDecimal = toBigDecimal(scores.w());
+ BigDecimal sDecimal = toBigDecimal(scores.s());
+ BigDecimal pDecimal = toBigDecimal(scores.p());
+
+ travelTendencyRepository.findByUser(user)
+ .ifPresentOrElse(
+ existing -> existing.update(rDecimal, wDecimal, sDecimal, pDecimal, avatarType),
+ () -> travelTendencyRepository.save(
+ TravelTendency.create(user, rDecimal, wDecimal, sDecimal, pDecimal, avatarType)
+ )
+ );
+ }
+
+ private BigDecimal toBigDecimal(double value) {
+ return BigDecimal.valueOf(value).setScale(1, RoundingMode.HALF_UP);
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/service/TravelSurveyService.java b/src/main/java/com/dduru/gildongmu/survey/service/TravelSurveyService.java
deleted file mode 100644
index 93293ad2..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/service/TravelSurveyService.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.dduru.gildongmu.survey.service;
-
-import com.dduru.gildongmu.survey.converter.TravelSurveyConverter;
-import com.dduru.gildongmu.survey.converter.TravelSurveyInterestConverter;
-import com.dduru.gildongmu.survey.domain.TravelSurvey;
-import com.dduru.gildongmu.survey.domain.TravelSurveyInterest;
-import com.dduru.gildongmu.survey.dto.TravelSurveyRequest;
-import com.dduru.gildongmu.survey.repository.TravelSurveyInterestRepository;
-import com.dduru.gildongmu.survey.repository.TravelSurveyRepository;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-
-@Service
-@Transactional
-@RequiredArgsConstructor
-public class TravelSurveyService {
-
- private final TravelSurveyRepository travelSurveyRepository;
- private final TravelSurveyInterestRepository travelSurveyInterestRepository;
-
- private final TravelSurveyConverter travelSurveyConverter;
- private final TravelSurveyInterestConverter travelSurveyInterestConverter;
-
- public void create(Long userId, TravelSurveyRequest request) {
- TravelSurvey surveyRequest = travelSurveyConverter.toEntity(userId, request);
- TravelSurvey travelSurvey = travelSurveyRepository.findByUserId(userId)
- .map(existingSurvey -> {
- existingSurvey.updateStyles(
- surveyRequest.getPlanStyle(),
- surveyRequest.getTastingStyle(),
- surveyRequest.getStayStyle(),
- surveyRequest.getExpenseStyle(),
- surveyRequest.getMoveStyle(),
- surveyRequest.getSpendStyle(),
- surveyRequest.getCaptureStyle(),
- surveyRequest.getPaceStyle()
- );
- travelSurveyInterestRepository.deleteAllByTravelSurvey(existingSurvey);
- travelSurveyRepository.save(existingSurvey);
- return existingSurvey;
- })
- .orElseGet(() -> travelSurveyRepository.save(surveyRequest));
-
- List interests = travelSurveyInterestConverter.toEntity(travelSurvey, request.interests());
- travelSurveyInterestRepository.saveAll(interests);
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculator.java b/src/main/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculator.java
new file mode 100644
index 00000000..9ede699f
--- /dev/null
+++ b/src/main/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculator.java
@@ -0,0 +1,164 @@
+package com.dduru.gildongmu.survey.service;
+
+import com.dduru.gildongmu.survey.domain.Survey;
+import com.dduru.gildongmu.survey.domain.enums.*;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+public class TravelTendencyCalculator {
+
+ private static final double INITIAL_SCORE = 5.0;
+ private static final double MIN_SCORE = 0.0;
+ private static final double MAX_SCORE = 10.0;
+
+ public TendencyScores calculate(Survey survey) {
+ double r = INITIAL_SCORE; // 여행 리듬
+ double w = INITIAL_SCORE; // 지갑 성향
+ double s = INITIAL_SCORE; // 동행 스타일
+ double p = INITIAL_SCORE; // 활동 에너지
+
+ // Q1. 이동수단
+ if (survey.getQ1Transport() == Question1Transport.WALK_BUS) {
+ w -= 1.0;
+ p += 1.0;
+ } else if (survey.getQ1Transport() == Question1Transport.TAXI) {
+ w += 1.0;
+ p -= 1.0;
+ }
+
+ // Q2. 웨이팅
+ if (survey.getQ2Waiting() == Question2Waiting.WAIT) {
+ r += 1.0;
+ p += 1.0;
+ } else if (survey.getQ2Waiting() == Question2Waiting.MOVE_ELSEWHERE) {
+ r -= 1.0;
+ p -= 1.0;
+ }
+
+ // Q3. 숙소
+ if (survey.getQ3Stay() == Question3Stay.HOTEL) {
+ r -= 1.0;
+ w += 1.5;
+ } else if (survey.getQ3Stay() == Question3Stay.JUST_SLEEP) {
+ r += 1.0;
+ w -= 1.5;
+ }
+
+ // Q4. 기상시간
+ if (survey.getQ4Wakeup() == Question4Wakeup.EARLY) {
+ p += 2.0;
+ } else if (survey.getQ4Wakeup() == Question4Wakeup.RELAXED) {
+ p -= 2.0;
+ }
+
+ // Q5. 경비관리
+ if (survey.getQ5Expense() == Question5Expense.EACH_PAYS) {
+ w -= 1.0;
+ s -= 0.5;
+ } else if (survey.getQ5Expense() == Question5Expense.POOLED) {
+ w += 1.0;
+ s += 0.5;
+ }
+
+ // Q6. 소비태도
+ if (survey.getQ6Spend() == Question6Spend.SPLURGE) {
+ w += 2.0;
+ } else if (survey.getQ6Spend() == Question6Spend.SAVE) {
+ w -= 2.0;
+ }
+
+ // Q7. 선호활동 (택3 누적합산)
+ List interests = survey.getQ7Interests();
+ if (interests != null) {
+ for (Question7Interest interest : interests) {
+ switch (interest) {
+ case SIGHTSEEING:
+ p += 0.5;
+ break;
+ case EXHIBITION:
+ p -= 0.5;
+ break;
+ case NATURE:
+ p -= 0.5;
+ break;
+ case FOOD:
+ w += 0.5;
+ break;
+ case SHOPPING:
+ w += 1.0;
+ break;
+ case RESORT:
+ p -= 1.0;
+ break;
+ case ACTIVITY:
+ r += 1.0;
+ p += 0.5;
+ break;
+ case THEME_PARK:
+ r += 0.5;
+ p += 0.5;
+ break;
+ case FESTIVAL:
+ r += 1.0;
+ p += 1.0;
+ break;
+ }
+ }
+ }
+
+ // Q8. 계획성
+ if (survey.getQ8Planning() == Question8Planning.DETAILED) {
+ r -= 2.0;
+ } else if (survey.getQ8Planning() == Question8Planning.FLEXIBLE) {
+ } else if (survey.getQ8Planning() == Question8Planning.ON_SITE) {
+ r += 2.0;
+ }
+
+ // Q9. 낯선메뉴
+ if (survey.getQ9Menu() == Question9Menu.SAFE) {
+ r -= 1.5;
+ } else if (survey.getQ9Menu() == Question9Menu.CHECK_REVIEW) {
+ r -= 0.5;
+ } else if (survey.getQ9Menu() == Question9Menu.CHALLENGE) {
+ r += 1.5;
+ }
+
+ // Q10. 동행제안
+ if (survey.getQ10Companion() == Question10Companion.WELCOME) {
+ s += 3.0;
+ } else if (survey.getQ10Companion() == Question10Companion.SITUATIONAL) {
+ s += 1.0;
+ } else if (survey.getQ10Companion() == Question10Companion.US_ONLY) {
+ s -= 2.0;
+ }
+
+ // Q11. 사진
+ if (survey.getQ11Photo() == Question11Photo.LIFETIME_SHOT) {
+ s += 1.0;
+ p += 1.0;
+ } else if (survey.getQ11Photo() == Question11Photo.MATCH_COMPANION) {
+ s += 0.5;
+ } else if (survey.getQ11Photo() == Question11Photo.EYES_ONLY) {
+ s -= 1.0;
+ p -= 1.0;
+ }
+
+ r = clamp(r);
+ w = clamp(w);
+ s = clamp(s);
+ p = clamp(p);
+
+ return new TendencyScores(r, w, s, p);
+ }
+
+ private double clamp(double value) {
+ return Math.max(MIN_SCORE, Math.min(MAX_SCORE, value));
+ }
+
+ public record TendencyScores(double r, double w, double s, double p) {
+ }
+}
diff --git a/src/main/java/com/dduru/gildongmu/survey/validation/InterestListValidator.java b/src/main/java/com/dduru/gildongmu/survey/validation/InterestListValidator.java
deleted file mode 100644
index e5d0f2e4..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/validation/InterestListValidator.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.dduru.gildongmu.survey.validation;
-
-import com.dduru.gildongmu.survey.domain.enums.Interest;
-import jakarta.validation.ConstraintValidator;
-import jakarta.validation.ConstraintValidatorContext;
-
-import java.util.Arrays;
-import java.util.List;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-public class InterestListValidator implements ConstraintValidator> {
-
- private Set validInterests;
-
- @Override
- public void initialize(ValidInterestList constraintAnnotation) {
- validInterests = Arrays.stream(Interest.values())
- .map(Enum::name)
- .collect(Collectors.toSet());
- }
-
- @Override
- public boolean isValid(List value, ConstraintValidatorContext context) {
- if (value == null || value.isEmpty()) {
- return false;
- }
- return value.stream().allMatch(validInterests::contains);
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/survey/validation/ValidInterestList.java b/src/main/java/com/dduru/gildongmu/survey/validation/ValidInterestList.java
deleted file mode 100644
index 3c34c8c3..00000000
--- a/src/main/java/com/dduru/gildongmu/survey/validation/ValidInterestList.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.dduru.gildongmu.survey.validation;
-
-import jakarta.validation.Constraint;
-import jakarta.validation.Payload;
-
-import java.lang.annotation.*;
-
-@Documented
-@Constraint(validatedBy = InterestListValidator.class)
-@Target({ ElementType.FIELD, ElementType.PARAMETER })
-@Retention(RetentionPolicy.RUNTIME)
-public @interface ValidInterestList {
- String message() default "유효하지 않은 관심사입니다";
- Class>[] groups() default {};
- Class extends Payload>[] payload() default {};
-}
diff --git a/src/main/java/com/dduru/gildongmu/user/controller/UserApiDocs.java b/src/main/java/com/dduru/gildongmu/user/controller/UserApiDocs.java
deleted file mode 100644
index 556aaa11..00000000
--- a/src/main/java/com/dduru/gildongmu/user/controller/UserApiDocs.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.dduru.gildongmu.user.controller;
-
-import com.dduru.gildongmu.common.dto.ApiResult;
-import com.dduru.gildongmu.user.dto.NicknameRandomResponse;
-import com.dduru.gildongmu.user.dto.UserCheckNicknameResponse;
-import com.dduru.gildongmu.user.dto.UserUpdateNicknameRequest;
-import com.dduru.gildongmu.user.validator.ValidNickname;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.responses.ApiResponses;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import jakarta.validation.Valid;
-import org.springframework.http.ResponseEntity;
-
-@Tag(name = "Users", description = "사용자 관리 API")
-public interface UserApiDocs {
-
- @Operation(summary = "닉네임 수정", description = "사용자의 닉네임을 수정합니다.")
- @ApiResponses({
- @ApiResponse(responseCode = "204", description = "수정 성공"),
- @ApiResponse(responseCode = "400", description = "잘못된 요청")
- })
- ResponseEntity> updateNickname(
- @Parameter(hidden = true) Long id,
- @Valid UserUpdateNicknameRequest request
- );
-
- @Operation(summary = "닉네임 유효성 확인", description = "닉네임의 사용 가능 여부를 확인합니다.")
- @ApiResponse(responseCode = "200", description = "유효성 확인 성공")
- ResponseEntity> checkNickname(
- @Parameter(
- description = "유효성 체크할 닉네임",
- example = "길동무"
- )
- @ValidNickname String nickname
- );
-
- @Operation(summary = "랜덤 닉네임 생성", description = "랜덤으로 닉네임을 생성합니다.")
- @ApiResponses({
- @ApiResponse(responseCode = "200", description = "랜덤 닉네임 생성 성공"),
- @ApiResponse(responseCode = "401", description = "인증 필요")
- })
- ResponseEntity> generateRandomNickname();
-}
diff --git a/src/main/java/com/dduru/gildongmu/user/domain/User.java b/src/main/java/com/dduru/gildongmu/user/domain/User.java
index 23fae491..04c5c643 100644
--- a/src/main/java/com/dduru/gildongmu/user/domain/User.java
+++ b/src/main/java/com/dduru/gildongmu/user/domain/User.java
@@ -1,17 +1,15 @@
package com.dduru.gildongmu.user.domain;
-import com.dduru.gildongmu.user.enums.Gender;
+import com.dduru.gildongmu.common.entity.BaseTimeEntity;
+import com.dduru.gildongmu.profile.domain.Profile;
import com.dduru.gildongmu.user.enums.OauthType;
import com.dduru.gildongmu.user.enums.Role;
-import com.dduru.gildongmu.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
-import java.time.LocalDate;
-
@Entity
@Table(name = "users")
@Getter
@@ -27,12 +25,6 @@ public class User extends BaseTimeEntity {
@Column(nullable = false, length = 50)
private String name;
- @Column(length = 14, unique = true)
- private String nickname;
-
- @Column(name = "profile_image", nullable = false, length = 500)
- private String profileImage;
-
@Column(name = "oauth_id", nullable = false, length = 100)
private String oauthId;
@@ -44,49 +36,15 @@ public class User extends BaseTimeEntity {
@Column(nullable = false)
private Role role = Role.USER;
- @Enumerated(EnumType.STRING)
- private Gender gender;
-
- // AgeRange는 사용하지 않으므로 주석 처리
- // @Enumerated(EnumType.STRING)
- // @Column(name = "age_range", nullable = true)
- // private AgeRange ageRange;
-
- @Column(name = "phone_number", length = 20)
- private String phoneNumber;
-
- @Column(name = "birthday")
- private LocalDate birthday;
+ @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
+ private Profile profile;
@Builder
- public User(String email, String name, String nickname, String profileImage, String oauthId,
- OauthType oauthType, Role role, Gender gender, /* AgeRange ageRange, */ String phoneNumber, LocalDate birthday) {
+ public User(String email, String name, String oauthId, OauthType oauthType, Role role) {
this.email = email;
this.name = name;
- this.nickname = nickname;
- this.profileImage = profileImage;
this.oauthId = oauthId;
this.oauthType = oauthType;
this.role = role != null ? role : Role.USER;
- this.gender = gender;
- // this.ageRange = ageRange;
- this.phoneNumber = phoneNumber;
- this.birthday = birthday;
}
-
- public void updateNickname(String nickname) {
- this.nickname = nickname;
- }
-
-// public void updateAdditionalInfo(Gender gender, LocalDate birthday, String phoneNumber) {
-// if (gender != null) {
-// this.gender = gender;
-// }
-// if (birthday != null) {
-// this.birthday = birthday;
-// }
-// if (phoneNumber != null) {
-// this.phoneNumber = phoneNumber;
-// }
-// }
}
diff --git a/src/main/java/com/dduru/gildongmu/user/dto/UserCheckNicknameResponse.java b/src/main/java/com/dduru/gildongmu/user/dto/UserCheckNicknameResponse.java
deleted file mode 100644
index 19595c29..00000000
--- a/src/main/java/com/dduru/gildongmu/user/dto/UserCheckNicknameResponse.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.dduru.gildongmu.user.dto;
-
-import lombok.Builder;
-
-@Builder
-public record UserCheckNicknameResponse(
- String sanitizedNickname
-) {
-}
diff --git a/src/main/java/com/dduru/gildongmu/user/dto/UserInfo.java b/src/main/java/com/dduru/gildongmu/user/dto/UserInfo.java
index d8c4af02..ca255e36 100644
--- a/src/main/java/com/dduru/gildongmu/user/dto/UserInfo.java
+++ b/src/main/java/com/dduru/gildongmu/user/dto/UserInfo.java
@@ -1,5 +1,6 @@
package com.dduru.gildongmu.user.dto;
+import com.dduru.gildongmu.profile.domain.Profile;
import com.dduru.gildongmu.user.domain.User;
public record UserInfo(
@@ -11,13 +12,14 @@ public record UserInfo(
String nickname
) {
public static UserInfo from(User user) {
+ Profile profile = user.getProfile();
return new UserInfo(
user.getId(),
user.getName(),
- user.getProfileImage(),
- user.getGender() != null ? user.getGender().name() : null,
- user.getBirthday() != null ? user.getBirthday().toString() : null,
- user.getNickname()
+ profile.getProfileImage(),
+ profile.getGender().name(),
+ profile.getBirthday().toString(),
+ profile.getNickname()
);
}
}
diff --git a/src/main/java/com/dduru/gildongmu/user/repository/UserRepository.java b/src/main/java/com/dduru/gildongmu/user/repository/UserRepository.java
index e37f5c4c..131c54fc 100644
--- a/src/main/java/com/dduru/gildongmu/user/repository/UserRepository.java
+++ b/src/main/java/com/dduru/gildongmu/user/repository/UserRepository.java
@@ -18,7 +18,4 @@ default User getByIdOrThrow(Long id) {
return findById(id)
.orElseThrow(() -> UserNotFoundException.of(id));
}
-
- boolean existsByNickname(String nickname);
- boolean existsByPhoneNumber(String phoneNumber);
}
diff --git a/src/main/java/com/dduru/gildongmu/user/service/UserService.java b/src/main/java/com/dduru/gildongmu/user/service/UserService.java
deleted file mode 100644
index 9442b39b..00000000
--- a/src/main/java/com/dduru/gildongmu/user/service/UserService.java
+++ /dev/null
@@ -1,76 +0,0 @@
-package com.dduru.gildongmu.user.service;
-
-import com.dduru.gildongmu.common.exception.BusinessException;
-import com.dduru.gildongmu.common.exception.ErrorCode;
-import com.dduru.gildongmu.user.domain.User;
-import com.dduru.gildongmu.user.dto.NicknameRandomResponse;
-import com.dduru.gildongmu.user.dto.UserCheckNicknameResponse;
-import com.dduru.gildongmu.user.dto.UserUpdateNicknameRequest;
-import com.dduru.gildongmu.user.repository.UserRepository;
-import com.dduru.gildongmu.user.utils.NicknameGenerator;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-@Slf4j
-@RequiredArgsConstructor
-@Service
-public class UserService {
-
- private static final int NICKNAME_MAX_RETRY_ATTEMPTS = 10;
-
- private final NicknameGenerator nicknameGenerator;
- private final UserRepository userRepository;
-
- @Transactional
- public void updateNickname(Long userId, UserUpdateNicknameRequest request) {
- User user = userRepository.getByIdOrThrow(userId);
- checkDuplicateNickname(request.nickname());
-
- user.updateNickname(request.nickname());
- }
-
- @Transactional(readOnly = true)
- public UserCheckNicknameResponse checkNickname(String nickname) {
- checkDuplicateNickname(nickname);
-
- return UserCheckNicknameResponse.builder()
- .sanitizedNickname(nickname)
- .build();
- }
-
- private void checkDuplicateNickname(String nickname) {
- if (userRepository.existsByNickname(nickname)) {
- throw new BusinessException(ErrorCode.NICKNAME_ALREADY_TAKEN);
- }
- }
-
- /**
- * 중복되지 않는 유니크한 닉네임을 생성합니다.
- * 형용사 + 명사 조합에 랜덤 숫자를 추가하여 고유성을 보장합니다.
- * DB에 중복이 있으면 새로운 랜덤 숫자로 재시도합니다.
- *
- * @return 유니크한 닉네임 (예: "용감한 여행자1234")
- */
- @Transactional(readOnly = true)
- public NicknameRandomResponse generateRandomNickname() {
-
- for (int attempt = 0; attempt < NICKNAME_MAX_RETRY_ATTEMPTS; attempt++) {
- String baseNickname = nicknameGenerator.generateBaseNickname();
- int randomNumber = nicknameGenerator.generateRandomNumber();
- String nickname = baseNickname + randomNumber;
-
- if (!userRepository.existsByNickname(nickname)) {
- log.info("랜덤 닉네임 생성: {}", nickname);
- return NicknameRandomResponse.of(nickname);
- }
-
- log.debug("해당 닉네임이 이미 존재합니다: {}, 새로운 숫자를 부여하겠습니다.", nickname);
- }
-
- String fallbackNickname = "뚜비" + (System.currentTimeMillis() % 10000);
- log.warn("유니크한 닉네임 생성에 {}회 실패하여 대체 닉네임 사용: {}", NICKNAME_MAX_RETRY_ATTEMPTS, fallbackNickname);
- return NicknameRandomResponse.of(fallbackNickname);
- }
-}
diff --git a/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java b/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java
index b672354b..690d3471 100644
--- a/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java
+++ b/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java
@@ -1,7 +1,5 @@
package com.dduru.gildongmu.verification.controller;
-import com.dduru.gildongmu.common.annotation.CommonApiResponses;
-import com.dduru.gildongmu.common.annotation.CurrentUser;
import com.dduru.gildongmu.common.dto.ApiResult;
import com.dduru.gildongmu.common.exception.ErrorResponse;
import com.dduru.gildongmu.verification.dto.VerificationSendRequest;
@@ -18,13 +16,11 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.RequestBody;
@Tag(name = "Phone Verification", description = "휴대폰 인증 API")
public interface PhoneVerificationApiDocs {
- @Operation(summary = "인증번호 발송", description = "소셜 로그인한 사용자만 전화번호로 인증번호를 발송할 수 있습니다.")
- @CommonApiResponses
+ @Operation(summary = "인증번호 발송", description = "전화번호로 인증번호를 발송합니다.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
@@ -93,12 +89,11 @@ public interface PhoneVerificationApiDocs {
)
})
ResponseEntity> sendVerificationCode(
- @Parameter(hidden = true) @CurrentUser Long userId,
- @Valid @RequestBody VerificationSendRequest request
+ @Parameter(hidden = true) Long userId,
+ @Valid VerificationSendRequest request
);
- @Operation(summary = "인증번호 검증", description = "소셜 로그인한 사용자만 발송된 인증번호를 검증하고 인증 토큰을 발급할 수 있습니다.")
- @CommonApiResponses
+ @Operation(summary = "인증번호 검증", description = "발송된 인증번호를 검증하고 인증 토큰을 발급합니다.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
@@ -188,7 +183,7 @@ ResponseEntity> sendVerificationCode(
)
})
ResponseEntity> verifyCode(
- @Parameter(hidden = true) @CurrentUser Long userId,
- @Valid @RequestBody VerificationVerifyRequest request
+ @Parameter(hidden = true) Long userId,
+ @Valid VerificationVerifyRequest request
);
}
diff --git a/src/main/java/com/dduru/gildongmu/verification/service/PhoneVerificationService.java b/src/main/java/com/dduru/gildongmu/verification/service/PhoneVerificationService.java
index aea28cf6..22dd740b 100644
--- a/src/main/java/com/dduru/gildongmu/verification/service/PhoneVerificationService.java
+++ b/src/main/java/com/dduru/gildongmu/verification/service/PhoneVerificationService.java
@@ -1,7 +1,7 @@
package com.dduru.gildongmu.verification.service;
import com.dduru.gildongmu.common.jwt.JwtTokenProvider;
-import com.dduru.gildongmu.user.repository.UserRepository;
+import com.dduru.gildongmu.profile.repository.ProfileRepository;
import com.dduru.gildongmu.verification.dto.VerificationSendResponse;
import com.dduru.gildongmu.verification.dto.VerificationVerifyResponse;
import com.dduru.gildongmu.verification.exception.DuplicatePhoneNumberException;
@@ -19,7 +19,7 @@ public class PhoneVerificationService {
private final SmsService smsService;
private final VerificationCodeService verificationCodeService;
private final JwtTokenProvider jwtTokenProvider;
- private final UserRepository userRepository;
+ private final ProfileRepository profileRepository;
public VerificationSendResponse sendVerificationCode(String phoneNumber) {
validatePhoneNumber(phoneNumber);
@@ -48,7 +48,7 @@ public VerificationVerifyResponse verifyCode(String phoneNumber, String code) {
}
private void validatePhoneNumber(String phoneNumber) {
- if (userRepository.existsByPhoneNumber(phoneNumber)) {
+ if (profileRepository.existsByPhoneNumber(phoneNumber)) {
throw new DuplicatePhoneNumberException("이미 가입된 전화번호입니다. 로그인해주세요.");
}
}
diff --git a/src/test/java/com/dduru/gildongmu/auth/repository/UserRepositoryTest.java b/src/test/java/com/dduru/gildongmu/auth/repository/UserRepositoryTest.java
index 47389568..186190e7 100644
--- a/src/test/java/com/dduru/gildongmu/auth/repository/UserRepositoryTest.java
+++ b/src/test/java/com/dduru/gildongmu/auth/repository/UserRepositoryTest.java
@@ -1,8 +1,10 @@
package com.dduru.gildongmu.auth.repository;
import com.dduru.gildongmu.config.QueryDslConfig;
+import com.dduru.gildongmu.profile.domain.Profile;
+import com.dduru.gildongmu.profile.domain.enums.Gender;
+import com.dduru.gildongmu.profile.repository.ProfileRepository;
import com.dduru.gildongmu.user.domain.User;
-import com.dduru.gildongmu.user.enums.Gender;
import com.dduru.gildongmu.user.enums.OauthType;
import com.dduru.gildongmu.user.repository.UserRepository;
import org.junit.jupiter.api.Test;
@@ -21,21 +23,29 @@ class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
+ @Autowired
+ private ProfileRepository profileRepository;
+
@Test
void 사용자_저장_및_조회_테스트() {
// given
User user = User.builder()
.email("test@example.com")
.name("테스트사용자")
- .profileImage("http://example.com/profile.jpg")
.oauthId("12345")
.oauthType(OauthType.KAKAO)
- .gender(Gender.M)
- .phoneNumber("010-1234-5678")
.build();
// when
User savedUser = userRepository.save(user);
+
+ Profile profile = Profile.builder()
+ .user(savedUser)
+ .profileImage("http://example.com/profile.jpg")
+ .gender(Gender.M)
+ .phoneNumber("010-1234-5678")
+ .build();
+ profileRepository.save(profile);
// then
assertThat(savedUser.getId()).isNotNull();
@@ -49,13 +59,18 @@ class UserRepositoryTest {
User user = User.builder()
.email("find@example.com")
.name("찾을사용자")
- .profileImage("http://example.com/profile.jpg")
.oauthId("67890")
.oauthType(OauthType.GOOGLE)
- .gender(Gender.F)
.build();
- userRepository.save(user);
+ User savedUser = userRepository.save(user);
+
+ Profile profile = Profile.builder()
+ .user(savedUser)
+ .profileImage("http://example.com/profile.jpg")
+ .gender(Gender.F)
+ .build();
+ profileRepository.save(profile);
// when
boolean exists = userRepository.existsByEmail("find@example.com");
@@ -70,13 +85,18 @@ class UserRepositoryTest {
User user = User.builder()
.email("oauth@example.com")
.name("OAuth사용자")
- .profileImage("http://example.com/profile.jpg")
.oauthId("oauth123")
.oauthType(OauthType.KAKAO)
- .gender(Gender.M)
.build();
- userRepository.save(user);
+ User savedUser = userRepository.save(user);
+
+ Profile profile = Profile.builder()
+ .user(savedUser)
+ .profileImage("http://example.com/profile.jpg")
+ .gender(Gender.M)
+ .build();
+ profileRepository.save(profile);
// when
boolean exists = userRepository.existsByOauthIdAndOauthType("oauth123", OauthType.KAKAO);
diff --git a/src/test/java/com/dduru/gildongmu/profile/service/ProfileServiceTest.java b/src/test/java/com/dduru/gildongmu/profile/service/ProfileServiceTest.java
new file mode 100644
index 00000000..d38eedcc
--- /dev/null
+++ b/src/test/java/com/dduru/gildongmu/profile/service/ProfileServiceTest.java
@@ -0,0 +1,300 @@
+package com.dduru.gildongmu.profile.service;
+
+import com.dduru.gildongmu.common.exception.BusinessException;
+import com.dduru.gildongmu.common.exception.ErrorCode;
+import com.dduru.gildongmu.common.jwt.JwtTokenProvider;
+import com.dduru.gildongmu.profile.domain.Profile;
+import com.dduru.gildongmu.profile.dto.ProfileSetupRequest;
+import com.dduru.gildongmu.profile.repository.ProfileRepository;
+import com.dduru.gildongmu.profile.utils.NicknameGenerator;
+import com.dduru.gildongmu.user.domain.User;
+import com.dduru.gildongmu.user.enums.OauthType;
+import com.dduru.gildongmu.user.repository.UserRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ProfileService 프로필 생성 테스트")
+class ProfileServiceTest {
+
+ @Mock
+ private ProfileRepository profileRepository;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private JwtTokenProvider jwtTokenProvider;
+
+ @Mock
+ private NicknameGenerator nicknameGenerator;
+
+ @InjectMocks
+ private ProfileService profileService;
+
+ @Test
+ @DisplayName("정상적인 프로필 생성 성공")
+ void 성공() {
+ // given
+ Long userId = 1L;
+ String nickname = "테스트닉네임";
+ String phoneNumber = "01012345678";
+ String verificationToken = "valid-token";
+ ProfileSetupRequest request = new ProfileSetupRequest(
+ nickname,
+ "M",
+ phoneNumber,
+ "2000-01-01",
+ verificationToken
+ );
+
+ User user = User.builder()
+ .email("test@example.com")
+ .name("테스트사용자")
+ .oauthId("12345")
+ .oauthType(OauthType.KAKAO)
+ .build();
+
+ Profile profile = Profile.builder()
+ .user(user)
+ .build();
+
+ when(userRepository.getByIdOrThrow(userId)).thenReturn(user);
+ when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile));
+ when(profileRepository.existsByNicknameWithLock(nickname)).thenReturn(false);
+ when(jwtTokenProvider.validateVerificationToken(verificationToken, phoneNumber)).thenReturn(true);
+ when(profileRepository.existsByPhoneNumber(phoneNumber)).thenReturn(false);
+ when(profileRepository.save(any(Profile.class))).thenReturn(profile);
+
+ // when
+ profileService.setupInitialProfile(userId, request);
+
+ // then
+ verify(profileRepository).existsByNicknameWithLock(nickname);
+ verify(jwtTokenProvider).validateVerificationToken(verificationToken, phoneNumber);
+ verify(profileRepository).existsByPhoneNumber(phoneNumber);
+ verify(profileRepository).save(any(Profile.class));
+ }
+
+ @Test
+ @DisplayName("닉네임 중복 시 NICKNAME_ALREADY_TAKEN 예외 발생 (비관적 잠금 사용)")
+ void 닉네임중복_예외발생() {
+ // given
+ Long userId = 1L;
+ String duplicateNickname = "중복닉네임";
+ ProfileSetupRequest request = new ProfileSetupRequest(
+ duplicateNickname,
+ "M",
+ "01012345678",
+ "2000-01-01",
+ "valid-token"
+ );
+
+ User user = User.builder()
+ .email("test@example.com")
+ .name("테스트사용자")
+ .oauthId("12345")
+ .oauthType(OauthType.KAKAO)
+ .build();
+
+ Profile profile = Profile.builder()
+ .user(user)
+ .build();
+
+ when(userRepository.getByIdOrThrow(userId)).thenReturn(user);
+ when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile));
+ when(profileRepository.existsByNicknameWithLock(duplicateNickname)).thenReturn(true);
+
+ // when & then
+ assertThatThrownBy(() -> profileService.setupInitialProfile(userId, request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException businessException = (BusinessException) exception;
+ assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.NICKNAME_ALREADY_TAKEN);
+ });
+
+ verify(profileRepository).existsByNicknameWithLock(duplicateNickname);
+ verify(profileRepository, never()).save(any(Profile.class));
+ verify(jwtTokenProvider, never()).validateVerificationToken(anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("전화번호 중복 시 DUPLICATE_PHONE_NUMBER 예외 발생")
+ void 전화번호중복_예외발생() {
+ // given
+ Long userId = 1L;
+ String duplicatePhoneNumber = "01012345678";
+ ProfileSetupRequest request = new ProfileSetupRequest(
+ "테스트닉네임",
+ "M",
+ duplicatePhoneNumber,
+ "2000-01-01",
+ "valid-token"
+ );
+
+ User user = User.builder()
+ .email("test@example.com")
+ .name("테스트사용자")
+ .oauthId("12345")
+ .oauthType(OauthType.KAKAO)
+ .build();
+
+ Profile profile = Profile.builder()
+ .user(user)
+ .build();
+
+ when(userRepository.getByIdOrThrow(userId)).thenReturn(user);
+ when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile));
+ when(profileRepository.existsByNicknameWithLock("테스트닉네임")).thenReturn(false);
+ when(jwtTokenProvider.validateVerificationToken("valid-token", duplicatePhoneNumber)).thenReturn(true);
+ when(profileRepository.existsByPhoneNumber(duplicatePhoneNumber)).thenReturn(true);
+
+ // when & then
+ assertThatThrownBy(() -> profileService.setupInitialProfile(userId, request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException businessException = (BusinessException) exception;
+ assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_PHONE_NUMBER);
+ });
+
+ verify(profileRepository).existsByNicknameWithLock("테스트닉네임");
+ verify(profileRepository).existsByPhoneNumber(duplicatePhoneNumber);
+ verify(profileRepository, never()).save(any(Profile.class));
+ }
+
+ @Test
+ @DisplayName("인증 토큰 검증 실패 시 INVALID_TOKEN 예외 발생")
+ void 인증토큰검증실패_예외발생() {
+ // given
+ Long userId = 1L;
+ String invalidToken = "invalid-token";
+ String phoneNumber = "01012345678";
+ ProfileSetupRequest request = new ProfileSetupRequest(
+ "테스트닉네임",
+ "M",
+ phoneNumber,
+ "2000-01-01",
+ invalidToken
+ );
+
+ User user = User.builder()
+ .email("test@example.com")
+ .name("테스트사용자")
+ .oauthId("12345")
+ .oauthType(OauthType.KAKAO)
+ .build();
+
+ Profile profile = Profile.builder()
+ .user(user)
+ .build();
+
+ when(userRepository.getByIdOrThrow(userId)).thenReturn(user);
+ when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile));
+ when(profileRepository.existsByNicknameWithLock("테스트닉네임")).thenReturn(false);
+ when(jwtTokenProvider.validateVerificationToken(invalidToken, phoneNumber)).thenReturn(false);
+
+ // when & then
+ assertThatThrownBy(() -> profileService.setupInitialProfile(userId, request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException businessException = (BusinessException) exception;
+ assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.INVALID_TOKEN);
+ });
+
+ verify(profileRepository).existsByNicknameWithLock("테스트닉네임");
+ verify(jwtTokenProvider).validateVerificationToken(invalidToken, phoneNumber);
+ verify(profileRepository, never()).existsByPhoneNumber(anyString());
+ verify(profileRepository, never()).save(any(Profile.class));
+ }
+
+ @Test
+ @DisplayName("프로필이 없는 경우 PROFILE_NOT_FOUND 예외 발생")
+ void 프로필없음_예외발생() {
+ // given
+ Long userId = 1L;
+ ProfileSetupRequest request = new ProfileSetupRequest(
+ "테스트닉네임",
+ "M",
+ "01012345678",
+ "2000-01-01",
+ "valid-token"
+ );
+
+ User user = User.builder()
+ .email("test@example.com")
+ .name("테스트사용자")
+ .oauthId("12345")
+ .oauthType(OauthType.KAKAO)
+ .build();
+
+ when(userRepository.getByIdOrThrow(userId)).thenReturn(user);
+ when(profileRepository.findByUser(user)).thenReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> profileService.setupInitialProfile(userId, request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException businessException = (BusinessException) exception;
+ assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.PROFILE_NOT_FOUND);
+ });
+
+ verify(profileRepository).findByUser(user);
+ verify(profileRepository, never()).existsByNicknameWithLock(anyString());
+ verify(profileRepository, never()).save(any(Profile.class));
+ }
+
+ @Test
+ @DisplayName("비관적 잠금을 사용하여 닉네임 중복 체크 메서드 호출 확인")
+ void 비관적잠금사용확인() {
+ // given
+ Long userId = 1L;
+ String nickname = "테스트닉네임";
+ ProfileSetupRequest request = new ProfileSetupRequest(
+ nickname,
+ "F",
+ "01087654321",
+ "1995-05-15",
+ "valid-token"
+ );
+
+ User user = User.builder()
+ .email("test2@example.com")
+ .name("테스트사용자2")
+ .oauthId("67890")
+ .oauthType(OauthType.GOOGLE)
+ .build();
+
+ Profile profile = Profile.builder()
+ .user(user)
+ .build();
+
+ when(userRepository.getByIdOrThrow(userId)).thenReturn(user);
+ when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile));
+ when(profileRepository.existsByNicknameWithLock(nickname)).thenReturn(false);
+ when(jwtTokenProvider.validateVerificationToken("valid-token", "01087654321")).thenReturn(true);
+ when(profileRepository.existsByPhoneNumber("01087654321")).thenReturn(false);
+ when(profileRepository.save(any(Profile.class))).thenReturn(profile);
+
+ // when
+ profileService.setupInitialProfile(userId, request);
+
+ // then
+ // existsByNickname이 아닌 existsByNicknameWithLock이 호출되었는지 확인
+ verify(profileRepository).existsByNicknameWithLock(nickname);
+ verify(profileRepository, never()).existsByNickname(nickname);
+ }
+}
diff --git a/src/test/java/com/dduru/gildongmu/survey/converter/SurveyConverterTest.java b/src/test/java/com/dduru/gildongmu/survey/converter/SurveyConverterTest.java
new file mode 100644
index 00000000..e966dfd7
--- /dev/null
+++ b/src/test/java/com/dduru/gildongmu/survey/converter/SurveyConverterTest.java
@@ -0,0 +1,122 @@
+package com.dduru.gildongmu.survey.converter;
+
+import com.dduru.gildongmu.survey.domain.Survey;
+import com.dduru.gildongmu.survey.domain.enums.*;
+import com.dduru.gildongmu.survey.dto.SurveyRequest;
+import com.dduru.gildongmu.survey.exception.InvalidSurveyAnswerCodeException;
+import com.dduru.gildongmu.user.domain.User;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("설문조사 Converter 테스트")
+class SurveyConverterTest {
+
+ private SurveyConverter converter;
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ converter = new SurveyConverter();
+ testUser = User.builder()
+ .email("test@example.com")
+ .name("테스트")
+ .oauthId("12345")
+ .oauthType(com.dduru.gildongmu.user.enums.OauthType.KAKAO)
+ .build();
+ }
+
+ @Test
+ @DisplayName("유효한_코드로_Survey_엔티티_생성_성공")
+ void 유효한_코드로_Survey_엔티티_생성_성공() {
+ // given
+ SurveyRequest request = new SurveyRequest(
+ 1, 1, 1, 1, 1, 2,
+ List.of(1, 2, 3),
+ 2, 1, 2, 1
+ );
+
+ // when
+ Survey survey = converter.toEntity(testUser, request);
+
+ // then
+ assertThat(survey.getUser()).isEqualTo(testUser);
+ assertThat(survey.getQ1Transport()).isEqualTo(Question1Transport.WALK_BUS);
+ assertThat(survey.getQ2Waiting()).isEqualTo(Question2Waiting.WAIT);
+ assertThat(survey.getQ3Stay()).isEqualTo(Question3Stay.HOTEL);
+ assertThat(survey.getQ4Wakeup()).isEqualTo(Question4Wakeup.EARLY);
+ assertThat(survey.getQ5Expense()).isEqualTo(Question5Expense.EACH_PAYS);
+ assertThat(survey.getQ6Spend()).isEqualTo(Question6Spend.SAVE);
+ assertThat(survey.getQ7Interests()).hasSize(3);
+ assertThat(survey.getQ7Interests()).containsExactly(
+ Question7Interest.SIGHTSEEING,
+ Question7Interest.EXHIBITION,
+ Question7Interest.NATURE
+ );
+ assertThat(survey.getQ8Planning()).isEqualTo(Question8Planning.FLEXIBLE);
+ assertThat(survey.getQ9Menu()).isEqualTo(Question9Menu.SAFE);
+ assertThat(survey.getQ10Companion()).isEqualTo(Question10Companion.SITUATIONAL);
+ assertThat(survey.getQ11Photo()).isEqualTo(Question11Photo.LIFETIME_SHOT);
+ }
+
+ @Test
+ @DisplayName("유효하지_않은_Q1_코드_예외발생")
+ void 유효하지_않은_Q1_코드_예외발생() {
+ // given
+ SurveyRequest request = new SurveyRequest(
+ 999, 1, 1, 1, 1, 1, List.of(1, 2, 3), 1, 1, 1, 1
+ );
+
+ // when & then
+ assertThatThrownBy(() -> converter.toEntity(testUser, request))
+ .isInstanceOf(InvalidSurveyAnswerCodeException.class)
+ .hasMessageContaining("Q1 이동수단");
+ }
+
+ @Test
+ @DisplayName("유효하지_않은_Q7_코드_예외발생")
+ void 유효하지_않은_Q7_코드_예외발생() {
+ // given
+ SurveyRequest request = new SurveyRequest(
+ 1, 1, 1, 1, 1, 1,
+ List.of(1, 2, 999),
+ 1, 1, 1, 1
+ );
+
+ // when & then
+ assertThatThrownBy(() -> converter.toEntity(testUser, request))
+ .isInstanceOf(InvalidSurveyAnswerCodeException.class)
+ .hasMessageContaining("Q7 선호활동");
+ }
+
+ @Test
+ @DisplayName("모든_질문_최대값_코드_정상변환")
+ void 모든_질문_최대값_코드_정상변환() {
+ // given
+ SurveyRequest request = new SurveyRequest(
+ 2, 2, 2, 2, 2, 1,
+ List.of(7, 8, 9),
+ 3, 3, 1, 3
+ );
+
+ // when
+ Survey survey = converter.toEntity(testUser, request);
+
+ // then
+ assertThat(survey.getQ1Transport()).isEqualTo(Question1Transport.TAXI);
+ assertThat(survey.getQ2Waiting()).isEqualTo(Question2Waiting.MOVE_ELSEWHERE);
+ assertThat(survey.getQ3Stay()).isEqualTo(Question3Stay.JUST_SLEEP);
+ assertThat(survey.getQ4Wakeup()).isEqualTo(Question4Wakeup.RELAXED);
+ assertThat(survey.getQ5Expense()).isEqualTo(Question5Expense.POOLED);
+ assertThat(survey.getQ6Spend()).isEqualTo(Question6Spend.SPLURGE);
+ assertThat(survey.getQ8Planning()).isEqualTo(Question8Planning.ON_SITE);
+ assertThat(survey.getQ9Menu()).isEqualTo(Question9Menu.CHALLENGE);
+ assertThat(survey.getQ10Companion()).isEqualTo(Question10Companion.WELCOME);
+ assertThat(survey.getQ11Photo()).isEqualTo(Question11Photo.EYES_ONLY);
+ }
+}
diff --git a/src/test/java/com/dduru/gildongmu/survey/service/AvatarMatcherTest.java b/src/test/java/com/dduru/gildongmu/survey/service/AvatarMatcherTest.java
new file mode 100644
index 00000000..819aeb11
--- /dev/null
+++ b/src/test/java/com/dduru/gildongmu/survey/service/AvatarMatcherTest.java
@@ -0,0 +1,154 @@
+package com.dduru.gildongmu.survey.service;
+
+import com.dduru.gildongmu.survey.domain.enums.AvatarType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("아바타 매칭 테스트")
+class AvatarMatcherTest {
+
+ private AvatarMatcher avatarMatcher;
+
+ @BeforeEach
+ void setUp() {
+ avatarMatcher = new AvatarMatcher();
+ }
+
+ @Test
+ @DisplayName("안정_x_독립_x_가성비_매칭")
+ void 안정_x_독립_x_가성비_매칭() {
+ // given
+ double r = 4.0;
+ double w = 4.0;
+ double s = 4.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_POGUNI);
+ }
+
+ @Test
+ @DisplayName("안정_x_독립_x_플랙스_매칭")
+ void 안정_x_독립_x_플랙스_매칭() {
+ // given
+ double r = 4.0;
+ double w = 6.0;
+ double s = 4.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_MOOD);
+ }
+
+ @Test
+ @DisplayName("안정_x_사교_x_가성비_매칭")
+ void 안정_x_사교_x_가성비_매칭() {
+ // given
+ double r = 4.0;
+ double w = 4.0;
+ double s = 6.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_MALLANGI);
+ }
+
+ @Test
+ @DisplayName("안정_x_사교_x_플랙스_매칭")
+ void 안정_x_사교_x_플랙스_매칭() {
+ // given
+ double r = 4.0;
+ double w = 6.0;
+ double s = 6.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_SWEET);
+ }
+
+ @Test
+ @DisplayName("모험_x_독립_x_가성비_매칭")
+ void 모험_x_독립_x_가성비_매칭() {
+ // given
+ double r = 6.0;
+ double w = 4.0;
+ double s = 4.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_POPO);
+ }
+
+ @Test
+ @DisplayName("모험_x_독립_x_플랙스_매칭")
+ void 모험_x_독립_x_플랙스_매칭() {
+ // given
+ double r = 6.0;
+ double w = 6.0;
+ double s = 4.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_SPARKLE);
+ }
+
+ @Test
+ @DisplayName("모험_x_사교_x_가성비_매칭")
+ void 모험_x_사교_x_가성비_매칭() {
+ // given
+ double r = 6.0;
+ double w = 4.0;
+ double s = 6.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_GLIMMING);
+ }
+
+ @Test
+ @DisplayName("모험_x_사교_x_플랙스_매칭")
+ void 모험_x_사교_x_플랙스_매칭() {
+ // given
+ double r = 6.0;
+ double w = 6.0;
+ double s = 6.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_PADO);
+ }
+
+ @Test
+ @DisplayName("임계값_5점_경계_테스트")
+ void 임계값_5점_경계_테스트() {
+ // given
+ double r = 5.0;
+ double w = 5.0;
+ double s = 5.0;
+
+ // when
+ AvatarType avatar = avatarMatcher.match(r, w, s);
+
+ // then
+ assertThat(avatar).isEqualTo(AvatarType.TTUR_PADO);
+ }
+}
diff --git a/src/test/java/com/dduru/gildongmu/survey/service/SurveyServiceTest.java b/src/test/java/com/dduru/gildongmu/survey/service/SurveyServiceTest.java
new file mode 100644
index 00000000..bb5db99e
--- /dev/null
+++ b/src/test/java/com/dduru/gildongmu/survey/service/SurveyServiceTest.java
@@ -0,0 +1,266 @@
+package com.dduru.gildongmu.survey.service;
+
+import com.dduru.gildongmu.auth.exception.UserNotFoundException;
+import com.dduru.gildongmu.survey.converter.SurveyConverter;
+import com.dduru.gildongmu.survey.domain.Survey;
+import com.dduru.gildongmu.survey.domain.TravelTendency;
+import com.dduru.gildongmu.survey.domain.enums.*;
+import com.dduru.gildongmu.survey.dto.SurveyRequest;
+import com.dduru.gildongmu.survey.dto.SurveyResponse;
+import com.dduru.gildongmu.survey.exception.SurveyResultNotFoundException;
+import com.dduru.gildongmu.survey.repository.SurveyRepository;
+import com.dduru.gildongmu.survey.repository.TravelTendencyRepository;
+import com.dduru.gildongmu.user.domain.User;
+import com.dduru.gildongmu.user.repository.UserRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("설문조사 서비스 테스트")
+class SurveyServiceTest {
+
+ @Mock
+ private SurveyRepository surveyRepository;
+ @Mock
+ private TravelTendencyRepository travelTendencyRepository;
+ @Mock
+ private SurveyConverter surveyConverter;
+ @Mock
+ private TravelTendencyCalculator tendencyCalculator;
+ @Mock
+ private AvatarMatcher avatarMatcher;
+ @Mock
+ private AvatarProfileProvider avatarProfileProvider;
+ @Mock
+ private UserRepository userRepository;
+
+ @InjectMocks
+ private SurveyService surveyService;
+
+ private User testUser;
+ private SurveyRequest testRequest;
+ private Survey testSurvey;
+
+ @BeforeEach
+ void setUp() {
+ testUser = User.builder()
+ .email("test@example.com")
+ .name("테스트")
+ .oauthId("12345")
+ .oauthType(com.dduru.gildongmu.user.enums.OauthType.KAKAO)
+ .build();
+
+ testRequest = new SurveyRequest(
+ 1, 1, 1, 1, 1, 1,
+ List.of(1, 2, 3),
+ 1, 1, 1, 1
+ );
+
+ testSurvey = Survey.createSurvey(
+ testUser,
+ Question1Transport.WALK_BUS,
+ Question2Waiting.WAIT,
+ Question3Stay.HOTEL,
+ Question4Wakeup.EARLY,
+ Question5Expense.EACH_PAYS,
+ Question6Spend.SAVE,
+ List.of(Question7Interest.SIGHTSEEING, Question7Interest.EXHIBITION, Question7Interest.NATURE),
+ Question8Planning.DETAILED,
+ Question9Menu.SAFE,
+ Question10Companion.SITUATIONAL,
+ Question11Photo.LIFETIME_SHOT
+ );
+ }
+
+ @Test
+ @DisplayName("새로운_설문_제출_성공")
+ void 새로운_설문_제출_성공() {
+ // given
+ SurveyConverter.ParsedSurveyData parsedData = new SurveyConverter.ParsedSurveyData(
+ Question1Transport.WALK_BUS,
+ Question2Waiting.WAIT,
+ Question3Stay.HOTEL,
+ Question4Wakeup.EARLY,
+ Question5Expense.EACH_PAYS,
+ Question6Spend.SAVE,
+ List.of(Question7Interest.SIGHTSEEING, Question7Interest.EXHIBITION, Question7Interest.NATURE),
+ Question8Planning.DETAILED,
+ Question9Menu.SAFE,
+ Question10Companion.SITUATIONAL,
+ Question11Photo.LIFETIME_SHOT
+ );
+
+ when(userRepository.getByIdOrThrow(1L)).thenReturn(testUser);
+ when(surveyRepository.findByUser(testUser)).thenReturn(Optional.empty());
+ when(surveyConverter.parseRequest(testRequest)).thenReturn(parsedData);
+ when(surveyConverter.toEntity(testUser, testRequest)).thenReturn(testSurvey);
+ when(surveyRepository.save(any(Survey.class))).thenReturn(testSurvey);
+
+ TravelTendencyCalculator.TendencyScores scores =
+ new TravelTendencyCalculator.TendencyScores(5.5, 6.0, 7.0, 4.5);
+ when(tendencyCalculator.calculate(testSurvey)).thenReturn(scores);
+ when(avatarMatcher.match(scores.r(), scores.w(), scores.s())).thenReturn(AvatarType.TTUR_SWEET);
+
+ AvatarProfileProvider.AvatarProfile profile = new AvatarProfileProvider.AvatarProfile(
+ "성격", "강점", "팁", List.of("태그1", "태그2", "태그3")
+ );
+ when(avatarProfileProvider.getProfile(AvatarType.TTUR_SWEET)).thenReturn(profile);
+
+ when(travelTendencyRepository.findByUser(testUser)).thenReturn(Optional.empty());
+ when(travelTendencyRepository.save(any(TravelTendency.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ SurveyResponse response = surveyService.submitSurvey(1L, testRequest);
+
+ // then
+ assertThat(response.r()).isEqualTo(5.5);
+ assertThat(response.w()).isEqualTo(6.0);
+ assertThat(response.s()).isEqualTo(7.0);
+ assertThat(response.p()).isEqualTo(4.5);
+ assertThat(response.avatarType()).isEqualTo("TTUR_SWEET");
+ assertThat(response.personality()).isEqualTo("성격");
+ assertThat(response.strength()).isEqualTo("강점");
+ assertThat(response.tip()).isEqualTo("팁");
+
+ verify(surveyRepository).save(any(Survey.class));
+ verify(travelTendencyRepository).save(any(TravelTendency.class));
+ }
+
+ @Test
+ @DisplayName("기존_설문_업데이트_성공")
+ void 기존_설문_업데이트_성공() {
+ // given
+ Survey existingSurvey = Survey.createSurvey(
+ testUser,
+ Question1Transport.TAXI,
+ Question2Waiting.MOVE_ELSEWHERE,
+ Question3Stay.JUST_SLEEP,
+ Question4Wakeup.RELAXED,
+ Question5Expense.POOLED,
+ Question6Spend.SPLURGE,
+ List.of(Question7Interest.FOOD, Question7Interest.SHOPPING, Question7Interest.ACTIVITY),
+ Question8Planning.ON_SITE,
+ Question9Menu.CHALLENGE,
+ Question10Companion.WELCOME,
+ Question11Photo.EYES_ONLY
+ );
+
+ SurveyConverter.ParsedSurveyData parsedData = new SurveyConverter.ParsedSurveyData(
+ Question1Transport.WALK_BUS,
+ Question2Waiting.WAIT,
+ Question3Stay.HOTEL,
+ Question4Wakeup.EARLY,
+ Question5Expense.EACH_PAYS,
+ Question6Spend.SAVE,
+ List.of(Question7Interest.SIGHTSEEING, Question7Interest.EXHIBITION, Question7Interest.NATURE),
+ Question8Planning.DETAILED,
+ Question9Menu.SAFE,
+ Question10Companion.SITUATIONAL,
+ Question11Photo.LIFETIME_SHOT
+ );
+
+ when(userRepository.getByIdOrThrow(1L)).thenReturn(testUser);
+ when(surveyRepository.findByUser(testUser)).thenReturn(Optional.of(existingSurvey));
+ when(surveyConverter.parseRequest(testRequest)).thenReturn(parsedData);
+
+ TravelTendencyCalculator.TendencyScores scores =
+ new TravelTendencyCalculator.TendencyScores(6.0, 7.5, 8.0, 5.0);
+ when(tendencyCalculator.calculate(existingSurvey)).thenReturn(scores);
+ when(avatarMatcher.match(scores.r(), scores.w(), scores.s())).thenReturn(AvatarType.TTUR_PADO);
+
+ AvatarProfileProvider.AvatarProfile profile = new AvatarProfileProvider.AvatarProfile(
+ "성격2", "강점2", "팁2", List.of("태그1", "태그2", "태그3")
+ );
+ when(avatarProfileProvider.getProfile(AvatarType.TTUR_PADO)).thenReturn(profile);
+
+ TravelTendency existingTendency = TravelTendency.create(
+ testUser,
+ BigDecimal.valueOf(5.0),
+ BigDecimal.valueOf(6.0),
+ BigDecimal.valueOf(7.0),
+ BigDecimal.valueOf(4.0),
+ AvatarType.TTUR_SWEET
+ );
+ when(travelTendencyRepository.findByUser(testUser)).thenReturn(Optional.of(existingTendency));
+
+ // when
+ SurveyResponse response = surveyService.submitSurvey(1L, testRequest);
+
+ // then
+ assertThat(response.avatarType()).isEqualTo("TTUR_PADO");
+ verify(surveyRepository, never()).save(any(Survey.class));
+ }
+
+ @Test
+ @DisplayName("존재하지_않는_사용자_예외발생")
+ void 존재하지_않는_사용자_예외발생() {
+ // given
+ when(userRepository.getByIdOrThrow(999L)).thenThrow(UserNotFoundException.of(999L));
+
+ // when & then
+ assertThatThrownBy(() -> surveyService.submitSurvey(999L, testRequest))
+ .isInstanceOf(UserNotFoundException.class);
+
+ verify(surveyRepository, never()).save(any());
+ verify(travelTendencyRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("설문_결과_조회_성공")
+ void 설문_결과_조회_성공() {
+ // given
+ TravelTendency travelTendency = TravelTendency.create(
+ testUser,
+ BigDecimal.valueOf(5.5),
+ BigDecimal.valueOf(6.0),
+ BigDecimal.valueOf(7.0),
+ BigDecimal.valueOf(4.5),
+ AvatarType.TTUR_SWEET
+ );
+
+ when(userRepository.getByIdOrThrow(1L)).thenReturn(testUser);
+ when(travelTendencyRepository.findByUser(testUser)).thenReturn(Optional.of(travelTendency));
+
+ AvatarProfileProvider.AvatarProfile profile = new AvatarProfileProvider.AvatarProfile(
+ "성격", "강점", "팁", List.of("태그1", "태그2", "태그3")
+ );
+ when(avatarProfileProvider.getProfile(AvatarType.TTUR_SWEET)).thenReturn(profile);
+
+ // when
+ SurveyResponse response = surveyService.getMySurveyResult(1L);
+
+ // then
+ assertThat(response.r()).isEqualTo(5.5);
+ assertThat(response.w()).isEqualTo(6.0);
+ assertThat(response.s()).isEqualTo(7.0);
+ assertThat(response.p()).isEqualTo(4.5);
+ assertThat(response.avatarType()).isEqualTo("TTUR_SWEET");
+ assertThat(response.avatarCode()).isEqualTo(4);
+ }
+
+ @Test
+ @DisplayName("설문_결과_없을때_예외발생")
+ void 설문_결과_없을때_예외발생() {
+ // given
+ when(userRepository.getByIdOrThrow(1L)).thenReturn(testUser);
+ when(travelTendencyRepository.findByUser(testUser)).thenReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> surveyService.getMySurveyResult(1L))
+ .isInstanceOf(SurveyResultNotFoundException.class);
+ }
+}
diff --git a/src/test/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculatorTest.java b/src/test/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculatorTest.java
new file mode 100644
index 00000000..211eca32
--- /dev/null
+++ b/src/test/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculatorTest.java
@@ -0,0 +1,182 @@
+package com.dduru.gildongmu.survey.service;
+
+import com.dduru.gildongmu.survey.domain.Survey;
+import com.dduru.gildongmu.survey.domain.enums.*;
+import com.dduru.gildongmu.user.domain.User;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("여행 성향 점수 계산 테스트")
+class TravelTendencyCalculatorTest {
+
+ private TravelTendencyCalculator calculator;
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ calculator = new TravelTendencyCalculator();
+ testUser = User.builder()
+ .email("test@example.com")
+ .name("테스트")
+ .oauthId("12345")
+ .oauthType(com.dduru.gildongmu.user.enums.OauthType.KAKAO)
+ .build();
+ }
+
+ @Test
+ @DisplayName("기본값_모두_5점으로_시작")
+ void 기본값_모두_5점으로_시작() {
+ // given
+ Survey survey = Survey.createSurvey(
+ testUser,
+ Question1Transport.WALK_BUS,
+ Question2Waiting.WAIT,
+ Question3Stay.HOTEL,
+ Question4Wakeup.EARLY,
+ Question5Expense.EACH_PAYS,
+ Question6Spend.SAVE,
+ List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT),
+ Question8Planning.FLEXIBLE,
+ Question9Menu.SAFE,
+ Question10Companion.SITUATIONAL,
+ Question11Photo.EYES_ONLY
+ );
+
+ // when
+ TravelTendencyCalculator.TendencyScores scores = calculator.calculate(survey);
+
+ // then
+ assertThat(scores.r()).isBetween(0.0, 10.0);
+ assertThat(scores.w()).isBetween(0.0, 10.0);
+ assertThat(scores.s()).isBetween(0.0, 10.0);
+ assertThat(scores.p()).isBetween(0.0, 10.0);
+ }
+
+ @Test
+ @DisplayName("Q1_걷기버스선택시_W감소_P증가")
+ void Q1_걷기버스선택시_W감소_P증가() {
+ // given
+ Survey walkBusSurvey = Survey.createSurvey(
+ testUser, Question1Transport.WALK_BUS,
+ Question2Waiting.WAIT, Question3Stay.HOTEL, Question4Wakeup.EARLY,
+ Question5Expense.EACH_PAYS, Question6Spend.SAVE,
+ List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT),
+ Question8Planning.FLEXIBLE, Question9Menu.SAFE,
+ Question10Companion.SITUATIONAL, Question11Photo.EYES_ONLY
+ );
+
+ Survey taxiSurvey = Survey.createSurvey(
+ testUser, Question1Transport.TAXI,
+ Question2Waiting.WAIT, Question3Stay.HOTEL, Question4Wakeup.EARLY,
+ Question5Expense.EACH_PAYS, Question6Spend.SAVE,
+ List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT),
+ Question8Planning.FLEXIBLE, Question9Menu.SAFE,
+ Question10Companion.SITUATIONAL, Question11Photo.EYES_ONLY
+ );
+
+ // when
+ TravelTendencyCalculator.TendencyScores walkBusScores = calculator.calculate(walkBusSurvey);
+ TravelTendencyCalculator.TendencyScores taxiScores = calculator.calculate(taxiSurvey);
+
+ // then
+ assertThat(walkBusScores.w()).isLessThan(taxiScores.w());
+ assertThat(walkBusScores.p()).isGreaterThan(taxiScores.p());
+ }
+
+ @Test
+ @DisplayName("Q7_선호활동_택3_누적합산_정상작동")
+ void Q7_선호활동_택3_누적합산_정상작동() {
+ // given
+ Survey survey = Survey.createSurvey(
+ testUser,
+ Question1Transport.WALK_BUS,
+ Question2Waiting.WAIT,
+ Question3Stay.JUST_SLEEP,
+ Question4Wakeup.EARLY,
+ Question5Expense.POOLED,
+ Question6Spend.SPLURGE,
+ List.of(Question7Interest.FOOD, Question7Interest.SHOPPING, Question7Interest.ACTIVITY),
+ Question8Planning.FLEXIBLE,
+ Question9Menu.CHECK_REVIEW,
+ Question10Companion.SITUATIONAL,
+ Question11Photo.MATCH_COMPANION
+ );
+
+ // when
+ TravelTendencyCalculator.TendencyScores scores = calculator.calculate(survey);
+
+ // then
+ assertThat(scores.r()).isGreaterThan(5.0);
+ assertThat(scores.p()).isGreaterThan(5.0);
+ }
+
+ @Test
+ @DisplayName("점수_범위_0점에서_10점으로_제한")
+ void 점수_범위_0점에서_10점으로_제한() {
+ // given
+ Survey minScoreSurvey = Survey.createSurvey(
+ testUser,
+ Question1Transport.WALK_BUS,
+ Question2Waiting.MOVE_ELSEWHERE,
+ Question3Stay.JUST_SLEEP,
+ Question4Wakeup.RELAXED,
+ Question5Expense.EACH_PAYS,
+ Question6Spend.SAVE,
+ List.of(Question7Interest.EXHIBITION, Question7Interest.NATURE, Question7Interest.RESORT),
+ Question8Planning.DETAILED,
+ Question9Menu.SAFE,
+ Question10Companion.US_ONLY,
+ Question11Photo.EYES_ONLY
+ );
+
+ // when
+ TravelTendencyCalculator.TendencyScores scores = calculator.calculate(minScoreSurvey);
+
+ // then
+ assertThat(scores.r()).isGreaterThanOrEqualTo(0.0);
+ assertThat(scores.w()).isGreaterThanOrEqualTo(0.0);
+ assertThat(scores.s()).isGreaterThanOrEqualTo(0.0);
+ assertThat(scores.p()).isGreaterThanOrEqualTo(0.0);
+ assertThat(scores.r()).isLessThanOrEqualTo(10.0);
+ assertThat(scores.w()).isLessThanOrEqualTo(10.0);
+ assertThat(scores.s()).isLessThanOrEqualTo(10.0);
+ assertThat(scores.p()).isLessThanOrEqualTo(10.0);
+ }
+
+ @Test
+ @DisplayName("Q10_완전환영선택시_S크게증가")
+ void Q10_완전환영선택시_S크게증가() {
+ // given
+ Survey welcomeSurvey = Survey.createSurvey(
+ testUser,
+ Question1Transport.WALK_BUS, Question2Waiting.WAIT, Question3Stay.HOTEL,
+ Question4Wakeup.EARLY, Question5Expense.EACH_PAYS, Question6Spend.SAVE,
+ List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT),
+ Question8Planning.FLEXIBLE, Question9Menu.SAFE,
+ Question10Companion.WELCOME,
+ Question11Photo.EYES_ONLY
+ );
+
+ Survey onlyUsSurvey = Survey.createSurvey(
+ testUser,
+ Question1Transport.WALK_BUS, Question2Waiting.WAIT, Question3Stay.HOTEL,
+ Question4Wakeup.EARLY, Question5Expense.EACH_PAYS, Question6Spend.SAVE,
+ List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT),
+ Question8Planning.FLEXIBLE, Question9Menu.SAFE,
+ Question10Companion.US_ONLY,
+ Question11Photo.EYES_ONLY
+ );
+
+ // when
+ TravelTendencyCalculator.TendencyScores welcomeScores = calculator.calculate(welcomeSurvey);
+ TravelTendencyCalculator.TendencyScores onlyUsScores = calculator.calculate(onlyUsSurvey);
+
+ // then
+ assertThat(welcomeScores.s()).isGreaterThan(onlyUsScores.s() + 4.0);
+ }
+}
diff --git a/src/test/java/com/dduru/gildongmu/user/service/UserServiceTest.java b/src/test/java/com/dduru/gildongmu/user/service/UserServiceTest.java
index 9635844c..48ce864c 100644
--- a/src/test/java/com/dduru/gildongmu/user/service/UserServiceTest.java
+++ b/src/test/java/com/dduru/gildongmu/user/service/UserServiceTest.java
@@ -1,8 +1,9 @@
package com.dduru.gildongmu.user.service;
-import com.dduru.gildongmu.user.dto.NicknameRandomResponse;
-import com.dduru.gildongmu.user.repository.UserRepository;
-import com.dduru.gildongmu.user.utils.NicknameGenerator;
+import com.dduru.gildongmu.profile.repository.ProfileRepository;
+import com.dduru.gildongmu.profile.dto.NicknameRandomResponse;
+import com.dduru.gildongmu.profile.service.ProfileService;
+import com.dduru.gildongmu.profile.utils.NicknameGenerator;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -23,10 +24,10 @@ class UserServiceTest {
private NicknameGenerator nicknameGenerator;
@Mock
- private UserRepository userRepository;
+ private ProfileRepository profileRepository;
@InjectMocks
- private UserService userService;
+ private ProfileService profileService;
@DisplayName("중복이 없는 경우 첫 시도에 랜덤 닉네임을 반환한다")
@Test
@@ -38,16 +39,16 @@ void randomNickname_firstTrySuccess() {
when(nicknameGenerator.generateBaseNickname()).thenReturn(baseNickname);
when(nicknameGenerator.generateRandomNumber()).thenReturn(randomNumber);
- when(userRepository.existsByNickname(expectedNickname)).thenReturn(false);
+ when(profileRepository.existsByNickname(expectedNickname)).thenReturn(false);
// when
- NicknameRandomResponse response = userService.generateRandomNickname();
+ NicknameRandomResponse response = profileService.generateRandomNickname();
// then
assertThat(response.nickname()).isEqualTo(expectedNickname);
verify(nicknameGenerator, times(1)).generateBaseNickname();
verify(nicknameGenerator, times(1)).generateRandomNumber();
- verify(userRepository, times(1)).existsByNickname(eq(expectedNickname));
+ verify(profileRepository, times(1)).existsByNickname(eq(expectedNickname));
}
@DisplayName("중복이 발생하면 재시도하여 다음 닉네임을 반환한다")
@@ -62,16 +63,16 @@ void randomNickname_retriesOnDuplicate() {
when(nicknameGenerator.generateBaseNickname()).thenReturn(baseNickname);
when(nicknameGenerator.generateRandomNumber()).thenReturn(firstNumber, secondNumber);
- when(userRepository.existsByNickname(firstNickname)).thenReturn(true);
- when(userRepository.existsByNickname(secondNickname)).thenReturn(false);
+ when(profileRepository.existsByNickname(firstNickname)).thenReturn(true);
+ when(profileRepository.existsByNickname(secondNickname)).thenReturn(false);
// when
- NicknameRandomResponse response = userService.generateRandomNickname();
+ NicknameRandomResponse response = profileService.generateRandomNickname();
// then
assertThat(response.nickname()).isEqualTo(secondNickname);
- verify(userRepository, times(1)).existsByNickname(eq(firstNickname));
- verify(userRepository, times(1)).existsByNickname(eq(secondNickname));
+ verify(profileRepository, times(1)).existsByNickname(eq(firstNickname));
+ verify(profileRepository, times(1)).existsByNickname(eq(secondNickname));
verify(nicknameGenerator, times(2)).generateRandomNumber();
}
@@ -85,16 +86,16 @@ void randomNickname_returnsFallbackAfterMaxAttempts() {
when(nicknameGenerator.generateBaseNickname()).thenReturn(baseNickname);
when(nicknameGenerator.generateRandomNumber()).thenReturn(duplicatedNumber);
- when(userRepository.existsByNickname(duplicatedNickname)).thenReturn(true);
+ when(profileRepository.existsByNickname(duplicatedNickname)).thenReturn(true);
// when
- NicknameRandomResponse response = userService.generateRandomNickname();
+ NicknameRandomResponse response = profileService.generateRandomNickname();
// then
assertThat(response.nickname()).startsWith("뚜비");
verify(nicknameGenerator, times(10)).generateBaseNickname();
verify(nicknameGenerator, times(10)).generateRandomNumber();
- verify(userRepository, times(10)).existsByNickname(eq(duplicatedNickname));
+ verify(profileRepository, times(10)).existsByNickname(eq(duplicatedNickname));
}
}
diff --git a/src/test/java/com/dduru/gildongmu/verification/service/PhoneVerificationServiceTest.java b/src/test/java/com/dduru/gildongmu/verification/service/PhoneVerificationServiceTest.java
index c44395e5..d9ad427f 100644
--- a/src/test/java/com/dduru/gildongmu/verification/service/PhoneVerificationServiceTest.java
+++ b/src/test/java/com/dduru/gildongmu/verification/service/PhoneVerificationServiceTest.java
@@ -1,7 +1,7 @@
package com.dduru.gildongmu.verification.service;
import com.dduru.gildongmu.common.jwt.JwtTokenProvider;
-import com.dduru.gildongmu.user.repository.UserRepository;
+import com.dduru.gildongmu.profile.repository.ProfileRepository;
import com.dduru.gildongmu.verification.dto.VerificationVerifyResponse;
import com.dduru.gildongmu.verification.exception.DuplicatePhoneNumberException;
import com.dduru.gildongmu.verification.exception.SmsSendFailedException;
@@ -25,7 +25,7 @@ class PhoneVerificationServiceTest {
@Mock
private JwtTokenProvider jwtTokenProvider;
@Mock
- private UserRepository userRepository;
+ private ProfileRepository profileRepository;
@InjectMocks
private PhoneVerificationService phoneVerificationService;
@@ -33,7 +33,7 @@ class PhoneVerificationServiceTest {
@Test
void 인증번호발송_가입된번호면_예외발생() {
// given
- when(userRepository.existsByPhoneNumber("01012345678")).thenReturn(true);
+ when(profileRepository.existsByPhoneNumber("01012345678")).thenReturn(true);
// when & then
assertThatThrownBy(() -> phoneVerificationService.sendVerificationCode("01012345678"))
@@ -45,7 +45,7 @@ class PhoneVerificationServiceTest {
@Test
void 인증번호발송_SMS실패시_롤백후_예외발생() {
// given
- when(userRepository.existsByPhoneNumber(anyString())).thenReturn(false);
+ when(profileRepository.existsByPhoneNumber(anyString())).thenReturn(false);
VerificationCodeService.VerificationCreateResult result =
new VerificationCodeService.VerificationCreateResult("999999", java.time.LocalDateTime.now().plusMinutes(3));
when(verificationCodeService.createVerification("01022223333")).thenReturn(result);