요약: 이 글은 카카오페이 혜택 서비스를 개발하며 발생한 동시성, DB 성능, 멱등성 이슈와 그 해결 방법을 다룹니다. 동시성 문제는 Redis 분산 락을 사용해 해결하고, DB 성능 문제는 트랜잭션 분리로 해결했습니다. 또한, 멱등성 문제는 페이포인트 지급 이력을 활용한 멱등성 보장 로직을 통해 해결했습니다. 주니어 서버 개발자를 대상으로 실제 문제 해결 경험을 공유하며, 시스템의 안정성과 일관성을 강조하고 있습니다.
💡 리뷰어 한줄평
rain.drop 몰라도 재밌고 알아도 재밌는 이야기!! 주니어 개발자가 어떤 이슈를 마주했을까요? 데이지와 함께 여러 이슈들과 해결 방법을 같이 들여다봐요!
doy.ou 서버 개발자라면 누구나 한 번쯤은 고민했을법한 이슈를 소개하는 글입니다. 특히 주니어 개발자분들에게 도움이 될 거 같아 추천드립니다!
시작하며
안녕하세요. 카카오페이 채널서버유닛에서 혜택 서비스를 개발하고 있는 데이지입니다.
현재 혜택탭과 혜택탭에 속한 버티컬 프로덕트(e.g. 퀴즈타임, 매일모으기, 페이래플 등)의 서버 개발을 담당하고 있습니다.
혜택탭 | 퀴즈타임 | 페이래플 |
---|---|---|
서비스를 개발하며 예상하지 못한 이슈를 마주칠 때가 있지 않나요? 이런 경우 어떻게 해결하시나요?
저는 보통 코드를 디버깅하며 문제를 풀어나가지만,
다른 사람이 비슷한 이슈를 어떻게 해결했는지 참고하기 위해 기술 블로그를 살펴보기도 합니다.
제가 타 기술 블로그에서 도움을 받은 것처럼 다른 사람이 제 글을 통해 조금이나마 도움을 받길 바라는 마음으로 이번 글을 작성합니다.
3년 차 주니어 서버 개발자로서 혜택 서비스를 개발하며 마주쳤던 이슈와 해결 방안을 이어지는 내용을 통해 공유하겠습니다.
이 글의 메인 대상 독자는 신입-주니어 레벨의 서버 개발자입니다.
동시성 이슈 (a.k.a. 따닥 이슈)
개념 및 문제점
동시성 이슈, 흔히 말하는 따닥 이슈를 아시나요?
유저가 어떤 버튼을 한 순간에 여러 번 클릭하여 API 호출이 중복으로 일어나게 되면 따닥 이슈가 발생했다고 합니다.
이런 경우 비즈니스 로직에서 예외 처리를 해주어도, 여러 요청이 동시에 비즈니스 로직을 타게 되어 예외가 발생하지 않고 통과하게 됩니다.
따닥 이슈는 어떤 문제를 발생시킬 수 있을까요?
제가 개발하고 있는 혜택 서비스의 경우 유저가 어떠한 액션을 수행했을 때, 이 액션에 대한 보상으로 페이포인트 리워드를 제공합니다.
페이포인트는 실제 현금은 아닌데요. 그렇지만 현금처럼 사용할 수 있는 디지털 화폐이고, 회사 예산을 사용하기 때문에 중복으로 제공하면 안 됩니다.
하지만 따닥 이슈가 발생하면, 유저에게 페이포인트가 중복으로 지급되는 문제가 발생할 수 있습니다.
해결 방안 및 선택 이유
저는 퀴즈타임 서비스를 오픈하기 전 해당 이슈를 방지할 필요가 있었습니다.
1차적으로는 FE에서 디바운스를 통해 동시성 이슈를 막고 있지만, 100% 막을 수는 없어 서버에서도 이를 인지하고 방어해야 하는데요.
서버에서 해결할 수 있는 방법에는 애플리케이션 단 분산 락 구현, DB 단 베타 락(쓰기 락) 사용 등이 있습니다.
저는 여러 방법 중 애플리케이션 단 분산 락 구현 방법을 활용했습니다.
아무래도 서버 애플리케이션을 직접 개발하다 보니 애플리케이션 단에서 처리하는 게 유지보수 측면에서 좋다고 생각했고,
분산 락의 경우 Controller 단에서 예외를 던져 빠른 실패가 가능하여 좋다고 생각했습니다.
DB 베타 락은 Repository(Infra) 단에서 예외를 던지기 때문에 서버 리소스를 불필요하게 낭비한다고 생각했습니다.
해결) 분산 락 구현
분산 락은 Redis(Lettuce)를 사용해서 구현했습니다. 정확히는 스핀 락을 구현했는데요.
이때 Redis의 SETNX(SET if Not eXists) 명령어를 활용했습니다.
스핀 락은 락을 사용할 수 있을 때까지 지속적으로 확인하며 기다리는 방식입니다.
Redis 서버에 지속적으로 SETNX 명령을 보내서 락을 획득할 수 있는지 확인하는 방법입니다.
클래스는 크게 2개로 구성되는데요.
Redis를 직접 사용하는 LockManager
, 분산 락 관련 로직을 처리하는 RedisLockUtil
입니다.
먼저, LockManager
부터 살펴보겠습니다.
@Component
class LockManager(private val redisTemplate: RedisTemplate<String, String>) {
fun lock(key: String): Boolean = redisTemplate.opsForValue().setIfAbsent(key, "lock", Duration.ofSeconds(3)) ?: false
fun unlock(key: String): Boolean = redisTemplate.delete(key)
}
RedisLockUtil
에 의존성 주입(DI)하기 위해 Component로 선언했습니다.- lock() : Redis에
key(e.g. ${quizId}:${userId}:earnReward) : value(lock)
을 저장하여 락을 설정한 후 true(1)을 반환하고, 만약 이미 key가 존재한다면 false(0)을 반환합니다. 이때 TTL(Time To Live)은 3초로 설정했습니다. - unlock() : Redis에서 key를 삭제하여 락을 해제합니다.
다음으로, RedisLockUtil
을 살펴보겠습니다.
@Component
class RedisLockUtil(private val lockManager: LockManager) {
init {
manager = lockManager
}
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
private lateinit var manager: LockManager
fun <T> acquireAndRunLock(key: String, block: () -> T): T {
if (key.isBlank()) {
log.error("[RedisLock] key is blank.")
return block.invoke()
}
val acquired = acquireLock(key)
return if (acquired) {
proceedWithLock(key, block)
} else {
throw ApiException("[RedisLock] failed to acquire lock.")
}
}
private fun acquireLock(key: String): Boolean {
return try {
manager.lock(key)
} catch (e: Exception) {
log.error("[RedisLock] failed to acquire lock. key: $key", e)
false
}
}
private fun <T> proceedWithLock(key: String, block: () -> T): T {
return try {
block.invoke()
} catch (e: Exception) {
throw e
} finally {
releaseLock(key)
}
}
private fun releaseLock(key: String): Boolean {
return try {
manager.unlock(key)
} catch (e: Exception) {
log.error("[RedisLock] failed to release lock. key: $key", e)
false
}
}
}
}
RedisLockUtil
은 LockManager
를 DI 받아야 하기 때문에 역시 Component로 선언했습니다.
- acquireAndRunLock() :
LockManager#lock
에서 받은 응답값(Boolean 타입)을 바탕으로 특정 비즈니스 로직에 락을 걸거나, 예외를 던집니다. - acquireLock() :
LockManager#lock
을 사용하여 락을 획득합니다. - proceedWithLock() : 특정 비즈니스 로직을 실행하고, 실행이 끝나면 락을 해제합니다.
- releaseLock() :
LockManager#unlock
을 사용하여 락을 해제합니다.
최종적으로 Controller 단에서는 다음과 같이 사용합니다.
@PostMapping
fun earnReward(quizId: Long, userId: Long): QuizRewardResponse =
RedisLockUtil.acquireAndRunLock("${quizId}:${userId}:earnReward") { quizService.earnReward(quizId, userId) }
DB 트랜잭션 이슈
개념 및 문제점
하나의 트랜잭션에 내부 비즈니스 로직과 외부 클라이언트 호출 로직을 묶으면 어떤 일이 생길 수 있을까요?
내부 비즈니스 로직이 아무리 빨리 처리된다고 해도, 만약 외부 클라이언트 호출 로직이 느리게 처리되면 API 응답 시간이 길어지게 됩니다.
API 응답 지연은 유저에게 서비스가 느리고 불안정하다는 인상을 주어, 최악의 경우 서비스 이탈을 초래합니다.
또한, 서버 리소스 낭비로 이어져 서버 비용도 늘어납니다.
해결 방안 및 선택 이유
저는 혜택탭을 개편할 당시 이 문제를 마주했습니다.
혜택탭에서는 내부 DB에서 필요한 데이터를 가져와, 각 유저의 상황에 맞게 가공하여 활용하고 있는데요.
혜택 정보를 가공할 때 외부 클라이언트를 사용합니다.
개편 초기에는 한 트랜잭션에 이 로직들을 한 번에 묶어 처리하여 API 응답 시간이 일정하지 않고, 때때로 느린 경우가 있었습니다.
내부 비즈니스 로직은 성공했으나, 외부 클라이언트 호출 로직에서 네트워크 이슈 등 직접 제어할 수 없는 문제가 발생하는 경우 API 응답 타임아웃이 발생하기도 했습니다.
내부 로직이 외부 로직에 영향을 받는 게 맞지 않다고 판단하여, 이를 트랜잭션 분리해야겠다고 결정했습니다.
해결) 트랜잭션 분리
내부 비즈니스 로직은 트랜잭션을 사용하고, 외부 클라이언트 호출 로직은 트랜잭션을 사용하지 않도록 수정했습니다.
혜택탭은 혜택 정보를 가져올 때 DB에 저장되어 있는 혜택 정보를 가져오기도 하지만, 외부의 추천 로직을 탄 혜택 정보를 가져오기도 하는데요.
이때 외부 추천 로직에 이슈가 있을 경우 응답을 빈 리스트로 하게 했고, DB에 있는 별도의 혜택 정보를 응답하게 처리했습니다.
@Service
class BenefitInfoService(
private val noTransactionalService: BenefitInfoNoTransactionalService,
private val transactionalService: BenefitInfoTransactionalService,
) {
fun getBenefitInfo(userId: Long): BenefitInfoResponse {
// 외부 클라이언트 호출 로직 (트랜잭션 X)
val recommendedBenefitInfo = noTransactionalService.getBenefitInfoFromRecommendation(userId)
// 내부 비즈니스 로직 (트랜잭션 O)
val benefitInfo = transactionalService.getBenefitInfoFromDB(recommendedBenefitInfo)
}
}
@Service
class BenefitInfoNoTransactionalService(
private val recommendationClient: RecommendationClient,
) {
fun getBenefitInfoFromRecommendation(userId: Long): List<BenefitInfo> {
return recommendationClient.getBenefitInfo(userId) ?: emptyList()
}
}
@Service
class BenefitInfoTransactionalService(
private val benefitInfoRepository: BenefitInfoRepository,
) {
@Transactional(readOnly = true)
fun getBenefitInfoFromDB(recommendedInfo: List<BenefitInfo>): BenefitInfoResponse {
val benefitInfo = benefitInfoRepository.findAllBenefitInfo()
return if (recommendedBenefitInfo.isEmpty()) {
val extraInfo = benefitInfoRepository.findAllExtraBenefitInfo()
BenefitInfoResponse.from(benefitInfo + extraInfo)
} else {
BenefitInfoResponse.from(benefitInfo + recommendedInfo)
}
}
}
이처럼 트랜잭션을 분리하면 어떤 장점이 있을까요?
외부 시스템의 지연 또는 실패가 내부 비즈니스 로직에 영향을 미치지 않아 시스템의 안정성과 가용성이 향상됩니다.
그리고 더 유연한 에러 핸들링이 가능해집니다.
앞선 예시 코드처럼 외부 시스템에 에러가 발생한 경우 예외를 던지지 않고, 내부 시스템의 데이터를 활용하는 별도 로직으로 처리할 수 있습니다.
물론 상황에 따라 외부 시스템에 재처리 요청을 보내거나, 서버 밖으로 예외를 던질 수도 있습니다.
멱등성 이슈
개념 및 문제점
멱등성에 대해 들어보셨나요?
멱등성은 같은 연산을 N번 수행해도, 결과가 단 1번 수행했을 때와 동일하게 유지되는 성질을 말합니다.
즉 어떤 리소스에 A라는 요청을 했을 때 B라는 응답을 받았다면, 해당 리소스가 변하지 않는 한 A 요청에 대한 응답은 항상 B가 됩니다.
만약 멱등성이 지켜지지 않는다면 어떤 문제가 발생할까요?
동일한 작업이 여러 번 처리되어 리소스 낭비가 발생하고, 데이터 불일치 문제가 발생할 수도 있습니다.
또한 동일한 요청에 대해 다른 결과가 반환되어 시스템의 신뢰성이 떨어집니다.
해결 방안 및 선택 이유
저는 매일모으기 서비스를 이관할 때 해당 이슈를 마주했습니다.
매일모으기 서비스에서는 유저가 특정 액션을 행하면, 이 액션에 대한 보상으로 페이포인트를 지급합니다.
이때 페이포인트는 플랫폼에서 제공하는 내부 API를 사용해서 지급하고 있습니다.
이 API에는 페이포인트 중복 지급을 막는 로직이 있는데요.
그래서 특정 요청이 중복으로 들어오면 가장 처음 시도만 성공시켜 페이포인트를 지급하고, 이후 시도부터는 예외를 던지게 되어 있습니다.
저는 이 로직을 참고해서 페이포인트 지급에 성공하면 성공 응답을, 예외가 발생하면 실패 응답을 하도록 구현했습니다.
하지만 이는 멱등성이 지켜지지 않는다고 판단되어, 플랫폼과 논의 후에 멱등성을 지키기 위한 로직을 추가하기로 결정했습니다.
해결) 멱등성 보장 로직 추가
혜택 서버는 유저에게 페이포인트와 같은 리워드를 제공하면, 이에 대한 지급 이력을 남기고 있는데요.
이를 활용해서 매일모으기 서비스에 대한 멱등성 보장 로직을 구현했습니다.
아래 코드를 통해 살펴보겠습니다.
@Service
class OfferwallService(
private val earnPayPointHistoryRepository: EarnPayPointHistoryRepository,
private val payPointClient: PayPointClient,
) {
@Transactional
fun earnPayPoint(request: EarnPayPointRequest): EarnPayPointResponse = earnPayPointHistoryRepository.firstOrNull(request)?.let { // (1)
EarnPayPointResponse.from(it) // (2)
} ?: earnPayPointAndSaveHistory(request) // (3)
private fun earnPayPointAndSaveHistory(request: EarnPayPointRequest): EarnPayPointResponse {
try {
val response = payPointClient.earn(request) // (3-1)
earnPayPointHistoryRepository.save(EarnPayPointHistory.fromSuccess(response)) // (3-2)
return EarnPayPointRequest.from(response)
} catch (exception: Exception) {
earnPayPointHistoryRepository.save(EarnPayPointHistory.fromFail(exception)) // (3-3)
throw exception // (3-4)
}
}
}
- (1) 먼저, 요청(EarnPayPointRequest)에 해당하는 페이포인트 지급 이력(EarnPayPointHistory)이 있는지 확인합니다.
- (2) 만약
페이포인트 지급 이력이 있다
면, 해당 이력을 바탕으로 응답(EarnPayPointResponse)을 반환합니다.
이때 외부 클라이언트(PayPointClient)는 호출하지 않습니다. - (3) 만약
페이포인트 지급 이력이 없다
면, 페이포인트 지급을 위해 외부 클라이언트를 호출합니다.
외부 클라이언트 호출에 성공
하면, (3-1) 페이포인트 지급 후 (3-2) 성공 이력을 저장합니다.
외부 클라이언트 호출에 실패
하면, (3-3) 실패 이력을 저장하고 (3-4) 예외를 던집니다.
이 작업을 통해 동일한 요청에 대해서는 항상 동일한 응답이 내려가게 되었습니다.
결과적으로, 매일모으기 서비스의 리워드 지급 로직에 대한 멱등성을 보장할 수 있었습니다.
마치며
지금까지 혜택 서비스를 개발하며 어떤 이슈가 있었고, 어떻게 해결했는지 설명했습니다.
동시성 이슈는 레디스 분산락 구현으로, DB 성능 이슈는 애플리케이션단 트랜잭션 분리로, 멱등성 이슈는 멱등성 보장 로직 추가로 대응했습니다.
이처럼 전체적인 시스템의 안정성과 일관성을 보장하려면, 각 이슈 해결 방안을 종합해서 고려해야 합니다.
이 글에서 소개한 이슈는 타 서비스 개발 시에도 충분히 발생할 수 있는 이슈인데요.
혹시 비슷한/동일한 이슈를 마주한 분에게 조금이나마 도움이 되는 글이었으면 좋겠습니다.
긴 글 읽어주셔서 감사합니다!