-
Notifications
You must be signed in to change notification settings - Fork 0
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
쿠폰을 잘못 발급해서 일부 쿠폰은 바로 소멸시키고 새로운 쿠폰으로 교체한다. #28
The head ref may contain hidden characters: "4-\uCFE0\uD3F0\uC744-\uC798\uBABB-\uBC1C\uAE09\uD574\uC11C-\uC77C\uBD80-\uCFE0\uD3F0\uC740-\uBC14\uB85C-\uC18C\uBA78\uC2DC\uD0A4\uACE0-\uC0C8\uB85C\uC6B4-\uCFE0\uD3F0\uC73C\uB85C-\uAD50\uCCB4\uD558\uACE0-\uC2F6\uC5B4\uC84C\uC74C"
Changes from all commits
33398b3
5e6b118
5ea6a79
be706f9
47b8694
8b59b10
4e148c6
b995625
e0aa221
f9fb098
1fc1aad
564c449
52d806c
53a7812
67f8942
5d6907c
e8b187a
c2a3618
4113d8b
4890c6d
dc65c7c
2be83c1
bc9ba0e
4dc167b
1e69f63
bd74ac8
b7bc1ce
81c399d
20ed30f
4cb4c01
6aaffaf
973e6c9
5406ec3
d38f5a3
4d1da98
f1cb57b
d4341ca
67e8b6f
166d0ef
e5f45ef
39fba20
1d270e9
b5ad69b
cbe1b47
bf14ae2
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 |
---|---|---|
@@ -0,0 +1,17 @@ | ||
|
||
### 배치 실행 명령어 | ||
|
||
```shell | ||
export PROD_DATASOURCE_URL=jdbc:mysql://{DB_HOST}:{DP_PORT}/batch?serverTimezone=Asia/Seoul | ||
export PROD_DATASOURCE_USERNAME={USERNAME} | ||
export PROD_DATASOURCE_PASSWORD={PASSWORD} | ||
|
||
java -jar batch-coupon-exchang/build/libs/batch-coupon-exchang-1.0-SNAPSHOT.jar \ | ||
--spring.profiles.active=prod \ | ||
name='100% 할인 보상 쿠폰' \ | ||
description='배송 오류로 인한 쿠폰 교환' \ | ||
amountType='RATE' \ | ||
amount=100 \ | ||
expiredCouponId=13 | ||
``` | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
group = "com.example.estdelivery" | ||
version = "1.0-SNAPSHOT" | ||
|
||
tasks.withType<Jar> { | ||
enabled = true | ||
} | ||
|
||
repositories { | ||
mavenCentral() | ||
} | ||
|
||
dependencies { | ||
runtimeOnly("com.mysql:mysql-connector-j") | ||
implementation("org.springframework.boot:spring-boot-starter-batch") | ||
testImplementation("org.springframework.batch:spring-batch-test") | ||
testImplementation("org.springframework.boot:spring-boot-testcontainers") | ||
implementation("org.springframework.cloud:spring-cloud-starter-openfeign") | ||
testImplementation("org.testcontainers:mysql") | ||
testImplementation("org.testcontainers:junit-jupiter") | ||
testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter-kotlin:1.0.16") | ||
} | ||
|
||
tasks.test { | ||
useJUnitPlatform() | ||
} | ||
|
||
kotlin { | ||
jvmToolchain(17) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
package com.example.estdelivery | ||
|
||
import javax.sql.DataSource | ||
import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration | ||
import org.springframework.context.annotation.Configuration | ||
import org.springframework.transaction.PlatformTransactionManager | ||
|
||
|
||
/** | ||
* datasource 를 빈으로 등록하고 EnableBatchProcessing 애너테이션을 사용하면 순환 의존성으로 인해 datasource 를 외부에서 주입받기 어렵다. | ||
* DefaultBatchConfiguration 로 의존성을 주입하자. | ||
*/ | ||
@Configuration | ||
class BatchConfiguration( | ||
private val dataSource: DataSource, | ||
private val transactionManager: PlatformTransactionManager, | ||
) : DefaultBatchConfiguration() { | ||
override fun getDataSource(): DataSource { | ||
return dataSource | ||
} | ||
|
||
override fun getTransactionManager(): PlatformTransactionManager { | ||
return transactionManager | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package com.example.estdelivery | ||
|
||
import org.springframework.boot.context.properties.ConfigurationProperties | ||
|
||
@ConfigurationProperties(prefix = "batch.chunk") | ||
data class ChunkSizeProperty( | ||
val commitSize: Int, | ||
val processSize: Int, | ||
val writeSize: Int, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package com.example.estdelivery | ||
|
||
import org.springframework.boot.autoconfigure.SpringBootApplication | ||
import org.springframework.boot.context.properties.EnableConfigurationProperties | ||
import org.springframework.boot.runApplication | ||
|
||
@SpringBootApplication | ||
@EnableConfigurationProperties( | ||
ChunkSizeProperty::class | ||
) | ||
class CouponExchangeApplication | ||
|
||
fun main(args: Array<String>) { | ||
runApplication<CouponExchangeApplication>(*args) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package com.example.estdelivery | ||
|
||
import org.springframework.cloud.openfeign.EnableFeignClients | ||
import org.springframework.context.annotation.Configuration | ||
import org.springframework.context.annotation.Profile | ||
|
||
@Configuration | ||
@Profile("!test") | ||
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. 테스트를 위한 소스 코드 없애기 |
||
@EnableFeignClients | ||
class FeignClientConfig |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package com.example.estdelivery | ||
|
||
import org.springframework.context.annotation.Configuration | ||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories | ||
|
||
@Configuration | ||
@EnableJpaRepositories( | ||
basePackages = ["com.example.estdelivery.job.step.service.repository"] | ||
) | ||
class JpaConfiguration |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
package com.example.estdelivery | ||
|
||
import org.springframework.batch.core.explore.JobExplorer | ||
import org.springframework.batch.core.launch.JobLauncher | ||
import org.springframework.batch.core.repository.JobRepository | ||
import org.springframework.boot.autoconfigure.batch.BatchProperties | ||
import org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner | ||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean | ||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty | ||
import org.springframework.boot.context.properties.EnableConfigurationProperties | ||
import org.springframework.context.annotation.Bean | ||
import org.springframework.context.annotation.Configuration | ||
import org.springframework.util.StringUtils | ||
|
||
/** | ||
* CommandLineRunner 를 이용하면 spring boot 가 제공하는 기본 기능을 이용할 수 없다. | ||
* 예를 들어 JPA 관련 설정 DB 관련 설정이다. 이런 auto configure 가 필요없다면 해당 클래스를 직접 생성하지 않아도 된다. | ||
*/ | ||
@Configuration | ||
@EnableConfigurationProperties(BatchProperties::class) | ||
class RunnerConfiguration { | ||
|
||
@Bean | ||
@ConditionalOnMissingBean | ||
@ConditionalOnProperty(prefix = "spring.batch.job", name = ["enabled"]) | ||
fun jobLauncherApplicationRunner( | ||
jobLauncher: JobLauncher, | ||
jobExplorer: JobExplorer, | ||
jobRepository: JobRepository, | ||
properties: BatchProperties | ||
): JobLauncherApplicationRunner { | ||
val runner = JobLauncherApplicationRunner(jobLauncher, jobExplorer, jobRepository) | ||
val jobNames = properties.job.name | ||
if (StringUtils.hasText(jobNames)) { | ||
runner.setJobName(jobNames) | ||
} | ||
return runner | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package com.example.estdelivery.client | ||
|
||
interface AlimTalkClient { | ||
fun sendAlimTalk(alimTalkRequest: AlimTalkRequest) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package com.example.estdelivery.client | ||
|
||
import org.springframework.cloud.openfeign.FeignClient | ||
import org.springframework.web.bind.annotation.PostMapping | ||
import org.springframework.web.bind.annotation.RequestBody | ||
|
||
|
||
@FeignClient( | ||
name = "alim", | ||
url = "http://localhost:8090" | ||
) | ||
interface AlimTalkFeignClient : AlimTalkClient { | ||
@PostMapping("/alim") | ||
override fun sendAlimTalk(@RequestBody alimTalkRequest: AlimTalkRequest) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package com.example.estdelivery.client | ||
|
||
data class AlimTalkRequest( | ||
val sender: String, | ||
val message: String, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package com.example.estdelivery.client | ||
|
||
interface MemberClient{ | ||
fun findMemberById(memberId: Long): MemberResponse | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
package com.example.estdelivery.client | ||
|
||
import org.springframework.cloud.openfeign.FeignClient | ||
import org.springframework.web.bind.annotation.GetMapping | ||
import org.springframework.web.bind.annotation.PathVariable | ||
|
||
@FeignClient( | ||
name = "member", | ||
url = "http://localhost:8081" | ||
) | ||
interface MemberFeignClient : MemberClient { | ||
@GetMapping("/members/{memberId}") | ||
override fun findMemberById(@PathVariable memberId: Long): MemberResponse | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
package com.example.estdelivery.client | ||
|
||
import com.example.estdelivery.domain.PhoneNumber | ||
import com.example.estdelivery.domain.UserName | ||
|
||
data class MemberResponse( | ||
val phone: PhoneNumber, | ||
val name: UserName, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
package com.example.estdelivery.domain | ||
|
||
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.NamedQuery | ||
import jakarta.persistence.Table | ||
|
||
@Entity | ||
@NamedQuery( | ||
name = "couponFinaAll", | ||
query = "SELECT c FROM Coupon c" | ||
) | ||
@Table(name = "coupon", catalog = "coupon") | ||
class Coupon( | ||
val name: String, | ||
val description: String, | ||
@Enumerated(EnumType.STRING) | ||
val amountType: CouponStateAmountType, | ||
@Enumerated(EnumType.STRING) | ||
val type: CouponStateType, | ||
val amount: Int, | ||
@Id | ||
@GeneratedValue(strategy = GenerationType.IDENTITY) | ||
val id: Long = 0, | ||
) { | ||
override fun equals(other: Any?): Boolean { | ||
if (this === other) return true | ||
if (other !is Coupon) return false | ||
|
||
if (id != other.id) return false | ||
|
||
return true | ||
} | ||
|
||
override fun hashCode(): Int { | ||
return id.hashCode() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
package com.example.estdelivery.domain | ||
|
||
data class CouponExchangeNotificationTemplate( | ||
private val receivingPhoneNumber: PhoneNumber, | ||
private val receivingName: UserName, | ||
private val couponNameBeforeExchange: String, | ||
private val couponNameAfterExchange: String, | ||
) { | ||
val sendMessage: String | ||
get(): String = """ | ||
[이스트딜리버리] | ||
${receivingName}님, 보관중인 쿠폰에 문제가 생겨 다음과 같이 변경됩니다. | ||
|
||
사유 : ${receivingName}님의 쿠폰이 만료되었습니다. | ||
변경 전 쿠폰 : $couponNameBeforeExchange | ||
변경 후 쿠폰 : $couponNameAfterExchange | ||
""".trimIndent() | ||
|
||
val sender: String | ||
get(): String = receivingPhoneNumber.number | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package com.example.estdelivery.domain | ||
|
||
enum class CouponStateAmountType { | ||
RATE, | ||
FIX, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package com.example.estdelivery.domain | ||
|
||
enum class CouponStateType { | ||
PUBLISHED, | ||
HANDOUT, | ||
EVENT, | ||
EXPIRED, | ||
REWARD, | ||
; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package com.example.estdelivery.domain | ||
|
||
class ExchangeCoupon( | ||
val owner: UserName, | ||
val ownerPhone: PhoneNumber, | ||
val memberCoupon: MemberCoupon, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package com.example.estdelivery.domain | ||
|
||
import jakarta.persistence.Entity | ||
import jakarta.persistence.GeneratedValue | ||
import jakarta.persistence.GenerationType | ||
import jakarta.persistence.Id | ||
import jakarta.persistence.Table | ||
|
||
@Entity | ||
@Table(name = "exchange_coupon_history", catalog = "coupon") | ||
class ExchangeCouponHistory( | ||
val expiredCouponId: Long, | ||
val rewardCouponId: Long, | ||
val jobExecutionId: Long, | ||
@Id | ||
@GeneratedValue(strategy = GenerationType.IDENTITY) | ||
val id: Long = 0, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
package com.example.estdelivery.domain | ||
|
||
import com.example.estdelivery.domain.MemberCouponUseState.UNUSED | ||
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.NamedQuery | ||
import jakarta.persistence.Table | ||
|
||
const val UNUSED_MEMBER_COUPON_FIND_ALL = "unusedMemberCouponFinaAll" | ||
|
||
@Entity | ||
@NamedQuery( | ||
name = UNUSED_MEMBER_COUPON_FIND_ALL, | ||
query = """ | ||
SELECT c | ||
FROM MemberCoupon c | ||
WHERE c.couponId = :couponId | ||
""" | ||
) | ||
@Table(name = "member_coupon", catalog = "coupon") | ||
class MemberCoupon( | ||
val couponId: Long, | ||
val memberId: Long, | ||
@Enumerated(EnumType.STRING) | ||
var status: MemberCouponUseState = UNUSED, | ||
@Id | ||
@GeneratedValue(strategy = GenerationType.IDENTITY) | ||
val id: Long = 0, | ||
) { | ||
override fun equals(other: Any?): Boolean { | ||
if (this === other) return true | ||
if (other !is MemberCoupon) return false | ||
|
||
if (other.id == 0L) return false | ||
if (id != other.id) return false | ||
|
||
return true | ||
} | ||
|
||
override fun hashCode(): Int { | ||
return id.hashCode() | ||
} | ||
|
||
fun exchange(exchangedCouponId: Long): MemberCoupon { | ||
return MemberCoupon( | ||
couponId = exchangedCouponId, | ||
memberId = memberId, | ||
id = id, | ||
) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
package com.example.estdelivery.domain | ||
|
||
enum class MemberCouponUseState { | ||
USED, | ||
UNUSED, | ||
; | ||
} |
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.
아래에서 확인했듯이 버전으로 실행 이력을 관리한다. Job 정보가 Serializable하게 생성되면 Job 식별자에 맞게 개별적으로 Step 실행 정보가 생성된다.
Step에서 청크 사이즈나 병렬 처리에도 정상적인 데이터를 유지할 수 있는 이유는 Step에서 실행되는 버전 정보 덕분이다. 커밋 단위로 버전이 업데이트되므로 순차적으로 Step 실행 이력을 저장할 수 있다. 일반적으로 Step의
커밋 수 + 2
가 버전 정보다. 아마 처음 실행되면서+1
마지막 종료되면서+1
로 추가 카운팅이 되는 것처럼 보인다.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.
⭐ 다시 확인해보니 트랜잭션을 걸고 있다. AbstractJobRepositoryFactoryBean 에서 getObject 메서드를 호출하는 시점에 트랜잭션을 걸게 된다. Job 정보는 JobRepository에 의해서 관리되고 JobRepository 내부 정보는 AbstractJobRepositoryFactoryBean 에서 getObject 로 호출해서 변경된다.