Repository: bottle-note/bottle-note-api-server
Pull request #360: Change of login api specification according to application of apple login
Author: login: chadongmin
Labels: name: 기능
description: 신규 기능
Created: 2025-04-01T15:15:22Z | Merged: 2025-04-02T12:47:56Z

Resolves #{이슈-번호}
#359 

# 해결하려는 문제가 무엇인가요?

- 애플 로그인의 요구사항을 적용합니다. 
> 최초 로그인시, email 과 id를 둘 다 받습니다.
이후 로그인시, 유저 id 만을 받습니다.
아이디는 001856.64db7e4847bc4e3895847ab884862ac4.0554 와 같은 형식의 string 입니다.
이에 따라, 최초 로그인시 id 와 email 을 BE 로 전달하고,
이후 로그인 시 id 를 전송하여 서버에 저장된 id, email 값과 매칭시켜 토큰 발급을 해주시면 될 것 같습니다



# 어떻게 해결했나요?

- switch case문을 사용해서 분기
- 중복된 로직을 추출

# 어떤 부분에 집중하여 리뷰해야 할까요?
**- 애플 로그인 시 최초에만 email, socialUniqueId를 주고, 이후부터는 email을 주지않아서 RequestDto의 email에 Validation 어노테이션을 제거해야함**
**- 애플이 아닌 다른 로그인에는 email이 필수값이기 때문에 어떻게 validation을 수행해야할지 고민중......**

---

# RCA 룰

r: 꼭 반영해 주세요. 적극적으로 고려해 주세요. (Request changes)  
c: 웬만하면 반영해 주세요. (Comment)  
a: 반영해도 좋고 넘어가도 좋습니다. 그냥 사소한 의견입니다. (Approve)


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
	- Enhanced social login functionality by using a unique identifier for improved security and streamlined authentication.
	- Improved error feedback during login, with clearer messaging for temporary login issues.
	- Added a new field for social unique ID in user records to support social login identification.
	- Introduced a method to retrieve users by their social unique ID.

- **Bug Fixes**
	- Resolved issues related to user login handling when social unique ID is missing.

- **Chores**
	- Adjusted test formatting for improved readability and consistency without altering functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/database/mysql/init/01-init-table.sql b/database/mysql/init/01-init-table.sql
index 2b4fe894a..a11b674ce 100644
--- a/database/mysql/init/01-init-table.sql
+++ b/database/mysql/init/01-init-table.sql
@@ -52,6 +52,7 @@ CREATE TABLE `alcohol`
 CREATE TABLE `users`
 (
     `id`             bigint       NOT NULL AUTO_INCREMENT COMMENT '사용자',
+	`social_unique_id` VARCHAR(255) NULL COMMENT '소셜 로그인 제공자의 고유 식별자',
     `email`          varchar(255) NOT NULL COMMENT '사용자 소셜 이메일',
     `password`       varchar(255) NULL COMMENT '사용자 비밀번호',
     `nick_name`      varchar(255) NOT NULL COMMENT '사용자 소셜 닉네임 ( 수정 가능 )',
@@ -66,7 +67,8 @@ CREATE TABLE `users`
     `last_modify_at` timestamp    NULL COMMENT '최종 생성일',
     PRIMARY KEY (`id`),
     UNIQUE KEY `email` (`email`),
-    UNIQUE KEY `nick_name` (`nick_name`)
+	UNIQUE KEY `nick_name` (`nick_name`),
+	UNIQUE KEY `social_unique_id` (`social_unique_id`)
 ) COMMENT = '사용자';
 
 CREATE TABLE `picks`
diff --git a/src/main/java/app/bottlenote/user/domain/User.java b/src/main/java/app/bottlenote/user/domain/User.java
index e30a4c855..d8baa7cc1 100644
--- a/src/main/java/app/bottlenote/user/domain/User.java
+++ b/src/main/java/app/bottlenote/user/domain/User.java
@@ -49,6 +49,10 @@ public class User extends BaseTimeEntity {
 	@GeneratedValue(strategy = GenerationType.IDENTITY)
 	private Long id;
 
+	@Comment("소셜 로그인 제공자의 고유 식별자")
+	@Column(name = "social_unique_id", unique = true)
+	private String socialUniqueId;
+
 	@Comment("사용자 이메일")
 	@Column(name = "email", nullable = false, unique = true)
 	private String email;
diff --git a/src/main/java/app/bottlenote/user/dto/request/OauthRequest.java b/src/main/java/app/bottlenote/user/dto/request/OauthRequest.java
index 888a52bee..cf1cfdf57 100644
--- a/src/main/java/app/bottlenote/user/dto/request/OauthRequest.java
+++ b/src/main/java/app/bottlenote/user/dto/request/OauthRequest.java
@@ -4,20 +4,22 @@
 import app.bottlenote.user.constant.SocialType;
 import jakarta.validation.constraints.Email;
 import jakarta.validation.constraints.Min;
-import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 
-public record OauthRequest
-	(@NotBlank(message = "EMAIL_NOT_BLANK")
-	 @Email(message = "EMAIL_PATTERN_NOT_VALID")
-	 String email,
+public record OauthRequest(
 
-	 @NotNull(message = "SOCIAL_TYPE_REQUIRED")
-	 SocialType socialType,
+		@Email(message = "EMAIL_PATTERN_NOT_VALID")
+		String email,
 
-	 GenderType gender,
-	 @Min(value = 0, message = "AGE_MINIMUM")
-	 Integer age
-	) {
+		String socialUniqueId,
+
+		@NotNull(message = "SOCIAL_TYPE_REQUIRED")
+		SocialType socialType,
+
+		GenderType gender,
+
+		@Min(value = 0, message = "AGE_MINIMUM")
+		Integer age
+) {
 
 }
diff --git a/src/main/java/app/bottlenote/user/exception/UserExceptionCode.java b/src/main/java/app/bottlenote/user/exception/UserExceptionCode.java
index 224472123..2a078898a 100644
--- a/src/main/java/app/bottlenote/user/exception/UserExceptionCode.java
+++ b/src/main/java/app/bottlenote/user/exception/UserExceptionCode.java
@@ -27,7 +27,8 @@ public enum UserExceptionCode implements ExceptionCode {
 	USER_NICKNAME_NOT_VALID(HttpStatus.BAD_REQUEST, "중복된 닉네임입니다."),
 	JSON_PARSING_EXCEPTION(HttpStatus.BAD_REQUEST, "JSON 처리 중 오류가 발생했습니다"),
 	NOT_MATCH_GUEST_CODE(HttpStatus.BAD_REQUEST, "게스트 코드가 일치하지 않습니다."),
-	INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다.");
+	INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."),
+	TEMPORARY_LOGIN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "일시적인 로그인 오류입니다.");
 	private final HttpStatus httpStatus;
 	private final String message;
 
diff --git a/src/main/java/app/bottlenote/user/repository/OauthRepository.java b/src/main/java/app/bottlenote/user/repository/OauthRepository.java
index d6b596a07..f1ec62dba 100644
--- a/src/main/java/app/bottlenote/user/repository/OauthRepository.java
+++ b/src/main/java/app/bottlenote/user/repository/OauthRepository.java
@@ -2,10 +2,11 @@
 
 import app.bottlenote.user.domain.User;
 import feign.Param;
-import java.util.Optional;
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.data.repository.CrudRepository;
 
+import java.util.Optional;
+
 public interface OauthRepository extends CrudRepository<User, Long> {
 
 	@Query(value =
@@ -18,6 +19,8 @@ AND JSON_CONTAINS(u.social_type, JSON_QUOTE(:socialType), '$')
 		nativeQuery = true)
 	Optional<User> findByEmailAndSocialType(@Param("email") String email, @Param("socialType") String socialType);
 
+	Optional<User> findBySocialUniqueId(String socialUniqueId);
+
 	Optional<User> findByNickName(String nickName);
 
 	Optional<User> findByEmail(String email);
diff --git a/src/main/java/app/bottlenote/user/service/OauthService.java b/src/main/java/app/bottlenote/user/service/OauthService.java
index 4fe6d58af..350fcf9a2 100644
--- a/src/main/java/app/bottlenote/user/service/OauthService.java
+++ b/src/main/java/app/bottlenote/user/service/OauthService.java
@@ -41,16 +41,51 @@ public class OauthService {
 	@Transactional
 	public TokenItem login(OauthRequest oauthReq) {
 		final String email = oauthReq.email();
+		final String socialUniqueId = oauthReq.socialUniqueId();
 		final SocialType socialType = oauthReq.socialType();
-		final GenderType genderType = oauthReq.gender();
-		final Integer age = oauthReq.age();
 
-		User user = oauthRepository.findByEmail(email).orElseGet(() -> oauthSignUp(email, socialType, genderType, age, UserType.ROLE_USER));
+		switch (socialType) {
+			case APPLE -> {
+				return doAppleLogin(oauthReq, socialUniqueId, email, socialType);
+			}
+			default -> {
+				return doLogin(oauthReq, email, socialType);
+			}
+		}
+	}
+
+	private TokenItem doLogin(OauthRequest oauthReq, String email, SocialType socialType) {
+		User user;
+		user = oauthRepository.findByEmail(email)
+				.orElseGet(() -> oauthSignUp(oauthReq, UserType.ROLE_USER));
+		checkActiveUser(user);
+		return getTokenItem(user, socialType);
+	}
+
+	private TokenItem doAppleLogin(OauthRequest oauthReq, String socialUniqueId, String email, SocialType socialType) {
+		User user;
+		//최초 로그인 (email과 socialUniqueId 모두 존재하는 경우)
+		if (socialUniqueId != null && !socialUniqueId.isBlank() && email != null && !email.isBlank()) {
+			user = oauthSignUp(oauthReq, UserType.ROLE_USER);
+		} else {
+			if (socialUniqueId == null) {
+				log.error("ERROR : socialUniqueId is null");
+				throw new UserException(UserExceptionCode.TEMPORARY_LOGIN_ERROR);
+			}
+			user = oauthRepository.findBySocialUniqueId(socialUniqueId).orElseThrow(
+					() -> new UserException(UserExceptionCode.USER_NOT_FOUND));
+		}
+		checkActiveUser(user);
+		return getTokenItem(user, socialType);
+	}
 
+	private void checkActiveUser(User user) {
 		if (Boolean.FALSE.equals(user.isAlive()))
 			throw new UserException(UserExceptionCode.USER_DELETED);
+	}
 
-		user.addSocialType(oauthReq.socialType());
+	private TokenItem getTokenItem(User user, SocialType socialType) {
+		user.addSocialType(socialType);
 		TokenItem token = tokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId());
 		user.updateRefreshToken(token.refreshToken());
 		return token;
@@ -59,7 +94,7 @@ public TokenItem login(OauthRequest oauthReq) {
 	@Transactional
 	public void restoreUser(String email, String password) {
 		User user = oauthRepository.findByEmail(email)
-			.orElseThrow(() -> new UserException(UserExceptionCode.USER_NOT_FOUND));
+				.orElseThrow(() -> new UserException(UserExceptionCode.USER_NOT_FOUND));
 
 		if (Boolean.TRUE.equals(user.isAlive()))
 			throw new UserException(UserExceptionCode.USER_ALREADY_EXISTS);
@@ -78,37 +113,30 @@ public void restoreUser(String email, String password) {
 	@Transactional
 	public String guestLogin() {
 		final int expireTime = 1000 * 60 * 60 * 24;
+		OauthRequest oauthRequest = new OauthRequest(
+				"guest" + UUID.randomUUID() + "@bottlenote.com",
+				"socialUniqueId" + UUID.randomUUID(),
+				SocialType.APPLE,
+				GenderType.MALE,
+				30);
 		User guest = oauthRepository.loadGuestUser()
-			.orElseGet(() ->
-				oauthSignUp("guest" + UUID.randomUUID() + "@bottlenote.com",
-					SocialType.APPLE,
-					GenderType.MALE,
-					30,
-					UserType.ROLE_GUEST
-				)
-			);
+				.orElseGet(() -> oauthSignUp(oauthRequest, UserType.ROLE_GUEST));
 		return tokenProvider.createGuestToken(
-			guest.getId(),
-			expireTime
+				guest.getId(),
+				expireTime
 		);
 	}
 
-	@Transactional
-	public User oauthSignUp(
-		String email,
-		SocialType socialType,
-		GenderType genderType,
-		Integer age,
-		UserType userType
-	) {
+	private User oauthSignUp(OauthRequest oauthRequest, UserType userType) {
 		User user = User.builder()
-			.email(email)
-			.socialType(List.of(socialType))
-			.role(userType)
-			.gender(genderType)
-			.age(age)
-			.nickName(generateNickname())
-			.build();
+				.email(oauthRequest.email())
+				.socialUniqueId(oauthRequest.socialUniqueId())
+				.socialType(List.of(oauthRequest.socialType()))
+				.role(userType)
+				.gender(oauthRequest.gender())
+				.age(oauthRequest.age())
+				.nickName(generateNickname())
+				.build();
 
 		return oauthRepository.save(user);
 	}
@@ -125,11 +153,11 @@ public TokenItem refresh(String refreshToken) {
 
 		//refresh Token DB에 존재하는지 검사
 		User user = oauthRepository.findByRefreshToken(refreshToken).orElseThrow(
-			() -> new UserException(INVALID_REFRESH_TOKEN)
+				() -> new UserException(INVALID_REFRESH_TOKEN)
 		);
 
 		TokenItem reissuedToken = tokenProvider.generateToken(user.getEmail(),
-			user.getRole(), user.getId());
+				user.getRole(), user.getId());
 
 		// DB에 저장된 refresh 토큰을 재발급한 refresh 토큰으로 업데이트
 		user.updateRefreshToken(reissuedToken.refreshToken());
@@ -145,25 +173,25 @@ public BasicAccountResponse basicSignup(String email, String password, Integer a
 
 		String encodePassword = passwordEncoder.encode(password);
 		User user = oauthRepository.save(User.builder()
-			.email(email)
-			.password(encodePassword)
-			.role(UserType.ROLE_USER)
-			.socialType(List.of(SocialType.BASIC))
-			.nickName(generateNickname())
-			.age(age)
-			.gender(gender)
-			.build());
+				.email(email)
+				.password(encodePassword)
+				.role(UserType.ROLE_USER)
+				.socialType(List.of(SocialType.BASIC))
+				.nickName(generateNickname())
+				.age(age)
+				.gender(gender)
+				.build());
 
 		TokenItem token = tokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId());
 		user.updateRefreshToken(token.refreshToken());
 
 		return BasicAccountResponse.builder()
-			.message(user.getNickName() + "님 환영합니다!")
-			.email(user.getEmail())
-			.nickname(user.getNickName())
-			.accessToken(token.accessToken())
-			.refreshToken(token.refreshToken())
-			.build();
+				.message(user.getNickName() + "님 환영합니다!")
+				.email(user.getEmail())
+				.nickname(user.getNickName())
+				.accessToken(token.accessToken())
+				.refreshToken(token.refreshToken())
+				.build();
 	}
 
 	@Transactional
@@ -173,8 +201,7 @@ public TokenItem basicLogin(String email, String password) {
 			throw new UserException(UserExceptionCode.INVALID_PASSWORD);
 		}
 
-		if (Boolean.FALSE.equals(user.isAlive()))
-			throw new UserException(UserExceptionCode.USER_DELETED);
+		checkActiveUser(user);
 
 		TokenItem token = tokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId());
 		user.updateRefreshToken(token.refreshToken());
@@ -198,7 +225,6 @@ public String verifyToken(String token) {
 			return validateToken ? "Token is valid" : "Token is invalid {empty}";
 		} catch (Exception e) {
 			log.error("Token is invalid : {}", e.getMessage());
-			//return "Token is invalid :{}" + e.getMessage();
 			return String.format("Token is invalid {%s}", e.getMessage());
 		}
 	}
diff --git a/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java b/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java
index 7dae37798..4bd7f4103 100644
--- a/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java
+++ b/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java
@@ -43,20 +43,20 @@ class AlcoholQueryIntegrationTest extends IntegrationTestSupport {
 	RatingTestFactory ratingTestFactory;
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-alcohol.sql"
+			"/init-script/init-user.sql",
+			"/init-script/init-alcohol.sql"
 	})
 	@DisplayName("알코올 목록조회를 할 수 있다.")
 	@Test
 	void test_1() throws Exception {
 
 		MvcResult result = mockMvc.perform(get("/api/v1/alcohols/search")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andReturn();
 		String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class);
 		AlcoholSearchResponse alcoholSearchResponse = mapper.convertValue(response.getData(), AlcoholSearchResponse.class);
@@ -67,9 +67,9 @@ void test_1() throws Exception {
 	}
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-alcohol.sql",
-		"/init-script/init-review.sql"
+			"/init-script/init-user.sql",
+			"/init-script/init-alcohol.sql",
+			"/init-script/init-review.sql"
 	})
 	@DisplayName("알코올 상세 조회를 할 수 있다.")
 	@Test
@@ -77,12 +77,12 @@ void test_2() throws Exception {
 		final Long alcoholId = 1L;
 
 		MvcResult result = mockMvc.perform(get("/api/v1/alcohols/{alcoholId}", alcoholId)
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andReturn();
 		String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class);
 		AlcoholDetailResponse alcoholDetail = mapper.convertValue(response.getData(), AlcoholDetailResponse.class);
@@ -107,24 +107,24 @@ void test_3() throws Exception {
 		ratingTestFactory.createRating(user2, alcohol, 3);
 
 		MvcResult authResult = mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(new OauthRequest(userEmail, userSocialType.get(0), null, null)))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(new OauthRequest(userEmail, null, userSocialType.get(0), null, null)))
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andReturn();
 		String contentAsString = authResult.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse tokenResponse = mapper.readValue(contentAsString, GlobalResponse.class);
 		JsonNode dataNode = mapper.convertValue(tokenResponse.getData(), JsonNode.class);
 		String accessToken = dataNode.get("accessToken").asText();
 
 		MvcResult result = mockMvc.perform(get("/api/v1/alcohols/{alcoholId}", alcoholId)
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + accessToken)
-				.with(csrf())
-			)
-			.andDo(print())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + accessToken)
+						.with(csrf())
+				)
+				.andDo(print())
+				.andReturn();
 		String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class);
 		AlcoholDetailResponse alcoholDetail = mapper.convertValue(response.getData(), AlcoholDetailResponse.class);
diff --git a/src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java b/src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java
index 34e2fdc4e..589ef04b6 100644
--- a/src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java
+++ b/src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java
@@ -43,10 +43,10 @@ class RestOauthControllerTest extends AbstractRestDocs {
 
 	public RestOauthControllerTest() {
 		this.config = OauthConfigProperties.builder()
-			.guestCode("TEST_GUEST_CODE")
-			.cookieExpireTime(123456)
-			.refreshTokenHeaderPrefix("refresh-token")
-			.build();
+				.guestCode("TEST_GUEST_CODE")
+				.cookieExpireTime(123456)
+				.refreshTokenHeaderPrefix("refresh-token")
+				.build();
 	}
 
 	@Override
@@ -60,53 +60,54 @@ protected Object initController() {
 	void login_test() throws Exception {
 
 		//given
-		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", SocialType.KAKAO, GenderType.MALE, 27);
+		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", "", SocialType.KAKAO, GenderType.MALE, 27);
 		TokenItem tokenItem = TokenItem.builder()
-			.accessToken("eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJn44WB44WBZ2dAbmF2ZXIuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjE2LCJpYXQiOjE3MTQ5NzU2MjMsImV4cCI6MTcxNDk3NjUyM30.41SuOBgmX-sd8nrMbC-xm0kH6rbny_SMYCKWE4rNQEZgSrRPS0HvYv0X7E-weo6sHlWWm1OmiQgHl4-uy6-9ig")
-			.refreshToken("eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJn44WB44WBZ2dAbmF2ZXIuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjE2LCJpYXQiOjE3MTQ5NzU2MjMsImV4cCI6MTcxNjE4NTIyM30.lvmPueUcOb1erv5Llo4qhEUQ_gtWrpFGbBHDw-Pi94qj8MGojoEI3ugdMo8PwoKgrVQZ_gBwBbytwjxh8XktUg")
-			.build();
+				.accessToken("eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJn44WB44WBZ2dAbmF2ZXIuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjE2LCJpYXQiOjE3MTQ5NzU2MjMsImV4cCI6MTcxNDk3NjUyM30.41SuOBgmX-sd8nrMbC-xm0kH6rbny_SMYCKWE4rNQEZgSrRPS0HvYv0X7E-weo6sHlWWm1OmiQgHl4-uy6-9ig")
+				.refreshToken("eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJn44WB44WBZ2dAbmF2ZXIuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjE2LCJpYXQiOjE3MTQ5NzU2MjMsImV4cCI6MTcxNjE4NTIyM30.lvmPueUcOb1erv5Llo4qhEUQ_gtWrpFGbBHDw-Pi94qj8MGojoEI3ugdMo8PwoKgrVQZ_gBwBbytwjxh8XktUg")
+				.build();
 
 		//when
 		when(oauthService.login(oauthRequest)).thenReturn(tokenItem);
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.with(csrf())
-				.content(objectMapper.writeValueAsString(oauthRequest)))
-			.andExpect(status().isOk())
-			.andExpect(cookie().exists("refresh-token"))
-
-			.andDo(
-				document("user/user-login",
-					requestFields(
-						fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
-						fieldWithPath("gender").type(JsonFieldType.STRING).description("성별")
-							.optional(),
-						fieldWithPath("age").type(JsonFieldType.NUMBER).description("나이")
-							.optional(),
-						fieldWithPath("socialType").type(JsonFieldType.STRING)
-							.description("소셜 로그인 타입")
-					),
-					responseFields(
-						fieldWithPath("success").type(JsonFieldType.BOOLEAN)
-							.description("응답 성공 여부"),
-						fieldWithPath("code").type(JsonFieldType.NUMBER)
-							.description("응답 코드(http status code)"),
-						fieldWithPath("data.accessToken").type(JsonFieldType.STRING)
-							.description("액세스 토큰"),
-						fieldWithPath("errors").type(JsonFieldType.ARRAY)
-							.description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"),
-						fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"),
-						fieldWithPath("meta.serverVersion").description("서버 버전"),
-						fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"),
-						fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")
-					),
-					responseHeaders(
-						headerWithName("Set-Cookie").description("리프레쉬 토큰")
-					)
-				)
-			);
+						.contentType(MediaType.APPLICATION_JSON)
+						.with(csrf())
+						.content(objectMapper.writeValueAsString(oauthRequest)))
+				.andExpect(status().isOk())
+				.andExpect(cookie().exists("refresh-token"))
+
+				.andDo(
+						document("user/user-login",
+								requestFields(
+										fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
+										fieldWithPath("socialUniqueId").type(JsonFieldType.STRING).description("소셜 로그인 제공자의 고유 식별자"),
+										fieldWithPath("gender").type(JsonFieldType.STRING).description("성별")
+												.optional(),
+										fieldWithPath("age").type(JsonFieldType.NUMBER).description("나이")
+												.optional(),
+										fieldWithPath("socialType").type(JsonFieldType.STRING)
+												.description("소셜 로그인 타입")
+								),
+								responseFields(
+										fieldWithPath("success").type(JsonFieldType.BOOLEAN)
+												.description("응답 성공 여부"),
+										fieldWithPath("code").type(JsonFieldType.NUMBER)
+												.description("응답 코드(http status code)"),
+										fieldWithPath("data.accessToken").type(JsonFieldType.STRING)
+												.description("액세스 토큰"),
+										fieldWithPath("errors").type(JsonFieldType.ARRAY)
+												.description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"),
+										fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"),
+										fieldWithPath("meta.serverVersion").description("서버 버전"),
+										fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"),
+										fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")
+								),
+								responseHeaders(
+										headerWithName("Set-Cookie").description("리프레쉬 토큰")
+								)
+						)
+				);
 	}
 
 	@Test
@@ -117,42 +118,42 @@ void reissue_test() throws Exception {
 		String request = "refresh-token";
 
 		TokenItem newTokenItem = TokenItem.builder()
-			.accessToken("new-access-token")
-			.refreshToken("new-refresh-token")
-			.build();
+				.accessToken("new-access-token")
+				.refreshToken("new-refresh-token")
+				.build();
 
 		//when
 		when(oauthService.refresh(request)).thenReturn(newTokenItem);
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/reissue")
-				.header("refresh-token", request)
-				.contentType(MediaType.APPLICATION_JSON)
-				.with(csrf()))
-			.andExpect(status().isOk())
-			.andExpect(cookie().exists("refresh-token"))
-
-			.andDo(
-				document("user/user-reissue",
-					responseFields(
-						fieldWithPath("success").type(JsonFieldType.BOOLEAN)
-							.description("응답 성공 여부"),
-						fieldWithPath("code").type(JsonFieldType.NUMBER)
-							.description("응답 코드(http status code)"),
-						fieldWithPath("data.accessToken").type(JsonFieldType.STRING)
-							.description("액세스 토큰"),
-						fieldWithPath("errors").type(JsonFieldType.ARRAY)
-							.description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"),
-						fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"),
-						fieldWithPath("meta.serverVersion").description("서버 버전"),
-						fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"),
-						fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")
-					),
-					responseHeaders(
-						headerWithName("Set-Cookie").description("리프레쉬 토큰")
-					)
-				)
-			);
+						.header("refresh-token", request)
+						.contentType(MediaType.APPLICATION_JSON)
+						.with(csrf()))
+				.andExpect(status().isOk())
+				.andExpect(cookie().exists("refresh-token"))
+
+				.andDo(
+						document("user/user-reissue",
+								responseFields(
+										fieldWithPath("success").type(JsonFieldType.BOOLEAN)
+												.description("응답 성공 여부"),
+										fieldWithPath("code").type(JsonFieldType.NUMBER)
+												.description("응답 코드(http status code)"),
+										fieldWithPath("data.accessToken").type(JsonFieldType.STRING)
+												.description("액세스 토큰"),
+										fieldWithPath("errors").type(JsonFieldType.ARRAY)
+												.description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"),
+										fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"),
+										fieldWithPath("meta.serverVersion").description("서버 버전"),
+										fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"),
+										fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")
+								),
+								responseHeaders(
+										headerWithName("Set-Cookie").description("리프레쉬 토큰")
+								)
+						)
+				);
 
 
 	}
@@ -170,27 +171,27 @@ void guest_login() throws Exception {
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/guest-login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(objectMapper.writeValueAsString(request))
-				.with(csrf()))
-			.andExpect(status().isOk())
-			.andDo(
-				document("user/guest-login",
-					requestFields(
-						fieldWithPath("code").description("게스트 코드")
-					),
-					responseFields(
-						fieldWithPath("success").ignored(),
-						fieldWithPath("code").ignored(),
-						fieldWithPath("errors").ignored(),
-						fieldWithPath("data.accessToken").description("액세스 토큰"),
-						fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"),
-						fieldWithPath("meta.serverVersion").description("서버 버전"),
-						fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"),
-						fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")
-					)
-				)
-			);
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(objectMapper.writeValueAsString(request))
+						.with(csrf()))
+				.andExpect(status().isOk())
+				.andDo(
+						document("user/guest-login",
+								requestFields(
+										fieldWithPath("code").description("게스트 코드")
+								),
+								responseFields(
+										fieldWithPath("success").ignored(),
+										fieldWithPath("code").ignored(),
+										fieldWithPath("errors").ignored(),
+										fieldWithPath("data.accessToken").description("액세스 토큰"),
+										fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"),
+										fieldWithPath("meta.serverVersion").description("서버 버전"),
+										fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"),
+										fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")
+								)
+						)
+				);
 
 
 	}
@@ -207,27 +208,27 @@ void token_validation() throws Exception {
 
 		//then
 		mockMvc.perform(put("/api/v1/oauth/token/verify")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(objectMapper.writeValueAsString(request))
-				.with(csrf()))
-			.andExpect(status().isOk())
-			.andDo(
-				document("user/token-verify",
-					requestFields(
-						fieldWithPath("token").description("검사할 토큰")
-					),
-					responseFields(
-						fieldWithPath("success").ignored(),
-						fieldWithPath("code").ignored(),
-						fieldWithPath("errors").ignored(),
-						fieldWithPath("data").description("결과"),
-						fieldWithPath("meta.serverEncoding").ignored(),
-						fieldWithPath("meta.serverVersion").ignored(),
-						fieldWithPath("meta.serverPathVersion").ignored(),
-						fieldWithPath("meta.serverResponseTime").ignored()
-					)
-				)
-			);
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(objectMapper.writeValueAsString(request))
+						.with(csrf()))
+				.andExpect(status().isOk())
+				.andDo(
+						document("user/token-verify",
+								requestFields(
+										fieldWithPath("token").description("검사할 토큰")
+								),
+								responseFields(
+										fieldWithPath("success").ignored(),
+										fieldWithPath("code").ignored(),
+										fieldWithPath("errors").ignored(),
+										fieldWithPath("data").description("결과"),
+										fieldWithPath("meta.serverEncoding").ignored(),
+										fieldWithPath("meta.serverVersion").ignored(),
+										fieldWithPath("meta.serverPathVersion").ignored(),
+										fieldWithPath("meta.serverResponseTime").ignored()
+								)
+						)
+				);
 	}
 
 	@Test
@@ -236,55 +237,55 @@ void basic_signup() throws Exception {
 		//given
 		final String nickName = "부드러운몰트";
 		final BasicAccountRequest request = BasicAccountRequest.builder()
-			.email("test@email.com")
-			.password("test-password")
-			.age(27)
-			.gender(null)
-			.build();
+				.email("test@email.com")
+				.password("test-password")
+				.age(27)
+				.gender(null)
+				.build();
 
 		final BasicAccountResponse response = BasicAccountResponse.builder()
-			.message(nickName + "님 환영합니다!")
-			.email(request.getEmail())
-			.nickname(nickName)
-			.accessToken("access-token")
-			.refreshToken("refresh-token")
-			.build();
+				.message(nickName + "님 환영합니다!")
+				.email(request.getEmail())
+				.nickname(nickName)
+				.accessToken("access-token")
+				.refreshToken("refresh-token")
+				.build();
 
 		//when
 		when(oauthService.basicSignup(request.getEmail(), request.getPassword(),
-			request.getAge(), request.getGender())).thenReturn(response);
+				request.getAge(), request.getGender())).thenReturn(response);
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/basic/signup")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(objectMapper.writeValueAsString(request))
-				.with(csrf()))
-			.andExpect(status().isOk())
-			.andDo(
-				document("user/basic-signup",
-					requestFields(
-						fieldWithPath("email").description("이메일"),
-						fieldWithPath("password").description("비밀번호"),
-						fieldWithPath("age").description("나이"),
-						fieldWithPath("gender").description("성별")
-					),
-					responseFields(
-						fieldWithPath("success").ignored(),
-						fieldWithPath("code").ignored(),
-						fieldWithPath("errors").ignored(),
-						fieldWithPath("data").description("결과"),
-						fieldWithPath("data.message").description("결과 메시지"),
-						fieldWithPath("data.email").description("이메일"),
-						fieldWithPath("data.nickname").description("닉네임"),
-						fieldWithPath("data.accessToken").description("accessToken"),
-						fieldWithPath("data.refreshToken").description("refreshToken"),
-						fieldWithPath("meta.serverEncoding").ignored(),
-						fieldWithPath("meta.serverVersion").ignored(),
-						fieldWithPath("meta.serverPathVersion").ignored(),
-						fieldWithPath("meta.serverResponseTime").ignored()
-					)
-				)
-			);
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(objectMapper.writeValueAsString(request))
+						.with(csrf()))
+				.andExpect(status().isOk())
+				.andDo(
+						document("user/basic-signup",
+								requestFields(
+										fieldWithPath("email").description("이메일"),
+										fieldWithPath("password").description("비밀번호"),
+										fieldWithPath("age").description("나이"),
+										fieldWithPath("gender").description("성별")
+								),
+								responseFields(
+										fieldWithPath("success").ignored(),
+										fieldWithPath("code").ignored(),
+										fieldWithPath("errors").ignored(),
+										fieldWithPath("data").description("결과"),
+										fieldWithPath("data.message").description("결과 메시지"),
+										fieldWithPath("data.email").description("이메일"),
+										fieldWithPath("data.nickname").description("닉네임"),
+										fieldWithPath("data.accessToken").description("accessToken"),
+										fieldWithPath("data.refreshToken").description("refreshToken"),
+										fieldWithPath("meta.serverEncoding").ignored(),
+										fieldWithPath("meta.serverVersion").ignored(),
+										fieldWithPath("meta.serverPathVersion").ignored(),
+										fieldWithPath("meta.serverResponseTime").ignored()
+								)
+						)
+				);
 	}
 
 	@Test
@@ -293,9 +294,9 @@ void basic_login() throws Exception {
 		//given
 		final String nickName = "부드러운몰트";
 		final BasicLoginRequest request = BasicLoginRequest.builder()
-			.email("test@email.com")
-			.password("test-password")
-			.build();
+				.email("test@email.com")
+				.password("test-password")
+				.build();
 
 		final TokenItem response = TokenItem.of("access-token", "refresh-token");
 		//when
@@ -303,29 +304,29 @@ void basic_login() throws Exception {
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/basic/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(objectMapper.writeValueAsString(request))
-				.with(csrf()))
-			.andExpect(status().isOk())
-			.andDo(
-				document("user/basic-login",
-					requestFields(
-						fieldWithPath("email").description("이메일"),
-						fieldWithPath("password").description("비밀번호")
-					),
-					responseFields(
-						fieldWithPath("success").ignored(),
-						fieldWithPath("code").ignored(),
-						fieldWithPath("errors").ignored(),
-						fieldWithPath("data").description("결과"),
-						fieldWithPath("data.accessToken").description("accessToken"),
-						fieldWithPath("meta.serverEncoding").ignored(),
-						fieldWithPath("meta.serverVersion").ignored(),
-						fieldWithPath("meta.serverPathVersion").ignored(),
-						fieldWithPath("meta.serverResponseTime").ignored()
-					)
-				)
-			);
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(objectMapper.writeValueAsString(request))
+						.with(csrf()))
+				.andExpect(status().isOk())
+				.andDo(
+						document("user/basic-login",
+								requestFields(
+										fieldWithPath("email").description("이메일"),
+										fieldWithPath("password").description("비밀번호")
+								),
+								responseFields(
+										fieldWithPath("success").ignored(),
+										fieldWithPath("code").ignored(),
+										fieldWithPath("errors").ignored(),
+										fieldWithPath("data").description("결과"),
+										fieldWithPath("data.accessToken").description("accessToken"),
+										fieldWithPath("meta.serverEncoding").ignored(),
+										fieldWithPath("meta.serverVersion").ignored(),
+										fieldWithPath("meta.serverPathVersion").ignored(),
+										fieldWithPath("meta.serverResponseTime").ignored()
+								)
+						)
+				);
 	}
 
 	@Test
@@ -335,33 +336,33 @@ void restore() throws Exception {
 		final String email = "test-email";
 		final String password = "test-password";
 		var request = BasicLoginRequest.builder()
-			.email(email)
-			.password(password)
-			.build();
+				.email(email)
+				.password(password)
+				.build();
 
 		doNothing().when(oauthService).restoreUser(email, password);
 
 		mockMvc.perform(post("/api/v1/oauth/restore")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(objectMapper.writeValueAsString(request))
-				.with(csrf()))
-			.andExpect(status().isOk())
-			.andDo(
-				document("user/restore",
-					requestFields(
-						fieldWithPath("email").description("이메일"),
-						fieldWithPath("password").description("비밀번호")
-					),
-					responseFields(
-						fieldWithPath("success").ignored(),
-						fieldWithPath("code").ignored(),
-						fieldWithPath("errors").ignored(),
-						fieldWithPath("data").description("결과 메시지"),
-						fieldWithPath("meta.serverEncoding").ignored(),
-						fieldWithPath("meta.serverVersion").ignored(),
-						fieldWithPath("meta.serverPathVersion").ignored(),
-						fieldWithPath("meta.serverResponseTime").ignored()
-					)
-				));
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(objectMapper.writeValueAsString(request))
+						.with(csrf()))
+				.andExpect(status().isOk())
+				.andDo(
+						document("user/restore",
+								requestFields(
+										fieldWithPath("email").description("이메일"),
+										fieldWithPath("password").description("비밀번호")
+								),
+								responseFields(
+										fieldWithPath("success").ignored(),
+										fieldWithPath("code").ignored(),
+										fieldWithPath("errors").ignored(),
+										fieldWithPath("data").description("결과 메시지"),
+										fieldWithPath("meta.serverEncoding").ignored(),
+										fieldWithPath("meta.serverVersion").ignored(),
+										fieldWithPath("meta.serverPathVersion").ignored(),
+										fieldWithPath("meta.serverResponseTime").ignored()
+								)
+						));
 	}
 }
diff --git a/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java b/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java
index 4e62aa4f8..8aefdf5e6 100644
--- a/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java
+++ b/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java
@@ -39,16 +39,16 @@ class JpaAuditingIntegrationTest extends IntegrationTestSupport {
 
 	@BeforeEach
 	void setUp() {
-		oauthRequest = new OauthRequest("chadongmin@naver.com", SocialType.KAKAO, null, null);
+		oauthRequest = new OauthRequest("chadongmin@naver.com", null, SocialType.KAKAO, null, null);
 		reviewCreateRequest = ReviewObjectFixture.getReviewCreateRequest();
 	}
 
 	@DisplayName("DB 저장 시 생성자와 수정자가 기록된다.")
 	@Sql(scripts = {
-		"/init-script/init-alcohol.sql",
-		"/init-script/init-user.sql",
-		"/init-script/init-review.sql",
-		"/init-script/init-review-reply.sql"}
+			"/init-script/init-alcohol.sql",
+			"/init-script/init-user.sql",
+			"/init-script/init-review.sql",
+			"/init-script/init-review-reply.sql"}
 	)
 	@Test
 	void test_1() throws Exception {
@@ -56,16 +56,16 @@ void test_1() throws Exception {
 
 		// when
 		MvcResult result = mockMvc.perform(post("/api/v1/reviews")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(reviewCreateRequest))
-				.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(reviewCreateRequest))
+						.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class);
diff --git a/src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java b/src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java
index 5527ddaf1..904b9aa7f 100644
--- a/src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java
+++ b/src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java
@@ -58,7 +58,7 @@ class HelpIntegrationTest extends IntegrationTestSupport {
 
 	@BeforeEach
 	void setUp() {
-		oauthRequest = new OauthRequest("chadongmin@naver.com", SocialType.KAKAO, null, null);
+		oauthRequest = new OauthRequest("chadongmin@naver.com", null, SocialType.KAKAO, null, null);
 		helpUpsertRequest = HelpObjectFixture.getHelpUpsertRequest();
 
 	}
@@ -72,15 +72,15 @@ void test_1() throws Exception {
 		helpUpsertRequest = new HelpUpsertRequest("로그인이 안돼요", null, List.of(new HelpImageItem(1L, "https://test.com")));
 		// given when
 		mockMvc.perform(post("/api/v1/help")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsBytes(helpUpsertRequest))
-				.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isBadRequest())
-			.andExpect(jsonPath("$.errors[?(@.code == 'REQUIRED_HELP_TYPE')].status").value(error.status().name()))
-			.andExpect(jsonPath("$.errors[?(@.code == 'REQUIRED_HELP_TYPE')].message").value(error.message()));
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsBytes(helpUpsertRequest))
+						.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isBadRequest())
+				.andExpect(jsonPath("$.errors[?(@.code == 'REQUIRED_HELP_TYPE')].status").value(error.status().name()))
+				.andExpect(jsonPath("$.errors[?(@.code == 'REQUIRED_HELP_TYPE')].message").value(error.message()));
 	}
 
 	@Nested
@@ -92,16 +92,16 @@ class HelpRegisterControllerIntegrationTest extends IntegrationTestSupport {
 		void test_1() throws Exception {
 			// given when
 			MvcResult result = mockMvc.perform(post("/api/v1/help")
-					.contentType(MediaType.APPLICATION_JSON)
-					.content(mapper.writeValueAsBytes(helpUpsertRequest))
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isOk())
-				.andExpect(jsonPath("$.code").value(200))
-				.andExpect(jsonPath("$.data").exists())
-				.andReturn();
+							.contentType(MediaType.APPLICATION_JSON)
+							.content(mapper.writeValueAsBytes(helpUpsertRequest))
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isOk())
+					.andExpect(jsonPath("$.code").value(200))
+					.andExpect(jsonPath("$.data").exists())
+					.andReturn();
 
 			String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 			GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class);
@@ -113,8 +113,8 @@ void test_1() throws Exception {
 	}
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-help.sql"})
+			"/init-script/init-user.sql",
+			"/init-script/init-help.sql"})
 	@Nested
 	@DisplayName("[Integration] 문의글 조회 통합테스트")
 	class HelpReadIntegrationTest extends IntegrationTestSupport {
@@ -125,15 +125,15 @@ void test_1() throws Exception {
 			// given
 
 			MvcResult result = mockMvc.perform(get("/api/v1/help")
-					.contentType(MediaType.APPLICATION_JSON)
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isOk())
-				.andExpect(jsonPath("$.code").value(200))
-				.andExpect(jsonPath("$.data").exists())
-				.andReturn();
+							.contentType(MediaType.APPLICATION_JSON)
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isOk())
+					.andExpect(jsonPath("$.code").value(200))
+					.andExpect(jsonPath("$.data").exists())
+					.andReturn();
 
 			String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 			GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class);
@@ -148,15 +148,15 @@ void test_2() throws Exception {
 			// given
 
 			MvcResult result = mockMvc.perform(get("/api/v1/help/{helpId}", 1L)
-					.contentType(MediaType.APPLICATION_JSON)
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isOk())
-				.andExpect(jsonPath("$.code").value(200))
-				.andExpect(jsonPath("$.data").exists())
-				.andReturn();
+							.contentType(MediaType.APPLICATION_JSON)
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isOk())
+					.andExpect(jsonPath("$.code").value(200))
+					.andExpect(jsonPath("$.data").exists())
+					.andReturn();
 
 			String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 			GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class);
@@ -167,8 +167,8 @@ void test_2() throws Exception {
 	}
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-help.sql"})
+			"/init-script/init-user.sql",
+			"/init-script/init-help.sql"})
 	@Nested
 	@DisplayName("[Integration] 문의글 수정 통합테스트")
 	class HelpModifyIntegrationTest extends IntegrationTestSupport {
@@ -179,16 +179,16 @@ void test_1() throws Exception {
 			// given when
 			long helpId = 1L;
 			MvcResult result = mockMvc.perform(patch("/api/v1/help/{helpId}", helpId)
-					.contentType(MediaType.APPLICATION_JSON)
-					.content(mapper.writeValueAsBytes(helpUpsertRequest))
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isOk())
-				.andExpect(jsonPath("$.code").value(200))
-				.andExpect(jsonPath("$.data").exists())
-				.andReturn();
+							.contentType(MediaType.APPLICATION_JSON)
+							.content(mapper.writeValueAsBytes(helpUpsertRequest))
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isOk())
+					.andExpect(jsonPath("$.code").value(200))
+					.andExpect(jsonPath("$.data").exists())
+					.andReturn();
 
 			String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 			GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class);
@@ -203,19 +203,19 @@ void test_1() throws Exception {
 		void test_2() throws Exception {
 			// given when
 			long helpId = 1L;
-			oauthRequest = new OauthRequest("test@naver.com", SocialType.KAKAO, null, null);
+			oauthRequest = new OauthRequest("test@naver.com", null, SocialType.KAKAO, null, null);
 			Error error = Error.of(HELP_NOT_AUTHORIZED);
 
 			mockMvc.perform(patch("/api/v1/help/{helpId}", helpId)
-					.contentType(MediaType.APPLICATION_JSON)
-					.content(mapper.writeValueAsBytes(helpUpsertRequest))
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isUnauthorized())
-				.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].status").value(error.status().name()))
-				.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].message").value(error.message()));
+							.contentType(MediaType.APPLICATION_JSON)
+							.content(mapper.writeValueAsBytes(helpUpsertRequest))
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isUnauthorized())
+					.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].status").value(error.status().name()))
+					.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].message").value(error.message()));
 		}
 
 		@DisplayName("존재하지 않는 문의글을 수정할 수 없다.")
@@ -223,19 +223,19 @@ void test_2() throws Exception {
 		void test_3() throws Exception {
 			// given when
 			long helpId = -1L;
-			oauthRequest = new OauthRequest("test@naver.com", SocialType.KAKAO, null, null);
+			oauthRequest = new OauthRequest("test@naver.com", null, SocialType.KAKAO, null, null);
 			Error error = Error.of(HELP_NOT_FOUND);
 
 			mockMvc.perform(patch("/api/v1/help/{helpId}", helpId)
-					.contentType(MediaType.APPLICATION_JSON)
-					.content(mapper.writeValueAsBytes(helpUpsertRequest))
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isBadRequest())
-				.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].status").value(error.status().name()))
-				.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].message").value(error.message()));
+							.contentType(MediaType.APPLICATION_JSON)
+							.content(mapper.writeValueAsBytes(helpUpsertRequest))
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isBadRequest())
+					.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].status").value(error.status().name()))
+					.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].message").value(error.message()));
 		}
 
 		@DisplayName("Not null 필드에 null이 할당되면 예외를 반환한다.")
@@ -248,21 +248,21 @@ void test_4() throws Exception {
 			helpUpsertRequest = new HelpUpsertRequest(null, HelpType.USER, List.of(new HelpImageItem(1L, "https://test.com")));
 			// given when
 			mockMvc.perform(patch("/api/v1/help/{helpId}", helpId)
-					.contentType(MediaType.APPLICATION_JSON)
-					.content(mapper.writeValueAsBytes(helpUpsertRequest))
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isBadRequest())
-				.andExpect(jsonPath("$.errors[?(@.code == 'CONTENT_NOT_EMPTY')].status").value(error.status().name()))
-				.andExpect(jsonPath("$.errors[?(@.code == 'CONTENT_NOT_EMPTY')].message").value(error.message()));
+							.contentType(MediaType.APPLICATION_JSON)
+							.content(mapper.writeValueAsBytes(helpUpsertRequest))
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isBadRequest())
+					.andExpect(jsonPath("$.errors[?(@.code == 'CONTENT_NOT_EMPTY')].status").value(error.status().name()))
+					.andExpect(jsonPath("$.errors[?(@.code == 'CONTENT_NOT_EMPTY')].message").value(error.message()));
 		}
 	}
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-help.sql"})
+			"/init-script/init-user.sql",
+			"/init-script/init-help.sql"})
 	@Nested
 	@DisplayName("[Integration] 문의글 수정 통합테스트")
 	class HelpDeleteIntegrationTest extends IntegrationTestSupport {
@@ -273,15 +273,15 @@ void test_1() throws Exception {
 			// given when
 			long helpId = 1L;
 			MvcResult result = mockMvc.perform(delete("/api/v1/help/{helpId}", helpId)
-					.contentType(MediaType.APPLICATION_JSON)
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isOk())
-				.andExpect(jsonPath("$.code").value(200))
-				.andExpect(jsonPath("$.data").exists())
-				.andReturn();
+							.contentType(MediaType.APPLICATION_JSON)
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isOk())
+					.andExpect(jsonPath("$.code").value(200))
+					.andExpect(jsonPath("$.data").exists())
+					.andReturn();
 
 			String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 			GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class);
@@ -296,18 +296,18 @@ void test_1() throws Exception {
 		void test_2() throws Exception {
 			// given when
 			long helpId = -1L;
-			oauthRequest = new OauthRequest("test@naver.com", SocialType.KAKAO, null, null);
+			oauthRequest = new OauthRequest("test@naver.com", null, SocialType.KAKAO, null, null);
 			Error error = Error.of(HELP_NOT_FOUND);
 
 			mockMvc.perform(delete("/api/v1/help/{helpId}", helpId)
-					.contentType(MediaType.APPLICATION_JSON)
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isBadRequest())
-				.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].status").value(error.status().name()))
-				.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].message").value(error.message()));
+							.contentType(MediaType.APPLICATION_JSON)
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isBadRequest())
+					.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].status").value(error.status().name()))
+					.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].message").value(error.message()));
 		}
 
 		@DisplayName("유저 본인이 작성한 글이 아니면 문의글을 삭제할 수 없다.")
@@ -315,18 +315,18 @@ void test_2() throws Exception {
 		void test_3() throws Exception {
 			// given when
 			long helpId = 1L;
-			oauthRequest = new OauthRequest("test@naver.com", SocialType.KAKAO, null, null);
+			oauthRequest = new OauthRequest("test@naver.com", null, SocialType.KAKAO, null, null);
 			Error error = Error.of(HELP_NOT_AUTHORIZED);
 
 			mockMvc.perform(delete("/api/v1/help/{helpId}", helpId)
-					.contentType(MediaType.APPLICATION_JSON)
-					.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
-					.with(csrf())
-				)
-				.andDo(print())
-				.andExpect(status().isUnauthorized())
-				.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].status").value(error.status().name()))
-				.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].message").value(error.message()));
+							.contentType(MediaType.APPLICATION_JSON)
+							.header("Authorization", "Bearer " + getToken(oauthRequest).accessToken())
+							.with(csrf())
+					)
+					.andDo(print())
+					.andExpect(status().isUnauthorized())
+					.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].status").value(error.status().name()))
+					.andExpect(jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].message").value(error.message()));
 		}
 	}
 }
diff --git a/src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java b/src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java
index 31526a42e..56b81f4d5 100644
--- a/src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java
+++ b/src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java
@@ -52,9 +52,9 @@ class ReportIntegrationTest extends IntegrationTestSupport {
 	@DisplayName("리뷰를 신고할 수 있다.")
 	@Test
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-alcohol.sql",
-		"/init-script/init-review.sql"
+			"/init-script/init-user.sql",
+			"/init-script/init-alcohol.sql",
+			"/init-script/init-review.sql"
 	})
 	void test_1() throws Exception {
 		// given
@@ -62,16 +62,16 @@ void test_1() throws Exception {
 
 		final Long reviewReportId = 1L;
 		MvcResult result = mockMvc.perform(post("/api/v1/reports/review")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(reviewReportRequest))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(reviewReportRequest))
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		ReviewReport saved = reviewReportRepository.findById(reviewReportId).orElse(null);
 		assertNotNull(saved);
@@ -86,25 +86,25 @@ void test_1() throws Exception {
 	@DisplayName("유저는 하나의 리뷰에 대해 한번만 신고할 수 있다.")
 	@Test
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-alcohol.sql",
-		"/init-script/init-review.sql"
+			"/init-script/init-user.sql",
+			"/init-script/init-alcohol.sql",
+			"/init-script/init-review.sql"
 	})
 	void test_2() throws Exception {
 		// given
 		ReviewReportRequest reviewReportRequest = new ReviewReportRequest(1L, ADVERTISEMENT, "이 리뷰는 광고 리뷰입니다.");
 
 		MvcResult result = mockMvc.perform(post("/api/v1/reports/review")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(reviewReportRequest))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(reviewReportRequest))
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		ReviewReport saved = reviewReportRepository.findById(reviewReportRequest.reportReviewId()).orElse(null);
 		assertNotNull(saved);
@@ -118,24 +118,24 @@ void test_2() throws Exception {
 		Error error = Error.of(ALREADY_REPORTED_REVIEW);
 
 		mockMvc.perform(post("/api/v1/reports/review")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(reviewReportRequest))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isBadRequest())
-			.andExpect(jsonPath("$.code").value(400))
-			.andExpect(jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].status").value(error.status().name()))
-			.andExpect(jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].message").value(error.message()));
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(reviewReportRequest))
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isBadRequest())
+				.andExpect(jsonPath("$.code").value(400))
+				.andExpect(jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].status").value(error.status().name()))
+				.andExpect(jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].message").value(error.message()));
 	}
 
 	@DisplayName("서로 다른 IP로 5개의 신고가 누적되면 리뷰가 비활성화 된다.")
 	@Test
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-alcohol.sql",
-		"/init-script/init-review.sql"
+			"/init-script/init-user.sql",
+			"/init-script/init-alcohol.sql",
+			"/init-script/init-review.sql"
 	})
 	void test_3() throws Exception {
 		// given
@@ -143,27 +143,27 @@ void test_3() throws Exception {
 
 		IntStream.range(1, 5).forEach((int i) -> {
 			ReviewReport reviewReport = ReviewReport.builder()
-				.reviewId(reviewReportRequest.reportReviewId())
-				.ipAddress(String.valueOf(i))
-				.type(ADVERTISEMENT)
-				.userId((long) i)
-				.reportContent("이 리뷰는 광고 리뷰입니다.")
-				.build();
+					.reviewId(reviewReportRequest.reportReviewId())
+					.ipAddress(String.valueOf(i))
+					.type(ADVERTISEMENT)
+					.userId((long) i)
+					.reportContent("이 리뷰는 광고 리뷰입니다.")
+					.build();
 			reviewReportRepository.save(reviewReport);
 		});
 		Review beforeReview = reviewRepository.findById(reviewReportRequest.reportReviewId()).orElse(null);
 
 		MvcResult authResult = mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(new OauthRequest("test@test.com", SocialType.KAKAO, null, null)))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(new OauthRequest("test@test.com", null, SocialType.KAKAO, null, null)))
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		String contentAsString = authResult.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class);
@@ -171,16 +171,16 @@ void test_3() throws Exception {
 		String accessToken = dataNode.get("accessToken").asText();
 
 		MvcResult result = mockMvc.perform(post("/api/v1/reports/review")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(reviewReportRequest))
-				.header("Authorization", "Bearer " + accessToken)
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(reviewReportRequest))
+						.header("Authorization", "Bearer " + accessToken)
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		ReviewReport savedReport = reviewReportRepository.findById(reviewReportRequest.reportReviewId()).orElse(null);
 		assertNotNull(savedReport);
@@ -204,24 +204,24 @@ void test_3() throws Exception {
 	@DisplayName("유저를 신고할 수 있다.")
 	@Test
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-alcohol.sql",
-		"/init-script/init-review.sql"
+			"/init-script/init-user.sql",
+			"/init-script/init-alcohol.sql",
+			"/init-script/init-review.sql"
 	})
 	void test_4() throws Exception {
 		UserReportRequest userReportRequest = new UserReportRequest(2L, UserReportType.FRAUD, "아주 나쁜놈이에요 신고합니다.");
 
 		MvcResult result = mockMvc.perform(post("/api/v1/reports/user")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(userReportRequest))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(userReportRequest))
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class);
diff --git a/src/test/java/app/bottlenote/user/controller/OauthControllerTest.java b/src/test/java/app/bottlenote/user/controller/OauthControllerTest.java
index 3626e4bb0..3e4d7bce0 100644
--- a/src/test/java/app/bottlenote/user/controller/OauthControllerTest.java
+++ b/src/test/java/app/bottlenote/user/controller/OauthControllerTest.java
@@ -52,9 +52,9 @@ class OauthControllerTest {
 	@BeforeEach
 	void setUp() {
 		tokenItem = TokenItem.builder()
-			.accessToken("access-token")
-			.refreshToken("refresh-token")
-			.build();
+				.accessToken("access-token")
+				.refreshToken("refresh-token")
+				.build();
 		oauthConfigProperties.printConfigs();
 	}
 
@@ -64,30 +64,30 @@ void setUp() {
 	void user_login_test() throws Exception {
 
 		//given
-		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", SocialType.KAKAO,
-			GenderType.MALE,
-			27);
+		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", null, SocialType.KAKAO,
+				GenderType.MALE,
+				27);
 
 		TokenItem tokenItem = TokenItem.builder()
-			.accessToken("accessToken")
-			.refreshToken("refreshToken")
-			.build();
+				.accessToken("accessToken")
+				.refreshToken("refreshToken")
+				.build();
 
 		//when
 		when(oauthService.login(oauthRequest)).thenReturn(tokenItem);
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.with(csrf())
-				.content(mapper.writeValueAsString(oauthRequest)))
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value("200"))
-			.andExpect(jsonPath("$.data.accessToken").value("accessToken"))
-			.andExpect(cookie().value("refresh-token", "refreshToken"))
-			.andExpect(cookie().httpOnly("refresh-token", true))
-			.andExpect(cookie().secure("refresh-token", true))
-			.andDo(print());
+						.contentType(MediaType.APPLICATION_JSON)
+						.with(csrf())
+						.content(mapper.writeValueAsString(oauthRequest)))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value("200"))
+				.andExpect(jsonPath("$.data.accessToken").value("accessToken"))
+				.andExpect(cookie().value("refresh-token", "refreshToken"))
+				.andExpect(cookie().httpOnly("refresh-token", true))
+				.andExpect(cookie().secure("refresh-token", true))
+				.andDo(print());
 	}
 
 	@Test
@@ -97,8 +97,8 @@ void user_login_fail_when_socialType_is_null() throws Exception {
 		Error error = Error.of(ValidExceptionCode.SOCIAL_TYPE_REQUIRED);
 
 		//given
-		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", null,
-			GenderType.MALE, 27);
+		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", null, null,
+				GenderType.MALE, 27);
 
 
 		//when
@@ -106,13 +106,13 @@ void user_login_fail_when_socialType_is_null() throws Exception {
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.with(csrf())
-				.content(mapper.writeValueAsString(oauthRequest)))
-			.andExpect(status().isBadRequest())
-			.andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code())))
-			.andExpect(jsonPath("$.errors[0].status").value(error.status().name()))
-			.andExpect(jsonPath("$.errors[0].message").value(error.message()));
+						.contentType(MediaType.APPLICATION_JSON)
+						.with(csrf())
+						.content(mapper.writeValueAsString(oauthRequest)))
+				.andExpect(status().isBadRequest())
+				.andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code())))
+				.andExpect(jsonPath("$.errors[0].status").value(error.status().name()))
+				.andExpect(jsonPath("$.errors[0].message").value(error.message()));
 	}
 
 	@Test
@@ -121,21 +121,21 @@ void user_login_fail_when_gender_is_null() throws Exception {
 		Error error = Error.of(ValidExceptionCode.AGE_MINIMUM);
 
 		//given
-		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", SocialType.KAKAO,
-			GenderType.MALE, -10);
+		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", null, SocialType.KAKAO,
+				GenderType.MALE, -10);
 
 		//when
 		when(oauthService.login(oauthRequest)).thenReturn(tokenItem);
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.with(csrf())
-				.content(mapper.writeValueAsString(oauthRequest)))
-			.andExpect(status().isBadRequest())
-			.andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code())))
-			.andExpect(jsonPath("$.errors[0].status").value(error.status().name()))
-			.andExpect(jsonPath("$.errors[0].message").value(error.message()));
+						.contentType(MediaType.APPLICATION_JSON)
+						.with(csrf())
+						.content(mapper.writeValueAsString(oauthRequest)))
+				.andExpect(status().isBadRequest())
+				.andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code())))
+				.andExpect(jsonPath("$.errors[0].status").value(error.status().name()))
+				.andExpect(jsonPath("$.errors[0].message").value(error.message()));
 	}
 
 	@Test
@@ -146,26 +146,26 @@ void user_reissue_test() throws Exception {
 		String reissueRefreshToken = "refresh-token";
 
 		TokenItem newTokenItem = TokenItem.builder()
-			.accessToken("new-access-token")
-			.refreshToken("new-refresh-token")
-			.build();
+				.accessToken("new-access-token")
+				.refreshToken("new-refresh-token")
+				.build();
 
 		//when
 		when(oauthService.refresh(reissueRefreshToken)).thenReturn(newTokenItem);
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/reissue")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("refresh-token", reissueRefreshToken)
-				.with(csrf())
-				.content(mapper.writeValueAsString(reissueRefreshToken)))
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value("200"))
-			.andExpect(jsonPath("$.data.accessToken").value("new-access-token"))
-			.andExpect(cookie().value("refresh-token", "new-refresh-token"))
-			.andExpect(cookie().httpOnly("refresh-token", true))
-			.andExpect(cookie().secure("refresh-token", true))
-			.andDo(print());
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("refresh-token", reissueRefreshToken)
+						.with(csrf())
+						.content(mapper.writeValueAsString(reissueRefreshToken)))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value("200"))
+				.andExpect(jsonPath("$.data.accessToken").value("new-access-token"))
+				.andExpect(cookie().value("refresh-token", "new-refresh-token"))
+				.andExpect(cookie().httpOnly("refresh-token", true))
+				.andExpect(cookie().secure("refresh-token", true))
+				.andDo(print());
 	}
 
 	@Test
@@ -183,22 +183,22 @@ void user_reissue_fail_when_refreshToken_is_not_invalid() throws Exception {
 		String reissueRefreshToken = "refresh-tokenxzz";
 
 		TokenItem newTokenItem = TokenItem.builder()
-			.accessToken("new-access-token")
-			.refreshToken("new-refresh-token")
-			.build();
+				.accessToken("new-access-token")
+				.refreshToken("new-refresh-token")
+				.build();
 		//when
 		when(oauthService.refresh(reissueRefreshToken)).thenThrow(
-			new UserException(UserExceptionCode.INVALID_REFRESH_TOKEN));
+				new UserException(UserExceptionCode.INVALID_REFRESH_TOKEN));
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/reissue")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("refresh-token", reissueRefreshToken)
-				.with(csrf()))
-			.andExpect(status().isUnauthorized()).andDo(print())
-			.andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code())))
-			.andExpect(jsonPath("$.errors[0].status").value(error.status().name()))
-			.andExpect(jsonPath("$.errors[0].message").value(error.message()));
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("refresh-token", reissueRefreshToken)
+						.with(csrf()))
+				.andExpect(status().isUnauthorized()).andDo(print())
+				.andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code())))
+				.andExpect(jsonPath("$.errors[0].status").value(error.status().name()))
+				.andExpect(jsonPath("$.errors[0].message").value(error.message()));
 
 	}
 
@@ -209,16 +209,16 @@ void user_reissue_fail_when_refreshToken_is_null() throws Exception {
 
 		// when
 		when(oauthService.refresh(reissueRefreshToken)).thenThrow(
-			new IllegalArgumentException("Refresh token is missing"));
+				new IllegalArgumentException("Refresh token is missing"));
 
 		// then
 		mockMvc.perform(post("/api/v1/oauth/reissue")
-				.contentType(MediaType.APPLICATION_JSON)
-				.with(csrf()))
-			.andExpect(status().isUnauthorized())
-			.andExpect(result -> assertTrue(
-				result.getResolvedException() instanceof IllegalArgumentException))
-			.andExpect(jsonPath("$.errors").exists());
+						.contentType(MediaType.APPLICATION_JSON)
+						.with(csrf()))
+				.andExpect(status().isUnauthorized())
+				.andExpect(result -> assertTrue(
+						result.getResolvedException() instanceof IllegalArgumentException))
+				.andExpect(jsonPath("$.errors").exists());
 	}
 
 
diff --git a/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java b/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java
index ebd859f85..14215d062 100644
--- a/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java
+++ b/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java
@@ -47,33 +47,33 @@ class UserCommandIntegrationTest extends IntegrationTestSupport {
 	private UserRepository userRepository;
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql"}
+			"/init-script/init-user.sql"}
 	)
 	@DisplayName("회원탈퇴에 성공한다.")
 	@Test
 	void test_1() throws Exception {
 		// given
 		MvcResult result = mockMvc.perform(delete("/api/v1/users")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class);
 		WithdrawUserResultResponse withdrawUserResultResponse = mapper.convertValue(response.getData(), WithdrawUserResultResponse.class);
 
 		userRepository.findById(withdrawUserResultResponse.userId())
-			.ifPresent(withdraw -> assertEquals(DELETED, withdraw.getStatus()));
+				.ifPresent(withdraw -> assertEquals(DELETED, withdraw.getStatus()));
 	}
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql"}
+			"/init-script/init-user.sql"}
 	)
 	@DisplayName("탈퇴한 회원이 다시 탈퇴하는 경우 성공")
 	@Test
@@ -85,18 +85,18 @@ void test_2() throws Exception {
 		statusField.set(firstUser, UserStatus.DELETED);
 
 		mockMvc.perform(delete("/api/v1/users")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists());
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists());
 	}
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql"}
+			"/init-script/init-user.sql"}
 	)
 	@DisplayName("탈퇴한 회원이 재로그인 하는 경우 예외가 발생한다.")
 	@Test
@@ -105,47 +105,47 @@ void test_3() throws Exception {
 		final User firstUser = oauthRepository.getFirstUser().get();
 
 		mockMvc.perform(delete("/api/v1/users")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists());
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists());
 
 		mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(new OauthRequest(firstUser.getEmail(), SocialType.KAKAO, null, null)))
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isBadRequest())
-			.andExpect(jsonPath("$.code").value(400))
-			.andExpect(jsonPath("$.errors").isArray())
-			.andExpect(jsonPath("$.errors[0].code").value(UserExceptionCode.USER_DELETED.name()))
-			.andExpect(jsonPath("$.errors[0].message").value(UserExceptionCode.USER_DELETED.getMessage()));
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(new OauthRequest(firstUser.getEmail(), null, SocialType.KAKAO, null, null)))
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isBadRequest())
+				.andExpect(jsonPath("$.code").value(400))
+				.andExpect(jsonPath("$.errors").isArray())
+				.andExpect(jsonPath("$.errors[0].code").value(UserExceptionCode.USER_DELETED.name()))
+				.andExpect(jsonPath("$.errors[0].message").value(UserExceptionCode.USER_DELETED.getMessage()));
 	}
 
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql"}
+			"/init-script/init-user.sql"}
 	)
 	@DisplayName("닉네임 변경에 성공한다.")
 	@Test
 	void test_4() throws Exception {
 
 		MvcResult result = mockMvc.perform(patch("/api/v1/users/nickname")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.content(mapper.writeValueAsString(new NicknameChangeRequest("newNickname")))
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.content(mapper.writeValueAsString(new NicknameChangeRequest("newNickname")))
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class);
@@ -161,45 +161,45 @@ void test_5() throws Exception {
 
 		// 중복 닉네임 생성
 		userRepository.save(User.builder()
-			.email("test@email.com")
-			.password("password")
-			.nickName("fail")
-			.role(UserType.ROLE_USER)
-			.socialType(List.of(SocialType.KAKAO))
-			.build());
+				.email("test@email.com")
+				.password("password")
+				.nickName("fail")
+				.role(UserType.ROLE_USER)
+				.socialType(List.of(SocialType.KAKAO))
+				.build());
 
 		mockMvc.perform(patch("/api/v1/users/nickname")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.content(mapper.writeValueAsString(new NicknameChangeRequest("fail")))
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isBadRequest())
-			.andExpect(jsonPath("$.code").value(400))
-			.andExpect(jsonPath("$.errors").isArray())
-			.andExpect(jsonPath("$.errors[0].code").value(UserExceptionCode.USER_NICKNAME_NOT_VALID.name()))
-			.andExpect(jsonPath("$.errors[0].message").value(UserExceptionCode.USER_NICKNAME_NOT_VALID.getMessage()));
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.content(mapper.writeValueAsString(new NicknameChangeRequest("fail")))
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isBadRequest())
+				.andExpect(jsonPath("$.code").value(400))
+				.andExpect(jsonPath("$.errors").isArray())
+				.andExpect(jsonPath("$.errors[0].code").value(UserExceptionCode.USER_NICKNAME_NOT_VALID.name()))
+				.andExpect(jsonPath("$.errors[0].message").value(UserExceptionCode.USER_NICKNAME_NOT_VALID.getMessage()));
 	}
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql"}
+			"/init-script/init-user.sql"}
 	)
 	@DisplayName("프로필 이미지 변경에 성공한다.")
 	@Test
 	void test_6() throws Exception {
 
 		MvcResult result = mockMvc.perform(patch("/api/v1/users/profile-image")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.content(mapper.writeValueAsString(new ProfileImageChangeRequest("newProfileImageUrl")))
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.content(mapper.writeValueAsString(new ProfileImageChangeRequest("newProfileImageUrl")))
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class);
@@ -209,23 +209,23 @@ void test_6() throws Exception {
 	}
 
 	@Sql(scripts = {
-		"/init-script/init-user.sql"}
+			"/init-script/init-user.sql"}
 	)
 	@DisplayName("프로필 이미지에 null을 넣는 경우 변경에 성공한다.(삭제)")
 	@Test
 	void test_7() throws Exception {
 
 		MvcResult result = mockMvc.perform(patch("/api/v1/users/profile-image")
-				.contentType(MediaType.APPLICATION_JSON)
-				.header("Authorization", "Bearer " + getToken())
-				.content(mapper.writeValueAsString(new ProfileImageChangeRequest(null)))
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.header("Authorization", "Bearer " + getToken())
+						.content(mapper.writeValueAsString(new ProfileImageChangeRequest(null)))
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.code").value(200))
+				.andExpect(jsonPath("$.data").exists())
+				.andReturn();
 
 		String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
 		GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class);
diff --git a/src/test/java/app/bottlenote/user/service/OauthServiceTest.java b/src/test/java/app/bottlenote/user/service/OauthServiceTest.java
index ea27b3584..be5ba6708 100644
--- a/src/test/java/app/bottlenote/user/service/OauthServiceTest.java
+++ b/src/test/java/app/bottlenote/user/service/OauthServiceTest.java
@@ -68,43 +68,29 @@ class OauthServiceTest {
 	@BeforeEach
 	void setUp() {
 
-		request = new OauthRequest("cdm2883@naver.com", SocialType.KAKAO,
-			null, 26);
+		request = new OauthRequest("cdm2883@naver.com", null, SocialType.KAKAO,
+				null, 26);
 
 		String nickName = "mockNickname";
 
 		user = User.builder()
-			.id(1L)
-			.email("cdm2883@naver.com")
-			.gender(GenderType.MALE)
-			.socialType(new ArrayList<>(List.of(SocialType.KAKAO)))
-			.age(26)
-			.nickName(nickName)
-			.role(UserType.ROLE_USER)
-			.build();
+				.id(1L)
+				.email("cdm2883@naver.com")
+				.gender(GenderType.MALE)
+				.socialType(new ArrayList<>(List.of(SocialType.KAKAO)))
+				.age(26)
+				.nickName(nickName)
+				.role(UserType.ROLE_USER)
+				.build();
 
 		tokenItem = TokenItem.builder()
-			.accessToken("mock-accessToken")
-			.refreshToken("mock-refreshToken")
-			.build();
+				.accessToken("mock-accessToken")
+				.refreshToken("mock-refreshToken")
+				.build();
 
 		reissueRefreshToken = "mock-refreshToken";
 	}
 
-	@Test
-	@DisplayName("회원가입을 할 수 있다.")
-	void signup_test() {
-
-		//given
-
-		//when
-		when(oauthRepository.save(any(User.class))).thenReturn(user);
-		//then
-		User resultUser = oauthService.oauthSignUp(request.email(), request.socialType(), request.gender(), request.age(), UserType.ROLE_USER);
-
-		assertThat(resultUser.getAge()).isEqualTo(request.age());
-	}
-
 	@Test
 	@DisplayName("로그인을 할 수 있다.")
 	void signin_test() {
@@ -115,7 +101,7 @@ void signin_test() {
 		when(oauthRepository.findByEmail(anyString())).thenReturn(Optional.of(user));
 
 		when(jwtTokenProvider.generateToken(user.getEmail(), user.getRole(),
-			user.getId())).thenReturn(tokenItem);
+				user.getId())).thenReturn(tokenItem);
 
 		TokenItem result = oauthService.login(request);
 		System.out.println(result.accessToken() + " " + result.refreshToken());
@@ -134,7 +120,7 @@ void test_Login_Or_CreateAccount_Based_On_EmailExistence() {
 		when(oauthRepository.save(any(User.class))).thenReturn(user);
 
 		when(jwtTokenProvider.generateToken(user.getEmail(), user.getRole(),
-			user.getId())).thenReturn(tokenItem);
+				user.getId())).thenReturn(tokenItem);
 
 		oauthService.login(request);
 
@@ -153,7 +139,7 @@ void test_Login_Or_CreateAccount_Based_On_Email_Not_Existence() {
 		when(oauthRepository.findByEmail(anyString())).thenReturn(Optional.of(user));
 
 		when(jwtTokenProvider.generateToken(user.getEmail(), user.getRole(),
-			user.getId())).thenReturn(tokenItem);
+				user.getId())).thenReturn(tokenItem);
 
 		oauthService.login(request);
 
@@ -170,23 +156,23 @@ void test_Login_Or_CreateAccount_Based_On_Email_Not_Existence() {
 	@DisplayName("토큰 재발급을 할 수 있다.")
 	void reissue_token() {
 		try (MockedStatic<JwtTokenValidator> mockedValidator = mockStatic(
-			JwtTokenValidator.class)) {
+				JwtTokenValidator.class)) {
 
 			//when
 			mockedValidator.when(() -> JwtTokenValidator.validateToken(reissueRefreshToken))
-				.thenReturn(true);
+					.thenReturn(true);
 
 			when(jwtAuthenticationManager.getAuthentication(reissueRefreshToken)).thenReturn(
-				mock(Authentication.class));
+					mock(Authentication.class));
 
 			when(oauthRepository.findByRefreshToken(reissueRefreshToken)).thenReturn(
-				Optional.of(user));
+					Optional.of(user));
 
 			when(jwtTokenProvider.generateToken(anyString(), any(UserType.class), anyLong()))
-				.thenReturn(TokenItem.builder()
-					.accessToken("newAccessToken")
-					.refreshToken("newRefreshToken")
-					.build());
+					.thenReturn(TokenItem.builder()
+							.accessToken("newAccessToken")
+							.refreshToken("newRefreshToken")
+							.build());
 
 			// then
 			TokenItem response = oauthService.refresh(reissueRefreshToken);
@@ -204,11 +190,11 @@ void reissue_token() {
 	@DisplayName("토큰 검증에 통과하지 못하면 토큰 재발급에 실패한다")
 	void reissue_token_fail() {
 		try (MockedStatic<JwtTokenValidator> mockedValidator = mockStatic(
-			JwtTokenValidator.class)) {
+				JwtTokenValidator.class)) {
 
 			//when
 			mockedValidator.when(() -> JwtTokenValidator.validateToken(reissueRefreshToken))
-				.thenReturn(false);
+					.thenReturn(false);
 
 			// then
 			assertThrows(UserException.class, () -> oauthService.refresh(reissueRefreshToken));


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>🧹 Nitpick comments (7)</summary><blockquote>

<details>
<summary>src/main/java/app/bottlenote/user/exception/UserExceptionCode.java (1)</summary><blockquote>

`30-31`: **Consider using a 4xx status code instead of 500 for login errors.**

The newly added `TEMPORARY_LOGIN_ERROR` uses `HttpStatus.INTERNAL_SERVER_ERROR` (500), which typically indicates server-side issues rather than client-related problems. Since this appears to be related to missing social unique IDs during login (based on PR objectives), consider using a more appropriate 4xx status code such as `BAD_REQUEST` (400) or `UNPROCESSABLE_ENTITY` (422) instead.


```diff
-	TEMPORARY_LOGIN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "일시적인 로그인 오류입니다.");
+	TEMPORARY_LOGIN_ERROR(HttpStatus.UNPROCESSABLE_ENTITY, "일시적인 로그인 오류입니다.");
```

</blockquote></details>
<details>
<summary>src/main/java/app/bottlenote/user/domain/User.java (2)</summary><blockquote>

`52-54`: **Consider adding a nullable constraint to the socialUniqueId field.**

The `socialUniqueId` field is defined with a unique constraint but without a nullable constraint. Based on the PR objectives, this field is crucial for Apple login identification. Consider whether this field should be non-nullable, especially for Apple login users.

Also, you might want to add length constraints to match the expected format mentioned in the PR objectives (e.g., "001856.64db7e4847bc4e3895847ab884862ac4.0554").


```diff
@Comment("소셜 로그인 제공자의 고유 식별자")
-@Column(name = "social_unique_id", unique = true)
+@Column(name = "social_unique_id", unique = true, length = 100)
private String socialUniqueId;
```

---

`52-54`: **Consider adding a getter/setter or builder method for socialUniqueId.**

The `socialUniqueId` field has been added, but there are no specific methods to update this field if needed. Consider adding a method to set or update this field, similar to other fields in this class.


```java
public void updateSocialUniqueId(String socialUniqueId) {
    Objects.requireNonNull(socialUniqueId, "socialUniqueId는 null이 될 수 없습니다.");
    this.socialUniqueId = socialUniqueId;
}
```

</blockquote></details>
<details>
<summary>src/main/java/app/bottlenote/user/dto/request/OauthRequest.java (1)</summary><blockquote>

`11-12`: **Consider validation requirements for different login scenarios.**

The @NotBlank annotation has been removed from the email field, which aligns with the Apple login requirement where email might not be required for subsequent logins. However, there's no validation to ensure that at least one identifier (email or socialUniqueId) is provided.



Consider adding custom validation to ensure either email or socialUniqueId is provided for login requests, as described in the PR objectives:

```java
// Example of a custom validator that could be applied to the OauthRequest class
@AssertTrue(message = "Either email or socialUniqueId must be provided")
private boolean isEitherEmailOrSocialUniqueIdProvided() {
    return email != null || socialUniqueId != null;
}
```

</blockquote></details>
<details>
<summary>src/test/java/app/bottlenote/user/service/OauthServiceTest.java (1)</summary><blockquote>

`130-146`: **Add test for Apple login specific flow**

The current test verifies the general case where a new user is created when the email doesn't exist, but there's no test specifically for the Apple login scenario described in the PR where only the ID is used for subsequent logins.

Consider adding a test case that specifically verifies the behavior when a user logs in with Apple credentials, where:
1. First login - both email and social unique ID are used
2. Subsequent login - only social unique ID is used to identify the user

</blockquote></details>
<details>
<summary>src/main/java/app/bottlenote/user/service/OauthService.java (2)</summary><blockquote>

`77-80`: **Check `isAlive()` naming consistency.**  
`checkActiveUser(user)` throws if `user.isAlive()` is `false`. This is logically correct, but consider consistent naming (e.g., `isActive`) to improve semantic clarity.

---

`82-83`: **Confirm multi-social support.**  
Calling `user.addSocialType(socialType)` on each login implies that users may accumulate multiple social types over time. Verify that this is the intended functionality.  

Do you need additional help ensuring that the user’s social types remain accurate when they switch or add providers?

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between db986a5b5172e8e6f3189e143f9480bb21941ce9 and 3315c6d81ea5ec79cb0d2225a6c4a3fb4afcbb98.

</details>

<details>
<summary>📒 Files selected for processing (14)</summary>

* `database/mysql/init/01-init-table.sql` (2 hunks)
* `src/main/java/app/bottlenote/user/domain/User.java` (1 hunks)
* `src/main/java/app/bottlenote/user/dto/request/OauthRequest.java` (1 hunks)
* `src/main/java/app/bottlenote/user/exception/UserExceptionCode.java` (1 hunks)
* `src/main/java/app/bottlenote/user/repository/OauthRepository.java` (2 hunks)
* `src/main/java/app/bottlenote/user/service/OauthService.java` (6 hunks)
* `src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java` (3 hunks)
* `src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java` (8 hunks)
* `src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java` (1 hunks)
* `src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java` (12 hunks)
* `src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java` (4 hunks)
* `src/test/java/app/bottlenote/user/controller/OauthControllerTest.java` (7 hunks)
* `src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java` (5 hunks)
* `src/test/java/app/bottlenote/user/service/OauthServiceTest.java` (7 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (4)</summary>

<details>
<summary>src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java (1)</summary><blockquote>

<details>
<summary>src/test/java/app/bottlenote/docs/user/RestDocsUserChangeContollerTest.java (1)</summary>

* `DisplayName` (36-146)

</details>

</blockquote></details>
<details>
<summary>src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java (2)</summary><blockquote>

<details>
<summary>src/test/java/app/bottlenote/docs/user/RestDocsUserChangeContollerTest.java (1)</summary>

* `DisplayName` (36-146)

</details>
<details>
<summary>src/test/java/app/bottlenote/docs/user/RestUserBasicControllerTest.java (1)</summary>

* `DisplayName` (30-120)

</details>

</blockquote></details>
<details>
<summary>src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java (1)</summary><blockquote>

<details>
<summary>src/test/java/app/bottlenote/docs/review/RestReviewControllerDocsTest.java (1)</summary>

* `DisplayName` (47-484)

</details>

</blockquote></details>
<details>
<summary>src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java (1)</summary><blockquote>

<details>
<summary>src/test/java/app/bottlenote/docs/support/help/RestDocsHelpCommandControllerTest.java (1)</summary>

* `DisplayName` (45-285)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: dev_ci

</details>

<details>
<summary>🔇 Additional comments (24)</summary><blockquote>

<details>
<summary>src/main/java/app/bottlenote/user/repository/OauthRepository.java (1)</summary>

`22-22`: **LGTM! Good addition of repository method for social unique ID.**

This new repository method aligns well with the Apple login implementation requirements. The method follows Spring Data JPA naming conventions and will allow lookup of users by their unique social identifier.

</details>
<details>
<summary>src/test/java/app/bottlenote/user/controller/OauthControllerTest.java (3)</summary>

`67-69`: **Constructor parameters updated to support new social unique ID field.**

The OauthRequest constructor now includes the new socialUniqueId parameter (set to null) to accommodate the changes to the DTO. This ensures tests continue to function with the updated data structure.

---

`100-101`: **Constructor parameters updated correctly for test case.**

The OauthRequest constructor is consistently updated to include the new socialUniqueId parameter (set to null).

---

`124-125`: **Constructor parameters updated correctly for test case.**

The OauthRequest constructor is consistently updated to include the new socialUniqueId parameter (set to null).

</details>
<details>
<summary>src/main/java/app/bottlenote/user/dto/request/OauthRequest.java (1)</summary>

`14-15`: **New field added for social login unique identifier.**

This field will store the unique identifier provided by social login providers like Apple. According to the PR description, this ID has a format like "001856.64db7e4847bc4e3895847ab884862ac4.0554".


Consider adding validation for the format of this field if there's a specific pattern expected from Apple or other providers.

</details>
<details>
<summary>src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java (1)</summary>

`119-120`: **Integration test updated to include new socialUniqueId parameter.**

The OauthRequest constructor call has been correctly updated to include the new socialUniqueId parameter (set to null) to accommodate the changes to the DTO.

</details>
<details>
<summary>database/mysql/init/01-init-table.sql (2)</summary>

`54-55`: **Appropriate addition of social_unique_id column to support social login identifiers**

The addition of this nullable VARCHAR column will properly store unique identifiers from social login providers like Apple, matching the PR objective for supporting different login flows.

---

`70-71`: **Well-implemented unique constraints**

The unique constraint on `social_unique_id` ensures that each social platform identifier can only be associated with a single user account, which is essential for proper user identification during subsequent logins.

</details>
<details>
<summary>src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java (1)</summary>

`63-63`: **Updated OauthRequest to support social unique identifier**

The constructor call has been modified to include a different parameter order, with `null` as the second parameter (which represents the social unique identifier). This is aligned with the apple login implementation requirements mentioned in the PR.

</details>
<details>
<summary>src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java (1)</summary>

`42-42`: **Updated OauthRequest constructor call for test consistency**

The constructor call has been properly updated to match the new parameter ordering that supports the social unique identifier, consistent with changes in other test files.

</details>
<details>
<summary>src/test/java/app/bottlenote/user/service/OauthServiceTest.java (2)</summary>

`71-72`: **Updated OauthRequest construction to support social unique ID**

The updated constructor call properly matches the new parameter ordering required for the apple login implementation.

---

`103-103`: **Updated oauthSignUp method signature to include socialUniqueId parameter**

The method call now includes the social unique identifier as a parameter, which is essential for the Apple login implementation. This aligns with the PR objective of handling user identification differently based on whether it's the first login or a subsequent one.

</details>
<details>
<summary>src/main/java/app/bottlenote/user/service/OauthService.java (3)</summary>

`44-44`: **Consider clarifying the usage of `socialUniqueId`.**  
Extracting `socialUniqueId` directly from the request is fine, but confirm whether it is always expected or optional for non-Apple logins.

---

`49-75`: **Validate the new Apple login logic flow carefully.**  
The nested conditions for Apple login appear correct:  
- If `socialUniqueId` and `email` are both provided, treat it as first-time Apple login.  
- If `socialUniqueId` is present (but no `email`), treat it as a returning Apple user.  
- If `socialUniqueId` is `null`, throw a temporary login error.  

This design seems to address the different Apple login paths. However, ensure thorough integration testing to confirm that first-time vs. returning-Apple-user scenarios behave as intended.

---

`92-92`: **Exception handling clarified.**  
Throwing `USER_NOT_FOUND` when email-based user retrieval fails is consistent with the rest of the code. Good job.

</details>
<details>
<summary>src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java (9)</summary>

`61-61`: **Constructor alignment with new `OauthRequest` signature.**  
The addition of `null` for `socialUniqueId` is consistent with the updated constructor. Looks correct.

---

`75-83`: **Good practice adding authorization to the request.**  
Including the bearer token from `getToken(oauthRequest)` ensures secure endpoint testing. No issues spotted.

---

`116-117`: **SQL scripts for test setup.**  
Ensuring consistent initial states with `init-user.sql` and `init-help.sql` helps with reproducible tests. Great approach.

---

`151-159`: **Check GET request correctness.**  
The test properly checks for HTTP 200, verifies code presence, and asserts the response structure. Implementation is fine.

---

`182-191`: **PATCH request coverage.**  
The test coverage for modifying helps is thorough. The input is validated, and the success path is verified with status 200. Good job.

---

`206-206`: **Ensuring user difference in tests.**  
Using a different `oauthRequest` (“test@naver.com”) confirms negative scenarios. This helps ensure proper authorization checks.

---

`275-284`: **DELETE request verification.**  
Looks solid. The test confirms successful deletion with a 200 response and checks for the result message.

---

`299-306`: **Boundary check for non-existent help ID.**  
Good coverage for erroneous delete attempts with a -1 ID. Negative path properly validated with `HELP_NOT_FOUND`.

---

`318-330`: **Unauthorized deletion scenario tested.**  
Verifying that a different user (`test@naver.com`) cannot delete another user’s content is essential. The test approach is correct.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-01T15:19:36Z
user: login: Whale0928
body: 테스트는 성공하는걸로 파악되니 별도로 확인안해도 될것같고.
코드도 오류는 없을거같으니 분리만 좀 하고 , 마무리하죠.

 p.s 설정창에서 에딧컨피그 적용되어있는지 확인만 한번 더 해보죠.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T07:52:19Z
user: login: chadongmin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T12:03:48Z
user: login: chadongmin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T12:03:57Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>src/main/java/app/bottlenote/user/dto/request/OauthRequest.java (2)</summary><blockquote>

`14-14`: **Add validation for socialUniqueId**

The `socialUniqueId` field is crucial for Apple login but has no validation to ensure it's provided.


Add appropriate validation to ensure the `socialUniqueId` is provided for Apple login:

```diff
-		String socialUniqueId,
+		@NotNull(message = "SOCIAL_UNIQUE_ID_REQUIRED_FOR_APPLE", groups = AppleLoginValidation.class)
+		String socialUniqueId,
```

You'll need to define a validation group and use it conditionally in your controller.

---

`9-25`: **Add documentation for new field and changed validation**

The changes to this record are significant as they modify validation behavior. Without documentation, other developers may misunderstand the intended usage.


Add Javadoc to explain the purpose and validation requirements:

```diff
+/**
+ * Request DTO for OAuth login and signup.
+ * <p>
+ * For Apple login:
+ * - On first login: both email and socialUniqueId are required
+ * - On subsequent logins: only socialUniqueId is required
+ * <p>
+ * For other social logins:
+ * - email is always required
+ * - socialUniqueId is not used
+ */
public record OauthRequest(
```

</blockquote></details>
<details>
<summary>src/test/java/app/bottlenote/user/service/OauthServiceTest.java (1)</summary><blockquote>

`128-146`: **Add test for error cases in Apple login**

The implementation handles error cases for Apple login (null socialUniqueId, user not found), but there are no test cases covering these scenarios.


Add test cases for error handling in Apple login:

```java
@Test
@DisplayName("애플 로그인시 소셜 고유 ID가 null이면 임시 로그인 오류를 던진다")
void test_apple_login_with_null_social_id() {
    // given
    OauthRequest appleRequest = new OauthRequest(null, null, SocialType.APPLE, null, 26);
    
    // then
    assertThrows(UserException.class, () -> oauthService.login(appleRequest));
}

@Test
@DisplayName("애플 재로그인시 소셜 고유 ID로 사용자를 찾지 못하면 사용자 미발견 오류를 던진다")
void test_apple_login_user_not_found_by_social_id() {
    // given
    OauthRequest appleRequest = new OauthRequest(null, "unknown.id", SocialType.APPLE, null, 26);
    
    // when
    when(oauthRepository.findBySocialUniqueId(anyString())).thenReturn(Optional.empty());
    
    // then
    assertThrows(UserException.class, () -> oauthService.login(appleRequest));
}
```

</blockquote></details>
<details>
<summary>src/main/java/app/bottlenote/user/service/OauthService.java (3)</summary><blockquote>

`65-80`: **Improve error handling for Apple login**

The current implementation for Apple login has good error handling for null `socialUniqueId`, but the error message when a user is not found by `socialUniqueId` could be more specific.


Add more specific error handling for the Apple login flow:

```diff
} else {
	if (socialUniqueId == null) {
		log.error("ERROR : socialUniqueId is null");
		throw new UserException(UserExceptionCode.TEMPORARY_LOGIN_ERROR);
	}
	user = oauthRepository.findBySocialUniqueId(socialUniqueId).orElseThrow(
-			() -> new UserException(UserExceptionCode.USER_NOT_FOUND));
+			() -> {
+				log.error("ERROR : User not found with socialUniqueId: {}", socialUniqueId);
+				return new UserException(UserExceptionCode.USER_NOT_FOUND);
+			});
}
```

---

`115-127`: **Update guestLogin to match Apple login flow**

The `guestLogin` method creates a guest user with an Apple social type but doesn't follow the same validation pattern as regular Apple login.


Consider consistency in handling guest logins:

```diff
@Transactional
public String guestLogin() {
	final int expireTime = 1000 * 60 * 60 * 24;
+	// Generate unique identifier for guest user
+	String guestEmail = "guest" + UUID.randomUUID() + "@bottlenote.com";
+	String guestSocialId = "socialUniqueId" + UUID.randomUUID();
	OauthRequest oauthRequest = new OauthRequest(
-			"guest" + UUID.randomUUID() + "@bottlenote.com",
-			"socialUniqueId" + UUID.randomUUID(),
+			guestEmail,
+			guestSocialId,
			SocialType.APPLE,
			GenderType.MALE,
			30);
+	// Log guest creation for debugging
+	log.info("Creating guest user with email: {} and socialId: {}", guestEmail, guestSocialId);
	User guest = oauthRepository.loadGuestUser()
			.orElseGet(() -> oauthSignUp(oauthRequest, UserType.ROLE_GUEST));
	return tokenProvider.createGuestToken(
			guest.getId(),
			expireTime
	);
}
```

---

`42-55`: **Add documentation for the login method**

The login method now has complex branching logic based on social type, but lacks documentation explaining the different flows.


Add Javadoc to explain the different login flows:

```diff
+/**
+ * Handles user login through various social authentication providers.
+ * 
+ * <p>For Apple login:</p>
+ * <ul>
+ *   <li>First login: Requires both email and socialUniqueId</li>
+ *   <li>Subsequent logins: Only requires socialUniqueId</li>
+ * </ul>
+ * 
+ * <p>For other social logins:</p>
+ * <ul>
+ *   <li>Requires email (socialUniqueId is ignored)</li>
+ * </ul>
+ *
+ * @param oauthReq the OAuth request containing login credentials
+ * @return TokenItem containing access and refresh tokens
+ */
@Transactional
public TokenItem login(OauthRequest oauthReq) {
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 3315c6d81ea5ec79cb0d2225a6c4a3fb4afcbb98 and 185956d5e662b4ca80dd1de321c1bb550414d010.

</details>

<details>
<summary>📒 Files selected for processing (13)</summary>

* `src/main/java/app/bottlenote/user/domain/User.java` (1 hunks)
* `src/main/java/app/bottlenote/user/dto/request/OauthRequest.java` (1 hunks)
* `src/main/java/app/bottlenote/user/exception/UserExceptionCode.java` (1 hunks)
* `src/main/java/app/bottlenote/user/repository/OauthRepository.java` (2 hunks)
* `src/main/java/app/bottlenote/user/service/OauthService.java` (6 hunks)
* `src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java` (3 hunks)
* `src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java` (8 hunks)
* `src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java` (1 hunks)
* `src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java` (12 hunks)
* `src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java` (4 hunks)
* `src/test/java/app/bottlenote/user/controller/OauthControllerTest.java` (7 hunks)
* `src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java` (5 hunks)
* `src/test/java/app/bottlenote/user/service/OauthServiceTest.java` (7 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (10)</summary>

* src/main/java/app/bottlenote/user/domain/User.java
* src/main/java/app/bottlenote/user/exception/UserExceptionCode.java
* src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java
* src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java
* src/main/java/app/bottlenote/user/repository/OauthRepository.java
* src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java
* src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java
* src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java
* src/test/java/app/bottlenote/user/controller/OauthControllerTest.java
* src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>src/test/java/app/bottlenote/user/service/OauthServiceTest.java (1)</summary>

`102-103`: **Update all test cases to use new OauthSignUp method signature**

The `oauthSignUp` method now accepts an `OauthRequest` object instead of individual parameters. All test cases should be updated to use the new method signature.


The change to use the request object directly is good, making the code more maintainable.

</details>
<details>
<summary>src/main/java/app/bottlenote/user/service/OauthService.java (3)</summary>

`47-55`: **Good refactoring with dedicated methods for different login types**

The switch statement provides clear branching logic for handling different social login types, making the code more maintainable.


The separation of Apple login from other social logins is well-structured and aligns with the requirements for handling Apple's unique login flow.

---

`130-142`: **Good refactoring of oauthSignUp method**

The `oauthSignUp` method has been effectively refactored to accept an `OauthRequest` object instead of individual parameters, which simplifies the code and improves maintainability.


The updated method now properly handles the new `socialUniqueId` field, ensuring it's stored with the user.

---

`82-85`: **Good extraction of common functionality**

The extraction of the `checkActiveUser` method improves code maintainability by eliminating duplication.


This refactoring follows the DRY principle and makes the code more maintainable.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-02T12:09:48Z
user: login: Whale0928
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-02T12:47:47Z

Review comments:
diff_hunk: @@ -118,69 +118,69 @@ void test_2() throws Exception {
 		Error error = Error.of(ALREADY_REPORTED_REVIEW);
 
 		mockMvc.perform(post("/api/v1/reports/review")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(reviewReportRequest))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isBadRequest())
-			.andExpect(jsonPath("$.code").value(400))
-			.andExpect(jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].status").value(error.status().name()))
-			.andExpect(jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].message").value(error.message()));
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(reviewReportRequest))
+						.header("Authorization", "Bearer " + getToken())
+						.with(csrf())
+				)
+				.andDo(print())
+				.andExpect(status().isBadRequest())
+				.andExpect(jsonPath("$.code").value(400))
+				.andExpect(jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].status").value(error.status().name()))
+				.andExpect(jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].message").value(error.message()));
 	}
 
 	@DisplayName("서로 다른 IP로 5개의 신고가 누적되면 리뷰가 비활성화 된다.")
 	@Test
 	@Sql(scripts = {
-		"/init-script/init-user.sql",
-		"/init-script/init-alcohol.sql",
-		"/init-script/init-review.sql"
+			"/init-script/init-user.sql",
+			"/init-script/init-alcohol.sql",
+			"/init-script/init-review.sql"
 	})
 	void test_3() throws Exception {
 		// given
 		ReviewReportRequest reviewReportRequest = new ReviewReportRequest(1L, ADVERTISEMENT, "이 리뷰는 광고 리뷰입니다.");
 
 		IntStream.range(1, 5).forEach((int i) -> {
 			ReviewReport reviewReport = ReviewReport.builder()
-				.reviewId(reviewReportRequest.reportReviewId())
-				.ipAddress(String.valueOf(i))
-				.type(ADVERTISEMENT)
-				.userId((long) i)
-				.reportContent("이 리뷰는 광고 리뷰입니다.")
-				.build();
+					.reviewId(reviewReportRequest.reportReviewId())
+					.ipAddress(String.valueOf(i))
+					.type(ADVERTISEMENT)
+					.userId((long) i)
+					.reportContent("이 리뷰는 광고 리뷰입니다.")
+					.build();
 			reviewReportRepository.save(reviewReport);
 		});
 		Review beforeReview = reviewRepository.findById(reviewReportRequest.reportReviewId()).orElse(null);
 
 		MvcResult authResult = mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(new OauthRequest("test@test.com", SocialType.KAKAO, null, null)))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andDo(print())
-			.andExpect(status().isOk())
-			.andExpect(jsonPath("$.code").value(200))
-			.andExpect(jsonPath("$.data").exists())
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(new OauthRequest("test@test.com", null, SocialType.KAKAO, null, null)))
path: src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Update OauthRequest usage for socialUniqueId compatibility.**

The `OauthRequest` constructor call is passing all null values for the newly added parameters. Based on the PR objectives, for Apple login, both email and socialUniqueId should be provided on the first login. Consider updating this test to properly test the Apple login flow with valid socialUniqueId values.

---

🏁 Script executed:

```shell
#!/bin/bash
# Find all usages of OauthRequest to verify consistent parameter usage
grep -r "new OauthRequest" --include="*.java" .
```

Length of output: 2478

---



</details>

**Action Required: Update OauthRequest for Apple Login Compatibility**

- In `src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java` at line 158, the current instantiation:
  ```java
  .content(mapper.writeValueAsString(new OauthRequest("test@test.com", null, SocialType.KAKAO, null, null)))
  ```
  passes `null` for the fields newly added to support Apple login. Based on the PR objectives, Apple login should provide both a valid email and a non-null socialUniqueId on the first login.
- **Recommendation:** Update this test case to simulate an Apple login scenario correctly. This might involve:
  - Changing the `SocialType` from `KAKAO` to the appropriate value (e.g., `APPLE`) if this test is meant to cover the Apple login flow.
  - Providing a valid `socialUniqueId` instead of `null`.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-01T15:19:35Z
author_association: NONE
diff_hunk: @@ -107,24 +107,24 @@ void test_3() throws Exception {
 		ratingTestFactory.createRating(user2, alcohol, 3);
 
 		MvcResult authResult = mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.content(mapper.writeValueAsString(new OauthRequest(userEmail, userSocialType.get(0), null, null)))
-				.header("Authorization", "Bearer " + getToken())
-				.with(csrf())
-			)
-			.andReturn();
+						.contentType(MediaType.APPLICATION_JSON)
+						.content(mapper.writeValueAsString(new OauthRequest(userEmail, null, userSocialType.get(0), null, null)))
path: src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Update OauthRequest to include socialUniqueId for testing.**

The `OauthRequest` constructor call has been updated with the new parameter order but still passes null for the socialUniqueId. Consider adding appropriate socialUniqueId values to test the new Apple login functionality thoroughly.

---

🏁 Script executed:

```shell
#!/bin/bash
# Find the OauthRequest class to understand its structure
find . -name "OauthRequest.java" -type f -exec cat {} \;
```

Length of output: 608

---



</details>

**Action Required: Provide a valid socialUniqueId in your test instantiation**

The test in **AlcoholQueryIntegrationTest.java** (line 111) still passes `null` for the `socialUniqueId` parameter. Since the `OauthRequest` record now requires a non-null `socialUniqueId` to accurately test the new Apple login functionality, please update this test by providing a meaningful dummy value. For example:

```java
.content(mapper.writeValueAsString(new OauthRequest(userEmail, "dummySocialUniqueId", userSocialType.get(0), null, null)))
```

This change will ensure that the integration test thoroughly validates the functionality associated with the Apple login flow.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-01T15:19:35Z
author_association: NONE
diff_hunk: @@ -60,53 +60,53 @@ protected Object initController() {
 	void login_test() throws Exception {
 
 		//given
-		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", SocialType.KAKAO, GenderType.MALE, 27);
+		OauthRequest oauthRequest = new OauthRequest("cdm2883@naver.com", null, SocialType.KAKAO, GenderType.MALE, 27);
 		TokenItem tokenItem = TokenItem.builder()
-			.accessToken("eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJn44WB44WBZ2dAbmF2ZXIuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjE2LCJpYXQiOjE3MTQ5NzU2MjMsImV4cCI6MTcxNDk3NjUyM30.41SuOBgmX-sd8nrMbC-xm0kH6rbny_SMYCKWE4rNQEZgSrRPS0HvYv0X7E-weo6sHlWWm1OmiQgHl4-uy6-9ig")
-			.refreshToken("eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJn44WB44WBZ2dAbmF2ZXIuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjE2LCJpYXQiOjE3MTQ5NzU2MjMsImV4cCI6MTcxNjE4NTIyM30.lvmPueUcOb1erv5Llo4qhEUQ_gtWrpFGbBHDw-Pi94qj8MGojoEI3ugdMo8PwoKgrVQZ_gBwBbytwjxh8XktUg")
-			.build();
+				.accessToken("eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJn44WB44WBZ2dAbmF2ZXIuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjE2LCJpYXQiOjE3MTQ5NzU2MjMsImV4cCI6MTcxNDk3NjUyM30.41SuOBgmX-sd8nrMbC-xm0kH6rbny_SMYCKWE4rNQEZgSrRPS0HvYv0X7E-weo6sHlWWm1OmiQgHl4-uy6-9ig")
+				.refreshToken("eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJn44WB44WBZ2dAbmF2ZXIuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjE2LCJpYXQiOjE3MTQ5NzU2MjMsImV4cCI6MTcxNjE4NTIyM30.lvmPueUcOb1erv5Llo4qhEUQ_gtWrpFGbBHDw-Pi94qj8MGojoEI3ugdMo8PwoKgrVQZ_gBwBbytwjxh8XktUg")
+				.build();
 
 		//when
 		when(oauthService.login(oauthRequest)).thenReturn(tokenItem);
 
 		//then
 		mockMvc.perform(post("/api/v1/oauth/login")
-				.contentType(MediaType.APPLICATION_JSON)
-				.with(csrf())
-				.content(objectMapper.writeValueAsString(oauthRequest)))
-			.andExpect(status().isOk())
-			.andExpect(cookie().exists("refresh-token"))
-
-			.andDo(
-				document("user/user-login",
-					requestFields(
-						fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
-						fieldWithPath("gender").type(JsonFieldType.STRING).description("성별")
-							.optional(),
-						fieldWithPath("age").type(JsonFieldType.NUMBER).description("나이")
-							.optional(),
-						fieldWithPath("socialType").type(JsonFieldType.STRING)
-							.description("소셜 로그인 타입")
-					),
-					responseFields(
-						fieldWithPath("success").type(JsonFieldType.BOOLEAN)
-							.description("응답 성공 여부"),
-						fieldWithPath("code").type(JsonFieldType.NUMBER)
-							.description("응답 코드(http status code)"),
-						fieldWithPath("data.accessToken").type(JsonFieldType.STRING)
-							.description("액세스 토큰"),
-						fieldWithPath("errors").type(JsonFieldType.ARRAY)
-							.description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"),
-						fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"),
-						fieldWithPath("meta.serverVersion").description("서버 버전"),
-						fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"),
-						fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")
-					),
-					responseHeaders(
-						headerWithName("Set-Cookie").description("리프레쉬 토큰")
-					)
-				)
-			);
+						.contentType(MediaType.APPLICATION_JSON)
+						.with(csrf())
+						.content(objectMapper.writeValueAsString(oauthRequest)))
+				.andExpect(status().isOk())
+				.andExpect(cookie().exists("refresh-token"))
+
+				.andDo(
+						document("user/user-login",
+								requestFields(
+										fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
+										fieldWithPath("gender").type(JsonFieldType.STRING).description("성별")
path: src/test/java/app/bottlenote/docs/user/RestOauthControllerTest.java
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Verify email field validation**

Based on the PR description, email validation was to be removed since it will only be provided during the first login for Apple users. Confirm that this change in the documentation reflects the actual implementation.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check how email validation is currently implemented in OauthRequest
fd -e java OauthRequest.java -x cat {}
```

Length of output: 591

---



</details>

**Action Required: Align Email Validation with PR Specifications**

After verification, the email field in `OauthRequest.java` still has the `@Email` annotation enforcing validation:

```java
@Email(message = "EMAIL_PATTERN_NOT_VALID")
String email,
```

This contradicts the PR description stating that email validation should be removed (as it’s only provided during the first login for Apple users). Please ensure that one of the following is done:
  
- If email validation removal is intended, remove the `@Email` annotation from the email field and update any related logic accordingly.
- If the validation is meant to remain, update both the PR description and the API documentation (e.g., in `RestOauthControllerTest.java`) to reflect the continued enforcement of email validation.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-01T15:19:35Z
author_association: NONE
diff_hunk: @@ -41,16 +41,46 @@ public class OauthService {
 	@Transactional
 	public TokenItem login(OauthRequest oauthReq) {
 		final String email = oauthReq.email();
+		final String socialUniqueId = oauthReq.socialUniqueId();
 		final SocialType socialType = oauthReq.socialType();
 		final GenderType genderType = oauthReq.gender();
 		final Integer age = oauthReq.age();
 
-		User user = oauthRepository.findByEmail(email).orElseGet(() -> oauthSignUp(email, socialType, genderType, age, UserType.ROLE_USER));
+		User user;
+
+		switch (socialType) {
+			case APPLE -> {
+				//최초 로그인 (email과 socialUniqueId 모두 존재하는 경우)
+				if (socialUniqueId != null && !socialUniqueId.isBlank() && email != null && !email.isBlank()) {
+					user = oauthSignUp(oauthReq, UserType.ROLE_USER);
+					checkActiveUser(user);
+					return getTokenItem(user, socialType);
+				} else {
+					if (socialUniqueId == null) {
+						throw new UserException(UserExceptionCode.TEMPORARY_LOGIN_ERROR);
path: src/main/java/app/bottlenote/user/service/OauthService.java
user: login: Whale0928
body: 이 상황은 체크를 해봐야하는 상황일거같으니 error level 로그를 추가해두죠 . 
created_at: 2025-04-02T07:49:29Z
author_association: COLLABORATOR
diff_hunk: @@ -41,16 +41,46 @@ public class OauthService {
 	@Transactional
 	public TokenItem login(OauthRequest oauthReq) {
 		final String email = oauthReq.email();
+		final String socialUniqueId = oauthReq.socialUniqueId();
 		final SocialType socialType = oauthReq.socialType();
 		final GenderType genderType = oauthReq.gender();
 		final Integer age = oauthReq.age();
 
-		User user = oauthRepository.findByEmail(email).orElseGet(() -> oauthSignUp(email, socialType, genderType, age, UserType.ROLE_USER));
+		User user;
+
+		switch (socialType) {
+			case APPLE -> {
+				//최초 로그인 (email과 socialUniqueId 모두 존재하는 경우)
+				if (socialUniqueId != null && !socialUniqueId.isBlank() && email != null && !email.isBlank()) {
path: src/main/java/app/bottlenote/user/service/OauthService.java
user: login: Whale0928
body: 가독성이 상당히 낮지만 분기 요구사항은 이해가니  appleLogin이라는 내부 private 메소드로분리해보죠
created_at: 2025-04-02T07:50:19Z
author_association: COLLABORATOR
diff_hunk: @@ -41,16 +41,46 @@ public class OauthService {
 	@Transactional
 	public TokenItem login(OauthRequest oauthReq) {
 		final String email = oauthReq.email();
+		final String socialUniqueId = oauthReq.socialUniqueId();
 		final SocialType socialType = oauthReq.socialType();
 		final GenderType genderType = oauthReq.gender();
 		final Integer age = oauthReq.age();
 
-		User user = oauthRepository.findByEmail(email).orElseGet(() -> oauthSignUp(email, socialType, genderType, age, UserType.ROLE_USER));
+		User user;
+
+		switch (socialType) {
+			case APPLE -> {
+				//최초 로그인 (email과 socialUniqueId 모두 존재하는 경우)
+				if (socialUniqueId != null && !socialUniqueId.isBlank() && email != null && !email.isBlank()) {
+					user = oauthSignUp(oauthReq, UserType.ROLE_USER);
+					checkActiveUser(user);
+					return getTokenItem(user, socialType);
+				} else {
+					if (socialUniqueId == null) {
+						throw new UserException(UserExceptionCode.TEMPORARY_LOGIN_ERROR);
path: src/main/java/app/bottlenote/user/service/OauthService.java
user: login: chadongmin
body: done
created_at: 2025-04-02T12:03:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,16 +41,46 @@ public class OauthService {
 	@Transactional
 	public TokenItem login(OauthRequest oauthReq) {
 		final String email = oauthReq.email();
+		final String socialUniqueId = oauthReq.socialUniqueId();
 		final SocialType socialType = oauthReq.socialType();
 		final GenderType genderType = oauthReq.gender();
 		final Integer age = oauthReq.age();
 
-		User user = oauthRepository.findByEmail(email).orElseGet(() -> oauthSignUp(email, socialType, genderType, age, UserType.ROLE_USER));
+		User user;
+
+		switch (socialType) {
+			case APPLE -> {
+				//최초 로그인 (email과 socialUniqueId 모두 존재하는 경우)
+				if (socialUniqueId != null && !socialUniqueId.isBlank() && email != null && !email.isBlank()) {
path: src/main/java/app/bottlenote/user/service/OauthService.java
user: login: chadongmin
body: 분리했습니다.
created_at: 2025-04-02T12:03:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,20 +4,22 @@
 import app.bottlenote.user.constant.SocialType;
 import jakarta.validation.constraints.Email;
 import jakarta.validation.constraints.Min;
-import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 
-public record OauthRequest
-	(@NotBlank(message = "EMAIL_NOT_BLANK")
-	 @Email(message = "EMAIL_PATTERN_NOT_VALID")
-	 String email,
+public record OauthRequest(
 
-	 @NotNull(message = "SOCIAL_TYPE_REQUIRED")
-	 SocialType socialType,
+		@Email(message = "EMAIL_PATTERN_NOT_VALID")
+		String email,
 
-	 GenderType gender,
-	 @Min(value = 0, message = "AGE_MINIMUM")
-	 Integer age
-	) {
+		String socialUniqueId,
+
path: src/main/java/app/bottlenote/user/dto/request/OauthRequest.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Validation needs adjustment for social login types**

The `@NotBlank` annotation has been removed from the email field, making it optional to support Apple login flow. However, this change affects all social login types, not just Apple.


Consider adding conditional validation based on the social type:

```diff
public record OauthRequest(
		@Email(message = "EMAIL_PATTERN_NOT_VALID")
		String email,

		String socialUniqueId,

		@NotNull(message = "SOCIAL_TYPE_REQUIRED")
		SocialType socialType,

		GenderType gender,

		@Min(value = 0, message = "AGE_MINIMUM")
		Integer age
) {
+    // Add validation method to ensure email is provided for non-Apple logins
+    public void validate() {
+        if (socialType != SocialType.APPLE && (email == null || email.isBlank())) {
+            throw new ValidationException("EMAIL_REQUIRED_FOR_NON_APPLE_LOGIN");
+        }
+    }
}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
public record OauthRequest(
		@Email(message = "EMAIL_PATTERN_NOT_VALID")
		String email,

		String socialUniqueId,

		@NotNull(message = "SOCIAL_TYPE_REQUIRED")
		SocialType socialType,

		GenderType gender,

		@Min(value = 0, message = "AGE_MINIMUM")
		Integer age
) {
	// Add validation method to ensure email is provided for non-Apple logins
	public void validate() {
		if (socialType != SocialType.APPLE && (email == null || email.isBlank())) {
			throw new ValidationException("EMAIL_REQUIRED_FOR_NON_APPLE_LOGIN");
		}
	}
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-02T12:09:47Z
author_association: NONE
diff_hunk: @@ -68,25 +68,25 @@ class OauthServiceTest {
 	@BeforeEach
 	void setUp() {
 
-		request = new OauthRequest("cdm2883@naver.com", SocialType.KAKAO,
-			null, 26);
+		request = new OauthRequest("cdm2883@naver.com", null, SocialType.KAKAO,
+				null, 26);
path: src/test/java/app/bottlenote/user/service/OauthServiceTest.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add tests for Apple login scenarios**

The current tests only cover the KAKAO login path with a null `socialUniqueId`. With the introduction of special Apple login handling, dedicated test cases are needed.


Add test cases for Apple login scenarios:

```java
@Test
@DisplayName("애플 최초 로그인시 이메일과 소셜 고유 ID가 모두 있는 경우 회원가입이 진행된다")
void test_apple_first_login() {
    // given
    OauthRequest appleRequest = new OauthRequest("test@apple.com", "apple.unique.id.123", SocialType.APPLE, null, 26);
    
    // when
    when(oauthRepository.save(any(User.class))).thenReturn(user);
    when(jwtTokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId())).thenReturn(tokenItem);
    
    // then
    TokenItem result = oauthService.login(appleRequest);
    
    verify(oauthRepository, times(1)).save(any(User.class));
    assertThat(result.accessToken()).isEqualTo("mock-accessToken");
}

@Test
@DisplayName("애플 재로그인시 소셜 고유 ID만 있는 경우 해당 ID로 사용자를 찾는다")
void test_apple_subsequent_login() {
    // given
    OauthRequest appleRequest = new OauthRequest(null, "apple.unique.id.123", SocialType.APPLE, null, 26);
    
    // when
    when(oauthRepository.findBySocialUniqueId(anyString())).thenReturn(Optional.of(user));
    when(jwtTokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId())).thenReturn(tokenItem);
    
    // then
    TokenItem result = oauthService.login(appleRequest);
    
    verify(oauthRepository, never()).save(any(User.class));
    verify(oauthRepository, times(1)).findBySocialUniqueId(anyString());
    assertThat(result.accessToken()).isEqualTo("mock-accessToken");
}
```

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-02T12:09:47Z
author_association: NONE
diff_hunk: @@ -41,16 +41,51 @@ public class OauthService {
 	@Transactional
 	public TokenItem login(OauthRequest oauthReq) {
 		final String email = oauthReq.email();
+		final String socialUniqueId = oauthReq.socialUniqueId();
 		final SocialType socialType = oauthReq.socialType();
-		final GenderType genderType = oauthReq.gender();
-		final Integer age = oauthReq.age();
 
-		User user = oauthRepository.findByEmail(email).orElseGet(() -> oauthSignUp(email, socialType, genderType, age, UserType.ROLE_USER));
+		switch (socialType) {
+			case APPLE -> {
+				return doAppleLogin(oauthReq, socialUniqueId, email, socialType);
+			}
+			default -> {
+				return doLogin(oauthReq, email, socialType);
+			}
+		}
+	}
 
+	private TokenItem doLogin(OauthRequest oauthReq, String email, SocialType socialType) {
+		User user;
+		user = oauthRepository.findByEmail(email)
+				.orElseGet(() -> oauthSignUp(oauthReq, UserType.ROLE_USER));
+		checkActiveUser(user);
+		return getTokenItem(user, socialType);
+	}
path: src/main/java/app/bottlenote/user/service/OauthService.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add validation for email in non-Apple login flows**

The `doLogin` method doesn't check if the email is null or empty before attempting to find the user.


Add validation for email in the `doLogin` method:

```diff
private TokenItem doLogin(OauthRequest oauthReq, String email, SocialType socialType) {
+	if (email == null || email.isBlank()) {
+		log.error("ERROR : email is null or blank for non-Apple login");
+		throw new UserException(UserExceptionCode.INVALID_EMAIL);
+	}
	User user;
	user = oauthRepository.findByEmail(email)
			.orElseGet(() -> oauthSignUp(oauthReq, UserType.ROLE_USER));
	checkActiveUser(user);
	return getTokenItem(user, socialType);
}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
	private TokenItem doLogin(OauthRequest oauthReq, String email, SocialType socialType) {
		if (email == null || email.isBlank()) {
			log.error("ERROR : email is null or blank for non-Apple login");
			throw new UserException(UserExceptionCode.INVALID_EMAIL);
		}
		User user;
		user = oauthRepository.findByEmail(email)
				.orElseGet(() -> oauthSignUp(oauthReq, UserType.ROLE_USER));
		checkActiveUser(user);
		return getTokenItem(user, socialType);
	}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-02T12:09:47Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-01T15:15:31Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough
This pull request introduces a new field, `social_unique_id`, across multiple layers to support social login. The database schema is updated with a new column and unique constraint on the `users` table. The domain model (`User` class) and the data transfer object (`OauthRequest`) are enhanced with a new social identifier attribute. The exception enumeration and repository interfaces gain corresponding entries. Additionally, the login and signup logic in the `OauthService` are refactored to incorporate the new identifier, with various test files updated for formatting consistency and adjusted parameter ordering.

## Changes

| File(s) | Change Summary |
|---------|----------------|
| `database/mysql/init/01-init-table.sql` | Added a new nullable column `social_unique_id` with a comment, and applied a unique key constraint on it in the `users` table. |
| `src/main/java/app/bottlenote/user/domain/User.java` | Introduced a new private field `socialUniqueId` in the `User` class, annotated with `@Comment` and `@Column`. |
| `src/main/java/app/bottlenote/user/dto/request/OauthRequest.java` | Updated the record signature to include `socialUniqueId` (with adjusted parameter order) and removed the `@NotBlank` annotation from `email`. |
| `src/main/java/app/bottlenote/user/exception/UserExceptionCode.java` | Added a new constant `TEMPORARY_LOGIN_ERROR` with `HttpStatus.INTERNAL_SERVER_ERROR` and a corresponding message. |
| `src/main/java/app/bottlenote/user/repository/OauthRepository.java` | Introduced the method `findBySocialUniqueId(String socialUniqueId)` for user lookup. |
| `src/main/java/app/bottlenote/user/service/OauthService.java` | Refactored the login method to incorporate `socialUniqueId` using a switch-case for social types; added a helper method `checkActiveUser` and updated the `oauthSignUp` method signature. |
| Test Files (various under `src/test/java/app/bottlenote/...`) | Applied formatting adjustments and updated instantiations of `OauthRequest` as well as parameter order in method calls to align with DTO changes. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant U as User
    participant OS as OauthService
    participant OR as OauthRepository

    U->>OS: Initiate social login (email, socialUniqueId, socialType, etc.)
    alt socialType is APPLE
        alt Both socialUniqueId and email provided
            OS->>OS: Call oauthSignUp(email, socialUniqueId, socialType, ...)
            OS->>OS: Execute checkActiveUser
            OS->>U: Return token
        else Missing socialUniqueId
            OS->>U: Throw TEMPORARY_LOGIN_ERROR
        else socialUniqueId provided but email absent
            OS->>OR: findBySocialUniqueId(socialUniqueId)
            OS->>U: Return token if found; else, error
        end
    else Other social types
        OS->>OR: findByEmail(email)
        alt User found
            OS->>OS: Execute checkActiveUser
            OS->>U: Return token
        else
            OS->>OS: Call oauthSignUp(email, socialUniqueId, socialType, ...)
            OS->>OS: Execute checkActiveUser
            OS->>U: Return token
        end
    end
```

## Poem
> I’m a rabbit in the code, hopping with delight,  
> Adding fields and constraints in the database light.  
> Social logins bloom with each new line,  
> Tests and tweaks all align just fine.  
> With each change, I rap with glee,  
> CodeRabbit cheers — now run and see!  
> 🐰💻 Hop on!

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 185956d5e662b4ca80dd1de321c1bb550414d010 and 1ed922ae6b9ddfa9f7fe784c7375eaa812cebe4c.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/main/java/app/bottlenote/user/service/OauthService.java` (6 hunks)
* `src/test/java/app/bottlenote/user/service/OauthServiceTest.java` (6 hunks)

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: dev_ci

</details>

<details>
<summary>🔇 Additional comments (18)</summary><blockquote>

<details>
<summary>src/test/java/app/bottlenote/user/service/OauthServiceTest.java (10)</summary>

`71-72`: **Parameter order updated but no Apple login test cases**

The OauthRequest constructor has been updated to include a null parameter for `socialUniqueId`, but there are no test cases covering the new Apple login scenarios with both email and socialUniqueId present (first login) or with only socialUniqueId present (subsequent login).

---

`76-84`: **LGTM: Formatted code maintains correct structure**

The code formatting adjustments improve readability while maintaining the correct functional structure of the User builder pattern.

---

`86-89`: **LGTM: Consistent formatting applied**

The TokenItem builder formatting has been consistently applied with the rest of the code.

---

`103-104`: **LGTM: Proper indentation applied**

The method call formatting has been adjusted for better readability.

---

`122-123`: **LGTM: Consistent indentation applied**

The indentation adjustments maintain functionality while improving readability.

---

`141-142`: **LGTM: Method call formatting consistent with codebase style**

The formatting of the generateToken method call has been improved for consistency.

---

`159-160`: **LGTM: Consistent indentation pattern applied**

The method call indentation has been standardized to match the rest of the codebase.

---

`165-167`: **LGTM: Consistent formatting maintained**

The indentation has been adjusted to match the rest of the codebase while maintaining functionality.

---

`172-175`: **LGTM: Indentation improvements**

The generateToken method call has been properly indented for better readability.

---

`193-194`: **LGTM: Consistent formatting**

The consistent formatting improves the overall code readability.

</details>
<details>
<summary>src/main/java/app/bottlenote/user/service/OauthService.java (8)</summary>

`44-45`: **LGTM: Added socialUniqueId field extraction**

This change properly extracts the socialUniqueId from the OauthRequest for use in login authentication.

---

`47-55`: **Appropriate branching logic for different social login types**

The switch statement creates a clean separation of concerns between Apple login and other login methods, which is necessary for the different login flows.

---

`65-80`: **Well-structured Apple login handling**

The `doAppleLogin` method effectively handles both first-time login (with email and socialUniqueId) and subsequent logins (with only socialUniqueId). The error handling for missing socialUniqueId is appropriate.


However, consider adding debug/info logs for successful paths to complement the error logs:

```diff
private TokenItem doAppleLogin(OauthRequest oauthReq, String socialUniqueId, String email, SocialType socialType) {
	User user;
	//최초 로그인 (email과 socialUniqueId 모두 존재하는 경우)
	if (socialUniqueId != null && !socialUniqueId.isBlank() && email != null && !email.isBlank()) {
+		log.info("Apple first-time login with email: {} and socialUniqueId", email);
		user = oauthSignUp(oauthReq, UserType.ROLE_USER);
	} else {
		if (socialUniqueId == null) {
			log.error("ERROR : socialUniqueId is null");
			throw new UserException(UserExceptionCode.TEMPORARY_LOGIN_ERROR);
		}
+		log.info("Apple subsequent login with socialUniqueId");
		user = oauthRepository.findBySocialUniqueId(socialUniqueId).orElseThrow(
				() -> new UserException(UserExceptionCode.USER_NOT_FOUND));
	}
	checkActiveUser(user);
	return getTokenItem(user, socialType);
}
```

---

`82-85`: **LGTM: Good extraction of duplicate code**

Extracting the user activity check into a separate method reduces code duplication and improves maintainability.

---

`87-92`: **LGTM: Well-structured token generation method**

The refactored `getTokenItem` method properly handles the social type addition and token generation process.

---

`116-123`: **LGTM: Updated guest login to include socialUniqueId**

The guest login now correctly generates a random socialUniqueId for the guest user, which is consistent with the updated user model.

---

`130-142`: **LGTM: Simplified signature improves maintainability**

The refactored `oauthSignUp` method now accepts an OauthRequest object instead of individual parameters, which reduces parameter count and improves code maintainability.

---

`204-204`: **LGTM: Consistent user check**

The extracted `checkActiveUser` method is now consistently used across login methods.

</details>

</blockquote></details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=bottle-note/bottle-note-api-server&utm_content=360):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: chadongmin
created_at: 2025-04-02T12:08:32Z
body: 개발 db에 컬럼추가하고 로컬에서 서버 뜨는 것 확인했습니다.
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-02T12:41:09Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=bottle-note_bottle-note-api-server&pullRequest=360) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=bottle-note_bottle-note-api-server&pullRequest=360&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=bottle-note_bottle-note-api-server&pullRequest=360&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=bottle-note_bottle-note-api-server&pullRequest=360&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=bottle-note_bottle-note-api-server&pullRequest=360&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=bottle-note_bottle-note-api-server&pullRequest=360&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=bottle-note_bottle-note-api-server&pullRequest=360)


author_association: NONE