TL;DR
-
3차 면접에서 2차 코딩테스트의 동시성 제어 설계를 기반으로 심화 질문을 받았다.
-
"Redis 없이 고충돌 도메인을 어떻게 처리할 것인가"라는 질문에 사전 필터링, 커넥션 풀 튜닝, 샤딩까지 답했지만, 근본 원인인 단일 record 경합을 스스로 짚지 못했다.
-
면접관의 유도로 record 분할이라는 개념에 도달했고, 분할된 record에 요청을 어떻게 분배할 것인지의 트레이드오프를 함께 설계했다.
배경
이 글은 2차 코딩테스트 회고의 후속이다.
2차 코테에서 구현한 수강신청 API의 코드는 GitHub 저장소에서 확인할 수 있다.
2차 코테에서 나는 Course row에 SELECT FOR UPDATE(비관적 락)를 걸어 동시성을 제어했다.
정원 1명 남은 강좌에 100명이 동시 신청해도 정확히 1명만 성공하는 구조다.
3차 면접에서는 이 설계를 출발점으로, 규모가 커졌을 때 어떤 문제가 발생하고 어떻게 대응할 수 있는지를 깊이 파고들었다.
면접 형식
| 항목 | 내용 |
|---|---|
| 면접 | 무신사 AI Native Engineer 3차 면접 |
| 형식 | 1:1 기술 면접 |
면접관이 전체 코테의 구현 내용을 이미 파악하고 있었기 때문에, 면접이 "내가 무엇을 구현했는가"의 확인이 아니라 "왜 그렇게 구현했고, 한계는 어디인가"를 파고드는 방향으로 진행됐다.
비관적 락, 무엇이 비관적인가
첫 질문은 2차 코테의 핵심이었던 비관적 락의 트레이드오프였다.
락을 걸면 해당 row에 대한 모든 쓰기 요청이 직렬화된다.
동시에 몇 건이 발생하더라도 데이터 정합성은 반드시 보장되지만, 락 획득을 기다리는 요청들이 전부 대기해야 하므로 throughput이 떨어진다.
여기까지는 2차 코테 경험에서 이미 정리한 내용이라 무난하게 답했다.
이어서 낙관적 락과의 비교를 물었다.
낙관적 락은 모든 트랜잭션의 시작을 즉시 허용하되, 커밋 시점에 버전 정보를 확인하여 충돌이 발생했으면 롤백시키는 방식이다.
수강신청처럼 단기간에 충돌률이 극단적으로 높은 도메인에서는 대부분의 트랜잭션이 실패하고, 실패한 요청들이 동시에 재시도하면서 재충돌을 일으켜 총 트랜잭션 수가 급증하기 때문에, 비관적 락이 더 적합하다고 답했다.
그런데 면접관이 한 가지를 짚었다.
엄격이라는 키워드를 말씀해주셨는데, 비관적 락의 이름이 왜 엄격한 락이 아니라 비관적 락인 것 같나?
이 질문에 막혔다. "비관적"이라는 이름을 당연하게 사용해왔지만, 그 이름이 가리키는 관점의 차이를 정확히 설명하지 못했다.
돌아보면 명확한 구분이다.
비관적 락은 "충돌이 발생할 것"이라고 비관적으로 가정하고 선제적으로 락을 건다.
낙관적 락은 "충돌이 발생하지 않을 것"이라고 낙관적으로 가정하고 커밋 시점에 검증한다.
"엄격"과 "느슨"은 구현의 강도를 표현하지만, "비관적"과 "낙관적"은 충돌 가능성에 대한 가정을 표현한다.
기술의 이름에 설계 철학이 담겨 있다는 걸 면접장에서야 깨달았다.
그리고 이 도메인은 비관적 가정이 정확히 맞는 경우였다.
수강신청은 인기 강좌에 수백 명이 동시에 몰리는 고충돌 도메인이고, 충돌이 발생할 것이라는 가정 하에 선제적으로 락을 거는 것이 합리적이다.
문제는 그 합리적인 선택의 결과인 직렬화가, 규모가 커졌을 때 병목이 된다는 것이었다.
고충돌 도메인 스케일링: 단일 Record 경합을 향해
면접의 핵심 구간이었다.
면접관이 시나리오를 확장했다.
Redis를 이용한 큐잉이 불가능한 상황이라고 가정해보자.
아이폰 사전신청을 받는 상황이에요.
수강신청보다 요청이 훨씬 많은 도메인에서, 대기열 없이, 우리가 취할 수 있는 개선방안이 있을까요?
수강신청은 수백 명 규모지만, 아이폰 사전신청은 수만에서 수십만 명이 동시에 몰릴 수 있다.
여기서부터 면접관이 단계적으로 스케일을 올리면서 내 사고의 한계를 확인해나갔다.
내가 도달한 답들
1단계: 사전 필터링락을 획득하기 전에 자격 미달 요청을 먼저 걸러내는 방식이다.
락 밖에서 검증하므로 대기 시간 없이 즉시 실패를 반환할 수 있다.
하지만 면접관이 바로 짚었다.
”운이 좋으면 100만 명 중에 90만 명은 걸러낼 수 있을 것 같네요. 그래도 10만 명이 남는데?”
사전 필터링은 최적화이지 해법이 아니다.
자격을 충족하는 요청이 대량으로 남으면 동일한 병목이 반복된다.
DB가 동시에 처리할 수 있는 요청 수를 늘려보려는 답변이었다.
면접관이 해당 접근의 단점을 질문했고, 나는 DB 작업이 CPU-bound하기 때문에 커넥션을 늘리면 컴퓨팅 리소스 부하가 올라가고 경우에 따라 인스턴스 scale-up이 필요해진다고 답했다.
돌아보면 근거가 부정확했다.
커넥션 풀을 늘려도 효과가 제한적인 핵심 이유는 row-level lock contention에 있다.
10만 명이 동시에 같은 row에 FOR UPDATE를 걸면, 한 번에 하나의 트랜잭션만 통과하고 나머지는 락 대기 상태로 커넥션을 점유한 채 기다린다.
커넥션 풀이 100개면 최악의 경우 100개 전부가 락 대기 중인 트랜잭션에 물리고, 이 상태에서 락과 무관한 읽기 요청이 들어와도 커넥션 자체를 할당받지 못해 실패하거나 타임아웃된다.
커넥션 풀을 늘리면 락 대기 트랜잭션이 더 많이 커넥션을 잡고 기다릴 수 있게 될 뿐, row-level lock의 직렬화는 변하지 않는다.
처리량을 늘리겠다는 판단 자체는 합리적이었을 수 있어도, 적어도 이 상황에서의 병목은 커넥션 수는 아니었다.
아이폰 미니, 프로, 프로맥스 등 품목별로 데이터를 분리하는 방식을 제안했다.
하지만 같은 품목에 10만 명이 몰리면 해당 샤드에서 동일한 문제가 발생한다.
면접관이 끌어준 전환점
여기서 내 답이 막혔고, 면접관이 방향을 전환했다.
근본적으로 우리가 락을 걸어야 하는 이유가 뭐죠? 어디에 락을 걸었어요?
"FOR UPDATE를 통해 row level에 pessimistic_write lock을 걸었습니다.”
그러면 우리가 왜 이 모든 요청을 직렬화하려고 한 거죠?
"많은 사람들이 동시에 한 record를 얻어내려고 시도하는 상황이라서요."
그쵸? 그러면 그 근본적인 문제를 어떻게 해결할 수 있을까요?
이 시점에서야 깨달았다.
내가 앞에서 제안한 사전 필터링, 커넥션 풀, 샤딩 모두 경합의 주변부를 건드리는 것이었다.
근본 원인은 수만 건의 쓰기가 하나의 row에 몰린다는 구조 자체였다.
"record를 쪼개볼 수도 있을 것 같은데요?"
면접관이 이 아이디어를 구체화하는 방향으로 전환했다.
이 질문은 인턴 레벨을 넘어서는 난이도라고 알려주시며, 같이 설계해보자는 분위기로 진행됐다.
Record 분할: Hot Spot 완화 전략
개념
하나의 record가 전체 재고(quantity: 100)를 보유한다고 가정해보자.
| id | product | quantity |
| --- | -------------- | -------- |
| 1 | iPhone 17 Pro | 100 |10만 명이 이 하나의 row에 FOR UPDATE 락을 걸며 경쟁하므로, 모든 요청이 직렬화된다.
record를 분할하면 구조가 바뀐다.
| id | product | quantity |
| ---- | -------------- | -------- |
| 1-01 | iPhone 17 Pro | 10 |
| 1-02 | iPhone 17 Pro | 10 |
| ... | ... | ... |
| 1-10 | iPhone 17 Pro | 10 |10만 건의 쓰기가 10개의 row로 분산되므로, 각 row의 경합이 1/10로 줄어든다.
단일 record가 병목인 구조에서, 경합 지점 자체를 분산시키는 접근이다.
trade-off: 읽기 복잡도의 증가
면접에서도 바로 이 문제를 짚었다.
record를 분할하면 쓰기 성능은 올라가지만 읽기가 복잡해진다.
전체 재고를 알려면 10개 row를 전부 읽어서 합산해야 하고, 분할 간 재고 편차가 생기면 일부 분할은 소진됐는데 다른 분할에는 남아있는 상태가 발생한다.
이 구조에서 자연스럽게 다음 질문이 이어졌다.
하나의 분할에만 보내기사용자의 요청을 분할된 record 중 몇 개에 보낼 것인가?
구현이 단순하고 한 사용자가 여러 슬롯을 동시에 점유하는 문제가 없다.
하지만 배정된 분할의 재고가 0이면 다른 분할에 재고가 남아있더라도 실패한다.
사용자 입장에서는 "분명 재고가 있는데 왜 실패한거지?"라는 경험이 생긴다.
추가로, 어떤 분할에 배정되느냐에 따라 성공 확률이 달라진다.
배정 방식이 랜덤이든 해시 기반이든, 재고가 빨리 소진되는 분할에 걸린 사용자는 불리해진다.
하나만 보내는 것보다 성공 확률이 올라간다.
하지만 여러 분할에서 동시에 성공하면 한 사용자가 재고를 중복 차감하는 문제가 발생한다.
이를 방지하려면 하나가 성공했을 때 나머지를 취소하는 로직이 필요하고, 취소 전에 다른 사용자의 요청이 거부될 수 있다.
성공 확률은 가장 높지만, 10만 명이 10개 분할 전부에 요청을 보내면 각 분할에서 10만 건의 경합이 발생한다.
분할의 이점이 사라지고 부하만 올라간다.
시간 관계상 이 논의를 더 깊이 이어가지는 못했지만, 면접관이 확인하려 한 것은 정답이 아니라 각 선택지의 트레이드오프를 인지해낼 수 있는가였다.
고충돌 도메인에서는 사용자 경험과 시스템 복잡도 사이에서 도메인에 맞는 지점을 선택해야 하고, 그 선택에는 항상 무언가를 포기하는 판단이 따른다.
돌아보며
면접 후 가장 기억에 남는 건 면접관이 "근본적으로 왜 락을 걸어야 하는가"로 되돌아간 순간이다.
사전 필터링, 커넥션 풀 튜닝, 샤딩
— 전부 유효한 기법이지만, 나는 "어떻게 더 빨리 처리할까"에만 집중하고 있었다.
면접관이 묻고 있던 건 "왜 느린가"였다.
처리 속도를 올리는 것과 경합 자체를 줄이는 것은 다른 차원의 문제라는 걸, 면접관의 유도가 아니었으면 도달하지 못했을 것이다.
이 경험이 분산 시스템 설계를 더 깊이 공부하려는 계기가 됐다.
record 분할이라는 개념에 면접장에서 임기응변으로 도달하는 것과, 그것이 왜 작동하고 어디서 무너지는지를 이해하는 것은 다르다.
입사 전까지 DDIA(Designing Data-Intensive Applications)를 1회독하는 것을 목표로 읽기 시작했고, 이 면접에서 다뤘던 문제들이 책의 어느 지점에서 다시 등장하는지를 확인해볼 생각이다.