개발 트러블슈팅 완전 정복 | 웹개발·백엔드·데브옵스 실무 에러 해결법. Docker, GitHub Actions, TypeScript, MySQL, Redis 등 실전 경험 기반의 개발 블로그
목요일
수요일
Nginx SSL 인증서 만료 해결법
🔍 검색 키워드: Nginx SSL 인증서 만료, Let's Encrypt 갱신, TLS 에러, HTTPS 연결 실패, 인증서 업데이트
Nginx SSL 인증서 만료 해결법
증상
브라우저에서 HTTPS 사이트 접속 시:
NET::ERR_CERT_AUTHORITY_INVALID
ERR_SSL_OBSOLETE_VERSION
The certificate has expired
SSL_ERROR_HANDSHAKE_FAILURE_ALERT
또는 서버 로그에:
SSL_ERROR_RX_RECORD_TOO_LONG
no shared ciphers
Peer rejected a valid certificate
certificate verify failed (self signed certificate)
cURL로 확인 시:
$ curl -v https://example.com
* SSL certificate problem: certificate has expired
원인
- 인증서 만료: Let's Encrypt 90일 정책, 또는 상용 인증서 만료
- 자동 갱신 미작동: certbot/Nginx 플러그인 설정 오류
- Nginx 설정 오류: 잘못된 인증서 경로
- 시스템 시간 오류: 서버 시계가 실시간과 다름
- 인증서 체인 미완성: 중간 인증서(Intermediate) 누락
- 포트 443 차단: Let's Encrypt 갱신 포트 막힘
- 권한 오류: 인증서 파일 읽기 권한 부족
해결 방법
방법 1: 현재 인증서 상태 확인
# 인증서 만료 기간 확인
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem \
-noout -dates
# 출력 예:
# notBefore=Jun 4 12:00:00 2024 GMT
# notAfter=Sep 2 12:00:00 2024 GMT
# SSL 프로토콜 버전 확인
openssl s_client -connect example.com:443 -tls1_2
# 인증서 체인 확인
openssl s_client -connect example.com:443 -showcerts
# 남은 기간 확인 (일 수)
ssl-cert-check -c /etc/letsencrypt/live/example.com/cert.pem
# Nginx 설정에서 인증서 경로 확인
grep "ssl_certificate" /etc/nginx/sites-enabled/default
방법 2: Let's Encrypt 인증서 자동 갱신
# 1. certbot 설치 (미설치 시)
sudo apt-get install certbot python3-certbot-nginx
# 2. 인증서 수동 갱신
sudo certbot renew
# 3. 특정 도메인만 갱신
sudo certbot renew --cert-name example.com
# 4. 강제 갱신 (만료 60일 전이 아니라도)
sudo certbot renew --force-renewal
# 5. 갱신 후 Nginx 재로드
sudo systemctl reload nginx
# 6. Nginx 설정 테스트
sudo nginx -t
자동 갱신 설정 (cron 또는 systemd):
# crontab 설정 (매일 오전 3시 확인)
sudo crontab -e
# 다음 라인 추가:
0 3 * * * /usr/bin/certbot renew --quiet && systemctl reload nginx
# 또는 systemd timer (권장)
sudo systemctl list-timers certbot
sudo systemctl status certbot.timer
방법 3: Nginx 설정 확인 및 수정
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# SSL 인증서 경로 확인
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# TLS 버전 명시 (TLS 1.2 이상)
ssl_protocols TLSv1.2 TLSv1.3;
# 최신 암호화 알고리즘
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers on;
# HSTS 설정 (선택사항)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 설정 테스트
# $ sudo nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
}
# HTTP → HTTPS 리다이렉트
server {
listen 80;
server_name example.com www.example.com;
location / {
return 301 https://$host$request_uri;
}
}
설정 적용:
sudo nginx -t
sudo systemctl reload nginx
방법 4: 새 인증서 발급 (기존 만료된 경우)
# 1. certbot 대화형 모드로 새 인증서 발급
sudo certbot certonly --nginx -d example.com -d www.example.com
# 2. 수동 DNS 검증 방식
sudo certbot certonly --manual --preferred-challenges dns \
-d example.com -d www.example.com
# DNS TXT 레코드 추가 후 엔터
# 3. 발급된 인증서 확인
sudo ls -la /etc/letsencrypt/live/example.com/
방법 5: 시스템 시간 확인 및 수정
# 시스템 시간 확인
date
# 시간이 틀렸다면 수정
sudo timedatectl set-ntp true # NTP 자동 동기화
sudo timedatectl set-timezone Asia/Seoul
# 수정 확인
date
timedatectl
# 시간 동기화 강제 실행
sudo ntpdate -s ntp.ubuntu.com
방법 6: SSL 점검 및 모니터링
# SSL Labs 온라인 테스트 (브라우저에서)
# https://www.ssllabs.com/ssltest/analyze.html?d=example.com
# 로컬에서 SSL 테스트
sudo apt-get install sslscan
sslscan --no-failed example.com:443
# 인증서 투명성 로그 확인
curl https://ct.googleapis.com/log/all_logs_list.json
# Nginx 에러 로그 확인
sudo tail -f /var/log/nginx/error.log
# Certbot 갱신 로그 확인
sudo tail -f /var/log/letsencrypt/letsencrypt.log
정리표
| 에러 메시지 | 원인 | 해결법 |
|---|---|---|
| certificate has expired | 인증서 만료 | certbot renew 실행 |
| SSL_ERROR_HANDSHAKE_FAILURE_ALERT | TLS 설정 오류 | Nginx SSL 설정 확인 |
| no shared ciphers | 암호화 불일치 | ssl_ciphers 최신 버전 설정 |
| certificate verify failed | 인증서 체인 누락 | fullchain.pem 사용 확인 |
| ERR_SSL_OBSOLETE_VERSION | 구버전 TLS | TLS 1.2 이상으로 설정 |
팁: Let's Encrypt는 90일마다 갱신이 필요합니다. certbot renew --dry-run으로 자동 갱신을 사전 테스트하고, 갱신 후 systemctl reload nginx로 무중단 재로드하세요. Nginx 재시작 시 기존 연결은 유지되므로 서비스 중단이 없습니다.
Kubernetes ImagePullBackOff 에러 해결법
🔍 검색 키워드: k8s ImagePullBackOff, 쿠버네티스 이미지 풀 실패, Docker 레지스트리 인증, 이미지 태그 오류, Pod 시작 실패
Kubernetes ImagePullBackOff 에러 해결법
증상
Kubernetes Pod를 배포했을 때 다음과 같은 상태에서 멈춘다:
$ kubectl get pods -n production
NAME READY STATUS RESTARTS AGE
app-deployment-abc123 0/1 ImagePullBackOff 2 5m
상세 확인 시:
$ kubectl describe pod app-deployment-abc123 -n production
Events:
Type Reason Age Message
---- ------ ---- -------
Normal Scheduled 5m Successfully assigned production/app-deployment-abc123 to worker-node-1
Normal BackOff 4m Back-off pulling image "myrepo/app:v1.0"
Warning Failed 4m Failed to pull image "myrepo/app:v1.0": rpc error: code = Unknown desc = Error response from daemon: unauthorized
Warning Failed 3m Back-off pulling image
또는 다음과 같은 메시지:
image not found
image pull rate limit exceeded
no such image
invalid reference format
원인
- 이미지명 오류: 잘못된 저장소명, 태그 또는 레지스트리 주소
- 인증 실패: Private Docker 레지스트리 접근 권한 없음
- 네트워크 단절: 워커 노드에서 레지스트리 접근 불가
- 레지스트리 다운: Docker Hub, ECR 등 서비스 장애
- 이미지 미존재: 푸시되지 않은 이미지 태그
- 레이트 제한: Docker Hub 무료 계정 풀 한도 초과
- CPU/메모리 부족: 노드 리소스 부족으로 스케줄링 실패
해결 방법
방법 1: 이미지명 및 태그 확인
# 현재 Pod의 이미지 정보 확인
kubectl get pod app-deployment-abc123 -n production -o yaml | grep image
# 정확한 이미지명 확인 (레지스트리 포함)
# 형식: [registry]/[repository]/[image]:[tag]
# 정상 예: docker.io/myrepo/app:v1.0
# 정상 예: gcr.io/my-project/app:latest
# 정상 예: ecr.amazonaws.com/123456789.dkr.ecr.us-east-1.amazonaws.com/app:v1.0
Deployment 수정:
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-deployment
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: app
template:
metadata:
labels:
app: app
spec:
containers:
- name: app
image: docker.io/myrepo/app:v1.0 # 정확한 이미지명
imagePullPolicy: IfNotPresent # 또는 Always
방법 2: Private 레지스트리 인증 설정
# 1. Docker 자격증명으로 Secret 생성
kubectl create secret docker-registry regcred \
--docker-server=gcr.io \
--docker-username=_json_key \
--docker-password="$(cat ~/gcr-key.json)" \
--docker-email=user@example.com \
-n production
# 2. 또는 기존 docker config 파일 사용
kubectl create secret generic regcred \
--from-file=.dockerconfigjson=$HOME/.docker/config.json \
--type=kubernetes.io/dockercfg \
-n production
Deployment에서 사용:
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-deployment
spec:
template:
spec:
imagePullSecrets:
- name: regcred # 위에서 생성한 Secret 이름
containers:
- name: app
image: gcr.io/my-project/app:v1.0
방법 3: 워커 노드 네트워크 확인
# 워커 노드에서 직접 레지스트리 연결 확인
kubectl debug node/worker-node-1 -it --image=ubuntu
# Pod 내에서:
apt-get update && apt-get install -y curl
curl -I https://gcr.io
curl -I https://docker.io
# 또는 임시 Pod에서 테스트
kubectl run test-curl --image=curlimages/curl -it --rm -- \
curl -v https://gcr.io
방법 4: 로컬에서 이미지 빌드 및 푸시 확인
# 1. 로컬에서 이미지 빌드
docker build -t myrepo/app:v1.0 .
# 2. 레지스트리에 푸시
docker push myrepo/app:v1.0
# 3. 푸시된 이미지 확인
# Docker Hub: https://hub.docker.com/r/myrepo/app
# GCR: gcloud container images list
# ECR: aws ecr describe-images --repository-name app
# 4. 로컬에서 이미지 실행 가능 확인
docker run --rm myrepo/app:v1.0 --version
방법 5: imagePullPolicy 조정
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-deployment
spec:
template:
spec:
containers:
- name: app
image: myrepo/app:v1.0
# imagePullPolicy 옵션:
# Always: 매번 레지스트리에서 풀 (기본, 태그 latest 사용 시)
# IfNotPresent: 로컬에 없을 때만 풀
# Never: 로컬에서만 사용 (오프라인 환경)
imagePullPolicy: Always
방법 6: 디버깅 및 재시도
# Pod 상세 로그 확인
kubectl logs app-deployment-abc123 -n production --previous
# 이벤트 확인 (시간 역순)
kubectl get events -n production --sort-by='.lastTimestamp'
# Pod 재생성 (자동 재시도)
kubectl rollout restart deployment/app-deployment -n production
# 캐시 클리어 후 재배포
kubectl set image deployment/app-deployment \
app=myrepo/app:v1.1 \
-n production
# 또는 현재 이미지로 강제 롤아웃
kubectl rollout restart deployment/app-deployment -n production
정리표
| 원인 | 증상 | 해결법 |
|---|---|---|
| 이미지명 오류 | image not found | 정확한 이미지명 확인 |
| 인증 실패 | unauthorized | imagePullSecrets 설정 |
| 네트워크 단절 | connection timeout | 워커 노드 네트워크 확인 |
| 레지스트리 장애 | service unavailable | 레지스트리 상태 확인 |
| 태그 미존재 | manifest not found | docker push 재실행 |
| 레이트 제한 | rate limit exceeded | 인증된 계정으로 전환 |
| 노드 리소스 부족 | OutOfmemory, OutOfDisk | 노드 리소스 확인 |
팁: 배포 전에 로컬 환경에서 docker push까지 성공하는지 확인하고, 클러스터에는 imagePullPolicy: Always 설정으로 항상 최신 이미지를 가져오도록 설정하면 버전 관리가 쉬워집니다.
Redis WRONGTYPE 에러: 데이터 타입 불일치 해결
🔍 검색 키워드: Redis WRONGTYPE 에러, Redis 데이터 타입, Redis 명령어 호환성, INCR 문자열, Redis 키 재사용
Redis WRONGTYPE 에러: 데이터 타입 불일치 해결
Redis는 강타입 데이터베이스입니다. 같은 키에 다른 타입의 데이터를 저장하려고 하면 WRONGTYPE 에러가 발생합니다. 흔히 개발 중에 같은 키명을 여러 목적으로 재사용할 때 마주치는 실수입니다. 이 글에서는 에러 원인과 해결 방법을 실제 상황으로 설명하겠습니다.
1. 증상: WRONGTYPE 에러 메시지
ERR WRONGTYPE Operation against a key holding the wrong kind of value
at Object.writeError (/app/node_modules/redis/lib/reply.js:71:15)
at Socket.onreply (/app/node_modules/redis/lib/index.js:304:17)
또는 Redis CLI에서:
> set user:1 "{name: john}"
OK
> lpush user:1 "new_value"
(error) WRONGTYPE Operation against a key holding the wrong kind of value
2. 원인 분석
Redis는 5가지 기본 데이터 타입을 가집니다:
- String:
set,get,incr등 - List:
lpush,rpush,lrange등 - Set:
sadd,scard,smembers등 - Hash:
hset,hget,hgetall등 - Sorted Set:
zadd,zscore,zrange등
원인 1: 같은 키를 다른 타입으로 사용
// 기존 코드: user:1을 String으로 사용
await redis.set('user:1', JSON.stringify({ name: 'john' }));
// 신규 코드: 같은 키를 List로 변경
await redis.lpush('user:1', 'new_value');
// ❌ WRONGTYPE 에러!
원인 2: 개발 중 타입 변경
// 처음에는 값 저장 (String)
redis.set('counter', '10');
// 나중에 증가시키려고 시도 (INCR은 String 전용)
redis.incr('counter'); // 기존 값이 JSON이면 에러
// 또는 반대로
redis.lpush('counter', 1); // 이제 List
redis.incr('counter'); // WRONGTYPE!
원인 3: TTL 만료 후 타입 재사용
redis.set('session:abc', 'token123', 'EX', 3600); // String으로 저장
// TTL이 지나 자동 삭제됨
// 몇 시간 후...
redis.hset('session:abc', 'user_id', '123'); // Hash로 저장 시도
// 만약 키가 남아있으면 WRONGTYPE!
3. 해결 방법
방법 1: 기존 키 삭제 후 재생성
// 안전한 재설정
await redis.del('user:1');
await redis.lpush('user:1', 'value1', 'value2');
방법 2: 다른 키명 사용
// AS-IS: 충돌하는 키
const key = 'user:1';
// TO-BE: 타입 명시하는 키명
const userKey = 'user:1:string'; // String용
const userListKey = 'user:1:list'; // List용
const userHashKey = 'user:1:hash'; // Hash용
// 또는 버전 붙이기
const userKeyV2 = 'user:1:v2';
방법 3: 타입 확인 후 처리
// Node.js Redis 클라이언트
async function safeSet(key, value, type = 'string') {
const existingType = await redis.type(key);
if (existingType !== 'none' && existingType !== type) {
console.warn(`키 '${key}'의 기존 타입: ${existingType}, 요청 타입: ${type}`);
await redis.del(key); // 기존 데이터 삭제
}
if (type === 'string') {
await redis.set(key, value);
} else if (type === 'list') {
await redis.lpush(key, value);
} else if (type === 'hash') {
await redis.hset(key, ...Object.entries(value).flat());
}
}
// 사용
await safeSet('user:1', { name: 'john' }, 'hash');
방법 4: Redis에서 타입 조회
# 기존 모든 키와 타입 확인
$ redis-cli KEYS '*' | while read key; do echo -n "$key: "; redis-cli TYPE "$key"; done
# 특정 패턴 확인
$ redis-cli SCAN 0 MATCH "user:*" | xargs -I {} redis-cli TYPE {}
방법 5: 마이그레이션 스크립트
// 전체 키를 새 구조로 마이그레이션
const redis = require('redis');
const client = redis.createClient();
async function migrateKeys() {
const keys = await new Promise((resolve, reject) => {
let allKeys = [];
const stream = client.scanStream();
stream.on('data', (keys) => allKeys.push(...keys));
stream.on('end', () => resolve(allKeys));
stream.on('error', reject);
});
for (const key of keys) {
if (key.startsWith('user:')) {
const type = await client.type(key);
if (type === 'string') {
const value = await client.get(key);
await client.del(key);
await client.hset(`${key}:v2`, 'data', value);
}
// 다른 타입도 처리...
}
}
console.log('마이그레이션 완료');
}
await migrateKeys();
4. 타입별 명령어 정리
| 명령어 | 대상 타입 | 예제 |
|---|---|---|
set / get |
String | set key value |
lpush / lrange |
List | lpush list_key value |
sadd / smembers |
Set | sadd set_key member |
hset / hget |
Hash | hset hash_key field value |
zadd / zrange |
Sorted Set | zadd zset_key 1 member |
incr |
String만 | incr counter |
lpop |
List만 | lpop list_key |
5. 예방 가이드
개발 단계:
- 키 설계 시 타입 명시:
{domain}:{id}:{type} - 예:
user:123:string,session:456:hash,queue:789:list
테스트 단계:
- Redis 모의 객체로 타입 검증
- 개발/스테이징 Redis 정기 초기화
배포 단계:
- 마이그레이션 스크립트로 기존 키 처리
- TTL 만료 후 재사용 금지 정책 수립
WRONGTYPE 에러는 Redis를 처음 다룰 때 흔한 실수입니다. 핵심은 같은 키에는 하나의 타입만 유지하는 것. 타입을 바꿔야 한다면 새 키를 사용하거나, 기존 데이터를 명시적으로 삭제하세요. 이를 통해 안정적인 Redis 운영이 가능합니다.
React useEffect 무한루프 문제 해결하기
🔍 검색 키워드: React useEffect 무한루프, useEffect 의존성 배열, React hooks 성능 최적화, useEffect 콘솔 로그, 클로저 이슈
React useEffect 무한루프 문제 해결하기
useEffect는 강력하지만, 잘못 사용하면 무한 루프에 빠질 수 있습니다. 특히 의존성 배열이 없거나 잘못 설정되면 매번 렌더링 후에 effect가 실행되어 상태를 계속 변경하는 악순환에 빠집니다. 이 글에서는 증상부터 원인, 그리고 해결 방법까지 실제 코드로 설명하겠습니다.
1. 증상: 콘솔 로그가 무한정 출력된다
의존성 배열 없이 useEffect를 작성하면 다음과 같이 작동합니다.
useEffect(() => {
console.log('effect 실행됨');
setCount(count + 1);
});
이 코드를 실행하면 콘솔에는:
- effect 실행됨
- effect 실행됨
- effect 실행됨
- (무한반복...)
이렇게 계속 출력되며, 브라우저가 느려지거나 먹통이 됩니다.
2. 원인 분석
의존성 배열이 없는 경우
useEffect(() => {
setCount(count + 1); // 상태 변경
}, []); // ❌ 의존성 배열 누락
의존성 배열을 생략하면 매번 렌더링 후마다 effect가 실행됩니다.
- 컴포넌트 렌더링
- useEffect 실행 → setCount 호출
- 상태 변경 → 컴포넌트 리렌더링
- useEffect 다시 실행
- 무한 반복...
의존성에 상태가 포함된 경우
useEffect(() => {
setCount(count + 1);
}, [count]); // ❌ count가 의존성에 포함됨
이 경우도 같은 문제가 발생합니다:
- count 변경 → effect 실행
- setCount(count + 1) → count 업데이트
- count 의존성이 변경됨 → effect 다시 실행
- 무한 루프...
3. 해결 방법
방법 1: 빈 의존성 배열 사용
마운트 시에만 한 번 실행하려면:
useEffect(() => {
// API 호출, 리스너 등록 등
fetchData();
}, []); // ✅ 빈 배열: 마운트 시에만 실행
방법 2: 정확한 의존성 명시
실제 변경 감지할 항목만:
useEffect(() => {
setDerivedValue(count * 2);
}, [count]); // ✅ count 변경 시에만 effect 실행
방법 3: 상태 업데이트 함수 패턴
이전 상태에 기반해 업데이트:
useEffect(() => {
setCount(prev => prev + 1);
}, []); // ✅ 의존성 없이도 안전 (단, 무한루프 원하지 않으면 피하기)
방법 4: useCallback으로 함수 메모이제이션
함수가 의존성이 되는 경우:
const handleUpdate = useCallback(() => {
setCount(count + 1);
}, [count]);
useEffect(() => {
handleUpdate();
}, [handleUpdate]); // 필요한 경우만 실행
방법 5: useRef로 초기 실행 방지
조건부 effect 실행:
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setCount(count + 1); // 마운트 후에만 실행
}, [count]);
4. 빠른 진단 가이드
| 증상 | 원인 | 해결책 |
|---|---|---|
| 무한 콘솔 로그 | 의존성 배열 누락 | 빈 배열 추가 [] |
| 특정 상태 변경 시 무한 루프 | 자신을 변경하는 상태가 의존성에 포함됨 | 의존성에서 제거하거나 함수 패턴 사용 |
| 마운트 후 특정 시점에만 실행 원함 | 조건 체크 없음 | useRef 또는 상태 플래그로 조건 추가 |
| 객체/배열 의존성으로 매번 실행됨 | 새 객체/배열 매번 생성됨 | useMemo 또는 상수로 변경 |
5. 실전 예제
API 호출 + cleanup 패턴 (무한루프 없음):
useEffect(() => {
let isMounted = true;
fetchUserData(userId).then(data => {
if (isMounted) setUser(data);
});
return () => {
isMounted = false; // cleanup
};
}, [userId]); // userId 변경 시에만 새로 fetch
의존성 배열 체크
[]→ 마운트 시에만 1회 실행[dep1, dep2]→ dep1 또는 dep2 변경 시 실행- 없음 → 매번 렌더링 후 실행 (⚠️ 위험)
useEffect의 무한루프는 React 개발에서 가장 흔한 실수입니다. 항상 의존성 배열을 명시하고, 그 안에는 실제 변경을 감지해야 할 항목만 넣으세요. 이 규칙만 지켜도 대부분의 성능 문제를 예방할 수 있습니다.
화요일
k8s OOMKilled 에러 진단 및 해결 완벽 가이드
<div style="font-family:'Noto Sans KR',sans-serif;line-height:1.8;max-width:860px;margin:0 auto">
<p style="color:#666;font-size:14px">🔍 검색 키워드: k8s OOMKilled, Kubernetes 메모리 부족, 파드 종료, 메모리 리소스 설정, 컨테이너 메모리 누수</p>
<h2>k8s OOMKilled 에러 진단 및 해결</h2>
<p>Kubernetes에서 파드가 갑자기 종료되고 재시작되는 문제는 보통 메모리 부족 때문입니다.</p>
<h3>1. 증상: 파드가 자꾸 크래시된다</h3>
<pre style="background:#1e1e1e;color:#d4d4d4;padding:16px;border-radius:6px;overflow-x:auto"><code>$ kubectl get pods
NAME READY STATUS RESTARTS AGE
my-app-abc123 0/1 CrashLoopBackOff 5 2m
Last State: Terminated (reason:OOMKilled, exit code:137)</code></pre>
<p>exit code 137은 Linux에서 SIGKILL(9) 신호를 받았다는 뜻입니다.</p>
<h3>2. 원인</h3>
<ul>
<li>메모리 limit이 너무 낮게 설정됨</li>
<li>애플리케이션의 메모리 누수</li>
<li>노드의 물리 메모리 부족</li>
</ul>
<h3>3. 해결 방법</h3>
<h4>방법 1: 메모리 limit 증가</h4>
<pre style="background:#1e1e1e;color:#d4d4d4;padding:16px;border-radius:6px;overflow-x:auto"><code>resources:
limits:
memory: 512Mi
requests:
memory: 256Mi</code></pre>
<h4>방법 2: HPA로 자동 확장</h4>
<pre style="background:#1e1e1e;color:#d4d4d4;padding:16px;border-radius:6px;overflow-x:auto"><code>apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-app-hpa
spec:
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70</code></pre>
<h3>정리표</h3>
<table border="1" cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse">
<tr style="background:#f5f5f5"><th style="text-align:left">확인 항목</th><th style="text-align:left">명령어</th><th style="text-align:left">의미</th></tr>
<tr><td>파드 상태</td><td>kubectl describe pod</td><td>OOMKilled 여부</td></tr>
<tr><td>실제 메모리 사용</td><td>kubectl top pod</td><td>현재 사용량</td></tr>
<tr><td>노드 여유</td><td>kubectl top node</td><td>전체 노드 메모리 상태</td></tr>
</table>
<hr>
<p><strong>팁</strong>: requests는 평상시 사용량의 70~80%, limits는 requests의 1.5~2배로 설정하는 것이 best practice입니다.</p>
</div>
Zustand persist 직렬화 에러 완벽 해결법
🔍 검색 키워드: Zustand persist 에러, 직렬화 실패, JSON stringify, localStorage 버그, 상태 관리 디버깅
Zustand persist 직렬화 에러 해결법
증상
Zustand의 persist 미들웨어를 사용할 때 다음과 같은 에러가 발생한다:
TypeError: Converting circular structure to JSON
at JSON.stringify (<anonymous>)
at persist.ts:42
원인
Zustand persist는 상태를 JSON으로 직렬화하여 localStorage에 저장합니다. 순환 참조, 함수, Date/Map/Set 등 특수 객체, undefined 값이 포함되면 에러가 발생합니다.
해결 방법
방법 1: partialize로 직렬화 가능한 상태만 저장
const useStore = create(
persist(
(set) => ({ user: { id: 1, createdAt: new Date() }, count: 0 }),
{
name: 'app-storage',
partialize: (state) => ({
user: { id: state.user.id, createdAt: state.user.createdAt.toISOString() },
count: state.count,
}),
}
)
);
방법 2: 버전 마이그레이션
const useStore = create(
persist(
(set) => ({ version: 2, theme: 'dark' }),
{ name: 'app-storage', version: 2, migrate: (s, v) => v < 2 ? { ...s, version: 2 } : s }
)
);
정리표
| 에러 상황 | 원인 | 해결법 |
|---|---|---|
| Converting circular structure to JSON | 순환 참조 객체 | partialize로 필드 선택 |
| Unexpected token u in JSON | undefined 값 저장됨 | 초기값을 null로 설정 |
| 함수가 저장되지 않음 | JSON 미지원 타입 | 함수는 상태에서 제외 |
팁: Chrome DevTools → Application → Local Storage에서 저장된 값을 직접 확인하고 JSON.parse()로 유효성을 검사하세요.
금요일
Prisma N+1 쿼리 문제 완벽 해결 가이드
🔍 검색 키워드: Prisma N+1 쿼리 문제, relation 로드 최적화, Prisma include, eager loading, 데이터베이스 쿼리 최적화
Prisma N+1 쿼리 문제 완벽 해결 가이드
1. 증상: N+1 쿼리 문제 발생
API 요청 하나에 데이터베이스 쿼리가 1 + N개 날아간다:
// 문제 있는 코드
const users = await prisma.user.findMany(); // Query 1: 사용자 100명 조회
for (const user of users) {
user.posts = await prisma.post.findMany({
where: { authorId: user.id }
}); // Query 2-101: 각 사용자마다 posts 조회 (100번 반복)
}
// 총 101개의 쿼리 발생! 🚨
// 로그:
// Query 1: SELECT * FROM "User" WHERE 1=1
// Query 2: SELECT * FROM "Post" WHERE "authorId" = 1
// Query 3: SELECT * FROM "Post" WHERE "authorId" = 2
// ...
// Query 101: SELECT * FROM "Post" WHERE "authorId" = 100
API 응답 속도가 매우 느리고, 데이터베이스 CPU 사용률이 급증한다. 동시 사용자가 많아지면 connection pool 고갈로 이어진다.
2. 원인 분석
Lazy Loading의 위험성
Prisma의 기본 동작은 "필요할 때만 로드하는" lazy loading이다. relation을 접근할 때마다 새로운 쿼리를 발생시킨다.
Include 미사용
relation 데이터가 필요한데도 include나 select를 사용하지 않아 N번의 추가 쿼리가 필요하다.
깊은 relation 체인
user.posts.comments.author.profile 같은 깊은 relation 체인은 기하급수적으로 쿼리가 증가한다.
루프 내에서의 데이터베이스 접근
forEach, for-of 루프 내에서 직접 데이터베이스 쿼리를 실행하는 구조.
3. 해결방법
방법 1: Include를 사용한 Eager Loading
// 1단계: 단순 include
const users = await prisma.user.findMany({
include: {
posts: true // 모든 posts를 함께 로드
}
});
// 총 2개 쿼리:
// Query 1: SELECT * FROM "User"
// Query 2: SELECT * FROM "Post" WHERE "authorId" IN (1, 2, ..., 100)
// 2단계: 조건부 include
const users = await prisma.user.findMany({
include: {
posts: {
where: { published: true }, // published=true인 posts만
orderBy: { createdAt: 'desc' },
take: 5 // 최근 5개만
}
}
});
// 3단계: 깊은 relation include (2단계까지만 권장)
const users = await prisma.user.findMany({
include: {
posts: {
include: {
comments: { // posts의 comments도 로드
include: {
author: true // comments의 author도 로드
}
}
}
}
}
});
방법 2: Select를 사용한 필드 최적화
// 필요한 필드만 선택
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
posts: {
select: {
id: true,
title: true,
slug: true
}
}
}
});
// 장점:
// 1. 필요 없는 컬럼(password, bio 등)을 제외 → 네트워크 대역폭 절약
// 2. 쿼리 성능 향상
// 3. 클라이언트에 민감한 정보 노출 방지
방법 3: BatchLoad 패턴 (DataLoader)
// dataloader 라이브러리 설치: npm install dataloader
import DataLoader from 'dataloader';
// User의 posts를 배치로 로드하는 DataLoader
const postsByUserIdLoader = new DataLoader(async (userIds) => {
const postsByUserId = await prisma.post.findMany({
where: { authorId: { in: userIds } }
});
// userIds 순서대로 결과 반환
return userIds.map(userId =>
postsByUserId.filter(post => post.authorId === userId)
);
});
// GraphQL resolver에서 사용
const userResolver = {
posts: (user) => postsByUserIdLoader.load(user.id)
};
// 효과:
// 100개의 개별 쿼리 대신 1개의 배치 쿼리로 통합
방법 4: 정규화된 API 응답 구조
// 비정규화 응답 (nested - 중복 데이터)
{
users: [
{
id: 1,
name: 'Alice',
posts: [
{ id: 101, title: 'Post 1', author: { id: 1, name: 'Alice' } }
]
}
]
}
// 정규화 응답 (flat - 효율적)
{
users: [{ id: 1, name: 'Alice' }],
posts: [{ id: 101, title: 'Post 1', authorId: 1 }]
}
// Prisma에서 정규화 응답 생성
const [users, posts, comments] = await Promise.all([
prisma.user.findMany(),
prisma.post.findMany(),
prisma.comment.findMany()
]);
const response = {
users,
posts,
comments
};
// 이렇게 하면 클라이언트가 필요한 대로 조합 가능
방법 5: 쿼리 성능 측정 및 디버깅
// .prisma/client 에서 로깅 활성화
const prisma = new PrismaClient({
log: [
{ emit: 'stdout', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stderr', level: 'error' }
]
});
// 또는 런타임 환경에서
prisma.$on('query', (e) => {
console.log(`${e.query} - ${e.duration}ms`);
});
// 성능 프로파일링 (Node.js console.time)
console.time('fetch users with posts');
const users = await prisma.user.findMany({
include: { posts: true }
});
console.timeEnd('fetch users with posts');
// fetch users with posts: 45.231ms
4. 정리표
| 상황 | 해결 방법 | 쿼리 수 | 성능 개선도 |
|---|---|---|---|
| N+1 발생 | include 미사용 | 1 + N | - |
| 단순 관계 | include true | 2 | ⬆⬆⬆ |
| 조건 필터 | include with where | 2 | ⬆⬆⬆ |
| 깊은 관계 (2단계) | 중첩 include | 3-4 | ⬆⬆⬆ |
| GraphQL 환경 | DataLoader | 1-2 (배치) | ⬆⬆⬆⬆ |
| 민감한 필드 | select 사용 | 2 + 대역폭↓ | ⬆⬆⬆ |
| 매우 깊은 관계 | 정규화 응답 | N (분산) | ⬆⬆⬆ |
핵심: N+1은 Prisma만의 문제가 아니라 ORM의 근본적인 특성. include로 eager loading을 하거나, API 설계 단계부터 정규화된 응답 구조를 고려해야 한다. GraphQL을 사용한다면 DataLoader는 필수다.
Next.js Hydration Mismatch 에러 해결
🔍 검색 키워드: Next.js hydration 불일치, hydration mismatch 에러, Next.js 서버사이드 렌더링, suppressHydrationWarning
Next.js Hydration Mismatch 에러 해결
증상
Warning: Expected server HTML to contain a matching <div> in <div>.
This means the server HTML, the browser's DOM, and the virtual DOM are out of sync.
또는:
Text content did not match. Server: "2026-07-30 14:32" Client: "2026-07-30 14:33"
개발 환경에서 콘솔에 경고가 나타나고, 프로덕션에서 UI가 깜빡거리거나 스타일이 제대로 적용되지 않습니다.
원인
Next.js는 서버에서 HTML을 생성한 후 클라이언트에서 해당 DOM에 이벤트 리스너를 연결(hydration)합니다. 서버와 클라이언트의 렌더링 결과가 다르면 hydration mismatch 발생:
// ❌ 서버와 클라이언트 결과가 다른 경우
export default function Clock() {
const now = new Date(); // 서버와 클라이언트 시간 다름
return <div>{now.toLocaleString()}</div>;
}
// 서버 렌더링: "2026-07-30 08:00:00 UTC"
// 클라이언트 렌더링: "2026-07-30 14:00:00 KST"
// → Mismatch!
흔한 원인들:
- 시간/날짜 함수 사용 (Date, new Date())
- 난수 생성 (Math.random())
- localStorage/sessionStorage 접근
- 조건부 렌더링이 서버/클라이언트마다 다름
- CSS-in-JS의 서버 스타일과 클라이언트 스타일 불일치
해결방법
1. useEffect 활용 (권장)
클라이언트에서만 렌더링하는 콘텐츠는 마운트 후 렌더링:import { useState, useEffect } from 'react';
export default function Clock() {
const [time, setTime] = useState(null);
useEffect(() => {
setTime(new Date().toLocaleString());
}, []); // 클라이언트에서만 실행
// 서버 렌더링: null 또는 placeholder 반환
if (!time) return <div>Loading...</div>;
return <div>{time}</div>;
}
2. suppressHydrationWarning (간단한 경우)
export default function Counter() {
return (
<div suppressHydrationWarning>
{Math.random()}
</div>
);
}
// 또는 특정 속성만 억제
export default function Div() {
return (
<div suppressHydrationWarning={true}>
Content
</div>
);
}
3. Dynamic Import with ssr: false
// components/ClientOnlyComponent.tsx
export default function ClientOnly() {
return <div>{Math.random()}</div>;
}
// pages/index.tsx
import dynamic from 'next/dynamic';
const ClientOnlyComponent = dynamic(
() => import('@/components/ClientOnlyComponent'),
{ ssr: false }
);
export default function Home() {
return (
<div>
<h1>Server Content</h1>
<ClientOnlyComponent /> {/ 클라이언트에서만 렌더링 /}
</div>
);
}
4. useLayoutEffect 또는 useId (안정적 ID)
import { useId } from 'react';
export default function Modal() {
const id = useId(); // 서버와 클라이언트에서 동일한 ID 생성
return (
<div id={id} data-modal={id}>
Modal with stable ID
</div>
);
}
5. 실전 예제: Dark Mode Toggle
'use client'; // App Router의 경우
import { useState, useEffect } from 'react';
export default function ThemeProvider() {
const [isDark, setIsDark] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
// 로컬스토리지에서 테마 읽기 (클라이언트에서만)
const saved = localStorage.getItem('theme') === 'dark';
setIsDark(saved);
setIsMounted(true);
}, []);
// 마운트 전: 기본값 표시 (서버 렌더링과 일치)
if (!isMounted) {
return <div className="light">Loading...</div>;
}
// 마운트 후: 실제 테마 적용
return (
<div className={isDark ? 'dark' : 'light'}>
<button onClick={() => setIsDark(!isDark)}>
Toggle Theme
</button>
</div>
);
}
6. 조건부 렌더링 안정화
// ❌ hydration mismatch 위험
const isClient = typeof window !== 'undefined';
export default function Component() {
return isClient ? <ClientComponent /> : <ServerComponent />;
}
// ✅ useEffect 사용
import { useEffect, useState } from 'react';
export default function Component() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) return <ServerComponent />;
return <ClientComponent />;
}
정리표
| 원인 | 증상 | 해결책 |
|---|---|---|
| 시간/날짜 함수 | "Expected server HTML..." 경고 | useEffect에서만 실행 |
| localStorage 접근 | 서버/클라이언트 값 다름 | 마운트 후 접근 |
| 조건부 렌더링 | UI 깜빡임 또는 깨짐 | useLayoutEffect 또는 dynamic import |
| 난수 생성 | 텍스트 불일치 경고 | suppressHydrationWarning 또는 useId |
| CSS-in-JS 불일치 | 스타일 깜빡임 | 서버 스타일과 클라이언트 스타일 동기화 |
useEffect로 분리하세요.
Kubernetes OOMKilled 에러 완벽 해결 가이드
🔍 검색 키워드: k8s OOMKilled, 쿠버네티스 메모리 부족, 포드 강제 종료, 메모리 리소스 요청, 리미트 설정
Kubernetes OOMKilled 에러 완벽 해결 가이드
1. 증상: OOMKilled 에러 메시지
Pod이 갑자기 재시작되고 로그에 다음과 같이 나타난다:
$ kubectl describe pod my-app-5f8d4c9d7b-xyz12
...
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Killed: True
Events:
Type Reason Age Message
---- ------ --- -------
Normal Created 2m Created container app
Normal Started 2m Started container app
Normal Killing 1m Killing container app (killed due to memory limit exceeded)
Pod의 상태가 계속 Pending이거나 CrashLoopBackOff 상태에 빠진다. 다른 Pod들은 정상인데 특정 Pod만 반복적으로 재시작된다.
2. 원인 분석
메모리 리미트 설정 부족
애플리케이션이 사용하는 실제 메모리가 Pod에 할당된 메모리 한계를 초과했다. kubelet이 OOM(Out of Memory) 상태를 감지하면 강제로 Pod을 kill한다.
메모리 누수
애플리케이션 코드에 메모리 누수가 있어서 시간이 지날수록 메모리 사용량이 증가한다. Node의 물리적 메모리도 한계가 있어 OOMKilled 발생.
Node 자원 부족
Node 레벨의 메모리가 부족하면 kubelet은 eviction policy에 따라 Pod들을 순차적으로 종료한다.
사이드카 컨테이너
logging agent, service mesh proxy(Istio sidecar) 등 추가 컨테이너들의 메모리 누적.
3. 해결방법
방법 1: Memory Request/Limit 올바르게 설정
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- name: app
image: my-app:latest
resources:
requests:
memory: "256Mi" # Pod 스케줄링 시 보장받는 메모리
limits:
memory: "512Mi" # 절대 초과 불가능한 한계
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
requests: Scheduler가 Node 할당 결정 시 사용
limits: cgroup을 통해 강제로 제한
방법 2: 메모리 사용량 모니터링
# Pod의 실제 메모리 사용량 확인
$ kubectl top pod my-app-5f8d4c9d7b-xyz12 -n default
NAME CPU(cores) MEMORY(bytes)
my-app-5f8d4c9d7b-xyz12 150m 387Mi
# Node 전체 메모리 상태
$ kubectl top nodes
NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
worker-node1 1200m 60% 5432Mi 68%
Prometheus + Grafana로 시간대별 메모리 추이를 분석. 대부분 정상이지만 특정 시간에 메모리 스파이크 발생하는지 확인.
방법 3: 메모리 누수 디버깅 (Node.js 예시)
// Node.js 힙 덤프 수집
const heapdump = require('heapdump');
app.get('/heapdump', (req, res) => {
const fileName = `/tmp/heapdump-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(fileName, (err, filename) => {
if (err) return res.status(500).send(err);
res.send(`Heap dump written to ${filename}`);
});
});
// Kubernetes manifest에 보안 컨텍스트 추가
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
allowPrivilegeEscalation: false
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
방법 4: HPA(Horizontal Pod Autoscaler) 설정
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70
메모리 사용율이 70% 도달하면 자동으로 Pod 개수 증가.
4. 정리표
| 구분 | 내용 | 해결도 |
|---|---|---|
| 증상 | Pod이 OOMKilled로 반복 재시작됨 | - |
| 원인 1 | 메모리 limit 설정 부족 | ✓ 높음 |
| 원인 2 | 애플리케이션 메모리 누수 | ✓ 중간 |
| 원인 3 | Node 자원 전체 부족 | ✓ 중간 |
| 원인 4 | 사이드카 컨테이너 누적 | ✓ 낮음 |
| 해결 1 | Request/Limit 재설정 | 즉시 효과 |
| 해결 2 | 메모리 모니터링 (top, Prometheus) | 원인 파악 필수 |
| 해결 3 | 메모리 누수 디버깅 | 근본 해결 |
| 해결 4 | HPA 자동 스케일링 | 장기 대책 |
핵심: k8s OOMKilled는 "메모리가 부족하다"는 신호. requests는 넉넉하게, limits는 필요한 만큼만 설정하고, 정기적으로 메모리 프로파일링을 해야 한다.
목요일
React useEffect 무한루프 문제 해결
🔍 검색 키워드: React useEffect 무한루프, useEffect 의존성 배열, useEffect 성능 최적화, React 렌더링 무한 반복
React useEffect 무한루프 문제 해결
증상
import { useEffect, useState } from 'react';
export default function DataFetcher() {
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setData(data));
}); // ⚠️ 의존성 배열 없음
return <div>{data.length} items</div>;
}
결과: 컴포넌트가 무한히 렌더링되고 API 요청이 계속 발생하며 브라우저 성능이 급격히 저하됩니다.
원인
React 18 Strict Mode에서 개발 환경 시 useEffectꊔ 의도적으로 2회 실행되지만, 가장 흔한 무한루프 원인은 의존성 배열이 생략되거나 빈 배열이 아닌 경우입니다.
- 의존성 배열 없음: 렌더링될 때마다 effect가 실행 → state 업데이트 → 다시 렌더링 → 무한 루프 - 객체/배열을 의존성으로 사용: 매 렌더링마다 새 참조 생성 → effect 재실행 → 무한 루프
// ❌ 나쁜 예: effect 내부에서 생성한 객체를 의존성으로 사용
useEffect(() => {
const config = { timeout: 5000 };
setData(config);
}, [config]); // config는 매번 새로 생성됨
// ❌ 나쁜 예: 함수를 의존성으로 사용
const fetchData = () => fetch('/api');
useEffect(() => {
fetchData();
}, [fetchData]); // fetchData는 매번 새로 생성됨
해결방법
1. 의존성 배열 추가 (기본 해결)
import { useEffect, useState } from 'react';
export default function DataFetcher() {
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setData(data));
}, []); // ✅ 마운트 시에만 실행
return <div>{data.length} items</div>;
}
2. 객체/배열 의존성 안정화
// ❌ useEffect 내부에서 객체 생성
useEffect(() => {
const options = { headers: { 'Authorization': token } };
fetchWithOptions(options);
}, []); // 작동하지만 options는 매번 재생성
// ✅ useMemo로 안정화
const options = useMemo(() =>
({ headers: { 'Authorization': token } }),
[token]
);
useEffect(() => {
fetchWithOptions(options);
}, [options]);
3. 함수 의존성 안정화 (useCallback)
// ❌ fetchData 매번 재생성
const fetchData = () => {
return fetch('/api/data').then(r => r.json());
};
useEffect(() => {
fetchData();
}, [fetchData]);
// ✅ useCallback으로 메모이제이션
const fetchData = useCallback(() => {
return fetch('/api/data').then(r => r.json());
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
4. 실전 예제: API 데이터 페칭
import { useEffect, useState, useCallback } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true; // 언마운트 후 state 업데이트 방지
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(/api/users/${userId});
const data = await response.json();
if (isMounted) {
setUser(data);
setError(null);
}
} catch (err) {
if (isMounted) {
setError(err.message);
setUser(null);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchUser();
return () => {
isMounted = false; // 정리 함수: 언마운트 시 플래그 설정
};
}, [userId]); // userId 변경 시에만 재실행
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{user?.name}</div>;
}
정리표
| 상황 | 원인 | 해결책 |
|---|---|---|
| 의존성 배열 없음 | 매 렌더링마다 effect 재실행 | [] 추가하거나 올바른 의존성 지정 |
| 객체를 의존성으로 사용 | 참조 변경으로 매번 재실행 | useMemo로 메모이제이션 |
| 함수를 의존성으로 사용 | 새 함수 참조로 무한 루프 | useCallback으로 메모이제이션 |
| state setter 직접 의존성 | 불필요한 재실행 | 의존성에서 제거 (setter는 안정함) |
| 외부 변수를 직접 사용 | ESLint 경고 무시 시 버그 | 모든 의존성을 배열에 포함 |
금요일
tRPC 타입 에러: Input Validation 실패와 해결방법
🔍 검색 키워드: tRPC 타입 에러, tRPC input validation 실패, tRPC ZodError, tRPC 클라이언트 타입 추론, tRPC 런타임 검증
tRPC 타입 에러: Input Validation 실패와 해결방법
증상
tRPC 클라이언트에서 정확하게 타입을 맞춰 데이터를 전송했는데도 서버에서 타입 검증 에러가 발생합니다.
Error: [UNPROCESSABLE_CONTENT]: Input validation failed
├─ expected number, received string (code: invalid_type)
└─ at "age"
또는 클라이언트에서 데이터를 보낼 때 타입스크립트 컴파일 에러:
Argument of type '{ name: string; age: string }' is not assignable
to parameter of type '{ name: string; age: number }'
원인
tRPC는 타입스크립트의 타입 검사와 런타임 검증(보통 Zod 스키마)을 별도로 수행합니다. 세 가지 주요 원인이 있습니다:
- Zod 스키마와 TS 타입이 불일치
서버에서z.number()로 정의했지만, 클라이언트 데이터는 문자열로 전달 - 타입 강제(coerce)가 없음
API 요청에서 쿼리 파라미터나 폼 데이터는 항상 문자열이지만, 스키마에서 변환하지 않음 - 선택적 필드와 기본값 처리 오류
.optional()또는.default()누락으로 undefined/null 검증 실패
해결방법
해결책 1: Zod 스키마에서 타입 강제(Coerce)
import { z } from 'zod';
import { router, publicProcedure } from '@trpc/server';
const userSchema = z.object({
name: z.string().min(1, "이름은 필수입니다"),
age: z.coerce.number().min(0, "나이는 0 이상이어야 합니다"),
email: z.string().email().optional(),
});
export const appRouter = router({
createUser: publicProcedure
.input(userSchema)
.mutation(async ({ input }) => {
// input.age는 이제 자동으로 number 타입 보장
return { success: true, age: input.age };
}),
});
해결책 2: 쿼리 파라미터 검증 시 .default() 사용
export const appRouter = router({
listUsers: publicProcedure
.input(
z.object({
limit: z.coerce.number().default(10).min(1).max(100),
skip: z.coerce.number().default(0).min(0),
sortBy: z.enum(['name', 'age', 'createdAt']).default('createdAt'),
})
)
.query(async ({ input }) => {
// input.limit, skip, sortBy 모두 안전하게 기본값 적용됨
return { data: [], total: 0 };
}),
});
해결책 3: 클라이언트에서 타입 안전성 확보
// 클라이언트
const trpc = createTRPCReact();
export function UserForm() {
const createUser = trpc.createUser.useMutation();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// 명시적 타입 변환
const payload = {
name: formData.get('name') as string,
age: parseInt(formData.get('age') as string), // 문자열 → 숫자
email: (formData.get('email') as string) || undefined,
};
// 이제 tRPC이 타입 검증 & 런타임 검증 수행
createUser.mutate(payload);
};
return (
);
}
정리표
| 문제 상황 | 원인 | 해결방법 |
| 쿼리 파라미터가 문자열인데 number 검증 실패 | Zod에서 강제 변환 안 함 | z.coerce.number() 사용 |
| 폼 데이터 전송 시 "필드 없음" 에러 | 선택적 필드에 .optional() 미적용 |
스키마에 .optional() 또는 .default() 추가 |
| TS 컴파일은 통과했는데 런타임 에러 | 타입과 Zod 스키마 불일치 | 스키마에서 satisfies z.ZodType<YourType> 검증 |
| API 응답 타입이 예상과 다름 | 뮤테이션 반환값 타입 정의 누락 | .mutation() 뒤에 .output(schema) 체인 추가 |
tRPC는 타입스크립트 안전성과 런타임 검증을 결합한 강력한 도구입니다. Zod 스키마를 "소스 오브 트루스"로 생각하고, 클라이언트 타입은 자동으로 유추되도록 하면 대부분의 타입 에러를 사전에 방지할 수 있습니다.
AWS Lambda Task Timed Out 에러 해결 — 타임아웃 원인 6가지와 실무 대응법
Lambda를 운영하다 보면 CloudWatch 로그에서 이런 메시지를 맞닥뜨리게 된다.
REPORT RequestId: abc123 Duration: 3000.21 ms Billed Duration: 3000 ms
ERROR Task timed out after 3.00 seconds
함수는 실행됐는데 응답 없이 죽어버렸다. 처음엔 당황���럽지만 원인은 거의 정해져 있다.
증상
- CloudWatch 로그에
Task timed out after X.XX seconds출력 - Lambda 함수가 응답을 반환하지 않고 강제 종료됨
- 간헐적으로만 발생하거나, 특정 요청에서 반드시 발생
- API Gateway 연동 시
502 Bad Gateway또는504 Gateway Timeout함께 발생
원인 1: 기본 타임아웃(3초)이 너무 짧다
Lambda 함수의 기본 타임아웃은 3초다. 이게 문제다. DB 쿼리 한 번, 외부 API 호출 한 번만 해도 금방 넘어간다.
확인 방법:
aws lambda get-function-configuration --function-name my-function \
--query 'Timeout'
# 출력: 3
해결: 콘솔에서 [구성] → [일반 구성] → [편집] → 타임아웃 값을 늘린다.
CLI로 변경하려면:
aws lambda update-function-configuration \
--function-name my-function \
--timeout 30
원인 2: DB 연결이 매 요청마다 새로 맺어진다
가장 많이 보는 패턴이다. Lambda 핸들러 쳸안에서 DB 커넥션을 생성하면 요청마다 TCP 핸드셰이크 + 인증 과정을 반복한다. RDS 기준으로 연결 하나 맺는 데 수백 ms가 날아간다.
나쁜 패턴:
def handler(event, context):
conn = psycopg2.connect(host=..., dbname=..., user=..., password=...) # 매번 생성
cursor = conn.cursor()
cursor.execute("SELECT ...")
return cursor.fetchall()
좋은 패턴:
import psycopg2
# 핸들러 밖 — 컨테이너가 살아있는 동안 재사용
conn = psycopg2.connect(host=..., dbname=..., user=..., password=...)
def handler(event, context):
cursor = conn.cursor()
cursor.execute("SELECT ...")
return cursor.fetchall()
Lambda 컨테이너가 재사용되는 Warm 상태에서는 전역 변수가 유지된다. 커넥션을 핸들러 밖에 두면 같은 컨테이너 인스턴스에서 재사용된다.
RDS를 쓰고 있다면 RDS Proxy 도입을 검토한다. 커넥션 풀링을 프록시가 대신 처리해줘서 Lambda와 RDS 사이의 커넥션 폭발 문제도 같이 해결된다.
원인 3: 외부 API 호출 병목 접근하연동 해에서 없다
// 이 코드는 외부 API가 응답 안 하면 Lambda 타임아웃까지 기다린다
const response = await axios.get('https://some-api.example.com/data');
외부 서비스가 느리거나 다운된 경우, 설정된 타임아웃까지 Lambda가 하염없이 대기한다.
수정 (JavaScript):
const response = await axios.get('https://some-api.example.com/data', {
timeout: 5000, // 5초 안에 응답 없으면 에러
});
수정 (Python):
import requests
response = requests.get(
'https://some-api.example.com/data',
timeout=(3.0, 10.0) # (connect timeout, read timeout)
)
원인 4: 순차 API 호출을 직렬로 처리화고 있다
// 이러면 A + B + C 호출 시간이 전부 합산된다
const userInfo = await fetchUser(userId);
const orderInfo = await fetchOrder(userId);
const reviewInfo = await fetchReview(userId);
병렬 처리로 개선:
const [userInfo, orderInfo, reviewInfo] = await Promise.all([
fetchUser(userId),
fetchOrder(userId),
fetchReview(userId),
]);
독립적인 API 호출이라면 Promise.all로 묶으면 가장 느린 요청 시간 하나로 줄어든다.
원인 5: Cold Start + VPC 설정
Lambda를 VPC 안에 넣으면 Cold Start 시간이 크게 늘어난다. CloudWatch Logs에서 Init Duration이 표시되면 Cold Start다.
REPORT RequestId: abc123 Duration: 850.00 ms Billed Duration: 851 ms
Init Duration: 612.38 ms
대응 방법:
- VPC가 필요 없다면 빼는 게 제일 낫다
- 꼭 필요하다면 Provisioned Concurrency 사용 (미리 컨테이너를 띄워둠)
원인 6: 메모리 부족 → CPU 부족
Lambda에서 메모리와 CPU는 연동된다. 메모리를 늘리면 CPU도 더 할당된다. CloudWatch Metrics에서 Max Memory Used를 확인한다.
aws lambda update-function-configuration \
--function-name my-function \
--memory-size 1024
진단 체크리스트
| 확인 항목 | 방법 |
|---|---|
| 타임아웃 설정값 확인 | AWS 콘솔 → 함수 → 일반 구성 |
| Cold Start 여부 | CloudWatch Logs에서 Init Duration 검색 |
| VPC 설정 여부 | 함수 구성 → VPC 섹션 |
| 메모리 사용량 | CloudWatch → Max Memory Used |
| 외부 호출 병목 | AWS X-Ray 트레이싱 활성화 후 확인 |
| DB 커넥션 위치 | 코드에서 커넥션이 핸들러 안에 있는지 확인 |
X-Ray로 병목 찾기
CloudWatch Logs만으로는 어디서 느린지 파악이 어렵다. AWS X-Ray를 활성화하면 함수 내 각 구간의 소요 시간을 추적할 수 있다.
from aws_xray_sdk.core import xray_recorder, patch_all
patch_all() # boto3, requests, psycopg2 등 자동 추적
def handler(event, context):
with xray_recorder.in_subsegment('db-query'):
result = query_database()
return result
정리
Lambda 타임아웃 에러는 대부분 이 순서로 접근하면 해결된다.
- CloudWatch Logs에서
Init Duration유무로 Cold Start 여부 확인 - X-Ray 트레이싱 켜고 어느 구간에서 시간이 먹히는지 파악
- DB 커넥션이 핸들러 안에 있으면 밖으로 빼기
- 외부 API 호출에 명시적 timeout 설정
- 순차 호출을
Promise.all/asyncio.gather로 병렬화 - 그래도 안 되면 메모리 늘리거나 타임아웃 값 상향
타임아웃 값 늘리는 게 제일 빠른 임시방편이긴 하지만, 근본 원인을 안 잡으면 요금만 늘어난다. 특히 RDS 커넥션 관리와 외부 API timeout 설정은 Lambda 쓰면서 기본 중의 기본이다.
MongoDB ECONNREFUSED ::1:27017 에러 해결 — 연결 거부의 원인과 진단법
이 에러가 뜨는 상황
Node.js 앱을 로컬에서 실행하는데 이런 에러가 나온다.
MongoServerSelectionError: connect ECONNREFUSED ::1:27017
at Topology.selectServer (/node_modules/mongoose/...)
at ...
또는 Python 쪽이라면:
pymongo.errors.ServerSelectionTimeoutError: localhost:27017: [Errno 111] Connection refused
분명히 mongod를 실행했다고 생각하는데 연결이 안 된다. mongodb://localhost:27017로 접속 시도하는데 왜 ::1(IPv6)로 가는지도 모르겠다. 이게 왜 생기는지, 어떻게 잡는지 순서대로 정리한다.
핵심 원인 세 가지
1. MongoDB 서비스가 실제로 안 돌고 있음
가장 기본적인 원인인데 의외로 많다. mongod 명령어를 쳤는데 포그라운드로 떠서 터미널 닫을 때 같이 죽는 경우, 또는 서비스 등록 없이 수동 실행했다가 시스템 재시작 후 안 뜨는 경우다.
# Linux/macOS — MongoDB 상태 확인
sudo systemctl status mongod # Linux (systemd)
brew services list | grep mongodb # macOS (Homebrew)
# 안 떠있으면 시작
sudo systemctl start mongod # Linux
brew services start mongodb-community # macOS
# 실제로 27017 포트 열려있는지 확인
netstat -tlnp | grep 27017 # Linux
lsof -i :27017 # macOS
포트 리스닝이 없으면 MongoDB가 안 뜬 것이다.
2. localhost가 ::1(IPv6)로 해석되는 문제
Node.js v17부터 DNS 리졸버가 IPv6를 우선한다. localhost를 DNS로 조회하면 ::1(IPv6)를 먼저 반환하는데, MongoDB가 IPv4(127.0.0.1)에서만 리스닝 중이면 연결이 거부된다.
에러 메시지의 ::1:27017이 바로 이 경우다.
빠른 확인: MongoDB가 IPv4로 리스닝하는지 확인
netstat -tlnp | grep 27017
# 결과 예시:
# tcp 0 0 127.0.0.1:27017 0.0.0.0:* LISTEN (IPv4만 리스닝)
# tcp6 0 0 :::27017 :::* LISTEN (IPv6도 리스닝)
3. bindIp 설정이 제한되어 있음
/etc/mongod.conf의 bindIp가 127.0.0.1로만 설정돼 있으면 IPv6(::1)로 오는 연결은 거부된다.
상황별 해결 방법
방법 1: 연결 문자열에서 localhost 대신 IP 직접 지정
가장 빠른 임시 해결책이다. localhost 대신 127.0.0.1을 명시한다.
// 변경 전
mongoose.connect('mongodb://localhost:27017/mydb');
// 변경 후
mongoose.connect('mongodb://127.0.0.1:27017/mydb');
# Python pymongo
from pymongo import MongoClient
# 변경 전
client = MongoClient('localhost', 27017)
# 변경 후
client = MongoClient('127.0.0.1', 27017)
localhost 대신 127.0.0.1을 쓰면 DNS 조회 없이 바로 IPv4로 연결한다. 많은 경우데 이것만으로 해결된다.
방법 2: MongoDB bindIp에 IPv6 추가
근본 해결을 원하면 MongoDB 설정에서 IPv6도 리스닝하도록 한다.
# /etc/mongod.conf
net:
port: 27017
bindIp: 127.0.0.1,::1 # IPv4, IPv6 모두 리스닝
설정 변경 후 재시작:
sudo systemctl restart mongod
방법 3: Docker로 MongoDB 실행 중인 경우
Docker로 MongoDB를 띄웠는데 포트 매핑을 빠뜨린 경우다.
# 잘못된 예 — 포트 매핑 없음
docker run -d --name mongo mongo:7
# 올바른 예 — 27017 포트 매핑
docker run -d --name mongo -p 27017:27017 mongo:7
Docker Compose라면:
services:
mongo:
image: mongo:7
ports:
- "27017:27017" # 이 줄이 없으면 호스트에서 접속 불가
volumes:
- mongo_data:/data/db
상황별 체크리스트
| 체크 항목 | 명령어 | 기대 결과 |
|---|---|---|
| MongoDB 프로세스 실행 중 | ps aux | grep mongod | mongod 프로세스 보임 |
| 27017 포트 리스닝 | lsof -i :27017 | mongod 프로세스가 27017 점유 |
| IPv4 리스닝 확인 | netstat -tlnp | grep 27017 | 127.0.0.1:27017 또는 0.0.0.0:27017 |
| Docker 포트 매핑 | docker ps | 0.0.0.0:27017->27017/tcp 확인 |
| 연결 테스트 (IPv4) | nc -zv 127.0.0.1 27017 | Connection succeeded |
| 연결 테스트 (IPv6) | nc -zv ::1 27017 | succeeded/failed 여부 확인 |
MongoDB 로그로 원인 확인
# 로그 실시간 확인
sudo tail -f /var/log/mongodb/mongod.log
# 최근 에러만
sudo grep -i error /var/log/mongodb/mongod.log | tail -20
로그에서 bind failed 또는 exception in initAndListen 같은 메시지가 보이면 포트 충돌이나 권한 문제다.
# 27017 포트 점유 프로세스 확인 (MongoDB 아닌 다른 프로세스일 수도 있음)
sudo lsof -i :27017
MongoServerError: Authentication Failed도 같이 나온다면
연결은 됐는데 인증에서 막히는 경우는 별개 이슈다. ECONNREFUSED(연결 거부)와 헷갈리기 쉬운데, 연결 거부는 TCP 자체가 안 되는 것이고 인증 실패는 연결 후 자격증명 검증 단계다.
인증 실패 에러:
MongoServerError: Authentication failed.
이 경우는 username/password 오류이거나 authSource 설정 문제다.
// authSource 명시
mongoose.connect('mongodb://user:pass@127.0.0.1:27017/mydb?authSource=admin');
마무리
ECONNREFUSED ::1:27017 에러는 90%가 두 가지 원인 중 하나다:
- MongoDB가 실제로 안 떠있음
- localhost가 IPv6로 해석되는데 MongoDB는 IPv4만 리스닝
연결 문자열의 localhost를 127.0.0.1로 바꾸는 것만으로도 대부분 해결된다. Docker를 쓴다면 포트 매핑(-p 27017:27017)을 빠뜨리지 않았는지 확인하자.
DB 연결 에러 관련해서 MySQL 연결 에러는 MySQL connection error 해결, Redis 연결 에러는 Redis connection refused 해결 글을 참고하면 된다.
작성일: 2026-06-25
React useEffect 무한루프 원인과 해결 방법
🔍 검색 키워드: React useEffect 무한루프, useEffect 의존성 배열, useEffect 성능 최적화, 리액트 렌더링 최적화, useEffect 클린업 함수
React useEffect 무한루프 원인과 해결 방법
useEffect는 React의 핵심 Hook이지만, 잘못 사용하면 무한 루프에 빠져 성능 저하와 메모리 누수를 유발합니다. 특히 의존성 배열을 제대로 관리하지 않으면 예상치 못한 사이드 이펙트가 반복됩니다.
문제 증상
콘솔에서 다음과 같은 현상이 반복됩니다:
GET /api/data 200 (무한 반복)
Warning: Can't perform a React state update on an unmounted component
Memory usage: 500MB → 1.5GB (급격히 증가)
브라우저 탭이 점점 느려지고, 개발자 도구의 Network 탭에서 같은 요청이 계속 쌓입니다.
원인 분석
원인 1: 의존성 배열 누락
useEffect(() => {
fetchData();
}, []); // ✗ 의존성 배열이 비어있음
이 경우 useEffect는 마운트 시점에만 실행되지만, fetchData 내부에서 setState를 호출하면:
- 상태가 변경됨 → 리렌더링 → useEffect 다시 실행 (만약 의존성이 제대로 안 되어 있으면)
원인 2: 객체/배열을 의존성으로 사용
const config = { url: '/api/data' };
useEffect(() => {
fetch(config.url);
}, [config]); // ✗ 객체는 매번 새로 생성되므로 무한루프
원인 3: 상태를 의존성에 포함 + 그 상태를 변경
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // ✗ count가 변경 → useEffect 실행 → count 변경 → 무한루프
}, [count]);
해결 방법
해결책 1: 의존성 배열 올바르게 설정
useEffect(() => {
fetchData();
}, []); // 마운트 시점에만 1회 실행
특정 값이 변경될 때만 실행:
const [userId, setUserId] = useState(null);
useEffect(() => {
if (userId) {
fetchUserData(userId);
}
}, [userId]); // userId 변경 시에만 실행
해결책 4: 클린업 함수로 이전 요청 취소
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(data => setData(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort();
}, []);
체크리스트 및 정리표
| 상황 | 결과 |
| 마운트 시점에만 실행 | 1회 실행 ✓ |
| 특정 값 변경 시 실행 | value 변경 시마다 실행 ✓ |
이 패턴들을 적용하면 useEffect 무한루프 문제를 대부분 해결할 수 있습니다.
🔍 검색 키워드: PostgreSQL connection pool, 데이터베이스 연결 풀, pg-pool, pgBouncer, 데이터베이스 성능 최적화, 커넥션 풀 설정
PostgreSQL Connection Pool 설정과 최적화 전략
PostgreSQL 데이터베이스에서 성능 병목의 70%는 연결 관리 문제에서 비롯됩니다. 특히 고트래픽 환경에서는 연결 수가 제한되어 있어서, 적절한 Connection Pool 설정이 없으면 요청이 대기 상태에 빠지고 타임아웃이 발생합니다.
문제 증상
다음과 같은 에러가 주기적으로 발생합니다:
Error: connect ECONNREFUSED 127.0.0.1:5432
Error: Client already has a client in it
FATAL: remaining connection slots are reserved
Error: query timeout - Client request timeout
원인 분석
원인 1: Connection Pool 미설정
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
// 각 쿼리마다 새 연결 생성 → 연결 고갈
원인 2: Connection Leak - 연결 반환 미흡
const client = await pool.connect();
const result = await client.query('SELECT * FROM users');
// ✗ client.release()를 호출하지 않음
해결 방법
해결책 1: Connection Pool 설정
const pool = new Pool({
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
statement_timeout: '30s'
});
app.get('/user/:id', async (req, res) => {
let client;
try {
client = await pool.connect();
const result = await client.query(
'SELECT * FROM users WHERE id = $1',
[req.params.id]
);
res.json(result.rows[0]);
} finally {
if (client) client.release();
}
});
해결책 2: pgBouncer 설정 (고트래픽 환경)
sudo apt-get install pgbouncer
# /etc/pgbouncer/pgbouncer.ini
[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp_prod
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
idle_in_transaction_timeout = 60
성능 최적화
| 항목 | 최적값 |
| max 연결 수 | CPU 코어 × 2~4 |
| idleTimeoutMillis | 30~60초 |
| statement_timeout | 30~60초 |
Connection Pool을 올바르게 설정하면 데이터베이스 성능이 2~5배 향상될 수 있습니다.