Conversation
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 개요Spring Boot 3.5.9에서 4.0.2로 업그레이드하고 gRPC, Redis, OAuth2, 이메일 기능을 포함한 포괄적인 인증 시스템으로 리팩터링했습니다. REST 기반 사용자 관리에서 마이크로서비스 아키텍처로 전환하며, 프로토콜 버퍼, JWT 토큰 관리, 비동기 이메일 처리를 도입했습니다. 변경사항
시퀀스 다이어그램sequenceDiagram
participant Client
participant AuthController
participant AuthService
participant JwtProvider
participant UserRepository
participant EventPublisher
Client->>AuthController: POST /v1/auth/register<br/>(SignupRequest)
AuthController->>AuthService: register(request)
AuthService->>UserRepository: findByEmail(email)
alt Email exists
AuthService-->>Client: Error (EMAIL_ALREADY_EXISTS)
else Email not found
AuthService->>UserRepository: save(User)
AuthService->>EventPublisher: publish(EmailVerificationSendEvent)
AuthService->>JwtProvider: generateTokenPair(user)
AuthService-->>AuthController: UserResponse
AuthController-->>Client: 201 Created + Tokens in Cookies
end
sequenceDiagram
participant Client
participant OAuthController
participant OAuthService
participant OAuthApiClient
participant UserRepository
participant OAuthLinkRepository
Client->>OAuthController: GET /oauth2/authorization/google?userId=123
OAuthController->>OAuthService: getAuthorizationUri(provider, userId)
OAuthService->>OAuthApiClient: buildAuthorizeUri(provider)
OAuthService-->>OAuthController: AuthorizationRedirect
OAuthController-->>Client: 302 Redirect + Verifier Cookie
Note over Client: User authorizes and redirects back
Client->>OAuthController: GET /oauth2/callback/google?code=xyz&state=abc
OAuthController->>OAuthService: socialLogin(provider, code, verifier)
OAuthService->>OAuthApiClient: requestAccessToken()
OAuthApiClient-->>OAuthService: accessToken
OAuthService->>OAuthApiClient: requestUserInfo(accessToken)
OAuthApiClient-->>OAuthService: userInfo(email, providerId)
OAuthService->>OAuthLinkRepository: findByProviderAndProviderId()
OAuthService->>UserRepository: findByEmail()
OAuthService-->>OAuthController: TokenPair
OAuthController-->>Client: 302 Redirect + Token Cookies
예상 코드 리뷰 난이도🎯 4 (복잡함) | ⏱️ ~45분 시
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 18
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/main/java/flipnote/user/domain/user/presentation/dto/request/LoginRequest.java (1)
16-17:⚠️ Potential issue | 🟡 Minor비밀번호 필드에 최소 길이 제약이 없습니다.
@NotBlank만으로는 길이 1인 비밀번호도 통과합니다.@Size(min = 8)등 최소 길이 제약을 추가하세요.🛡️ 수정 제안
- `@NotBlank`(message = "비밀번호는 필수입니다") + `@NotBlank`(message = "비밀번호는 필수입니다") + `@Size`(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다") private String password;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/presentation/dto/request/LoginRequest.java` around lines 16 - 17, LoginRequest currently only uses `@NotBlank` for the password field which allows 1-char passwords; add a minimum length constraint by annotating the password field with `@Size`(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다") (and import javax.validation.constraints.Size) so validation enforces at least 8 characters; keep `@NotBlank` as well if you want non-whitespace enforcement.
🟡 Minor comments (11)
src/main/java/flipnote/user/global/config/ResendProperties.java-18-19 (1)
18-19:⚠️ Potential issue | 🟡 Minor
fromEmail에 이메일 형식 유효성 검증이 없습니다.
@NotEmpty만으로는 유효하지 않은 이메일 주소(예:"notanemail")가 설정되어도 기동 시 감지되지 않습니다.🛡️ 수정 제안
+ import jakarta.validation.constraints.Email; ... + `@Email`(message = "올바른 이메일 형식이 아닙니다") `@NotEmpty` private final String fromEmail;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/config/ResendProperties.java` around lines 18 - 19, The fromEmail field in ResendProperties lacks email-format validation: add the `@Email` constraint to the fromEmail field in class ResendProperties (alongside the existing `@NotEmpty`) so invalid addresses are rejected at startup, and update the imports to include the appropriate Email annotation (e.g., jakarta.validation.constraints.Email or javax.validation.constraints.Email to match the project).src/main/resources/application.yml-68-69 (1)
68-69:⚠️ Potential issue | 🟡 Minor
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET에 빈 문자열 기본값이 설정되어 있습니다.기본값이 빈 문자열(
${GOOGLE_CLIENT_ID:})이므로 환경변수가 설정되지 않아도 애플리케이션이 정상적으로 기동됩니다. 이 경우 OAuth 인증이 런타임에 자격증명 오류로 조용히 실패할 수 있습니다. 미설정 시 기동을 실패시키려면 기본값을 제거하는 것을 권장합니다.- client-id: ${GOOGLE_CLIENT_ID:} - client-secret: ${GOOGLE_CLIENT_SECRET:} + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/resources/application.yml` around lines 68 - 69, The application.yml currently provides empty-string defaults for the Google OAuth keys (client-id: ${GOOGLE_CLIENT_ID:} and client-secret: ${GOOGLE_CLIENT_SECRET:}), allowing startup to succeed with missing credentials; remove the empty-defaults so the placeholders require environment variables (change ${GOOGLE_CLIENT_ID:} -> ${GOOGLE_CLIENT_ID} and ${GOOGLE_CLIENT_SECRET:} -> ${GOOGLE_CLIENT_SECRET}) or otherwise enforce presence via a validation mechanism so the app fails fast when GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are not set.src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java-23-26 (1)
23-26:⚠️ Potential issue | 🟡 Minor
Long::parseLong실패 시 예외가 전파됩니다.Redis 값이 손상되거나 예상치 못한 형식일 경우
Long::parseLong이NumberFormatException을 던지며, 이는Optional.empty()가 아닌 500 오류로 전파됩니다.🛡️ 방어적 파싱 제안
public Optional<Long> findUserIdByState(String state) { String value = redisTemplate.opsForValue().get(KEY_PREFIX + state); - return Optional.ofNullable(value).map(Long::parseLong); + if (value == null) return Optional.empty(); + try { + return Optional.of(Long.parseLong(value)); + } catch (NumberFormatException e) { + redisTemplate.delete(KEY_PREFIX + state); + return Optional.empty(); + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java` around lines 23 - 26, In SocialLinkTokenRepository.findUserIdByState, guard against NumberFormatException when converting the Redis string to Long: retrieve the value from redisTemplate.opsForValue().get(KEY_PREFIX + state) then attempt parsing inside a try-catch (or use a safe parse utility) and return Optional.empty() on any parse failure instead of letting the exception propagate; ensure the method still returns Optional.of(Long) when parsing succeeds.src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java-13-14 (1)
13-14:⚠️ Potential issue | 🟡 Minor
nickname에@Size유효성 검사 누락 —SignupRequest와 불일치
SignupRequest는@Size(min = 2, max = 50)을 적용하고 있으나UpdateProfileRequest에는 없어, 1자 혹은 DB 컬럼 길이를 초과하는 닉네임이 API 레이어를 통과할 수 있습니다.🛡️ 제안된 수정
+import jakarta.validation.constraints.Size; `@NotBlank`(message = "닉네임은 필수입니다") +@Size(min = 2, max = 50, message = "닉네임은 2자 이상 50자 이하여야 합니다") private String nickname;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java` around lines 13 - 14, UpdateProfileRequest의 nickname 필드에 SignupRequest와 동일한 길이 제약이 빠져 있어 1자 입력 또는 DB 길이 초과가 허용됩니다; UpdateProfileRequest 클래스의 private String nickname 필드에 `@Size`(min = 2, max = 50) 애노테이션을 추가하고 필요한 javax/validation import를 추가해 SignupRequest의 검증 규칙과 일치시키세요 (참조: UpdateProfileRequest.nickname, SignupRequest).src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java-3-8 (1)
3-8:⚠️ Potential issue | 🟡 MinorJWT 클레임에 이메일(PII) 포함 여부 재검토
userId만으로 충분한지 검토하고, 이메일이 꼭 필요한 경우 로깅 설정에서 토큰 값이 마스킹되는지 확인해 주세요.또한
role필드는String대신 enum 타입을 사용하면 타입 안전성이 향상됩니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java` around lines 3 - 8, TokenClaims currently exposes a raw email (PII) and a String role; remove or stop populating the email field in the JWT claims unless strictly required (use userId alone for identity), or replace it with a non-reversible identifier (e.g., hashedEmail or externalId) and ensure any token logging/middleware masks token values; also change the role field from String to a dedicated enum (e.g., Role) and update usages that construct or read TokenClaims to use that enum for type safety (refer to the TokenClaims record, its constructor/creators, and any token encoding/decoding code to apply these changes).src/main/java/flipnote/user/domain/user/domain/UserException.java-11-14 (1)
11-14:⚠️ Potential issue | 🟡 Minor
errorCodenull 방어 코드 누락
errorCode가null로 전달될 경우errorCode.getMessage()호출 시NullPointerException이 발생합니다.UserErrorCode열거형으로만 사용되는 지금은 문제없지만, 향후 다른ErrorCode구현이 추가될 때를 대비해 방어 코드를 추가하는 것을 권장합니다.🛡️ 제안 수정
public UserException(ErrorCode errorCode) { + super(errorCode != null ? errorCode.getMessage() : null); - super(errorCode.getMessage()); this.errorCode = errorCode; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/domain/UserException.java` around lines 11 - 14, The UserException constructor calls errorCode.getMessage() without guarding against null; add a null check in the UserException(ErrorCode errorCode) constructor (referencing UserException and ErrorCode) and handle a null argument explicitly—either throw an IllegalArgumentException("errorCode must not be null") to fail-fast or use a safe fallback (e.g., call super("Unknown error") and set this.errorCode to a default UserErrorCode.UNKNOWN) so that errorCode.getMessage() is never invoked on null.src/main/resources/templates/email/password-reset.html-28-30 (1)
28-30:⚠️ Potential issue | 🟡 Minor
th:href="${link}"에서 사용하는 변수 표현식 — 서버 측 검증으로 강화 권장현재
th:href="${link}"는 Thymeleaf 변수 표현식을 사용하고 있어 Thymeleaf 자체에서javascript:스킴을 자동으로 차단하지 않습니다. 다만 이메일 템플릿의 특성상 직접적인 보안 위험은 낮습니다. 링크가 서버의 설정 값과 토큰으로 생성되며 사용자 입력이 직접 포함되지 않기 때문입니다.방어 깊이 원칙에 따라
ResendMailService에서link값을 템플릿에 전달하기 전에https://로 시작하는지 검증하는 것을 권장합니다.🛡️ 제안: ResendMailService에서 링크 검증 추가
// link 전달 전 검증 if (!link.startsWith("https://")) { throw new IllegalArgumentException("비밀번호 재설정 링크는 https://로 시작해야 합니다"); } context.setVariable("link", link);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/resources/templates/email/password-reset.html` around lines 28 - 30, Validate the reset link in ResendMailService before passing it to the Thymeleaf template (the variable used by th:href="${link}"): ensure the string starts with "https://" (or otherwise meets your allowed URL policy) and throw or handle an error if it does not, then call context.setVariable("link", link) only after the check so the template never receives an unvalidated value.src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java-16-18 (1)
16-18:⚠️ Potential issue | 🟡 Minor
imageRefId가 항상null로 반환됩니다.
from()팩토리 메서드에서imageRefId를 항상null로 고정하고 있습니다. 이 필드가 미래 이미지 서비스 통합을 위한 플레이스홀더라면, 명시적인 주석(// TODO)을 추가하거나, 실제 값을 채울 수 없는 경우 응답 DTO에서 필드를 제거하는 것을 고려해 주세요. API 소비자가 항상null을 받게 되므로 혼동을 줄 수 있습니다.
UserUpdateResponse.from()도 동일한 패턴을 갖습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java` around lines 16 - 18, The factory method UserInfoResponse.from currently hardcodes imageRefId to null; update it to populate the field from the domain model (e.g., use user.getImageRefId() or the equivalent accessor) so the DTO returns actual data, and make the same change for UserUpdateResponse.from; if there is no value yet by design, replace the hardcoded null with a clear TODO comment on the imageRefId field (or remove the field from the DTO) and add a unit/test or comment stating the integration is pending.src/main/java/flipnote/user/domain/user/presentation/UserController.java-28-32 (1)
28-32:⚠️ Potential issue | 🟡 Minor
GET /{userId}엔드포인트에 인증 검증이 부재합니다.
GET /me,PUT /,DELETE /엔드포인트는 모두X-USER-ID헤더를 요구하는 반면,GET /{userId}는 경로 변수로 userId를 받기만 합니다. 게이트웨이를 통해 이 엔드포인트가 외부에 노출되는 경우, 인증된 사용자가 임의의 userId로 다른 사용자 정보를 조회할 수 있습니다. 다음 중 하나를 선택하세요:
@RequestHeader(HttpConstants.USER_ID_HEADER) Long userId파라미터를 추가하고 접근 권한을 검증하거나- 이 엔드포인트가 내부 서비스 간 전용이라면 클래스 또는 메서드에 명확한 주석을 추가해 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/presentation/UserController.java` around lines 28 - 32, The GET /{userId} handler in UserController (getUserInfo) lacks authentication header validation; update the method signature to accept the authenticated caller id via `@RequestHeader`(HttpConstants.USER_ID_HEADER) Long callerUserId and enforce access control inside getUserInfo (or call a new/existing authorization helper) to verify the caller is allowed to view the requested userId, or if this endpoint is strictly internal add a clear class- or method-level comment stating it's internal-only and ensure any gateway routing rules block external access; reference UserController#getUserInfo and HttpConstants.USER_ID_HEADER when making the change.src/main/proto/user_query.proto-7-7 (1)
7-7:⚠️ Potential issue | 🟡 MinorProto 파일 디렉토리가 패키지 이름과 일치하지 않습니다.
Buf 린트에서
user_query패키지의 파일은user_query디렉토리 내에 있어야 한다고 보고하고 있습니다. 현재 파일은src/main/proto/에 위치해 있지만,src/main/proto/user_query/user_query.proto로 이동해야 합니다.제안: 파일 이동
파일을
src/main/proto/user_query/user_query.proto로 이동하세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/proto/user_query.proto` at line 7, 현재 proto 패키지 선언(package user_query)과 파일 디렉토리 구조가 일치하지 않습니다; user_query 패키지를 사용하는 user_query.proto 파일(심볼: package user_query, 파일명: user_query.proto)을 src/main/proto/user_query/ 폴더로 옮기고 기존 위치에서 삭제(또는 이동 커밋)하세요; 이동 후 다른 proto 파일들이 상대경로로 이 파일을 import 한다면 import 경로들을 새 위치(src/main/proto/user_query/user_query.proto)에 맞게 업데이트하세요.src/main/java/flipnote/user/domain/user/application/AuthService.java-90-96 (1)
90-96:⚠️ Potential issue | 🟡 Minor세션 무효화 검증 시
ZoneId.systemDefault()사용 — 분산 환경에서 시간대 불일치 위험Lines 92-93과 131-132에서
ZoneId.systemDefault()를 사용합니다. JVM 인스턴스마다 기본 시간대가 다를 수 있어 토큰 만료 판정이 일관되지 않을 수 있습니다.invalidatedAt필드가LocalDateTime이라면 UTC 기반Instant나OffsetDateTime으로 전환하는 것이 안전합니다.또한 이 세션 무효화 검증 로직이
refreshToken과validateToken에서 중복되고 있으므로 private 메서드로 추출을 권장합니다.♻️ 공통 메서드 추출 + UTC 사용 제안
+ private void checkSessionInvalidation(User user, String token) { + if (user.getInvalidatedAt() != null) { + Date issuedAt = jwtProvider.getIssuedAt(token); + if (issuedAt.before(Date.from(user.getInvalidatedAt() + .atZone(ZoneOffset.UTC).toInstant()))) { + throw new UserException(UserErrorCode.INVALIDATED_SESSION); + } + } + }Also applies to: 129-135
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/application/AuthService.java` around lines 90 - 96, The session invalidation check in AuthService duplicates logic in refreshToken and validateToken and uses ZoneId.systemDefault(), which can yield inconsistent results across JVMs; extract the logic into a private helper (e.g., verifySessionNotInvalidated(User user, String token)) and in it convert user.getInvalidatedAt() to an Instant using UTC (use ZoneOffset.UTC or OffsetDateTime/Instant conversion) before comparing with jwtProvider.getIssuedAt(refreshToken) to perform the before() check; update both refreshToken and validateToken to call this new helper.
🧹 Nitpick comments (30)
src/main/java/flipnote/user/global/entity/BaseEntity.java (1)
16-24: 전체적으로 잘 구현되어 있습니다.표준적인 Spring Data JPA Auditing 패턴을 따르고 있으며,
@CreatedDate에updatable = false를 적용한 점이 좋습니다.한 가지 사소한 제안:
modifiedAt필드에도createdAt과 일관성을 위해@Column(nullable = false)설정을 추가하는 것을 고려해 보세요. Spring Data JPA는 엔티티 최초 저장 시@LastModifiedDate도 함께 설정하므로 해당 값이null이 되는 경우는 없습니다.♻️ 일관성을 위한 수정 제안
`@LastModifiedDate` + `@Column`(nullable = false) private LocalDateTime modifiedAt;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/entity/BaseEntity.java` around lines 16 - 24, Add a non-null constraint to the modifiedAt field in BaseEntity by annotating the modifiedAt field with `@Column`(nullable = false); locate the modifiedAt declaration in the BaseEntity class and add the `@Column`(nullable = false) annotation above the private LocalDateTime modifiedAt; keep the existing `@LastModifiedDate` annotation intact.src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java (1)
10-12:SecureRandom명시적 선언 패턴과의 일관성 (선택적 개선)
UUID.randomUUID()는 내부적으로SecureRandom을 사용하므로 암호학적으로 안전합니다. 다만 동일 패키지의VerificationCodeGenerator와PkceUtil은 모두private static final SecureRandom필드를 명시적으로 선언해 난수 소스를 직접 노출하는 패턴을 사용합니다. 일관성을 위해 아래와 같이 동일한 패턴을 적용하는 것을 고려해 보세요.♻️ SecureRandom 기반으로 리팩터링 제안
package flipnote.user.domain.user.infrastructure; +import java.security.SecureRandom; +import java.util.Base64; import org.springframework.stereotype.Component; -import java.util.UUID; - `@Component` public class PasswordResetTokenGenerator { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + public String generate() { - return UUID.randomUUID().toString(); + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } }256비트 엔트로피 + URL-safe Base64 인코딩으로 UUID(122비트)보다 높은 보안 강도를 제공하며,
PkceUtil의generateCodeVerifier()와 동일한 방식입니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java` around lines 10 - 12, PasswordResetTokenGenerator.generate() currently returns UUID.randomUUID() but should follow the same explicit SecureRandom pattern used by VerificationCodeGenerator and PkceUtil; add a private static final SecureRandom (e.g., SECURE_RANDOM) to the class and implement generate() to produce 32 bytes of entropy from SECURE_RANDOM and return a URL-safe Base64 (without padding) string (256-bit entropy, matching PkceUtil style) so the token generation is consistent across classes.src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java (1)
7-8:infrastructure패키지 위치 재고 — 외부 의존성이 없는 순수 도메인 유틸리티입니다.
VerificationCodeGenerator는 DB, 메시징, HTTP 등 외부 인프라에 전혀 의존하지 않습니다. DDD 관점에서infrastructure패키지는 외부 시스템 어댑터를 위한 공간이므로, 이 클래스는domain레이어 내부(예:flipnote.user.domain.user)에 두는 것이 계층 역할에 더 부합합니다.♻️ 패키지 이동 제안
-package flipnote.user.domain.user.infrastructure; +package flipnote.user.domain.user;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java` around lines 7 - 8, VerificationCodeGenerator is a pure domain utility placed in the infrastructure package; move it into the domain layer by relocating the class (VerificationCodeGenerator) to the flipnote.user.domain.user package, update its package declaration and imports accordingly, and remove the inappropriate infrastructure stereotype if present (e.g., consider removing `@Component` unless the class must be injected—if removal breaks usages, instead register a domain-facing factory or inject a thin adapter at the application layer). Ensure all call sites are updated to the new package/import.src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java (1)
1-12:OAuth2UserInfo인터페이스의 패키지 위치가 DDD 레이어 규칙을 위반합니다.이 인터페이스는
infrastructure패키지에 위치하지만application레이어의OAuthService가 이를 직접 소비합니다. 이로 인해 application 레이어가 infrastructure 패키지에 의존하게 되어 의존성 역전 원칙(DIP)이 깨집니다.인터페이스는 이를 사용하는 레이어(예:
flipnote.user.domain.user.domain또는flipnote.user.domain.user.application)에 위치해야 하며,GoogleUserInfo같은 구현체만infrastructure패키지에 두어야 합니다.♻️ 수정 제안
-package flipnote.user.domain.user.infrastructure; +package flipnote.user.domain.user.domain; public interface OAuth2UserInfo { ... }
GoogleUserInfo등 구현체의implements구문도 새 패키지 경로에 맞게 import를 업데이트해야 합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java` around lines 1 - 12, Move the OAuth2UserInfo interface out of the infrastructure package into a package consumed by the application/domain layer (for example flipnote.user.domain.user.application or flipnote.user.domain.user.domain) so OAuthService depends on an abstraction in its own layer; keep concrete classes like GoogleUserInfo in flipnote.user.domain.user.infrastructure and update their implements/import statements to reference the new OAuth2UserInfo location, then run a quick compile to fix any remaining import errors.src/main/java/flipnote/user/UserApplication.java (1)
11-11:@EnableAsync적용 시 기본 스레드 풀 설정 확인이 필요합니다.Spring Boot의 기본
ThreadPoolTaskExecutor설정은corePoolSize=8,maxPoolSize=Integer.MAX_VALUE,queueCapacity=Integer.MAX_VALUE입니다. 명시적인Executor빈이나AsyncConfigurer가 없을 경우, Spring은 컨텍스트에서 유일한TaskExecutor빈 또는taskExecutor라는 이름의Executor빈을 탐색하며, 둘 다 없을 경우SimpleAsyncTaskExecutor를 사용합니다.SimpleAsyncTaskExecutor는 스레드를 재사용하지 않고 호출마다 새 스레드를 생성하므로, 지속되면OutOfMemoryError를 유발할 수 있습니다.프로덕션 환경을 대비해
application.yml에 명시적으로 풀 크기를 설정하거나,AsyncConfigurer를 구현한 별도 설정 클래스를 두는 것을 권장합니다.⚙️ 설정 예시
application.yml에 아래 설정 추가:spring: task: execution: pool: core-size: 5 max-size: 20 queue-capacity: 100또는 전용
AsyncConfigurer빈:`@Configuration` public class AsyncConfig implements AsyncConfigurer { `@Override` public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(20); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); executor.initialize(); return executor; } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/UserApplication.java` at line 11, The class annotated with `@EnableAsync` (UserApplication) relies on Spring's default async executor which can be unsafe in production; add a dedicated Executor bean or implement AsyncConfigurer to provide a ThreadPoolTaskExecutor with bounded core/max pool and queue sizes (e.g., corePoolSize, maxPoolSize, queueCapacity) and a thread name prefix, and/or add equivalent spring.task.execution.pool settings in application.yml so async methods use this configured pool instead of SimpleAsyncTaskExecutor.src/test/java/flipnote/user/TestRedisConfig.java (1)
17-20:StringRedisTemplate목(mock) 사용 시 Redis 연산을 호출하면 NPE가 발생합니다.
Mockito.mock(StringRedisTemplate.class)는opsForValue(),opsForHash()등 모든 메서드에서 기본적으로null을 반환합니다. 현재 컨텍스트 로딩 테스트(UserApplicationTests)에는 문제없지만,EmailVerificationRepository나PasswordResetRepository등을 실제로 검증하는 테스트가 추가될 경우NullPointerException이 발생합니다.실제 Redis 연산을 검증해야 하는 테스트에는 Embedded Redis 또는 Testcontainers Redis를 사용하는 것을 권장합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/flipnote/user/TestRedisConfig.java` around lines 17 - 20, The current StringRedisTemplate bean returns a Mockito mock which makes opsForValue()/opsForHash() return null and causes NPEs when repository tests (e.g., EmailVerificationRepository, PasswordResetRepository) exercise Redis operations; replace the mock in TestRedisConfig.stringRedisTemplate() with a real StringRedisTemplate wired to an embedded Redis or Testcontainers Redis instance (or, if you intentionally want a mock, explicitly stub opsForValue()/opsForHash() to return suitable mocks) so repository tests run against a real Redis client—update the bean to create a LettuceConnectionFactory/RedisConnectionFactory pointing at the embedded/Testcontainers Redis and construct new StringRedisTemplate(factory) instead of Mockito.mock(...).src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java (1)
15-17: 비밀번호 복잡도 검증 추가를 고려해 보세요.현재
@Size(min = 8, max = 20)길이 검증만 존재합니다."aaaaaaaa"처럼 단순한 문자열도 통과합니다. 보안을 강화하려면 영문자·숫자·특수문자 조합 등 복잡도 요구사항을 추가하는 것을 권장합니다.♻️ 비밀번호 복잡도 검증 추가 제안
`@NotBlank`(message = "새 비밀번호는 필수입니다") `@Size`(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다") +@Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", + message = "비밀번호는 영문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다" +) private String password;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java` around lines 15 - 17, Add a password complexity check to PasswordResetRequest by validating the password field (private String password) with either a `@Pattern` annotation using a regex that enforces at least one uppercase, one lowercase, one digit and one special character and preserves the existing `@Size`, or implement a custom Constraint (e.g., `@PasswordComplexity`) and validator that enforces those rules; update the validation message to describe the complexity requirement so failures are clear.src/main/java/flipnote/user/global/constants/HttpConstants.java (1)
11-11: 상수명과 값 사이의 명명 불일치가 있습니다.
OAUTH_VERIFIER_COOKIE라는 이름은 검증(verifier) 개념을 시사하지만, 실제 값은"oauth2_auth_request"로 인증 요청(auth request) 개념입니다. 이름을 값의 의미와 일치시키는 것을 고려해 보세요.-public static final String OAUTH_VERIFIER_COOKIE = "oauth2_auth_request"; +public static final String OAUTH_AUTH_REQUEST_COOKIE = "oauth2_auth_request";🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/constants/HttpConstants.java` at line 11, The constant HttpConstants.OAUTH_VERIFIER_COOKIE has a name/value mismatch: the name implies a "verifier" while the value "oauth2_auth_request" represents an auth request. Rename the constant to match the value (e.g., OAUTH2_AUTH_REQUEST_COOKIE or OAUTH_AUTH_REQUEST_COOKIE) and update all usages across the codebase to the new name; optionally keep the old OAUTH_VERIFIER_COOKIE as a deprecated alias that forwards to the new constant to preserve backward compatibility while you update callers.src/main/java/flipnote/user/global/config/AppConfig.java (1)
17-20:RestClient에 타임아웃을 설정하는 것을 권장합니다.
RestClient.create()는 커넥션/읽기 타임아웃 없이 생성됩니다.OAuthApiClient에서 외부 OAuth 토큰 교환 및 사용자 정보 API를 호출할 때 네트워크 지연이 발생하면 요청 스레드가 무기한 블록될 수 있습니다.♻️ 타임아웃 설정 제안
+import org.springframework.http.client.SimpleClientHttpRequestFactory; + `@Bean` public RestClient restClient() { - return RestClient.create(); + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(5)); + factory.setReadTimeout(Duration.ofSeconds(10)); + return RestClient.builder() + .requestFactory(factory) + .build(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/config/AppConfig.java` around lines 17 - 20, The RestClient bean in AppConfig currently uses RestClient.create() with no timeouts; update the restClient() bean to build a RestClient with explicit connection and read/response timeouts (e.g., connection timeout ~5s, read/response timeout ~10s) instead of the no-arg create(), so OAuthApiClient calls (token exchange and user info) cannot block indefinitely; locate the restClient() method in AppConfig and replace the creation with a RestClient builder that sets connection and read/response timeouts (and any applicable socket/idle timeouts) so the bean injected into OAuthApiClient uses those timeouts.src/main/java/flipnote/user/domain/user/domain/User.java (1)
79-83:withdraw()에서LocalDateTime.now()두 번 호출
invalidatedAt과deletedAt이 서로 다른 시각으로 기록될 수 있습니다. 단일 변수로 포착하여 두 필드에 할당하는 것이 안전합니다.♻️ 제안된 수정
public void withdraw() { this.status = Status.WITHDRAWN; - this.invalidatedAt = LocalDateTime.now(); - this.deletedAt = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(); + this.invalidatedAt = now; + this.deletedAt = now; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/domain/User.java` around lines 79 - 83, withdraw() currently calls LocalDateTime.now() twice causing invalidatedAt and deletedAt to differ; capture LocalDateTime.now() once into a local variable (e.g., now) and assign that same value to both this.invalidatedAt and this.deletedAt after setting this.status = Status.WITHDRAWN in the User.withdraw method.src/main/java/flipnote/user/global/util/CookieUtil.java (1)
11-20:secure(true)하드코딩으로 인한 로컬 개발 환경 제약HTTPS 없이 로컬 개발 시 브라우저가 Secure 쿠키를 전송하지 않습니다. 환경별로
@Value를 통해 주입받는 방식으로 만들거나,application-local.yml에서 별도로 처리하는 구조가 권장됩니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/util/CookieUtil.java` around lines 11 - 20, The addCookie method in CookieUtil hardcodes ResponseCookie.from(...).secure(true), preventing cookies from being sent in non-HTTPS local development; modify CookieUtil to read a boolean property (e.g., cookie.secure or cookie.secure.enabled) via `@Value` or Environment/ConfigurationProperties and use that flag when building the cookie (i.e., replace .secure(true) with .secure(cookieSecureFlag)); ensure the property can be overridden in application-local.yml (default true for prod, false for local) and update addCookie signature or class to access the injected flag.src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java (1)
22-32:md.update()+md.digest()→md.digest(bytes)로 단순화 가능
MessageDigest.digest(byte[])는update()후digest()를 한 번에 처리합니다.♻️ 제안된 리팩토링
public String generateCodeChallenge(String codeVerifier) { try { byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII); MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(bytes); - byte[] digest = md.digest(); + byte[] digest = md.digest(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 not supported", e); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java` around lines 22 - 32, In generateCodeChallenge(String codeVerifier) in PkceUtil, simplify the MessageDigest usage by replacing the separate md.update(bytes) followed by md.digest() with a single md.digest(bytes) call; keep the same SHA-256 MessageDigest instantiation and Base64 URL encoding logic and preserve the NoSuchAlgorithmException handling in the method.src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java (1)
17-24: Redis 키에 전체 JWT 토큰 문자열 사용 — 메모리 비효율JWT 토큰은 일반적으로 200–500자 이상이므로, 그대로 키로 사용하면 Redis 메모리 사용량이 증가합니다.
jti클레임 또는 토큰의 SHA-256 해시를 키로 사용하는 방식을 권장합니다.♻️ 해시 기반 키 사용 예시
public void add(String token, long expirationMillis) { redisTemplate.opsForValue().set( - KEY_PREFIX + token, + KEY_PREFIX + toKeyHash(token), "blacklisted", expirationMillis, TimeUnit.MILLISECONDS ); } public boolean isBlacklisted(String token) { - return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + token)); + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + toKeyHash(token))); } +private String toKeyHash(String token) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(token.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not supported", e); + } +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java` around lines 17 - 24, The current add method in TokenBlacklistRepository stores the full JWT string as a Redis key (KEY_PREFIX + token), which wastes memory; change add(String token, long expirationMillis) to derive a compact key instead (e.g., extract the jti claim from the token or compute a SHA-256 hex of token) and use KEY_PREFIX + <jti-or-hash> when calling redisTemplate.opsForValue().set(...). Keep expirationMillis behavior the same; if you choose hashing, ensure you normalize the token input and document the choice in the method comment so other callers know the key scheme.src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java (1)
3-6: 필드명 명확성 개선 권장 (선택)
to와link는 이메일 이벤트 컨텍스트에서 의미가 모호할 수 있습니다.recipientEmail과resetLink(또는resetUrl)로 변경하면 가독성이 향상됩니다.♻️ 제안 수정
public record PasswordResetCreateEvent( - String to, - String link + String recipientEmail, + String resetLink ) { }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java` around lines 3 - 6, The record PasswordResetCreateEvent currently uses ambiguous field names `to` and `link`; rename them to more descriptive names (e.g., `recipientEmail` and `resetLink` or `resetUrl`) in the PasswordResetCreateEvent record definition and update all usages/constructors, accessors, and any serialization/deserialization logic that reference `to` or `link` (including places that call the record constructor, pattern-match, or use generated accessor methods) to use the new names to improve readability and avoid breaking behavior.src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java (1)
1-4:infrastructure패키지 배치 재검토 권장
TokenPair는AuthService,OAuthService(application 계층),AuthController(presentation 계층)에서 모두 사용됩니다. 인프라 패키지에 두면 상위 계층이 하위 계층에 의존하는 구조가 되어 클린 아키텍처 원칙에 어긋납니다.application또는domain패키지로 이동을 고려해 주세요.TokenClaims도 동일한 상황입니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java` around lines 1 - 4, TokenPair (and TokenClaims) are located in the infrastructure layer which causes higher-level components (AuthService, OAuthService in application layer and AuthController in presentation layer) to depend on a lower layer; move TokenPair and TokenClaims out of the infrastructure package into a shared higher-level package (either application or domain) so they represent application/domain DTOs, update their package declarations accordingly, and then update all imports/usages in AuthService, OAuthService, and AuthController to the new package to restore proper layer dependencies.src/main/java/flipnote/user/global/config/JwtProperties.java (1)
13-14: 만료 시간 단위 명시 필요
accessTokenExpiration과refreshTokenExpiration의 단위(밀리초? 초?)가 코드와 설정 파일 모두에서 불명확합니다. 단위를 잘못 입력하면 토큰이 즉시 만료되거나 수천 년 동안 유효해지는 심각한 문제가 발생할 수 있습니다.필드명에 단위를 포함하거나, 주석으로 단위를 명시하는 것을 권장합니다.
♻️ 제안 수정 (예: 초 단위 명시)
- private final long accessTokenExpiration; - private final long refreshTokenExpiration; + private final long accessTokenExpirationSeconds; + private final long refreshTokenExpirationSeconds;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/config/JwtProperties.java` around lines 13 - 14, The expiration unit for JwtProperties.accessTokenExpiration and JwtProperties.refreshTokenExpiration is ambiguous; make the unit explicit by either renaming the fields to accessTokenExpirationSeconds and refreshTokenExpirationSeconds (and update their getters/setters and any config bindings) or change their type to java.time.Duration and add Javadoc comments on the class/fields that state the expected unit (e.g., "seconds" or "ISO-8601 duration") and update the external configuration examples accordingly so the mapping and documentation are unambiguous; ensure any code that reads these properties (constructors, Jwt token creation code) is updated to use the new names/types.src/test/resources/application.yml (1)
20-23: 테스트용 JWT 시크릿 값을 명확한 테스트 값으로 교체를 고려하세요.정적 분석 도구(Gitleaks)가 Line 21의 JWT secret을 API 키로 감지했습니다.
src/test/resources에 있으므로 프로덕션 위험은 낮지만, 이 값이 실제 시크릿이 아닌 테스트 전용 값임을 명확히 하기 위해 더 짧고 명시적인 테스트 값(예:test-jwt-secret-key-for-testing-only-do-not-use-in-production)을 사용하는 것이 좋습니다.또한, 실제 프로덕션 환경의 JWT secret이 코드베이스 어딘가에 하드코딩되어 있지 않은지 확인하세요.
#!/bin/bash # 프로덕션 설정에서 JWT secret 하드코딩 여부 확인 echo "=== main resources에서 jwt secret 검색 ===" fd "application" --extension yml --extension yaml --extension properties -p "src/main" --exec cat {} echo "" echo "=== 전체 코드베이스에서 jwt.secret 하드코딩 검색 ===" rg -n "jwt" --type=yaml -g '!src/test/**'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/resources/application.yml` around lines 20 - 23, Replace the long opaque JWT secret in the test config by setting jwt.secret in the test application.yml to a clear, short test-only value (e.g. "test-jwt-secret-key-for-testing-only-do-not-use-in-production") so it is unmistakably non-production; update the same block that contains jwt.access-token-expiration and jwt.refresh-token-expiration to use that test string, and then run a quick grep/ripgrep across the repo to ensure no real production JWT secret is hardcoded elsewhere (search for "jwt.secret" and other jwt keys outside test resources).src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java (1)
23-31: @async와@Retryable조합의 AOP 프록시 순서 확인됨 - 현재 구성은 문제 없음
@EnableRetry와@EnableAsync가UserApplication.java에서 모두 활성화되어 있으며, 현재 구성은 Spring의 기본 AOP 프록시 순서 처리로 정상 작동합니다. 이 패턴이PasswordResetEventListener에서도 동일하게 사용되고 있어, 이미 검증된 구성입니다.선택사항: 명시적 제어를 위해
@EnableRetry(order = ...)또는@EnableAsync(order = ...)에@Order를 지정하여 프록시 순서를 명확히 하면 더욱 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java` around lines 23 - 31, The AOP proxy ordering for combining `@Async` and `@Retryable` in EmailVerificationEventListener.handle is confirmed as working but to make the behavior explicit and future-proof, add an explicit proxy order on the configuration that enables these features (e.g., set `@EnableRetry`(order = ...) or `@EnableAsync`(order = ...) in the same config used by UserApplication) or add an `@Order` on the relevant listener beans (EmailVerificationEventListener and PasswordResetEventListener) so the proxy chain executes in the intended sequence; update the config/class that registers these annotations to include the chosen order value to ensure deterministic proxy wrapping.src/main/java/flipnote/user/domain/user/presentation/dto/request/SignupRequest.java (1)
23-24:name필드에@Size제약 조건 추가 고려
password와nickname에는@Size제약이 있지만name에는 없습니다. 이름에 대한 최대 길이 제한이 없으면 DB 컬럼 길이를 초과하는 값이 유입될 수 있습니다.제안
`@NotBlank`(message = "이름은 필수입니다") + `@Size`(max = 50, message = "이름은 50자 이하여야 합니다") private String name;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/presentation/dto/request/SignupRequest.java` around lines 23 - 24, Add a `@Size`(max = ...) constraint to the name field in SignupRequest to mirror password/nickname validation and prevent values exceeding the DB column; update the name declaration (field "name" in class SignupRequest) to include an appropriate max length (use the same maximum as the corresponding DB column or the existing nickname/password max), and supply a localized message similar to other fields (e.g., "이름은 최대 N자입니다"). Ensure imports for javax.validation.constraints.Size are present.src/main/java/flipnote/user/global/error/ErrorResponse.java (1)
13-15:errorCode.toString()대신 명시적인name()또는 별도 메서드 사용 고려
ErrorCode가 인터페이스이므로,toString()의 결과가 구현체에 따라 달라질 수 있습니다.ErrorCode인터페이스에getCode()같은 메서드를 추가하거나, enum 전용이라면((Enum<?>) errorCode).name()을 사용하면 코드의 의도가 더 명확해집니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/error/ErrorResponse.java` around lines 13 - 15, The ErrorResponse.of(ErrorCode) currently uses errorCode.toString(), which is ambiguous for an interface; change it to use an explicit identifier method: update the ErrorCode contract to include getCode() (or if ErrorCode is guaranteed to be an enum, use ((Enum<?>) errorCode).name()) and then have ErrorResponse.of(ErrorCode errorCode) call that method instead of toString(); modify the ErrorCode interface (or its enum implementations) and replace errorCode.toString() in ErrorResponse.of with errorCode.getCode() or the enum name cast to make the intent explicit.src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java (1)
15-15:TTL_MINUTES가PasswordResetConstants.TOKEN_TTL_MINUTES를 중복 정의합니다.
PasswordResetConstants.TOKEN_TTL_MINUTES가 이미 동일한 값(30)으로 존재합니다. 로컬 상수를 별도로 두면 나중에 TTL 값을 변경할 때 한 곳을 놓칠 수 있습니다.♻️ 상수 중복 제거 제안
- private static final long TTL_MINUTES = 30;
TTL_MINUTES참조를 모두PasswordResetConstants.TOKEN_TTL_MINUTES로 교체하세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java` at line 15, Remove the duplicated TTL_MINUTES constant from PasswordResetRepository and use the existing PasswordResetConstants.TOKEN_TTL_MINUTES everywhere instead: delete the private static final long TTL_MINUTES field in PasswordResetRepository and update any references in methods of PasswordResetRepository to use PasswordResetConstants.TOKEN_TTL_MINUTES (ensure imports/reference are available).src/main/java/flipnote/user/domain/user/domain/OAuthLink.java (1)
36-44:linkedAt에@CreationTimestamp사용을 권장합니다.
linkedAt = LocalDateTime.now()는 JVM의 시스템 타임존을 사용합니다. 컨테이너 환경이나 다중 인스턴스 환경에서는@CreationTimestamp를 사용하는 것이 더 안정적이고 관용적입니다.♻️ `@CreationTimestamp` 적용 제안
+import org.hibernate.annotations.CreationTimestamp; `@Column`(updatable = false) + `@CreationTimestamp` private LocalDateTime linkedAt; `@Builder` public OAuthLink(String provider, String providerId, User user) { this.provider = provider; this.providerId = providerId; this.user = user; - this.linkedAt = LocalDateTime.now(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/domain/OAuthLink.java` around lines 36 - 44, Replace manual timestamp assignment with Hibernate's creation timestamp: annotate the linkedAt field in OAuthLink with `@CreationTimestamp` (import org.hibernate.annotations.CreationTimestamp) and keep or add `@Column`(updatable = false) as needed, then remove the linkedAt = LocalDateTime.now() line from the OAuthLink(String provider, String providerId, User user) constructor so the value is set by Hibernate at persist time.src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java (1)
23-67:sendVerificationCode와sendPasswordResetLink의 공통 로직 추출을 고려하세요.두 메서드의 구조(Context 생성 → 템플릿 렌더링 → 이메일 빌드 → 전송 → 예외 처리)가 거의 동일합니다. 공통
sendEmail헬퍼 메서드를 추출하면 향후 새로운 이메일 유형 추가 시 유지보수가 용이해집니다.♻️ 리팩토링 제안
+ private void sendEmail(String to, String subject, String template, Context context) { + String html = templateEngine.process(template, context); + CreateEmailOptions params = CreateEmailOptions.builder() + .from(resendProperties.getFromEmail()) + .to(to) + .subject(subject) + .html(html) + .build(); + try { + resend.emails().send(params); + } catch (ResendException e) { + log.error("이메일 발송 실패: template={}", template, e); + throw new EmailSendException(e); + } + } + `@Override` public void sendVerificationCode(String to, String code, int ttl) { Context context = new Context(); context.setVariable("code", code); context.setVariable("validMinutes", ttl); - - String html = templateEngine.process("email/email-verification", context); - - CreateEmailOptions params = CreateEmailOptions.builder() - .from(resendProperties.getFromEmail()) - .to(to) - .subject("이메일 인증번호 안내") - .html(html) - .build(); - - try { - resend.emails().send(params); - } catch (ResendException e) { - log.error("이메일 인증번호 발송 실패: to={}, ttl={}분", to, ttl, e); - throw new EmailSendException(e); - } + sendEmail(to, "이메일 인증번호 안내", "email/email-verification", context); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java` around lines 23 - 67, Extract the duplicated flow in sendVerificationCode and sendPasswordResetLink into a private helper method (e.g., sendEmail) that accepts parameters like recipient (to), template name, subject, map/context values (or a Consumer<Context>), and ttl for logging; in sendVerificationCode and sendPasswordResetLink create the Context and set variables (or pass a lambda to the helper), then call sendEmail which will render the template via templateEngine.process, build CreateEmailOptions (using resendProperties.getFromEmail(), to, subject, html), call resend.emails().send(params) and wrap ResendException into EmailSendException while logging the failure with the provided to and ttl. Ensure the helper is used by both methods (keep existing method signatures) and preserve existing log messages/exception types.src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java (2)
65-70:createUserInfo의 예외 타입이OAuthService.resolveProvider와 일관성이 없습니다.
OAuthService.resolveProvider는 지원하지 않는 프로바이더에 대해UserException(INVALID_OAUTH_PROVIDER)를 던지지만, 여기서는IllegalArgumentException을 사용합니다.createUserInfo가resolveProvider이후에 호출되므로 실제로 도달할 가능성은 낮지만, 방어적 코딩 관점에서UserException으로 통일하는 것이 좋습니다.♻️ 수정 제안
public OAuth2UserInfo createUserInfo(String providerName, Map<String, Object> attributes) { return switch (providerName.toLowerCase()) { case "google" -> new GoogleUserInfo(attributes); - default -> throw new IllegalArgumentException("Unsupported OAuth provider: " + providerName); + default -> throw new UserException(UserErrorCode.INVALID_OAUTH_PROVIDER); }; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java` around lines 65 - 70, The createUserInfo method throws IllegalArgumentException for unsupported providers, which is inconsistent with OAuthService.resolveProvider that uses UserException(INVALID_OAUTH_PROVIDER); change createUserInfo to throw the same UserException with the INVALID_OAUTH_PROVIDER error code when the provider is unsupported so error handling is uniform; update the default branch in createUserInfo (return switch on providerName) to throw new UserException(INVALID_OAUTH_PROVIDER) instead of IllegalArgumentException and ensure imports/reference to UserException and INVALID_OAUTH_PROVIDER are available alongside OAuth2UserInfo/GoogleUserInfo.
46-48:catch (Exception e)가 너무 광범위합니다.
Exception전체를 잡으면NullPointerException,InterruptedException등 예상치 못한 예외까지RuntimeException으로 감싸져서 디버깅이 어려워집니다.RestClientException과JsonProcessingException등 구체적인 예외를 catch하는 것을 고려하세요.Also applies to: 60-62
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java` around lines 46 - 48, In OAuthApiClient, replace the broad catch (Exception e) in the try/catch blocks that currently throw new RuntimeException("Failed to get OAuth access token", e) (the two catch sites in the class) with narrower catches for the likely errors such as org.springframework.web.client.RestClientException for HTTP/client failures and com.fasterxml.jackson.core.JsonProcessingException for JSON parsing errors; handle each specifically (e.g., throw new RuntimeException("Failed to get OAuth access token: HTTP error", e) for RestClientException and a distinct message for JsonProcessingException) and optionally add a final catch (RuntimeException e) to rethrow other runtime problems unchanged to avoid swallowing unexpected exceptions.src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java (1)
32-41: Validation 에러 응답 형식이 다른 핸들러와 일관성이 없습니다.다른 핸들러들은
ErrorResponse를 반환하는데,handleValidationException만Map<String, String>을 반환합니다. API 클라이언트가 일관된 에러 응답 구조를 기대할 수 있으므로,ErrorResponse에 validation 에러 필드를 포함하는 방식으로 통일하는 것을 고려해 보세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java` around lines 32 - 41, The handleValidationException method in GlobalExceptionHandler currently returns Map<String,String> which breaks consistency with other handlers returning ErrorResponse; change the method signature to return ResponseEntity<ErrorResponse>, build an ErrorResponse instance that includes a validationErrors field (or reuse an existing field for details) populated with the Map of field→message created in handleValidationException, set an appropriate status/message on the ErrorResponse (e.g. "Validation Failed" and HTTP 400) and return ResponseEntity.badRequest().body(errorResponse); ensure you reference and use the ErrorResponse class and the handleValidationException method when making these changes.src/main/proto/user_query.proto (1)
18-23:GetUserResponse에 optional 필드 또는 상태 필드 추가를 고려해 보세요.
GetUsersRPC에서 요청한 일부 사용자 ID가 존재하지 않을 경우, 현재 응답 구조로는 어떤 ID가 누락되었는지 클라이언트가 판별하기 어렵습니다.GetUsersResponse에 누락된 ID 목록을 포함하거나, 개별 응답에 상태 정보를 추가하는 것을 고려해 보세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/proto/user_query.proto` around lines 18 - 23, GetUsers can return ambiguous results when some requested IDs are missing; update the proto to make this explicit by either adding a status field to the per-user message (e.g., add an optional enum or string like status/error to GetUserResponse to indicate NOT_FOUND or an error) or by extending the batch response (GetUsersResponse) to include a repeated int64 missing_ids field listing IDs that were not found; modify whichever RPC message/schema is used by the GetUsers RPC (GetUsersResponse or GetUserResponse) so clients can deterministically detect missing users.src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java (1)
88-97:isTokenExpired의 반환값이 혼동을 줄 수 있음유효하지 않은 (파싱 실패) 토큰에 대해
false를 반환합니다. 호출자가 "만료되지 않음 = 유효함"으로 오해할 수 있습니다. 현재 코드에서isTokenExpired가 사용되지 않는 것으로 보이지만, 향후 사용 시 주의가 필요합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java` around lines 88 - 97, The isTokenExpired method currently returns false for parse failures which is misleading; update JwtProvider.isTokenExpired to treat any parsing error as "expired/invalid" by returning true for ExpiredJwtException and for JwtException | IllegalArgumentException as well (i.e., replace the final catch branch to return true), or alternatively change the method to throw the parsing exception so callers can distinguish expiry vs invalid token; reference the JwtProvider.isTokenExpired method and the parseClaims call when making this change.src/main/java/flipnote/user/domain/user/presentation/OAuthController.java (1)
63-80: 토큰 쿠키 설정 로직이AuthController.setTokenCookies와 중복됨Lines 67-70의 쿠키 설정 로직이
AuthController의setTokenCookies헬퍼와 동일합니다. 공통 유틸로 추출하면 만료 시간 계산 등이 변경될 때 한 곳만 수정하면 됩니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/presentation/OAuthController.java` around lines 63 - 80, handleSocialLogin duplicates cookie-setting logic from AuthController.setTokenCookies; replace the in-method CookieUtil.addCookie calls in handleSocialLogin by extracting a shared helper (or invoking AuthController.setTokenCookies) that accepts HttpServletResponse and TokenPair and uses jwtProvider.getAccessTokenExpiration()/getRefreshTokenExpiration() and HttpConstants.ACCESS_TOKEN_COOKIE/REFRESH_TOKEN_COOKIE, so cookie creation and expiration calculation live in one place (update imports/visibility as needed and remove the duplicated lines in handleSocialLogin).src/main/java/flipnote/user/domain/user/application/AuthService.java (1)
140-148: 이메일 인증 코드 발송 시 rate limiting 부재
hasCode체크로 동일 이메일에 대한 중복 발송은 방지하지만, 코드 만료 후 반복 요청에 대한 rate limiting이 없습니다. 공격자가 만료를 기다린 후 반복 요청하여 메일 발송 비용을 증가시킬 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/application/AuthService.java` around lines 140 - 148, sendEmailVerificationCode lacks rate limiting after codes expire; add a cooldown check in this method by delegating to the repository (e.g., add and use emailVerificationRepository.canSend(email) or emailVerificationRepository.getLastSentTimestamp(email)) before generating a new code, and throw a rate-limit-specific exception (e.g., UserErrorCode.RATE_LIMIT_EXCEEDED) when requests are too frequent; ensure saveCode records the send timestamp and only publish EmailVerificationSendEvent after the canSend check passes so repeated post-expiry requests cannot spam mails.
| @Transactional | ||
| public void linkSocialAccount(String providerName, String code, String state, | ||
| String codeVerifier, HttpServletRequest request) { | ||
| Long userId = socialLinkTokenRepository.findUserIdByState(state) | ||
| .orElseThrow(() -> new UserException(UserErrorCode.INVALID_SOCIAL_LINK_TOKEN)); | ||
|
|
||
| socialLinkTokenRepository.delete(state); | ||
|
|
||
| OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); | ||
|
|
||
| if (oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId( | ||
| userId, userInfo.getProvider(), userInfo.getProviderId())) { | ||
| throw new UserException(UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); | ||
| } | ||
|
|
||
| User user = userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) | ||
| .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| OAuthLink link = OAuthLink.builder() | ||
| .provider(userInfo.getProvider()) | ||
| .providerId(userInfo.getProviderId()) | ||
| .user(user) | ||
| .build(); | ||
| oAuthLinkRepository.save(link); | ||
| } |
There was a problem hiding this comment.
linkSocialAccount에서 state 토큰 소비와 외부 API 호출의 순서를 확인하세요.
Line 78에서 socialLinkTokenRepository.delete(state)로 state 토큰을 먼저 삭제한 후 Line 80에서 OAuth 토큰 교환(getOAuth2UserInfo)을 수행합니다. 토큰 교환이 실패하면 state 토큰은 이미 소비되어 사용자가 재시도할 수 없습니다. state 삭제를 링크 저장 직전으로 이동하는 것을 고려하세요.
♻️ 순서 조정 제안
public void linkSocialAccount(String providerName, String code, String state,
String codeVerifier, HttpServletRequest request) {
Long userId = socialLinkTokenRepository.findUserIdByState(state)
.orElseThrow(() -> new UserException(UserErrorCode.INVALID_SOCIAL_LINK_TOKEN));
- socialLinkTokenRepository.delete(state);
-
OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request);
if (oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId(
userId, userInfo.getProvider(), userInfo.getProviderId())) {
throw new UserException(UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT);
}
User user = userRepository.findByIdAndStatus(userId, User.Status.ACTIVE)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
+ socialLinkTokenRepository.delete(state);
+
OAuthLink link = OAuthLink.builder()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/domain/user/application/OAuthService.java` around
lines 72 - 96, In linkSocialAccount, don't delete the state token before calling
getOAuth2UserInfo: the current socialLinkTokenRepository.delete(state) happens
before the external OAuth exchange and will permanently consume the token if the
exchange fails; move the delete call to after getOAuth2UserInfo has succeeded
and after you’ve validated there’s no existing link (i.e., place deletion just
before creating/saving the OAuthLink), so the state is only consumed on a
successful link creation.
| if (oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId( | ||
| userId, userInfo.getProvider(), userInfo.getProviderId())) { | ||
| throw new UserException(UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find OAuthLinkRepository interface
fd -e 'java' -path '*OAuthLinkRepository*' | head -20Repository: FlipNoteTeam/FlipNote-User
Length of output: 238
🏁 Script executed:
#!/bin/bash
# Search for OAuthLinkRepository definition
rg -l 'interface OAuthLinkRepository|class OAuthLinkRepository' --type=javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 141
🏁 Script executed:
#!/bin/bash
# Find OAuthLink entity
fd -e 'java' -path '*OAuthLink*' | grep -v Repository | head -20Repository: FlipNoteTeam/FlipNote-User
Length of output: 238
🏁 Script executed:
#!/bin/bash
# Search for the OAuthService file
fd -e 'java' -path '*OAuthService*'Repository: FlipNoteTeam/FlipNote-User
Length of output: 238
🏁 Script executed:
#!/bin/bash
# Read the OAuthLinkRepository interface
cat -n src/main/java/flipnote/user/domain/user/domain/OAuthLinkRepository.javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 1159
🏁 Script executed:
#!/bin/bash
# Find OAuthLink entity
find . -name 'OAuthLink.java' -type fRepository: FlipNoteTeam/FlipNote-User
Length of output: 133
🏁 Script executed:
#!/bin/bash
# Read OAuthService to see the complete linkSocialAccount method
cat -n src/main/java/flipnote/user/domain/user/application/OAuthService.java | head -120Repository: FlipNoteTeam/FlipNote-User
Length of output: 6004
🏁 Script executed:
#!/bin/bash
# Search for unique constraints on OAuthLink
rg -A 5 -B 5 'class OAuthLink|@Table|@Unique|@Column' src/main/java/flipnote/user/domain/user/domain/OAuthLink.javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 907
소셜 계정 중복 연동을 모든 사용자 대상으로 검증하지 않습니다.
existsByUser_IdAndProviderAndProviderId는 현재 사용자(userId)가 동일한 소셜 계정을 이미 연동했는지만 확인합니다. 다른 사용자가 동일한 provider + providerId 조합을 이미 연동한 경우를 체크하지 않으므로, 하나의 소셜 계정이 여러 사용자에게 연동될 수 있습니다. 이로 인해 socialLogin 시 findByProviderAndProviderIdWithUser()가 반환하는 결과가 모호해지는 보안 문제가 발생합니다.
🐛 수정 제안
OAuthLinkRepository에 새로운 메서드를 추가하고 linkSocialAccount에서 검증하세요:
+ boolean existsByProviderAndProviderId(String provider, String providerId);그 후 linkSocialAccount의 검증 로직을 수정하세요:
+ if (oAuthLinkRepository.existsByProviderAndProviderId(
+ userInfo.getProvider(), userInfo.getProviderId())) {
+ throw new UserException(UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT);
+ }
+
if (oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId(
userId, userInfo.getProvider(), userInfo.getProviderId())) {
throw new UserException(UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT);
}또는 더 간단하게, OAuthLink 테이블에 (provider, providerId) 컬럼에 대한 UNIQUE 제약 조건을 추가하여 데이터베이스 수준에서 중복을 방지하세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/domain/user/application/OAuthService.java` around
lines 82 - 85, 현재 OAuthService의 linkSocialAccount 처리에서
existsByUser_IdAndProviderAndProviderId(userId, provider, providerId)만 검사해 동일한
(provider, providerId)를 다른 사용자가 이미 연동했는지 확인하지 않습니다; OAuthLinkRepository에
existsByProviderAndProviderId(String provider, String providerId) 같은 메서드를 추가하고
OAuthService.linkSocialAccount(또는 해당 검증 블록)에서 기존
existsByUser_IdAndProviderAndProviderId 호출과 함께 existsByProviderAndProviderId 호출을
추가하여 다른 사용자가 이미 같은 소셜 계정을 연동했는지 검증하도록 수정하세요 (대안으로는 DB에 (provider, providerId)에
대한 UNIQUE 제약을 추가해 중복을 DB 수준에서 방지).
| try { | ||
| resend.emails().send(params); | ||
| } catch (ResendException e) { | ||
| log.error("이메일 인증번호 발송 실패: to={}, ttl={}분", to, ttl, e); |
There was a problem hiding this comment.
로그에 이메일 주소(PII)가 기록됩니다.
to={} 파라미터로 사용자 이메일 주소가 로그에 직접 기록됩니다. 이는 개인정보 보호(GDPR/개인정보보호법) 관점에서 문제가 될 수 있습니다. 이메일을 마스킹하거나 로그에서 제거하는 것을 고려하세요. 또한, EmailVerificationEventListener와 PasswordResetEventListener의 @Recover 메서드에서도 동일하게 이메일이 로그에 기록되고 있으므로 함께 검토해 주세요.
Also applies to: 64-64
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java`
at line 41, The log currently writes the full user email (PII) in
ResendMailService.log.error ("이메일 인증번호 발송 실패: to={}, ttl={}분", to, ttl, e); —
replace the plain email with a masked or redacted value before logging (e.g.,
implement and call a maskEmail(String email) helper that keeps only safe chars
or domain, or remove the email field entirely and log a non-PII identifier), and
update the same pattern in the `@Recover` methods of
EmailVerificationEventListener and PasswordResetEventListener so no raw email is
emitted to logs; ensure you call the masking helper where the variables 'to' or
equivalent are passed into log.error.
| @PostMapping("/token/validate") | ||
| public ResponseEntity<TokenValidateResponse> validateToken( | ||
| @Valid @RequestBody TokenValidateRequest request) { | ||
| TokenValidateResponse result = authService.validateToken(request.getToken()); | ||
| return ResponseEntity.ok(result); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for Spring Security configuration and the token/validate endpoint
rg -n "token/validate|SecurityFilterChain|authorizeHttpRequests|permitAll" --type=java -C5Repository: FlipNoteTeam/FlipNote-User
Length of output: 1370
🏁 Script executed:
#!/bin/bash
# Also check for security configuration files
fd -e java | xargs grep -l "SecurityConfig\|SecurityConfiguration" | head -20Repository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
#!/bin/bash
# Check the AuthController file to see if there are any `@PreAuthorize` or other security annotations
cat -n src/main/java/flipnote/user/domain/user/presentation/AuthController.java | head -100Repository: FlipNoteTeam/FlipNote-User
Length of output: 5043
🏁 Script executed:
#!/bin/bash
# Find configuration classes and security setup
fd -e java -type f | xargs grep -l "SecurityFilterChain\|@Configuration.*Security\|WebSecurityConfigurerAdapter" | head -20Repository: FlipNoteTeam/FlipNote-User
Length of output: 240
🏁 Script executed:
#!/bin/bash
# Search for any security configuration or filter chain setup
find . -type f -name "*.java" | xargs grep -l "SecurityFilterChain\|@Bean.*SecurityFilterChain" 2>/dev/nullRepository: FlipNoteTeam/FlipNote-User
Length of output: 54
🏁 Script executed:
#!/bin/bash
# Look for application.yml or application.properties for security settings
fd -e yml -e yaml -e properties | xargs grep -l "security\|auth" 2>/dev/null | head -10Repository: FlipNoteTeam/FlipNote-User
Length of output: 139
🏁 Script executed:
#!/bin/bash
# Search for authorize rules or matchers specific to token endpoints
rg -n "permitAll|authenticate|authorizeRequests|authorizeHttpRequests" --type=java -B2 -A2Repository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
#!/bin/bash
# Read application.yml files to check for security configuration
cat -n src/main/resources/application.ymlRepository: FlipNoteTeam/FlipNote-User
Length of output: 2427
🏁 Script executed:
#!/bin/bash
# Search for all `@Configuration` classes
rg -n "@Configuration" --type=java -A 5Repository: FlipNoteTeam/FlipNote-User
Length of output: 4063
🏁 Script executed:
#!/bin/bash
# Search for `@Bean` annotations that might define security beans
rg -n "@Bean" --type=java -B 2 -A 3 | head -50Repository: FlipNoteTeam/FlipNote-User
Length of output: 2360
🏁 Script executed:
#!/bin/bash
# Search for any interceptors, filters, or security-related beans
fd -name "*.java" -path "*/config/*" -o -path "*/security/*" -o -path "*/configuration/*" 2>/dev/nullRepository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
#!/bin/bash
# Check for pom.xml to see dependencies
fd -name "pom.xml" -type fRepository: FlipNoteTeam/FlipNote-User
Length of output: 300
🏁 Script executed:
#!/bin/bash
# Check if Spring Security is a dependency
cat pom.xml | grep -A 2 -B 2 "spring-security" 2>/dev/null || echo "No Spring Security in pom.xml"Repository: FlipNoteTeam/FlipNote-User
Length of output: 156
🏁 Script executed:
#!/bin/bash
# Search for any Filter implementations
rg -n "implements Filter|extends.*Filter|@Component.*Filter" --type=javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
#!/bin/bash
# Search for any global interceptors or handlers
fd -name "*.java" | xargs grep -l "HandlerInterceptor\|WebRequestInterceptor\|ClientHttpRequestInterceptor" 2>/dev/nullRepository: FlipNoteTeam/FlipNote-User
Length of output: 302
🏁 Script executed:
#!/bin/bash
# Check for any AOP or aspect-based security
rg -n "@Aspect|@Around|@Before" --type=java | head -20Repository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
#!/bin/bash
# Find build files
fd -name "build.gradle" -o -name "pom.xml" -o -name "build.gradle.kts"Repository: FlipNoteTeam/FlipNote-User
Length of output: 300
🏁 Script executed:
#!/bin/bash
# List root directory to understand project structure
ls -la | head -20Repository: FlipNoteTeam/FlipNote-User
Length of output: 920
🏁 Script executed:
#!/bin/bash
# Search for any authentication or authorization logic in service layer
rg "validateToken|TokenValidateRequest" --type=java -A 10 | head -50Repository: FlipNoteTeam/FlipNote-User
Length of output: 3436
🏁 Script executed:
#!/bin/bash
# Check if there are any `@RequestHeader` annotations that might enforce security
rg "@RequestHeader" --type=javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 1103
/token/validate 엔드포인트에 접근 제어 없음
이 엔드포인트는 어떤 호출자로부터도 임의의 토큰 검증 요청을 받으며, 다른 엔드포인트들이 사용하는 @RequestHeader(HttpConstants.USER_ID_HEADER) 같은 접근 제어 메커니즘이 전혀 없습니다. 외부 호출자가 토큰 유효성을 임의로 검증할 수 있어 토큰 열거 공격(token enumeration)이나 정보 유출 위험이 있습니다.
내부 서비스(API 게이트웨이 등)에서만 호출하도록 제한하거나, 다른 엔드포인트처럼 사용자 식별 헤더를 통한 접근 제어를 추가하세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/domain/user/presentation/AuthController.java`
around lines 60 - 65, The validateToken endpoint (AuthController.validateToken)
lacks caller verification and must be restricted; update the method signature to
require the same caller identity header used elsewhere (e.g., add a
`@RequestHeader`(HttpConstants.USER_ID_HEADER) String callerId parameter) or
enforce an internal-only policy (e.g., a custom security annotation or check)
before calling authService.validateToken(request.getToken()); ensure
TokenValidateRequest remains the body type but reject requests when the header
is missing/unauthorized so only internal services or authenticated callers can
perform token validation.
| @PatchMapping("/password") | ||
| public ResponseEntity<Void> changePassword( | ||
| @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId, | ||
| @Valid @RequestBody ChangePasswordRequest request, | ||
| HttpServletResponse response) { | ||
| authService.changePassword(userId, request); | ||
| CookieUtil.deleteCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE); | ||
| CookieUtil.deleteCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE); | ||
| return ResponseEntity.noContent().build(); | ||
| } |
There was a problem hiding this comment.
비밀번호 변경 후 기존 세션 무효화 누락 — 다른 기기의 세션이 유지됨
비밀번호 변경 후 현재 세션의 쿠키는 삭제하지만, AuthService.changePassword에서 user.invalidatedAt을 갱신하지 않아 다른 기기에서 발급된 기존 토큰들이 유효한 채로 남습니다. 비밀번호가 탈취되어 변경하는 경우, 공격자의 세션도 계속 유효합니다.
AuthService.changePassword에서 비밀번호 변경 시 invalidatedAt을 현재 시간으로 설정하는 것을 권장합니다.
🔒 AuthService.changePassword 수정 제안
AuthService.java의 changePassword 메서드:
`@Transactional`
public void changePassword(Long userId, ChangePasswordRequest request) {
User user = findActiveUser(userId);
if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) {
throw new UserException(UserErrorCode.PASSWORD_MISMATCH);
}
user.changePassword(passwordEncoder.encode(request.getNewPassword()));
+ user.invalidateAllSessions(); // invalidatedAt을 현재 시간으로 설정
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/domain/user/presentation/AuthController.java`
around lines 67 - 76, AuthService.changePassword currently updates the user's
password but does not invalidate existing sessions; update the method to set the
user's invalidatedAt (e.g., user.setInvalidatedAt(Instant.now())) when a
password change succeeds, persist the user change (save/update via the
UserRepository or equivalent), and ensure AuthController.changePassword still
clears cookies; reference AuthService.changePassword, user.invalidatedAt, and
AuthController.changePassword when making the change.
| private ResponseEntity<Void> handleSocialLink(String provider, String code, String state, | ||
| String codeVerifier, HttpServletRequest request) { | ||
| try { | ||
| oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request); | ||
| return ResponseEntity.status(HttpStatus.FOUND) | ||
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkSuccess())) | ||
| .build(); | ||
| } catch (UserException e) { | ||
| log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e); | ||
| if (e.getErrorCode() == UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { | ||
| return ResponseEntity.status(HttpStatus.FOUND) | ||
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkConflict())) | ||
| .build(); | ||
| } | ||
| return ResponseEntity.status(HttpStatus.FOUND) | ||
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkFailure())) | ||
| .build(); | ||
| } | ||
| } |
There was a problem hiding this comment.
handleSocialLink에서 UserException 외의 예외 미처리 — OAuth API 실패 시 500 에러 발생
handleSocialLogin(Line 74)은 Exception을 포괄적으로 잡아 실패 페이지로 리다이렉트하지만, handleSocialLink는 UserException만 처리합니다. OAuth 프로바이더 통신 실패, 네트워크 타임아웃 등 UserException이 아닌 예외 발생 시 500 에러가 사용자에게 노출됩니다.
🐛 예외 처리 범위 확장 제안
private ResponseEntity<Void> handleSocialLink(String provider, String code, String state,
String codeVerifier, HttpServletRequest request) {
try {
oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request);
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkSuccess()))
.build();
} catch (UserException e) {
log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e);
if (e.getErrorCode() == UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) {
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkConflict()))
.build();
}
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkFailure()))
.build();
+ } catch (Exception e) {
+ log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e);
+ return ResponseEntity.status(HttpStatus.FOUND)
+ .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkFailure()))
+ .build();
}
}📝 Committable suggestion
‼️ 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.
| private ResponseEntity<Void> handleSocialLink(String provider, String code, String state, | |
| String codeVerifier, HttpServletRequest request) { | |
| try { | |
| oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request); | |
| return ResponseEntity.status(HttpStatus.FOUND) | |
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkSuccess())) | |
| .build(); | |
| } catch (UserException e) { | |
| log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e); | |
| if (e.getErrorCode() == UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { | |
| return ResponseEntity.status(HttpStatus.FOUND) | |
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkConflict())) | |
| .build(); | |
| } | |
| return ResponseEntity.status(HttpStatus.FOUND) | |
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkFailure())) | |
| .build(); | |
| } | |
| } | |
| private ResponseEntity<Void> handleSocialLink(String provider, String code, String state, | |
| String codeVerifier, HttpServletRequest request) { | |
| try { | |
| oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request); | |
| return ResponseEntity.status(HttpStatus.FOUND) | |
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkSuccess())) | |
| .build(); | |
| } catch (UserException e) { | |
| log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e); | |
| if (e.getErrorCode() == UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { | |
| return ResponseEntity.status(HttpStatus.FOUND) | |
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkConflict())) | |
| .build(); | |
| } | |
| return ResponseEntity.status(HttpStatus.FOUND) | |
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkFailure())) | |
| .build(); | |
| } catch (Exception e) { | |
| log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e); | |
| return ResponseEntity.status(HttpStatus.FOUND) | |
| .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkFailure())) | |
| .build(); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/domain/user/presentation/OAuthController.java`
around lines 82 - 100, handleSocialLink currently only catches UserException so
non-UserException failures (e.g., network/OAuth provider errors from
oAuthService.linkSocialAccount) bubble up and can return 500; expand the catch
to mirror handleSocialLogin by adding a broader catch(Exception e) after the
existing UserException handler that logs the error and redirects to the failure
page via clientProperties.getUrl() +
clientProperties.getPaths().getSocialLinkFailure(), ensuring
ALREADY_LINKED_SOCIAL_ACCOUNT still returns the conflict redirect and success
continues to return the success redirect.
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (5)
src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java (1)
16-45:EmailVerificationEventListener와 재시도 로직이 완전히 동일 — 공통 유틸로 추출 고려
EmailVerificationEventListener와 이 리스너의 재시도 루프(상수, 지수 백오프,InterruptedException처리)가 구조적으로 동일합니다. 향후 재시도 정책 변경 시 두 곳을 모두 수정해야 하는 문제가 생깁니다.재시도 로직을 공통 헬퍼(예:
RetryTemplate활용 또는 간단한 유틸 메서드)로 추출하면 중복을 줄이고 정책 변경을 한 곳에서 관리할 수 있습니다.♻️ 공통 유틸 메서드 예시
public final class RetryUtils { private RetryUtils() {} public static void executeWithRetry(Runnable task, int maxAttempts, long initialDelayMs, String failureLogMessage, Object... logArgs) { long delay = initialDelayMs; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { task.run(); return; } catch (EmailSendException e) { if (attempt == maxAttempts) { log.error(failureLogMessage, logArgs, e); return; } try { Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; } delay *= 2; } } } }그러면 각 리스너에서는 다음과 같이 호출할 수 있습니다:
`@Async` `@EventListener` public void handle(PasswordResetCreateEvent event) { RetryUtils.executeWithRetry( () -> mailService.sendPasswordResetLink(event.to(), event.link(), PasswordResetConstants.TOKEN_TTL_MINUTES), MAX_ATTEMPTS, INITIAL_DELAY_MS, "비밀번호 재설정 링크 전송 실패: to={}", event.to() ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java` around lines 16 - 45, The retry logic in PasswordResetEventListener's handle method duplicates what is in EmailVerificationEventListener, including constants and exponential backoff with InterruptedException handling. To remove redundancy and centralize retry policy management, extract this retry loop into a shared utility method such as a RetryUtils.executeWithRetry that accepts the retry parameters, the task to run, and failure logging details, then replace the current loop in the handle method with a call to this utility passing the mailService call as a lambda and the appropriate constants and log message.src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java (1)
18-44:PasswordResetEventListener와 재시도 로직 중복
MAX_ATTEMPTS,INITIAL_DELAY_MS, 지수 백오프 루프,InterruptedException처리 방식이PasswordResetEventListener와 완전히 동일합니다. 향후 재시도 정책 변경 시 두 곳을 모두 수정해야 하는 유지보수 부담이 생깁니다.공통 재시도 헬퍼(예:
MailSendRetrySupport추상 클래스 또는RetryableMailSender유틸리티)로 추출하는 것을 권장합니다.♻️ 리팩토링 예시 (추상 베이스 클래스)
// 공통 추상 클래스 public abstract class RetryableMailEventListener { private static final int MAX_ATTEMPTS = 3; private static final long INITIAL_DELAY_MS = 2000L; protected void sendWithRetry(Runnable sendAction, String recipient) { long delay = INITIAL_DELAY_MS; for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { sendAction.run(); return; } catch (EmailSendException e) { if (attempt == MAX_ATTEMPTS) { log.error("메일 전송 실패: to={}", maskEmail(recipient), e); return; } try { Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; } delay *= 2; } } } } // 이메일 인증 리스너 public class EmailVerificationEventListener extends RetryableMailEventListener { `@Async` `@EventListener` public void handle(EmailVerificationSendEvent event) { sendWithRetry( () -> mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES), event.to() ); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java` around lines 18 - 44, The retry logic (MAX_ATTEMPTS, INITIAL_DELAY_MS, exponential backoff, InterruptedException handling) in EmailVerificationEventListener.handle duplicates PasswordResetEventListener; extract this into a shared helper/abstract (e.g., create RetryableMailEventListener or MailSendRetrySupport) that exposes a sendWithRetry(Runnable sendAction, String recipient) method implementing the current loop and logging behavior, then change EmailVerificationEventListener.handle to call sendWithRetry(() -> mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES), event.to()) and do the same refactor for PasswordResetEventListener to remove duplicate constants and logic.src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java (1)
46-62:getUsers()는 존재하지 않는 ID를 조용히 무시합니다 – 호출자가 누락 여부를 알 수 없습니다.요청한 ID 중 일부가 존재하지 않아도 오류 없이 결과가 반환됩니다. 호출하는 서비스에서 요청한 ID 수와 응답의 사용자 수를 비교하지 않으면 누락된 항목을 감지할 방법이 없습니다. 의도적인 설계라면 API 계약(proto 주석 또는 문서)에 명시하는 것을 권장합니다. 만약 부분 실패를 표현해야 한다면
Status.NOT_FOUND나 별도의not_found_ids필드를 응답에 추가하는 것을 검토하세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java` around lines 46 - 62, The getUsers method currently silently omits non-existent IDs; update getUsers to detect missing IDs by comparing request.getUserIdsList() with the IDs returned from userRepository.findByIdInAndStatus(...) (e.g., collect returned users' IDs via users.stream().map(User::getId)...), then if any requested IDs are missing either: 1) return a gRPC NOT_FOUND error via responseObserver.onError(Status.NOT_FOUND.withDescription("Missing userIds: ...").asRuntimeException()), or 2) augment the response (GetUsersResponse) to include a not_found_ids field and populate it with the missing IDs before onNext/onCompleted—make the change inside getUsers and keep existing logging (log.error) for exceptions.src/main/java/flipnote/user/domain/user/application/AuthService.java (2)
94-100:refreshToken과validateToken의 세션 무효화 검증 로직 중복동일한
invalidatedAt비교 블록이 두 메서드에 각각 존재합니다. private 헬퍼로 추출하면 유지보수 부담을 줄일 수 있습니다.♻️ 리팩토링 제안
+private void checkSessionInvalidation(User user, String token) { + if (user.getInvalidatedAt() != null) { + Date issuedAt = jwtProvider.getIssuedAt(token); + if (issuedAt.before(Date.from(user.getInvalidatedAt() + .atZone(ZoneId.systemDefault()).toInstant()))) { + throw new UserException(UserErrorCode.INVALIDATED_SESSION); + } + } +}
refreshToken및validateToken내부의 동일 블록을checkSessionInvalidation(user, refreshToken)/checkSessionInvalidation(user, token)호출로 대체합니다.Also applies to: 133-139
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/application/AuthService.java` around lines 94 - 100, Extract the duplicated session invalidation check in AuthService into a private helper called checkSessionInvalidation(User user, String token) and replace the duplicate blocks in refreshToken and validateToken with calls to this helper; the helper should obtain issuedAt via jwtProvider.getIssuedAt(token), convert user.getInvalidatedAt() to an Instant using atZone(ZoneId.systemDefault()).toInstant(), compare issuedAt.before(Date.from(invalidatedInstant)), and throw new UserException(UserErrorCode.INVALIDATED_SESSION) when appropriate so both refreshToken and validateToken use the same logic.
213-216:findActiveUser헬퍼가UserService와 중복됩니다
UserService에도 동일한 구현의findActiveUser가 존재합니다(UserService.java42-46번째 줄). 도메인 서비스 계층이나UserRepository기본 메서드로 공통화하는 것을 고려할 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/application/AuthService.java` around lines 213 - 216, There is a duplicate helper method findActiveUser in AuthService and UserService; remove the duplication by centralizing the logic: delete findActiveUser from AuthService and call the existing UserService.findActiveUser(...) (or extract a single shared method into a new domain helper/class such as UserDomainService.findActiveUser that uses userRepository.findByIdAndStatus(...) and throws new UserException(UserErrorCode.USER_NOT_FOUND)); update all call sites in AuthService to use the centralized method and ensure imports/exception handling remain the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@build.gradle.kts`:
- Around line 27-31: Remove the duplicate dependencyManagement block that
hardcodes the Spring gRPC BOM (the block containing
mavenBom("org.springframework.grpc:spring-grpc-dependencies:1.0.2")), leaving
only the other dependencyManagement block that references the extra property
springGrpcVersion defined earlier; in short, delete the first
dependencyManagement block so the build uses the springGrpcVersion property
consistently.
In `@src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java`:
- Around line 25-44: The NOT_FOUND onError call in GrpcUserQueryService.getUser
can itself throw and cause the outer catch to call responseObserver.onError a
second time; to fix, only wrap repository access in the try and perform the
NOT_FOUND branch (the responseObserver.onError(Status.NOT_FOUND...)) outside the
try so it cannot be caught by the outer catch, or alternatively wrap each
responseObserver.onError(...) in its own try/catch to prevent rethrow; locate
getUser, userRepository.findByIdAndStatus(...) and responseObserver.onError(...)
and adjust control flow so repository lookup is try-protected but the NOT_FOUND
response is invoked outside that try (or each onError is individually guarded).
In
`@src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java`:
- Line 33: The error logs currently expose plaintext email addresses
(event.to()) in EmailVerificationEventListener (and similarly in
PasswordResetEventListener and ResendMailService); replace direct email logging
with a masked value or internal identifier: implement or reuse a maskEmail
utility (e.g., maskEmail(String email)) and use maskEmail(event.to()) in the log
calls, or log an internal userId instead of the email, ensuring all occurrences
in EmailVerificationEventListener, PasswordResetEventListener, and
ResendMailService are updated to avoid PII leakage.
- Around line 23-24: The listeners EmailVerificationEventListener and
PasswordResetEventListener use `@Async` without a specified executor which
defaults to an unbounded SimpleAsyncTaskExecutor; define a bounded
ThreadPoolTaskExecutor bean (e.g., bean name "emailExecutor") with sensible
core/max pool sizes and queue capacity in a `@Configuration` class, register it as
the TaskExecutor, and update the listeners' annotations to
`@Async`("emailExecutor") so they use the bounded pool instead of the default
executor.
In
`@src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java`:
- Line 33: The log in PasswordResetEventListener currently writes the raw email
from event.to() (PII); change the logging to use a masked email instead (e.g.,
u***@example.com) by creating a small reusable utility method
(maskEmail(String)) and call it when logging in PasswordResetEventListener,
EmailVerificationEventListener, and ResendMailService; ensure the log call uses
maskEmail(event.to()) and keep the original exception parameter (e) intact so
only the email is masked while preserving error details.
In `@src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java`:
- Around line 27-35: The requestAccessToken method adds a code_verifier param
unconditionally which results in params.add("code_verifier", null) when
codeVerifier is null; change the logic in requestAccessToken to only add the
"code_verifier" entry to the params MultiValueMap when codeVerifier is
non-null/non-empty (e.g., check codeVerifier != null && !codeVerifier.isBlank())
so PKCE is only sent when present, keeping the rest of the parameter
construction (grant_type, client_id, client_secret, redirect_uri, code)
unchanged.
- Around line 45-46: In OAuthApiClient where you parse the token response
(objectMapper.readValue into responseMap and return (String)
responseMap.get("access_token")), guard against missing "access_token": if
responseMap.get("access_token") is null, extract any "error" and
"error_description" from responseMap and throw a descriptive runtime (or custom
OAuth) exception instead of returning null; update the method to return a
non-null token or throw (e.g., IllegalStateException or OAuthException) so
callers never receive a silent null and the thrown error includes the provider
error details for debugging.
In `@src/main/java/flipnote/user/UserApplication.java`:
- Line 10: Your app enables async via `@EnableAsync` in UserApplication but lacks
an explicit TaskExecutor, which causes Spring Boot to default to an unbounded
pool; fix by adding either (A) the spring.task.execution.pool settings in
application.yml (set core-size, max-size, queue-capacity and thread-name-prefix)
or (B) create a configuration class (e.g., AsyncConfig) that implements
AsyncConfigurer and returns a configured ThreadPoolTaskExecutor from
getAsyncExecutor (set corePoolSize, maxPoolSize, queueCapacity, threadNamePrefix
and call initialize()), then remove reliance on the unbounded default and tune
sizes for your environment.
In `@src/main/resources/application.yml`:
- Around line 68-69: The OAuth client properties currently set default empty
strings which lets the app start but send invalid credentials; update the YAML
so the client-id and client-secret reference environment variables without a
colon/default (change client-id: ${GOOGLE_CLIENT_ID:} to client-id:
${GOOGLE_CLIENT_ID} and client-secret: ${GOOGLE_CLIENT_SECRET:} to
client-secret: ${GOOGLE_CLIENT_SECRET}) so Spring fails fast when those env vars
are missing and the misconfiguration is detected early (refer to the client-id
and client-secret property entries in the OAuth config).
In `@src/test/resources/application.yml`:
- Line 25: The jwt.secret property currently contains a long hex-like string
that looks like a real key; replace the value of jwt.secret with a clearly fake
test value (for example "test-jwt-secret" or "dummy-jwt-secret")—keeping the
same YAML quoting—to match other test fixtures (e.g., app.resend.api-key,
app.oauth2.providers.google.client-id) and avoid committing anything resembling
a production secret.
---
Duplicate comments:
In `@src/main/java/flipnote/user/domain/user/application/AuthService.java`:
- Around line 168-176: The current requestPasswordReset method leaks
registration status because after checking userRepository.existsByEmail(email)
it throws when passwordResetRepository.hasToken(email) is true; change
requestPasswordReset so that if passwordResetRepository.hasToken(email) returns
true it simply returns quietly (just like the non-existent-email path) instead
of throwing UserException(UserErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK),
keeping the existsByEmail check and any other side effects intact; optionally
log the condition internally but do not surface an exception or differing
response to callers.
- Around line 39-60: The register() method correctly enforces
emailVerificationRepository.isVerified(request.getEmail()) before checking
userRepository.existsByEmail(request.getEmail()) and constructing the User; no
code changes are required—keep the current ordering and exception behaviors, but
add or update unit tests that exercise AuthService.register, specifically
covering the paths where emailVerificationRepository.isVerified returns false
(expect UserErrorCode.UNVERIFIED_EMAIL), where userRepository.existsByEmail
returns true (expect UserErrorCode.EMAIL_ALREADY_EXISTS), and the successful
creation path to verify password encoding and saved UserResponse.
In `@src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java`:
- Around line 52-64: In requestUserInfo(OAuthProperties.Provider provider,
String accessToken) the responseBody returned from restClient may be null which
will cause objectMapper.readValue(...) to throw an NPE; mirror the fix used in
requestAccessToken by null-checking responseBody before calling
objectMapper.readValue, and if null throw a descriptive exception (or return an
empty map) so objectMapper is never invoked with null—update the error path in
requestUserInfo to include the response content (or lack thereof) in the thrown
RuntimeException for easier debugging.
- Around line 37-50: The code calls
restClient.post()...retrieve().body(String.class) and immediately passes
responseBody to objectMapper.readValue which will NPE if responseBody is null;
update the method in OAuthApiClient to check responseBody for null or empty
after the retrieve() call (the block that builds the request using restClient
and the parsing with objectMapper.readValue) and throw a clear RuntimeException
(or return an Optional) if the response body is missing, or handle it gracefully
before calling objectMapper.readValue; reference the
restClient.post()...retrieve().body(String.class) call and
objectMapper.readValue(...) usage when making the change.
---
Nitpick comments:
In `@src/main/java/flipnote/user/domain/user/application/AuthService.java`:
- Around line 94-100: Extract the duplicated session invalidation check in
AuthService into a private helper called checkSessionInvalidation(User user,
String token) and replace the duplicate blocks in refreshToken and validateToken
with calls to this helper; the helper should obtain issuedAt via
jwtProvider.getIssuedAt(token), convert user.getInvalidatedAt() to an Instant
using atZone(ZoneId.systemDefault()).toInstant(), compare
issuedAt.before(Date.from(invalidatedInstant)), and throw new
UserException(UserErrorCode.INVALIDATED_SESSION) when appropriate so both
refreshToken and validateToken use the same logic.
- Around line 213-216: There is a duplicate helper method findActiveUser in
AuthService and UserService; remove the duplication by centralizing the logic:
delete findActiveUser from AuthService and call the existing
UserService.findActiveUser(...) (or extract a single shared method into a new
domain helper/class such as UserDomainService.findActiveUser that uses
userRepository.findByIdAndStatus(...) and throws new
UserException(UserErrorCode.USER_NOT_FOUND)); update all call sites in
AuthService to use the centralized method and ensure imports/exception handling
remain the same.
In `@src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java`:
- Around line 46-62: The getUsers method currently silently omits non-existent
IDs; update getUsers to detect missing IDs by comparing request.getUserIdsList()
with the IDs returned from userRepository.findByIdInAndStatus(...) (e.g.,
collect returned users' IDs via users.stream().map(User::getId)...), then if any
requested IDs are missing either: 1) return a gRPC NOT_FOUND error via
responseObserver.onError(Status.NOT_FOUND.withDescription("Missing userIds:
...").asRuntimeException()), or 2) augment the response (GetUsersResponse) to
include a not_found_ids field and populate it with the missing IDs before
onNext/onCompleted—make the change inside getUsers and keep existing logging
(log.error) for exceptions.
In
`@src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java`:
- Around line 18-44: The retry logic (MAX_ATTEMPTS, INITIAL_DELAY_MS,
exponential backoff, InterruptedException handling) in
EmailVerificationEventListener.handle duplicates PasswordResetEventListener;
extract this into a shared helper/abstract (e.g., create
RetryableMailEventListener or MailSendRetrySupport) that exposes a
sendWithRetry(Runnable sendAction, String recipient) method implementing the
current loop and logging behavior, then change
EmailVerificationEventListener.handle to call sendWithRetry(() ->
mailService.sendVerificationCode(event.to(), event.code(),
VerificationConstants.CODE_TTL_MINUTES), event.to()) and do the same refactor
for PasswordResetEventListener to remove duplicate constants and logic.
In
`@src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java`:
- Around line 16-45: The retry logic in PasswordResetEventListener's handle
method duplicates what is in EmailVerificationEventListener, including constants
and exponential backoff with InterruptedException handling. To remove redundancy
and centralize retry policy management, extract this retry loop into a shared
utility method such as a RetryUtils.executeWithRetry that accepts the retry
parameters, the task to run, and failure logging details, then replace the
current loop in the handle method with a call to this utility passing the
mailService call as a lambda and the appropriate constants and log message.
| dependencyManagement { | ||
| imports { | ||
| mavenBom("org.springframework.grpc:spring-grpc-dependencies:1.0.2") | ||
| } | ||
| } |
There was a problem hiding this comment.
dependencyManagement 블록이 중복 선언되어 있습니다. 첫 번째 블록(lines 27–31)을 제거하세요.
Lines 27–31과 63–67이 동일한 Spring gRPC BOM을 두 번 임포트하고 있습니다. 첫 번째 블록은 버전을 "1.0.2"로 하드코딩하고 있으며, 24번 줄에 선언된 springGrpcVersion extra property를 사용하지 않아 일관성도 깨집니다. 두 번째 블록(lines 63–67)이 property를 올바르게 참조하므로, 첫 번째 블록을 제거해야 합니다.
🔧 수정 제안
-dependencyManagement {
- imports {
- mavenBom("org.springframework.grpc:spring-grpc-dependencies:1.0.2")
- }
-}
-
-
dependencies {Also applies to: 63-67
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@build.gradle.kts` around lines 27 - 31, Remove the duplicate
dependencyManagement block that hardcodes the Spring gRPC BOM (the block
containing mavenBom("org.springframework.grpc:spring-grpc-dependencies:1.0.2")),
leaving only the other dependencyManagement block that references the extra
property springGrpcVersion defined earlier; in short, delete the first
dependencyManagement block so the build uses the springGrpcVersion property
consistently.
| @Override | ||
| public void getUser(GetUserRequest request, StreamObserver<GetUserResponse> responseObserver) { | ||
| try { | ||
| User user = userRepository.findByIdAndStatus(request.getUserId(), User.Status.ACTIVE) | ||
| .orElse(null); | ||
|
|
||
| if (user == null) { | ||
| responseObserver.onError( | ||
| Status.NOT_FOUND.withDescription("사용자를 찾을 수 없습니다.").asRuntimeException() | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| responseObserver.onNext(toResponse(user)); | ||
| responseObserver.onCompleted(); | ||
| } catch (Exception e) { | ||
| log.error("gRPC getUser error. userId: {}", request.getUserId(), e); | ||
| responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException()); | ||
| } | ||
| } |
There was a problem hiding this comment.
NOT_FOUND 경로의 onError() 가 예외를 던질 경우 catch 블록에서 onError()가 중복 호출됩니다.
try 블록이 NOT_FOUND onError 호출(Line 32–34)까지 감싸고 있어, responseObserver.onError(NOT_FOUND...) 자체가 예외를 던지면 catch 블록에서 onError(INTERNAL...)이 다시 호출됩니다. gRPC StreamObserver에서 onError를 두 번 호출하는 것은 프로토콜 위반입니다. NOT_FOUND 분기를 try 블록 바깥으로 분리하거나, 각 onError 호출을 별도로 보호하는 방식으로 수정하세요.
🐛 수정 제안
`@Override`
public void getUser(GetUserRequest request, StreamObserver<GetUserResponse> responseObserver) {
- try {
- User user = userRepository.findByIdAndStatus(request.getUserId(), User.Status.ACTIVE)
- .orElse(null);
-
- if (user == null) {
- responseObserver.onError(
- Status.NOT_FOUND.withDescription("사용자를 찾을 수 없습니다.").asRuntimeException()
- );
- return;
- }
-
- responseObserver.onNext(toResponse(user));
- responseObserver.onCompleted();
- } catch (Exception e) {
- log.error("gRPC getUser error. userId: {}", request.getUserId(), e);
- responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException());
- }
+ User user;
+ try {
+ user = userRepository.findByIdAndStatus(request.getUserId(), User.Status.ACTIVE)
+ .orElse(null);
+ } catch (Exception e) {
+ log.error("gRPC getUser error. userId: {}", request.getUserId(), e);
+ responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException());
+ return;
+ }
+
+ if (user == null) {
+ responseObserver.onError(
+ Status.NOT_FOUND.withDescription("사용자를 찾을 수 없습니다.").asRuntimeException()
+ );
+ return;
+ }
+
+ try {
+ responseObserver.onNext(toResponse(user));
+ responseObserver.onCompleted();
+ } catch (Exception e) {
+ log.error("gRPC getUser error. userId: {}", request.getUserId(), e);
+ responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException());
+ }
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java`
around lines 25 - 44, The NOT_FOUND onError call in GrpcUserQueryService.getUser
can itself throw and cause the outer catch to call responseObserver.onError a
second time; to fix, only wrap repository access in the try and perform the
NOT_FOUND branch (the responseObserver.onError(Status.NOT_FOUND...)) outside the
try so it cannot be caught by the outer catch, or alternatively wrap each
responseObserver.onError(...) in its own try/catch to prevent rethrow; locate
getUser, userRepository.findByIdAndStatus(...) and responseObserver.onError(...)
and adjust control flow so repository lookup is try-protected but the NOT_FOUND
response is invoked outside that try (or each onError is individually guarded).
| @Async | ||
| @EventListener |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# TaskExecutor 및 Async 관련 설정 확인
echo "=== Searching for TaskExecutor and Async configuration ==="
rg -n --type=java -C3 'TaskExecutor|ThreadPoolTaskExecutor|AsyncConfigurer|@EnableAsync'
echo -e "\n=== Searching for `@Async` usage ==="
rg -n --type=java '@Async' -C2
echo -e "\n=== Checking EmailVerificationEventListener.java ==="
find . -name "EmailVerificationEventListener.java" -type f
echo -e "\n=== Checking PasswordResetEventListener.java ==="
find . -name "PasswordResetEventListener.java" -type fRepository: FlipNoteTeam/FlipNote-User
Length of output: 2246
🏁 Script executed:
#!/bin/bash
# 애플리케이션 설정 파일 확인
echo "=== Searching for application configuration files ==="
find . -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" | head -20
echo -e "\n=== Searching for any Bean definitions with `@Bean` and Executor ==="
rg -n --type=java '@Bean.*[Ee]xecutor|@Bean.*[Aa]sync' -A5
echo -e "\n=== Checking for AsyncConfigurer implementations ==="
rg -n --type=java 'implements AsyncConfigurer' -A10
echo -e "\n=== Checking UserApplication.java for async configuration ==="
cat -n src/main/java/flipnote/user/UserApplication.javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 987
🏁 Script executed:
#!/bin/bash
# Check application.yml for async executor configuration
echo "=== Checking application.yml for async executor config ==="
cat -n src/main/resources/application.yml
echo -e "\n=== Checking test application.yml ==="
cat -n src/test/resources/application.ymlRepository: FlipNoteTeam/FlipNote-User
Length of output: 4447
@Async에 실행기(Executor) 미지정 — 스레드 고갈 위험
실행기 이름을 지정하지 않으면 Spring은 기본적으로 SimpleAsyncTaskExecutor를 사용하며, 이는 호출마다 새 스레드를 생성하고 스레드 수에 제한이 없습니다. 프로젝트에 @EnableAsync는 활성화되어 있으나 커스텀 TaskExecutor 빈이나 spring.task.execution 설정이 없으므로, 이메일 발송 이벤트가 다량 발생할 경우 스레드 고갈로 이어질 수 있습니다.
EmailVerificationEventListener와 PasswordResetEventListener 모두 동일한 문제를 가지고 있습니다. 스레드 풀 크기를 제한하는 전용 TaskExecutor 빈을 구성하고, @Async("beanName") 형태로 실행기를 명시적으로 지정하는 것을 권장합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java`
around lines 23 - 24, The listeners EmailVerificationEventListener and
PasswordResetEventListener use `@Async` without a specified executor which
defaults to an unbounded SimpleAsyncTaskExecutor; define a bounded
ThreadPoolTaskExecutor bean (e.g., bean name "emailExecutor") with sensible
core/max pool sizes and queue capacity in a `@Configuration` class, register it as
the TaskExecutor, and update the listeners' annotations to
`@Async`("emailExecutor") so they use the bounded pool instead of the default
executor.
| return; | ||
| } catch (EmailSendException e) { | ||
| if (attempt == MAX_ATTEMPTS) { | ||
| log.error("이메일 인증번호 전송 실패: to={}", event.to(), e); |
There was a problem hiding this comment.
이메일 주소(PII) 오류 로그 노출 위험
event.to()는 수신자 이메일 주소로, 오류 로그에 평문으로 기록되면 GDPR/CCPA 상 PII 누출에 해당합니다. 동일한 문제가 PasswordResetEventListener(Line 24)와 ResendMailService의 오류 로그에도 존재합니다.
운영 로그에 이메일 주소를 직접 기록하는 대신 마스킹 처리하거나, 내부 사용자 식별자(예: userId)로 대체하는 것을 권장합니다.
🛡️ 수정 예시 (마스킹 처리)
- log.error("이메일 인증번호 전송 실패: to={}", event.to(), e);
+ log.error("이메일 인증번호 전송 실패: to={}", maskEmail(event.to()), e);별도 유틸 메서드 예시:
private static String maskEmail(String email) {
int atIdx = email.indexOf('@');
if (atIdx <= 1) return "***";
return email.charAt(0) + "***" + email.substring(atIdx);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java`
at line 33, The error logs currently expose plaintext email addresses
(event.to()) in EmailVerificationEventListener (and similarly in
PasswordResetEventListener and ResendMailService); replace direct email logging
with a masked value or internal identifier: implement or reuse a maskEmail
utility (e.g., maskEmail(String email)) and use maskEmail(event.to()) in the log
calls, or log an internal userId instead of the email, ensuring all occurrences
in EmailVerificationEventListener, PasswordResetEventListener, and
ResendMailService are updated to avoid PII leakage.
| return; | ||
| } catch (EmailSendException e) { | ||
| if (attempt == MAX_ATTEMPTS) { | ||
| log.error("비밀번호 재설정 링크 전송 실패: to={}", event.to(), e); |
There was a problem hiding this comment.
에러 로그에 이메일 주소(PII) 포함 — 마스킹 검토
event.to()는 사용자 이메일 주소로, 로그에 그대로 출력하면 개인정보(PII) 노출 우려가 있습니다. GDPR/개인정보보호법 준수를 위해 로그 출력 시 마스킹 처리(예: u***@example.com)를 고려해 주세요.
참고: EmailVerificationEventListener와 ResendMailService에도 동일한 패턴이 존재합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java`
at line 33, The log in PasswordResetEventListener currently writes the raw email
from event.to() (PII); change the logging to use a masked email instead (e.g.,
u***@example.com) by creating a small reusable utility method
(maskEmail(String)) and call it when logging in PasswordResetEventListener,
EmailVerificationEventListener, and ResendMailService; ensure the log call uses
maskEmail(event.to()) and keep the original exception parameter (e) intact so
only the email is masked while preserving error details.
| public String requestAccessToken(OAuthProperties.Provider provider, String code, | ||
| String codeVerifier, HttpServletRequest request) { | ||
| MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); | ||
| params.add("grant_type", "authorization_code"); | ||
| params.add("client_id", provider.getClientId()); | ||
| params.add("client_secret", provider.getClientSecret()); | ||
| params.add("redirect_uri", buildRedirectUri(request, provider.getRedirectUri())); | ||
| params.add("code", code); | ||
| params.add("code_verifier", codeVerifier); |
There was a problem hiding this comment.
codeVerifier가 null이면 PKCE 파라미터가 빈 값으로 요청에 포함됩니다.
codeVerifier가 null일 경우 params.add("code_verifier", null)로 파라미터가 추가되어, RestClient가 이를 code_verifier=(빈 문자열)으로 직렬화하거나 예기치 않은 동작을 유발할 수 있습니다. PKCE를 사용하지 않는 흐름을 지원할 의도라면 조건부로 추가해야 합니다.
🛡️ 수정 제안
- params.add("code_verifier", codeVerifier);
+ if (codeVerifier != null) {
+ params.add("code_verifier", codeVerifier);
+ }📝 Committable suggestion
‼️ 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.
| public String requestAccessToken(OAuthProperties.Provider provider, String code, | |
| String codeVerifier, HttpServletRequest request) { | |
| MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); | |
| params.add("grant_type", "authorization_code"); | |
| params.add("client_id", provider.getClientId()); | |
| params.add("client_secret", provider.getClientSecret()); | |
| params.add("redirect_uri", buildRedirectUri(request, provider.getRedirectUri())); | |
| params.add("code", code); | |
| params.add("code_verifier", codeVerifier); | |
| public String requestAccessToken(OAuthProperties.Provider provider, String code, | |
| String codeVerifier, HttpServletRequest request) { | |
| MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); | |
| params.add("grant_type", "authorization_code"); | |
| params.add("client_id", provider.getClientId()); | |
| params.add("client_secret", provider.getClientSecret()); | |
| params.add("redirect_uri", buildRedirectUri(request, provider.getRedirectUri())); | |
| params.add("code", code); | |
| if (codeVerifier != null) { | |
| params.add("code_verifier", codeVerifier); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java`
around lines 27 - 35, The requestAccessToken method adds a code_verifier param
unconditionally which results in params.add("code_verifier", null) when
codeVerifier is null; change the logic in requestAccessToken to only add the
"code_verifier" entry to the params MultiValueMap when codeVerifier is
non-null/non-empty (e.g., check codeVerifier != null && !codeVerifier.isBlank())
so PKCE is only sent when present, keeping the rest of the parameter
construction (grant_type, client_id, client_secret, redirect_uri, code)
unchanged.
| Map<String, Object> responseMap = objectMapper.readValue(responseBody, new TypeReference<>() {}); | ||
| return (String) responseMap.get("access_token"); |
There was a problem hiding this comment.
access_token 키 부재 시 null이 조용히 반환됩니다.
OAuth 공급자가 에러 응답(예: {"error":"invalid_grant","error_description":"..."})을 반환하면 responseMap.get("access_token")은 null을 반환하고, 현재 코드는 이를 예외 없이 그대로 반환합니다. 호출 측에서 null 검사 없이 사용하면 NPE가 발생합니다.
🛡️ 수정 제안
Map<String, Object> responseMap = objectMapper.readValue(responseBody, new TypeReference<>() {});
- return (String) responseMap.get("access_token");
+ String accessToken = (String) responseMap.get("access_token");
+ if (accessToken == null) {
+ throw new RuntimeException("OAuth token response missing access_token: " + responseBody);
+ }
+ return accessToken;📝 Committable suggestion
‼️ 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.
| Map<String, Object> responseMap = objectMapper.readValue(responseBody, new TypeReference<>() {}); | |
| return (String) responseMap.get("access_token"); | |
| Map<String, Object> responseMap = objectMapper.readValue(responseBody, new TypeReference<>() {}); | |
| String accessToken = (String) responseMap.get("access_token"); | |
| if (accessToken == null) { | |
| throw new RuntimeException("OAuth token response missing access_token: " + responseBody); | |
| } | |
| return accessToken; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java`
around lines 45 - 46, In OAuthApiClient where you parse the token response
(objectMapper.readValue into responseMap and return (String)
responseMap.get("access_token")), guard against missing "access_token": if
responseMap.get("access_token") is null, extract any "error" and
"error_description" from responseMap and throw a descriptive runtime (or custom
OAuth) exception instead of returning null; update the method to return a
non-null token or throw (e.g., IllegalStateException or OAuthException) so
callers never receive a silent null and the thrown error includes the provider
error details for debugging.
| client-id: ${GOOGLE_CLIENT_ID:} | ||
| client-secret: ${GOOGLE_CLIENT_SECRET:} |
There was a problem hiding this comment.
OAuth 자격증명 기본값이 빈 문자열이면 OAuth 흐름 전체가 조용히 실패합니다.
${GOOGLE_CLIENT_ID:}와 ${GOOGLE_CLIENT_SECRET:}의 기본값이 빈 문자열이므로, 환경변수 미설정 시 애플리케이션은 정상 기동되지만 실제 OAuth 요청은 유효하지 않은 client_id/client_secret을 Google에 전송합니다. 설정 누락을 조기에 감지하기 위해 기본값 없이 환경변수만 참조하도록 변경하는 것을 권장합니다.
🛡️ 수정 제안
- client-id: ${GOOGLE_CLIENT_ID:}
- client-secret: ${GOOGLE_CLIENT_SECRET:}
+ client-id: ${GOOGLE_CLIENT_ID}
+ client-secret: ${GOOGLE_CLIENT_SECRET}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/resources/application.yml` around lines 68 - 69, The OAuth client
properties currently set default empty strings which lets the app start but send
invalid credentials; update the YAML so the client-id and client-secret
reference environment variables without a colon/default (change client-id:
${GOOGLE_CLIENT_ID:} to client-id: ${GOOGLE_CLIENT_ID} and client-secret:
${GOOGLE_CLIENT_SECRET:} to client-secret: ${GOOGLE_CLIENT_SECRET}) so Spring
fails fast when those env vars are missing and the misconfiguration is detected
early (refer to the client-id and client-secret property entries in the OAuth
config).
| port: 0 | ||
|
|
||
| jwt: | ||
| secret: "55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Search for JWT secret definitions across all configuration files
find . -type f \( -name "application*.yml" -o -name "application*.yaml" -o -name "*.properties" \) | head -20Repository: FlipNoteTeam/FlipNote-User
Length of output: 186
🏁 Script executed:
# Search for jwt.secret mentions in configuration files
rg -n "jwt.*secret" --type yamlRepository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
# Check if the same secret value appears in non-test configurations
TEST_SECRET="55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729"
echo "=== Checking for secret in all configurations ==="
rg "$TEST_SECRET" --type yaml
echo ""
echo "=== Check if main application config exists ==="
ls -la src/main/resources/application*.yml 2>/dev/null || echo "No main application.yml found"Repository: FlipNoteTeam/FlipNote-User
Length of output: 556
🏁 Script executed:
# Get full context of the test configuration file to understand structure
cat -n src/test/resources/application.ymlRepository: FlipNoteTeam/FlipNote-User
Length of output: 1985
🏁 Script executed:
# Read the main application configuration to see jwt.secret setup
cat -n src/main/resources/application.ymlRepository: FlipNoteTeam/FlipNote-User
Length of output: 2433
테스트 설정에서 명백한 더미 값으로 JWT 시크릿을 교체하세요.
현재 src/test/resources/application.yml 라인 25의 jwt.secret은 128자 16진수 문자열로 실제 암호화 키처럼 보입니다. 같은 파일의 다른 테스트 값들(예: app.resend.api-key: "test-api-key", app.oauth2.providers.google.client-id: "test-client-id")처럼 명백하게 테스트 목적임을 나타내는 더미 값을 사용해야 합니다.
프로덕션 설정(src/main/resources/application.yml 라인 48)은 올바르게 환경 변수(${JWT_SECRET})를 사용하므로 시크릿이 VCS에 노출되지 않습니다. 다만 테스트 설정도 다른 테스트 값들과 일관성 있게 간단한 더미 값으로 통일하는 것이 모범 사례입니다.
제안하는 수정 방법
jwt:
- secret: "55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729"
+ secret: "test-jwt-secret"📝 Committable suggestion
‼️ 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.
| secret: "55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729" | |
| jwt: | |
| secret: "test-jwt-secret" |
🧰 Tools
🪛 Gitleaks (8.30.0)
[high] 25-25: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.
(generic-api-key)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/test/resources/application.yml` at line 25, The jwt.secret property
currently contains a long hex-like string that looks like a real key; replace
the value of jwt.secret with a clearly fake test value (for example
"test-jwt-secret" or "dummy-jwt-secret")—keeping the same YAML quoting—to match
other test fixtures (e.g., app.resend.api-key,
app.oauth2.providers.google.client-id) and avoid committing anything resembling
a production secret.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java (1)
12-30: 도메인 계층에 인터페이스가 없어 인프라 구현체에 직접 의존하게 됩니다.현재 패키지 구조(
domain.user.infrastructure)는 클린 아키텍처 의도를 반영하고 있으나, 도메인 계층에 해당하는 인터페이스(포트)가 없습니다. 서비스 계층이SessionInvalidationRepository구현체를 직접 참조하면 인프라 의존성이 도메인으로 역류하고, Redis를 Mock으로 교체한 단위 테스트 작성도 어려워집니다.♻️ 도메인 포트 인터페이스 분리 제안
도메인 계층에 인터페이스를 정의합니다:
// flipnote.user.domain.user.port.SessionInvalidationPort (또는 domain 내 적절한 위치) package flipnote.user.domain.user.port; import java.util.Optional; public interface SessionInvalidationPort { void invalidate(long userId, long ttlMillis); Optional<Long> getInvalidatedAtMillis(long userId); }인프라 구현체에서 해당 인터페이스를 구현합니다:
+import flipnote.user.domain.user.port.SessionInvalidationPort; + `@Repository` `@RequiredArgsConstructor` -public class SessionInvalidationRepository { +public class SessionInvalidationRepository implements SessionInvalidationPort {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java` around lines 12 - 30, Add a domain port interface and have the Redis implementation implement it: create flipnote.user.domain.user.port.SessionInvalidationPort with methods void invalidate(long userId, long ttlMillis) and Optional<Long> getInvalidatedAtMillis(long userId); then modify SessionInvalidationRepository to implement SessionInvalidationPort (adjusting method signatures from Long to long if you prefer primitives or keep boxed types consistent across interface/impl) and ensure callers/services depend on SessionInvalidationPort instead of SessionInvalidationRepository so tests can mock the port.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java`:
- Around line 27-30: getInvalidatedAtMillis에서
redisTemplate.opsForValue().get(KEY_PREFIX + userId)로 읽은 값에 Long::parseLong을 직접
사용하면 잘못된 문자열에서 NumberFormatException이 발생하므로 Optional.map 내부에서 예외가 터집니다; 수정:
getInvalidatedAtMillis 메서드의 Optional.ofNullable(value).map(Long::parseLong) 부분을
안전한 파싱 로직으로 교체해 파싱 실패(NumberFormatException)를 잡아 Optional.empty()를 반환하도록 하거나 별도
유틸(예: parseLongSafely(String): Optional<Long>)을 만들어 사용하고 필요하면 로깅(processLogger
또는 적절한 로거)을 추가해 잘못된 값임을 기록하십시오.
---
Duplicate comments:
In `@src/main/java/flipnote/user/domain/user/application/UserService.java`:
- Around line 28-32: Replace the duplicated lookup in UserService.getUserInfo by
reusing the existing helper findActiveUser: remove the direct
userRepository.findByIdAndStatus(...) call and instead call
findActiveUser(userId) to obtain the User, then return
UserInfoResponse.from(user); ensure you reference the UserService.getUserInfo
method and the findActiveUser helper so all active-user lookup logic is
centralized.
In `@src/main/java/flipnote/user/domain/user/presentation/AuthController.java`:
- Around line 60-65: The validateToken endpoint in AuthController (method
validateToken handling POST "/token/validate") is publicly accessible and must
be restricted to internal callers; update the endpoint to enforce access control
by validating an internal service credential (e.g., a shared service token, API
key, or mutual TLS info) or by applying Spring Security restrictions (e.g., an
`@PreAuthorize` or security filter that checks a specific scope/authority or a
custom InternalServiceAuthFilter) so only the API gateway/internal services can
call validateToken; ensure the authService.validateToken call remains but gate
the controller method entry with the internal-only check and return 403 for
unauthorized callers.
In `@src/main/java/flipnote/user/UserApplication.java`:
- Line 11: The project enables asynchronous execution with `@EnableAsync` in
UserApplication but doesn't configure a bounded thread pool, risking unbounded
thread growth; add a properly sized TaskExecutor bean or implement
AsyncConfigurer to return a configured ThreadPoolTaskExecutor (set sensible
corePoolSize, maxPoolSize, queueCapacity, threadNamePrefix, and
setRejectedExecutionHandler) and register it as the default executor for async
tasks (either in UserApplication or a dedicated `@Configuration` class) so
`@EnableAsync` uses this bounded executor instead of Spring's unbounded defaults.
---
Nitpick comments:
In
`@src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java`:
- Around line 12-30: Add a domain port interface and have the Redis
implementation implement it: create
flipnote.user.domain.user.port.SessionInvalidationPort with methods void
invalidate(long userId, long ttlMillis) and Optional<Long>
getInvalidatedAtMillis(long userId); then modify SessionInvalidationRepository
to implement SessionInvalidationPort (adjusting method signatures from Long to
long if you prefer primitives or keep boxed types consistent across
interface/impl) and ensure callers/services depend on SessionInvalidationPort
instead of SessionInvalidationRepository so tests can mock the port.
src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java
Show resolved
Hide resolved
| public Optional<Long> getInvalidatedAtMillis(Long userId) { | ||
| String value = redisTemplate.opsForValue().get(KEY_PREFIX + userId); | ||
| return Optional.ofNullable(value).map(Long::parseLong); | ||
| } |
There was a problem hiding this comment.
Long::parseLong이 NumberFormatException을 던질 수 있습니다.
Redis에 저장된 값이 유효하지 않은 숫자 문자열일 경우(수동 조작, 데이터 손상 등), Long::parseLong이 unchecked NumberFormatException을 던집니다. Optional.map() 내부에서 발생하면 호출자 측에서 의미 있는 오류 처리가 어렵습니다.
🛡️ 안전한 파싱으로 수정 제안
public Optional<Long> getInvalidatedAtMillis(Long userId) {
String value = redisTemplate.opsForValue().get(KEY_PREFIX + userId);
- return Optional.ofNullable(value).map(Long::parseLong);
+ if (value == null) return Optional.empty();
+ try {
+ return Optional.of(Long.parseLong(value));
+ } catch (NumberFormatException e) {
+ // 비정상적인 Redis 값 — 무효화 항목 없음으로 처리
+ return Optional.empty();
+ }
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java`
around lines 27 - 30, getInvalidatedAtMillis에서
redisTemplate.opsForValue().get(KEY_PREFIX + userId)로 읽은 값에 Long::parseLong을 직접
사용하면 잘못된 문자열에서 NumberFormatException이 발생하므로 Optional.map 내부에서 예외가 터집니다; 수정:
getInvalidatedAtMillis 메서드의 Optional.ofNullable(value).map(Long::parseLong) 부분을
안전한 파싱 로직으로 교체해 파싱 실패(NumberFormatException)를 잡아 Optional.empty()를 반환하도록 하거나 별도
유틸(예: parseLongSafely(String): Optional<Long>)을 만들어 사용하고 필요하면 로깅(processLogger
또는 적절한 로거)을 추가해 잘못된 값임을 기록하십시오.
There was a problem hiding this comment.
Actionable comments posted: 14
🧹 Nitpick comments (10)
src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java (1)
15-17: 비밀번호 복잡도 검증(@Pattern) 추가를 고려해 보세요.현재
@Size(min = 8, max = 20)은 길이만 검사하며 대소문자·숫자·특수문자 조합 같은 복잡도 요구사항은 적용되지 않습니다. 보안 정책에 따라@Pattern으로 복잡도를 강제하는 것을 권장합니다.✨ 수정 제안 예시
+import jakarta.validation.constraints.Pattern; + `@NotBlank`(message = "새 비밀번호는 필수입니다") `@Size`(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다") +@Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*]).+$", + message = "비밀번호는 영문 대소문자, 숫자, 특수문자를 각각 1자 이상 포함해야 합니다" +) private String password;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java` around lines 15 - 17, Add a password complexity check to the PasswordResetRequest DTO by applying a `@Pattern` annotation to the password field (private String password) to enforce your policy (e.g., require at least one uppercase, one lowercase, one digit and one special character, and length already constrained by `@Size`), update the validation message to reflect complexity requirements, and ensure any related controller/validation tests accept the new constraint; locate the password field in the PasswordResetRequest class and add the appropriate `@Pattern`(regex=..., message="...") alongside the existing `@NotBlank` and `@Size` annotations.src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java (1)
9-9:PasswordResetRepository가 이 상수를 사용하지 않아 DRY 원칙 위반
PasswordResetRepository에private static final long TTL_MINUTES = 30이 별도로 정의되어 있어PasswordResetConstants.TOKEN_TTL_MINUTES가 단일 진실 공급원(SSOT) 역할을 하지 못합니다. 향후 TTL 값을 변경할 때 두 곳을 모두 수정해야 하는 위험이 있습니다.
PasswordResetRepository(관련 스니펫 참조)를 아래와 같이 수정하는 것을 권장합니다.♻️ PasswordResetRepository에서 상수 참조 통일
- private static final long TTL_MINUTES = 30; public void save(String token, String email) { redisTemplate.opsForValue().set( TOKEN_KEY_PREFIX + token, email, - TTL_MINUTES, + PasswordResetConstants.TOKEN_TTL_MINUTES, TimeUnit.MINUTES ); redisTemplate.opsForValue().set( EMAIL_KEY_PREFIX + email, token, - TTL_MINUTES, + PasswordResetConstants.TOKEN_TTL_MINUTES, TimeUnit.MINUTES ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java` at line 9, Replace the duplicate TTL definition in PasswordResetRepository by removing the private static final long TTL_MINUTES = 30 and using PasswordResetConstants.TOKEN_TTL_MINUTES instead; update all usages of TTL_MINUTES in PasswordResetRepository to reference PasswordResetConstants.TOKEN_TTL_MINUTES, add the necessary import (if missing), and ensure any type conversions (int↔long) are handled where the constant is consumed.src/main/java/flipnote/user/global/exception/UserException.java (1)
11-14: 원인 예외를 전달하는 생성자 추가를 권장합니다.현재
Throwable cause를 받는 생성자가 없어, 외부 호출 실패(예: OAuth API 오류)를UserException으로 변환할 때 원인 예외 체인이 소실됩니다. 디버깅 시 스택 트레이스 추적이 어려워집니다.♻️ 개선 제안
public UserException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } + +public UserException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/exception/UserException.java` around lines 11 - 14, Add a constructor to UserException that accepts both ErrorCode and a Throwable cause so the original exception chain is preserved; implement the new constructor to call super(errorCode.getMessage(), cause) and assign this.errorCode = errorCode (matching the existing single-arg constructor pattern) so callers converting external failures (e.g., OAuth API errors) can pass the underlying exception and retain stack-trace/debugging information.src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java (1)
1-13: LGTM!
UUID.randomUUID()는 내부적으로SecureRandom을 사용하므로 비밀번호 재설정 토큰으로 충분히 안전합니다.다만 이 클래스는 Redis를 직접 사용하지 않으므로
redis패키지에 위치하는 것이 다소 어색합니다.infrastructure.token또는infrastructure.util같은 패키지로 이동하는 것을 고려해 볼 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java` around lines 1 - 13, PasswordResetTokenGenerator.generate is a token utility and not Redis-specific, so move the class out of the redis package (update its package declaration and file location) into a more appropriate package such as infrastructure.token or infrastructure.util; update any import references/usages of flipnote.user.auth.infrastructure.redis.PasswordResetTokenGenerator to the new package and verify Spring Component scanning still picks up the `@Component` (or adjust annotations/config if package scanning differs).src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java (1)
29-31:getCode()가null을 반환할 수 있어 호출부에서 명시적 null 처리가 필요합니다.같은 모듈의
SocialLinkTokenRepository.findUserIdByState()는Optional<String>을 반환하는 반면, 이 메서드는 nullableString을 직접 반환합니다. 반환 타입을Optional<String>으로 통일하면 호출부에서 null 처리를 누락할 위험을 줄일 수 있습니다.♻️ 개선 제안
- public String getCode(String email) { - return redisTemplate.opsForValue().get(CODE_KEY_PREFIX + email); - } + public Optional<String> getCode(String email) { + return Optional.ofNullable(redisTemplate.opsForValue().get(CODE_KEY_PREFIX + email)); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java` around lines 29 - 31, Change EmailVerificationRepository.getCode to return Optional<String> instead of nullable String: update the method signature in EmailVerificationRepository.getCode, wrap the redisTemplate.opsForValue().get(CODE_KEY_PREFIX + email) result with Optional.ofNullable(...), and update all call sites to handle Optional (or use orElse/orElseThrow where appropriate); mirror the pattern used by SocialLinkTokenRepository.findUserIdByState to ensure consistent null-safety across the module.src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java (1)
16-18:imageRefId는 항상null— 응답에서 제외하거나 TODO를 추가하는 것을 권장합니다.
from()팩토리 메서드에서imageRefId가 하드코딩된null로 설정됩니다. 이는UserUpdateResponse,MyInfoResponse와 일관된 패턴이지만, 클라이언트에게 불필요한"imageRefId": null필드가 노출됩니다. 이미지 서비스 연동 전까지@JsonInclude(JsonInclude.Include.NON_NULL)을 적용하거나, 향후 구현 예정임을 명시하는 주석을 추가하는 것을 고려해 보세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java` around lines 16 - 18, The factory method UserInfoResponse.from(User user) always sets imageRefId to null, causing "imageRefId": null in responses; update the UserInfoResponse class (and its from(User user) method) to either annotate the DTO with `@JsonInclude`(JsonInclude.Include.NON_NULL) so null fields are omitted, or adjust from() to omit setting imageRefId and add a clear TODO comment on the imageRefId field (or constructor) indicating pending image service integration; ensure the change references UserInfoResponse, from(User user), and the imageRefId field so the null value is no longer serialized to clients.src/main/java/flipnote/user/auth/application/AuthService.java (1)
168-180:verifyEmail에서hasCode와getCode사이 경합 조건 가능성 (TOCTOU)
hasCode(Line 169) 확인 후getCode(Line 173) 호출 사이에 코드가 TTL 만료될 수 있습니다. 실제 발생 확률은 매우 낮지만,getCode가null을 반환할 경우 Line 174의code.equals(savedCode)에서 의도치 않은 동작이 발생할 수 있습니다.
getCode결과를 먼저 가져온 후null체크하는 방식으로 단순화하면 한 번의 Redis 호출로 처리됩니다.♻️ 리팩터링 제안
public void verifyEmail(String email, String code) { - if (!emailVerificationRepository.hasCode(email)) { - throw new UserException(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE); - } - - String savedCode = emailVerificationRepository.getCode(email); - if (!code.equals(savedCode)) { + String savedCode = emailVerificationRepository.getCode(email); + if (savedCode == null) { + throw new UserException(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE); + } + if (!savedCode.equals(code)) { throw new UserException(AuthErrorCode.INVALID_VERIFICATION_CODE); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/auth/application/AuthService.java` around lines 168 - 180, verifyEmail contains a TOCTOU between emailVerificationRepository.hasCode(...) and getCode(...); instead, call emailVerificationRepository.getCode(email) first, check the returned savedCode for null (throw NOT_ISSUED_VERIFICATION_CODE if null), then compare safely (use Objects.equals or null-safe comparison) to validate the provided code and on success call deleteCode(email) and markVerified(email); update the verifyEmail method to use getCode as the single source and remove the separate hasCode check to avoid the race and potential NPE.src/main/java/flipnote/user/global/error/ApiResponse.java (2)
10-17:ApiResponse에 성공 응답 팩터리 메서드 부재현재
error,validationError,internalError만 존재합니다. 컨트롤러에서 성공 응답도ApiResponse로 감싸는 경우가 생기면success(T data)같은 정적 팩터리 메서드를 추가하는 것이 일관성 있는 API 설계에 도움이 됩니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/error/ApiResponse.java` around lines 10 - 17, ApiResponse currently only provides error/validationError/internalError factory methods; add a static generic success factory to make successful controller responses consistent. Implement a method like public static <T> ApiResponse<T> success(T data) (and optionally success() no-arg) in class ApiResponse that returns an ApiResponse built via the existing `@Builder` with a 200 status, a standard code (e.g., "SUCCESS" or "OK"), an appropriate message (e.g., "OK" or null), and the supplied data so controllers can call ApiResponse.success(data); ensure the method signature is generic to preserve type information.
27-34:validationError에서 클래스 레벨ObjectError가 무시됩니다.
bindingResult.getFieldErrors()는 필드 수준 오류만 반환하므로,@ScriptAssert등 클래스 레벨 제약 조건 위반 시 해당 오류가 응답에서 누락됩니다. 현재 사용 패턴에서는 문제가 없을 수 있지만, 향후 클래스 레벨 검증을 추가할 경우 주의가 필요합니다.Also applies to: 51-58
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/error/ApiResponse.java` around lines 27 - 34, The validationError method currently only maps field-level errors via FieldError.of(bindingResult) and thus omits class-level ObjectError (e.g., from `@ScriptAssert`); update ApiResponse.validationError (and the analogous method at the other occurrence) to include both field and global/class errors by aggregating bindingResult.getFieldErrors() and bindingResult.getGlobalErrors() (or bindingResult.getAllErrors()) and mapping them into the response payload (extend or adapt FieldError.of to accept all errors or create a mapper that converts ObjectError instances into the same response DTO) so class-level validation violations are returned as well.src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java (1)
24-32:handleMissingCookie에서 하드코딩된 코드/메시지 —ErrorCode패턴과 불일치다른 핸들러들은
ErrorCode기반ApiResponse.error()를 사용하는 반면, 이 핸들러만"MISSING_COOKIE"와 메시지를 직접 빌더에 하드코딩합니다.AuthErrorCode에MISSING_COOKIE항목을 추가하면 일관성을 유지할 수 있습니다.♻️ 리팩터링 제안
`@ExceptionHandler`(MissingRequestCookieException.class) public ResponseEntity<ApiResponse<Void>> handleMissingCookie(MissingRequestCookieException e) { - ApiResponse<Void> response = ApiResponse.<Void>builder() - .status(HttpStatus.BAD_REQUEST.value()) - .code("MISSING_COOKIE") - .message("필수 쿠키가 누락되었습니다.") - .build(); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(AuthErrorCode.MISSING_COOKIE)); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java` around lines 24 - 32, The handleMissingCookie method currently builds a hardcoded ApiResponse with code "MISSING_COOKIE" and a Korean message; instead add a MISSING_COOKIE enum to AuthErrorCode and refactor handleMissingCookie(MissingRequestCookieException e) to return ApiResponse.error(AuthErrorCode.MISSING_COOKIE) (and keep the same HttpStatus.BAD_REQUEST ResponseEntity wrapper) so this handler follows the same ErrorCode/ApiResponse.error() pattern used by other handlers in GlobalExceptionHandler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/main/java/flipnote/user/auth/domain/AuthErrorCode.java`:
- Around line 24-25: AuthErrorCode enum is skipping AUTH_014 between
INVALID_OAUTH_PROVIDER ("AUTH_013") and BLACKLISTED_TOKEN ("AUTH_015"); restore
the missing entry or document the intentional gap: add a new enum constant for
AUTH_014 in AuthErrorCode with the appropriate HttpStatus and message (or, if
intentionally removed, renumber subsequent codes or add a comment explaining the
removal) so the sequence no longer skips AUTH_014; reference the existing
constants INVALID_OAUTH_PROVIDER and BLACKLISTED_TOKEN to place the fix.
In
`@src/main/java/flipnote/user/auth/domain/event/EmailVerificationSendEvent.java`:
- Around line 3-7: EmailVerificationSendEvent 레코드의 자동 생성된 toString()이 인증
코드(code)를 노출할 수 있으니 toString()을 재정의하여 민감한 필드(code)를 마스킹하도록 변경하세요; 구체적으로
EmailVerificationSendEvent의 toString() 메서드를 오버라이드하고 to 필드는 그대로 표시하되 code는 완전히
숨기거나 일부(예: 마지막 2글자만)만 노출하도록 포맷을 반환하게 구현하면 됩니다.
In `@src/main/java/flipnote/user/auth/domain/event/PasswordResetCreateEvent.java`:
- Around line 3-7: PasswordResetCreateEvent's automatically generated toString()
will include the sensitive 'link' field; override toString() inside the
PasswordResetCreateEvent record to avoid leaking the reset token by masking or
omitting the 'link' value (for example show only recipient 'to' and a redacted
placeholder for 'link' or a truncated/hashed version), and ensure any logging or
usage of PasswordResetCreateEvent uses this safe toString() instead of printing
the raw 'link'.
In `@src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java`:
- Around line 90-99: The isTokenExpired method in JwtProvider is unused and
incorrectly returns false for parsing/signature errors
(JwtException/IllegalArgumentException), which mislabels invalid tokens as "not
expired"; either remove isTokenExpired entirely (since isTokenValid handles
validation) or change its behavior so it only checks expiration: call
parseClaims(token) and return true only when catching ExpiredJwtException, and
rethrow or propagate other JwtException/IllegalArgumentException instead of
returning false; refer to JwtProvider.isTokenExpired, JwtProvider.isTokenValid
and parseClaims/ExpiredJwtException/JwtException symbols when making the change.
In `@src/main/java/flipnote/user/auth/infrastructure/oauth/OAuthApiClient.java`:
- Around line 17-18: The imports in OAuthApiClient are using the Jackson 3
package (tools.jackson) causing a bean type mismatch; replace the two import
statements for TypeReference and ObjectMapper in class OAuthApiClient with the
Jackson 2 package (com.fasterxml.jackson.core.type.TypeReference and
com.fasterxml.jackson.databind.ObjectMapper) so the injected ObjectMapper bean
from Spring Boot matches the type and resolves the
NoSuchBeanDefinitionException.
In
`@src/main/java/flipnote/user/auth/infrastructure/redis/TokenBlacklistRepository.java`:
- Around line 17-24: The TokenBlacklistRepository.add currently uses the full
JWT as the Redis key and doesn't validate expirationMillis; change it to derive
a compact key (use the token's jti if available or a SHA-256 hex of the token)
instead of KEY_PREFIX + token, and validate expirationMillis > 0 (throw
IllegalArgumentException or return early consistent with
SessionInvalidationRepository.invalidate); reference
TokenBlacklistRepository.add, KEY_PREFIX,
SessionInvalidationRepository.invalidate, and
JwtProvider.parseClaims(token).getId() to locate where to extract jti or compute
the hash and apply the TTL check before calling redisTemplate.opsForValue().set.
In `@src/main/java/flipnote/user/auth/presentation/AuthController.java`:
- Around line 85-97: Add request-level rate limiting to the unauthenticated
endpoints to prevent abuse: apply IP- and email-based throttling around the
controller methods that initiate outbound emails (specifically
sendEmailVerification in AuthController and the password-reset request handler,
e.g., sendPasswordResetRequest/POST /password-reset/request) so repeated calls
are blocked for a short window; leverage the existing Redis-backed checks
(AuthService.hasCode / hasToken) or a centralized rate-limiter middleware/filter
(or existing gateway) to enforce limits and return 429 when exceeded, and ensure
the limiter keys combine client IP and the target email to avoid account-wide
lockouts while still preventing spam.
In
`@src/main/java/flipnote/user/auth/presentation/dto/request/SignupRequest.java`:
- Around line 30-34: SignupRequest currently allows smsAgree=true with a
null/empty phone because `@Pattern` permits null; add a class-level cross-field
validation so when smsAgree is true phone must be non-null and match
"^01[0-9]{8,9}$". Fix by either adding a custom class-level `@Constraint` (e.g.,
`@PhoneRequiredIfSmsAgree` with a validator that checks
Boolean.TRUE.equals(signupRequest.getSmsAgree()) implies phone != null/blank and
matches the regex) or by adding an instance-level boolean validator method on
SignupRequest annotated for validation (e.g., an `@AssertTrue-style` method) that
returns true when smsAgree is false or when phone != null &&
phone.matches("^01[0-9]{8,9}$"); ensure the validator references the
SignupRequest.smsAgree and SignupRequest.phone fields and produces a clear
violation message when the rule is broken.
In `@src/main/java/flipnote/user/user/domain/OAuthLink.java`:
- Around line 36-37: The linkedAt field in class OAuthLink is annotated with
`@Column`(updatable = false) but missing nullable = false; update the `@Column` on
the linkedAt property to explicitly include nullable = false so the DB schema
prohibits nulls (e.g., `@Column`(updatable = false, nullable = false)), ensuring
the runtime initialization in the OAuthLink constructor (LocalDateTime.now())
matches the intended non-null database constraint.
In `@src/main/java/flipnote/user/user/domain/OAuthLinkRepository.java`:
- Around line 12-20: The query method findByProviderAndProviderIdWithUser
returns Optional but the underlying OAuthLink entity lacks a DB-level uniqueness
constraint for (provider, providerId), which can lead to
IncorrectResultSizeDataAccessException if duplicates exist; fix by updating the
OAuthLink entity (OAuthLink.java) to add a unique constraint on the provider and
providerId columns (e.g., via `@Table`(uniqueConstraints = ...)) so the database
enforces uniqueness and the repository method can safely assume at most one row.
In `@src/main/java/flipnote/user/user/domain/User.java`:
- Around line 67-74: updateProfile currently ignores a null profileImageUrl
(preserving the existing image) which prevents explicit deletion; either remove
the null-guard in updateProfile so passing null clears the field
(updateProfile(String nickname, String phone, boolean smsAgree, String
profileImageUrl) should set this.profileImageUrl = profileImageUrl
unconditionally) or keep the guard but add a clearProfileImage() method to
explicitly set this.profileImageUrl = null; modify the User.updateProfile or add
User.clearProfileImage() accordingly and update callers to use the new behavior.
In
`@src/main/java/flipnote/user/user/presentation/dto/request/UpdateProfileRequest.java`:
- Around line 13-14: Update the UpdateProfileRequest DTO to apply the same
nickname length validation as SignupRequest by adding a `@Size`(min = 2, max = 50)
constraint to the nickname field alongside the existing `@NotBlank`; update the
field declaration for nickname in class UpdateProfileRequest to include
`@Size`(min = 2, max = 50) so updates reject nicknames shorter than 2 or longer
than 50 characters and match the signup validation rules.
In
`@src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java`:
- Around line 46-62: The getUsers method currently wraps DB read and response
emission in one try so an exception from responseObserver.onNext/onCompleted
could trigger the catch and call responseObserver.onError after a successful
onNext, violating gRPC protocol; refactor getUsers by isolating the DB/query
logic (userRepository.findByIdInAndStatus and toResponse mapping) in its own
try/catch and construct the GetUsersResponse there, then perform
responseObserver.onNext(response) and responseObserver.onCompleted() outside
that DB try (or in a separate send try that does NOT call onError if onNext
succeeded); ensure only the DB error path calls responseObserver.onError with
Status.INTERNAL and preserve logging via log.error in the DB catch.
---
Duplicate comments:
In `@src/main/java/flipnote/user/auth/application/AuthService.java`:
- Around line 182-198: The requestPasswordReset method leaks account existence
because it throws UserException(AuthErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK)
when passwordResetRepository.hasToken(email) is true; change this behavior to
silently return instead so both registered and unregistered emails behave
identically. In AuthService.requestPasswordReset, replace the throw on
passwordResetRepository.hasToken(email) with an early return (no exception),
keeping the subsequent token generation (passwordResetTokenGenerator.generate),
passwordResetRepository.save(token, email), link construction and
eventPublisher.publishEvent(new PasswordResetCreateEvent(email, link))
unchanged.
In `@src/main/java/flipnote/user/auth/application/OAuthService.java`:
- Around line 83-107: The state token is being deleted before attempting the
OAuth exchange in linkSocialAccount, preventing retries if getOAuth2UserInfo
fails; move the socialLinkTokenRepository.delete(state) call to after a
successful exchange (e.g., after getOAuth2UserInfo returns and validation/saving
of OAuthLink completes) so the state is only consumed on success (or delete in a
finally block that checks success), ensuring users can retry failed exchanges.
- Around line 93-96: The current check using
oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId(userId,
userInfo.getProvider(), userInfo.getProviderId()) only ensures the current user
doesn't already have the link but misses if another user already linked the same
(provider, providerId); change the logic to first lookup an existing OAuth link
by provider and providerId (e.g.,
oAuthLinkRepository.findByProviderAndProviderId or
existsByProviderAndProviderId) and if a link exists and its user id is not the
current userId, throw
UserException(AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); if no such link or
it belongs to the same user, proceed as before.
In `@src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java`:
- Around line 32-51: generateToken currently emits identical claims for access
and refresh tokens (used by generateTokenPair/TokenPair), enabling token
swapping; modify generateToken in JwtProvider to accept a token-type parameter
(or add a local tokenType variable when called from generateTokenPair) and
include a "type" claim set to "access" or "refresh"; update token validation
logic (the Jwt validation method(s) that parse tokens) to assert the "type"
claim matches the expected token use before accepting the token so refresh
tokens cannot be used as access tokens.
In
`@src/main/java/flipnote/user/auth/infrastructure/listener/PasswordResetEventListener.java`:
- Around line 21-26: The handle method in PasswordResetEventListener currently
mixes `@Async` with Spring Retry (`@Retryable`) which suffers the same proxying
issue as EmailVerificationEventListener; either remove `@Async` from the
PasswordResetEventListener.handle method or enable the resilient retry proxies
by adding `@EnableResilientMethods` to your configuration so `@Retryable` actually
executes; specifically update the annotations on
PasswordResetEventListener.handle (which calls mailService.sendPasswordResetLink
with PasswordResetCreateEvent) and ensure the chosen fix mirrors the approach
used to resolve the EmailVerificationEventListener (i.e., do not rely on `@Async`
to run the retried execution unless resilient methods are enabled).
In `@src/main/java/flipnote/user/auth/infrastructure/mail/ResendMailService.java`:
- Line 41: The log statement in ResendMailService that calls log.error("이메일 인증번호
발송 실패: to={}, ttl={}분", to, ttl, e) is recording PII (the full email); change it
to avoid storing raw emails by masking or hashing the address before logging
(e.g., implement and use a helper like maskEmail(to) or logHash(to) and pass
that masked/hash value into the log), keep ttl and exception info as-is, and
ensure the original 'to' is only used for sending and not written to logs.
- Line 64: The log statement in ResendMailService.java currently records the
recipient email (PII) in log.error; remove or redact the raw email there. Update
the log call in the method that sends the reset link to either omit the "to"
value or replace it with a masked form (e.g., use a helper method
maskEmail(String) that returns a non-PII token like "j***@example.com" or only
the domain), keep ttl and the exception e in the log, and add the new maskEmail
helper inside ResendMailService (or reuse an existing utility) so future logs
never include the full email address.
In `@src/main/java/flipnote/user/auth/infrastructure/oauth/GoogleUserInfo.java`:
- Around line 13-31: The methods getProviderId, getEmail and getName currently
use String.valueOf(attributes.get(...)) which converts null to the literal
"null"; change these to return actual null when the attribute is missing by
using Objects.toString(attributes.get("sub"), null) (and similarly for "email"
and "name") or explicitly check attributes.get(...) == null and return null;
update imports to include java.util.Objects and modify
getProviderId/getEmail/getName accordingly so the DB never receives the string
"null".
In `@src/main/java/flipnote/user/auth/infrastructure/oauth/OAuthApiClient.java`:
- Around line 27-50: In requestAccessToken, avoid adding a null codeVerifier to
params and guard against a null responseBody before calling
objectMapper.readValue: only add params.add("code_verifier", codeVerifier) when
codeVerifier != null (or use Optional) and after
restClient.post()...retrieve().body(String.class) check if responseBody is
null/empty and throw a clear exception (or return null) before calling
objectMapper.readValue; reference the params variable, the codeVerifier
parameter, the restClient.post()...retrieve().body(String.class) call and the
objectMapper.readValue(...) invocation when making these checks and error
handling.
- Around line 45-46: The code in OAuthApiClient currently returns (String)
responseMap.get("access_token") silently when the key is missing; update the
method to validate that responseMap contains a non-null String for
"access_token" and throw a clear exception (e.g., an OAuthException or
IllegalStateException) when it's absent or not a String, including the raw
responseBody/responseMap in the exception message for debugging; reference the
existing objectMapper.readValue call and the access_token retrieval in
OAuthApiClient and ensure the method signature allows the exception to propagate
or wrap it appropriately.
In
`@src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetRepository.java`:
- Around line 23-36: The two Redis writes in save() (setting TOKEN_KEY_PREFIX +
token and EMAIL_KEY_PREFIX + email via redisTemplate) are not atomic — wrap both
ops in a single pipelined execution to avoid partial failures: use
redisTemplate.executePipelined(...) (or a RedisCallback/SessionCallback) and
perform both ops (preserving TTL_MINUTES and TimeUnit.MINUTES) inside the
callback so they are sent in one network round-trip; keep the same keys
TOKEN_KEY_PREFIX and EMAIL_KEY_PREFIX and TTL constants when moving the two
redisTemplate.opsForValue().set calls into the pipeline.
In
`@src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java`:
- Around line 27-30: SessionInvalidationRepository.getInvalidatedAtMillis
currently uses Optional.ofNullable(value).map(Long::parseLong) which will throw
NumberFormatException for malformed Redis values; change this to explicitly
parse within a try-catch: retrieve the String from
redisTemplate.opsForValue().get(KEY_PREFIX + userId), if null return
Optional.empty, otherwise try Long.parseLong(value) and return
Optional.of(parsed) but catch NumberFormatException and return Optional.empty
(optionally log a warning including the offending value and userId for
debugging).
- Around line 18-25: The invalidate method in SessionInvalidationRepository
(invalidate(Long userId, long ttlMillis)) doesn't validate ttlMillis; add
defensive validation at the top of the method to handle ttlMillis <= 0 (e.g.,
throw an IllegalArgumentException with a clear message or normalize to a minimum
positive value) before calling redisTemplate.opsForValue().set with KEY_PREFIX +
userId so you never pass a non-positive TTL into Redis; ensure the
exception/message references the parameter name (ttlMillis) and the method name
for easy debugging.
In `@src/main/java/flipnote/user/auth/presentation/AuthController.java`:
- Around line 67-72: The validateToken endpoint in AuthController exposes token
validation without caller identification; require the same caller header used by
other endpoints (HttpConstants.USER_ID_HEADER) and enforce access control: add a
`@RequestHeader`(HttpConstants.USER_ID_HEADER) String callerId parameter to
validateToken and verify the callerId (or call an internal authorization method)
before calling authService.validateToken(request.getToken()); if this endpoint
must be internal-only, also ensure a network/internal marker is checked (e.g.,
validate a configured internal service id or flag) and return 403 when the
header/marker is missing or unauthorized.
In `@src/main/java/flipnote/user/auth/presentation/OAuthController.java`:
- Around line 82-100: handleSocialLink only catches UserException, so other
exceptions propagate to GlobalExceptionHandler and can return JSON/500 during
the OAuth redirect flow; update handleSocialLink to also catch generic Exception
(or Throwable) around oAuthService.linkSocialAccount and perform the same
redirect to the failure URL (use
clientProperties.getPaths().getSocialLinkFailure()), while logging the
unexpected error (include provider and exception) so all errors during
oAuthService.linkSocialAccount are handled as redirects rather than JSON
responses.
In `@src/main/java/flipnote/user/user/application/UserService.java`:
- Around line 31-35: getUserInfo currently duplicates lookup logic by calling
userRepository.findByIdAndStatus(...); replace that with the existing helper
findActiveUser(Long userId) to reuse the common behavior and error handling. In
UserService, have getUserInfo call findActiveUser(userId) and then return
UserInfoResponse.from(...) instead of performing the repository lookup inline so
the code reuses the centralized retrieval and error semantics.
In `@src/main/java/flipnote/user/user/domain/OAuthLink.java`:
- Around line 14-16: The `@Index` in class OAuthLink uses logical property names
("provider, providerId") which won't be converted by
SpringPhysicalNamingStrategy; update the index declaration on OAuthLink to
reference the physical column names used in the DB (e.g., "provider,
provider_id") so the DDL references actual columns (ensure the names match any
`@Column`(name=...) values if present).
In `@src/main/java/flipnote/user/user/domain/UserRepository.java`:
- Around line 10-12: Add a new repository method existsByEmailAndStatus(String
email, User.Status status) to mirror the existing findByEmailAndStatus and
ensure existence checks can be scoped by status; then update the signup flow to
replace calls to existsByEmail(email) with existsByEmailAndStatus(email,
User.Status.ACTIVE) so withdrawn users aren’t treated as blocking
re-registration (refer to the existing methods existsByEmail,
findByEmailAndStatus, and the enum User.Status for identifiers).
In
`@src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java`:
- Around line 25-44: The current getUser method may call
responseObserver.onError(NOT_FOUND) inside the try block which, if that call
itself throws, will be caught and cause a second onError(INTERNAL) call from the
catch block; move the NOT_FOUND error path out of the try or otherwise ensure
onError is invoked exactly once (e.g., compute user and/or throw a controlled
exception inside the try, then perform responseObserver.onError(...) for
NOT_FOUND outside the try), so that getUser's try/catch only wraps logic that
can legitimately throw and the catch block never triggers a second onError;
target the getUser method and the places using responseObserver.onError and the
catch block to implement this change.
---
Nitpick comments:
In `@src/main/java/flipnote/user/auth/application/AuthService.java`:
- Around line 168-180: verifyEmail contains a TOCTOU between
emailVerificationRepository.hasCode(...) and getCode(...); instead, call
emailVerificationRepository.getCode(email) first, check the returned savedCode
for null (throw NOT_ISSUED_VERIFICATION_CODE if null), then compare safely (use
Objects.equals or null-safe comparison) to validate the provided code and on
success call deleteCode(email) and markVerified(email); update the verifyEmail
method to use getCode as the single source and remove the separate hasCode check
to avoid the race and potential NPE.
In `@src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java`:
- Line 9: Replace the duplicate TTL definition in PasswordResetRepository by
removing the private static final long TTL_MINUTES = 30 and using
PasswordResetConstants.TOKEN_TTL_MINUTES instead; update all usages of
TTL_MINUTES in PasswordResetRepository to reference
PasswordResetConstants.TOKEN_TTL_MINUTES, add the necessary import (if missing),
and ensure any type conversions (int↔long) are handled where the constant is
consumed.
In
`@src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java`:
- Around line 29-31: Change EmailVerificationRepository.getCode to return
Optional<String> instead of nullable String: update the method signature in
EmailVerificationRepository.getCode, wrap the
redisTemplate.opsForValue().get(CODE_KEY_PREFIX + email) result with
Optional.ofNullable(...), and update all call sites to handle Optional (or use
orElse/orElseThrow where appropriate); mirror the pattern used by
SocialLinkTokenRepository.findUserIdByState to ensure consistent null-safety
across the module.
In
`@src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java`:
- Around line 1-13: PasswordResetTokenGenerator.generate is a token utility and
not Redis-specific, so move the class out of the redis package (update its
package declaration and file location) into a more appropriate package such as
infrastructure.token or infrastructure.util; update any import references/usages
of flipnote.user.auth.infrastructure.redis.PasswordResetTokenGenerator to the
new package and verify Spring Component scanning still picks up the `@Component`
(or adjust annotations/config if package scanning differs).
In
`@src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java`:
- Around line 15-17: Add a password complexity check to the PasswordResetRequest
DTO by applying a `@Pattern` annotation to the password field (private String
password) to enforce your policy (e.g., require at least one uppercase, one
lowercase, one digit and one special character, and length already constrained
by `@Size`), update the validation message to reflect complexity requirements, and
ensure any related controller/validation tests accept the new constraint; locate
the password field in the PasswordResetRequest class and add the appropriate
`@Pattern`(regex=..., message="...") alongside the existing `@NotBlank` and `@Size`
annotations.
In `@src/main/java/flipnote/user/global/error/ApiResponse.java`:
- Around line 10-17: ApiResponse currently only provides
error/validationError/internalError factory methods; add a static generic
success factory to make successful controller responses consistent. Implement a
method like public static <T> ApiResponse<T> success(T data) (and optionally
success() no-arg) in class ApiResponse that returns an ApiResponse built via the
existing `@Builder` with a 200 status, a standard code (e.g., "SUCCESS" or "OK"),
an appropriate message (e.g., "OK" or null), and the supplied data so
controllers can call ApiResponse.success(data); ensure the method signature is
generic to preserve type information.
- Around line 27-34: The validationError method currently only maps field-level
errors via FieldError.of(bindingResult) and thus omits class-level ObjectError
(e.g., from `@ScriptAssert`); update ApiResponse.validationError (and the
analogous method at the other occurrence) to include both field and global/class
errors by aggregating bindingResult.getFieldErrors() and
bindingResult.getGlobalErrors() (or bindingResult.getAllErrors()) and mapping
them into the response payload (extend or adapt FieldError.of to accept all
errors or create a mapper that converts ObjectError instances into the same
response DTO) so class-level validation violations are returned as well.
In `@src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java`:
- Around line 24-32: The handleMissingCookie method currently builds a hardcoded
ApiResponse with code "MISSING_COOKIE" and a Korean message; instead add a
MISSING_COOKIE enum to AuthErrorCode and refactor
handleMissingCookie(MissingRequestCookieException e) to return
ApiResponse.error(AuthErrorCode.MISSING_COOKIE) (and keep the same
HttpStatus.BAD_REQUEST ResponseEntity wrapper) so this handler follows the same
ErrorCode/ApiResponse.error() pattern used by other handlers in
GlobalExceptionHandler.
In `@src/main/java/flipnote/user/global/exception/UserException.java`:
- Around line 11-14: Add a constructor to UserException that accepts both
ErrorCode and a Throwable cause so the original exception chain is preserved;
implement the new constructor to call super(errorCode.getMessage(), cause) and
assign this.errorCode = errorCode (matching the existing single-arg constructor
pattern) so callers converting external failures (e.g., OAuth API errors) can
pass the underlying exception and retain stack-trace/debugging information.
In
`@src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java`:
- Around line 16-18: The factory method UserInfoResponse.from(User user) always
sets imageRefId to null, causing "imageRefId": null in responses; update the
UserInfoResponse class (and its from(User user) method) to either annotate the
DTO with `@JsonInclude`(JsonInclude.Include.NON_NULL) so null fields are omitted,
or adjust from() to omit setting imageRefId and add a clear TODO comment on the
imageRefId field (or constructor) indicating pending image service integration;
ensure the change references UserInfoResponse, from(User user), and the
imageRefId field so the null value is no longer serialized to clients.
| INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 OAuth 제공자입니다."), | ||
| BLACKLISTED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_015", "무효화된 토큰입니다."), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check for AUTH_014 references
rg -n "AUTH_014" --type javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
#!/bin/bash
# Examine the full AuthErrorCode.java file
find . -name "AuthErrorCode.java" -type fRepository: FlipNoteTeam/FlipNote-User
Length of output: 130
🏁 Script executed:
cat -n ./src/main/java/flipnote/user/auth/domain/AuthErrorCode.javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 2237
AUTH_014 에러 코드가 누락되었습니다.
AUTH_013에서 AUTH_015로 건너뛰어 AUTH_014가 정의되어 있지 않습니다. 의도적으로 삭제된 경우라면 이후 코드를 재정렬하거나 주석으로 이유를 명시하고, 실수로 누락된 경우라면 해당 코드를 추가해 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/auth/domain/AuthErrorCode.java` around lines 24 -
25, AuthErrorCode enum is skipping AUTH_014 between INVALID_OAUTH_PROVIDER
("AUTH_013") and BLACKLISTED_TOKEN ("AUTH_015"); restore the missing entry or
document the intentional gap: add a new enum constant for AUTH_014 in
AuthErrorCode with the appropriate HttpStatus and message (or, if intentionally
removed, renumber subsequent codes or add a comment explaining the removal) so
the sequence no longer skips AUTH_014; reference the existing constants
INVALID_OAUTH_PROVIDER and BLACKLISTED_TOKEN to place the fix.
| public record EmailVerificationSendEvent( | ||
| String to, | ||
| String code | ||
| ) { | ||
| } |
There was a problem hiding this comment.
record 자동 생성 toString()에 인증 코드가 노출될 수 있습니다.
Java record는 모든 필드 값을 포함하는 toString()을 자동 생성합니다. 만약 이 이벤트 객체가 리스너나 상위 코드에서 로그에 찍힐 경우 code 값이 평문으로 유출됩니다. 민감 필드를 마스킹하도록 toString()을 직접 재정의하는 것을 권장합니다.
🛡️ 수정 제안
public record EmailVerificationSendEvent(
String to,
String code
-) {
+) {
+ `@Override`
+ public String toString() {
+ return "EmailVerificationSendEvent[to=" + to + ", code=***]";
+ }
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/flipnote/user/auth/domain/event/EmailVerificationSendEvent.java`
around lines 3 - 7, EmailVerificationSendEvent 레코드의 자동 생성된 toString()이 인증
코드(code)를 노출할 수 있으니 toString()을 재정의하여 민감한 필드(code)를 마스킹하도록 변경하세요; 구체적으로
EmailVerificationSendEvent의 toString() 메서드를 오버라이드하고 to 필드는 그대로 표시하되 code는 완전히
숨기거나 일부(예: 마지막 2글자만)만 노출하도록 포맷을 반환하게 구현하면 됩니다.
| public record PasswordResetCreateEvent( | ||
| String to, | ||
| String link | ||
| ) { | ||
| } |
There was a problem hiding this comment.
record 자동 생성 toString()에 비밀번호 재설정 토큰이 포함된 링크가 노출될 수 있습니다.
Java record는 모든 필드를 포함하는 toString()을 자동 생성하므로, link 필드에 포함된 비밀번호 재설정 토큰이 로그에 평문으로 유출될 위험이 있습니다. 이 토큰이 유출되면 공격자가 해당 계정의 비밀번호를 임의로 재설정할 수 있어 계정 탈취(account takeover)로 이어질 수 있습니다.
toString()을 재정의하여 민감 필드를 마스킹하세요.
🛡️ 수정 제안
public record PasswordResetCreateEvent(
String to,
String link
-) {
+) {
+ `@Override`
+ public String toString() {
+ return "PasswordResetCreateEvent[to=" + to + ", link=***]";
+ }
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/auth/domain/event/PasswordResetCreateEvent.java`
around lines 3 - 7, PasswordResetCreateEvent's automatically generated
toString() will include the sensitive 'link' field; override toString() inside
the PasswordResetCreateEvent record to avoid leaking the reset token by masking
or omitting the 'link' value (for example show only recipient 'to' and a
redacted placeholder for 'link' or a truncated/hashed version), and ensure any
logging or usage of PasswordResetCreateEvent uses this safe toString() instead
of printing the raw 'link'.
| public boolean isTokenExpired(String token) { | ||
| try { | ||
| parseClaims(token); | ||
| return false; | ||
| } catch (ExpiredJwtException e) { | ||
| return true; | ||
| } catch (JwtException | IllegalArgumentException e) { | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n --type=java '\bisTokenExpired\b' -C 5Repository: FlipNoteTeam/FlipNote-User
Length of output: 1155
🏁 Script executed:
rg -n --type=java 'isTokenExpired\s*\('Repository: FlipNoteTeam/FlipNote-User
Length of output: 191
🏁 Script executed:
rg -n --type=java '\bisTokenValid\b'Repository: FlipNoteTeam/FlipNote-User
Length of output: 590
🏁 Script executed:
sed -n '81,88p' src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 292
🏁 Script executed:
head -20 src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 683
isTokenExpired는 사용되지 않는 메서드이며, 의미적으로 혼동되는 반환값을 가짐
현재 코드베이스에서 isTokenExpired는 호출되지 않으며, 모든 토큰 검증은 isTokenValid를 통해 수행됩니다. 그러나 isTokenExpired의 로직에 문제가 있습니다: 서명 위조나 파싱 오류 등으로 인한 JwtException 발생 시 false(만료되지 않음)를 반환하여, 유효하지 않은 토큰을 "만료되지 않은" 상태로 해석할 수 있습니다.
이 메서드를 제거하거나, 만료 여부만 정확하게 판단하도록 수정해야 합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java` around
lines 90 - 99, The isTokenExpired method in JwtProvider is unused and
incorrectly returns false for parsing/signature errors
(JwtException/IllegalArgumentException), which mislabels invalid tokens as "not
expired"; either remove isTokenExpired entirely (since isTokenValid handles
validation) or change its behavior so it only checks expiration: call
parseClaims(token) and return true only when catching ExpiredJwtException, and
rethrow or propagate other JwtException/IllegalArgumentException instead of
returning false; refer to JwtProvider.isTokenExpired, JwtProvider.isTokenValid
and parseClaims/ExpiredJwtException/JwtException symbols when making the change.
| @Async | ||
| @EventListener | ||
| @Retryable(delay = 2000, multiplier = 2.0, maxRetries = 3, includes = EmailSendException.class) | ||
| public void handle(EmailVerificationSendEvent event) { | ||
| mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 프록시 순서 관련 설정 확인
rg -rn "AsyncConfigurer\|@EnableAsync\|setTaskExecutor\|ThreadPoolTask\|AsyncUncaughtExceptionHandler" --type javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
# EmailVerificationEventListener 파일 전체 내용 확인
cat -n src/main/java/flipnote/user/auth/infrastructure/listener/EmailVerificationEventListener.javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 1261
🏁 Script executed:
# `@Recover` 메서드 검색
rg -rn "@Recover" --type java -A 3Repository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
# `@EnableAsync`, `@EnableRetry` 설정 클래스 검색
rg -rn "@EnableAsync|@EnableRetry|@Configuration.*Async|@Configuration.*Retry" --type java -B 2 -A 5Repository: FlipNoteTeam/FlipNote-User
Length of output: 667
🏁 Script executed:
# Resilience4j 설정 파일 찾기
find . -type f \( -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" \) | head -20Repository: FlipNoteTeam/FlipNote-User
Length of output: 143
🏁 Script executed:
# Resilience4j 설정 확인
rg -rn "resilience4j" --type yaml --type properties --type yml -iRepository: FlipNoteTeam/FlipNote-User
Length of output: 104
🏁 Script executed:
# EmailVerificationEventListener 관련 테스트 찾기
fd -t f -i "test" | rg -i "emailverification|eventlistener"Repository: FlipNoteTeam/FlipNote-User
Length of output: 52
🏁 Script executed:
# `@Async` 관련 설정 더 자세히 검색
rg -rn "@Async|@EnableAsync" --type java -B 2 -A 5Repository: FlipNoteTeam/FlipNote-User
Length of output: 2766
🏁 Script executed:
cat -n src/main/resources/application.ymlRepository: FlipNoteTeam/FlipNote-User
Length of output: 2433
🏁 Script executed:
# pom.xml에서 Resilience4j 버전 확인
rg -rn "resilience4j|spring-boot-starter" -A 2 pom.xml | head -50Repository: FlipNoteTeam/FlipNote-User
Length of output: 117
🏁 Script executed:
# gradle 파일 찾기
find . -name "build.gradle*" -o -name "gradle.properties"Repository: FlipNoteTeam/FlipNote-User
Length of output: 88
🏁 Script executed:
# 전체 코드에서 `@EnableAsync` 정말로 없는지 확인
rg -rn "EnableAsync" --type javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 222
🏁 Script executed:
# UserApplication.java 전체 확인
cat -n src/main/java/flipnote/user/UserApplication.javaRepository: FlipNoteTeam/FlipNote-User
Length of output: 785
🏁 Script executed:
# Resilience4j 관련 의존성 확인
cat -n build.gradleRepository: FlipNoteTeam/FlipNote-User
Length of output: 114
🏁 Script executed:
cat -n build.gradle.ktsRepository: FlipNoteTeam/FlipNote-User
Length of output: 3233
🏁 Script executed:
# Resilience4j 관련 설정 더 자세히 확인
rg -rn "spring.resilience4j" src/main/resources/ -A 2Repository: FlipNoteTeam/FlipNote-User
Length of output: 52
Resilience4j의 @Retryable과 @Async 조합에서 실패한 재시도 처리가 명확하지 않습니다.
코드는 Spring Retry가 아닌 Resilience4j를 사용하고 있으며(org.springframework.resilience.annotation.Retryable), @EnableAsync와 @EnableResilientMethods가 모두 설정되어 있습니다. 다만 모든 재시도가 실패할 경우 예외 처리 메커니즘이 없어 에러가 조용히 묻힐 수 있습니다.
@Recover메서드가 없으므로 재시도 소진 후 예외가 명시적으로 처리되지 않음- 비동기 스레드에서 발생한 예외는
AsyncUncaughtExceptionHandler로만 전달되며, 이는 기본적으로 로깅만 수행 - application.yml에서
spring.task.execution또는 resilience4j 설정이 없으므로 기본값 사용 PasswordResetEventListener와의 일관성을 고려할 때,@Async사용 여부를 재검토하거나 명시적 에러 핸들링 추가 필요
| @Column(updatable = false) | ||
| private LocalDateTime linkedAt; |
There was a problem hiding this comment.
linkedAt 컬럼에 nullable = false 누락
생성자에서 항상 LocalDateTime.now()로 초기화되지만, @Column(updatable = false) 만 선언되어 DB 스키마 상 NULL을 허용합니다. 의도를 명확히 하려면 nullable = false를 함께 선언하는 것이 좋습니다.
🛠️ 수정 제안
- `@Column`(updatable = false)
+ `@Column`(nullable = false, updatable = false)
private LocalDateTime linkedAt;📝 Committable suggestion
‼️ 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.
| @Column(updatable = false) | |
| private LocalDateTime linkedAt; | |
| `@Column`(nullable = false, updatable = false) | |
| private LocalDateTime linkedAt; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/user/domain/OAuthLink.java` around lines 36 - 37,
The linkedAt field in class OAuthLink is annotated with `@Column`(updatable =
false) but missing nullable = false; update the `@Column` on the linkedAt property
to explicitly include nullable = false so the DB schema prohibits nulls (e.g.,
`@Column`(updatable = false, nullable = false)), ensuring the runtime
initialization in the OAuthLink constructor (LocalDateTime.now()) matches the
intended non-null database constraint.
| @Query(""" | ||
| SELECT ol FROM OAuthLink ol | ||
| JOIN FETCH ol.user | ||
| WHERE ol.provider = :provider AND ol.providerId = :providerId | ||
| """) | ||
| Optional<OAuthLink> findByProviderAndProviderIdWithUser( | ||
| @Param("provider") String provider, | ||
| @Param("providerId") String providerId | ||
| ); |
There was a problem hiding this comment.
(provider, providerId) 유니크 제약이 없어 Optional 반환 타입이 런타임 예외를 유발할 수 있음
OAuthLink 엔티티의 @Index는 일반 인덱스일 뿐 유니크 제약이 아닙니다. 동시 요청 등으로 동일한 (provider, providerId) 조합이 두 건 이상 삽입된 경우, 이 쿼리는 IncorrectResultSizeDataAccessException을 던집니다. OAuthLink.java에 @UniqueConstraint를 추가하여 DB 수준에서 유일성을 보장해야 합니다.
🛠️ 수정 제안 (OAuthLink.java)
`@Table`(
name = "oauth_link",
indexes = {
`@Index`(name = "idx_oauth_provider_provider_id", columnList = "provider, provider_id")
- }
+ },
+ uniqueConstraints = {
+ `@UniqueConstraint`(name = "uq_oauth_provider_provider_id", columnNames = {"provider", "provider_id"})
+ }
)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/user/domain/OAuthLinkRepository.java` around
lines 12 - 20, The query method findByProviderAndProviderIdWithUser returns
Optional but the underlying OAuthLink entity lacks a DB-level uniqueness
constraint for (provider, providerId), which can lead to
IncorrectResultSizeDataAccessException if duplicates exist; fix by updating the
OAuthLink entity (OAuthLink.java) to add a unique constraint on the provider and
providerId columns (e.g., via `@Table`(uniqueConstraints = ...)) so the database
enforces uniqueness and the repository method can safely assume at most one row.
| public void updateProfile(String nickname, String phone, boolean smsAgree, String profileImageUrl) { | ||
| this.nickname = nickname; | ||
| this.phone = phone; | ||
| this.smsAgree = smsAgree; | ||
| if (profileImageUrl != null) { | ||
| this.profileImageUrl = profileImageUrl; | ||
| } | ||
| } |
There was a problem hiding this comment.
updateProfile에서 profileImageUrl을 명시적으로 null(삭제)로 설정할 수 없음
profileImageUrl이 null인 경우 기존 값을 유지하는 가드가 있어, 한 번 설정된 프로필 이미지를 제거하는 것이 불가능합니다. 의도적인 설계라면 문제 없지만, 이미지 삭제 기능이 필요하다면 별도의 clearProfileImage() 메서드를 추가하거나 명시적 삭제 플래그를 도입해야 합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/user/domain/User.java` around lines 67 - 74,
updateProfile currently ignores a null profileImageUrl (preserving the existing
image) which prevents explicit deletion; either remove the null-guard in
updateProfile so passing null clears the field (updateProfile(String nickname,
String phone, boolean smsAgree, String profileImageUrl) should set
this.profileImageUrl = profileImageUrl unconditionally) or keep the guard but
add a clearProfileImage() method to explicitly set this.profileImageUrl = null;
modify the User.updateProfile or add User.clearProfileImage() accordingly and
update callers to use the new behavior.
| @NotBlank(message = "닉네임은 필수입니다") | ||
| private String nickname; |
There was a problem hiding this comment.
nickname 필드에 @Size 제약이 누락되었습니다.
SignupRequest에서는 닉네임에 @Size(min = 2, max = 50) 제약이 적용되어 있으나, UpdateProfileRequest에는 @NotBlank만 있어 1자 이하 또는 50자 초과의 닉네임으로 업데이트하는 것이 허용됩니다. 가입 시 검증 규칙과 동일하게 유지해야 합니다.
🛡️ 제안 수정
+import jakarta.validation.constraints.Size;
`@NotBlank`(message = "닉네임은 필수입니다")
+@Size(min = 2, max = 50, message = "닉네임은 2자 이상 50자 이하여야 합니다")
private String nickname;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/flipnote/user/user/presentation/dto/request/UpdateProfileRequest.java`
around lines 13 - 14, Update the UpdateProfileRequest DTO to apply the same
nickname length validation as SignupRequest by adding a `@Size`(min = 2, max = 50)
constraint to the nickname field alongside the existing `@NotBlank`; update the
field declaration for nickname in class UpdateProfileRequest to include
`@Size`(min = 2, max = 50) so updates reject nicknames shorter than 2 or longer
than 50 characters and match the signup validation rules.
| @Override | ||
| public void getUsers(GetUsersRequest request, StreamObserver<GetUsersResponse> responseObserver) { | ||
| try { | ||
| List<Long> userIds = request.getUserIdsList(); | ||
| List<User> users = userRepository.findByIdInAndStatus(userIds, User.Status.ACTIVE); | ||
|
|
||
| GetUsersResponse response = GetUsersResponse.newBuilder() | ||
| .addAllUsers(users.stream().map(this::toResponse).toList()) | ||
| .build(); | ||
|
|
||
| responseObserver.onNext(response); | ||
| responseObserver.onCompleted(); | ||
| } catch (Exception e) { | ||
| log.error("gRPC getUsers error. userIds: {}", request.getUserIdsList(), e); | ||
| responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException()); | ||
| } | ||
| } |
There was a problem hiding this comment.
getUsers: onNext/onCompleted가 try 블록 내에 있어 이중 신호 전송 가능
responseObserver.onNext(response) 또는 responseObserver.onCompleted()가 예외를 던지면, catch 블록에서 responseObserver.onError()가 호출됩니다. 이미 onNext가 성공적으로 호출된 상태에서 onError를 호출하는 것은 gRPC 스트림 프로토콜 위반입니다. DB 조회와 응답 전송 단계를 별도의 try 블록으로 분리하여 예외 경계를 명확히 해야 합니다.
🛠️ 수정 제안
`@Override`
public void getUsers(GetUsersRequest request, StreamObserver<GetUsersResponse> responseObserver) {
+ List<User> users;
try {
List<Long> userIds = request.getUserIdsList();
- List<User> users = userRepository.findByIdInAndStatus(userIds, User.Status.ACTIVE);
-
- GetUsersResponse response = GetUsersResponse.newBuilder()
- .addAllUsers(users.stream().map(this::toResponse).toList())
- .build();
-
- responseObserver.onNext(response);
- responseObserver.onCompleted();
- } catch (Exception e) {
+ users = userRepository.findByIdInAndStatus(userIds, User.Status.ACTIVE);
+ } catch (Exception e) {
log.error("gRPC getUsers error. userIds: {}", request.getUserIdsList(), e);
responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException());
+ return;
}
+
+ GetUsersResponse response = GetUsersResponse.newBuilder()
+ .addAllUsers(users.stream().map(this::toResponse).toList())
+ .build();
+ responseObserver.onNext(response);
+ responseObserver.onCompleted();
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java`
around lines 46 - 62, The getUsers method currently wraps DB read and response
emission in one try so an exception from responseObserver.onNext/onCompleted
could trigger the catch and call responseObserver.onError after a successful
onNext, violating gRPC protocol; refactor getUsers by isolating the DB/query
logic (userRepository.findByIdInAndStatus and toResponse mapping) in its own
try/catch and construct the GetUsersResponse there, then perform
responseObserver.onNext(response) and responseObserver.onCompleted() outside
that DB try (or in a separate send try that does NOT call onError if onNext
succeeded); ensure only the DB error path calls responseObserver.onError with
Status.INTERNAL and preserve logging via log.error in the DB catch.
Summary by CodeRabbit
릴리스 노트
새 기능
개선 사항