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

[1단계 - DB 복제와 캐시] 테니(나아연) 미션 제출합니다. #63

Open
wants to merge 14 commits into
base: ay-eonii
Choose a base branch
from
Open
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
README.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## 기능 요구 사항

### 도메인
Copy link
Member

Choose a reason for hiding this comment

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

아직 체크 안된 요구사항은 반영 예정인 것인가요?

Copy link
Author

Choose a reason for hiding this comment

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

다음 요구사항에 필요하다면 점진적으로 추가할 예정입니다 ㅎㅎ..


- [x] 쿠폰
- [x] 이름
- 최대 30자
- [ ] 할인
- [x] 할인 금액
- 할인 금액은 1,000원 이상
- 할인 금액은 10,000원 이하
- 할인 금액은 500원 단위
- [ ] 할인율
- 할인율은 `할인금액 / 최소 주문 금액`, 소수점은 버림
- 할인율은 3% 이상
- 할인율은 20% 이하
- [x] 최소 주문 금액
- 최소 주문 금액은 5,000원 이상
- 최소 주문 금액은 100,000원 이하
- [ ] 카테고리
- 패션, 가전, 가구, 식품
- [ ] 발급 기간
- 시작일은 종료일보다 이전
- 시작일 00:00:00.000000 부터 발급
- 종료일 23:59:59.999999 까지 발급


45 changes: 45 additions & 0 deletions src/main/java/coupon/config/DataSourceConfig.java
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()));
}
}
6 changes: 6 additions & 0 deletions src/main/java/coupon/config/DataSourceType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package coupon.config;

public enum DataSourceType {

READER, WRITER
}
19 changes: 19 additions & 0 deletions src/main/java/coupon/config/ReadOnlyDataSourceRouter.java
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;
}
}
30 changes: 30 additions & 0 deletions src/main/java/coupon/domain/Coupon.java
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();
}
}
32 changes: 32 additions & 0 deletions src/main/java/coupon/domain/DiscountAmount.java
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;
}
}
24 changes: 24 additions & 0 deletions src/main/java/coupon/domain/MinimumOrderPrice.java
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;
}
}
19 changes: 19 additions & 0 deletions src/main/java/coupon/domain/Name.java
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("최대 이름 길이를 초과합니다.");
}
}
}
35 changes: 35 additions & 0 deletions src/main/java/coupon/entity/CouponEntity.java
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 {
Copy link
Member

Choose a reason for hiding this comment

The 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);
}
}
7 changes: 7 additions & 0 deletions src/main/java/coupon/repository/CouponRepository.java
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> {
}
37 changes: 37 additions & 0 deletions src/main/java/coupon/service/CouponService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package coupon.service;
Copy link
Member

Choose a reason for hiding this comment

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

코드 단에서의 복제 지연 방지를 택하셨네요!
고려해주신 방법중 첫번째가 반동기 방식인데요,
비동기, 반동기, 동기 방식이 각각 db의 어느 작업 단위까지를 보장하는지 궁금해요!



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) {
Copy link
Member

Choose a reason for hiding this comment

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

@Transactional을 명시적으로 붙이지 않은 이유가 있을까요?

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));
}
}
17 changes: 17 additions & 0 deletions src/main/java/coupon/support/TransactionSupporter.java
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();
}
}

4 changes: 2 additions & 2 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ spring:
default_batch_fetch_size: 128
id.new_generator_mappings: true
format_sql: true
show_sql: false
show_sql: true
use_sql_comments: true
hbm2ddl.auto: validate
hbm2ddl.auto: update
check_nullability: true
query.in_clause_parameter_padding: true
open-in-view: false
Expand Down
23 changes: 23 additions & 0 deletions src/test/java/coupon/CouponServiceTest.java
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();
}
}
28 changes: 28 additions & 0 deletions src/test/java/coupon/domain/DiscountAmountTest.java
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("유효하지 않은 할인 금액 단위입니다.");
}
}
19 changes: 19 additions & 0 deletions src/test/java/coupon/domain/MinimumOrderPriceTest.java
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("유효하지 않은 최소 주문 금액입니다.");
}
}
Loading