Skip to content

Feature/사용자 소셜 로그인, 회원가입 기능 구현#102

Merged
KII1ua merged 20 commits into
developfrom
feature/register
May 11, 2026
Merged

Feature/사용자 소셜 로그인, 회원가입 기능 구현#102
KII1ua merged 20 commits into
developfrom
feature/register

Conversation

@KII1ua
Copy link
Copy Markdown
Member

@KII1ua KII1ua commented May 9, 2026

📌 관련 이슈

🔍 작업 내용

RTR(Refresh Token Rotation) 도입: 리프레시 토큰 사용 시 기존 토큰을 폐기하고 새 토큰을 발급하여 보안성을 높였습니다.

상태 기반 온보딩 흐름: 로그인 성공 시 유저 상태(REGISTER, UNREGISTER)에 따라 홈 또는 추가 정보 페이지로 리다이렉트합니다.

프로필 초기화 기능: '나중에 하기' 선택 시 시스템이 자동으로 닉네임과 기본 이미지를 설정하는 기능을 추가했습니다.

📝 변경 사항

AuthService: reissueAccessToken 메서드에 RTR 로직(기존 토큰 revoke 및 신규 발급)을 적용했습니다.

OAuth2LoginSuccessHandler: 구글 로그인 성공 후 HttpOnly 쿠키 발급 및 상태별 리다이렉트 로직을 추가했습니다.

User/Token Entity: 토큰 폐기를 위한 revoked 필드 관리 및 프로필 초기화를 위한 initializeDefault 메서드를 추가했습니다.

DTO: LoginTokenResponse에 UserStatus 필드를 추가하고, TokenReissueResponse를 통해 재발급 응답 형식을 통일했습니다.

Config: application.yml의 환경 변수(만료 시간, URL 등)를 @value로 주입받아 하드코딩을 제거하고 설정을 중앙 집중화했습니다.

ErrorCode: REVOKED_TOKEN, TOKEN_NOT_FOUND 등 커스텀 에러 코드 정의

CustomException: 도메인 비즈니스 예외 발생 시 일관된 응답을 위해 적용

💬 리뷰어에게

@KII1ua KII1ua added the ✨feature 구현, 개선 사항 관련 부분 label May 9, 2026
@KII1ua KII1ua linked an issue May 9, 2026 that may be closed by this pull request
@KII1ua
Copy link
Copy Markdown
Member Author

KII1ua commented May 9, 2026

빌드 시작

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 9, 2026

빌드 실패
코드를 확인해주세요.

return http
public SecurityFilterChain securityFilterChain(HttpSecurity http, OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler, CustomOAuth2UserService customOAuth2UserService) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/api/**를 전체 공개 경로로 두면 이번 PR의 /api/users/**처럼 사용자 컨텍스트가 필요한 API까지 기본적으로 인증을 우회하게 됩니다. 이후 API가 추가될 때도 실수로 공개될 가능성이 커서, 인증/미인증 경로 관리 원칙을 명확히 잡는 게 좋겠습니다.

제안:

  • 기본 정책은 protected-by-default로 두고, 공개 API만 allowlist로 명시
  • /api/** wildcard 공개는 제거
  • 공개 경로는 가능하면 HTTP method까지 지정 (GET /actuator/health, POST /auth/reissue 등)
  • SecurityConfigJwtAuthFilter의 예외 목록이 따로 drift 나지 않도록 한 곳에서 관리하거나 Security matcher 정책으로 통일
  • /api/users/me, /api/users/me/profile, /api/users/nickname/suggest 등은 JWT 필터가 동작하는 인증 경로로 분류하고, 컨트롤러에서 쿠키를 직접 파싱하기보다 SecurityContext / @AuthenticationPrincipal 기반으로 사용자 식별을 통일

예시:

.authorizeHttpRequests(auth -> auth
    .requestMatchers(PUBLIC_ENDPOINTS).permitAll()
    .requestMatchers(HttpMethod.POST, "/auth/reissue").permitAll()
    .anyRequest().authenticated()
)

return http
public SecurityFilterChain securityFilterChain(HttpSecurity http, OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler, CustomOAuth2UserService customOAuth2UserService) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쿠키 기반 인증을 사용하는데 CSRF가 비활성화되어 있습니다. HttpOnly 쿠키는 브라우저가 자동 첨부하므로 /api/users/me/profile, /auth/reissue 같은 상태 변경 API는 CSRF 공격 대상이 될 수 있습니다.

권장: refresh/reissue 및 상태 변경 API에 CSRF 토큰 또는 double-submit cookie 패턴을 적용하거나, 인증 방식을 Authorization 헤더 기반으로 바꾸는 방향을 검토해 주세요. 특히 재발급 쿠키는 SameSite=None으로 내려가므로 더 주의가 필요합니다.


switch (status) {
case VALID -> {
setAuthentication(request, accessToken);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 유효한 JWT인지 여부만 보고 인증을 세팅하면 refresh token도 인증 토큰처럼 처리될 수 있습니다. 기존에는 tokenInfo.isAccessToken() 확인이 있었는데 현재는 parseToken() 이후 token type 검증이 없습니다.

권장: TokenType.ACCESS일 때만 SecurityContext에 인증을 세팅하고, refresh token은 /auth/reissue에서만 허용되도록 제한해 주세요.

private final CookieUtil cookieUtil;

@PostMapping("/me/profile")
public ResponseEntity<UserProfileResponse> setupInfo(HttpServletRequest request, @RequestBody UserExtraInfo userExtraInfo) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요청 DTO에 Bean Validation이 없고 컨트롤러에서도 @Valid가 적용되지 않아 빈 값/과도하게 긴 값/null enum 등이 그대로 서비스로 들어갈 수 있습니다.

권장: UserExtraInfo, UserProfileRequest, UserNicknameRec@NotBlank, @Size, @Email, birth year 범위, enum @NotNull 등을 추가하고, 컨트롤러의 @RequestBody@Valid를 적용해 주세요. nickname은 중복 체크와 저장 사이 race condition 방지를 위해 DB unique constraint도 함께 고려하면 좋습니다.

throw new IllegalArgumentException("지정된 토큰 타입이 아닙니다.");
// 회수된 토큰인지 확인
if (savedToken.isRevoked()) {
throw new CustomException(ErrorCode.REVOKED_TOKEN);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rotation 이후 revoked refresh token이 다시 들어오는 것은 토큰 탈취 신호일 수 있는데, 현재는 해당 요청만 REVOKED_TOKEN으로 거절합니다.

권장: revoked refresh token 재사용 감지 시 해당 유저의 활성 refresh token 전체 revoke, 재로그인 요구, 보안 로그 기록을 고려해 주세요.

if (!StringUtils.hasText(accessToken) || !jwtProvider.validationToken(accessToken)) {
filterChain.doFilter(request, response);
return;
TokenStatus status = jwtProvider.validationToken(accessToken);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 변경으로 JwtAuthFilter 생성자와 validationToken() 반환 타입이 바뀌었는데 기존 테스트가 반영되지 않아 로컬에서 ./gradlew test --no-daemon 실행 시 테스트 컴파일이 실패합니다.

보안 로직 변경인 만큼 TokenStatus.VALID/EXPIRED/INVALID/EMPTY, access/refresh token type 구분 케이스를 회귀 테스트로 같이 보강하면 좋겠습니다.

}

@GetMapping("/info")
public ResponseEntity<UserProfileDefaultResponse> initializeDefaultProfile(HttpServletRequest request, @RequestBody UserProfileRequest userInfo) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GET 요청에 @RequestBody를 사용하는 패턴은 클라이언트/프록시/문서화 도구에서 일관되게 처리되지 않을 수 있습니다. 또한 메서드 이름이 initializeDefaultProfile이고 내부에서 사용자 정보를 저장한다면 조회보다는 상태 변경에 가까워 보입니다.

권장: 상태 변경이면 POST /api/users/info 또는 더 명확한 경로로 변경하고, 단순 조회라면 request body 대신 query param/path param으로 분리하는 편이 좋겠습니다.

public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findBySub(String sub);

@Query("select (count(u.id) = 0) from User u where u.nickname = :nickName")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메서드 이름은 existsUser인데 쿼리는 count(u.id) = 0이라서 실제 의미는 “존재 여부”가 아니라 “닉네임 사용 가능 여부/중복 없음”에 가깝습니다. 호출부에서 의미를 반대로 해석하기 쉬워 보입니다.

권장: isNicknameAvailable, isNicknameUnique, notExistsByNickname처럼 반환값의 의미가 드러나는 이름으로 바꾸면 좋겠습니다. 가능하면 Spring Data의 existsByNickname을 쓰고 서비스에서 !existsByNickname(...)로 표현하는 방식도 명확합니다.

try {
words2 = loadWords("classpath:data/ko_words_2.txt");
words3 = loadWords("classpath:data/ko_words_3.txt");
} catch (IOException e) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리소스 로딩 실패를 빈 catch로 삼키면 운영에서 단어 파일이 누락되거나 인코딩 문제가 생겨도 원인을 파악하기 어렵습니다. 이후 words2/words3가 빈 상태로 남아 닉네임 생성 품질에도 영향을 줄 수 있습니다.

권장: 최소한 로그를 남기거나, 닉네임 생성에 필수 리소스라면 애플리케이션 시작 실패로 처리하는 편이 좋겠습니다.


private void addTokenCookies(HttpServletResponse response, LoginTokenResponse loginResponse) {
// 30분
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", loginResponse.getAccessToken())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서는 쿠키 이름을 문자열로 직접 쓰고 있는데, 다른 곳에서는 CookieUtil.CookieType.ACCESS_TOKEN/REFRESH_TOKEN 상수를 사용하고 있어 정책이 drift 날 수 있습니다. 또한 토큰 쿠키 생성 로직이 AuthController에도 중복되어 있어 secure, sameSite, maxAge 설정이 서로 달라지기 쉽습니다.

권장: 쿠키 이름 상수를 재사용하고, 가능하면 CookieUtil 또는 별도 TokenCookieFactory로 access/refresh cookie 생성 로직을 모으면 유지보수성이 좋아질 것 같습니다.

this.userStatus = UserStatus.REGISTER;
}

public void initializeDefault(UserProfileRequest userInfo, String nickname) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateInfo()는 추가 정보 입력 후 userStatusREGISTER로 변경하는데, initializeDefault()는 email/birthYear/gender/nickname/imageColor를 채워도 상태를 변경하지 않습니다. 두 플로우의 의도 차이가 명확하지 않으면 가입 완료 여부가 일관되지 않을 수 있어 보입니다.

권장: 기본 프로필 초기화도 가입 완료로 보는 흐름이라면 userStatus = UserStatus.REGISTER를 같이 설정하고, 아니라면 메서드명/주석으로 상태를 유지하는 이유를 명확히 해두면 좋겠습니다.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Enumerated(EnumType.STRING)
private UserStatus userStatus = UserStatus.UNREGISTER;

아 default value를 넣고있군요... 이를 명시적으로 설정해주면 좋아보여요

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 이부분 이해 못했는데 자세히 설명해주실 수 있을까요??

Junhyukkkk
Junhyukkkk previously approved these changes May 10, 2026
Copy link
Copy Markdown
Member

@Junhyukkkk Junhyukkkk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다~~

TOKEN_NOT_FOUND("E400003", "토큰이 존재하지 않습니다.", HttpStatus.UNAUTHORIZED),
REFRESH_TOKEN_EXPIRED("E400004", "재발급 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED),
INVALID_TOKEN_TYPE("E400005", "해당 토큰의 종류가 다릅니다.", HttpStatus.UNAUTHORIZED),
REVOKED_TOKEN("E400006", "회수된 토큰입니다.", HttpStatus.UNAUTHORIZED);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 ErrorCode가 단일 enum으로 구현되어 있는데, ErrorCode를 인터페이스로 추상화하고 도메인별 enum(VoteErrorCode 등)이 이를 구현하는 방식으로 변경하려 합니다.
두 방식이 충돌하고 있어서 방향을 맞추면 좋을 것 같아요. 제안하는 구조는 아래와 같습니다.

public interface ErrorCode {
    String getCode();
    String getMessage();
    HttpStatus getStatus();
}
public enum AuthErrorCode implements ErrorCode { ... }
public enum VoteErrorCode implements ErrorCode { ... }

이렇게 하면 도메인이 늘어날수록 단일 enum이 비대해지는 걸 막을 수 있고, GlobalExceptionHandler도 ErrorCode 인터페이스 하나만 바라보면 되어서 훨씬 깔끔해질 꺼 같습니다.

Comment on lines +47 to +52
@GetMapping("/me")
public ResponseEntity<UserProfileResponse> getMyProfile(@AuthenticationPrincipal Long userId) {
UserProfileResponse response = userService.getUserProfile(userId);

return ResponseEntity.ok(response);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위 setupInfo 메서드에서는 쿠키에서 직접 토큰을 빼고 여기선 @AuthenticationPrincipal을 사용하는데 위에서 굳이 쿠키에서 직접 토큰을 빼서 검증하는 이유가 있을까요?

Comment on lines 72 to +80
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
return TokenStatus.VALID;
} catch (io.jsonwebtoken.ExpiredJwtException e) {
return TokenStatus.EXPIRED;
} catch (JwtException | IllegalArgumentException e) {
return false;
return TokenStatus.INVALID;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validationToken 메서드가 TokenStatus만 반환해서 JWTAuthFiltelr에서 parser가 반복되는 거 같은데 여기서 tokenstatus만 반환하지말고 parser한 값을 반환하는 건 어떨까요?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 임의로 User 내에 Gender를 넣어뒀는데 나중에 합칠 때 제가 변경해 놓을게요!

tlarbals824 and others added 10 commits May 12, 2026 02:38
Keep the local browser origin list aligned with the frontend development port currently in use.\n\nConstraint: User requested port 3000 only for now.\nRejected: Keeping Vite port 5173 | It is unnecessary for the current frontend setup.\nConfidence: high\nScope-risk: narrow\nTested: ./gradlew test
운영 환경의 기본 브라우저 허용 origin을 실제 서비스 도메인으로 맞춥니다.\n\nConstraint: 운영 도메인은 vs.io.kr입니다.\nRejected: 예시 도메인 유지 | 실제 운영 기본값으로 사용할 수 없습니다.\nConfidence: high\nScope-risk: narrow\nTested: ./gradlew test
Use the push event before/after range so API client publishing is gated by the changes introduced by the main push, not by the current develop/main branch delta.\n\nConstraint: dorny/paths-filter defaults compared develop...main on release pushes, missing src changes already present in develop.\nRejected: Always publish API client | would publish even for docs-only releases.\nConfidence: high\nScope-risk: narrow\nDirective: Keep release path filtering anchored to push event SHAs for main push workflows.\nTested: git diff --check\nNot-tested: GitHub Actions runtime execution
Switch the runtime stage to the Alpine Temurin JRE so the healthcheck can use BusyBox wget already present in the image. This removes apt package downloads from Docker builds.\n\nConstraint: GitHub Actions image builds were spending minutes in apt-get installing curl for healthchecks.\nRejected: Keep Ubuntu Temurin and install curl | preserves the slow apt network path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep container healthchecks dependency-free from apt packages.\nTested: docker manifest inspect eclipse-temurin:25-jre-alpine; git diff --check\nNot-tested: Full docker build
Move release Docker publishing to a jar-only runtime image and use Gradle's CI cache for the application build. This keeps Docker focused on packaging and lets setup-gradle own dependency and build-cache reuse across CI jobs.\n\nConstraint: Docker-internal Gradle builds could not directly reuse the host Gradle cache used by CI.\nRejected: BuildKit-only Gradle cache inside Docker | improves the old shape but still couples compilation to image construction.\nConfidence: high\nScope-risk: moderate\nDirective: Keep release image builds jar-only unless runtime packaging needs source-aware image generation.\nTested: ./gradlew build --build-cache --no-daemon; docker build --no-cache -t ject-server:jar-only-runtime-test .; container app.jar/wget BusyBox smoke; git diff --check\nNot-tested: actionlint unavailable locally; GitHub Actions cache round-trip
Permit the current production API to serve browser requests from local frontend dev origins while consolidating local-only Spring settings under the local profile. Keep the openapi profile intact for generated-client extraction.\n\nConstraint: The team has no separate development API server, so local Next.js testing needs to reach the deployed API.\nRejected: Wildcard CORS origins | unsafe with credentials enabled.\nConfidence: high\nScope-risk: moderate\nDirective: Remove localhost origins once a dedicated development API or BFF/proxy flow is available.\nTested: ./gradlew test --no-daemon; git diff --check\nNot-tested: Deployed CORS preflight after merge
@KII1ua KII1ua merged commit a1c9547 into develop May 11, 2026
@KII1ua KII1ua deleted the feature/register branch May 11, 2026 17:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨feature 구현, 개선 사항 관련 부분

Projects

None yet

Development

Successfully merging this pull request may close these issues.

사용자 소셜 로그인, 회원가입 기능 구현

3 participants