Spring Boot SNS 포트폴리오 시리즈 1편. 마이크로서비스 4개(api-gateway·user·post·notification) + Next.js 프론트엔드를 Docker Compose 한 파일로 묶은 전체 아키텍처와 Database-per-Service 패턴, API Gateway 라우팅, Kafka 이중 리스너, Healthcheck 설계까지 그림과 표로 정리합니다.
이 글은 Spring Boot SNS 마이크로서비스 포트폴리오 시리즈의 1편입니다. Java 21 + Spring Boot 3.2 기반의 작은 SNS를 마이크로서비스 4개로 쪼개고, Next.js 14 프론트엔드와 PostgreSQL·Redis·Kafka·Elasticsearch·LocalStack S3·Mailhog 같은 인프라 7종을 Docker Compose 한 파일로 묶어 돌리는 구조를 처음부터 끝까지 풀어 갑니다.
핵심 비유는 한 줄이에요 — 작은 회사 본사 한 동에 4개 부서와 7개 공용 시설이 같이 살림을 차린 그림. 정문 보안실(API Gateway), 회원 관리부(User Service), 콘텐츠부(Post Service), 알림부(Notification Service)가 각자 자기 금고(전용 DB)를 가지고, 공용 시설(Redis·Kafka·Elasticsearch 등)은 복도에서 같이 씁니다. 이 비유 하나만 머리에 박아 두면 시리즈 전체가 따라옵니다.
프로젝트의 SNS는 흔히 쓰는 기능을 한 번씩 다 다뤄 봅니다. 이메일·비밀번호 회원가입에 구글·깃허브 OAuth2 소셜 로그인, 게시글 작성과 이미지 업로드(LocalStack S3 Presigned URL), 댓글·대댓글, 좋아요와 인기 게시글 랭킹, 사용자 구독에 새 글 알림(인앱 + 이메일), Elasticsearch nori로 한국어 형태소 검색까지. 학습 목표는 이 평범한 기능을 마이크로서비스 패턴(Database-per-Service · API Gateway · Outbox + Debezium CDC · Redisson 분산 락)과 인프라(Kafka 비동기 메시징, Redis 캐시·랭킹·블랙리스트, ES 전문 검색, S3 파일 업로드)로 어떻게 묶어 내는가를 직접 손으로 짜 보면서 익히는 거예요. 코드는 GitHub에 공개돼 있어요(개인 학습 저장소).
이 시리즈는 Spring Boot 공식 문서, Spring Cloud Gateway 공식 가이드, Apache Kafka 공식 문서, Docker Compose 공식 레퍼런스 등 여러 공개 자료와 직접 만들어 본 SNS 포트폴리오 코드를 한국어 학습 노트로 풀어쓴 자료입니다.
로컬에 Docker Desktop을 켜고 docker-compose up 한 번이라도 직접 돌려 보면 본문이 머리에 훨씬 잘 박혀요. 4개 부서가 8080·8081·8082·8083 포트에서 동시에 깨어나는 장면을 한 번 보고 나면 다음 편부터가 편해집니다.
왜 마이크로서비스가 처음엔 어렵게 느껴질까
처음 마이크로서비스를 공부할 때 막히는 지점은 두 가지예요.
첫째, 부품 수가 한 번에 두 자릿수로 늘어납니다. 모놀리스 한 덩이로 짜던 사람 입장에서 "Spring Boot 4개 + DB 3개 + Redis + Kafka + Zookeeper + ES + Kibana + LocalStack + Mailhog" 가 한 번에 깨어나는 게 부담스럽거든요. 하나만 빠져도 동작 안 하는 것처럼 보이고, 어떤 게 필수고 어떤 게 보조인지 감이 안 잡힙니다.
둘째, "왜 굳이 쪼갰는가"가 안 보여요. SNS 만들겠다고 회원·게시글·알림을 굳이 따로 떼어 둘 필요가 있나, 그냥 한 프로젝트에 컨트롤러 세 개 두면 되지 않나 — 이 의문이 안 풀리면 코드 보기가 지루해집니다.
해결법은 두 단계예요. 먼저 4개 서비스를 회사 부서 비유 한 줄씩으로 줄여 머리에 넣고, 그다음에 "쪼개면 뭐가 좋은가"를 한 번 짚고 넘어갑니다. 이 두 가지가 통과되면 코드는 그냥 기록일 뿐, 흐름이 보이거든요.
전체 그림 — 회사 본사 한 동에 부서 4개 + 공용 시설 7개
먼저 그림 한 장으로 보겠습니다. 브라우저에서 시작해 어디로 흘러가는지.
┌─────────────────────────────────────────────────────────────────────┐
│ 브라우저 │
└───────────────────────────────┬─────────────────────────────────────┘
│ HTTP (port 3000)
┌───────────────────────────────▼─────────────────────────────────────┐
│ Next.js (port 3000) │
│ │
│ Server Component ──── serverFetch() ──────────────────────────┐ │
│ Client Component ──── apiFetch() → /api/proxy/** ─────────────┤ │
│ /api/auth/{login,register,logout} ── 쿠키 발행/삭제 │ │
└───────────────────────────────────────────────────────────────────┬─┘
HTTP (port 8080)│
┌───────────────────────────────────────────────────────────────────▼─┐
│ API Gateway (Spring Cloud Gateway, port 8080) │
│ │
│ GlobalFilter: JWT 파싱 → Redis Blacklist 확인 → X-User-Id 주입 │
│ │
│ /v1/users/** → User Service (port 8081) │
│ /v1/posts/** → Post Service (port 8082) │
│ /v1/media/** → Post Service (port 8082) │
│ /v1/notifications/** → Notification Service (port 8083) │
└──────────────┬───────────────────────────────────────────────────────┘
│
┌───────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
User Service Post Service Notification Service
(port 8081) (port 8082) (port 8083)
│ │ │
├─ PostgreSQL ├─ PostgreSQL ├─ PostgreSQL
│ (userdb) │ (postdb) │ (notifdb)
├─ Redis ├─ Redis └─ Kafka Consumer
└─ Kafka ├─ Kafka Producer
├─ Elasticsearch
└─ LocalStack S3
위에서부터 한 층씩 짚어 보면 이렇게 정리됩니다.
- 브라우저 → Next.js (3000) — 사용자 화면. 서버 컴포넌트가 SSR로 데이터를 미리 가져오고, 클라이언트 컴포넌트는
apiFetch()로 백엔드를 부릅니다. 로그인·회원가입·로그아웃은 Next.js 자체 API 라우트가 받아 쿠키를 발행하거나 지웁니다. - Next.js → API Gateway (8080) — 정문 보안실. 모든 API 요청이 여기 한 곳을 통과합니다.
- API Gateway → 4개 부서 —
Path조건으로 어느 부서로 보낼지 결정./v1/users/**는 User Service,/v1/posts/**와/v1/media/**는 Post Service,/v1/notifications/**는 Notification Service. - 각 부서 → 자기 DB + 공용 시설 — 부서마다 자기 PostgreSQL 한 대씩 따로. Redis는 캐시·블랙리스트·랭킹용으로 공용, Kafka는 부서 사이 이벤트 게시판, Elasticsearch는 게시글 검색 전용, LocalStack S3는 미디어 파일 저장소.
여기까지 한 번 머리에 박히면 시리즈 나머지 편들은 "그 그림 안의 어느 칸을 더 자세히 들여다보는가" 가 됩니다. 5편(Kafka)은 "부서 사이 게시판" 칸을 줌인, 6편(Redis)은 "복도 캐시" 칸을 줌인, 9편(ES)은 "게시글 검색대" 칸을 줌인이라는 식.
마이크로서비스 4개 + 인프라 7개 — 포트 한 장으로
코드 띄울 때 가장 헷갈리는 부분이 포트 번호예요. 호스트와 컨테이너 포트가 따로 있고, 일부는 기본 포트와 충돌나서 일부러 비킨 게 있어서 그렇습니다. 한 표로 정리해 두면 docker-compose up 후 어디로 접속할지 한 번에 보여요.
| 서비스 | 호스트 포트 | 컨테이너 내부 | 비고 |
|---|---|---|---|
| api-gateway | 8080 | 8080 | Spring Boot |
| user-service | 8081 | 8081 | Spring Boot |
| post-service | 8082 | 8082 | Spring Boot |
| notification-service | 8083 | 8083 | Spring Boot |
| frontend | 3000 | 3000 | Next.js dev |
| postgres-user | 5435 | 5432 | 기본 5432 충돌 방지 |
| postgres-post | 5433 | 5432 | |
| postgres-notif | 5434 | 5432 | |
| redis | 6380 | 6379 | 기본 6379 충돌 방지 |
| zookeeper | 2181 | 2181 | |
| kafka | 9092 | 9092 | |
| localstack | 4566 | 4566 | S3 에뮬레이터 |
| elasticsearch | 9203 | 9200 | 기본 9200 충돌 방지 |
| kibana | 5601 | 5601 | |
| mailhog UI | 8025 | 8025 | SMTP: 1025 |
여기서 시험 함정이 하나 있어요. 굵게 표시된 포트 세 개 — postgres-user 5435, redis 6380, elasticsearch 9203 — 는 일부러 기본값을 비킨 값이에요. 로컬에 이미 PostgreSQL이나 Redis나 ES가 깔려 있을 가능성이 높은데 기본 포트(5432·6379·9200)를 그대로 쓰면 컨테이너가 "포트 점유 중"으로 못 올라옵니다. 처음 한 번 데이고 나면 절대 까먹지 않는 부분이라, 시작부터 5435·6380·9203 세 숫자만 외워 두면 됩니다.
마이크로서비스 4개는 8080~8083, DB 3개는 5433/5434/5435 (post/notif/user), 공용 인프라는 6380(Redis)·9092(Kafka)·9203(ES)·4566(S3)·8025(Mailhog).
Database-per-Service — 마이크로서비스의 첫 번째 규칙
마이크로서비스 패턴 중에서 가장 먼저 자리 잡아야 하는 게 Database-per-Service예요. 부서마다 자기 PostgreSQL 한 대씩 따로 들고, 다른 부서 DB를 절대 직접 안 들여다본다는 규칙입니다. 마이크로서비스로 쪼갰다고 하면서 DB는 하나만 같이 쓰면, 사실은 모놀리스를 어색하게 둘로 나눈 모양이 되거든요.
| 부서 | DB | 호스트 포트 | 들어 있는 데이터 |
|---|---|---|---|
| User Service | userdb | 5435 | 회원·구독 관계·RefreshToken |
| Post Service | postdb | 5433 | 게시글·댓글·좋아요·미디어 메타·Outbox |
| Notification Service | notifdb | 5434 | 알림 로그·이메일 발송 기록 |
회사 비유로 보면, 회원 관리부(User)가 콘텐츠부(Post)의 책상 서랍을 직접 열어 보면 안 된다는 규칙입니다. 게시글 정보가 필요하면 콘텐츠부에 정식으로 요청(REST API)하거나, 콘텐츠부가 사내 게시판(Kafka)에 "이런 일이 있었어요" 라고 붙여 둔 공지를 알림부가 받아서 처리하는 식.
이렇게 묶어 두는 게 처음엔 비효율적으로 보여요. "어차피 한 회사인데 그냥 한 DB에서 JOIN 하면 되지 않나" 하는 의문이 들거든요. 그런데 이게 세 가지 큰 이득을 줍니다.
- 독립 배포·스케일링 — 게시글 트래픽이 폭주해 postdb를 더 큰 인스턴스로 갈아도, userdb·notifdb는 그대로 살아 있습니다. 한 부서 일정에 다른 부서가 휘둘리지 않아요.
- 스키마 변경의 안전한 격리 — Post Service가 댓글 테이블을 둘로 쪼개도 User·Notification 코드에는 한 줄도 안 닿습니다. 모놀리스에서 가장 무서운 "한 줄 바꿨더니 다른 모듈이 깨짐" 이 구조적으로 안 일어나죠.
- 장애 격리 — postdb가 잠깐 죽어도 회원 가입·로그인은 계속 됩니다. SNS 전체가 까맣게 꺼지는 일이 없어요.
여기서 정말 중요한 시험 함정 — 서비스 사이에는 외래 키(FK)가 없습니다. userdb의 users.id와 postdb의 posts.user_id는 같은 의미를 가리키지만 DB 차원의 FK 제약은 안 걸려 있어요. 한 DB 안에서만 FK가 살고, 다른 부서 데이터에 대한 일관성은 애플리케이션 코드와 Kafka 이벤트로 맞춥니다. 이 부분이 처음엔 불안하게 느껴지는데, 마이크로서비스에서는 의도적으로 그렇게 가는 게 맞는 길이에요.
부서별 자기 DB / 다른 부서 DB는 절대 직접 X / 서비스 사이 FK 없음 / 일관성은 코드 + Kafka로.
API Gateway가 정문 보안실 역할 — X-User-Id 헤더 주입
부서 4개를 묶어 두면 한 가지 문제가 생겨요. 클라이언트가 각 부서 주소를 다 외우고 인증도 부서마다 따로 처리해야 합니다. 이걸 한 곳에 모아 처리하는 게 API Gateway예요. 회사로 치면 정문 보안실 — 모든 외부 요청이 여기서 신분증을 한 번 검사받고, 통과하면 적힌 부서로 내선 연결됩니다.
이 시스템은 Spring Cloud Gateway를 씁니다. 보통 백엔드 사람이 익숙한 Spring MVC가 아니라 WebFlux 기반의 리액티브(non-blocking) 게이트웨이라는 점이 처음엔 좀 어색해요. Servlet 스레드를 점유 안 하고 이벤트 루프로 도는 방식이라, Redis 같은 외부 호출도 반드시 리액티브 클라이언트(ReactiveStringRedisTemplate)를 써야 합니다. 동기 클라이언트를 섞으면 이벤트 루프가 막히면서 게이트웨이 전체가 느려져요.
게이트웨이의 핵심 책임은 두 가지로 요약됩니다.
- JWT 파싱 → Redis 블랙리스트 확인 → 통과 / 거부 — Authorization 헤더의 Bearer 토큰을 읽어 서명을 검증하고, jti(JWT ID)가 Redis 블랙리스트에 있으면 (= 로그아웃된 토큰이면) 거부합니다.
X-User-Id헤더 주입 후 하위 서비스로 전달 — 통과한 요청에 사용자 ID와 이메일을 헤더로 박아서 부서로 넘깁니다. 부서들은 토큰을 다시 풀 필요 없이 헤더만 읽으면 되거든요.
// API Gateway → 하위 서비스로 userId 주입
ServerWebExchange mutated = exchange.mutate()
.request(r -> r.header("X-User-Id", userId)
.header("X-User-Email", email))
.build();
내부 부서가 사용자 정보를 받는 코드도 단순해요.
@GetMapping("/me")
public UserDto me(@RequestHeader("X-User-Id") Long userId) {
return userService.findById(userId);
}
여기서 정말 중요한 시험 함정 — X-User-Id 헤더는 외부에서 직접 들어오면 안 됩니다. 클라이언트가 게이트웨이를 거치지 않고 부서 포트(8081·8082·8083)로 바로 요청하면서 X-User-Id: 1 을 박으면 그 사람으로 위장되거든요. 그래서 운영 환경에서는 부서 서비스의 포트를 외부에 절대 노출하면 안 되고, 게이트웨이만 외부로 열어 둡니다. 로컬 개발에서는 편의상 다 열려 있지만, 그 차이를 의식 못 하면 보안이 통째로 뚫립니다.
게이트웨이 = 정문 보안실 / JWT 검증 + 블랙리스트 + X-User-Id 주입 / 내부 포트 외부 노출 금지.
JWT 발급·블랙리스트 처리·OAuth2 같은 인증 디테일은 2편(API Gateway + JWT)과 3편(User Service + OAuth2)에서 따로 풀어 갑니다. 1편에서는 "정문 한 곳에서 다 처리" 라는 그림만 머리에 넣어 두면 충분해요.
Kafka 이중 리스너 — "사내 내선번호와 외선번호"
부서 사이 비동기 통신은 Kafka로 합니다. 게시글이 만들어지면 Post Service가 post.events 토픽에 이벤트를 붙이고, Notification Service가 그걸 구독해 알림을 만들어 보내는 식. 이 부분의 설정에서 처음 사람이 가장 많이 막히는 게 이중 리스너 설정이에요.
KAFKA_LISTENERS: PLAINTEXT_INTERNAL://0.0.0.0:29092,PLAINTEXT_EXTERNAL://0.0.0.0:9092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT_INTERNAL://kafka:29092,PLAINTEXT_EXTERNAL://localhost:9092
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL
비유로 풀면 — Kafka가 "내선번호 29092"와 "외선번호 9092" 두 개를 동시에 들고 있는 사내 게시판이에요. 같은 회사 안 부서(다른 컨테이너)는 사내 주소 kafka:29092로 부르고, 출장 나가 있는 사람(호스트에서 IDE로 띄운 Spring Boot)은 외부 주소 localhost:9092로 부르는 식.
Docker 네트워크 내부 서비스 → kafka:29092 (PLAINTEXT_INTERNAL)
호스트에서 실행 중인 Spring Boot → localhost:9092 (PLAINTEXT_EXTERNAL)
여기서 시험 함정이 하나 있어요. Kafka는 클라이언트에게 자신의 주소를 advertised.listeners로 알려줍니다. 클라이언트가 처음엔 localhost:9092로 접속해도, 그다음부터는 Kafka가 알려준 주소로 다시 접속하거든요. 그래서 advertised 쪽 주소가 잘못 박혀 있으면 첫 핸드셰이크는 통과해도 두 번째 요청부터 끊깁니다. "왜 한 번은 되고 그 다음엔 안 되지" 라는 신비한 증상이 나오면 거의 advertised 설정 문제예요.
리스너 두 개를 분리한 덕분에 IDE에서 띄운 Spring Boot로 빠르게 디버깅(외선)도 되고, 컨테이너끼리 내부 통신(내선)도 안 끊기는 양쪽 다 잡힙니다. Kafka 자세한 토픽 설계와 Outbox 패턴은 5편에서 따로 풀어요.
Healthcheck로 시작 순서 묶기 — depends_on: condition: service_healthy
부품이 많아지면 시작 순서가 중요해집니다. Kafka가 Zookeeper 없이 먼저 깨면 죽고, 부서가 PostgreSQL 없이 먼저 깨도 죽거든요. Docker Compose에서는 healthcheck로 이걸 해결해요.
# Zookeeper
test: ["CMD-SHELL", "echo srvr | nc localhost 2181 | grep -q Mode"]
# ruok 대신 srvr 사용: Confluent 이미지는 ruok를 기본 화이트리스트에서 제외
# PostgreSQL
test: ["CMD-SHELL", "pg_isready -U appuser -d userdb"]
# Redis
test: ["CMD", "redis-cli", "ping"]
각 서비스가 "나 살아 있어요" 신호를 자기 방식으로 내고, 이 신호가 뜨고 나서야 다음 서비스가 시작합니다. 예를 들어 Kafka는 Zookeeper가 healthy 상태가 된 다음에만 깨어나요.
kafka:
depends_on:
zookeeper:
condition: service_healthy
여기서 시험 함정이 하나 있어요. Zookeeper Confluent 이미지는 ruok 명령을 기본 화이트리스트에서 제외해 놨어요. 다른 튜토리얼 보면 거의 다 ruok로 healthcheck 짜는데 이 이미지에서는 응답이 안 와서 영원히 unhealthy로 남거든요. 그래서 srvr 명령에 Mode 문자열이 포함됐는지로 살아 있는지를 확인합니다. 작은 디테일이지만 이 한 줄 때문에 막히는 사람이 정말 많아요.
Elasticsearch도 한 가지 함정이 있습니다. 기본 힙 메모리가 512MB~1GB라 Docker VM 메모리가 부족하면 OOM(exit code 137)으로 그냥 죽어요. 그래서 학습용에서는 힙을 128MB로 의도적으로 줄여 둡니다.
environment:
- ES_JAVA_OPTS=-Xms128m -Xmx128m # 힙 메모리 128MB로 제한
- network.host=0.0.0.0
운영에서는 절대 이렇게 작게 못 잡지만, 로컬 16GB 맥북에서 부품 14개를 동시에 띄우려면 ES 힙은 양보할 수밖에 없거든요. 이 한 줄을 빼고 그냥 띄우면 ES만 자꾸 죽으면서 "왜 인덱싱이 안 되지" 라고 한참 헤매게 됩니다.
depends_on + condition: service_healthy 로 시작 순서 / Zookeeper는 srvr 사용 / ES는 힙 128MB로 줄이기.
시리즈 다음 편 — 마이크로서비스 게이트웨이 줌인
여기까지가 1편입니다. 회사 본사 한 동에 마이크로서비스 4개와 공용 시설 7개가 어떻게 자리 잡고, 누가 누구의 어디로 어떻게 말을 거는지 — 큰 그림이 머리에 잡혔으면 1편 목적은 다 됐어요.
2편에서는 API Gateway를 줌인해서 풀어 갑니다. JWT 서명 검증, Redis 블랙리스트 동기화, 공개 경로 정책, ReactiveStringRedisTemplate 같은 부분이요. 정문 보안실의 일과를 처음부터 끝까지 따라가 보면 게이트웨이 코드가 한결 친근해질 거예요.
공식 문서: 더 깊이 파고 싶으면 Spring Cloud Gateway 공식 가이드와 Apache Kafka 공식 문서에 그림과 예제가 잘 정리돼 있습니다.
시리즈 다른 편
- 1편 — 마이크로서비스 아키텍처 전체 그림 (현재 글)
- 2편 — API Gateway JWT 검증
- 3편 — User Service · OAuth2
- 4편 — Redisson 분산 락 · 동시성
- 5편 — Kafka 이벤트 흐름 · Outbox
- 6편 — Redis 4가지 활용 패턴
- 7편 — Elasticsearch + S3 업로드
시험 직전 한 번 더 — 마이크로서비스 함정 압축 노트
- 마이크로서비스 4개 = 8080(gateway) · 8081(user) · 8082(post) · 8083(notif) · 3000(frontend)
- DB 3개 = 5433(postdb) · 5434(notifdb) · 5435(userdb) — userdb만 굳이 5435인 이유는 기본 5432 충돌 방지
- Redis 6380 / Kafka 9092 / Elasticsearch 9203 — 셋 다 기본 포트 충돌 방지로 비킨 값
- LocalStack 4566 / Mailhog UI 8025 / SMTP 1025 / Kibana 5601
- Database-per-Service — 부서마다 PostgreSQL 한 대씩, 다른 부서 DB 직접 X
- 서비스 사이 FK 없음 — 일관성은 애플리케이션 코드 + Kafka로 맞춤
- API Gateway = Spring Cloud Gateway, WebFlux 기반(리액티브)
- 게이트웨이 = JWT 파싱 → Redis 블랙리스트 → X-User-Id·X-User-Email 헤더 주입
- 부서는
@RequestHeader("X-User-Id") Long userId로 사용자 식별 - 운영에서는 부서 포트(8081·8082·8083) 외부 절대 비공개 —
X-User-Id위장 방지 - WebFlux 게이트웨이에서는 반드시
ReactiveStringRedisTemplate(블로킹 X) - Kafka 이중 리스너 = 내선
kafka:29092(컨테이너) + 외선localhost:9092(호스트) KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL— 브로커 사이도 내선 사용- Kafka는 advertised.listeners 주소로 클라이언트가 재접속 — 잘못 박으면 두 번째 요청부터 끊김
- Zookeeper Confluent 이미지는
ruok화이트리스트 제외 →srvr | grep Mode로 healthcheck - Kafka는
depends_on: zookeeper: condition: service_healthy로 시작 순서 묶음 - PostgreSQL healthcheck =
pg_isready -U appuser -d <dbname> - Redis healthcheck =
redis-cli ping - Elasticsearch 기본 힙 512MB~1GB → 학습용은
-Xms128m -Xmx128m로 강제 축소 - ES OOM 신호 = exit code 137
- LocalStack S3 접근 시
pathStyleAccess강제 — 도메인 형식 URL이 안 됨 - Kibana 5601은 ES
condition: service_healthy후에 깨어남 - Outbox + Debezium CDC = 트랜잭션 일관성 보장 (5편에서 다룸)
- 시리즈 큰 줄기 = 1편(아키텍처) → 2편(API Gateway) → 3편(User+OAuth2) → 4편(Post+캐시·랭킹) → 5편(Kafka+Outbox) → 6편(Redis 패턴) → 7편(ES + S3)
다음 글(2편)에서는 API Gateway에서 JWT를 검증하고 Redis 블랙리스트를 확인하고 X-User-Id를 주입하는 흐름을 코드 한 줄씩 따라가며 풀어 갑니다.