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

[#8] 주문 기능을 구현한다. #18

Merged
merged 11 commits into from
Jun 26, 2023
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.redisson:redisson-spring-boot-starter:3.21.1'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'com.google.guava:guava:31.1-jre'
implementation 'org.apache.commons:commons-lang3:3.12.0'

// lombok
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
implementation 'junit:junit:4.13.2'
compileOnly 'org.projectlombok:lombok'

// Mapstruct
Expand All @@ -43,6 +46,9 @@ dependencies {
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-core:3.11.2'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}

tasks.named('test') {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/flab/goodchoice/GoodchoiceApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@EnableCaching
@SpringBootApplication
public class GoodchoiceApplication {

Expand Down
56 changes: 56 additions & 0 deletions src/main/java/com/flab/goodchoice/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.flab.goodchoice.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableRedisRepositories
public class RedisConfig {

@Value("${spring.redis.host}")
private String host;

@Value("${spring.redis.port}")
private int port;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}

@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
return redisTemplate;
}

@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext
.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext
.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.flab.goodchoice.global.config.aop;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

/**
* Redisson Distributed Lock annotation
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

/**
* 락의 이름
*/
String key();

/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;

/**
* 락을 기다리는 시간 (default - 5s)
* 락 획득을 위해 waitTime 만큼 대기한다
*/
long waitTime() default 5L;

/**
* 락 임대 시간 (default - 3s)
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
*/
long leaseTime() default 3L;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.flab.goodchoice.global.config.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class RedissonCallTransaction {

/**
* 부모트랜잭션의 유무와 관계없이 동시성에 대한 처리는 별도의 트랜잭션으로 동작하기 위함
* @param joinPoint
* @return
* @throws Throwable
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.flab.goodchoice.global.config.aop;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

@Value("${spring.redis.host}")
private String redisHost;

@Value("${spring.redis.port}")
private int redisPort;

private static final String REDISSON_HOST_PREFIX = "redis://";

@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.flab.goodchoice.global.config.aop;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAop {

private final RedissonClient redissonClient;

private final RedissonCallTransaction redissonCallTransaction;

@Around("@annotation(com.flab.goodchoice.global.config.aop.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

String key = this.createKey(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean isPossible = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!isPossible) {
return false;
}

log.info("Redisson Lock Key : {}", key);
return redissonCallTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock(); // (4)
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}");
}
}

}

/**
* Redisson Key Create
* @param parameterNames
* @param args
* @param key
* @return
*/
private String createKey(String[] parameterNames, Object[] args, String key) {
String resultKey = key;
for (int i = 0; i < parameterNames.length; i++) {
if (parameterNames[i].equals(key)) {
resultKey += args[i];
break;
}
}
return resultKey;
}

}
8 changes: 8 additions & 0 deletions src/main/java/com/flab/goodchoice/item/domain/Item.java
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,12 @@ public void changeEndOfSale() {
this.status = Status.END_OF_SALE;
}

public void decrease(Long itemStock) {
if (this.itemStock - itemStock < 0) {
throw new RuntimeException("수량이 부족합니다.");
}

this.itemStock = this.itemStock - itemStock;
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.flab.goodchoice.item.domain;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/flab/goodchoice/item/domain/ItemReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@
public interface ItemReader {
Item getItemBy(String itemToken);

Item getItemByPessimisticLock(String itemToken);

Item getItemByOptimisticLock(String itemToken);

List<Item> getItems();
}
55 changes: 55 additions & 0 deletions src/main/java/com/flab/goodchoice/item/domain/StockHistory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.flab.goodchoice.item.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.time.ZonedDateTime;

@Getter
@Entity
@NoArgsConstructor
public class StockHistory {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "id", nullable = false)
private Long id;

private String itemToken;

private Long userId;

private String orderToken;

private Long itemPrice;

@Enumerated(EnumType.STRING)
private Status status;

@CreationTimestamp
private ZonedDateTime createdAt;

@Getter
@RequiredArgsConstructor
public enum Status {
PLUS("재고사용량 증가"),
MINUS("재고사용량 감소");

private final String description;
}

@Builder
public StockHistory(String itemToken, Long userId, String orderToken, Long itemPrice) {
this.itemToken = itemToken;
this.userId = userId;
this.orderToken = orderToken;
this.itemPrice = itemPrice;
this.status = Status.PLUS;
this.createdAt = ZonedDateTime.now();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@ public Item getItemBy(String itemToken) {
.orElseThrow(EntityNotFoundException::new);
}

@Override
public Item getItemByPessimisticLock(String itemToken) {
return itemRepository.findByItemTokenWithPessimisticLock(itemToken)
.orElseThrow(EntityNotFoundException::new);
Copy link
Collaborator

Choose a reason for hiding this comment

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

나중에 ExceptionHandler를 통해서 응답에 메세지도 신경써주시면 좋을 것 같아요
실제 API를 제공한다고 했을때 사용자 또는 클라(FE, iOS, AOS) 쪽에서 메세지에 대한 핸들링이 필요 할 수 있으니 깐요

}

@Override
public Item getItemByOptimisticLock(String itemToken) {
return itemRepository.findByItemTokenWithPessimisticLock(itemToken)
.orElseThrow(EntityNotFoundException::new);
}

@Override
public List<Item> getItems() {
return itemRepository.findAll();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,22 @@

import com.flab.goodchoice.item.domain.Item;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

import javax.persistence.LockModeType;
import java.util.Optional;

public interface ItemRepository extends JpaRepository<Item, Long> {

Optional<Item> findByItemToken(String itemToken);

@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select i from Item i where i.itemToken=:itemToken")
Optional<Item> findByItemTokenWithPessimisticLock(String itemToken);

@Lock(value = LockModeType.OPTIMISTIC)
@Query("select i from Item i where i.itemToken=:itemToken")
Optional<Item> findByItemTokenWithOptimisticLock(String itemToken);

}
Loading