0. 이 글의 배경
파일 업로드는 겉보기엔 단순한 기능입니다. 하지만 이 '간단함'이라는 덫이, 전체 마이크로서비스를 마비시킬 수 있는 시한폭탄이 될 수도 있을까요?
이 글은 초기 MVP의 동기식 파일 업로드 방식에 숨어있던 위험을 발견하고, 서비스가 멈추기 전에 선제적으로 비동기 아키텍처로 전환한 과정을 담은 기록이에요. 사용자 경험과 시스템 안정성, 두 마리 토끼를 모두 잡은 'Upload ID' 패턴의 모든 것을 공유해요.
1. 파일 업로드, 이대로 괜찮을까?: 비동기 전환의 서막
모든 사이드 프로젝트는 "일단 돌아가게 만들자"는 순수한 열정에서 시작되죠. AsyncSite의 프로필 이미지 업로드 기능도 그랬어요. 가장 직관적이고 간단한 방법, 즉 동기식(Synchronous)으로 구현했어요.
먼저 간단하게 우리 서비스 아키텍처를 소개드릴게요.
AsyncSite는 여러 마이크로서비스로 구성되어 있는데, 파일 업로드는 주로 세 서비스의 협력으로 이루어져요. 먼저 모든 요청은 API Gateway를 통해 들어오고, 프로필 이미지 변경과 같은 사용자 관련 요청은 User Service가 담당해요. 그리고 이미지나 PDF 같은 정적 파일의 저장 및 관리는 Asset Service라는 별도의 서비스가 전담합니다.
여기서 Asset Service의 역할이 중요해요. 저희는 비용 문제로 AWS S3 같은 클라우드 스토리지를 사용하는 대신, 고성능 미니 PC 한 대에 모든 서비스를 올려 직접 운영하고 있는데요. 따라서 Asset Service는 이 미니 PC의 로컬 파일 시스템에 파일을 안전하게 저장하고, 외부에서 접근 가능한 URL을 생성해주는 일종의 '사설 파일 서버'와 같은 역할을 수행해요.
사용자로부터 MultipartFile
을 받으면, 이 파일을 그대로 다른 마이크로서비스인 Asset Service
로 전달하고, 처리가 완료되면 그 결과(URL)를 받아 DB에 저장한 뒤 사용자에게 응답하는 단순한 흐름이었어요.
// User Service - 초기 MVP 구현
@PostMapping("/{userId}/profile-image")
fun uploadProfileImage(
@PathVariable userId: String,
@RequestParam("file") file: MultipartFile
): ResponseEntity<String> {
// 이 단순한 코드가 재앙의 시작이었습니다.
val assetResponse = assetServiceClient.uploadFile(file)
val user = userRepository.findById(userId)
user.profileImageUrl = assetResponse.publicUrl
userRepository.save(user)
return ResponseEntity.ok(assetResponse.publicUrl)
}
위 코드는 단순해 보이지만, MSA 환경에서는 치명적인 약점을 안고 있었어요.
바로 assetServiceClient.uploadFile(file)
이 한 줄이 '블로킹(Blocking)' 방식으로 동작한다는 점이에요. User Service는 Asset Service로부터 "업로드 완료!"라는 응답이 올 때까지, 해당 요청을 처리하던 스레드를 붙잡고 아무것도 하지 못한 채 기다려야만 합니다. 마치 중요한 전화를 걸어놓고 상대방이 받을 때까지 숨죽여 기다리는 것과 같아요.
기능 구현을 마치고 나니, 마음 한구석에 찜찜함이 남았어요. '이 동기식 구조가 과연 사용자가 늘어나고, 예상치 못한 크기의 파일이 업로드되어도 괜찮을까?'
이 막연한 불안감을 눈으로 직접 확인해보고 싶었습니다. 그래서 정식 오픈 전에, 이 아키텍처가 어디까지 버틸 수 있는지 의도적으로 스트레스 테스트를 진행하기로 했어요.
처음에는 동시 접속자 10명 정도가 작은 프로필 이미지를 올리는 상황을 테스트했어요. 이때는 평균 응답 속도가 조금 느려지는 것 외에는 큰 문제가 없어 보였는데요. 시나리오를 조금 바꿔, 한 명의 가상 사용자가 50MB짜리 대용량 PDF 파일을 업로드하는 동시에, 다른 사용자들이 일반적인 API를 호출하는 상황을 테스트하자 문제가 명확히 드러났어요.
대용량 파일이 업로드되는 동안, Postman으로 다른 API(예: 내 프로필 조회)를 호출하자 아무런 응답을 받지 못하고 타임아웃이 발생했어요. 바로 스레드 풀 고갈(Thread Pool Exhaustion) 현상이었습니다.
Tomcat 같은 웹 서버는 동시에 여러 요청을 처리하기 위해 '스레드 풀'이라는 일꾼 그룹을 운영해요. 한 요청이 들어오면 일꾼(스레드) 하나가 그 일을 맡아 처리하죠. 하지만 동기식 업로드에서는, 대용량 파일이 처리되는 수십 초 동안 일꾼 하나가 그 자리에 붙잡혀 꼼짝도 못 하게 돼요.
만약 이런 대용량 업로드가 단 하나만 발생해도, 남아있는 다른 일꾼들이 모두 다른 요청을 처리하느라 바쁘다면, 새로 들어오는 모든 요청(로그인, 글쓰기 등)은 일할 수 있는 일꾼이 없어 무작정 대기열에서 기다려야 해요. 결국 참다못한 요청들은 타임아웃으로 실패하고, 실제 사용자 입장에서는 서비스 전체가 멈춘 것처럼 보이게 될 거예요.
테스트 결과는 명확했어요. 이것은 단순히 '느린 기능'의 문제가 아니라, 언제 터질지 모르는 '시한폭탄'이었어요. 실제 장애가 발생해 사용자들이 불편을 겪기 전에, 반드시 이 구조를 개선해야 한다는 결론에 이르렀어요. 그렇게, 동기식 아키텍처와의 전면전을 준비하기 시작했습니다.
2. 비동기 전환을 위한 대안 탐색
"실제 장애가 발생하기 전에, 동기식 구조를 비동기 방식으로 선제적으로 전환하기로 결정했어요."
목표는 명확했지만, 그 방법까지 명확한 것은 아니었어요. 어떤 아키텍처가 우리의 제약 조건(온프레미스, MSA)과 목표(논블로킹, 좋은 UX)에 가장 적합할지, 여러 대안을 검토하기 시작했어요.
이 문제를 해결하는 가장 표준적인 방법은 아마도 S3의 'Presigned URL'과 같은 패턴일 겁니다. 프론트엔드가 서버에 업로드 허가를 요청하고, 서버가 발급해준 일회용 URL로 스토리지에 직접 파일을 올리는 방식이죠.
저는 미니 PC 환경이라 S3를 사용하진 않았지만, 이 패턴을 우리 Asset Service에 직접 구현할 수도 있었어요. 즉, 프론트엔드가 Asset Service로 직접 파일을 업로드하게 하는 것입니다. 이 방식은 User Service가 무거운 파일 트래픽을 중개하지 않아도 되므로 리소스 측면에서 훨씬 효율적일 거예요.
하지만 저는 의도적으로 다른 길을 택했어요. 저희 MSA 구조에서 Asset Service는 외부 노출 없이 내부망에만 존재하는 '보호된' 서비스였는데요. Presigned URL 패턴을 도입하려면 이 서비스를 외부에 노출하고, 파일 업로드만을 위한 별도의 인증/인가 로직을 추가해야 했기 때문입니다.
저는 초기 MVP 단계에서는 아키텍처의 단순성과 명확한 보안 경계를 유지하는 것이 더 중요하다고 판단했어요. 그래서 Asset Service를 계속 보호된 상태로 두면서, User Service가 업로드를 중개하되 '블로킹'만 해결하는 다른 대안들을 찾아보기로 했어요.
이러한 제약 조건 하에서, 저는 User Service를 경유하되 블로킹을 해결할 수 있는 두 가지 주요 대안을 검토했습니다. 하지만 두 방법 모두 치명적인 한계를 가지고 있었어요.
첫 번째 검토: 서버사이드 큐 (Server-side Queue)
가장 먼저 떠오른 아이디어는 '서버사이드 큐' 방식이에요. Asset Service가 파일을 받으면, 일단 내부 큐(Queue)에 작업을 던져놓고 "접수 완료"라고 User Service에 즉시 응답을 주는 구조입니다.
하지만 이 방식은 User Service와 Asset Service 간의 HTTP 데이터 전송 시간 자체를 줄여주지 못했어요. Asset Service가 아무리 빨리 응답을 준비해도, User Service는 50MB 파일 전체가 네트워크를 통해 전달될 때까지 스레드를 붙잡고 있어야만 했습니다. 문제의 핵심을 비껴간 해결책이었어요.
두 번째 검토: Kafka로 파일 전송
그렇다면 아예 HTTP가 아닌 Kafka 메시지로 파일을 보내면 어떨까? 하는 생각도 해보았어요. 하지만 이 아이디어는 몇 가지 심각한 기술적 제약 때문에 곧바로 폐기되었어요.
- 메시지 크기 제한: Kafka 브로커는 기본적으로 1MB 이상의 메시지를 거부하도록 설정되어 있어, 대용량 파일 전송에는 부적합했어요.
- 성능 및 리소스 부하: 대용량 바이너리 데이터를 Base64로 인코딩하고, 이를 다시 디코딩하여 조합하는 과정은 상당한 CPU와 메모리 부하를 유발해요.
결론적으로 Kafka는 이벤트 메시징을 위한 도구이지, 대용량 파일 전송을 위한 솔루션이 아니라는 점을 다시 한번 확인했어요.
앞서 검토했던 두 가지 대안 모두, User Service가 어떻게든 파일 전체를 먼저 받아 Asset Service로 '밀어 넣어야(Push)' 한다는 전제에서 벗어나지 못했어요. 바로 이 지점에서 근본적인 블로킹 문제가 해결되지 않았던 것입니다.
여기서 발상의 전환이 필요했어요. '파일 전송'이라는 무거운 작업을 '업로드 요청'이라는 가벼운 작업과 분리하는 것입니다.
사용자가 파일을 올리겠다는 '의도'를 먼저 서버에 알리고, 서버는 그 의도를 확인한 뒤 "알겠다"고 즉시 응답합니다. 실제 파일 전송은 그 응답을 받은 후에 비동기적으로 처리하는 방식입니다.
이 아이디어가 바로 'Upload ID' 패턴의 시작이었어요. 먼저 고유한 ID를 발급받아 업로드를 '예약'하고, 실제 데이터는 이 ID를 통해 나중에 전송하는 것입니다!
이 방식이야말로 Asset Service를 외부에 노출하지 않으면서도 User Service의 블로킹을 완벽하게 해결할 수 있는, 저의 제약 조건에 가장 잘 맞는 해결책이었어요.
3. '업로드 예약'이라는 발상의 전환: Upload ID 패턴
그렇다면 이 'Upload ID' 패턴을 어떻게 구체적으로 설계하고 구현해야 할까요?
이 패턴의 핵심은 UploadSession이라는 상태 관리 객체입니다. 파일 업로드의 시작부터 끝까지 모든 상태를 이 객체를 통해 추적하고 관리해요.
// Asset Service에 정의된 UploadSession Entity (핵심 필드)
@Entity
data class UploadSession(
@Id
val id: String = UUID.randomUUID().toString(), // 예약 번호 역할을 하는 Upload ID
@Enumerated(EnumType.STRING)
var status: UploadStatus = UploadStatus.PENDING, // PENDING, UPLOADING, COMPLETED 등
val userId: String,
val fileName: String,
var pendingUrl: String? = null, // 업로드 중에 보여줄 임시 URL
var publicUrl: String? = null // 업로드 완료 후 실제 파일 URL
)
enum class UploadStatus {
PENDING, UPLOADING, PROCESSING, COMPLETED, FAILED, EXPIRED
}
이제 이 UploadSession을 활용한 첫 번째 단계, 즉 30초짜리 요청을 50ms짜리 요청으로 바꾸는 과정입니다.
사용자가 프로필 이미지를 선택하면, 프론트엔드는 파일 자체를 보내는 대신, 파일의 정보(메타데이터: 파일명, 크기, 타입 등)만을 User Service로 보내요. User Service는 이 가벼운 정보를 받아 다시 Asset Service로 전달합니다.
Asset Service는 이 '업로드 예약' 요청을 받고, 데이터베이스에 status가 PENDING인 UploadSession 기록을 생성해요. 그리고 이 예약을 식별할 수 있는 고유한 uploadId를 즉시 클라이언트에게 반환합니다.
# 1. 클라이언트가 서버에 '업로드 예약' 요청 (메타데이터만 전송)
POST /api/assets/uploads/initiate
{
"fileName": "profile.jpg",
"fileSize": 2048000,
"mimeType": "image/jpeg"
}
# 2. 서버의 '즉각적인' 응답 (수십 ms 소요)
HTTP/1.1 200 OK
{
"uploadId": "550e8400-e29b-41d4",
"uploadUrl": "/api/assets/uploads/550e8400-e29b-41d4",
"pendingUrl": "/public/assets/pending/550e8400-e29b-41d4.jpg"
}
이 모든 과정은 파일 크기와 상관없이 단 수십 밀리초(ms) 만에 완료되죠. 이로써 User Service의 블로킹 문제는 완벽하게 해결되었어요. 사용자는 즉각적인 피드백을 받고, 서버는 안정성을 유지할 수 있게 된 것입니다.
Upload ID를 발급받아 블로킹을 해결했다면, 이제 남은 질문은 이것입니다. "실제 파일은 언제, 어떻게 전송되는가?"
2단계: 백그라운드 업로드와 이벤트 기반의 완료 통지
User Service는 클라이언트에게 Upload ID를 즉시 반환함과 동시에, 별도의 백그라운드 스레드(@Async)를 통해 실제 파일 전송을 시작해요. 이 무거운 작업은 이제 메인 스레드가 아닌, 격리된 일꾼 스레드가 담당하게 됩니다.
백그라운드 스레드는 Upload ID와 함께 파일 데이터를 Asset Service로 전달해요. Asset Service는 파일 저장을 완료하면, UploadSession의 상태를 COMPLETED로 변경하고, 최종적으로 접근 가능한 publicUrl을 확정해요.
그리고 가장 중요한 마지막 단계로, Asset Service는 "ID가 550e8400-e29b-41d4인 업로드가 완료되었다"는 사실을 Kafka로 이벤트(Event)를 발행하여 알립니다. User Service는 이 이벤트를 구독(subscribe)하고 있다가, 메시지를 받으면 비로소 해당 사용자의 프로필 이미지 URL을 pendingUrl에서 최종 publicUrl로 업데이트합니다.
이 이벤트 기반 방식 덕분에 Asset Service와 User Service는 서로를 직접 호출할 필요 없이, 오직 '업로드 완료'라는 이벤트에만 의존하게 됩니다. 이는 서비스 간의 결합도를 낮춰(Decoupling) 시스템을 훨씬 유연하고 확장 가능하게 만들어요.
동기식 지옥에서 비동기 천국으로: 아키텍처 비교
이 모든 변화를 그림으로 비교하면 그 차이가 명확하게 보입니다.
Before: 동기식 아키텍처
User → Gateway → User Service --[30초간 블로킹]--> Asset Service
↓
[다른 모든 요청 처리 불가]
↓
서비스 마비
하나의 긴 작업이 전체 시스템을 멈춰 세우는 구조였어요.
After: 비동기 아키텍처 (Upload ID 패턴)
(빠른 경로) User → Gateway → User Service → Upload ID 응답 (50ms)
(느린 경로) ↳ (백그라운드) User Service --[파일 전송]--> Asset Service
↓
(완료 통지) Kafka Event
↓
User Service (DB 업데이트)
결과적으로, 사용자의 요청을 직접 처리하는 스레드는 더 이상 무거운 파일 전송 작업에 발목 잡히지 않게 되었어요. 실제 작업은 백그라운드와 이벤트 시스템에 위임하고, 시스템의 전면에서는 빠르고 가벼운 응답성만 유지하는 구조가 완성된 것입니다.
4. 실시간처럼 보이는 비동기: Pending URL의 UX 마술
Upload ID 패턴으로 서버의 블로킹 문제는 해결했지만, 새로운 숙제가 생겼습니다. 바로 '사용자 경험(UX)'입니다.
사용자가 프로필 이미지를 올렸는데, 서버가 즉시 반환하는 것은 최종 이미지 URL이 아닌, pendingUrl(임시 URL)이죠. 실제 이미지 처리는 백그라운드에서 한참 걸릴 수 있어요. 만약 아무런 처리를 하지 않는다면, 사용자는 이미지를 올리고 나서도 한동안 자신의 프로필이 비어있거나, 이전 이미지 그대로인 화면을 보게 될 것이에요. 이는 사용자에게 혼란을 주고, "업로드가 실패했나?"라는 오해를 불러일으키기 충분해요.
이 문제를 해결하기 위해, 저는 "실제로는 비동기지만, 사용자에게는 마치 실시간처럼 보이게 만들자"는 목표를 세웠워요. 그 핵심에 바로 pendingUrl의 영리한 활용이 있습니다.
사용자가 이미지를 선택하는 순간, 프론트엔드는 Upload ID를 발급받고, 즉시 프로필 이미지의 src 속성을 응답으로 받은 pendingUrl로 교체해요.
// ProfileEdit.tsx
const { pendingUrl, upload } = useProfileImageUpload();
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 즉시 업로드를 시작하고, pendingUrl을 받아온다
upload(file);
}
};
return (
<img src={pendingUrl || user.profileImage} />
// ...
)
이제 pendingUrl을 요청하면, Asset Service는 UploadSession의 현재 상태를 확인하고 그에 맞는 응답을 돌려줍니다. 만약 상태가 PENDING이나 UPLOADING이라면, 실제 이미지를 보여주는 대신, 제가 미리 준비해 둔 로딩 애니메이션 SVG 이미지를 반환해요.
// Asset Service의 PendingAssetController
@GetMapping("/public/assets/pending/{uploadId}.{ext}")
fun getPendingAsset(...) {
val session = uploadService.getSession(uploadId)
when (session?.status) {
// ...
UploadStatus.PENDING, UploadStatus.UPLOADING -> {
// 진행 중일 때는 로딩 이미지를 반환
response.contentType = "image/svg+xml"
response.setHeader("Cache-Control", "no-cache") // 캐시 방지
response.writer.write(LOADING_SVG)
}
// ...
}
}
결과적으로, 사용자는 이미지를 선택하자마자 자신의 프로필 이미지가 로딩 애니메이션으로 바뀌는 것을 보게 됩니다. 이것만으로도 "아, 내 요청이 처리되고 있구나"라는 즉각적인 피드백을 주어, 사용자가 기다릴 수 있는 심리적 안정감을 제공합니다.
로딩 애니메이션으로 '진행 중'이라는 상태를 보여줬다면, 이제 '완료'된 상태를 어떻게 알려줄 차례입니다.
프론트엔드에서는 useEffect 훅을 사용해, pendingUrl이 설정되면 주기적으로(2초마다) 해당 URL에 HEAD 요청을 보내 상태를 확인하는 폴링(Polling)을 시작해요.
백엔드의 Asset Service는 업로드가 완료되면 UploadSession의 상태를 COMPLETED로 바꾸고, pendingUrl로 들어온 요청을 실제 파일이 있는 publicUrl로 리다이렉트(Redirect)시켜 줍니다. 프론트엔드는 이 리다이렉트를 감지하여 폴링을 멈추고, 브라우저는 자동으로 리다이렉트된 최종 이미지 URL을 로드하여 화면을 갱신해요. 마치 마법처럼, 로딩 애니메이션이 실제 프로필 이미지로 자연스럽게 바뀌게 되는 것입니다.
하지만 여기서 또 다른 문제가 발생했어요. 사용자가 프로필 이미지를 올리고 '저장' 버튼을 누른 뒤, MyPage로 이동했을 때, 아직 백그라운드 작업이 끝나지 않아 깨진 이미지가 보이는 경우가 있었어요. 즉, '최종 일관성(Eventual Consistency)'과 사용자 경험 사이의 딜레마인 것이죠.
저는 이 문제를 '5초 딜레이 전략'으로 해결했어요. '저장' 버튼을 누르면, 프론트엔드는 바로 다음 페이지로 넘어가는 대신, 최대 5초 동안 백그라운드 업로드 작업이 완료되기를 기다려줍니다. 대부분의 이미지 업로드는 5초 안에 끝나므로, 사용자는 MyPage로 이동했을 때 방금 올린 자신의 프로필 이미지를 바로 볼 수 있게 돼요. 만약 5초가 지나도 끝나지 않는다면, 그때는 어쩔 수 없이 로딩 이미지를 보여주게 되지만, 체감 성공률을 크게 높일 수 있었어요.
이처럼 기술적인 한계를 인정하되, 사용자의 입장에서 경험을 보완하기 위한 작은 트릭들을 추가함으로써, 우리는 비동기 시스템의 안정성과 실시간 시스템의 즉각적인 피드백이라는 두 마리 토끼를 모두 잡을 수 있었습니다.
5. MVP의 함정, 그리고 '간단함'이라는 덫에 대하여
이번 파일 업로드 아키텍처 개선 여정을 돌이켜보면, '단순한 기능은 없다'는 오래된 격언을 다시 한번 떠올리게 됐어요.
처음 구현했던 동기식 코드는 불과 몇 줄 되지 않았어요. 기능적으로도 완벽하게 동작했죠. MVP 단계에서는 이 '단순함'이 분명 미덕이에요. 가장 빠르게 기능을 구현하고 사용자에게 가치를 전달하는 것이 최우선 목표니까요.
하지만 그 단순함은 '사용자가 적을 것이고, 파일은 작을 것이며, 요청은 동시에 몰리지 않을 것'이라는 수많은 암묵적인 가정 위에 세워진 것이에요. 프로젝트가 성장하면 어쩌면 '단순했던' 기능은 순식간에 시스템 전체를 위협하는 '복잡한' 문제로 돌변할 수도 있어요.
그렇다고 MVP 단계에서부터 모든 비동기 아키텍처를 완벽하게 갖추는 것이 정답일까요? 아마 그건 아닐 겁니다. MVP의 핵심은 '가설 검증'이고, 가장 중요한 자원은 '시간'이니까요. 때로는 빠른 구현을 위해 동기식이 더 나은 선택일 수 있습니다.
다만 이번 경험을 통해 제가 얻은 교훈은, '구현은 단순하게 하더라도, 설계는 확장을 염두에 둬야 한다'는 것입니다. 예를 들어, 처음부터 파일 업로드 로직을 별도의 UploadService로 분리해두었다면 어땠을까요? 초기에는 이 서비스가 동기식으로 동작하더라도, 나중에 이 서비스의 내부 구현만 비동기식으로 바꾸면 다른 서비스에 미치는 영향을 최소화할 수 있었을 겁니다. '나중에 바꿀 수 있도록' 길을 열어두는 설계, 그것이 MVP 단계에서도 포기하지 말아야 할 최소한의 원칙 중 하나가 아닐까 하는 생각이 들었어요.
이번 개선 과정은 제게 '좋은 사용자 경험(UX)이란 무엇일까'라는 질문을 다시 던지게 했어요. 사용자가 느낀 '빠른 응답성'과 '끊김 없는 경험'은 결국 블로킹을 제거한 비동기 처리와, Pending URL이라는 작은 UX 트릭의 합작품이었죠. 어쩌면 최고의 UX는 화려한 UI가 아니라, 사용자가 아무런 불편함 없이 자신의 목표를 달성하게 만드는, 보이지 않는 곳의 견고한 아키텍처일지도 모르겠습니다.
마지막으로 '모니터링'에 대한 생각도 정리해볼 수 있었어요. 이전에는 그저 시스템이 '죽었는지 살았는지'를 보는 것이 전부였다면, 이제는 사용자의 경험과 직결되는 '응답 시간' 같은 지표를 통해 시스템의 건강 상태를 미리 진단해야 한다는 것을 배웠습니다. 문제가 터지고 나서 로그를 뒤지는 '사후 대응'에서, 지표를 보며 미리 알아채는 '사전 예방'으로 나아가야 하는 거겠죠.
결국 하나의 '간단한' 파일 업로드 기능을 개선하는 여정은, 마이크로서비스 아키텍처의 기본 원칙, 기술 부채의 관리, 사용자 경험 설계, 그리고 데이터 기반의 시스템 운영까지 되돌아보는 값진 기회였던 것 같아요. 이 경험이 다른 개발자분들에게도 작은 힌트가 되기를 바랍니다.
'이슈와해결' 카테고리의 다른 글
훔칠 비밀번호가 없습니다: 평문 없는 서버를 위한 Passkey 구현기 (0) | 2025.08.23 |
---|---|
멀티 인스턴스 환경에서 살아남기 Ep. 1: Canary 배포와 데이터 정합성 (2) | 2025.08.16 |
이벤트 드리븐 트러블슈팅 Ep. 2: 사라진 혜택, 중복된 포인트? Kafka 메시지 처리, 어디까지 믿어야 할까 (1) | 2025.06.01 |
이벤트 드리븐 트러블슈팅 Ep. 1: 비동기 결제 파이프라인에서 구글 API가 늦을 때 생기는 일 (1) | 2025.05.10 |
[시스템 디자인 챌린지 - 문제 요구 사항] MAU 2천만 대형 블로그 플랫폼에서 1백만 명 유저가 어느 날 자정, 동시 예약을 걸었다면? (16,667 TPS 쓰기 요청을 감당해보자) (5) | 2025.05.01 |