-
Notifications
You must be signed in to change notification settings - Fork 79
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
[1단계 - DB 복제와 캐시] 테니(나아연) 미션 제출합니다. #63
base: ay-eonii
Are you sure you want to change the base?
Changes from all commits
4d3908a
1f5735f
c323363
285a2a0
835a8bc
cc57c8c
f9cda40
566445b
ac2a19a
5e3ca24
8c1f260
492dedf
6a83ca2
1682c48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,3 @@ | ||
README.md | ||
.gradle | ||
build/ | ||
!gradle/wrapper/gradle-wrapper.jar | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
## 기능 요구 사항 | ||
|
||
### 도메인 | ||
|
||
- [x] 쿠폰 | ||
- [x] 이름 | ||
- 최대 30자 | ||
- [ ] 할인 | ||
- [x] 할인 금액 | ||
- 할인 금액은 1,000원 이상 | ||
- 할인 금액은 10,000원 이하 | ||
- 할인 금액은 500원 단위 | ||
- [ ] 할인율 | ||
- 할인율은 `할인금액 / 최소 주문 금액`, 소수점은 버림 | ||
- 할인율은 3% 이상 | ||
- 할인율은 20% 이하 | ||
- [x] 최소 주문 금액 | ||
- 최소 주문 금액은 5,000원 이상 | ||
- 최소 주문 금액은 100,000원 이하 | ||
- [ ] 카테고리 | ||
- 패션, 가전, 가구, 식품 | ||
- [ ] 발급 기간 | ||
- 시작일은 종료일보다 이전 | ||
- 시작일 00:00:00.000000 부터 발급 | ||
- 종료일 23:59:59.999999 까지 발급 | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
package coupon.config; | ||
|
||
import org.springframework.boot.context.properties.ConfigurationProperties; | ||
import org.springframework.boot.jdbc.DataSourceBuilder; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.context.annotation.Primary; | ||
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; | ||
|
||
import javax.sql.DataSource; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
|
||
@Configuration | ||
public class DataSourceConfig { | ||
|
||
@Bean | ||
@ConfigurationProperties(prefix = "coupon.datasource.writer") | ||
public DataSource writerDataSource() { | ||
return DataSourceBuilder.create().build(); | ||
} | ||
|
||
@Bean | ||
@ConfigurationProperties(prefix = "coupon.datasource.reader") | ||
public DataSource readerDataSource() { | ||
return DataSourceBuilder.create().build(); | ||
} | ||
|
||
@Bean | ||
public DataSource routingDataSource(DataSource writerDataSource, DataSource readerDataSource) { | ||
ReadOnlyDataSourceRouter routingDataSource = new ReadOnlyDataSourceRouter(); | ||
Map<Object, Object> targetDataSources = new HashMap<>(); | ||
targetDataSources.put(DataSourceType.WRITER, writerDataSource); | ||
targetDataSources.put(DataSourceType.READER, readerDataSource); | ||
routingDataSource.setTargetDataSources(targetDataSources); | ||
routingDataSource.setDefaultTargetDataSource(writerDataSource); | ||
return routingDataSource; | ||
} | ||
|
||
@Primary | ||
@Bean | ||
public DataSource dataSource() { | ||
return new LazyConnectionDataSourceProxy(routingDataSource(writerDataSource(), readerDataSource())); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package coupon.config; | ||
|
||
public enum DataSourceType { | ||
|
||
READER, WRITER | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
package coupon.config; | ||
|
||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; | ||
import org.springframework.transaction.support.TransactionSynchronizationManager; | ||
|
||
@Slf4j | ||
public class ReadOnlyDataSourceRouter extends AbstractRoutingDataSource { | ||
|
||
@Override | ||
protected Object determineCurrentLookupKey() { | ||
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { | ||
log.info("Reader is running"); | ||
return DataSourceType.READER; | ||
} | ||
log.info("Writer is running"); | ||
return DataSourceType.WRITER; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package coupon.domain; | ||
|
||
public class Coupon { | ||
|
||
private final Long id; | ||
private final DiscountAmount discountAmount; | ||
private final MinimumOrderPrice minimumOrderPrice; | ||
|
||
public Coupon(int discountAmount, int minimumOrderPrice) { | ||
this(null, discountAmount, minimumOrderPrice); | ||
} | ||
|
||
public Coupon(Long id, int discountAmount, int minimumOrderPrice) { | ||
this.id = id; | ||
this.discountAmount = new DiscountAmount(discountAmount); | ||
this.minimumOrderPrice = new MinimumOrderPrice(minimumOrderPrice); | ||
} | ||
|
||
public Long getId() { | ||
return id; | ||
} | ||
|
||
public int getDiscountAmount() { | ||
return discountAmount.getAmount(); | ||
} | ||
|
||
public int getMinimumOrderPrice() { | ||
return minimumOrderPrice.getAmount(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
package coupon.domain; | ||
|
||
public class DiscountAmount { | ||
|
||
private static final int MIN_DISCOUNT_AMOUNT_RANGE = 1000; | ||
private static final int MAX_DISCOUNT_AMOUNT_RANGE = 10000; | ||
private static final int DISCOUNT_AMOUNT_UNIT = 500; | ||
|
||
private final int amount; | ||
|
||
public DiscountAmount(int amount) { | ||
validateRange(amount); | ||
validateUnit(amount); | ||
this.amount = amount; | ||
} | ||
|
||
private void validateRange(int amount) { | ||
if (amount < MIN_DISCOUNT_AMOUNT_RANGE || amount > MAX_DISCOUNT_AMOUNT_RANGE) { | ||
throw new IllegalArgumentException("유효하지 않은 할인 금액입니다."); | ||
} | ||
} | ||
|
||
private void validateUnit(int amount) { | ||
if (amount % DISCOUNT_AMOUNT_UNIT != 0) { | ||
throw new IllegalArgumentException("유효하지 않은 할인 금액 단위입니다."); | ||
} | ||
} | ||
|
||
public int getAmount() { | ||
return amount; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
package coupon.domain; | ||
|
||
public class MinimumOrderPrice { | ||
|
||
private static final int MIN_AMOUNT = 5000; | ||
private static final int MAX_AMOUNT = 100_000; | ||
|
||
private final int amount; | ||
|
||
public MinimumOrderPrice(int amount) { | ||
validateAmount(amount); | ||
this.amount = amount; | ||
} | ||
|
||
private void validateAmount(int amount) { | ||
if (amount < MIN_AMOUNT || amount > MAX_AMOUNT) { | ||
throw new IllegalArgumentException("유효하지 않은 최소 주문 금액입니다."); | ||
} | ||
} | ||
|
||
public int getAmount() { | ||
return amount; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
package coupon.domain; | ||
|
||
public class Name { | ||
|
||
private static final int MAX_NAME_LENGTH = 30; | ||
|
||
private final String name; | ||
|
||
public Name(String name) { | ||
validateLength(name); | ||
this.name = name; | ||
} | ||
|
||
private void validateLength(String name) { | ||
if (name.length() > MAX_NAME_LENGTH) { | ||
throw new IllegalArgumentException("최대 이름 길이를 초과합니다."); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
package coupon.entity; | ||
|
||
import coupon.domain.Coupon; | ||
import jakarta.persistence.Entity; | ||
import jakarta.persistence.GeneratedValue; | ||
import jakarta.persistence.GenerationType; | ||
import jakarta.persistence.Id; | ||
import jakarta.persistence.Table; | ||
import lombok.AccessLevel; | ||
import lombok.NoArgsConstructor; | ||
|
||
@Entity | ||
@NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
@Table(name = "coupon") | ||
public class CouponEntity { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 도메인과 엔티티를 분리하셨네요! |
||
|
||
@Id | ||
@GeneratedValue(strategy = GenerationType.IDENTITY) | ||
private Long id; | ||
private int discountAmount; | ||
private int minimumOrderPrice; | ||
|
||
public CouponEntity(int discountAmount, int minimumOrderPrice) { | ||
this.discountAmount = discountAmount; | ||
this.minimumOrderPrice = minimumOrderPrice; | ||
} | ||
|
||
public static CouponEntity from(int discountAmount, int minimumOrderPrice) { | ||
return new CouponEntity(discountAmount, minimumOrderPrice); | ||
} | ||
|
||
public Coupon toCoupon() { | ||
return new Coupon(id, discountAmount, minimumOrderPrice); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package coupon.repository; | ||
|
||
import coupon.entity.CouponEntity; | ||
import org.springframework.data.jpa.repository.JpaRepository; | ||
|
||
public interface CouponRepository extends JpaRepository<CouponEntity, Long> { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
package coupon.service; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드 단에서의 복제 지연 방지를 택하셨네요! |
||
|
||
|
||
import coupon.domain.Coupon; | ||
import coupon.entity.CouponEntity; | ||
import coupon.repository.CouponRepository; | ||
import coupon.support.TransactionSupporter; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
public class CouponService { | ||
|
||
private final CouponRepository couponRepository; | ||
private final TransactionSupporter transactionSupporter; | ||
|
||
public Coupon create(Coupon coupon) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
CouponEntity couponEntity = CouponEntity.from(coupon.getDiscountAmount(), coupon.getMinimumOrderPrice()); | ||
CouponEntity savedCoupon = couponRepository.save(couponEntity); | ||
return savedCoupon.toCoupon(); | ||
} | ||
|
||
@Transactional(readOnly = true) | ||
public Coupon getCoupon(long id) { | ||
CouponEntity couponEntity = couponRepository.findById(id) | ||
.orElse(getCouponRetry(id)); | ||
|
||
return couponEntity.toCoupon(); | ||
} | ||
|
||
private CouponEntity getCouponRetry(long id) { | ||
return transactionSupporter.executeNewTransaction(() -> couponRepository.findById(id) | ||
.orElseThrow(IllegalArgumentException::new)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package coupon.support; | ||
|
||
import org.springframework.stereotype.Component; | ||
import org.springframework.transaction.annotation.Propagation; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
import java.util.function.Supplier; | ||
|
||
@Component | ||
public class TransactionSupporter { | ||
|
||
@Transactional(propagation = Propagation.REQUIRES_NEW) | ||
public <T> T executeNewTransaction(Supplier<T> method) { | ||
return method.get(); | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package coupon; | ||
|
||
import coupon.domain.Coupon; | ||
import coupon.service.CouponService; | ||
import org.junit.jupiter.api.Test; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.boot.test.context.SpringBootTest; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
|
||
@SpringBootTest | ||
public class CouponServiceTest { | ||
|
||
@Autowired | ||
private CouponService couponService; | ||
|
||
@Test | ||
void 복제지연테스트() { | ||
Coupon coupon = couponService.create(new Coupon(1000, 10000)); | ||
Coupon savedCoupon = couponService.getCoupon(coupon.getId()); | ||
assertThat(savedCoupon).isNotNull(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package coupon.domain; | ||
|
||
import org.junit.jupiter.api.DisplayName; | ||
import org.junit.jupiter.params.ParameterizedTest; | ||
import org.junit.jupiter.params.provider.ValueSource; | ||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
||
class DiscountAmountTest { | ||
|
||
@DisplayName("할인 금액의 범위를 넘거가면 예외가 발생한다.") | ||
@ParameterizedTest | ||
@ValueSource(ints = {500, 10500}) | ||
void throwException_outRange(int amount) { | ||
assertThatThrownBy(() -> new DiscountAmount(amount)) | ||
.isInstanceOf(IllegalArgumentException.class) | ||
.hasMessage("유효하지 않은 할인 금액입니다."); | ||
} | ||
|
||
@DisplayName("유효하지 않은 단위의 할인금액인 경우 예외가 발생한다.") | ||
@ParameterizedTest | ||
@ValueSource(ints = {1001, 9999}) | ||
void throwException_invalidUnit(int amount) { | ||
assertThatThrownBy(() -> new DiscountAmount(amount)) | ||
.isInstanceOf(IllegalArgumentException.class) | ||
.hasMessage("유효하지 않은 할인 금액 단위입니다."); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
package coupon.domain; | ||
|
||
import org.junit.jupiter.api.DisplayName; | ||
import org.junit.jupiter.params.ParameterizedTest; | ||
import org.junit.jupiter.params.provider.ValueSource; | ||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
||
class MinimumOrderPriceTest { | ||
|
||
@DisplayName("유효하지 않은 최소 주문 금액의 경우 예외가 발생한다.") | ||
@ParameterizedTest | ||
@ValueSource(ints = {4999, 100001}) | ||
void throwException_invalidAmount(int amount) { | ||
assertThatThrownBy(() -> new MinimumOrderPrice(amount)) | ||
.isInstanceOf(IllegalArgumentException.class) | ||
.hasMessage("유효하지 않은 최소 주문 금액입니다."); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아직 체크 안된 요구사항은 반영 예정인 것인가요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
다음 요구사항에 필요하다면 점진적으로 추가할 예정입니다 ㅎㅎ..