Hyun's Wonderwall

[BookDuck] 프로젝트 리팩토링 - (1) 부하 테스트 도구 선택(JMeter, k6), 외부 API Rate Limit 문제 해결 방법 고민, 동시성과 멱등성 보장에 대한 고민 본문

활동/프로젝트

[BookDuck] 프로젝트 리팩토링 - (1) 부하 테스트 도구 선택(JMeter, k6), 외부 API Rate Limit 문제 해결 방법 고민, 동시성과 멱등성 보장에 대한 고민

Hyun_! 2025. 7. 9. 05:08

 

2024년 9~12월 동안 EFUB 4기에서 학기 중 프로젝트로 BookDuck 프로젝트를 진행했었다. 나는 백엔드이고, 백엔드는 3명이 맡아 개발했다. 프로젝트 목표가 실사용자가 존재하는 서비스였는데, 목표가 높다보니 화면과 기능도 많아져서 최종 테이블 수가 21개, API 수가 85개에 달했었다.

  • 내가 개발한 도메인/기능: 유저, 유저 설정 독서레포트, 성장(경험치 및 레벨), 뱃지, 아이템, 유저 홈(리딩스페이스), 알림(SSE+FCM)
  • 내가 개발한 API 수: 30개

당시 새 기술을 학습하고(FCM) 효율적인 내부 로직 고민에 시간을 쏟느라(다양한 알림 유형, 게이미피케이션 시스템), 기능 구현 이상의 탐구를 진행하지 못한 데 대한 아쉬움이 남았었다.

따라서 이번 방학에 같이 프로젝트에 참여했던 백엔드 팀원(유정 언니)과 프로젝트를 되짚어보고 확장성 관련해 개선해보려 한다!


7/2 회의에서 한 일

  • ERD 구조와 API 명세서, Spring Boot 프로젝트를 확인해보며 프로젝트에 대한 개발 기억을 되살림
  • <처음 만난 리액트> 강의 1/2 수강 (필요할 경우 프론트엔드 수정 위해)
  • 성능 테스트 Apache JMeter를 사용해서 진행해봄 -> 실제로는 k6를 사용하기로 결정

Apache JMeter로 먼저 해본 이유는 이전에 했던 프로젝트("Artichat")에서 인프라 팀원이 JMeter로 부하 테스트를 진행해 알고 있었기 때문인데, 조사해보니 메모리 사용량이 많은 편이었다.

성능 테스트 도구 비교자료를 찾아본 결과, k6가 속도가 빠르고 Grafana k6 사용시 시각화할 수 있는 점도 매력적이어서 사용하기로 결정했다! 참고로 k6의 스크립팅 언어는 Go, JavaScript이다.

(*참고자료 -

성능테스트 (부하테스트 도구 비교) - jmeter, k6, ngrinder, locust https://baeji-develop.tistory.com/118,
Grafana k6으로 부하 테스트하고 시각화하기 https://velog.io/@heka1024/Grafana-k6%EC%9C%BC%EB%A1%9C-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0)

 

성능 테스트를 해본 결과 문제점을 하나 발견하게 되었다!!

Number of Threads(users): 1000Ramp-up period(seconds): 10Loop Count: 10

이 설정으로 테스트했는데 '통합 검색' API가 보낸 요청의 대다수가 실패하고 소수만 성공했다.

 


원인은 해당 기능이 의존 중인 외부 API의 Rate Limit이었다. 해당 기능은 내부적으로 도서 검색을 위해 Google Books API를 사용하고 있었는데, 이 때문에 Google Books API의 사용량 제한에 제약을 받아 커스텀 에러 (500, "외부 API 사용 중 문제가 발생했습니다.") 에러가 와르르 난 것이었다.

 

*참고-기존 호출 방식:

String url = "https://www.googleapis.com/books/v1/volumes?q=" + keyword + "&startIndex=" + (page * size) + "&maxResults=" + size + "&key=" + apiKey;

 

이 이슈를 포함해서, 프로젝트에 잠재된 이슈들(특히 동시성 관련)에 대한 해결 방안을 고민하고 관련 공부를 해오는 것이 다음 회의까지 할 일이었다!


7/3-7/9 진행한 것

  • <처음 만난 리액트> 강의 필요한 부분 수강 완료
  • 외부 API Rate Limit으로 인한 요청 실패 해결 방법 고민
  • 동시성과 멱등성 보장에 대한 고민

외부 API Rate Limit으로 인한 요청 실패 해결 방법 고민

1. 캐싱 레이어 추가 + Lazy Update 적용

캐시에 동일 키워드 요청의 결과를 일정기간 보관하고자 한다. (기존에 Redis를 Refresh Token 적재용으로 사용하고 있었어서, 캐시로도 사용)

Lazy Update: 요청이 들어올 때만, 캐시된 데이터가 만료된 경우에만 외부 데이터 소스(API 등)로부터 데이터를 받아와 갱신하는 것이다.

[<-> Eager Update: 주기적으로 갱신, 검색 기록 로그 데이터 필요 (현재 로그를 적재하고 있지 않아 불가)]

 

2. 외부 API 연결 실패에 대해 비동기 처리 및 큐잉 도입? -> 기각

Google Books API API에 천천히 순차적으로 요청을 보내려면 큐 기반의 흐름 제어 구조를 사용할 수 있을 것으로 생각해보았다.

1) 호출이 가능해질 때까지 기다리게 만드는 것을 고려했는데: 검색 결과는 빠르게 표시되어야 할 것 같아 기각했다.

2) 캐시를 업데이트하는 방법을 생각해보았는데:

- 기존: 사용자의 검색 요청을 서버가 직접 Google Books API에 동기 호출

- 생각한 개선 형태: 기본적으로 동기 호출을 하지만 실패한 경우, 사용자의 검색 요청을 메시지 큐(Kafka, SQS, RabbitMQ 등)에 태우고 백그라운드 워커가 순차적으로 API를 호출 → 캐시에 저장 (→ 나중에 같은 검색어 검색 시 캐시 Hit되는 것을 의도)

Client ──> [Search API]
             ├── 캐시 Hit → 응답
             └── 캐시 Miss → Google API 호출
                   ├── 성공 → 응답 + 캐시 저장
                   └── 실패 → 에러 메시지 응답 + 큐 전송
                           ↓
                    [Retry Worker] → Google API 호출 → 캐시 저장

 

 

GPT의 도움을 받아 위와 같이 짜 보았는데, 더 생각해보니... 검색어 키워드는 원래도 매우 다양하게 들어오는데, 외부 API 요청 실패 시 유저가 일시적 에러인지 확인하려고 하므로 더 다양하게 무의미한 검색어(단순 자음 등)를 입력할 것으로 생각되었다. 이때에 캐시를 갈아끼운다면 캐시 Hit Ratio를 떨어뜨리게 될 것 같아서 기각했다.


동시성과 멱등성 보장에 대한 고민

동시성(Concurrency)은 컴퓨터 시스템에서 여러 작업이나 프로세스가 동시에 실행되는 것처럼 보이는 것을 의미한다. 실제로는 한 번에 하나의 작업만 처리하지만, 짧은 시간 간격으로 작업을 번갈아 가며 실행하기 때문에 사용자는 여러 작업을 동시에 처리하는 것처럼 느낄 수 있다. (*참고자료 - 동시성: https://wikidocs.net/133213)

여러 유저가 접근하면 데이터 무결성이 깨질 수 있어 동시성 제어가 중요하다. (ex. 유저 2명이 재고 1개 남은 물건 구매 시도 시 1명만 성공해야 함)

(*참고 자료 - [Spring Boot] Java에서 동시성 문제를 해결하는 다양한 기법과 성능 평가: https://jaeseo0519.tistory.com/399,

[Spring] 스프링 동시성 처리 방법(feat. 비관적 락, 낙관적 락, 네임드 락) - https://ksh-coding.tistory.com/125)

 

(아래 표들은 GPT 도움 받아 정리)

 

📌 주요 동시성 제어 기법

비관적 락 (Pessimistic Lock) SELECT ... FOR UPDATE와 같이 DB에서 명시적으로 락을 거는 방식 정합성 강력 보장 데드락 위험, 성능 저하
낙관적 락 (Optimistic Lock) 버전 번호(version) 등을 비교하여 갱신 시점에 충돌 감지 락 없이 처리 가능, 성능 유리 충돌 시 실패 처리 필요
분산 락 (e.g., Redis SETNX) Redis 등을 이용해 여러 인스턴스 간 자원 점유 상태를 관리 다중 서버 환경에서 중복 요청 방지 TTL 설정 주의, 직접 해제 필요
DB Unique 제약 유일성 제약을 통해 중복 삽입 자체를 막음 간단하고 강력함 예외 처리 필요, 사후 대응 방식
Synchronized / Local Lock Java의 synchronized 또는 ConcurrentHashMap 기반 락 단일 인스턴스에서는 쉬움 분산 환경에서는 무력화


DB 레벨과 스프링 애플리케이션 레벨에서 동시성 제어는 다르게 구현되고, 역할과 책임이 다르다.

 

1. 개념 차이: 어디서 동시성 제어?

 

구분 DB 레벨 동시성 제어 Spring 레벨 동시성 제어
관여 위치 데이터베이스 내부 서비스 로직 / 애플리케이션 계층
제어 주체 DBMS 트랜잭션 엔진 (InnoDB 등) Spring AOP, 락 객체, Redis 등
처리 방식 트랜잭션, 락, 격리수준 등으로 직접 제어 코드 레벨에서 진입 차단 또는 로직 제어
2. 책임의 차이: 무엇을 보장?

 

항목 DB 레벨 Spring 레벨
데이터 정합성 최종 데이터 무결성 보장 (insert/update 중복 방지) 정합성은 보장하지 않음, 선제적 진입 차단
동시성 경쟁 해결 경쟁 조건을 감지하고 차단 (SELECT FOR UPDATE, unique 등) 경쟁 발생 자체를 회피하려는 목적 (락 획득 실패 시 종료 등)
실패 처리 DB에서 예외 → 롤백 예외 throw 또는 응답 코드 반환
멱등성 보장 Unique 제약, transaction rollback 등으로 결과 기준 보장 동일 요청을 선별하거나 우선 차단 (Idempotency-Key, Redis Lock 등)

3. 실제 동작 예시 비교

 

<DB 레벨 (예: 비관적 락)>
SELECT * FROM stock WHERE id = 1 FOR UPDATE;
-- 다른 트랜잭션이 해당 row 수정하려고 하면 대기 or 실패

 

<Spring 레벨 (예: Redis 분산락)>

if (!redis.setIfAbsent("lock:stock:1")) {
    throw new BusyException();
}

 

4. 결론: 역할 분담의 핵심

정리 설명
DB 레벨 데이터 자체에 대한 무결성과 정합성 보장 책임. 최후의 보루
애플리케이션 레벨 비용이 큰 DB 충돌을 미리 회피하고, UX를 부드럽게 만드는 역할
그래서 실무에서는 DB + Spring 레벨을 함께 조합해 동시성과 성능을 균형 있게 다룸

비용이 큰 DB 충돌?  > "락으로 인한 지연, 트랜잭션 실패/롤백, 처리량 저하...

=> DB 충돌 대비 Application Lock 사용의 장점

 

❌ DB에서 중복 insert 시도 → 실패 → 예외 처리 하면: 요청은 실패하고 트랜잭션 전체 롤백, DB I/O 낭비
✅ Redis Lock으로 중복 요청 차단 → insert는 한 번만 시도하면: 실패 요청은 DB 도달 전 차단, 자원 절약


멱등성(Idempotency)은 첫 번째 수행을 한 뒤 여러 차례 적용해도 결과가 동일하게 유지되는 성질을 의미한다.
CS 면접 질문 중 'HTTP 메서드 멱등성에 대해 알고 있나요?' 문항이 있다.
HTTP 메서드는 종류에 따라 멱등한지가 다른데, 멱등한 API는 같은 요청을 여러 번 보내도 같은 값을 반환하며 DB의 결과값이 달라지지 않아야 한다.

  • 멱등한 메서드: GET, PUT, DELETE, HEAD
  • 멱등하지 않은 메서드: POST, PATCH, CONNECT

멱등하지 않은 메서드에 멱등성을 제공하려면 서버에서 멱등성을 구현해야 한다.

(*참고자료 - 멱등성이 뭔가요? https://www.tosspayments.com/blog/articles/21448,

멱등성과 동시성을 보장한다는 것

https://velog.io/@chulxmin/%EB%A9%B1%EB%93%B1%EC%84%B1-%EB%B3%B4%EC%9E%A5-vs-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%B3%B4%EC%9E%A5)

 

=> 조사하면서 과거 타 프로젝트에서 데이터가 중복삽입 되었던 원인을 깨달았다... 

DB에 튜플이 특정 속성값 조합에 따라 하나만 있어야 하는 경우(pk는 아닌), 이때 그것들을 묶어서 unique 제약 추가해 해결할 수 있다.

save()를 try catch 잡아서 DataIntegrityViolationException 발생시 예외처리한다면 존재 여부 확인을 위한 조회를 안 해도 되고, 이것이 더 효율적이라는 것을 알게 되었다.

ex 1)    ALTER TABLE friend_requests ADD CONSTRAINT uq_friend UNIQUE (from_user_id, to_user_id);

ex 2)    try { friendRequestRepository.save(new FriendRequest(fromUser, toUser)); }

            catch (DataIntegrityViolationException e) { // 이미 요청한 사용자 → 조용히 무시하거나 409 응답 }

 

실제로는 이렇게 하기 불가능한 경우도 많을 것 같아 찾아보니, 포인트 지급 중복 방지를 위해 Redis 기반 분산락을 사용한 사례를 읽어볼 수 있었다.

(*참고자료 - 주니어 서버 개발자가 유저향 서비스를 개발하며 마주쳤던 이슈와 해결 방안

https://tech.kakaopay.com/post/troubleshooting-logs-as-a-junior-developer/)