정글 나만무 주차에 돌입을 한 지 몇 주가 지났다. 바쁜 시간을 보내고 있으나, 스스로 어디까지 이해하고 있는 바를 확인하고 재구성 하기 위해 글을 작성해보고자 한다. 해당 글은 상-중-하 단위로, 작성이 될 것이며, 팀 내에서 맡은 바를 수행하면서 직면한 과제를 해결하는 과정을 각 글의 후반 핵심부로 정리를 할 것이다. 그럼 스타트!
종합 PC 서비스 플랫폼
우리 팀은 PC 서비스 플랫폼을 설계하기로 방향을 정했다. 상용 PC 관련 서비스(다나와, 컴퓨존)을 참고하되, AI를 결합하고 다양한 부분에서 보완을 통해 서비스를 고도화 하기로 하였다. 여기서 내가 맡은 역할은 컴퓨터 부품 Tool 검증과, (다른 동료의 양해 덕분에) AI 챗봇 파트도 살펴볼 수 있었다. 먼저 우리 서비스의 "Self 견적 UI"를 공개하자면, 다음과 같다(Prototype):

우측은 "아나콘다 GUI"라 하며, 각각 슬롯을 클릭하면 하위로 해당 부품 목록이 display된다. 위의 예시에서는 8번인 쿨러를 선택했을 때의 예시다. 중앙에는 부품을 하나하나 선택할 때마다 책상에 장치가 놓여지는 애니메이션이 실행되며, 좌측은 견적 상담 AI 인데 아직 구현이 충분치 않은 상황이다.
검증 Tool을 설계하는 방식: 정규화
Naver Api를 통해서 우린 상당량의 PC 부품의 정보를 보유하게 되었다. 본 데이터는 다양한 요소를 내포하고 있으며, 이를 특정 기준(공통 척도)으로 평가하고, 최종적으로 PASS/WARN/FAIL라는 일관된 규칙으로 반환하도록 Tool을 설계를 하는 것이 관건이라 할 수 있겠다. 이러한 방식을 의미있게 표현하자면 정규화라는 말이 있다. 여기선 두 가지 정규화가 수행되는데, 요약하자면:
데이터 정규화: 정확성과 일관성을 향상시키기 위해 일정한 규칙/기준에 따라 데이터를 구조화 함(예: 모든 부품을 소비 전력으로 기준 맞추기)
판정 기준 정규화: 정규화된 데이터를 판별하는 행위도 동일한 기준에 맞춰 공정성을 확보하는 것(예: 모든 부품 소비 전력 <= 파워 전력량)
말로는 이해하기 어려운 만큼, Power Check(전력 호환성)을 검증하는 로직을 아래 도식화 해서 설명하자면 다음 아래와 같다:
연산:
예상전력 = CPU + GPU + 기타부품 + 기본 60W
여유전력 = PSU 용량 - 예상전력
부하율 = 예상전력 / PSU 용량
결과 도출:
PASS.. PSU 용량 >= max(GPU 권장 전력, 예상전력 + 120W) && PSU 부하율 <= 85%
WARN.. PSU 용량 >= 예상전력 && 여유 전력 >= 80W
FAIL.. 둘 다 만족하지 못함
집합관계:
PASS = P
WARN = W - P
FAIL = 전체 - W
User flow에 기반해 Tool 부하 모델링하기
검증 Tool은 아나콘다 GUI에서 매번 부품을 선택할 때마다 검증이 수행되며, PASS/WARN/FAIL 결과를 내놓는다. 만약 PASS가 아닌 부품을 억지로 선택할 경우, GUI는 아래와 같이 변한다.

프론트 단에서는 Tool 결과를 받아서 표시하는 만큼, 연산의 책임은 서버에게 있다. 그렇다면, 1번(CPU 슬롯)부터.. 8번(파워 슬롯)까지 사용자가 선택을 할 때마다, 서버에게 몇 번의 요청을 하고, query문이 수행이 되어야 할까? 먼저, 하나의 슬롯을 선택할 경우 서버에는 다음과 같은 요청을 받는다:
1. getParts(): 부품 후보 리스트를 조회한다. 예시로 메인보드 슬롯 선택 시, 수 십 개의 부품이 list 형태로 제공된다. 동시에, 개별 부품을 검증.
2. addPart(): 특정 부품을 유저가 선택한다. 이를 통해 해당 부품은 사용자 장바구니(draft)에 포함됨.
3. checkConditions(): 장바구니에 들어간 부품 간의 호환을 검증한다. 위 이미지 중앙의 붉은색 표시 장치와 문구가 이를 통해 산출된다.
아래의 k6으로 사용자 시나리오를 작성한 함수(슬롯 선택시 호출됨)의 예시로 확인해 볼 수 있다:
/* 일부 수식은 생략 */
export function addingPart(token, partCategory){
/* 부품 후보 리스트 조회
+ 현재 draft 조회
+ 후보별 호환 검증 */
const listRes = getParts(token, partCategory);
check(listRes, { [`${partCategory} 후보 리스트 200`]: (res) => res.status === 200, });
/* 가중치 넣어서 부품 하나 선택 */
/* 이 중 부품 하나 선택 => draft(장바구니)에 들어감 */
const putRes = addPart(token, PART_ID);
check(putRes, { [`${partCategory} 선택됨 200`]: (res) => res.status === 200, });
/* 부품이 슬롯에 들어감 => 의존성 그래프 검증 */
const graphRes = checkAllConditions(token, partCategory);
check(graphRes, { [`${partCategory} 의존성 확인 200`]: (res) => res.status === 200, });
sleep(0.3);
}
캐싱과정에서 나타난 문제: N + 1 문제
컴퓨터 부품 수는 한정되어 있다. Spring에서 제공하는 Caffeine이라는 "경량화"된 캐시로 충분하다고 판단하여 캐싱 적용 로직을 적용하기 시작했다. 캐싱 적용 로직은 DB에서 부품 정보를 가져올 때로 한정을 하였다. 왜냐하면 Tool 검증 로직이, ① 부품 정보를 가져오고 → ② 정보를 바탕으로 판단 연산이 돌아가기 때문이다. 캐싱 과정에서 DB와 직접 소통하는 Query문을 통일할 필요성을 느꼈고, 여기서 N + 1 문제를 경험하게 되었다.
N + 1 문제란 무엇인가?
N + 1 문제를 짧게 표현하자면, 의도치 않게 N번의 DB조회가 추가를 의미한다. 쉬운 예시로 주문 10건을 조회한다고 가정해보자. 로직의 흐름이 ① 주문 목록 조회 → ② 주문마다 상세 정보 조회.. 라면, 전자에서 1번 + 후자에서 N 번의 추가 조회(왕복)가 일어나게 된다. 특히, DB(Postgre)와의 왕복은 꽤나 비싼 I/O이기 때문에 문제가 된다.

왜 캐싱 과정에서 문제가 발생하였나?
기존 로직은 일괄 조회(Batch)의 형태로, 한 번만의 왕복만으로 여러 개의 row를 가져올 수 있었다. 예를 들어, CPU 부품 30개를 list로 가져오기가 있겠다. 그러나 개별 객체(낱개의 부품)을 캐싱 적용을 하려다 보니, 이를 단일 조회로 분해하였고, 여기서 N + 1 문제가 발생하였다. 아래 문제의 코드를 보자, 낱개의 부품을 캐싱하기 위해 List 형태의 partIds를 먼저 조회하고, 이를 인자로 받아 차례차례로 재조회를 하는 모습을 볼 수 있다.
public List<ToolBuildPart> partsByPublicIds(List<String> partIds) {
return partIds.stream()
.map(partQueryCached::partByPublicId)
.toList();
}
@Cacheable(
cacheNames = "tool-part",
key = "#partId",
unless = "#result == null"
)
public ToolBuildPart partByPublicId(String partId) {
return jdbcTemplate.queryForObject(
"""
SELECT 각종 column들
FROM parts
WHERE public_id::text = ? .. 각종 조건들
""",
헬퍼 함수,
partId
);
}
개선책: 캐시 HIT/MISS 기반으로 Batch 조회 로직을 추가하다
앞의 캐싱 로직에선, Batch(묶음) 형태의 조회는 낱개 형태로 반드시 재조회를 하였다. 이러한 반복 왕복로직을 최소화, 혹은 우회하는 방법이 있지 않을까? 발상과 함께, 캐시가 히트/미스 할 때 분기를 만들어 처리하는 발상을 하였다.
캐시가 히트할 때에는, 애초에 DB(Postgre)에 접근할 필요가 없으니 기존 로직을 건들 필요가 없다고 판단했다. 그러나 미스가 일어날 경우, 미스가 일어난 partsId를 list로 묶어서 일괄 DB에서 조회하고, 다시 list를 읽어서 개별 캐싱을 수행하면 된다는 아이디어로 이어졌다. 이를 mermaid로 도식화를 하면 다음과 같다:

k6을 통한 부하 테스트
500rps(초당 요청 수)를 목표로 부하 테스트를 로컬에서 진행하였다. 각 테스트는 2분이며, 캐싱이 적용된 서버와 그렇지 않은 서버를 테스트 한다. 아래 그래프는 "중위값"을 기반으로 하여 10초 단위로 응답속도를 측정을 한 도식이다. 좌측은 기존안, 우측은 N + 1 문제 개선 후를 나타낸다.

외관상 이 둘이 유사해 보일 수 있다. 트래픽이 시간 경과에 따라 늘어나면서 응답속도 지연 현상도 확연히 관찰이 가능하다. 그러나 응답시간 단위를 나타내는 Y축을 살펴보면, 우측 그래프는 1,200ms 좌측은 4,500ms 로 4배 가까이 스캐일 차이가 남을 알 수 있다. 즉, 캐싱 안될 경우 응답속도 지연에서 확연히 차이가 난다.
왜 그럴까..? 캐싱이 적용되지 않을 경우, 좌측은 여전히 N + 1 문제의 쿼리를 반복적으로 DB에 요청하는 반면, 우측은 캐시 미스된 partsId 묶음을 모아 한꺼번에 조회를 수행하기 때문이다. 반대로, 캐싱 N + 1 문제 개선 유무에 따라 캐싱된 서버의 응답속도는 큰 개선이 없었다. PC 부품은 400개 내외로 적은 만큼, 요청이 쌓이면서 대부분의 부품은 caffeine에 캐싱이 된다. 따라서 DB(Postgre)와의 왕복 비용 문제인 N + 1 문제가 발생하는 조건조차 어렵기 때문이다.
개선할 내용은 여전히 많다
이 서비스가 실제 상용되는 서비스라면, 특정 이벤트 시기에 1,000rps 정도를 가정하였다. 트래픽 폭증 시기에 응답속도가 50ms(사용자가 지연을 감지하여 불편함을 느끼지 않도록)를 넘지 않도록 하자는 목표에 비하면 (p95는 말할 것도 없고) 현재 중위값 400ms 가까이 되는 응답속도는 여전히 갈 길이 멀다고 느꼈다.
개선을 수행하기에 앞서 추가적인 분석의 필요성 또한 느꼈다. 현 그래프는 하나의 유저 플로우를 기준으로 하나, 이를 엔드 포인트 별로 잘게 쪼개서 병목지점을 더 구체적으로 파악해보고, 테스트 시나리오 면에서도 개선점을 찾는 분석에 들어가보고자 한다.
긴 글 읽어주셔서 감사하다. 이번 글은 여기로 마치겠다. 끝!
'크래프톤 정글 > WIL' 카테고리의 다른 글
| 정글[Week_13-14]: Deep Learning(LLM)은 어떻게 작동되는가? (0) | 2026.06.05 |
|---|---|
| 정글[Week_11-12]: Pintos의 VM 구현하기 (0) | 2026.05.21 |
| 정글[Week_10]: Pintos의 프로세스의 생애 주기 구현하기 (0) | 2026.05.07 |
| 정글[Week_09]: Pintos의 스레드 알고리즘을 설계하다 (4) | 2026.04.30 |
| 정글[Week_08]: 소켓으로 간단한 서버(tiny•proxy) 만들기 (2) | 2026.04.23 |