안녕하세요, 코어서비스팀(이하 CST)의 백엔드 개발자 김주혁입니다.
PFCT에서 CST의 역할은 유저, 인증, 약관 등 여러 내부 서버가 활용하는 서비스들을 제공하는 것입니다. 제공하는 기능들의 특성상 외부 서비스들과 연동되는 비중이 크기 때문에 개발 시 복잡성이 높고, 각종 변수가 발생할 수 있다는 특징이 있는데요.
이번 글을 통해서는 제가 CST에서 어떻게 이런 복잡성과 변수들을 고려하는 엔지니어링 경험을 쌓고 있는지 공유드리려 합니다. 구체적으로 이번에는 ‘서비스 안정성 개선’ 프로젝트에 대해 소개하려 해요.
문제 인식: 고객 경험 향상을 위한 고민을 시작하다
PFCT는 자체 구축한 금융 시스템을 통한 대출, 투자, 신용관리 등의 서비스를 제공하는 회사입니다. 실제 돈이 오가는 금융 서비스를 제공하는 만큼, 고객의 ‘본인인증’을 필수로 요구하는 기능들이 있는데요. 해당 절차는 휴대폰 인증을 통해 진행되고 있습니다.
PFCT의 인증 서버에서 휴대폰 인증 프로세스는 아래 순서로 동작합니다.

휴대폰 본인 인증을 위해선 인증 서버가 외부 서비스와 통신을 해야 하며, 해당 외부 서비스는 실제 통신사들과 통신하는 과정을 거칩니다.
결국 외부 시스템이 안정적일수록 사용자가 본인인증 기능을 원활히 사용할 수 있게 되는 것입니다만, 여기서 종종 문제가 발생하곤 합니다. 외부 시스템의 점검, 버그, 장애뿐만 아니라 통신사 자체 점검 등의 사유로 월 1~5회 정도 본인인증 기능이 불안정적으로 동작하게 되는 것입니다.
여기서 불안정적이라 함은, 본인인증 기능이
- 아주 낮은 확률로 완전히 먹통인 경우
- 첫 시도 시에는 에러가 발생하지만 두 번, 세 번 시도 시에는 성공하는 경우
등을 뜻합니다.
위 두 가지 경우에서 가장 높은 빈도로 발생하는 이슈는 ‘첫 시도 시에는 에러가 발생하지만 두 번, 세 번 시도 시에는 성공하는 경우’인데요. 이땐 보통 사용자들이 이후 본인인증을 몇 번 더 시도하다 포기하게 되는 경우가 발생하곤 합니다. 앞서 말씀드렸듯 고객이 우리 서비스를 이용하기 위해선 반드시 본인인증 절차를 거쳐야만 하므로, 위 문제는 반드시 개선이 필요한 부분이었죠.
여기서 저를 비롯한 CST의 고민이 시작되었습니다. 서비스 안정성 개선을 통한 본인 인증 과정에서의 고객 경험을 향상하는 방법을 고민했어요.
어떻게 하면 가장 경제적인 방법으로 가장 큰 임팩트를 내며 효과적으로 문제들을 해결할 수 있을까?
외부 시스템이 점검을 하거나 장애가 나거나 스펙 외 응답들을 내려줄 때 이걸 자동으로 해결하거나 고객에게 알릴 수 있는 방법이 없을까?
라고 말이죠.
곧바로 이런 저런 방법들이 떠오릅니다.
- 외부 인증 시스템 점검 공지 자동 크롤링 후 고객향 내용 전달
- 외부 시스템에서 협의되지 않은 스펙/응답이 오는 경우 자동 알림
- 에러 비중이 특정 한계치를 초과하면 알림 후 자동으로 대체 인증 수단으로 전환
위 아이디어만으로는 너무 막연해서 꼬리물기 식의 질문을 팀원들과 주고 받기 시작했습니다.
- 고객이 점검 공지를 받으면 뭘 해야 하지?
- 고객이 점검을 받을 필요가 없게 만들 수 있을까?
- 개발자가 미스펙 알림을 받으면 뭘 해야 하지?
- 수동적 개입을 어느 부분에서 최소화해야 가장 큰 임팩트를 낼 수 있을까?
등등이 나왔죠.
꼬리에 꼬리를 물다 보니, 특정 시점이 되면 담당자 수동 개입은 불가피하며 정말 경제적인 방안으로, 큰 폭의 개선을 이루어내기 쉽지 않다는 판단이 들었습니다.
방법 찾기: 핵심 질문으로 실마리를 발견하다
이럴 때 필요한 것은 막힌 혈을 뚫어주는 핵심 질문입니다.
How and what exactly is the problem?
정확히 뭐가, 어떻게 문제인가.고객들이 휴대폰 본인인증을 못해서 서비스 이용이 불가한 상황이다.
이용이 어떻게 불가한가?
문제가 어떤 형태인가?
어떻게 안 되는 거지?
이 시점에서 저희 팀의 Observability-갓 역할을 수행중이신 기현님께서 엄청난 발견을 해주셨습니다.
외부 시스템 장애로 휴대폰 본인인증이 실패하는 경우 2~3회 재시도하는 사용자들은 최종적으로 본인인증에 성공한다.
이후 저희는 사용자 대신 재시도를 해주는 방식을 아래 조건들과 함께 고민하기 시작했습니다.
- 개발 리소스도 정말 조금 들지만
- 사용성에 큰 영향을 주지는 않는
방법으로 말이지요.
그렇게 아래와 같은 결론이 나왔습니다.
휴대폰 본인인증 실패 시 서버에서 요청 2회 재시도를 대신 해준다!
자, 그렇다면 이전 AS-IS 휴대폰 본인인증 플로우를 살펴볼게요.

위와 같이 한 건 한 건씩 사용자가 직접 재시도하는 방식이었다면,
이후 TO-BE 휴대폰 본인인증 플로우는 아래와 같이 변경했습니다.

구현: 단순한 엔지니어링을 거치다
구현은 단순합니다. resilience4j 를 활용해서 선언적 재시도 메커니즘을 구현하는 방식입니다.
우선, 보일러플레이트를 최소화 하고자 ResilienceRegistryFactory 라는 헬퍼 클래스를 만들고…
@Component
class ResilienceRegistryFactory(
@Value("\${application.resilience-config.retry.max-attempts}") val maxAttempts: Int,
@Value("\${application.resilience-config.retry.wait-duration-millis}") private val waitDurationMillis: Long
) {
private val defaultConfig = RetryConfig.custom<Retry>()
.maxAttempts(maxAttempts)
.waitDuration(java.time.Duration.ofMillis(waitDurationMillis))
.retryOnException { true }
.build()
private val retryRegistry = RetryRegistry.of(defaultConfig)
fun <T> retry(
callingClass: Class<Any>,
callable: () -> T,
retryMatcher: Predicate<Throwable> = Predicate { true },
maxAttempts: Int = this.maxAttempts,
waitDurationMillis: Long = this.waitDurationMillis
): T {
// retry 설정이 같으면 기존 retry 를 사용하고, 다르면 새로운 retry 를 생성한다.
val retry = if (maxAttempts == this.maxAttempts && waitDurationMillis == this.waitDurationMillis) {
retryRegistry.retry(/* name = */ callingClass.simpleName)
} else {
retryRegistry.retry(
/* name = */ callingClass.simpleName,
/* config = */ RetryConfig.custom<Retry>()
.maxAttempts(maxAttempts)
.waitDuration(java.time.Duration.ofMillis(waitDurationMillis))
.retryOnException(retryMatcher)
.build()
)
}
// retry 시 이벤트 바인딩
retry.eventPublisher.onRetry {
// retry 할 때 로깅하기, 알림, 설정 switch ON/OFF 등...
logger.warn("Resilience retrying... lastThrowable: ${it.lastThrowable}")
}
// callable 실행
return retry.executeCallable { callable.invoke() }
}
companion object {
private val logger = LoggerFactory.getLogger(this::class.java)
}
}
아래와 같이 사용합니다.
val responseString = resilienceRegistryFactory.retry(
callingClass = this.javaClass,
retryMatcher = { it is ExternalSystemXxxxException },
callable = { // 외부시스템 호출 }
)
프레임워크/라이브러리 사용을 최소화하고 선언형으로 구현해서 테스트하기에도 편리하며 딱 저희가 필요한 만큼만 사용할 수 있다는 장점이 있습니다.
class ResilienceRegistryFactoryTest {
@Test
fun `test retry mechanism retries correct number of times on specific exception`() {
// Given
val someBean = SomeBean()
val failTimes = 3
// When
assertThrows<Exception> {
ResilienceRegistryFactory(maxAttempts = failTimes, waitDurationMillis = 100)
.retry(
callingClass = this.javaClass,
callable = { someBean.throwException() },
retryMatcher = { it is Exception && it.message?.startsWith("XxxXxx") == true }
)
}
// Then
assertEquals(failTimes, someBean.invokedCount)
}
companion object {
class SomeBean {
var invokedCount = 0
fun throwException() {
invokedCount++
throw Exception("XxxXxx something went wrong")
}
}
}
}
임팩트 확인: 작은 투자로 큰 성과를 보다
구현은 단순했지만, 그 효과는 대단했습니다!

몇 달 전까지만 해도 잦은 빈도로 저희 팀의 스프린트를 방해하던 인증 이슈의 실패 비율은 최소 10 분의 1 규모로 축소되었습니다.
정말 짜릿한 경험이지 않나요? 고작 30~50라인 내외로 안정성을 10배 개선하다니?! 세상의 문제를 경제적으로 해결하는 것이야말로 엔지니어링의 정수가 아닐까 하고 생각해봅니다.
마치며
지금까지 저희 팀이 추구하는 ‘심플한 엔지니어링으로 강력한 임팩트 내기’의 프랙티스를 공유해보았습니다. 이렇듯 저희는 < 문제 인식 → 방법 찾기 → 구현 → 임팩트 확인 > 으로 이어지는 업무 프로세스를 통해 효율적으로, 또 효과적으로 일하는 노력을 지속하고 있습니다.
위처럼 일하는 것은 엔지니어로써 매우 보람찬 일입니다. 그리고 그것을 가능토록 하는 것이 조직 문화가 아닐까 생각이 듭니다. 다행히 PFCT는 모두가 효과를 내는 것에 대해 공감하고 서로 아낌없이 지원과 공유를 해주는 문화가 잘 형성되어 있어서 의지만 있다면 모두가 기회를 가질 수 있습니다.
앞으로도 이런 재밌는 사례들을 조금씩 공유드리려 합니다! 엔지니어링의 작은 투자로 큰 성과를 얻는 재미를 느끼고 싶거나 구현에 관해 궁금한 점이 있으시다면 아래 제 링크드인으로 연락주시기 바랍니다. 감사합니다!
https://www.linkedin.com/in/joo-hyuk-kim/
written by Joohyuk
끊임없이 성장하는 개발자들과 일하고 싶다면?