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

feat: 북마크 기능 구현 및 redisson 활용한 동시성 핸들링 #25

Merged
merged 2 commits into from
Oct 6, 2023
Merged
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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.kafka:spring-kafka'
implementation 'it.ozimov:embedded-redis:0.7.2'
implementation 'org.redisson:redisson-spring-boot-starter:3.21.1'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.prography.kagongsillok.bookmark.application;

import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.prography.kagongsillok.bookmark.domain.BookmarkToggleSwitch;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class BookmarkService {

public static final String BOOKMARK_LOCK_FORMAT = "lockBookmark::placeId=%d::memberId=%d";
private final BookmarkToggleSwitch bookmarkToggleSwitch;
private final RedissonClient redissonClient;

public void toggle(final Long placeId, final Long memberId) {
final RLock lock = redissonClient.getLock(String.format(BOOKMARK_LOCK_FORMAT, placeId, memberId));

try {
final boolean available = lock.tryLock(15, 1, TimeUnit.SECONDS);
if (!available) {
throw new RuntimeException("락이 오랜 시간 획득되지 않습니다. 데드락이 발생했을 수 있습니다.");
}
bookmarkToggleSwitch.toggle(placeId, memberId);
} catch (final InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.prography.kagongsillok.bookmark.domain;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import org.prography.kagongsillok.common.entity.AbstractRootEntity;

@Getter
@Entity
@Table(name = "bookmark", indexes = {
@Index(name = "ix__bookmark__member_id", columnList = "memberId"),
@Index(name = "ix__bookmark__place_id", columnList = "placeId")
})
@Where(clause = "is_deleted = false")
@SQLDelete(sql = "update bookmark set is_deleted = true, updated_at = now() where id = ?")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Bookmark extends AbstractRootEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private Long placeId;
private Long memberId;

public Bookmark(final Long placeId, final Long memberId) {
this.placeId = placeId;
this.memberId = memberId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.prography.kagongsillok.bookmark.domain;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookmarkRepository extends JpaRepository<Bookmark, Long> {

Optional<Bookmark> findByPlaceIdAndMemberIdAndIsDeletedFalse(Long placeId, Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.prography.kagongsillok.bookmark.domain;

import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.prography.kagongsillok.place.domain.Place;
import org.prography.kagongsillok.place.domain.PlaceRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@RequiredArgsConstructor
public class BookmarkToggleSwitch {

private final BookmarkRepository bookmarkRepository;
private final PlaceRepository placeRepository;

@Transactional
public void toggle(final Long placeId, final Long memberId) {
final Optional<Bookmark> bookmark
= bookmarkRepository.findByPlaceIdAndMemberIdAndIsDeletedFalse(placeId, memberId);

bookmark.ifPresentOrElse(this::unbookmark, () -> bookmark(placeId, memberId));
}

private void unbookmark(final Bookmark bookmark) {
bookmark.delete();
placeRepository.findById(bookmark.getPlaceId())
.ifPresent(Place::decreaseBookmarkCount);
}

private void bookmark(final Long placeId, final Long memberId) {
final Bookmark bookmark = new Bookmark(placeId, memberId);
bookmarkRepository.save(bookmark);
placeRepository.findById(bookmark.getPlaceId())
.ifPresent(Place::increaseBookmarkCount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.prography.kagongsillok.bookmark.ui;

import lombok.RequiredArgsConstructor;
import org.prography.kagongsillok.bookmark.application.BookmarkService;
import org.prography.kagongsillok.bookmark.ui.dto.BookmarkToggleRequest;
import org.prography.kagongsillok.common.resolver.LoginMember;
import org.prography.kagongsillok.common.resolver.dto.LoginMemberDto;
import org.prography.kagongsillok.common.web.dto.CommonResponse;
import org.springframework.http.ResponseEntity;
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.RestController;

@RestController
@RequestMapping("/api/v1/bookmark")
@RequiredArgsConstructor
public class BookmarkV1Controller {

private final BookmarkService bookmarkService;

@PostMapping("/toggle")
public ResponseEntity<CommonResponse<Void>> toggle(
@LoginMember final LoginMemberDto loginMemberDto,
@RequestBody final BookmarkToggleRequest request
) {
bookmarkService.toggle(request.getPlaceId(), loginMemberDto.getMemberId());
return CommonResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.prography.kagongsillok.bookmark.ui.dto;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class BookmarkToggleRequest {

private Long placeId;

public BookmarkToggleRequest(final Long placeId) {
this.placeId = placeId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

private final long MAX_AGE = 3600;
private static final long MAX_AGE = 3600L;

private final AccessTokenArgumentResolver accessTokenArgumentResolver;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.prography.kagongsillok.common.exception;

public class CanNotProceedException extends BusinessException {

public CanNotProceedException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public class AccessTokenArgumentResolver implements HandlerMethodArgumentResolve

@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.getParameterType().equals(LoginMemberDto.class);
return parameter.getParameterType().equals(LoginMemberDto.class)
&& parameter.hasParameterAnnotation(LoginMember.class);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.prography.kagongsillok.common.resolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
public @interface LoginMember {

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
public class AccessTokenAuthenticationException extends AuthenticationException {

public AccessTokenAuthenticationException() {
super(String.format("Header에 AccessToken이 존재하지 않습니다."));
super("Header에 AccessToken이 존재하지 않습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.prography.kagongsillok.auth.application.exception.AuthenticationException;
import org.prography.kagongsillok.common.exception.CanNotProceedException;
import org.prography.kagongsillok.common.exception.CommonSecurityException;
import org.prography.kagongsillok.common.exception.InvalidParamException;
import org.prography.kagongsillok.common.exception.NotFoundException;
Expand Down Expand Up @@ -41,9 +42,16 @@ public ResponseEntity<CommonResponse<Void>> handleNotExistException(final NotFou
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(CommonResponse.error(e.getMessage()));
}

@ExceptionHandler(CanNotProceedException.class)
public ResponseEntity<CommonResponse<Void>> handleCanNotProceed(final CanNotProceedException e) {
log.warn(e.getMessage());
return ResponseEntity.badRequest().body(CommonResponse.error(e.getMessage()));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<CommonResponse<Void>> unhandledException(final Exception e) {
log.error(e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(CommonResponse.error(e.getMessage()));
.body(CommonResponse.error("알 수 없는 예외 발생"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public static <T> ResponseEntity<CommonResponse<T>> success(final T data) {
return ResponseEntity.ok(new CommonResponse<>(SUCCESS_MESSAGE, null, data));
}

public static ResponseEntity<CommonResponse<Void>> success() {
return ResponseEntity.ok(new CommonResponse<>(SUCCESS_MESSAGE, null, null));
}

public static CommonResponse<Void> error(final String message) {
return new CommonResponse<>(ERROR_MESSAGE, message, null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.prography.kagongsillok.member.ui;

import lombok.RequiredArgsConstructor;
import org.prography.kagongsillok.common.resolver.LoginMember;
import org.prography.kagongsillok.common.resolver.dto.LoginMemberDto;
import org.prography.kagongsillok.common.web.dto.CommonResponse;
import org.prography.kagongsillok.member.application.MemberService;
Expand All @@ -19,7 +20,7 @@ public class MemberV1Controller {
private final MemberService memberService;

@GetMapping
public ResponseEntity<CommonResponse<MemberResponse>> getMember(LoginMemberDto loginMemberDto) {
public ResponseEntity<CommonResponse<MemberResponse>> getMember(@LoginMember LoginMemberDto loginMemberDto) {
final MemberDto memberDto = memberService.getMember(loginMemberDto);
return CommonResponse.success(MemberResponse.from(memberDto));
}
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/org/prography/kagongsillok/place/domain/Place.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import org.prography.kagongsillok.common.entity.AbstractRootEntity;
import org.prography.kagongsillok.common.exception.CanNotProceedException;
import org.prography.kagongsillok.common.utils.CustomListUtils;
import org.prography.kagongsillok.common.utils.CustomStringUtils;

Expand Down Expand Up @@ -47,6 +48,8 @@ public class Place extends AbstractRootEntity {
@Embedded
private BusinessHours businessHours;

private Integer bookmarkCount;

@Builder
public Place(
final String name,
Expand All @@ -67,6 +70,7 @@ public Place(
this.phone = phone;
this.links = Links.of(links);
this.businessHours = BusinessHours.of(businessHours);
this.bookmarkCount = 0;
}

public void update(final Place target) {
Expand All @@ -80,6 +84,17 @@ public void update(final Place target) {
this.businessHours.update(target.businessHours);
}

public void increaseBookmarkCount() {
bookmarkCount++;
}

public void decreaseBookmarkCount() {
if (bookmarkCount == 0) {
throw new CanNotProceedException("북마크 수가 0보다 낮을 수 없습니다.");
}
bookmarkCount--;
}

public List<Long> getImageIds() {
return CustomStringUtils.splitToList(imageIds, ",", Long::valueOf);
}
Expand Down
Loading
Loading