aws:
s3:
bucket:
name: test
cloudFrontBaseUrl: "https://cloudfront.url"
mock:
enabled: false
port: 8111
위와 같이 작성되어 있는 application property 는 사실 아래와 의미가 동일하다.
aws:
s3:
bucket:
name: test
cloudFrontBaseUrl: "https://cloudfront.url"
aws:
s3:
mock:
enabled: false
port: 8111
이 사실을 인지하지 못하고 있다가, 예상치 못한 오류를 만나 반나절을 고생했다.
아래는 spring + kotlin 환경에서 aws s3 client 를 주입받는 설정이 담긴 코드다.
@Configuration
@EnableConfigurationProperties(AwsS3Properties::class)
@ComponentScan(basePackages = ["com.package.path"])
class AwsS3Config(
private val awsS3Properties: AwsS3Properties,
) {
@ConditionalOnProperty(
prefix = "aws.s3.mock",
name = ["enabled"],
havingValue = "false",
matchIfMissing = true
)
@Bean
fun s3Client(): AmazonS3 =
AmazonS3ClientBuilder
.standard()
.withRegion(Regions.AP_NORTHEAST_2)
.build()
@Profile(value = ["local"])
@ConditionalOnProperty(
prefix = "aws.s3.mock",
name = ["enabled"],
havingValue = "true",
matchIfMissing = false
)
@Bean
fun mockS3Client(): AmazonS3 {
return AmazonS3ClientBuilder.standard()
.withPathStyleAccessEnabled(true)
.withEndpointConfiguration(
AwsClientBuilder.EndpointConfiguration(
"http://localhost:${awsS3Properties.mockS3.port}",
Regions.AP_NORTHEAST_2.getName()
)
)
.withCredentials(AWSStaticCredentialsProvider(AnonymousAWSCredentials()))
.build()
}
}
s3Client
bean 의 @ConditionalOnProperty
설정을 풀어 설명하면 아래와 같다.
prefix
: aws.s3.mock
prefix 인 property 의name
: enabled
이름의 설정이havingValue
: false
인 경우에 s3Client
bean 을 생성 후 주입한다.matchIfMissing
: 만약 해당되는 property 가 없어도 비교를 진행한다.
mockS3Client
bean 의 @ConditionalOnProperty
설정은 아래와 같다.
prefix
: aws.s3.mock
prefix 인 property 의name
: enabled
이름의 설정이havingValue
: true
인 경우에 mockS3Client
bean 을 생성 후 주입한다.matchIfMissing
: 만약 해당되는 property 가 없으면 bean 생성을 포기한다.또한 mockS3Client
bean 에는 @Profile(value = ["local"])
설정이 포함되어 있으므로,
local profile 로 실행될 때만 mockS3Client bean 주입을 시도한다.
결국 위 코드대로라면 local profile 에서는 mockS3Client
bean 을, 그 외 profile 에서는 실제 s3Client
bean 을 주입받게 된다.
기존 정상적으로 동작하는 상황에서 AwsS3Properties
를 통해 불러오는 property 값은 아래와 같았다.
aws:
s3:
bucket:
name: test
cloudFrontBaseUrl: "https://default.cloudfront.url"
---
spring.config.activate.on-profile: local
aws:
s3:
bucket:
name: inara-web-management-local
cloudFrontBaseUrl: "https://local.cloudfront.url"
mock:
enabled: true
port: 8111
---
spring.config.activate.on-profile: dev
aws:
s3:
bucket:
name: inara-web-management-dev
cloudFrontBaseUrl: "https://dev.cloudfront.url"
그런데 어느 순간부터 dev profile 의 spring application 이 실행에 실패하고 있었다.
expected at least 1 bean which qualifies as autowire candidate for this dependency. Dependency annotations: {}
dev profile 설정에 의해 s3Client
bean 이 정상적으로 로드되었어야했는데,
s3Client
bean 을 찾을 수 없다는 설정. 심지어는 mockS3Client
bean 도 만들지 못하고 있었다.
왜 이런 문제가 갑자기 발생했을까?
한참을 헤매다 application.yml 파일의 커밋로그를 살펴보니, 아래와 같이 변경사항이 추가되어 있었다.
aws:
s3:
bucket:
name: test
cloudFrontBaseUrl: "https://default.cloudfront.url"
mock: # 추가된 부분
enabled: true # 추가된 부분
port: 8111 # 추가된 부분
---
spring.config.activate.on-profile: local
aws:
s3:
bucket:
name: inara-web-management-local
cloudFrontBaseUrl: "https://local.cloudfront.url"
mock:
enabled: true
port: 8111
---
spring.config.activate.on-profile: dev
aws:
s3:
bucket:
name: inara-web-management-dev
cloudFrontBaseUrl: "https://dev.cloudfront.url"
오류가 발생하기 전까지만 해도, dev profile 의 spring application 은
application.yml 의 spring.config.activate.on-profile: dev
하위 영역만
spring application 이 읽고 해석을 진행할거라 생각중이었다.
그런데 application.yml 파일은 그 특성상, default 설정 외 profile 별로 직접 property 를 덮어씌우지(override) 않으면 default 설정을 그대로 따른다.
즉, dev profile 에서 인지한 aws.s3
설정은, default 값을 포함해서 아래와 같이 적용된다.
spring.config.activate.on-profile: dev
aws:
s3:
bucket:
name: inara-web-management-dev
cloudFrontBaseUrl: "https://dev.cloudfront.url"
aws:
s3:
mock:
enabled: true
port: 8111
@ConditionalOnProperty
설정에 의해서 s3Client
bean 을 만들 수도,
@Profile
설정에 의해 mockS3Client
bean 을 만들 수도 없어서 bean 예외를 발생시키며 application 실행에 실패한 것이다.
당연하게도 default profile 에 추가된 설정을 지우니 정상적으로 application 이 구동되었다.
사실 spring application properties 는 자신의 역할을 충실히 수행했을 뿐, 아무런 문제가 없다. 인간의 인지 부조화 문제로 생긴 문제일 뿐. 즉 인간이 헷갈리거나 실수할 수 있는 여지를 남겨 놓지 않으면 된다.
@ConditionalOnProperty
을 부정형으로 사용하지 않는다.현재는 s3Client
bean 의 @ConditionalOnProperty
가 mock s3 의 부정형으로 작성되어 있어 헷갈린다.
@ConditionalOnProperty(
prefix = "aws.s3.mock",
name = ["enabled"],
havingValue = "false",
matchIfMissing = true
)
@Bean
fun s3Client(): AmazonS3 =
AmazonS3ClientBuilder
.standard()
.withRegion(Regions.AP_NORTHEAST_2)
.build()
명확하게 s3Client bean 을 만들기 위한 property 를 명시하고, 그에 해당되는 값들이 존재할 때 bean 을 생성한다는 조건을 명시하자.
@ConditionalOnProperty(
prefix = "aws.s3.bucket",
name = ["enabled"],
havingValue = "true",
matchIfMissing = false
)
@Bean
fun s3Client(): AmazonS3 =
AmazonS3ClientBuilder
.standard()
.withRegion(Regions.AP_NORTHEAST_2)
.build()
local 환경에만 불필요하게 중복값이 오버라이드 되어있어 더 헷갈리고 인지에 오랜 시간이 소요되었다. 둘 중 하나의 방식을 채택하여 인지 부조화를 줄이자.
# 아예 모두가 하지 않거나
aws:
s3:
bucket:
name: test
cloudFrontBaseUrl: "https://default.cloudfront.url"
mock:
enabled: true
port: 8111
---
spring.config.activate.on-profile: local
aws:
s3:
bucket:
name: inara-web-management-local
cloudFrontBaseUrl: "https://local.cloudfront.url"
---
spring.config.activate.on-profile: dev
aws:
s3:
bucket:
name: inara-web-management-dev
cloudFrontBaseUrl: "https://dev.cloudfront.url"
# 할거면 모두가 하는 걸로
aws:
s3:
bucket:
name: test
cloudFrontBaseUrl: "https://default.cloudfront.url"
mock:
enabled: true
port: 8111
---
spring.config.activate.on-profile: local
aws:
s3:
bucket:
name: inara-web-management-local
cloudFrontBaseUrl: "https://local.cloudfront.url"
mock:
enabled: false
port: 8111
---
spring.config.activate.on-profile: dev
aws:
s3:
bucket:
name: inara-web-management-dev
cloudFrontBaseUrl: "https://dev.cloudfront.url"
mock:
enabled: false
port: 8111
혼자가 아닌 동료 개발자들과 함께 개발하는 환경에서는 어떤 일이 발생할지 예측하기 어렵다. ‘이정도는 괜찮겠지.’ 라는 안일한 생각으로 위험요소를 남겨두지 말자. 아예 실수할 여지를 없애버리자. 그게 진짜 실력이다.
관련해서 읽어보면 좋은 글이 하나 있어 링크를 남긴다. 실수할 여지를 주지않기
]]>no newline at a end of file
line number 를 보면 알 수 있듯, 파일 가장 끝에 개행이 포함되지 않았을 때 발생하는 오류 표기다. 실제로 프로그램을 실행시킬 땐 아무런 문제가 되지 않는데, 왜 GitHub 에서는 이런 오류 표기를 항상 해주는 걸까?
개행없이 22line 에서 끝나도 사실 문제는 없다.
결론부터 말하면 파일의 마지막에는 개행이 포함되어야 한다.
라는 POSIX(IEEE 에서 책정한 UNIX 인터페이스)을 만족 시키기 위함이다.
현재 프로그램이 동작하는 것에는 아무런 문제가 없으나, 잠재적으로 문제가 될 수 있기 때문에 GitHub 에서 미리 경고를 하는 것.
POSIX 에서 정의하는 “불완전한 행(끝나지 않은 행)” 의 기준은 아래와 같다.
3.195 Incomplete Line
A sequence of one or more non-<newline>
characters at the end of the file.
불완전한 행
파일의 끝에<newline>
이 아닌 문자가 포함되는 것.
그렇다면 온전히 마무리 지어진, 하나의 행으로 인정 받는 기준은 무엇일까?
3.206 Line
A sequence of zero or more non-<newline>
characters plus a terminating<newline>
character.
행
아무것도 적혀있지 않거나,<newline>
이 아닌 문자 이후 마지막에<newline>
문자가 포함되는 것.
즉 행의 가장 마지막에 개행문자(<newline>
)가 하나 포함되어야 온전히 하나의 행으로 인정된다.
그렇지 않을 경우 여전히 행에 입력이 진행중인 것으로 판단하며, 자연스럽게 현재 지점이 EOF(End Of File)인지 판별할 수 없게 된다.
(POSIX 에 근거하여 동작하는 컴파일러 등에서 EOF 를 인지하지 못해 비정상적인 동작 결과를 만들어낼 수 있다.)
GitHub 에서는 이러한 잠재적인 오류를 예방하고자 파일 끝에 개행이 없을 시 경고를 띄우는 것이다.
이렇듯 파일 마지막에 개행을 넣는 건 매우 중요한 일이지만, 매번 파일 끝에 개행을 신경써야 하는 것은 여간 귀찮다. 사람이 코드를 작성하는 이상 실수를 하기도 쉽다. 다행히 IntelliJ 를 포함한 여러 IDE 에서는 파일 끝에 개행을 자동으로 추가해주는 설정을 제공한다.
Editor
> General
> Ensure every saved file ends with a line break
를 체크하면, 파일을 생성하거나 저장 단축키(CMD
+ S
)를 누를 때마다 파일 마지막에 개행이 자동으로 적용된다.
이를 통해서 파일 끝에 개행을 신경쓰지 않고도 안심하고 코드를 작성할 수 있고, GitHub 의 “no newline at a end of file” 경고딱지로부터 해방될 수 있다.
어릴적 문방구 앞에서 100원 짜리 가챠(랜덤 뽑기)를 돌려본 경험이 있는가? 광고 포스터 속 캐릭터 피규어를 보면서 설레이는 마음에 100원이라는 거금을 투자했지만, 알사탕이 들어있는 캡슐이 데구르르 굴러 나올 때 그 절망감은 평생 잊을 수가 없겠다. 나는 이런 가챠류에서 항상 운이 좋지 않았기 때문에, 그다지 가챠 시스템을 선호하지 않는다.
우연히 업무중 “사용자들에게 캐릭터 카드 랜덤 뽑기 기능을 제공해보면 어떨까?” 라는 아이디어가 등장했고, 테스트를 위해 이를 구현해보게 되었다. 내가 가챠 시스템을 선호하지 않는 것과 별개로 직접 구현해보는 과정은 상당히 즐거웠기 때문에 이를 남겨볼까 한다.
1~5성 까지의 등급이 존재하는 것은 여타 TCG 카드 게임, 모바일 게임에서 흔하게 보았던 시스템이라 쉽게 이해하고 정의할 수 있다.
대략 이런 느낌일 것
고민이 많았던 부분이 바로 ‘투자 재화별 등장 확률 변경’ 인데, 초기에는 카드 별로 등장 확률을 관리해볼까 고민했다. 그러나 카드마다 확률을 관리하는 방법은 아래와 같은 이유로 불가능하다 판단하여 빠르게 포기하게 된다.
매번 전체 카드들의 확률을 재조정해야한다는 문제점을 차치하더라도, 뽑기 방법이 추가될 때마다 확률 관리가 불가능하다는 점이 가장 리스키했다.
결국 현실적으로 선택할 수 있는 방법은 뽑기 자체마다 확률을 별도로 관리하는 것이다. 다행스럽게도 ‘특정 카드의 등장 확률이 높아진다.’ 대신 ‘특정 등급에 해당하는 카드들의 등장 확률이 높아진다.’ 이므로, 보다 편리하게 로직을 구상해볼 수 있다.
위와 같이 총량을 100% 로 하고, 소모되는 재화별 뽑기의 확률을 달리하면 관리가 간편해진다. 만약 재화를 30개 소모해서 무조건 3~5성만 등장하도록 하는 뽑기를 만들고 싶다면 아래와 같은 확률표 관리도 가능해진다.
그렇다면 어떻게 각 등급별로 확률에 맞추어 카드가 등장하게 끔 만들 수 있을까? 현재 100% 를 나타내는 상태에서 1~100 까지의 숫자를 각 확률 별로 고르게 분포시키면 된다.
그리고 매 뽑기 로직마다 1~100 까지의 정수 중 랜덤한 하나의 숫자를 뽑으면 된다. 가령 재화 5개 뽑기 중 72 라는 난수가 뽑힌 경우?
51~75
까지 분포된 3성 등급이 대상이 된다. 준비되어 있는 3성 카드들 중 하나를 랜덤하게 뽑아 사용자에게 지급하면 된다.
만약 등장 확률을 소수점 하위까지 관리하고 싶다면? 수의 범위를 100 이상으로 늘리면 된다.
(소수점 첫째자리는 1~1,000
, 둘째자리는 1~10,000
) 당연히 뽑는 숫자도 그에 맞춰서 늘려주면 된다.
Kotlin + Spring + JPA 환경에서 간단하게 랜덤 뽑기를 구현해보자.
// 카드
@Entity
class Card(
@Column(nullable = false)
var name: String,
@Column(nullable = false)
@Enumerated(EnumType.STRING)
var grade: CardGrade,
) {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
val id: Long = 0L
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Card
return id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
}
// 랜덤 뽑기
@Entity
class CardRandomDraw(
@Column(nullable = false)
val name: String,
@Column(name = "need_money", nullable = false)
val need_money: Int,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "card_random_draw_range_by_grade",
joinColumns = [JoinColumn(name = "card_random_draw_id")]
)
@AttributeOverrides(
value = [
AttributeOverride(name = "grade", column = Column(name = "grade", nullable = false),),
AttributeOverride(name = "startRange", column = Column(name = "start_range", nullable = false)),
AttributeOverride(name = "endRange", column = Column(name = "end_range", nullable = false))
]
)
private val ranges: MutableList<CardRandomDrawRangeByGrade>
) {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
val id: Long = 0L
init {
validateDuplicateGrade()
validatePercentRange()
}
private fun validateDuplicateGrade() {
val gradeSet = ranges.map { it.grade.value }.toSet()
require(gradeSet.size == ranges.size) { "중복되는 등급(별)이 존재합니다. 등급(별): ${gradeSet.joinToString(", ")}" }
}
private fun validatePercentRange() {
val percentRange = ranges.map { it.startRange..it.endRange }.flatten()
require(percentRange.size == 100) { "확률의 범위는 1~100까지의 숫자로만 이루어질 수 있습니다." }
}
fun randomDrawGrade(): CardGrade {
val randomValue = (1..100).random()
return ranges.first { it.isInRange(randomValue) }.grade
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CardRandomDraw
return id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
}
// 랜덤 뽑기 등급별 범위
@Embeddable
data class CardRandomDrawRangeByGrade(
@Enumerated(EnumType.STRING)
val grade: CardGrade,
val startRange: Int,
val endRange: Int,
) {
init {
require(startRange <= endRange) { "확률의 시작 범위는 끝 범위보다 작거나 같아야 합니다." }
require(startRange >= 1) { "확률의 범위는 1~100까지의 숫자로만 이루어질 수 있습니다." }
require(endRange <= 100) { "확률의 범위는 1~100까지의 숫자로만 이루어질 수 있습니다." }
}
fun isInRange(value: Int): Boolean = value in startRange..endRange
}
자세히 보면 1:N 구조를 나타내는 card_random_draw_range_by_grade
에는 별개 PK 가 존재하지 않는다. card_random_draw_range_by_grade
1 row 로서는 비즈니스적으로 아무런 가치를 지니지 않고, card_random_draw
와 함께 다 같이 조회되었을 때 실질적으로 가치를 지니므로, 생명주기를 card_random_draw
와 함께한다는 의미로 @ElementCollection
, @CollectionTable
을 활용했다.
@ElementCollection
,@CollectionTable
에 대해서는 아래 글을 참고하자. https://github.com/Hyeon9mak/WIL/blob/2e7a35895b85107b664fa44e5c3b9e57e85d51f9/jpa/jpa-value-type.md#%EA%B0%92-%ED%83%80%EC%9E%85-%EC%BB%AC%EB%A0%89%EC%85%98
또 하나 살펴볼 점은
CardRandomDrawRangeByGrade
에서@Embeddable
어노테이션을 사용중인데, 이 이유는 No-arg compiler-plugin 의 지원을 받기 위해서다.
구체적으로 확률별 랜덤한 등급을 뽑기 코드는 아래와 같이 동작한다.
fun randomDrawGrade(): CardGrade {
val randomValue = (1..100).random()
return ranges.first { it.isInRange(randomValue) }.grade
}
val cardGrade = cardRandomDraw.randomDrawGrade()
val drewCards = cardRepository.findByGrade(grade = cardGrade)
val drewCard = drewCards.random()
return drewCard
만약 특정 카드에 대한 등장 확률을 높이고 싶다면, 아래와 같은 방식으로 추가 관리가 가능하다.
요즘 모바일 게임 가챠에서 많이 보이는 사용자별 N 회 이상 뽑기시 특정 카드 무조건 지급
과 같은 로직의 경우 사용자별로 뽑기 내역 테이블을 별개로 관리하면서 뽑기 직전 N 회 이상
조건에 도달했는지 체크하는 것으로 간단히 구현이 가능하겠다!
다만 사용자별 N 회 이상 뽑기시 5성 카드 등장확률 20% 증가
와 같은 경우 다른 등급의 등장확률을 줄이고 5성 카드 등장 확률을 늘릴 것인지, 아니면 1~100 을 넘어 1~120 까지의 범위를 다루어 등장 확률을 늘릴 것인지 등 여러가지 고민이 추가로 필요할 것 같다.
가챠 시스템이 ‘대략 이런식으로 구현 되어 있겠지’ 라고 막연하게 생각만 해왔는데, 직접 구현해보니 생각보다 복잡하고 다양한 응용이 가능한 시스템이다. 물론 현재 수준은 간단히 테스트를 진행한 것이므로, 실제 비즈니스에서는 훨씬 더 복잡하고 다양한 요구사항이 존재할 것이다.
역시나 “생각만 해보는 것과 직접 해보는 것은 다르다.” 를 다시 한번 느끼게 해준 재밌는 경험. 항상 직접 손으로 만들어보는 습관을 들이자.
]]>학부 신입생 때의 텐션으로 돌아온 2023년이다. 코로나와 함께 여러가지 일들을 엮으면서 처음보는 사람들과 가까워지길 어려워했는데, 올해는 처음보는 사람이나 회사 사람들과도 참 많이 가까워진 것 같다. 코로나가 끝나고 잦아진 술자리에 취기를 빌려 사람들과 웃고 떠든 덕분이리라. 항상 과한 술은 조심해야겠지만, 역시 이만한 매개체도 없는 것 같다. 덕분에 항상 출근도 즐겁고, 새로운 사람을 만나는 일도 다시금 설레고 있다.
이제는 모두 뿔뿔히 흩어진 맘편한세상 주니어들
아버지 같은 현태님과 어머니 같은 경수님, 형 누나 같은 동료분들과 함께했던 전 회사. 단순히 우리가 만들고 싶은 기능이 아닌, 사용자 행동 로그를 기반으로 진짜 사용자들이 필요로 하는 기능을 고민하고 만들어내는 재미에 푹 빠져 주말 출근까지도 사랑할 수 있었다. 정말 좋은 제품을 만들기 위해선 어떤 초석들이 필요한지 배웠던 2022년. 그러나 내 바로 왼편, 하루종일 시시콜콜한 농담을 던지며 웃고 떠들던 동료의 자리가 하루 아침에 비워졌을 때. 다음엔 내 자리를 비워야 할 수도 있다는 불안감에 지배되어 더 이상 회사를 사랑할 수 없었다. 결국 나는 새 둥지를 찾기 위한 어설픈 날개짓을 시작했다.
모든게 단단하게 얼어붙은 겨울, 미생의 날개짓을 반겨줄 수 있는 따듯한 둥지는 그리 많지 않았다. 바쁘게 돌아가는 개발시장에서 고작 12개월 경력에게 무엇을 기대할 수 있을까? 운이 좋게 관심을 보였던 곳들도 미약한 날개짓을 확인하곤 이내 쫒아내기 부지기수였다. 첫 취업준비를 할 때와 같은 무력감. ‘아무래도 좋으니 사람들과 웃으며 일할 수 있는 곳이면 좋겠다.’ 바램조차 큰 욕심이었을까 후회하던 차, 또 한번 경수님께서 따듯한 빛을 내려주셨고 안전히 날개를 녹이며 안착할 새 둥지를 선택할 수 있었다. 항상 감사합니다 경수님 🙇♂️
하지만 새 둥지에서의 적응이 마냥 쉽진 않았다. 입사 이틀만에 업무에 투입되어 개발을 시작했고, 도메인 지식이 부족하다보니 고작 코드 2줄을 수정하는데 하루를 소모하곤 했다. ‘빠르게 도메인을 파악하고 아웃풋을 뽑아내주마!’ 입사 전 다짐은 일주일도 유지되지 못한 체 뭉게져버렸다. 하루정도 머리를 팽팽 돌리면 모든 걸 이해할 수 있었던 규모의 레거시만 체험해보다가, 그에 몇 배가 되는 규모의 레거시를 마주하니 의지가 바로 꺾여버렸다. 같은 시기에 입사하신 진성님께서 엄청난 속도로 도메인 파악을 마치고 여유롭게 충민님, 연재님과 회의하시는 모습에선 “마! 이게 실력이라는 거다!” 라는 목소리가 들리는 것 같았다.
게다가 함께하길 고대했던 경수님 곁엔 이미 효진님과 형준님이 계셨다. 알콩달콩 세 분이서 개발하는 모습에 군침을 흘리며 ‘왼팔 오른팔 자리는 뺏긴거 같으니 다리라도?’ 황급히 둘러보았지만 양쪽 다리는 이미 경현님과 다혜님이 든든하게 지탱하고 계셨다. 안돼! 하늘이 무너지는 기분.
(안울었음)
수처작주입처개진(隨處作主立處皆眞)이라 했다. 아쉬운들 어쩌겠나. 하늘이 무너진다고 하염없이 앉아 있을 순 없다. 당장 내가 선택 할 수 있는 최선의 수를 찾아 주도적으로 적응하자. 모든 경험들은 하나 하나 조각이 되어 온전한 형상의 나를 이룰 것이라는 믿음으로, 지금 내가 해야할 일은 그 조각들의 크기와 색깔을 결정한다는 생각으로 손 끝으로 편성 도메인을 간신히 더듬으며 운영 시스템을 만들어갔다.
돌이켜 생각해보면, ‘잘 이해되지 않거나 막막하게 느껴지는 부분이 있을 때마다 더 적극적으로 질문해도 좋지 않았을까?’ 라는 작은 아쉬움이 남는다. 의식적으로 질문을 던지려고 노력했지만, 홀로 잘 헤엄칠 수 있는 모습을 보여주고 싶은 욕심에 수심이 깊어질 때까지 허우적거리며 시간이 낭비했다. 2024년에는 욕심을 더 덜어내고 도와달라고 미리 손을 뻗을 수 있는 용기를 갖자. 나의 부족함과 능력을 인정하고 함께 더 나은 제품을 만드는 것에 집중하자. 그게 진짜 실력이다.
둥지의 온기가 익숙하게 느껴질 때쯤 원익님, 새롬님께서 사내 기술 블로그 작성을 권유해오셨다. 이제서야 막 새 날개깃이 자라고 있는데, 밖으로 등 떠밀린 느낌. 어떤 글을 쓰면 좋을까 고민하던 차 ‘기왕이면 업무 외적으로도 좋은 에너지를 전파할 수 있는 활동이 있을까?’ 라는 고민을 한참했다.
‘뛰어난 동료들 사이에서 내가 보여줄 수 있는 건 무엇일까?’
‘지금 우리 환경에서 정말 필요한 것은 무엇일까?’
‘내가 그동안 배운건 어떤게 있지? 코드 리뷰?’
‘그래, 코드 리뷰 문화를 강화해보자!’
팀 전반에서 적극적이고 유기적인 코드 리뷰 문화를 강화하고 싶었다. 파편화된 도메인을 모두 이해하긴 어렵곘지만, 어떤 코드가 읽기 좋은 코드인지, 어떤 아키텍처가 유지보수에 유리한 아키텍처인지는 개발자 모두가 공통으로 이해하고 리뷰할 수 있다고 믿는다. 서로의 깃허브 저장소를 구경하고 자연스럽게 코멘트를 남기는 분위기가 생겨나면 어떨까!
그렇다면 어떻게 팀원들을 설득하고 적극적인 코드 리뷰 문화를 만들 수 있을까?
항상 새로운 아이디어를 제안할 땐 타인의 공감과 이해가 필요하다. 그 아이디어가 갖는 효과, 효능을 따져보는 것도 중요하지만 내 의견이 합리적인지, 내 제안이 타인에게 어떻게 받아들여질 지 충분히 고민해보는 것이 더 중요하다. 결국 팀원들이 제안에 공감하고 필요를 느낄 수 있도록 마음을 움직이는 수단이 필요했고, 그 수단이 바로 우아한테크코스와 넥스트스탭에서 경험했던 미션 스터디라고 생각했다.
두 곳에서의 경험을 살려 조금 특별한 스터디를 기획했고, 보름간 정말 즐겁게 스터디를 진행했다. 우리 팀 개발자분들의 열정을 느낄 수 있었던 건 덤. 자세한 내용은 아이들나라 기술 블로그 링크로 대체한다. 적극적인 코드 리뷰 문화를 위한 조금 특별한 스터디 도입기
적극적으로 참여해주신 경현님, 효진님, 준우님, 종인님, 윤철님, 지원님, 연재님, 구화님, 윤지님, 그리고 강의자료 활용을 흔쾌히 허락해주신 제이슨께 무한한 감사를… 🙇♂️
미션 스터디를 운영할 수 있었던 초석이 되어준 리뷰어 활동들. 내가 알고 있는 지식으로 다른 사람에게 도움을 줄 수 있다는 건 정말 즐겁고 설레는 일이다. 그 설렘에 중독되어 상반기에 특히 열심히 활동했다. 열정 넘치는 리뷰이분들께 맞춰 정신없이 리뷰를 하다보면 개인공부 시간 확보가 어려웠지만, 그만큼 다른 사람들의 코드를 읽고 의중을 파악하는 능력은 정말 단단히 길러진 것 같다. 사내 개발자분들의 코드를 읽고 리뷰할 때도 보다 수월하다! 그리고 그 경험 덕분에 미션 스터디 운영까지도 잘 해내었으니!
공식적으로 리뷰어 활동을 한지 어느덧 1년이 지났다. 여러가지 감사한 일이 많았지만, 그 중 올 해 가장 기억에 강렬히 남았던 후기 글이 있다. 마음이 헤이해질 때마다 읽어보고 다시 자극 받자. https://engineerinsight.tistory.com/82
고민해봐야할 점은 앞서 말했듯 개인공부 시간 확보가 어려웠다. 리뷰이분이 열정적일 수록 리뷰 한 건에 2시간 이상 소요되는 일도 잦았다. 2024년엔 리뷰어 활동을 조금 줄이고, 학습 시간을 조금 더 확보하는 밸런스가 필요할 것 같다.
7기부터 시작한 글또를 어느덧 8기에 이어 9기까지 진행하고 있다. 최초 글또를 시작한 이유는 꾸준한 글 작성 습관을 위해서였는데, 현재는 긍정적인 에너지를 가진 분들과 함께 즐거운 시간을 보내기 위함이 더 크다. 그래서 그런지 2023년 블로그 포스트 개수가 많이 부진했다. 😅 작년 회고 포함 총 10개의 글을 작성했다.
그래도 그 시간동안 다양한 사람들과 에너지 교류를 적극적으로 해낸 것에 만족한다. 특히 운영진 활동을 통해 만나는 분들이 가지는 에너지가 참 대단한데, 지치지 않고 열정적으로 자신의 목표를 위해 달려가시는 멋진 분들이다. 정말 바쁜 시간에도 글또분들께 좋은 글을 전달하기 위해 멈추지 않고 노력했던 우리 큐레이션 팀 지훈님, 원종님, 나영님이 가장 먼저 생각난다! “저는 하고 싶은 것만 해요.” 쿨하게 말씀하시지만 실제론 AWS 자격증도 전부 취득하고 엄청난 능력을 보여주시는 지훈님, 개발 뿐만 아니라 해외 봉사활동도 적극적으로 참여하면서 진짜 가치를 전달할 줄 아시는 원종님, 누구보다 글또를 사랑하면서 글또에 필요한 모든 일을 도맡아 하시는 글또 어머니 나영님… ‘조금은 쉬어갈까?’ 라는 생각이 들 때쯤 만나면 다시 열정이 불타오르게 되는 멋진 분들.
“현구님 너무 극단적으로 한 쪽만 취하려 하지 않으셨으면 좋겠어요. 저는 일이나 삶이나 모두 밸런스가 중요하다고 생각해요.”
올해 재휘님께는 특히 더 큰 감사를 드리고 싶다. 이따금 힘이 부치는 상황에서 자조적, 부정적으로 생각을 이어나가다가 극단적으로 한쪽 길만 선택하는 경향이 있는데, 그걸 정확히 지적해주셨다. 2024년에는 꼭 끊고 버려내야 할 나쁜 습관. 항상 모든게 좋거나 모든게 나쁠 순 없다. 어느정도 나쁜 점이 있더라도, 좋은 점이 더 크기 때문에 선택을 내리는 것. 그런 균형을 잘 맞추어야한다. 재휘님의 날카로운 진단 덕에 올 한해 중요한 상황들 속에서 밸런스 있는 결정을 내릴 수 있었다.
작년에 처음 뵈었을 땐 외적으로 정말 단단한(몸이 좋으심ㅋㅋ)분이라고만 생각했는데, 내적으로도 정말 단단함이 느껴지는 분. 게다가 술과 사람을 좋아하셔서 낭만까지 있다. 2024년에도 재휘님 곁에서 내 인생의 밸런스에 대해 고민하고 배워야겠다.
모두 글또라는 커뮤니티 덕분에 만나뵐 수 있었던 귀인들이다. 비슷한 관심사를 가진 사람들끼리 모여 서로 긍정적인 에너지를 주고 받을 수 있음에 항상 감사한다. 긍정적인 에너지와 도움을 받은 만큼, 나도 다른 분들께 좋은 에너지를 전달 할 수 있도록 노력하자.
2018년 부산을 통해 대마도에 다녀온 걸 제외하면, 해외 여행 경험이 전무했다. 여행을 다닐 시간도 없었거니와, 여행의 매력을 모르니 여행 자체에 대한 욕심이 전혀 없었다. 그러다 이직 시기 무작정 다녀온 일본 여행에서 여러가지 깨달음과 여행에 대한 즐거움을 얻기 시작했다.
음식 주문, 화장실 위치 물어보기, 교통카드 충전방식 조사하기, 사고자 하는 물건이 있는 층수 알아내기… 한국에선 너무나 당연하고 사소했던 것들이, 외국에선 모두 난관과 도전이 된다. 더듬더듬 번역기를 돌려가며 음식 주문에 성공했을 때의 성취감, 한국에서는 맛볼 수 없는 맛있는 음식을 먹었을 때의 만족감, 감사함을 표현했을 때 미소로 화답하는 현지인들과의 감정교류. 모든 경험이 새롭고 즐겁다.
이따금씩 서울에서 영어를 사용하는 외국인을 보면 ‘참나, 한국에 왔으면 한국어를 써야지… 왜 당당하게 영어를 사용하는거야?’ 라고 어이없어 했는데, 막상 일본에서 말문이 막히니 나도 모르게 영어부터 나오더라. 그간 나의 오만이 부끄러워짐과 동시에 세계 공용어라는 게 얼마나 대단한 것인지 피부로 느꼈던 순간.
공간이 다르다는 것만으로 완전히 새로운 경험을 하고, 새로운 배움을 얻는 것이 즐거워 미서부 여행도 거부감없이 휘뚜루마뚜루 떠나버렸다. 샌프란시스코-라스베가스-로스엔젤레스 일정. 지구 반대편 미국은 일본과는 또 다른 느낌. 마약중독으로 황폐해진 길거리, 한국인 입장에서 이해하기 어려운 위생관념 등 불편한 점도 많았지만, 햇빛 짱짱한 날씨와 기후, 나는 이 세상의 작은 점임을 인지시켜주는 대자연 경관, 압도적인 규모의 인프라 등에서 “이래서 미국 미국 하는구나” 감탄할 수 밖에 없었다.
(후버댐이 5년만에 완공되었다는 사실을 알게된 우리에겐 “후버댐도 5년만에 지었는데 너는 뭐하고 있냐?” 라는 밈이 생겨났다ㅋㅋㅋ)
샌프란시스코 여행중엔 유튜브 코맹탈출 채널을 운영중이신 에피한테 무작정 “만나뵙고 싶다.” 연락을 드렸고, 감사하게도 기회가 닿아 메타 사무실을 구경하고 미국 개발 생활과 관련된 여러 이야기를 들을 수 있었다. 유튜브 채널에 올려주신 영상을 보고 감명받아 몇 가지 실험을 해보고 블로그에 트위터 시스템 디자인 실험기 라는 글을 포스팅 했었는데, 그 포스트를 굉장히 좋아해주셨다. 최근 학습동기가 많이 떨어지고 있었는데, 다시금 “열심히 살아야겠다!” 다짐을 하게 된 감사한 경험.
미국 여행을 다녀온 후로 영어에 대한 관심이 굉장히 커졌는데, 특히 ‘내가 아는 기술을 영어로 설명할 수 있으면 좋겠다.’ 라는 욕심이 생기기 시작했다. 이걸 연습할 수 있는 가장 좋은 방법이 뭘까… 고민하던 차 영어로 기술 블로그를 작성해볼 계획이다. 우선 간단한 기술 설명부터 차근차근 수준을 높여보자. 언젠가 영어로 된 기술 문서 번역도 수월해지겠지!
여행을 통해 얻은 가장 큰 자산은 일단 무작정 해보는 것. 해보기 전엔 천만가지 걱정이 떠오르지만 막상 해보면 별거 아니다. 앞으로도 해외여행을 자주 다니면서 여러가지 경험을 해봐야지. 인생에 한번쯤은 미국에서 살아보고도 싶은데… 언젠가 미국도 한번 살아보자!
올 한해 나를 지탱해준 운동. 고등학생 시절부터 미루기를 반복하다가 10년만에 시작한 클라이밍은, 나를 이루는 여러가지 조각중 가장 커다란 조각이 되었다. 벽에 붙어있는 오색 돌들을 바라보며 내가 오를 수 있는 방법을 상상하고, 손끝과 발끝 감각을 총 동원해서 벽을 오른다. 벽을 오르는 동안엔 다른 잡생각들은 모두 사라진다. 더 큰 힘을 내기 위한 호흡조절만 있을 뿐! 그리고 정상에 도달했을 때 느껴지는 짜릿함과 성취감은 이루 말할 수 없다.
클라이밍 매력에 푹 빠진 덕분에 샌프란시스코에서도, LA 에서도 클라이밍장을 다녀왔다. 이번 크리스마스에는 정말 클라이밍 때문에 오사카도 다녀왔다. ‘좀 과한가?’ 싶기도 했지만, 하나에 미치려면 제대로 미쳐봐야지 라는 심산으로…
무엇보다 사람들과 함께 교류하기 좋은 운동이라는 점이 좋다. 난이도 세분화가 잘 되어 있어서 처음 시작하시는 분들도 쉽게 성취감을 느낄 수 있고, 벽을 오르는 시간보다 앉아서 휴식을 취하며 두런두런 이야기 하는 시간이 더 길기 때문에 부담이 적다. 처음엔 주변에 클라이밍을 하시는 분이 몇 분 안계셨는데, 열심히 전도(?)한 덕에 이제는 언제든지 함께할 사람을 구할 수 있을 정도로 주변에 벽을 타시는 분들이 많아졌다.
하루종일 뜻대로 일이 풀리지 않아 기분이 꿀꿀할 때, 생각을 비우고 땀을 흘릴 수 있는 취미가 생겼음에 감사하다. 건강하고 좋은 취미 다치지 않게 조심해서 오래오래 잘 즐겨야겠다. 2024년에도 준비운동 꼬박꼬박 잘하자!
2023년 한 해 조금씩 나를 갉아먹던 녀석이 바로 ‘조바심’이었다.
몸무게가 늘어난건 체중계로 알아 보는데, 내 능력이 성장했음은 뭘로 알아 보아야 할까? 초등학생 때부터 학부생 졸업까지. 항상 시험 성적과 석차로 내 능력과 위치를 파악했다. 20년 가까이를 다른 사람과의 비교로 측정하다보니 그 방법이 가장 익숙하다. 사실 다른 방법은 잘 모르겠다.
다른 사람과 비교를 하다보니 나 자신이 한 없이 작게 느껴질 때가 잦다. 조바심에 목이 타들어간다. 모니터 속 꽉 채워진 투두 리스트를 하나씩 지워가며 숙제하듯 살아가는게 익숙하고, 투두 리스트를 모두 지워내지 못한 날이면 한 없는 자책에 빠진다. ‘왜 이리 게으르지?‘, ‘그 때 집중했으면 다 끝낼 수 있었잖아.‘, ‘더 잘되고 싶은 마음이 없구나?’ 꼭 오늘 끝내야 하는 일들이 아님에도 찝찝함이 느껴진다. 항상 무언가에 쫒기는 기분이다. 목마르다.
아니, 사실은 쫒고 있다. 더 잘된, 더 멋진, 더 높이 있는 사람들을 쫒고 있다. 친구들과의 술자리에서 듣는 챌린징한 개발 경험들 중에 내 이야기도 포함되고 싶다. 듣는 입장에서 들려주는 입장이 되고 싶다. 계속해서 목마르다. 오늘 하나라도 티켓을 더 쳐내야 저 사람들과 한발자국이라도 가까워질 것 같은데. 풍선처럼 부풀어오르는 조바심이 또 하나의 투두 티켓이 되어 삶의 무게를 짓누른다.
그러다 문뜩, 새로운 기술을 도입해 볼 여유가 생겼다. ‘이럴 때 아니면 언제 해보겠어.’ 라는 근거 없는 자신감으로 시작한 기술 도입은 어째선지 술술 풀리며 성공적인 결과를 만들어냈다. ‘이상하다… 분명 작년 이맘 때 똑같이 시도했을 땐 죽어도 모르겠어서 포기했는데…’ 머리 속을 두리뭉술하게 떠다니던 개념들을 어느 샌가 잠자리채로 낚아채어 능숙하게 활용했다.
우연히 비슷한 시기 비슷한 공간에서 달라졌음을 인지하니, ‘작년보다 성장했다.’ 라는 성취감이 느껴졌다.
“다른 사람과 비교하며 스트레스 받지 마세요. 어제의 자기 자신과 경쟁하세요.”
어제의 자신과 경쟁하라는게 이런 의미였구나. 처음 문장을 접했을 땐 ‘뭐 당연한 이야기 아닌가? 오늘 공부하면 어제보다 똑똑해진다는 건가?’ 정도로 여겼는데, 커다란 조바심을 이겨낸 후에야 진짜 뜻이 이해가 된다.
물론 다른 사람과의 비교를 그만 둘 생각은 없다. 여전히 나에게 강한 동기와 함께 투두 리스트를 채워주는 방법이다. 그렇지만 가끔은 옛날에 했던 일을 반복해보면서 자존감과 성취감을 채우는 것도 괜찮은 것 같다.
또 기회가 돌아오면 저번에 포기했던 걸 다시 시도해봐야지. 2024년에는 조바심을 이겨내고 더 건강한 내가 되어야지.
올해는 기술적인 성장을 도모하고 싶었는데, 그것보단 인간 최현구가 더 즐겁고 건강하게 사는 법에 대해 알아가는 시기였던 것 같다. 이제 거진 30년을 살았는데 아직도 나 자신에 대해 모르는 것들이 참 많았다는 걸 깨달은 시간. 그래도 이 시간들 또한 나를 이루는 하나의 커다란 조각이 되어줄 것이다. 내 실력을 솔직하게 인정하고, 적극적으로 질문하고, 더 자신있게 도전하고, 건강하게 운동하는 2024년이 되자.
]]>이전 글: spring circuit breaker 이해 및 예제 정리
spring circuit breaker(Spring Cloud Circuit Breaker) 에 대해 알아보았으니, 이번엔 Spring Retry 에 대해서 알아보자. 우선 두 기술은 모두 fault-tolerance(장애허용)을 위해 사용하는 기술이다. 때문에 동작이 상당히 유사하다. 그러나 자세히 살펴보면 아래와 같은 코어 컨셉 차이를 가지고 있다.
circuit breaker 는 요청 자체를 차단하는 것에, retry 는 다시 시도하는 것에 중점을 두고 있다. 이번 글에서는 retry 에 대해 중점적으로 다뤄보자.
최초 Retry 기능은 Spring Batch 라이브러리에 포함되어 있었다. 그러나 Spring Batch 버전이 2.2.0 이 되는 시점에서 Spring Retry 라는 별개 라이브러리로 분화되었다.
클라이언트가 고양이 사진을 요청하면, DB 로부터 고양이 사진을 가져와 응답해주는 서비스가 있다. 클라이언트와 서버, DB 간 동작 순서를 간단히 나타내면 아래와 같다.
갑작스럽게 서버에서 DB 로 요청을 보내는 과정에 네트워크 순단이 발생하여 고양이 사진을 가져오는데 실패했다고 가정해보자. 이 경우 클라이언트는 고양이 사진을 받지 못하고, 고양이 사진을 가져오지 못했다는 로그만 전달 받게 된다.
일시적으로 발생한 문제기 때문에, 클라이언트는 잠시 후 다시 요청을 보내면 고양이 사진을 받을 수 있을 것이다. 즉, 서버가 네트워크 순단을 인지하고 DB 에게 재요청을 보낼 수 있다면, 클라이언트는 문제상황을 인지하지 않고 고양이 사진을 받을 수 있을 것이다.
대부분의 상황에서 클라이언트에게 고양이 사진을 전달하는데 성공할 것이므로, 자연스럽게 서비스 신뢰도 하락을 방어할 수 있다.
테스트에 사용된 코드는 https://github.com/Hyeon9mak/spring-retry-playground 를 참고하자.
의존성 관리 파일에 spring-retry
라이브러리를 추가하는 것으로 편리하게 사용할 수 있다.
implementation 'org.springframework.retry:spring-retry'
물론 Spring AOP 를 활용하므로 spring-aspects
라이브러리 의존 또한 필요하다.
implementation 'org.springframework:spring-aspects'
Spring Retry 를 활성화 시키기 위해선 @EnableRetry
어노테이션을 추가한다.
@EnableRetry
@SpringBootApplication
class SpringRetryTestApplication
fun main(args: Array<String>) {
runApplication<SpringRetryTestApplication>(*args)
}
@retryable
annotation간단하게 고양이 이미지를 가져오는 API 를 만들어보자.
@RestController
class SpringRetryTestController(
private val springRetryTestService: SpringRetryTestService,
) {
@GetMapping("/cats/{id}/image")
fun catImage(@PathVariable id: Long): String = springRetryTestService.catImage(id = id)
}
@Retryable
어노테이션을 통해 간편하게 retryable 한 메서드를 정의할 수 있다.
@Service
class SpringRetryTestService {
private var counter = 0
@Retryable(
maxAttempts = 3,
backoff = Backoff(delay = 1000),
include = [RuntimeException::class]
)
fun catImage(id: Long): String {
counter += 1
logger.info("counter: $counter")
if (counter % 2 == 0) {
return "cat_${id}_image.png"
}
throw RuntimeException("Failed to get cat image.")
}
companion object {
private val logger = LoggerFactory.getLogger(SpringRetryTestService::class.java)
}
}
@Retryable
어노테이션의 속성은 아래와 같다.
maxAttempts
: 최대 재시도 횟수backoff
: 재시도 사이 시간 간격 (ms)include
: 재시도 대상 Exception
include
는 value
와 동일한 속성이다.위 코드상에서는 고양이 이미지 호출시 counter
가 홀수면 RuntimeException
발생 1초 후 retry 가 일어나면서 정상적으로 고양이 이미지를 반환할 것이다.
21:45:28 SpringRetryTestService : counter: 1
21:45:29 SpringRetryTestService : counter: 2
@recover
annotation@Recover
어노테이션을 통한 재시도가 최종적으로 실패했을 경우 대체 응답을 반환할 수 있다.
@Service
class SpringRecoverTestService {
@Retryable(
maxAttempts = 3,
backoff = Backoff(delay = 1000),
include = [RuntimeException::class],
recover = "recover"
)
fun catImage(id: Long): String {
throw RuntimeException("Failed to get cat image.")
}
@Recover
fun recover(e: RuntimeException, id: Long): String {
logger.info("recover: $e")
return "very_cute_cat_image.png"
}
companion object {
private val logger = LoggerFactory.getLogger(SpringRecoverTestService::class.java)
}
}
고양이 이미지 호출 재시도 3번을 모두 실패하면, @Recover
어노테이션을 통해 지정된 메서드가 응답을 대체한다.
@Recover
어노테이션의 메서드는 @Retryable
어노테이션의 메서드와 동일한 파라미터, 반환타입을 가져야 한다.
파라미터 첫 번째 인자로는 @Retryable
어노테이션의 메서드에서 발생한 Exception 이 전달된다.
@Retryable
어노테이션을 통해 간편하게 retryable 한 메서드를 정의할 수 있지만, 직접 RetryTemplate
을 사용하는 방법도 있다.
@Configuration
class RetryTemplateConfig {
@Bean
fun retryTemplate(): RetryTemplate = RetryTemplate.builder()
.maxAttempts(3)
.fixedBackoff(1000)
.retryOn(RuntimeException::class.java)
.build()
}
@Service
class SampleRetryService {
fun catImage(id: Long): String {
logger.info("exception will be thrown.")
throw RuntimeException("Failed to get cat image.")
}
fun recover(id: Long): String {
return "very_cute_cat_image.png"
}
companion object {
private val logger = LoggerFactory.getLogger(SampleRetryService::class.java)
}
}
@Service
class RetryTemplateService(
private val sampleRetryService: SampleRetryService,
private val retryTemplate: RetryTemplate,
) {
fun catImage(id: Long): String {
return retryTemplate.execute<String, RuntimeException>(
{ sampleRetryService.catImage(id = id) },
{ sampleRetryService.recover(id = id) }
)
}
}
execute
메서드의 첫 번째 인자는 RetryCallback
이다.
반복해서 수행할 타겟 메서드를 지정한다.
두 번째 인자는 RecoveryCallback
이다.
retry 실패 후 최종적으로 수행할 메서드를 지정한다.
만약 retry 실패 후 최종적으로 수행할 메서드가 없다면, 두 번째 인자는 생략해도 된다.
RetryListener
인터페이스를 구현한 후 spring bean 으로 등록하면
아래 3가지 타이밍에 추가 콜백을 제공할 수 있다.
public interface RetryListener {
<T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback);
<T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable);
<T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable);
}
@Component("catImageListener")
class CatImageListener : RetryListener {
override fun <T : Any?, E : Throwable?> open(context: RetryContext?, callback: RetryCallback<T, E>?): Boolean {
logger.info("open")
return true
}
override fun <T : Any?, E : Throwable?> close(
context: RetryContext?,
callback: RetryCallback<T, E>?,
throwable: Throwable?
) {
logger.info("close")
}
override fun <T : Any?, E : Throwable?> onError(
context: RetryContext?,
callback: RetryCallback<T, E>?,
throwable: Throwable?
) {
logger.info("onError")
}
companion object {
private val logger = LoggerFactory.getLogger(CatImageListener::class.java)
}
}
@Service
class SpringRetryListenerTestService {
@Retryable(
maxAttempts = 3,
backoff = Backoff(delay = 1000),
include = [RuntimeException::class],
listeners = ["catImageListener"]
)
fun catImage(id: Long): String {
throw RuntimeException("Failed to get cat image.")
}
@Recover
fun recoverListener(e: RuntimeException, id: Long): String {
logger.info("recover: $e")
return "very_cute_cat_image.png"
}
companion object {
private val logger = LoggerFactory.getLogger(SpringRetryListenerTestService::class.java)
}
}
@Retryable
어노테이션에 listeners
속성을 등록하는 것으로 편하게 RetryListener
를 사용할 수 있다.
만약 RetryTemplate
을 사용한다면, RetryTemplate
에 RetryListener
를 등록해야 한다.
@Bean
fun retryTemplate(): RetryTemplate = RetryTemplate.builder()
.maxAttempts(3)
.fixedBackoff(1000)
.retryOn(RuntimeException::class.java)
.withListeners(listOf(CatImageListener()))
.build()
실제 동작을 테스트 해보면 아래와 같다.
CatImageListener : open
CatImageListener : onError
CatImageListener : onError
CatImageListener : onError
SpringRetryListenerTestService : recover: java.lang.RuntimeException: Failed to get cat image.
CatImageListener : close
RetryListener
의 open
메서드가 호출된다.RetryListener
의 onError
메서드가 호출된다.RetryListener
의 close
메서드가 호출된다.강남언니에서는 지난 2023년 3월부터 코드리뷰 테스트를 통해 개발자 채용을 진행한다고 한다. 합격자 수와 수준을 이전과 동일하게 유지하면서도 인터뷰 진행 시간을 50% 이상 감소시켰는데, 지원자가 코드 리뷰를 남기는 모습을 보면서 회사에 어울리는 사람인지 높은 확신을 갖고 판단 할 수 있었고, 지원자들의 부담도 적었다고 한다.
코딩 테스트나 과제 대신 코드 리뷰 테스트를 선택한 이유와 방법에 대해 알아보자.
회사에 지원하는 개발자의 수는 늘어나고, 한정된 자원 속에서 실무와 인터뷰를 함께 병행해야 하는 개발자들의 부담도 함께 늘어나고 있는 상황이다. 한 주 인터뷰가 2~3개씩 존재하면 그 자체도 피곤하고, 실무 시간이 항상 부족하다. 우리는 왜 사람을 채용할 때 인터뷰에 많은 시간을 사용해야 하는걸까?
대부분의 회사에서 사용하는 채용 프로세스는 서류지원 > 코딩 테스트 > 인터뷰
다.
여기서 코딩 테스트는 코딩 능력, 적절한 자료구조 선택 능력, 문제 해결력이라는 중요한 요소들을 확인하는 테스트다.
그러나 이 3가지 능력은 살펴봐야할 요소들 중 일부일 뿐, 바로 지원자를 채용할만큼 절대적인 요소는 아니다.
또한 최근 들어 AI 를 이용한 어뷰징도 많아져서 신뢰도가 많이 떨어지고 있는 상태. ChatGPT 활성화 이후 코딩 테스트 통과율이 2배 상승했다고 한다.
코딩 테스트 대신 과제 전형을 진행하는 경우도 있지만, 과제 전형은 지원자에게 부담이 커진다. (이에 대한 반증으로, 실제 제출되는 과제의 수가 지원자 수의 절반도 되지 않는다고 한다.) 때문에 인터뷰에 많은 시간을 투자해서 지원자에 대해 확인할 수 밖에 없다.
‘지원자의 부담이 적으면서도, 인터뷰 전에 더 효율적으로 역량을 확인할 수 있는 방법이 있을까?’ 고민을 이어나가던 중 우연히 도착한 코드 리뷰 알림에 아이디어를 얻게 된다.
코드 리뷰 테스트는 지원자가 주어진 코드에 대한 코드 리뷰를 제출하는 채용 테스트다. 코드를 보고 도메인을 이해하는 능력, 코드를 대하는 태도나 관점, 커뮤니케이션 능력 등을 한꺼번에 확인할 수 있다. 또한 지원자에게 부담도 적다. 처음부터 집을 지어보라 하는 것보다, 이미 지어진 집에 대한 리뷰와 피드백을 요구하는 것이 훨씬 쉽다.
이 좋은 코드 리뷰 테스트를 진행하기 위해서는 어떤 준비가 필요할까?
우선 팀 내에서 특히 좋았거나 인상 깊은 코드와 코드 리뷰 코멘트를 수집해야한다. 왜 좋았는지, 채용 과정에 사용해도 적합한지 판단을 할 수 있어야 하는데, 이 판단을 위해서는 팀 내 개발자에 대한 기술 인재상 정의가 선행되어야 한다. (즉, 기준이 필요하다.)
기술 인재상 정의가 완료되고 적합한 코드와 코멘트를 수집했다면 채용 과정에 직접 사용할 수 있도록 문제 상황을 간소화한다. 가령 기존 코드가 800+- 라인이었다면 이를 300+- 라인정도로 줄이고, 사내 중요 비즈니스 로직이 노출되지 않는 선에서, 적절하게 복잡한 수준의 문제 상황을 연출한다.
코드 리뷰 테스트를 도입한 이후 지원자의 부담이 줄어들었고, 인터뷰 시간이 50% 이상 감소했다. 기존 코딩 테스트 통과자들의 인터뷰 통과율은 25% 에 그쳤으나, 코드 리뷰 테스트 통과자들의 인터뷰 통과율은 53% 를 기록했다.
코딩 테스트보다 코드 리뷰 테스트의 난이도가 높을수록 인터뷰 통과율이 높아질 수 밖에 없다는 통계의 오류로 볼 수 있다. 그러나 강남언니 내부에서 건전성 지표 확인 결과 통계의 오류가 아니었다.
인터뷰어들도 여러가지 장점을 느낄 수 있었는데, 지원자의 문제 인식 능력과 실무 능력을 파악하기 용이했고, 검증하고 싶은 항목을 유연하게 설정이 가능했다. 과제 전형만큼이나 인터뷰에서 직접적으로 활용할 수 있는 데이터가 생김은 물론이고, 무엇보다 인터뷰 진행 전 지원자의 협업 및 커뮤니케이션 능력을 확인할 수 있다는 점이 가장 눈에 띈다.
코드 리뷰 테스트가 항상 완벽한 채용 프로세스가 되어주진 못한다.
우선 실무 경험이 없는 신입 개발자를 대상으로는 사용이 어렵다. 사내 기술 스택에도, 코드 리뷰 문화에도 익숙해지는 시간이 필요하기 떄문. 때문에 신입을 대상으로는 여전히 코딩 테스트나 과제 등 다른 전형을 이용할 수 밖에 없다.
또한 지원자의 기술 스택이 다를 경우에도 코드 리뷰 테스트를 진행하기 어렵다. 가령, 지원자가 C++ 을 사용해왔는데, 회사에서는 Java 를 사용한다면 지원자에게 굉장히 불리한 테스트가 될 것이다. 때문에 코드 리뷰 테스트 자료를 준비할 때 가능하면 특정 기술에 종속되지 않은 일반적인 문제도 많이 포함하여 준비하는 것이 좋다.
그 외에도, 우선 회사의 기술 인재상이 명확하지 않다면 기술 인재상 선정이 선행되어야 하며, 회사의 채용 브랜드 파워가 강력해야한다. (특이한 채용 프로세스를 진행하더라도 제출률이 낮아지지 않을 만큼의) 만약 회사에 가용 가능한 인터뷰어 수가 많다면 굳이 도입하지 않아도 될 수 있으며, 필요에 따라서 코딩 테스트와 과제가 더 높은 가치를 전달해줄 수도 있음을 인지해야한다.
항상 코딩 테스트 전형보다 과제 전형이 더 많은 정보를 드러내 줄 수 있어서 선호했는데, 코드 리뷰 행위를 테스트한다는 신박한 아이디어를 얻게 되어 좋았다. 인터뷰에서 직접적으로 활용할 수 있는 데이터를 단기간에 얻어낸다는 점도 큰 메리트로 느껴졌고 (아무래도 과제 전형 역시 리뷰어가 코드를 읽고 파악하는데 부담) 인터뷰를 진행하기 전부터 지원자의 커뮤니케이션 능력을 확인할 수 있다는 점이 가장 돋보였다.
사내 시니어 개발자분들께도 코드 리뷰 테스트에 대해 소개해드렸는데, 소개된 장점과 정성적 후기에 공감하시며 긍정적인 평가를 내리셨다. (경수님께서는 앞으로 이직 못하겠다고 🤣)
언젠가 개발자 채용에 기여하게 된다면 코드 리뷰 테스트 도입에 대해서 적극 이야기 해봐야지!
]]>1년만에 돌아온 인프콘, 그동안 인프런의 아키텍처에는 어떤 변화가 있었을까?
보통 우리가 아키텍처나 구조를 바꿀 때 가장 큰 이유가 장애 해결이나, 트래픽 문제를 해결하기 위해서다.
인프런 역시 장애 해결을 위해 레거시 아키텍처 개편을 시작했었다.
그러나 올해는 장애나 트래픽이 아닌 조직 구조의 효율을 높이기 위해 아키텍처 변경이 필요했다.
인프런과 같은 소규모 조직에서 어떻게 계속해서 아키텍처를 개선해 나갈까?
인프런은 거대한 레거시 싱글 레포지토리를 조금씩 이관하는 과정을 거쳐서,
점차 (구) 인프런 싱글 레포
의 크기가 줄어들고, (신) 인프런 모노 레포
크기가 늘어나길 기대했다.
과연 2023년에 바라본 모습은 어떨까?
안타깝게도 현실은 여전히 작년과 비슷한 모습 ㅎㅎ
“거대한 레거시 싱글 레포를 이관하면서도, 비즈니스 속도를 유지할 수 있는 방법은 무엇일까? 더 나아가 조직의 규모를 키울 수 있는 방법은 무엇일까?” 라는 고민에서 조직의 형태를 바꿔보자는 결론에 도달한다.
인프랩은 기존 PM 파트, 디자인 파트, 백엔드 파트, 프론트 파트 등 기능조직에서 여러가지 불편함을 느끼고 있었다.
이 문제를 해소하기 위해, 개발자 1명, 디자이너 1명, PM 1명씩으로 구성되어 있던 과거 조직 형태로 돌아가보자는 생각을 하게 된다.
온전히 하나의 목적을 가진, Full cycle 목적 조직을 구성하고, 자신의 조직이 담당하는 제품에 대해서는 내부에서 즉시 결정을 내리고 실행.
각 목적 조직별로 병렬 행동이 일어나니, 매월 2~3개 이상의 제품이 개선 및 출시되기 시작했다.
실행력과 제품속도가 크게 상승된 것이다!
그러나, 장기적인 제품 속도와 조직 안정감이 떨어지고 있었다.
전반적으로 속도감을 유지하기 위해 조직 안정감을 포기한 상태였다.
실행력과 속도감을 유지하면서도 장기적 제품속도와 조직 안정감을 개선하기 위해 조직구조를 다시 한번 개선했다.
목적 조직 + 직군 조직(챕터, 파트)이 합쳐진 형태. 기존 목적 조직의 위키 스페이스에 더해 파트별로 위키 스페이스를 하나 더 신설하고 관리하기 시작한다. 개발자 입장에서는 목적 조직과 기능 조직 2개의 위키 스페이스에 포함되는 셈이다. 주된 업무는 목적 조직을 따르되, 정기적인 파트별 미팅을 통해 장애 상황이나 기술 개선 사항을 공유함으로서 부족함을 매꿔나간다. Github PR 또한 파트 공용 채널에 모두 공유하면서 서로 어떤 작업을 진행중인지 확인하도록 했다.
리뷰가 한정없이 밀리거나 리뷰없이 반영해야하는 부분. 비즈니스 로직은 모두 알 수 없겠지만 클린코드에 대해서는 확인해줄 수 있다. 이번에 사내에서 진행하고 있는 스터디에서도 내가 기대하는 있는 부분이다.
그렇다면 드디어 모든 문제가 해결된 것일까?!
안타깝게도 여전히 거대한 레거시 프로젝트 저장소가 발목을 붙잡았다. 모든 목적 조직이 사용하는 거대한 레거시 시스템은 기능이 추가될 때마다 빌드 속도가 늘어나고, commit log 가 뒤섞였다.
뒤섞인 commit log 는 label 을 붙여 추적 및 관리한다.
서로가 서로의 기능에 영향을 받기 때문에 QA 일정을 추산하기가 굉장히 어려웠다. 가장 중요한 것은, 이 거대한 단일 레거시 프로젝트 저장소를 어떤 조직도 가져가려하지 않는다는 점이다.
그렇다면 어떻게 저 거대한 레거시를 해소할 수 있을까? MSA? 소규모 팀이 MSA 를 도입하는 순간 비즈니스 속도가 기하급수적으로 떨어진다.
인프런에서는 이 문제를 해결하기 위해 분할 정복 패턴을 채택했다.
최종적으로 각 목적 조직은 작은 레거시 저장소 1개와, 작은 신규 시스템 1개씩을 나눠 갖게 된다. 레거시 저장소의 코드가 줄어든 만큼 빌드 속도도 개선되었고, commit log 도 체계적으로 관리되며, 격리된 QA 환경 덕분에 일정을 추산하기도 편해졌다. 무엇보다 중요한 것은 레거시 코드 개편 또한 해당 목적 조직의 목표가 되었다는 것이다. 비즈니스 속도 개선을 위해 PM, PO, 디자이너, 개발자가 다 함께 레거시 개편을 검토하기 시작했다.
기가 막힌 방법! 그럼 앞으로도 이런 문제를 마주했을 때 프로젝트를 복제하면 만사 OK 일까?
하나의 서비스를 N 개로 분리하면서 수 많은 문제가 드러났고, 이것들을 하나씩 해결해나가는 과정을 정리해보자.
1개의 프로젝트가 N 개로 분할됨에 따라 인프라 역시 N 개로 복제가 필요해졌다.
복제하는 것 자체는 큰 일이 아니다! 중요한 것은 ‘어떻게 하면 실수없이 완벽히 동일한 환경을 구축할 것인가?’ 해답은 역시나 IaC(Infrastructure as Code)
기존 IaC 로 Terraform 을 사용하고 있었는데, 이번에 Pulumi 로 갈아탔다.
인프라는 N 개로 잘 복제되었는데, 서비스는 하나의 URL Domain 을 사용하고 있다. 어떻게 하면 각각의 프로젝트(인프라)가 요청을 잘 나눠가지도록 할까?
CF(CloudFront) 에서 Path 에 맞춰 해당 프로젝트의 LB(Load Balancer)로 트래픽 전달 시키는 방법을 채택했다. 굳이 LB 가 아닌 CF 를 앞단에 사용한 이유는 무엇일까? LB 가 지원하지 않는 다양한 서비스로 라우팅이 가능하기 때문!
모든 문제가 해결된 줄 알았는데, 내부 서비스끼리 Public IP 로 인터넷 통신을 한다는 문제가 생겨났다.
Public IP 로 통신한다는 것은 외부 트래픽으로 계산되는 비용과 시간의 문제도 있었고, 사내 Wi-Fi, VPN 기반 내부망 구성이 어렵다는 문제를 의미했다.
하나의 서비스에 Public(VPC) LB, Private (VPC) LB 를 모두 붙이는 방법으로 문제를 해소했다. 이를 통해 네트워크 리소스와 외부 트래픽으로 계산되는 비용 부담도 줄이고, 내부망 구성도 훨씬 쉬워졌다.
마지막으로 남은 문제.. 기존 하나의 서비스에서 수행되던 로직들이 여러 서비스로 분화되다보니 트랜잭션 관리가 어려워졌다. 모든 API 가 절대 실패하면 안되고 모두 성공해야한다는 가정이 생겨났다.
AWS SNS + SQS(혹은 Kafka) 를 이용한 비동기 아키텍처로 간단하게 문제를 해결했다. SNS 를 통해 이벤트 발행만 하면 되는 도메인 의존성을 분리하고, SQS 가 이벤트를 받아가 처리하는 것으로 각 서비스의 최종적 일관성 보장했다. 이 덕분에 각 API 의 결과를 기다릴 필요가 없어졌다.
그러나 역시 트랜잭션이 필요한 곳에는 사용하면 안된다. 여러 로직이 비동기로 실행되어도 무방할 때만 적용한다. 만약 트랜잭션이 꼭 필요한 상황(단일 DB를 사용하는 환경)이라면, 직접 테이블에 SQL 을 사용하는 것이 차라리 낫다. 그 외에는 복잡도가 너무 올라간다.
서비스는 N 개로 분리되었지만, 여전히 하나의 데이터베이스를 바라보고 있다. 여러 서비스에서 단일 DB 를 바라본다는 것은, 테이블 변경 사항에 대해 모든 조직이 Sync 를 이루고 있어야 한다는 것을 의미한다.
만약 문제가 발생한다면 문제가 되는 SQL 의 출처를 빠르게 파악해야하고, 여러 서비스 프로젝트에 중복되게 생겨나는 엔티티 클래스의 관리가 필요하다.
DB 를 나누고 DB 간 동기화를 이루는 방법도 있지만, 이것 보다 단일 DB 를 유지하면서 위의 문제들을 해결하는게 훨씬 간결하다고 한다.
인프런에서는 각 서비스마다 다른 DB 계정을 사용해서 문제를 추적하기 용이하도록 하고, 모든 DDL query 수행에 대해 단일 채널에서 모니터링을 진행한다고 한다. 또한 중복되는 엔티티 클래스에 대해서는 중복을 허용하는 것으로 결정했는데, 하나의 엔티티 클래스를 만들고 관리하면 모두가 해당 클래스에 로직을 추가하고, 시간이 흘러 클래스 파일 하나의 크기가 거대해지면 그 누구도 건드릴 수 없는 레거시 폭탄이 되어버리기 때문에 중복을 허용하기로 결정했다.
“만약 조직이 수십개가 되면 수 많은 중복을 다 허용할거냐?” 라는 물음에는 “조직의 수가 늘지 줄지는 아무도 모르는데, 불확실한 미래를 고려해서 위기를 키우고 싶지 않다. 불확실한 미래에는 어설픈 추상화/공통화보다는 삭제하기 쉬운 중복이 낫다.” 라는 답변을 남기셨다.
너무너무 설득력이 강한 멘트… “삭제하기 쉬운 중복이 낫다.”. 항상 “여기저기 중복을 만들면 변경이 생겼을 때 한꺼번에 인지하고 다 바꿀 수 있어요?” 라는 질문에 말문이 막히곤 했는데, 충분한 대답이 될 수 있는 강렬한 멘트다!
그럼에도 불구하고 도메인에 종속적이지 않은 공통 유틸 클래스, 함수들은 GIthub Registry 로 관리한다.
인프런에는 아직 해결하지 못한, 비즈니스에 독립적인 플랫폼 성격의 API(이메일/문자/카카오톡 발송 등)들이 남아있다. 엄밀히 말하면 비즈니스 로직이 아니기 때문에 어떤 목적 조직도 이걸 목표로 갖진 않는다. 때문에 순수하게 기능로직만 관리하는 DevOps 조직에서 해당 API 들을 관리하도록 할 계획이다.
또한 인증, 권한을 한 곳에서 관리할 수 있도록, 인증 성공시 Request Header 에 있는 값들을 Reqeust Param 으로 전환해서 전달할 수 있는 최소 스펙의 API Gateway 를 준비중이다.
그 외에도 엔드포인트 공유 방지를 위해 public LB 와 private LB 를 각각 나눈 모놀리틱 저장소 구성을 계획중이다.
인프런은 트래픽, 장애가 아닌 비즈니스 속도 유지를 위해 조직 개편을 진행하면서, 자연스럽게 아키텍처가 개편된 사례다기 “소프트웨어 구조는 해당 소프트웨어를 개발한 조직의 커뮤니케이션 구조를 닮게 된다.” 는 콘웨이의 법칙을 따른 것. 아마 다음 아키텍처 변경 역시도 다음 조직 개선이 진행될 때 진행될 것이라 예상한다.
거대한 레거시 프로젝트를 작게 분리해서 각 목적 조직이 오너쉽을 갖도록 유도하는 방법은 정말… 동공이 확대되면서 장표화면에 확 빨려들어가는 그 충격은 며칠이 지난 지금도 잊을 수가 없다.
또, 기술적인 문제를 회피하기 위해 사람의 마음을 움직이는 것보다 사람의 마음을 움직이기 위해 기술적인 문제를 맞부딪히는 과정이 짜릿하게 느껴졌다. (어차피 개발자는 기술적인 문제 해결하라고 돈받는 거기도 하고…)
조직을 운영한다는 것은 단순히 높은 시야에서 문제해결에 집중하는 것 뿐만 아니라, 조직의 심리도 적절히 관리하고 이용할 줄 알아야 한다는 걸 느끼게 된다. 사업이나 조직 관리와 마찬가지로, 개발도 결국 사람이 사람과 하는 일이기 때문에 나도 ‘어떻게 하면 기술적으로 더 멋지게 만들 수 있을까?’ 만큼 ‘어떻게 하면 우리 팀원들이 더 만족할 수 있을까?’ 고민을 충분히 해봐야겠다.
]]>다음 글: spring retry 이해 및 예제 정리
circuit breaker 를 처음 접하게 되었을 때 retry 와 비슷한 기능을 하는 것 같아서 자주 헷갈렸다.
특히 @Retryable
과 @Recover
를 조합하다보면 circuit breaker 와 정말 유사한 결과물을 만들 수 있다.
그러나 기술의 컨셉을 이해하고 여러가지 테스트를 해보니 차이가 느껴진다.
우선 circuit breaker 와 retry 는 모두 fault-tolerance(장애허용)을 위한 기술이다. 그러나 실질적으로 추구하는 목표와 해결하는 방식이 서로 다르다.
차이가 느껴지는가? circuit breaker 는 요청 자체를 차단하는 것에, retry 는 요청을 다시 시도하는 것에 중점을 두고 있다. 이번 글에서는 circuit breaker 에 대해 중점적으로 다뤄보겠다.
circuit breaker 를 사용하기 적합한 예시 상황을 가정해보자.
사용자 입장에서 동물 사진을 요청하면, 각 동물 사진을 저장 서버로부터 가져와 응답해주는 서비스가 있다.
이 서비스는 요청처리 서버
와 고양이 사진 저장 서버
로 구성되어 있다.
요청처리 서버
는 고양이 사진 저장 서버
에게 요청을 보낼 때 circuit breaker 를 사용한다.
때문에 요청 중 예외가 발생하거나 circuit breaker 가 OPEN 되었을 때 응답을 내릴 수 있는 기본 이미지가 준비되어있다.
갑작스럽게 고양이 사진 저장 서버
에서 지연이 발생한다.
사용자는 고양이 사진을 요청하지만, 고양이 사진 저장 서버
의 지연으로 인해 원하는 응답을 받지 못한다.
고양이 사진 저장 서버
의 응답이 지연되면서, 덩달아 앞단 요청처리 서버
또한 지연이 시작된다.
고양이 사진 저장 서버
는 점차 밀려오는 요청을 처리하느라 계속해서 지연 상태에 빠지게 된다.
앞단 요청처리 서버
도 지연량이 점차 많아짐에 따라 강아지 사진을 원하는 요청을 처리할 때도 영향을 받게 될 것이다.
이럴 때 circuit breaker 를 통해 고양이 사진 저장 서버
로 요청을 차단하고 미리 지정해둔 fall back 응답을 사용하면,
사용자에게 장애 상황임을 노출하지 않을 수 있고, 요청처리 서버
의 응답지연도 방지할 수 있다.
circuit breaker 는 3가지 상태에 대한 FSM(Final State Machine)을 기반으로 동작한다.
요청의 성공과 실패에 대한 metric 을 수집하고, 미리 지정해둔 조건에 따라 상태가 변화한다. 그 외에도 metric 을 수집하지 않는 2가지 특수 상태가 존재한다.
circuit breaker 는 metric 을 수집하고 분석한다. 수집한 결과는 원형 배열 형태의 sliding window 에 담긴다.
두 타입 모두 요청이 실패했음을 판단하는 기준이 동일하다. 요청 실패의 기준은 2가지다.
circuit breaker 의 OPEN state transit 은 exception 과 slow call 의 관계없이, 지정한 실패율이 달성되면 바로 진행된다. sliding window size 10, failure rate 50% 상태에서의 예시 상황을 살펴보자.
또한 circuit breaker 의 state transit 은 sliding window 크기만큼 호출이 기록된 경우에만 계산이 진행된다. 가령 sliding window size 10, failure rate 50% 상태에서 9개 요청 중 9개 요청 모두가 exception 이 발생하더라도 OPEN 으로 변환은 진행되지 않는다.
테스트에 사용된 코드는 https://github.com/Hyeon9mak/spring-boot-circuit-breaker-playground 에서 확인할 수 있다.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
// ...
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
implementation("org.springframework.boot:spring-boot-starter-aop")
}
aop 라이브러리 의존이 추가되지 않으면 정상적으로 동작하지 않는다.
# application.yml
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
failureRateThreshold: 50
permittedNumberOfCallsInHalfOpenState: 5
registerHealthIndicator: true
management:
endpoints:
web:
exposure:
include:
- "*" # 테스트를 위해 actuator 전체 노출
health:
circuitbreakers:
enabled: true # circuitbreakers 정보 노출
최근 10회 요청 중 50% (5회) 이상 요청 실패시 OPEN 상태로 전환된다.
@RestController
class CircuitBreakerTestController(
private val circuitBreakerTestService: CircuitBreakerTestService,
) {
@GetMapping("/cats/{id}/image")
fun catImage(@PathVariable id: Long): String = circuitBreakerTestService.catImage(id = id)
}
@Service
class CircuitBreakerTestService {
@CircuitBreaker(name = "cat-image-circuit-breaker", fallbackMethod = "fallbackCatImage")
fun catImage(id: Long): String {
if (id < 10L) {
return "$id cat's image.png"
}
throw RuntimeException("there is no cat's image for $id")
}
private fun fallbackCatImage(id: Long, t: Throwable): String {
return "fallback cat image.png"
}
}
위 설정으로 의도적으로 RuntimeException 이 발생하는 요청 9번을 보내본다.
curl -X GET http://localhost:8080/cats/99/image
fallback cat image.png
curl -X GET http://localhost:8080/actuator/circuitbreakers | jq
{
"circuitBreakers": {
"cat-image-circuit-breaker": {
"failureRate": "-1.0%",
"slowCallRate": "-1.0%",
"failureRateThreshold": "50.0%",
"slowCallRateThreshold": "100.0%",
"bufferedCalls": 9,
"failedCalls": 9,
"slowCalls": 0,
"slowFailedCalls": 0,
"notPermittedCalls": 0,
"state": "CLOSED"
}
}
}
9회차까지 매번 실패하는 요청을 보내다가, 마지막 10회차에 정상 요청을 보냈다. circuit breaker 는 어떤 상태를 갖게 될까?
curl -X GET http://localhost:8080/actuator/circuitbreakers | jq
{
"circuitBreakers": {
"cat-image-circuit-breaker": {
"failureRate": "90.0%",
"slowCallRate": "0.0%",
"failureRateThreshold": "50.0%",
"slowCallRateThreshold": "100.0%",
"bufferedCalls": 10,
"failedCalls": 9,
"slowCalls": 0,
"slowFailedCalls": 0,
"notPermittedCalls": 0,
"state": "OPEN"
}
}
}
전체 10회 요청중 마지막 1회를 제외한 나머지 9회 요청이 모두 실패했기 때문에 실패율이 90% 에 달한다. 이 때문에 circuit breaker 가 OPEN 상태로 전환되었다. 여기서 정상 요청을 보내면 어떻게 될까?
curl -X GET http://localhost:8080/cats/1/image
fallback cat image.png
정상적인 요청을 보냈음에도 circuit breaker 는 해당 메서드가 정상적으로 요청을 처리할 수 없다고 판단하고, 무조건 fallback 메서드로 응답을 처리한다.
circuit breaker 는 자신의 상태를 AtomicReference
에 저장해서 원자 연산을 진행하기 때문에 멀티 스레드로부터 안전하다.
그러나 주의할 점은 sliding window size 와 동시에 수행할 수 있는 스레드의 개수는 절대 무관하다는 것이다. 가령 sliding window size 10 은 10개 스레드만 동시에 작업이 가능하다는 뜻이 아니다. circuit breaker 에 영향을 받는 동시 스레드 개수를 제한하려면 bulkhead 를 추가로 활용하자.
@NotBlank
, @Size
등과 같은 javax.Validation annotation 을 사용하는 경우
제대로 validation 이 동작하지 않는 경우가 있다.
예시를 통해 문제 상황을 인지하고, 문제 원인과 해결 방법을 알아보자.
data class BookEnrollReq(
@NotBlank(message = "책 제목은 공백일 수 없습니다.")
val title: String,
@NotBlank(message = "책 저자명은 공백일 수 없습니다.")
val author: String,
)
위 BookEnrollReq
data class 는 도서 관리 시스템에서 새로운 도서를 등록할 때 사용되는 request dto 이다.
@NotBlank
annotation 을 통해 title
또는 author
가 공백일 경우 validation 이 동작하도록 설정해주었다.
과연 실제로 validation 이 동작할까?
internal class BookEnrollReqTest : StringSpec({
val validator = Validation.buildDefaultValidatorFactory().validator
"책 제목이 공백일 경우 예외가 발생한다." {
// given
val bookEnrollReq = BookEnrollReq(
title = "",
author = "현구막",
)
// when
val results = validator.validate(bookEnrollReq)
// then
results.map { it.message } shouldContainExactly listOf("책 제목은 공백일 수 없습니다.")
}
})
Expecting: ["책 제목은 공백일 수 없습니다."] but was: []
Expected :["책 제목은 공백일 수 없습니다."]
Actual :[]
실제로는 validation 이 동작하지 않는다.
작성된 Kotlin 코드를 Java 코드로 변환해보면 의외로 쉽게 원인을 확인할 수 있다.
IntelliJ 기준
Menu
>Tools
>Kotlin
>Show Kotlin Bytecode
>Decompile
public final class BookEnrollReq {
@NotNull
private final String title;
@NotNull
private final String author;
@NotNull
public final String getTitle() {
return this.title;
}
@NotNull
public final String getAuthor() {
return this.author;
}
public BookEnrollReq(
@NotBlank(message = "책 제목은 공백일 수 없습니다.") @NotNull String title,
@NotBlank(message = "책 저자명은 공백일 수 없습니다.") @NotNull String author
) {
Intrinsics.checkNotNullParameter(title, "title");
Intrinsics.checkNotNullParameter(author, "author");
super();
this.title = title;
this.author = author;
}
// 그 외 copy, toString, equals, hashCode ...
}
변환된 Java 코드를 잘 살펴보면 @NotBlank
annotation 설정이 constructor parameter 에만 사용되는 모습을 확인할 수 있다.
즉, 현재 title
, author
field 에는 @NotBlank
annotation 이 제대로 적용되지 않았다.
@NotNull
annotation 의 경우 Kotlin 의?
연산자 생략으로 인해 모든 field, getter, getter 에 적용되어 있다.
어째서 이런 일이 발생했을까? 바로 Kotlin primary constructor 의 특성 때문에 그렇다. Kotlin primary constructor 는 아래와 같은 형태로 간단하게 생략이 가능하다.
// 서로 같은 코드
class Book {
val title: String
val author: String
constructor(title: String, author: String) {
this.title = title
this.author = author
}
}
// 서로 같은 코드
class Book(
val title: String,
val author: String,
)
때문에 개발자 눈에 보여지는 Kotlin constructor 공간이 사실 Java 코드로 치면 constructor 이자, getter 이자, field 인 셈이다. 그리고 Kotlin data class 는 무조건 primary constructor 를 생략한 상태로 가지고 있다.
class Book(
// constructor이자, getter 이자, field 이자, getter
val title: String,
val author: String,
)
data class BookEnrollReq(
// constructor이자, getter 이자, field 이자, getter
val title: String,
val author: String,
)
Kotlin 입장에서는 @NotBlank
와 같은 annotation 이 명시되었을 때
constructor, field, getter 중 어떤 곳에 적용할지를 고민하게 된다.
Kotlin 은 기본적으로는 아래와 같은 순서로 적용을 결정한다.
그렇기 때문에 annotation 을 어디에 붙일 것인지(use site)를 명시해주어야 한다. 적용가능한 use site 의 종류는 아래와 같다.
file
: 파일property
: Kotlin 의 property (이 use site 를 사용하면 Java에선 볼 수 없음)field
: Java 의 field(Kotlin 의 property)get
: property getterset
: property setterreceiver
: 확장 함수나 속성의 수신 객체 getterparam
: constructor gettersetparam
: property setter getterdelegate
: delegate property 의 인스턴스를 저장하는 fieldrequest dto 의 @NotBlank
annotation 이 동작하도록 하기 위해선 field
use site 를 명시해주면 된다.
data class BookEnrollReq(
@field:NotBlank(message = "책 제목은 공백일 수 없습니다.")
val title: String,
@field:NotBlank(message = "책 저자명은 공백일 수 없습니다.")
val author: String,
)
public final class BookEnrollReq {
@NotBlank(message = "책 제목은 공백일 수 없습니다.")
@NotNull
private final String title;
@NotBlank(message = "책 저자명은 공백일 수 없습니다.")
@NotNull
private final String author;
@NotNull
public final String getTitle() {
return this.title;
}
@NotNull
public final String getAuthor() {
return this.author;
}
public BookEnrollReq(@NotNull String title, @NotNull String author) {
Intrinsics.checkNotNullParameter(title, "title");
Intrinsics.checkNotNullParameter(author, "author");
super();
this.title = title;
this.author = author;
}
// 그 외 copy, toString, equals, hashCode ...
}
간혹 Java 를 사용할 때 습관으로 @NotNull
annotation 을 사용해서 nullable 에 대해서도 꼼꼼히 검증하는 경우가 있다.
그러나 우리는 null-safe 한 언어인 Kotlin 을 사용중임을 잊지 말자.
?
연산자를 제거하는 것만으로도 @NotNull
annotation 이 필요없는 안전한 코드를 작성할 수 있다.
data class BookEnrollReq(
@NotNull(message = "책 제목은 공백일 수 없습니다.")
val title: String?,
@NotNull(message = "책 저자명은 공백일 수 없습니다.")
val author: String?,
)
data class BookEnrollReq(
val title: String,
val author: String,
)
2023년 3월 진행된 우아한테크세미나 테크 리더 3인이 말하는 “개발자 원칙” 중 이동욱님 세션을 듣고나서 정리한 내용.
일정은 곧잘 지켜내지만 버그를 많이 만들어내는 개발자와 일정은 못 지키지만 버그가 없는 완벽한 프로그램을 만드는 개발자 중 어떤 개발자가 더 좋은 개발자일까?
사실 이 질문에 대한 답변은 이미 2020년 4월 5일에 이동욱님 블로그에 올라온 일정 vs 퀄리티 (오늘의 질문 2020.04.04) 에 나와있다. 프로그래머에게 요구되는 것은 100점이 아닌, 80~90점 짜리 프로그램을 기한 내에 완성하는 일이다. 가장 중요한 것은 고객이 원하는 기능을 ‘고객이 원하는 시점’에 전달하는 것. 개발자 뿐만 아니라 모든 직군에 있어서, 돈을 받으며 일을 하는 프로로서 당연히 지켜야 할 원칙이라고도 볼 수 있겠다.
오해해서 안되는 것은, 일정을 지키기 위해 20~30점 짜리 프로그램을 만들어도 된다는 것은 아니다. 아무리 급해도 항상 80~90점 짜리 소프트웨어를 일정 내에 개발할 수 있는 방법을 연구하고, 연마해야 한다. 그것이 경력이고, 짬밥이고, 생산성이다.
그렇다면 항상 일정을 잘 지키면서도 퀄리티 높은 코드를 만드는 개발자들의 특징은 무엇일까?
대부분의 개발자는 자신이 최근에 접한 이야기나 읽은 책, 인상 깊었던 이야기에 따라서 매번 다르게 설계를 고민한다. 그러나 그렇게 단기간에 만들어진 원칙들은 디테일한 부분까지 답을 내려주지 못하고, 설계에 대한 확신을 갖지 못해 많은 시간을 허비하게 만든다.
그러나 실력이 뛰어난 개발자들은 본인만의 원칙을 기준으로 빠르게 의사결정을 진행한다. 그동안 경험을 통해 여러가지 확고한 원칙을 세웠기 때문에 대부분의 경우 빠르게 결정을 내릴 수 있고, 정말 설계가 필요한 부분에서만 깊은 고민을 진행할 수 있기 때문이다. 이렇게 되면 일정은 일정대로 지켜지고, 퀄리티가 정말 중요한 부분에서도 퀄리티를 챙길 수 있게 된다.
정리해보면 둘의 차이는 “매 순간마다 자신의 선택을 고민하느냐, 원칙에 따라 빠르게 결정 후 중요한 것만 고민하느냐.” 가 된다.
현실 세계의 변화와 설계 사이의 결합도를 줄여야 한다.
사용자의 휴대전화번호를 식별자로 사용하는가?
자신의 힘으로 제어할 수 없는 속성에는 의존하지 말라.‘실용주의 프로그래머’ 중
“한국 사람의 주민등록번호는 절대 변하지 않는 고유 식별자니까, 주민등록번호를 PK 로 사용하자.” 라는 의사 결정으로 주민등록번호를 PK 로 회원정보를 저장했다가, 사용자의 주민등록번호를 함부러 수집하지 못하도록 개인정보보호법 개정되어 큰 낭패를 본 사례는 이미 너무나도 유명하다.
외부에서 전달 받은 데이터를 절대 주요한 결정 키로 사용해선 안된다. 내가 직접 만들고 관리할 수 있는 값이 아니라면, 주요한 의사결정 키로 선택하지 말자. 그래야 외부의 변화로부터 안전할 수 있고, 큰 변화가 발생하더라도 제어할 수 있다.
백엔드 입장에서는 조금 더 적나라한 이야기를 해보자. SQL 보다는 애플리케이션 레벨에서 값을 직접 다루자. 단순하게 생각했을 땐 백엔드 개발자 입장에서 SQL 보다는 애플리케이션 코드를 더 자주 보게 되므로 관리하기가 쉽고, 엣지 케이스를 생각해봐도 데이터베이스보다 애플리케이션 실행 코드를 튜닝하는 것이 훨씬 쉽다.
만약 데이터베이스에서 password()
, encrypt()
와 같은 함수를 사용해서 비즈니스 로직을 구현한 경우
분산 환경, 멀티 마스터, 데이터베이스 교체와 같은 상황에서 곧바로 영향을 받게 된다.
제어할 수 없는 것에 크게, 많이 의존할 수록 변화에 쉽게, 자주 흔들리는 소프트웨어가 만들어진다.
class Car {
var distance: Int = 0
private set
fun move() {
val randomNumber = Random.nextInt(0, 9)
if (randomNumber > 4) {
distance += 1
}
}
}
위와 같은 Car
클래스의 move()
메서드에 대해 테스트를 작성해보자.
"자동차는 숫자 4 이상이면 이동한다." {
val car = Car()
car.move()
car.distance shouldBe 1
}
move()
내부에서 Random
에 의존하고 난수를 생성하고 있기 때문에 테스트 작성이 거의 불가능하다.
확률적으로 테스트는 통과하거나 실패할 것이고, 아마도 reflection 이나 mock 프레임워크를 이용해야 테스트가 가능할 것이다.
하나의 테스트를 위해 mock 프레임워크가 추가되어야 하며, 만약 mock 프레임워크를 제거할 경우 테스트는 바로 실패하게 될 것이다.
불필요한 의존으로 인해 변화에 쉽게 흔들리는 소프트웨어가 된 상태다.
class Car {
var distance: Int = 0
private set
fun move(number: Int) {
if (number > 4) {
distance += 1
}
}
}
"자동차는 숫자 3 이하면 이동하지 않는다." {
val car = Car()
car.move(3)
car.distance shouldBe 0
}
"자동차는 숫자 4 이상이면 이동한다." {
val car = Car()
car.move(4)
car.distance shouldBe 1
}
하지만 Random
에 대해 의존하지 않고, Random
을 통해 생성된 값을 외부로부터 주입 받는다면
내가 만들어낸 Car
클래스에 대해 자유롭고 편리한 테스트 작성이 가능해진다.
내가 제어할 수 있는 것과 제어할 수 없는 것들을 분류하면, 제어할 수 있는 것들에 대한 단위 테스트가 쉬워진다.
제어할 수 없는 내용들을 모두 mock 처리하는 것도 테스트가 쉬워진다.
내가 제어할 수 있는, 제어하기 쉬운 것들은 내가 만들어낸 비즈니스 로직들. 내가 제어할 수 없는, 제어하기 어려운 것들은 외부로부터 전달되는 UI, Data, 서버 통신들.
그렇다면 우리가 해야할 일은 무엇일까?
정말 단순하게도 제어가 쉽게 가능한 코드들을 생산해서 domain 계층을 최대한 두텁게 만들고, 제어가 어려운 코드들은 ui, persistence 계층으로 최대한 밀어넣어 격리 시키는 것. 그렇게 해서 유지보수 할 일이 생기면 domain 계층에서 최대한 일을 해결하는 것.
리더로서 회사 생활 내에서도 내가 제어할 수 있는 것과 제어할 수 없는 것들이 나뉘어 있다. 우리 회사의 매출이나 투자 상황, 개발자의 연봉이나 업계 전반적인 분위기는 내가 제어할 수 없다. 그렇지만 우리 팀원들의 성장과 성장을 위한 환경 조성에는 기여할 수 있는 부분이 상당히 많다.
팀원들의 성장을 위해선 주변에 좋은 개발자가 많아야한다. 그러나 좋은 개발자를 쉽게 제어할 순(데려올 순) 없다. 그래서 우리 팀원들이 좋은 시니어들의 노하우를 흡수 할 수 있는 환경에 계속 노출 시킨다. (운이 좋다면 좋은 개발자가 발표 도중 우리 팀을 마음에 들어할 수도 있다!) 정기적인 외부 시니어 강연을 진행한다면 팀원들이 먼저 필요한 노하우에 대해 이야기 하기 시작한다. “YY 컨퍼런스에서 발표하신 BB 님께 여쭤보고 싶은게 있어요.”, “XX 회사의 리드로 계신 AA 님을 만나뵙고 싶어요.”
그 외에도 잦은 빈도로 피드백을 줄 수 있는 환경을 조성해야한다. 인격적인 피드백 뿐만 아니라 코드에 대해서도. sonarqube 를 이용한 정적 분석이나 테스트 코드 작성, Lint, 코드 포맷팅도 빠질 수 없는 요소다.
제어할 수 없는 것에는 거리를 두고, 제어할 수 있는 것에만 집중하기.
할 수 있는 것에만 집중하고 긍정적으로 상황 해석할 줄 알아야 한다.
제어할 수 없는 것에 매달리고 비관적으로 생각해봤자 달라지는 것은 아무 것도 없다.
수년간 160km 이상 떨어진 다른 학교에서 연습 경기를 해야 하는 팀의 감독이라면?
“장소를 옮겨 가면서 홈경기를 치르면 원정경기에 강해질 것이다. 다른 곳에서 시합할 때의 산만함과 혼란스러움에 익숙할 테니까.” - 88연승의 비밀 중
되는 일만 하면 성공할 수 없다. 안되는 일을 되게 한다라는 믿음, 뽕이 있어야 한다. 안되는 일을 하는 곳이 스타트업이다. 그런 뽕맛이 있어야 한다.
“트래픽이 큰 회사에 가서 여러가지 장애 상황을 겪어봐야지 성장할 수 있는게 아닐까?”
물론 그런 상황을 자주 겪을수록 빠르게 성장할 수 있겠지만, 트래픽이 작다 뿐이지 우리회사에서 진행 중인 개발문화나 프로세스가 틀리지 않았다는 것을 꼭 인지해야한다. 어떻게 하면 보다 더 큰 경험을 할 수 있을지. 만약 좋지 않은 환경에 놓여 있다면 그 환경 속에서도 배울 부분을 찾아내고, 긍정적으로 생각하는 방법을 연습하자.
“제가 틀렸네요.” 라고 말하고 인정할 수 있는 것. 주니어 땐 너무나 꺼내기 쉬운 말이지만 시니어가 될수록 말하기 어렵다. 연차가 쌓이기 전부터 미리미리 자신이 틀렸음을 이해하고 인정하고 이야기할 수 있는 습관을 만들어두자. 훗날 큰 도움이 되어 줄 것이다.
항상 긍정적으로 생각하고, 겸손하자.