Skip to content

fix/89/fe/votepage#105

Open
hangyeoli wants to merge 32 commits intodevelopfrom
fix/89/fe/votepage
Open

fix/89/fe/votepage#105
hangyeoli wants to merge 32 commits intodevelopfrom
fix/89/fe/votepage

Conversation

@hangyeoli
Copy link
Contributor

PULL REQUEST

🎋 작업 브랜치

🔑 주요 변경사항

  • 이번 PR에서 작업한 내용을 간략히 설명해주세요.
  • 투표가능식당 위젯에 스크랩 버튼, 상세정보 보기 추가
  • vote_state_provider.dart 추가
  • VoteQueue클래스: 5초 간격으로 투표 요청을 모아서 한 번에 처리
  • 요청 1분 최대 20회로 제한

🏞 스크린샷 (선택)

스크린샷을 첨부해주세요.
Simulator Screenshot - iPhone 16 Pro Max - 2025-04-28 at 19 41 01

@hangyeoli hangyeoli requested a review from Gothax April 28, 2025 10:51
@hangyeoli hangyeoli linked an issue Apr 28, 2025 that may be closed by this pull request
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Hello @hangyeoli, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

Summary of Changes

This pull request focuses on enhancing the vote page functionality. It introduces features such as restaurant voting (like/dislike), restaurant scraping, sorting options (by distance, likes, dislikes), and pagination for efficient data loading. The backend changes involve creating new APIs for voting and modifying existing restaurant data queries to include vote counts and scrap status. The frontend changes implement the UI components for voting, scraping, and displaying restaurant information, along with state management using Riverpod.

Highlights

  • Vote Functionality: Implements the core voting feature, allowing users to like or dislike restaurants. Includes backend API endpoints and frontend UI components for voting.
  • Scraping: Adds the ability for users to scrap restaurants, providing a way to save restaurants for later viewing. Includes backend API and frontend UI elements.
  • Sorting and Pagination: Introduces sorting options for restaurants (by distance, likes, dislikes) and pagination to handle large datasets efficiently.
  • State Management: Utilizes Riverpod for state management, ensuring a reactive and maintainable frontend architecture.
  • Rate Limiting: Implements rate limiting to prevent abuse of the voting system, ensuring fair usage.

Changelog

Click here to see the changelog
  • be/src/main/java/com/yumst/be/global/exception/ErrorResponse.java
    • Added ErrorResponse class to handle exceptions and provide structured error responses.
  • be/src/main/java/com/yumst/be/restaurant/repository/NaverReviewFeatureCountRepository.java
    • Added a method to find top 2 features for a list of restaurant IDs, optimizing feature retrieval for multiple restaurants.
  • be/src/main/java/com/yumst/be/restaurant/repository/RestaurantRepository.java
    • Added queries to find nearby restaurants, sort them by distance, likes, or dislikes, and include scrap status and vote counts.
  • be/src/main/java/com/yumst/be/restaurant/service/RestaurantService.java
    • Added methods to find nearby restaurants with sorting and pagination, validate restaurant existence, and retrieve restaurant features efficiently.
  • be/src/main/java/com/yumst/be/restaurant/vo/ResponseRestaurant.java
    • Added a static method to create ResponseRestaurant objects from search results, simplifying data transformation.
  • be/src/main/java/com/yumst/be/user/repository/UserRestaurantScrapRepository.java
    • Added a method to find scrapped restaurant IDs for a user, improving scrap status retrieval.
  • be/src/main/java/com/yumst/be/user/service/UserService.java
    • Added a method to validate user existence, ensuring user-related operations are performed on valid users.
  • be/src/main/java/com/yumst/be/vote/controller/UserRestaurantVoteController.java
    • Created a new controller to handle voting-related API endpoints, including fetching votable restaurants, voting, and batch voting.
  • be/src/main/java/com/yumst/be/vote/domain/UserRestaurantVote.java
    • Created a UserRestaurantVote entity to represent user votes on restaurants, including vote type and versioning for optimistic locking.
  • be/src/main/java/com/yumst/be/vote/dto/BatchVoteRequest.java
    • Created DTOs for batch voting requests, including validation constraints to ensure data integrity.
  • be/src/main/java/com/yumst/be/vote/dto/RestaurantRequest.java
    • Created DTOs for restaurant requests, including validation constraints to ensure data integrity.
  • be/src/main/java/com/yumst/be/vote/dto/VoteRequest.java
    • Created DTOs for vote requests, including validation constraints to ensure data integrity.
  • be/src/main/java/com/yumst/be/vote/dto/VoteResponse.java
    • Created DTOs for vote responses, including validation constraints to ensure data integrity.
  • be/src/main/java/com/yumst/be/vote/dto/VoteType.java
    • Created an enum to represent vote types (LIKE, DISLIKE).
  • be/src/main/java/com/yumst/be/vote/exception/VoteErrorCode.java
    • Created an enum to represent vote error codes.
  • be/src/main/java/com/yumst/be/vote/exception/VoteException.java
    • Created a custom exception class for vote-related errors.
  • be/src/main/java/com/yumst/be/vote/exception/VoteExceptionHandler.java
    • Created a handler for vote-related exceptions.
  • be/src/main/java/com/yumst/be/vote/repository/UserRestaurantVoteRepository.java
    • Created a repository to manage user restaurant votes, including methods to find votes, count votes, and retrieve vote statistics.
  • be/src/main/java/com/yumst/be/vote/service/UserRestaurantVoteService.java
    • Created a service to handle user restaurant votes, including methods to fetch votable restaurants, vote, and batch vote.
  • be/src/main/java/com/yumst/be/vote/util/VoteRateLimiter.java
    • Created a utility class to limit the rate of vote requests.
  • fe/lib/model/restaurant.dart
    • Modified the Restaurant model to handle null values for top2Features.
  • fe/lib/model/vote_restaurant.dart
    • Created a VoteRestaurant model to represent restaurant data on the vote page.
  • fe/lib/model/vote_restaurant.g.dart
    • Generated code for VoteRestaurant model.
  • fe/lib/provider/vote_state_provider.dart
    • Created a state provider to manage the state of the vote page, including restaurant data, loading status, and error handling. Implemented VoteQueue for batching vote requests and rate limiting.
  • fe/lib/repository/auth_repository.dart
    • Added agreeToTerms method to auth repository.
  • fe/lib/repository/vote_repository.dart
    • Created a repository to handle vote-related API calls, including fetching votable restaurants, voting, batch voting, and scraping.
  • fe/lib/repository/vote_repository.g.dart
    • Generated code for VoteRepository.
  • fe/lib/view/screen/auth_page/user_terms_page.dart
    • Modified user terms page to submit agreement and navigate to survey or main screen.
  • fe/lib/view/screen/main_page.dart
    • Modified main page to include vote page.
  • fe/lib/view/screen/vote_page.dart
    • Created a vote page to display votable restaurants and handle user interactions.
  • fe/lib/view/widget/vote_restaurant_card.dart
    • Created a widget to display restaurant information on the vote page, including voting buttons, scrap button, and restaurant details.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in issue comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist is currently in preview and may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments to provide feedback.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.


A vote cast, a choice made,
Restaurants rise, or gently fade.
Like or dislike,
Shape the night's hike,
In foodie quests, a path is laid.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new voting feature, including UI components and backend API endpoints. The code appears well-structured, incorporating pagination, rate limiting, and optimistic updates. However, there are several areas that could benefit from closer scrutiny and potential improvements, particularly around error handling, data consistency, and code clarity.

Summary of Findings

  • Inconsistent Data Handling: The frontend and backend handle restaurant data differently, leading to potential inconsistencies. The backend uses Object[] for nearby restaurant queries, while the frontend expects VoteRestaurant objects. This discrepancy requires careful mapping and could lead to errors or unexpected behavior.
  • Potential Race Condition in Vote Queue: The VoteQueue class in the frontend might be susceptible to race conditions if multiple votes for the same restaurant are added in quick succession. This could lead to incorrect vote counts or data inconsistencies.
  • Missing Error Handling in Scrap Toggle: The toggleScrap function in VotePageStateNotifier lacks proper error handling for API failures, potentially leading to a desynchronized UI state.
  • Hardcoded Values: Several hardcoded values, such as the default location in VotePageStateNotifier and the radius in VoteRepository, should be configurable to improve flexibility and maintainability.

Merge Readiness

While the pull request introduces valuable functionality, the identified issues regarding data consistency, potential race conditions, and error handling should be addressed before merging. I am unable to directly approve this pull request, and strongly recommend that these issues be addressed and the code be reviewed by other engineers before merging.

Comment on lines 80 to 117
Future<void> _toggleScrap() async {
try {
final restaurant = widget.restaurant;
final restaurantData = Restaurant(
restaurantId: restaurant.restaurantId,
name: restaurant.name,
category: restaurant.category ?? '',
latitude: '',
longitude: '',
thumbnailUrl: restaurant.thumbnailUrl,
fullAddress: '',
roadNameFullAddress: '',
phoneNumber: '',
todayOpening: restaurant.businessHours,
top2Features: restaurant.topFeatures,
isScrapped: !(restaurant.isScrapped ?? false),
likeCount: restaurant.likeCount,
dislikeCount: restaurant.dislikeCount,
distance: restaurant.distance,
);
await ref
.read(restaurantRepositoryProvider)
.scrapRestaurant(restaurantData);

// provider를 통해 스크랩 상태 업데이트
ref.read(voteRestaurantProvider(restaurant.restaurantId).notifier)
..toggleScrap() // 먼저 스크랩 상태 토글
..updateRestaurant(restaurant.copyWith( // 그 다음 업데이트된 상태로 restaurant 객체 업데이트
isScrapped: !(restaurant.isScrapped ?? false),
));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("스크랩 실패: $e")),
);
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The _toggleScrap function directly manipulates the UI state and then calls the API. If the API call fails, the UI will be out of sync. Consider reverting the UI state if the API call fails and showing an error message to the user.

Comment on lines 17 to 159
// 거리순 정렬
@Query(value =
"WITH RankedRestaurants AS (" +
" SELECT " +
" r.restaurant_id, r.name, r.category, r.thumbnail_url, " +
" ST_Distance(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" +
" ) as dist, " +
" COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " +
" COALESCE(SUM(CASE WHEN v.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " +
" COALESCE(SUM(CASE WHEN v.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " +
" ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))" +
" ) as row_num " +
" FROM " +
" restaurant r " +
" LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " +
" LEFT JOIN user_restaurant_vote v ON r.restaurant_id = v.restaurant_id " +
" WHERE " +
" r.crawl_complete = true AND " +
" ST_DWithin(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " +
" :radius * 1000" +
" ) " +
" GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" +
") " +
"SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count " +
"FROM RankedRestaurants " +
"WHERE row_num = 1 " +
"ORDER BY dist ASC",
countQuery = "SELECT COUNT(DISTINCT r.name) " +
"FROM restaurant r " +
"WHERE r.crawl_complete = true AND " +
"ST_DWithin(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " +
" :radius * 1000" +
")",
nativeQuery = true)
Page<Object[]> findNearbyRestaurantsOrderByDistance(
@Param("userId") String userId,
@Param("latitude") Double latitude,
@Param("longitude") Double longitude,
@Param("radius") Double radius,
Pageable pageable
);

// 좋아요순 정렬
@Query(value =
"WITH RankedRestaurants AS (" +
" SELECT " +
" r.restaurant_id, r.name, r.category, r.thumbnail_url, " +
" ST_Distance(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" +
" ) as dist, " +
" COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " +
" COALESCE(SUM(CASE WHEN v.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " +
" COALESCE(SUM(CASE WHEN v.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " +
" ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))" +
" ) as row_num " +
" FROM " +
" restaurant r " +
" LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " +
" LEFT JOIN user_restaurant_vote v ON r.restaurant_id = v.restaurant_id " +
" WHERE " +
" r.crawl_complete = true AND " +
" ST_DWithin(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " +
" :radius * 1000" +
" ) " +
" GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" +
") " +
"SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count " +
"FROM RankedRestaurants " +
"WHERE row_num = 1 " +
"ORDER BY like_count DESC, dist ASC",
countQuery = "SELECT COUNT(DISTINCT r.name) " +
"FROM restaurant r " +
"WHERE r.crawl_complete = true AND " +
"ST_DWithin(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " +
" :radius * 1000" +
")",
nativeQuery = true)
Page<Object[]> findNearbyRestaurantsOrderByLikes(
@Param("userId") String userId,
@Param("latitude") Double latitude,
@Param("longitude") Double longitude,
@Param("radius") Double radius,
Pageable pageable
);

// 싫어요순 정렬
@Query(value =
"WITH RankedRestaurants AS (" +
" SELECT " +
" r.restaurant_id, r.name, r.category, r.thumbnail_url, " +
" ST_Distance(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" +
" ) as dist, " +
" COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " +
" COALESCE(SUM(CASE WHEN v.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " +
" COALESCE(SUM(CASE WHEN v.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " +
" ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))" +
" ) as row_num " +
" FROM " +
" restaurant r " +
" LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " +
" LEFT JOIN user_restaurant_vote v ON r.restaurant_id = v.restaurant_id " +
" WHERE " +
" r.crawl_complete = true AND " +
" ST_DWithin(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " +
" :radius * 1000" +
" ) " +
" GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" +
") " +
"SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count " +
"FROM RankedRestaurants " +
"WHERE row_num = 1 " +
"ORDER BY dislike_count DESC, dist ASC",
countQuery = "SELECT COUNT(DISTINCT r.name) " +
"FROM restaurant r " +
"WHERE r.crawl_complete = true AND " +
"ST_DWithin(" +
" geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " +
" geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " +
" :radius * 1000" +
")",
nativeQuery = true)
Page<Object[]> findNearbyRestaurantsOrderByDislikes(
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The SQL queries are quite large and complex. Consider breaking them down into smaller, more manageable parts or using a query builder to improve readability and maintainability. This would also make it easier to test and debug the queries.

Comment on lines 108 to 159
public List<ResponseRestaurant> findNearbyRestaurants(String userId, Double latitude, Double longitude, Double radius, String sort, Pageable pageable) {
Page<Object[]> results;

switch (sort) {
case "likes":
results = restaurantRepository.findNearbyRestaurantsOrderByLikes(userId, latitude, longitude, radius, pageable);
break;
case "dislikes":
results = restaurantRepository.findNearbyRestaurantsOrderByDislikes(userId, latitude, longitude, radius, pageable);
break;
case "distance":
default:
results = restaurantRepository.findNearbyRestaurantsOrderByDistance(userId, latitude, longitude, radius, pageable);
break;
}

List<Object[]> content = results.getContent();

if (content.isEmpty()) {
return new ArrayList<>();
}

List<String> restaurantIds = content.stream()
.map(result -> (String) result[0])
.toList();


List<RestaurantNaverReviewFeatureCount> allFeatures = naverReviewFeatureCountRepository.findTop2FeaturesForRestaurantIds(restaurantIds);

Map<String, List<String>> featureMap = allFeatures.stream()
.collect(Collectors.groupingBy(
RestaurantNaverReviewFeatureCount::getRestaurantId,
Collectors.mapping(rc -> rc.getNaverReviewFeature().getFeature(), Collectors.toList())
))
.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> entry.getValue().stream().limit(2).toList()
));

return content.stream()
.map(result -> ResponseRestaurant.fromSearchResult(
(String) result[0], // restaurantId
(String) result[1], // name
(String) result[2], // category
(String) result[3], // thumbnailUrl
((Number) result[4]).doubleValue(), // distance
(Boolean) result[5], // isScrapped
((Number) result[6]).longValue(), // likeCount
((Number) result[7]).longValue(), // dislikeCount
featureMap.getOrDefault((String) result[0], new ArrayList<>()) // features
))
.toList();
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The method findNearbyRestaurants mixes data retrieval and DTO creation logic. It would be cleaner to separate these concerns. Consider creating a dedicated data transfer object (DTO) for the query results and mapping the results to ResponseRestaurant objects in a separate step. This improves separation of concerns and makes the code easier to test.

Comment on lines 191 to 204
// 위치 정보가 없을 때 기본값 사용 (서울 시청 좌표)
_lastPosition = Position(
latitude: 37.5665,
longitude: 126.9780,
timestamp: DateTime.now(),
accuracy: 0,
altitude: 0,
heading: 0,
speed: 0,
speedAccuracy: 0,
altitudeAccuracy: 0,
headingAccuracy: 0,
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Hardcoding the default location to Seoul City Hall is not ideal. It would be better to either retrieve a user's last known location from local storage or prompt the user for their location if it's unavailable. This provides a more personalized experience.

Comment on lines 107 to 125
void addVote(String restaurantId, VoteType voteType) {
final now = DateTime.now();
if (now.difference(_lastRequestTime) < _rateLimitWindow) {
if (_requestCount >= 20) {
throw Exception('Too many requests');
}
_requestCount++;
} else {
_requestCount = 1;
_lastRequestTime = now;
}

_queue.add({
'restaurantId': restaurantId,
'voteType': voteType.name,
});

_startBatchTimer();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The addVote method in VoteQueue throws a generic Exception('Too many requests'). Consider creating a custom exception class to provide more context and allow for more specific error handling in the UI.

@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)
B Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@hangyeoli hangyeoli mentioned this pull request May 13, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FE 투표 최적화

1 participant