Conversation
feat: 토큰 재발급 기능 구현
feat: 공개된 카테고리의 아티클만 공유하도록 기능 수정
📝 WalkthroughWalkthroughAdds Redis-backed refresh-token rotation: new AuthControllerV3 POST /api/v3/auth/reissue, AuthUsecase refresh validation/rotation, JwtProvider refresh token creation, updated login/signup to set secure HttpOnly refreshToken cookies, and security/filter updates to allow the reissue endpoint. Changes
Sequence DiagramsequenceDiagram
participant Client
participant AuthController as AuthControllerV3
participant AuthUsecase
participant JwtProvider
participant Redis
participant Response
Client->>AuthController: POST /api/v3/auth/reissue (refreshToken cookie)
AuthController->>AuthUsecase: getNewToken(refreshToken)
AuthUsecase->>Redis: lookup stored refresh token
Redis-->>AuthUsecase: stored refresh token
AuthUsecase->>AuthUsecase: validate provided == stored
alt valid
AuthUsecase->>JwtProvider: createAccessToken(userId)
JwtProvider-->>AuthUsecase: accessToken
AuthUsecase->>JwtProvider: createRefreshToken(userId)
JwtProvider-->>AuthUsecase: newRefreshToken
AuthUsecase->>Redis: store newRefreshToken with TTL
Redis-->>AuthUsecase: ack
AuthUsecase-->>AuthController: tokens (accessToken, newRefreshToken)
else invalid
AuthUsecase-->>AuthController: throw InvalidTokenException
end
AuthController->>Response: Build ResponseEntity + Set-Cookie (refreshToken HttpOnly Secure SameSite=None) + body (accessToken)
Response-->>Client: HTTP 200 with cookie
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ❌ 3❌ Failed checks (2 warnings, 1 inconclusive)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). 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: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
infrastructure/src/main/java/com/pinback/infrastructure/jwt/JwtProviderImpl.java (1)
36-46:⚠️ Potential issue | 🔴 CriticalCritical: Thread-safety issue with shared
JWTCreator.Builder.The
jwtBuilderfield is shared across all method calls, butJWTCreator.Builderis mutable. Methods likewithClaim(),withSubject(), andwithExpiresAt()mutate the builder's internal state. WhencreateAccessTokenandcreateRefreshTokenare called concurrently (common in a web application), the builder state will be corrupted, producing tokens with incorrect claims or subjects.Create a fresh builder for each token instead:
🔒 Proposed fix
`@Component` public class JwtProviderImpl implements JwtProvider { private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; private static final String ID_CLAIM = "id"; private final String key; private final long accessExpirationPeriod; private final long refreshTokenExpirationPeriod; - private final JWTCreator.Builder jwtBuilder; + private final String issuer; public JwtProviderImpl(`@Value`("${jwt.secret-key}") String key, `@Value`("${jwt.accessExpirationPeriod}") long accessExpirationPeriod, `@Value`("${jwt.refreshExpirationPeriod}") long refreshTokenExpirationPeriod, `@Value`("${jwt.issuer}") String issuer) { this.key = key; this.accessExpirationPeriod = accessExpirationPeriod; this.refreshTokenExpirationPeriod = refreshTokenExpirationPeriod; - this.jwtBuilder = JWT.create() - .withIssuer(issuer); + this.issuer = issuer; } `@Override` public String createAccessToken(UUID id) { - return jwtBuilder + return JWT.create() + .withIssuer(issuer) .withClaim(ID_CLAIM, id.toString()) .withSubject(ACCESS_TOKEN_SUBJECT) .withExpiresAt(Instant.now().plusMillis(accessExpirationPeriod)) .sign(Algorithm.HMAC512(key)); } `@Override` public String createRefreshToken(UUID userId) { - return jwtBuilder + return JWT.create() + .withIssuer(issuer) .withClaim(ID_CLAIM, userId.toString()) .withSubject(REFRESH_TOKEN_SUBJECT) .withExpiresAt(Instant.now().plusMillis(refreshTokenExpirationPeriod)) .sign(Algorithm.HMAC512(key)); }Also applies to: 66-73
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@infrastructure/src/main/java/com/pinback/infrastructure/jwt/JwtProviderImpl.java` around lines 36 - 46, The jwtBuilder field is a shared, mutable JWTCreator.Builder causing race conditions; instead of mutating the shared jwtBuilder in createAccessToken and createRefreshToken, instantiate a fresh builder from JWT.create() inside each method (e.g., within createAccessToken(UUID id) and createRefreshToken(UUID id)) and then call withClaim(ID_CLAIM,...), withSubject(...), withExpiresAt(...) and sign(Algorithm.HMAC512(key)) on that new builder so each call uses an independent builder instance.api/src/main/java/com/pinback/api/config/security/SecurityConfig.java (1)
54-61:⚠️ Potential issue | 🔴 CriticalFix the missing leading slash in the permitAll path for
/api/v3/auth/reissue(line 60).Line 60 has
"api/v3/auth/reissue"without the leading slash. Spring Security expects absolute, slash-prefixed request paths inrequestMatchers(String...), so this rule will not match incoming requests to/api/v3/auth/reissue. The request falls through to.anyRequest().authenticated(), rejecting anonymous reissue calls. All other paths in this block (lines 55–59) correctly include the leading slash.JwtAuthenticationFilter.java:108also validates the path with the leading slash (path.startsWith("/api/v3/auth/reissue")), confirming the expected format. (Spring Security Docs)🔧 Proposed fix
.requestMatchers( "/api/v1/auth/token", "/api/v2/auth/google", "/api/v2/auth/signup", "/api/v3/auth/signup", "/api/v3/auth/google", - "api/v3/auth/reissue" + "/api/v3/auth/reissue" ).permitAll()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@api/src/main/java/com/pinback/api/config/security/SecurityConfig.java` around lines 54 - 61, In SecurityConfig update the request matcher entry that currently reads "api/v3/auth/reissue" to include the leading slash so it matches incoming requests; locate the requestMatchers(...) call inside SecurityConfig and make the string "/api/v3/auth/reissue" consistent with the other entries so permitAll() applies; verify this aligns with the path check in JwtAuthenticationFilter (path.startsWith("/api/v3/auth/reissue")) to ensure anonymous reissue calls are allowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@api/src/main/java/com/pinback/api/google/controller/GoogleLoginControllerV3.java`:
- Around line 77-79: The controller currently returns SignUpResponseV3 (which
contains refreshToken) in the response body after setting the refresh token
cookie; change GoogleLoginControllerV3 to not expose SignUpResponseV3 directly
but instead construct and return a TokenResponse (or a DTO that excludes
refreshToken) in the body while keeping the refresh token only in the HttpOnly
cookie (use the existing cookie variable and map the accessToken and other
non-secret fields from SignUpResponseV3 into TokenResponse); ensure the response
body type and returned object match TokenResponse to mirror AuthControllerV3
behavior.
- Around line 47-57: The response currently always sets a refreshToken cookie in
GoogleLoginControllerV3 even when AuthUsecase.getInfoAndTokenV3() returns a
tempLogin (GoogleLoginResponseV3.tempLogin() sets refreshToken to null); change
the controller to check loginResponse.refreshToken() and if null send a clearing
Set-Cookie (e.g., cookie with empty value and maxAge=0/expired) instead of
issuing a normal refresh cookie so onboarding-incomplete/new-user flows do not
leave a stale or meaningless cookie.
In
`@application/src/main/java/com/pinback/application/google/dto/response/GoogleLoginResponseV3.java`:
- Around line 10-15: GoogleLoginResponseV3 currently includes a refreshToken
field and the loggedIn(...) factory returns it, causing the refresh token to be
serialized into the JSON response; remove the refreshToken from the record
declaration and update the loggedIn method signature and body to omit the
refreshToken parameter so JSON responses no longer include it (keep sending the
refresh token only via the HttpOnly cookie in GoogleLoginControllerV3). Ensure
all references to GoogleLoginResponseV3.refreshToken and the loggedIn overload
are updated or removed accordingly to avoid compilation errors.
---
Outside diff comments:
In `@api/src/main/java/com/pinback/api/config/security/SecurityConfig.java`:
- Around line 54-61: In SecurityConfig update the request matcher entry that
currently reads "api/v3/auth/reissue" to include the leading slash so it matches
incoming requests; locate the requestMatchers(...) call inside SecurityConfig
and make the string "/api/v3/auth/reissue" consistent with the other entries so
permitAll() applies; verify this aligns with the path check in
JwtAuthenticationFilter (path.startsWith("/api/v3/auth/reissue")) to ensure
anonymous reissue calls are allowed.
In
`@infrastructure/src/main/java/com/pinback/infrastructure/jwt/JwtProviderImpl.java`:
- Around line 36-46: The jwtBuilder field is a shared, mutable
JWTCreator.Builder causing race conditions; instead of mutating the shared
jwtBuilder in createAccessToken and createRefreshToken, instantiate a fresh
builder from JWT.create() inside each method (e.g., within
createAccessToken(UUID id) and createRefreshToken(UUID id)) and then call
withClaim(ID_CLAIM,...), withSubject(...), withExpiresAt(...) and
sign(Algorithm.HMAC512(key)) on that new builder so each call uses an
independent builder instance.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 83cf2429-c532-49e9-9518-c0d94e215862
📒 Files selected for processing (14)
api/src/main/java/com/pinback/api/auth/controller/AuthControllerV3.javaapi/src/main/java/com/pinback/api/config/filter/JwtAuthenticationFilter.javaapi/src/main/java/com/pinback/api/config/security/SecurityConfig.javaapi/src/main/java/com/pinback/api/google/controller/GoogleLoginControllerV3.javaapi/src/main/resources/application.ymlapplication/build.gradleapplication/src/main/java/com/pinback/application/auth/dto/SignUpResponseV3.javaapplication/src/main/java/com/pinback/application/auth/dto/TokenResponse.javaapplication/src/main/java/com/pinback/application/auth/service/JwtProvider.javaapplication/src/main/java/com/pinback/application/auth/usecase/AuthUsecase.javaapplication/src/main/java/com/pinback/application/common/exception/InvalidTokenException.javaapplication/src/main/java/com/pinback/application/google/dto/response/GoogleLoginResponseV3.javainfrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.javainfrastructure/src/main/java/com/pinback/infrastructure/jwt/JwtProviderImpl.java
| ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken()) | ||
| .httpOnly(true) | ||
| .secure(true) | ||
| .path("/") | ||
| .maxAge(refreshTokenExpirationPeriod / 1000) | ||
| .sameSite("None") | ||
| .build(); | ||
|
|
||
| return ResponseEntity.ok() | ||
| .header(HttpHeaders.SET_COOKIE, cookie.toString()) | ||
| .body(ResponseDto.ok(loginResponse)); |
There was a problem hiding this comment.
Clear the cookie on temp-login responses instead of issuing one.
AuthUsecase.getInfoAndTokenV3() returns GoogleLoginResponseV3.tempLogin(...) for onboarding-incomplete and new users in application/src/main/java/com/pinback/application/auth/usecase/AuthUsecase.java, Line 177-179 and Line 198-199, and tempLogin() sets refreshToken to null in application/src/main/java/com/pinback/application/google/dto/response/GoogleLoginResponseV3.java, Line 18-19. Line 47-57 still writes a refreshToken cookie for that branch, so the non-user path can leave a stale cookie behind or emit a meaningless one.
🔧 Proposed fix
.map(loginResponse -> {
- ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
- .httpOnly(true)
- .secure(true)
- .path("/")
- .maxAge(refreshTokenExpirationPeriod / 1000)
- .sameSite("None")
- .build();
+ ResponseCookie cookie = loginResponse.refreshToken() == null
+ ? ResponseCookie.from("refreshToken", "")
+ .httpOnly(true)
+ .secure(true)
+ .path("/")
+ .maxAge(0)
+ .sameSite("None")
+ .build()
+ : ResponseCookie.from("refreshToken", loginResponse.refreshToken())
+ .httpOnly(true)
+ .secure(true)
+ .path("/")
+ .maxAge(refreshTokenExpirationPeriod / 1000)
+ .sameSite("None")
+ .build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(ResponseDto.ok(loginResponse));
});📝 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.
| ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken()) | |
| .httpOnly(true) | |
| .secure(true) | |
| .path("/") | |
| .maxAge(refreshTokenExpirationPeriod / 1000) | |
| .sameSite("None") | |
| .build(); | |
| return ResponseEntity.ok() | |
| .header(HttpHeaders.SET_COOKIE, cookie.toString()) | |
| .body(ResponseDto.ok(loginResponse)); | |
| .map(loginResponse -> { | |
| ResponseCookie cookie = loginResponse.refreshToken() == null | |
| ? ResponseCookie.from("refreshToken", "") | |
| .httpOnly(true) | |
| .secure(true) | |
| .path("/") | |
| .maxAge(0) | |
| .sameSite("None") | |
| .build() | |
| : ResponseCookie.from("refreshToken", loginResponse.refreshToken()) | |
| .httpOnly(true) | |
| .secure(true) | |
| .path("/") | |
| .maxAge(refreshTokenExpirationPeriod / 1000) | |
| .sameSite("None") | |
| .build(); | |
| return ResponseEntity.ok() | |
| .header(HttpHeaders.SET_COOKIE, cookie.toString()) | |
| .body(ResponseDto.ok(loginResponse)); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@api/src/main/java/com/pinback/api/google/controller/GoogleLoginControllerV3.java`
around lines 47 - 57, The response currently always sets a refreshToken cookie
in GoogleLoginControllerV3 even when AuthUsecase.getInfoAndTokenV3() returns a
tempLogin (GoogleLoginResponseV3.tempLogin() sets refreshToken to null); change
the controller to check loginResponse.refreshToken() and if null send a clearing
Set-Cookie (e.g., cookie with empty value and maxAge=0/expired) instead of
issuing a normal refresh cookie so onboarding-incomplete/new-user flows do not
leave a stale or meaningless cookie.
| return ResponseEntity.ok() | ||
| .header(HttpHeaders.SET_COOKIE, cookie.toString()) | ||
| .body(ResponseDto.ok(response)); |
There was a problem hiding this comment.
Don't return SignUpResponseV3 verbatim after setting the cookie.
SignUpResponseV3 includes refreshToken in application/src/main/java/com/pinback/application/auth/dto/SignUpResponseV3.java, Line 1-10, so Line 77-79 still exposes the secret in JSON. AuthControllerV3 already shows the safer contract on Line 44-46 by returning only TokenResponse.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@api/src/main/java/com/pinback/api/google/controller/GoogleLoginControllerV3.java`
around lines 77 - 79, The controller currently returns SignUpResponseV3 (which
contains refreshToken) in the response body after setting the refresh token
cookie; change GoogleLoginControllerV3 to not expose SignUpResponseV3 directly
but instead construct and return a TokenResponse (or a DTO that excludes
refreshToken) in the body while keeping the refresh token only in the HttpOnly
cookie (use the existing cookie variable and map the accessToken and other
non-secret fields from SignUpResponseV3 into TokenResponse); ensure the response
body type and returned object match TokenResponse to mirror AuthControllerV3
behavior.
| @Transactional | ||
| public SignUpResponseV3 getNewToken(String refreshToken) { | ||
| UUID userId = jwtProvider.getUserIdFromToken(refreshToken); | ||
|
|
||
| String redisKey = REDIS_REFRESH_TOKEN_PREFIX + userId.toString(); | ||
| String savedToken = stringRedisTemplate.opsForValue().get(redisKey); | ||
| if (savedToken == null || !savedToken.equals(refreshToken)) { | ||
| stringRedisTemplate.delete(redisKey); | ||
| throw new InvalidTokenException(); | ||
| } |
There was a problem hiding this comment.
Validate the JWT type before deleting the Redis token.
jwtProvider.getUserIdFromToken(...) in infrastructure/src/main/java/com/pinback/infrastructure/jwt/JwtProviderImpl.java, Line 60-64 extracts the user ID from any valid JWT. If an access token is sent here, Line 210-212 will reject it and delete the real refresh token, turning a bad request into a forced logout. Only revoke on mismatch after you've confirmed the presented JWT is actually a refresh token.
| String accessToken, | ||
| String refreshToken | ||
| ) { | ||
| public static GoogleLoginResponseV3 loggedIn(boolean hasJob, UUID userId, String email, String accessToken) { | ||
| return new GoogleLoginResponseV3(true, hasJob, userId, email, accessToken); | ||
| public static GoogleLoginResponseV3 loggedIn(boolean hasJob, UUID userId, String email, String accessToken, | ||
| String refreshToken) { | ||
| return new GoogleLoginResponseV3(true, hasJob, userId, email, accessToken, refreshToken); |
There was a problem hiding this comment.
Keep refreshToken off this response record.
GoogleLoginControllerV3.googleLogin() serializes GoogleLoginResponseV3 directly in api/src/main/java/com/pinback/api/google/controller/GoogleLoginControllerV3.java, Line 55-57. Adding refreshToken here makes it readable in the JSON body even though the controller also sends it as an HttpOnly cookie.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@application/src/main/java/com/pinback/application/google/dto/response/GoogleLoginResponseV3.java`
around lines 10 - 15, GoogleLoginResponseV3 currently includes a refreshToken
field and the loggedIn(...) factory returns it, causing the refresh token to be
serialized into the JSON response; remove the refreshToken from the record
declaration and update the loggedIn method signature and body to omit the
refreshToken parameter so JSON responses no longer include it (keep sending the
refresh token only via the HttpOnly cookie in GoogleLoginControllerV3). Ensure
all references to GoogleLoginResponseV3.refreshToken and the loggedIn overload
are updated or removed accordingly to avoid compilation errors.
feat: 메모 글자수 500자 초과 제한으로 수정
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@api/src/main/java/com/pinback/api/config/cors/CorsConfig.java`:
- Line 49: The CorsConfig currently calls
configuration.setAllowCredentials(true) while also registering the
chrome-extension://* origin pattern; change this so credentialed CORS is never
enabled for a wildcard extension origin: create two distinct CorsConfiguration
entries in CorsConfig — one for chrome-extension://* (or extension traffic) with
setAllowCredentials(false) and the minimal allowed methods/headers, and a
separate credentialed configuration that only lists explicit allowedOrigins (no
wildcards) and keeps setAllowCredentials(true); update the
registerCorsConfiguration calls so chrome-extension://* uses the
non-credentialed CorsConfiguration and only specific extension IDs or trusted
origins are placed in the credentialed CorsConfiguration.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b57d8a53-9803-41d7-879e-e581fc2e3531
📒 Files selected for processing (2)
api/src/main/java/com/pinback/api/config/cors/CorsConfig.javaapi/src/main/java/com/pinback/api/config/security/SecurityConfig.java
🚧 Files skipped from review as they are similar to previous changes (1)
- api/src/main/java/com/pinback/api/config/security/SecurityConfig.java
| )); | ||
|
|
||
| configuration.setAllowCredentials(false); | ||
| configuration.setAllowCredentials(true); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Spring Framework CORS configuration, what are the security implications of using setAllowCredentials(true)together withsetAllowedOriginPatterns(List.of("chrome-extension://*"))? Does this allow any Chrome extension origin to make credentialed cross-origin requests?
💡 Result:
Using setAllowCredentials(true) means the server is telling the browser it’s OK for the JS caller to make credentialed cross-origin requests (cookies / HTTP auth / client certs, depending on the client) and read the response, as long as the response includes an explicit Access-Control-Allow-Origin (not *) plus Access-Control-Allow-Credentials: true. Spring notes that the CORS spec forbids * with credentials and provides allowedOriginPatterns as the “dynamic origins” alternative. [2]
setAllowedOriginPatterns(List.of("chrome-extension://*")) matches any Chrome extension origin of the form chrome-extension://<extension-id> (the extension ID acts like the “host” part of the origin). [3] When an allowedOriginPattern matches, Spring sets Access-Control-Allow-Origin to the request’s Origin value (the matched origin), not to * and not to the pattern. [1] That makes it fully effective for credentialed requests.
So yes: for requests that are subject to normal browser CORS enforcement, this configuration effectively allows any Chrome extension ID to make credentialed cross-origin requests to your Spring app and read the responses, as long as the extension uses credentials: "include" (or equivalent) and the browser includes the user’s cookies for your site.
Security implication: you are extending “trusted frontend” status to every installed extension (including potentially malicious ones), which can then exfiltrate user-specific data from your API using the user’s ambient authentication (cookies/session). Prefer whitelisting specific extension IDs (e.g., chrome-extension://<your-id>) rather than chrome-extension://*. [1]
🏁 Script executed:
# First, locate and read the CorsConfig.java file
find . -name "CorsConfig.java" -type f | head -20Repository: Pinback-Team/pinback-server
Length of output: 134
🏁 Script executed:
# Read the CorsConfig.java file with line numbers
cat -n ./api/src/main/java/com/pinback/api/config/cors/CorsConfig.javaRepository: Pinback-Team/pinback-server
Length of output: 2183
Do not enable credentialed CORS for the wildcard extension origin.
Line 49 enables credentials for the chrome-extension://* pattern defined at line 22, allowing any installed Chrome extension to make authenticated cross-origin requests and read responses with the user's cookies. Split extension traffic into a separate non-credentialed CORS policy, or whitelist only the specific extension IDs that require authenticated access.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@api/src/main/java/com/pinback/api/config/cors/CorsConfig.java` at line 49,
The CorsConfig currently calls configuration.setAllowCredentials(true) while
also registering the chrome-extension://* origin pattern; change this so
credentialed CORS is never enabled for a wildcard extension origin: create two
distinct CorsConfiguration entries in CorsConfig — one for chrome-extension://*
(or extension traffic) with setAllowCredentials(false) and the minimal allowed
methods/headers, and a separate credentialed configuration that only lists
explicit allowedOrigins (no wildcards) and keeps setAllowCredentials(true);
update the registerCorsConfiguration calls so chrome-extension://* uses the
non-credentialed CorsConfiguration and only specific extension IDs or trusted
origins are placed in the credentialed CorsConfiguration.
🚀 PR 요약
목적이 무엇인가요? - 지우고 작성
✨ PR 상세 내용
어떤 부분이 어떻게 변경이 되었나요? - 지우고 작성
🚨 주의 사항
주의할 부분이 무엇인가요? - 지우고 작성
✅ 체크 리스트
Summary by CodeRabbit
New Features
Bug Fixes