Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[fix] 좋아요 동시성 이슈 해결 #224

Open
wants to merge 16 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ dependencies {

// Discord Webhook
implementation 'com.github.napstr:logback-discord-appender:1.0.0'

// Spring Retry
implementation 'org.springframework.retry:spring-retry'
}

tasks.named('test') {
Expand All @@ -93,4 +96,4 @@ tasks.register('copyYml', Copy) {
include "*.yml"
into 'src/main/resources'
}
}
}
2 changes: 1 addition & 1 deletion server-yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.hankki.hankkiserver.common.code.StoreErrorCode;
import org.hankki.hankkiserver.common.code.StoreImageErrorCode;
import org.hankki.hankkiserver.common.exception.BadRequestException;
import org.hankki.hankkiserver.common.exception.ConcurrencyException;
import org.hankki.hankkiserver.common.exception.ConflictException;
import org.hankki.hankkiserver.common.exception.NotFoundException;
import org.hankki.hankkiserver.common.exception.UnauthorizedException;
Expand Down Expand Up @@ -49,6 +50,12 @@ public HankkiResponse<Void> handleConflictException(ConflictException e) {
return HankkiResponse.fail(e.getErrorCode());
}

@ExceptionHandler(ConcurrencyException.class)
public HankkiResponse<Void> handleConcurrencyException(ConcurrencyException e) {
log.warn("handleConcurrencyException() in GlobalExceptionHandler throw ConcurrencyException : {}", e.getMessage());
return HankkiResponse.fail(e.getErrorCode());
}

@ExceptionHandler(MissingServletRequestParameterException.class)
public HankkiResponse<Void> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) {
log.warn("handleMissingServletRequestParameterException() in GlobalExceptionHandler throw MissingServletRequestParameterException : {}", e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.hankki.hankkiserver.api.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.EnableRetry;

@EnableRetry
@Configuration
public class RetryConfig {
}
Copy link
Contributor

Choose a reason for hiding this comment

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

빈 등록하면서 aop 적용순서(transactional이랑 retry) 정해야 하는 거 같기도 하던데 고려된건가요?

Copy link
Member Author

Choose a reason for hiding this comment

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

명시적으로 지정해주지 않아도 기본적으로 Spring AOP의 우선순위에 따라 @retryable이 먼저 적용된다고 합니다.

Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,32 @@
import org.hankki.hankkiserver.api.store.service.HeartCommandService;
import org.hankki.hankkiserver.api.store.service.StoreCommandService;
import org.hankki.hankkiserver.api.store.service.StoreQueryService;
import org.hankki.hankkiserver.api.store.service.command.*;
import org.hankki.hankkiserver.api.store.service.response.*;
import org.hankki.hankkiserver.api.store.service.command.HeartCommand;
import org.hankki.hankkiserver.api.store.service.command.StoreDeleteCommand;
import org.hankki.hankkiserver.api.store.service.command.StorePostCommand;
import org.hankki.hankkiserver.api.store.service.command.StoreValidationCommand;
import org.hankki.hankkiserver.api.store.service.response.CategoriesResponse;
import org.hankki.hankkiserver.api.store.service.response.HeartResponse;
import org.hankki.hankkiserver.api.store.service.response.PriceCategoriesResponse;
import org.hankki.hankkiserver.api.store.service.response.SortOptionsResponse;
import org.hankki.hankkiserver.api.store.service.response.StoreDuplicateValidationResponse;
import org.hankki.hankkiserver.api.store.service.response.StoreGetResponse;
import org.hankki.hankkiserver.api.store.service.response.StorePinsResponse;
import org.hankki.hankkiserver.api.store.service.response.StorePostResponse;
import org.hankki.hankkiserver.api.store.service.response.StoreThumbnailResponse;
import org.hankki.hankkiserver.api.store.service.response.StoresResponse;
import org.hankki.hankkiserver.auth.UserId;
import org.hankki.hankkiserver.common.code.CommonSuccessCode;
import org.hankki.hankkiserver.domain.store.model.StoreCategory;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
Expand All @@ -42,9 +62,9 @@ public HankkiResponse<StorePinsResponse> getStorePins(@RequestParam(required = f

@GetMapping("/stores")
public HankkiResponse<StoresResponse> getStores(@RequestParam(required = false) final Long universityId,
@RequestParam(required = false) final StoreCategory storeCategory,
@RequestParam(required = false) final PriceCategory priceCategory,
@RequestParam(required = false) final SortOption sortOption) {
@RequestParam(required = false) final StoreCategory storeCategory,
@RequestParam(required = false) final PriceCategory priceCategory,
@RequestParam(required = false) final SortOption sortOption) {
return HankkiResponse.success(CommonSuccessCode.OK, storeQueryService.getStores(universityId, storeCategory, priceCategory, sortOption));
}

Expand All @@ -69,23 +89,28 @@ public HankkiResponse<PriceCategoriesResponse> getPrices() {
}

@PostMapping("/stores/{id}/hearts")
public HankkiResponse<HeartCreateResponse> createHeartStore(@UserId final Long userId, @PathVariable final Long id) {
return HankkiResponse.success(CommonSuccessCode.CREATED, heartCommandService.createHeart(HeartPostCommand.of(userId, id)));
public HankkiResponse<HeartResponse> createHeartStore(@UserId final Long userId,
@PathVariable final Long id) {
return HankkiResponse.success(CommonSuccessCode.CREATED, heartCommandService.createHeart(HeartCommand.of(userId, id)));
}

@DeleteMapping("/stores/{id}/hearts")
public HankkiResponse<HeartDeleteResponse> deleteHeartStore(@UserId final Long userId, @PathVariable final Long id) {
return HankkiResponse.success(CommonSuccessCode.OK, heartCommandService.deleteHeart(HeartDeleteCommand.of(userId, id)));
public HankkiResponse<HeartResponse> deleteHeartStore(@UserId final Long userId,
@PathVariable final Long id) {
return HankkiResponse.success(CommonSuccessCode.OK, heartCommandService.deleteHeart(HeartCommand.of(userId, id)));
}

@PostMapping("/stores/validate")
public HankkiResponse<StoreDuplicateValidationResponse> validateDuplicatedStore(@RequestBody @Valid final StoreDuplicateValidationRequest request) {
return HankkiResponse.success(CommonSuccessCode.OK, storeQueryService.validateDuplicatedStore(StoreValidationCommand.of(request.universityId(), request.latitude(), request.longitude(), request.storeName())));
public HankkiResponse<StoreDuplicateValidationResponse> validateDuplicatedStore(
@RequestBody @Valid final StoreDuplicateValidationRequest request) {
return HankkiResponse.success(CommonSuccessCode.OK, storeQueryService.validateDuplicatedStore(
StoreValidationCommand.of(request.universityId(), request.latitude(), request.longitude(),
request.storeName())));
}

@PostMapping("/stores")
public HankkiResponse<StorePostResponse> createStore(@RequestPart(required = false) final MultipartFile image,
@Valid @RequestPart final StorePostRequest request,
@Valid @RequestPart final StorePostRequest request,
@UserId final Long userId) {
return HankkiResponse.success(CommonSuccessCode.CREATED, storeCommandService.createStore(StorePostCommand.of(image, request, userId)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@

import lombok.RequiredArgsConstructor;
import org.hankki.hankkiserver.api.auth.service.UserFinder;
import org.hankki.hankkiserver.api.store.service.command.HeartDeleteCommand;
import org.hankki.hankkiserver.api.store.service.command.HeartPostCommand;
import org.hankki.hankkiserver.api.store.service.response.HeartCreateResponse;
import org.hankki.hankkiserver.api.store.service.response.HeartDeleteResponse;
import org.hankki.hankkiserver.api.store.service.command.HeartCommand;
import org.hankki.hankkiserver.api.store.service.response.HeartResponse;
import org.hankki.hankkiserver.common.code.HeartErrorCode;
import org.hankki.hankkiserver.common.exception.BadRequestException;
import org.hankki.hankkiserver.common.exception.ConcurrencyException;
import org.hankki.hankkiserver.common.exception.ConflictException;
import org.hankki.hankkiserver.common.exception.NotFoundException;
import org.hankki.hankkiserver.domain.heart.model.Heart;
import org.hankki.hankkiserver.domain.store.model.Store;
import org.hankki.hankkiserver.domain.user.model.User;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class HeartCommandService {

private final HeartUpdater heartUpdater;
Expand All @@ -25,32 +29,48 @@ public class HeartCommandService {
private final UserFinder userFinder;
private final StoreFinder storeFinder;

public HeartCreateResponse createHeart(final HeartPostCommand heartPostCommand) {
User user = userFinder.getUserReference(heartPostCommand.userId());
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartPostCommand.storeId());
@Retryable(
Copy link
Contributor

Choose a reason for hiding this comment

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

optimisticlockexception 발생하면 retry시키는건가요?

Copy link
Member Author

@kgy1008 kgy1008 Dec 9, 2024

Choose a reason for hiding this comment

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

넹 맞습니다. 다만, noRetryFor을 통해서 재시도 하지 않을 예외들은 제외하였습니다.

noRetryFor = {ConflictException.class, NotFoundException.class},
notRecoverable = {ConflictException.class, NotFoundException.class},
backoff = @Backoff(delay = 100))
@Transactional
public HeartResponse createHeart(final HeartCommand heartCommand) {
User user = userFinder.getUserReference(heartCommand.userId());
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartCommand.storeId());
validateStoreHeartCreation(user, store);
saveStoreHeart(user, store);
store.increaseHeartCount();
return HeartCreateResponse.of(store);
return HeartResponse.of(store);
}

public HeartDeleteResponse deleteHeart(final HeartDeleteCommand heartDeleteCommand) {
User user = userFinder.getUserReference(heartDeleteCommand.userId());
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartDeleteCommand.storeId());
@Retryable(
noRetryFor = {NotFoundException.class, ConflictException.class, BadRequestException.class},
notRecoverable = {NotFoundException.class, ConflictException.class, BadRequestException.class},
backoff = @Backoff(delay = 100))
@Transactional
public HeartResponse deleteHeart(final HeartCommand heartCommand) {
User user = userFinder.getUserReference(heartCommand.userId());
Store store = storeFinder.findByIdWhereDeletedIsFalse(heartCommand.storeId());
validateStoreHeartRemoval(user, store);
heartDeleter.deleteHeart(user,store);
heartDeleter.deleteHeart(user, store);
store.decreaseHeartCount();
return HeartDeleteResponse.of(store);
return HeartResponse.of(store);
}

@Recover
public HeartResponse recoverFromOptimisticLockFailure(final ObjectOptimisticLockingFailureException e,
final HeartCommand heartCommand) {
throw new ConcurrencyException(HeartErrorCode.HEART_COUNT_CONCURRENCY_ERROR);
}

private void validateStoreHeartCreation(final User user, final Store store) {
if(heartFinder.existsByUserAndStore(user, store)){
if (heartFinder.existsByUserAndStore(user, store)) {
throw new ConflictException(HeartErrorCode.ALREADY_EXISTED_HEART);
}
}

private void validateStoreHeartRemoval(final User user, final Store store) {
if(!heartFinder.existsByUserAndStore(user, store)){
if (!heartFinder.existsByUserAndStore(user, store)) {
throw new ConflictException(HeartErrorCode.ALREADY_NO_HEART);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.hankki.hankkiserver.api.store.service.command;

public record HeartCommand(
Long userId,
Copy link
Contributor

Choose a reason for hiding this comment

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

이 필드이 필수 값들이라면 원시타입으로 하면 어떨까요?

Long storeId
) {
public static HeartCommand of(final Long userId, final Long storeId) {
return new HeartCommand(userId, storeId);
}
}

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import org.hankki.hankkiserver.domain.store.model.Store;

public record HeartCreateResponse(
public record HeartResponse(
Long storeId,
int count,
boolean isHearted
) {
public static HeartCreateResponse of(final Store store) {
return new HeartCreateResponse(store.getId(), store.getHeartCount(), true);
public static HeartResponse of(final Store store) {
return new HeartResponse(store.getId(), store.getHeartCount(), true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
public enum HeartErrorCode implements ErrorCode {

ALREADY_EXISTED_HEART(HttpStatus.CONFLICT, "이미 좋아요 한 가게입니다."),
ALREADY_NO_HEART(HttpStatus.CONFLICT, "이미 좋아요를 취소한 가게입니다.");
ALREADY_NO_HEART(HttpStatus.CONFLICT, "이미 좋아요를 취소한 가게입니다."),
HEART_COUNT_CONCURRENCY_ERROR(HttpStatus.CONFLICT, "좋아요 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."),
INVALID_HEART_COUNT(HttpStatus.BAD_REQUEST, "좋아요 개수는 음수가 될 수 없습니다.");

private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.hankki.hankkiserver.common.exception;

import lombok.Getter;
import org.hankki.hankkiserver.common.code.ErrorCode;

@Getter
public class ConcurrencyException extends RuntimeException {
Copy link
Contributor

Choose a reason for hiding this comment

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

초반에 exception 유형 에러유형400 401... 등으로 하기로 했었음
굳이 이거 새로 만들 필요 있나 싶어요

private final ErrorCode errorCode;

public ConcurrencyException(final ErrorCode errorCode) {
this.errorCode = errorCode;
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
package org.hankki.hankkiserver.domain.store.model;

import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Version;
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hankki.hankkiserver.common.code.HeartErrorCode;
import org.hankki.hankkiserver.common.exception.BadRequestException;
import org.hankki.hankkiserver.domain.common.BaseTimeEntity;
import org.hankki.hankkiserver.domain.common.Point;
import org.hankki.hankkiserver.domain.heart.model.Heart;
import org.hankki.hankkiserver.domain.universitystore.model.UniversityStore;
import org.hibernate.annotations.BatchSize;

import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.DynamicInsert;

@Entity
@Getter
@BatchSize(size = 100)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicInsert
public class Store extends BaseTimeEntity {

private static final int DEFAULT_HEART_COUNT = 0;

@Id
@Column(name = "store_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down Expand Up @@ -57,8 +72,13 @@ public class Store extends BaseTimeEntity {
@Column(nullable = false)
private boolean isDeleted;

@Version
@ColumnDefault("0L")
private Long version;

@Builder
private Store (String name, Point point, String address, StoreCategory category, int lowestPrice, int heartCount, boolean isDeleted) {
private Store(String name, Point point, String address, StoreCategory category, int lowestPrice, int heartCount,
boolean isDeleted) {
this.name = name;
this.point = point;
this.address = address;
Expand All @@ -69,6 +89,7 @@ private Store (String name, Point point, String address, StoreCategory category,
}

public void decreaseHeartCount() {
validateHeartCount();
this.heartCount--;
}

Expand All @@ -87,4 +108,10 @@ public String getImageUrlOrElseNull() {
public void updateLowestPrice(int lowestPrice) {
this.lowestPrice = lowestPrice;
}

private void validateHeartCount() {
Copy link
Contributor

@PicturePark1101 PicturePark1101 Dec 9, 2024

Choose a reason for hiding this comment

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

BadRequestException이 domain 패키지에 존재하는 클래스에 있는건 적절하지 않은 것 같습니당 차라리 리턴값 boolean 등으로 변경하고 에러는 service 쪽에서 던져주는게 어떨까요?

if (this.heartCount <= DEFAULT_HEART_COUNT) {
throw new BadRequestException(HeartErrorCode.INVALID_HEART_COUNT);
}
}
}
Loading