레이블이 웹개발인 게시물을 표시합니다. 모든 게시물 표시
레이블이 웹개발인 게시물을 표시합니다. 모든 게시물 표시

수요일

React useEffect cleanup 누락 — 타이머·이벤트 리스너가 중복으로 쌓이는 이유

🔍 검색 키워드: react useEffect cleanup, useEffect 정리 함수, setInterval 중복 실행, addEventListener 중복 등록, StrictMode 두 번 실행, useEffect 메모리 누수

요약: useEffect가 타이머·구독·이벤트 리스너처럼 계속 살아 있는 것을 만들어 놓고 정리(cleanup) 함수를 반환하지 않으면, 의존성이 바뀌어 effect가 다시 실행될 때마다 이전 것이 그대로 남아 중복으로 쌓인다. setup이 만든 것을 그대로 되돌리는 함수를 return하면 해결된다. React 공식 문서 기준으로 cleanup은 의존성이 바뀐 커밋마다 setup보다 먼저, 그리고 언마운트 후 한 번 더 실행된다.

useEffect의 cleanup은 언제 실행되나요?

React 공식 useEffect 문서는 실행 순서를 다음과 같이 못 박는다.

  • 컴포넌트가 화면에 추가될 때(mount) setup 코드가 실행된다.
  • 의존성이 바뀐 커밋마다, 먼저 이전 props·state로 cleanup이 실행되고 그다음 새 값으로 setup이 실행된다.
  • 컴포넌트가 화면에서 제거된 뒤(unmount) cleanup이 마지막으로 한 번 더 실행된다.

문서는 cleanup이 "setup 함수가 하던 일을 멈추거나 되돌려야 한다"고 설명한다. 즉 cleanup은 선택 사항이 아니라 setup의 거울상이다. setup에서 무언가를 만들었다면 cleanup에서 그걸 없애야 한다.

테스트 환경

아래 숫자는 이 환경에서 직접 실행한 결과다. 다만 React 런타임을 띄워 컴포넌트를 렌더한 것이 아니라, effect가 반복 실행될 때 타이머와 리스너가 어떻게 쌓이는지를 Node에서 같은 구조로 재현한 것이다. React의 동작 순서 자체는 공식 문서를 근거로 한다.

  • macOS 27.0.1 (Darwin 27.0.0, arm64)
  • node --version 출력: v20.20.2

cleanup이 없으면 실제로 무엇이 쌓이나요?

1. 타이머: 되돌리지 않으면 그대로 중첩된다

effect가 세 번 실행되는 상황을 그대로 만들어 보자.

let ticks = 0;
const timers = [];

function effectNoCleanup() {           // cleanup 없는 useEffect
  timers.push(setInterval(() => { ticks++; }, 20));
}
for (let i = 0; i < 3; i++) effectNoCleanup();   // 의존성이 바뀌어 3번 재실행

// cleanup 있는 버전
let ticks2 = 0, cur = null;
const effectWithCleanup = () => {
  if (cur) clearInterval(cur);         // 이전 effect의 cleanup
  cur = setInterval(() => { ticks2++; }, 20);
};
for (let i = 0; i < 3; i++) effectWithCleanup();

직접 실행 결과 (각각 약 100ms 동안 관찰)

cleanup 없음 - 약 100ms 동안 tick: 12 회 (타이머 3 개 동시 작동)
cleanup 있음 - 약 100ms 동안 tick: 4 회 (타이머 1개)

같은 시간에 콜백이 세 배로 돈다. 화면에서는 "숫자가 갑자기 빨리 올라간다", "API가 몇 배로 호출된다"로 나타난다. 의존성이 자주 바뀌는 컴포넌트라면 재실행 횟수만큼 배수로 늘어난다.

2. 이벤트 리스너: 여기는 사정이 조금 다르다

"cleanup이 없으면 리스너가 무조건 쌓인다"는 설명을 자주 보는데, 절반만 맞다. DOM의 addEventListener는 같은 타입·같은 콜백 참조·같은 capture 옵션이면 중복 등록을 무시한다. 직접 확인해 보자.

const t2 = new EventTarget();
let c2 = 0;
const h2 = () => { c2++; };
t2.addEventListener("ping", h2);
t2.addEventListener("ping", h2);        // 같은 참조를 두 번
t2.dispatchEvent(new Event("ping"));

const t3 = new EventTarget();
let c3 = 0;
for (let i = 0; i < 3; i++)
  t3.addEventListener("ping", () => { c3++; });   // 매번 새 화살표 함수
t3.dispatchEvent(new Event("ping"));

직접 실행 결과

동일 참조 2번 등록 -> 호출: 1 회
매번 새 참조 3번 등록 -> 호출: 3 회

핵심은 이것이다. 컴포넌트 안에서 매 렌더마다 새로 만들어지는 인라인 핸들러를 등록하면 참조가 매번 다르므로 중복 등록이 그대로 쌓인다. 반대로 컴포넌트 밖에 선언한 함수나 useCallback으로 참조를 고정한 핸들러는 같은 참조라 중복 등록이 무시된다. 그래서 "어떤 코드는 리스너가 쌓이고 어떤 코드는 멀쩡해 보이는" 현상이 생긴다. 물론 어느 쪽이든 언마운트 시 리스너를 떼지 않으면 그 자체로 누수이므로 cleanup은 필요하다.

effect는 왜 자꾸 다시 실행되나요?

cleanup 문제가 드러나려면 effect가 반복 실행돼야 하는데, 그 원인은 보통 의존성 배열이다. React 공식 문서는 "React는 각 의존성을 이전 값과 Object.is로 비교한다"고 명시한다. Object.is가 객체와 함수를 어떻게 비교하는지 직접 확인해 보면 이렇다.

$ node -e '...'
Object.is({a:1},{a:1}) = false
Object.is([1],[1])     = false
Object.is(f,f)         = true
Object.is(()=>{},()=>{}) = false

내용이 같아도 렌더마다 새로 만든 객체·배열·함수는 전부 다른 값으로 취급된다. 공식 문서도 "컴포넌트 안에서 정의된 객체나 함수가 의존성에 들어가면 effect가 필요 이상으로 자주 재실행될 위험이 있다"고 경고하며, 해결책으로 그 객체를 effect 안에서 만들라고 안내한다.

// 문제: options가 매 렌더 새로 만들어진다
const options = { serverUrl, roomId };
useEffect(() => {
  const connection = createConnection(options);
  connection.connect();
  return () => connection.disconnect();
}, [options]);          // 매 커밋마다 다른 값

// 해결: 객체를 effect 안으로 옮기고 원시값만 의존성에 둔다
useEffect(() => {
  const options = { serverUrl, roomId };
  const connection = createConnection(options);
  connection.connect();
  return () => connection.disconnect();
}, [roomId]);

어떻게 해결하나요? 상황별 cleanup 패턴

// 1. 타이머
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

// 2. 이벤트 리스너 - 등록과 해제에 같은 참조를 써야 한다
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

// 3. 외부 스토어 구독
useEffect(() => {
  const unsubscribe = store.subscribe(handleChange);
  return unsubscribe;
}, [store]);

// 4. 네트워크 요청 - 늦게 도착한 응답이 새 상태를 덮어쓰지 않게
useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then((r) => r.json())
    .then(setData)
    .catch((e) => { if (e.name !== "AbortError") setError(e); });
  return () => controller.abort();
}, [url]);

// 5. WebSocket
useEffect(() => {
  const ws = new WebSocket(url);
  return () => ws.close();
}, [url]);

2번에서 removeEventListener에 넘기는 함수가 등록할 때와 다른 참조면 해제가 되지 않는다. 등록·해제 모두 effect 안에서 선언한 같은 onResize를 쓰는 것이 중요하다.

개발 모드에서만 두 번 실행되는 건 왜 그런가요?

React 공식 StrictMode 문서는 "Strict Mode가 켜져 있으면 React는 모든 Effect에 대해 개발 환경에서 setup과 cleanup을 한 번 더 실행한다"고 설명한다. 그리고 "이 검사들은 개발 환경에서만 실행되며 프로덕션 빌드에는 영향을 주지 않는다"고 덧붙인다.

즉 콘솔에 로그가 두 번 찍히는 것은 버그가 아니라, cleanup이 setup을 제대로 되돌리는지 확인하는 의도된 스트레스 테스트다. 문서의 표현을 빌리면 setup 대신 setup → cleanup → setup을 돌려서 빠진 cleanup을 눈에 띄게 만드는 것이다. 두 번 실행이 문제를 일으킨다면 StrictMode를 끌 것이 아니라 cleanup을 제대로 구현해야 한다. 프로덕션에서도 사용자가 페이지를 왔다 갔다 하면 같은 상황이 벌어진다.

주의할 점과 한계

  • cleanup에서 상태를 다시 세팅하는 식으로 "정리"하려 들면 안 된다. cleanup은 setup이 만든 외부 자원을 되돌리는 자리다.
  • 구독 해제 함수를 그대로 return할 때, 그 함수가 cleanup 인자를 받는 형태라면 의도치 않은 인자가 전달될 수 있다. 그런 경우는 return () => unsubscribe();처럼 감싸는 편이 안전하다.
  • 이 글의 수치는 Node 20.20.2에서 effect 반복 실행 구조를 재현한 것이다. React 렌더러를 띄워 측정한 값이 아니며, 실행 순서와 StrictMode 동작은 React 공식 문서를 근거로 했다.
  • React 18 이전 버전은 StrictMode의 이중 setup+cleanup 동작이 없다. 버전에 따라 콘솔에 보이는 횟수가 다를 수 있다.

정리표

setup에서 만든 것cleanup에서 할 일빠뜨리면 생기는 증상
setInterval / setTimeoutclearInterval / clearTimeout타이머가 중첩되어 콜백이 배수로 실행
addEventListener같은 참조로 removeEventListener인라인 핸들러면 중복 등록, 언마운트 후에도 잔존
store.subscribe반환된 unsubscribe 호출사라진 컴포넌트가 계속 상태 갱신 시도
fetch / axios 요청AbortController로 abort늦게 온 응답이 최신 상태를 덮어씀
new WebSocketws.close()연결이 계속 쌓여 서버 커넥션 고갈
외부 라이브러리 인스턴스destroy() 등 해제 API 호출DOM 노드와 리스너가 메모리에 잔존

자주 묻는 질문(FAQ)

cleanup 함수는 반드시 반환해야 하나요?

setup이 아무것도 만들지 않았다면 필요 없다. 예를 들어 값을 계산해서 state에 넣기만 하는 effect에는 되돌릴 것이 없다. 반대로 타이머, 구독, 이벤트 리스너, 소켓처럼 effect가 끝난 뒤에도 계속 살아 있는 것을 만들었다면 반드시 반환해야 한다.

개발 모드에서 effect가 두 번 실행되는데 StrictMode를 꺼야 하나요?

끄지 않는 편이 좋다. React 공식 문서에 따르면 이 동작은 개발 환경에서만 실행되고 프로덕션 빌드에는 영향을 주지 않으며, 빠진 cleanup을 드러내기 위한 의도된 검사다. 두 번 실행되어 문제가 생긴다면 그 문제는 프로덕션에서도 재마운트 시 똑같이 발생한다.

이벤트 리스너는 중복 등록이 안 된다고 들었는데 왜 쌓이나요?

같은 함수 참조일 때만 중복이 무시되기 때문이다. 직접 확인한 결과 동일 참조를 두 번 등록하면 핸들러가 1회만 호출됐지만, 매번 새로 만든 화살표 함수를 세 번 등록하니 3회 호출됐다. 컴포넌트 안에서 인라인으로 정의한 핸들러는 렌더마다 새 참조라서 쌓인다.

cleanup을 넣었는데도 중복 실행이 줄지 않습니다. 무엇을 봐야 하나요?

removeEventListener에 넘기는 함수가 등록 때와 같은 참조인지 먼저 확인한다. 그다음 의존성 배열을 본다. 렌더마다 새로 만들어지는 객체나 함수가 의존성에 들어 있으면 effect가 매 커밋마다 재실행되므로, 정리와 재생성이 끝없이 반복되며 네트워크 요청 같은 부작용이 계속 발생한다.

관련 글: React useEffect 무한루프 — 의존성 배열 검증과 최적화 전략 · React useEffect 무한루프 문제 해결하기

공식 문서: React — useEffect · React — StrictMode

최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기

월요일

PostgreSQL deadlock detected 해결 — 두 트랜잭션이 서로 락을 기다리는 교착상태 원인 해결

PostgreSQL deadlock detected 에러 해결 — 교착상태 원인과 방지 전략

핵심 답변: PostgreSQL deadlock detected는 두 트랜잭션이 서로 상대방이 보유한 락을 기다리는 교착상태에서 발생한다. PostgreSQL은 이를 자동으로 감지해 하나를 강제 롤백한다. 방지법은 ① 일관된 락 순서 유지, ② SELECT FOR UPDATE 사용, ③ 배치 단위 COMMIT, ④ 외래키 인덱스 추가, ⑤ 재시도 로직 구현이다.

운영 중인 서비스 로그에 갑자기 이런 에러가 쏟아진다.

ERROR: deadlock detected

DETAIL: Process 12345 waits for ShareLock on transaction 678;

blocked by process 67890.

Process 67890 waits for ShareLock on transaction 456;

blocked by process 12345.

HINT: See server log for query details.

교착상태(Deadlock)는 두 트랜잭션이 서로 상대방이 보유한 락을 기다리는 상황이다. 양쪽 모두 진행할 수 없는 상태가 되면 PostgreSQL이 감지해 하나를 강제 롤백하고 ERROR 40P01을 반환한다.
트랜잭션 A: 행 1 잠금 → 행 2 잠금 시도 (대기)

트랜잭션 B: 행 2 잠금 → 행 1 잠금 시도 (대기)

→ 영원히 풀리지 않음 → PostgreSQL이 하나를 강제 종료


원인 1: UPDATE 순서가 트랜잭션마다 다른 경우

가장 흔한 원인이다. 같은 테이블의 여러 행을 업데이트할 때 트랜잭션마다 접근 순서가 다르면 교착상태가 생긴다.

-- 트랜잭션 A (id=1 → id=2 순서)

BEGIN;

UPDATE orders SET status = 'processing' WHERE id = 1;

UPDATE orders SET status = 'processing' WHERE id = 2;

COMMIT;

-- 트랜잭션 B (id=2 → id=1 역순 — 충돌!)

BEGIN;

UPDATE orders SET status = 'cancelled' WHERE id = 2;

UPDATE orders SET status = 'cancelled' WHERE id = 1; -- deadlock

COMMIT;

해결: 모든 트랜잭션에서 동일한 순서(id 오름차순)로 접근
-- SQL에서 ORDER BY로 순서 보장

UPDATE orders SET status = 'processing'

WHERE id IN (1, 2)

ORDER BY id;

# 애플리케이션에서 정렬 후 처리

def update_orders(session, order_ids, status):

for order_id in sorted(order_ids): # 항상 오름차순

session.query(Order).filter(Order.id == order_id).update({"status": status})

session.commit()


원인 2: SELECT FOR UPDATE 없이 나중에 UPDATE

조회 후 수정하는 패턴에서 자주 발생한다. 조회 시 락을 잡지 않으면 다른 트랜잭션이 끼어든다.

-- ❌ 락 없이 조회 후 수정 — 위험

BEGIN;

SELECT balance FROM accounts WHERE id = 100;

-- 이 사이에 다른 트랜잭션이 같은 행을 수정 가능

UPDATE accounts SET balance = balance - 1000 WHERE id = 100;

COMMIT;

-- ✅ SELECT FOR UPDATE로 조회 시 즉시 락 획득

BEGIN;

SELECT balance FROM accounts WHERE id = 100 FOR UPDATE;

UPDATE accounts SET balance = balance - 1000 WHERE id = 100;

COMMIT;

// Spring Data JPA — 비관적 락 사용

@Repository

public interface AccountRepository extends JpaRepository<Account, Long> {

@Lock(LockModeType.PESSIMISTIC_WRITE)

@Query("SELECT a FROM Account a WHERE a.id = :id")

Optional<Account> findByIdForUpdate(@Param("id") Long id);

}

@Transactional

public void transfer(Long fromId, Long toId, BigDecimal amount) {

// 작은 id부터 락 획득 — 순서 일관성 보장

Long firstId = Math.min(fromId, toId);

Long secondId = Math.max(fromId, toId);

Account first = accountRepository.findByIdForUpdate(firstId).orElseThrow();

Account second = accountRepository.findByIdForUpdate(secondId).orElseThrow();

}


원인 3: 인덱스 없이 대량 UPDATE/DELETE

인덱스 없는 컬럼으로 넓은 범위를 업데이트하면 테이블 전체에 락이 걸린다.

-- ❌ 인덱스 없는 컬럼으로 대량 업데이트 — 테이블 전체 잠금

UPDATE orders SET processed = true WHERE created_at < '2026-01-01';

-- ✅ 배치로 나눠서 처리

DO $$

DECLARE

batch_size INT := 1000;

last_id BIGINT := 0;

BEGIN

LOOP

UPDATE orders SET processed = true

WHERE id IN (

SELECT id FROM orders

WHERE created_at < '2026-01-01'

AND processed = false

AND id > last_id

ORDER BY id

LIMIT batch_size

)

RETURNING max(id) INTO last_id;

EXIT WHEN NOT FOUND OR last_id IS NULL;

COMMIT;

PERFORM pg_sleep(0.01); -- 다른 트랜잭션에 기회 부여

END LOOP;

END $$;


원인 4: 외래키 인덱스 누락

PostgreSQL은 외래키 참조 시 부모 테이블에 ShareLock을 건다. 외래키 컬럼에 인덱스가 없으면 잠금 범위가 커진다.

-- 외래키 컬럼 인덱스 누락 여부 확인

SELECT

tc.table_name,

kcu.column_name,

(SELECT 1 FROM pg_indexes

WHERE tablename = tc.table_name

AND indexdef LIKE '%' || kcu.column_name || '%') AS has_index

FROM information_schema.table_constraints tc

JOIN information_schema.key_column_usage kcu

��N��B��6�FR7G��S�&&6�w&�V�C�6ccc�FF��s�'�g��&�&FW"�&F�W3�7��f��B�f֖Ǔ�����76S�f��B�6��S��V�#�4UB��6��F��V�WB�sW2s��6�FS������ȹΫN��B� ��Y��Zȉ��莸�B���ࠣ�7G&��s��FVF��6���B� ��9��Y���B����N�K�i�8��B�9ޫ��)�ɩC���7G&��s����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#���XN�����B��7Fw&U5���FVF��6�� �xȹ��Y��)��قث��������Y���BɘN�N���N� �Yθ�B�����N�K��B��ΫH�K�莸�B�8�9κ[����x�Yθ�B��N� � ����x^�x����ȹθ�N�Y���B� θ�B���ࠣƇ"7G��S�&&�&FW#����S�&�&FW"�F���6�ƖB6SSS��&v��3'�#ࠣƃ"7G��S�&f��B�6��S��VVӶ�&v��3'�'��FF��r�&�GF�ӣg��&�&FW"�&�GF�ӣ'�6�ƖB6SSS�6���#�3##"#�� ^�j����#ࠣ�7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#��7Fw&U5�FVF��6�� ��xnɹ˙����ࠣ�7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#���zι���h�ȉ�� Rȹ��7G&��s��Z��8�BɊN�hN� �ȉ���7G&��s�� �{�����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�"��٨�ٸBȉ�� ^���7G&��s�4T�T5Bd�"UDDS��7G&��s��ȹ�������7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�2�������x^����Nث���B�7G&��s� ˙������C��7G&��s���)���4��ԕC����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�B�ɛ����*B˺ι���y� ��9�ȹ��7G&��s��ێ��ȪC��7G&��s��9��K����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�R�ث��������Y������^�Y��7G&��s��z~�(���7G&��s����x����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�b����ȹθ�B���x���7G&��s�W���V�F��&6��fc��7G&��s���Z�وC��ࠣ�7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#���Bn��x�[��x�*N��B�Y� ��8�9�� ��9��ق�^�[��x���Bȉ��莸�B���ࠣƇ"7G��S�&&�&FW#����S�&�&FW"�F���6�ƖB6SSS��&v��3'�#ࠣ�F�b7G��S�&&6�w&�V�C�6S�cFfC�&�&FW"��VgC�G�6�ƖB3s6S��&�&FW"�&F�W3�G��FF��s�G�����&v��#��f��B�6��S��VVӶ6���#�3&S�Ɩ�RֆV�v�C��r#�H� ������&Vc�&�GG3���GV�GV�6�R�&��w7�B�6��"7G��S�&6���#�3s6S�#䆖�&�56���V7F������W��W7FVB�y����[N�+�����F�c���F�c

일요일

BeanCurrentlyInCreationException 해결 — Spring Boot 순환 의존성 에러 완전 정복

🔍 검색 키워드: BeanCurrentlyInCreationException 해결, Spring Boot 순환 의존성, circular dependency 에러, Spring Boot 빈 생성 실패, allow-circular-references, Spring Boot 시작 실패

Spring Boot 프로젝트를 띄우다가 서버가 뜨지도 않고 이런 에러를 만난 적 있을 것이다.

The dependencies of some of the beans in the application context form a cycle:
userService -> orderService -> userService

혹은 더 직접적으로:

org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'userService':
Requested bean is currently in creation: Is there an unresolvable circular reference?

서버 자체가 뜨지 않으니 당황스럽다. 이 글에서는 원인부터 실무에서 자주 쓰는 해결책까지 정리한다.

순환 의존성이란 뭔가

ServiceA가 ServiceB를 주입받고, ServiceB가 다시 ServiceA를 주입받는 상황이다. Spring이 A를 만들려면 B가 필요하고, B를 만들려면 A가 필요하다. 닭이 먼저냐 달걀이 먼저냐 — Spring은 이 상황에서 예외를 던진다.

@Service
public class UserService {
    private final OrderService orderService;

    public UserService(OrderService orderService) { // OrderService 주입
        this.orderService = orderService;
    }
}

@Service
public class OrderService {
    private final UserService userService;

    public OrderService(UserService userService) { // UserService 주입
        this.userService = userService;
    }
}

위처럼 생성자 주입(Constructor Injection)으로 서로를 참조하면 Spring Boot 2.6 이후부터는 기본적으로 예외가 발생한다.

왜 Spring Boot 2.6부터 더 자주 보이나

Spring Boot 2.6에서 기본 순환 의존성 감지가 강화됐다. 이전 버전에서는 필드 주입(@Autowired)의 경우 특별한 설정 없이도 넘어가는 경우가 있었는데, 2.6부터는 훨씬 엄격하게 잡아낸다. 기존에 잘 돌아가던 프로젝트를 Spring Boot 버전 올리다가 갑자기 이 에러를 만나는 이유다.

에러 발생 상황 체크리스트

상황가능성
Service 간 서로 참조★★★★★
Service → Repository → Service 체인★★★★☆
@Configuration 클래스 간 참조★★★☆☆
Spring Boot 버전 업그레이드 직후★★★★☆
이벤트 리스너와 서비스 간 참조★★★☆☆

해결 방법 1 — @Lazy 어노테이션 (빠른 임시 해결)

둘 중 하나의 의존성에 @Lazy를 붙인다. Spring이 실제로 해당 빈이 필요한 시점까지 초기화를 미룬다.

@Service
public class UserService {
    private final OrderService orderService;

    public UserService(@Lazy OrderService orderService) { // @Lazy 추가
        this.orderService = orderService;
    }
}

당장 급하게 해결해야 할 때 쓰는 방법이다. 하지만 근본적인 구조 문제를 가리는 것이라 장기적으로는 좋지 않다.

해결 방법 2 — 세터/필드 주입으로 전환

생성자 주입 대신 세터 주입(Setter Injection)으로 바꾸면 Spring이 빈을 먼저 만든 후 의존성을 주입하기 때문에 순환 참조 문제가 해소된다.

@Service
public class OrderService {
    private UserService userService;

    @Autowired
    public void setUserService(UserService userService) {
        this.userService = userService;
    }
}

해결 방법 3 — 구조 리팩토링 (가장 올바른 해결)

순환 의존성이 생겼다는 건 클래스의 책임이 잘못 분리됐다는 신호다. 보통 아래 패턴 중 하나로 해결된다.

공통 로직을 별도 서비스로 분리:

// UserService와 OrderService 모두 참조하던 공통 로직
@Service
public class UserOrderBridgeService {
    private final UserRepository userRepository;
    private final OrderRepository orderRepository;

    public void processUserOrder(Long userId, Long orderId) {
        // 공통 로직 처리
    }
}

@Service
public class UserService {
    private final UserRepository userRepository;
    private final UserOrderBridgeService bridgeService; // 공통 서비스만 참조
}

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final UserOrderBridgeService bridgeService;
}

이벤트 기반으로 분리:

@Service
public class UserService {
    private final ApplicationEventPublisher eventPublisher;

    public void deleteUser(Long userId) {
        eventPublisher.publishEvent(new UserDeletedEvent(userId));
    }
}

@Component
public class OrderEventListener {
    private final OrderService orderService;

    @EventListener
    public void handleUserDeleted(UserDeletedEvent event) {
        orderService.cancelOrdersByUserId(event.getUserId());
    }
}

해결 방법 4 — application.properties 설정 (비추천)

spring.main.allow-circular-references=true

이 설정은 에러만 안 보이게 하는 것이다. 실제로는 Spring Boot가 내부적으로 의존성 순서를 휴리스틱하게 결정하게 되어, 예측하기 어려운 초기화 순서 문제가 생길 수 있다. 근본 원인을 분석할 시간이 없는 긴급 상황에서 임시로만 쓰자.

정리

  • BeanCurrentlyInCreationException은 두 빈이 서로 의존하는 구조에서 발생한다
  • Spring Boot 2.6+에서 기본적으로 더 엄격하게 감지한다
  • 빠른 해결은 @Lazy, 올바른 해결은 공통 로직 분리 또는 이벤트 기반 구조 변경
  • allow-circular-references=true는 임시방편일 뿐, 운영 환경에서는 근본 해결 필요

순환 의존성 에러는 코드 냄새(Code Smell)다. 에러를 꺼주는 것보다 왜 두 서비스가 서로를 참조해야 하는지를 먼저 물어보는 게 맞다.

금요일

Redis WRONGTYPE Operation 해결 — 키 타입 충돌 완벽 정리

TypeScript cannot find module 해결 — ts(2307) 에러 완벽 정리

🔍 검색 키워드: typescript cannot find module, ts2307 에러 해결, tsconfig paths 설정, typescript module not found, cannot find module or its corresponding type declarations

TypeScript 프로젝트 하다 보면 한 번쯤은 꼭 만나는 에러다.

Cannot find module 'X' or its corresponding type declarations. ts(2307)

빌드도 안 되고, IDE에서도 빨간 줄 잔뜩 그어지면서 당황하게 된다. 이 에러는 발생 원인이 여러 가지라 원인을 제대로 모르면 해결책을 찾아 헤매다 시간만 버린다.


1. 증상

error TS2307: Cannot find module '../utils/logger' or its corresponding type declarations.
error TS2307: Cannot find module '@/components/Button' or its corresponding type declarations.
error TS2307: Cannot find module 'lodash' or its corresponding type declarations.

import 구문에서 빨간 줄이 생기고, tsc 실행 시 컴파일이 실패한다. VSCode 같은 IDE에서도 동일하게 에러가 표시된다.


2. 원인별 분류

크게 4가지 패턴으로 나뉜다.

패턴 1: @types 패키지 누락

서드파티 라이브러리를 설치했지만 TypeScript 타입 정의 패키지를 설치하지 않은 경우다. lodash, express, node 같은 라이브러리는 별도의 @types/xxx 패키지가 필요하다.

# 확인 방법
ls node_modules/@types/

# 해결
npm install --save-dev @types/lodash
npm install --save-dev @types/node
npm install --save-dev @types/express

패턴 2: tsconfig.json 경로 별칭(Path Alias) 미설정

@/, ~/ 같은 경로 별칭을 사용하는데 tsconfig에 paths 설정이 없는 경우다. Vite, Next.js, CRA 등에서 흔하게 발생한다.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "~/*": ["src/*"]
    }
  }
}
주의: Next.js는 tsconfig.json의 paths를 자동으로 인식하지만, Node.js 환경에서 직접 실행할 때는 tsconfig-paths 패키지가 추가로 필요하다.
npm install --save-dev tsconfig-paths

# 실행 시
ts-node -r tsconfig-paths/register src/index.ts

패턴 3: moduleResolution 설정 불일치

Node.js 16+ 혹은 ES Modules를 사용할 때 moduleResolution 설정이 맞지 않는 경우다.

// tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

NodeNext 설정에서는 import 시 파일 확장자를 명시해야 한다.

// ❌ 에러
import { logger } from './utils/logger';

// ✅ 정상
import { logger } from './utils/logger.js';

컴파일된 결과가 .js이기 때문에 소스에서도 .js 확장자를 써야 한다. 처음엔 이상하게 느껴지지만 ESM 스펙이 이렇다.

패턴 4: node_modules 손상 또는 누락

패키지가 설치되어 있는데도 에러가 난다면 node_modules 자체가 손상됐을 가능성이 있다.

# 클린 재설치
rm -rf node_modules package-lock.json
npm install

3. 원인 진단 체크리스트

점검 항목 확인 방법 해결 방법
@types 패키지 설치 여부 ls node_modules/@types/ npm i -D @types/xxx
tsconfig paths 설정 tsconfig.json 확인 baseUrl + paths 추가
moduleResolution 설정 tsconfig.json 확인 NodeNext 또는 bundler
node_modules 상태 ls node_modules/[패키지명] 클린 재설치
상대 경로 확장자 import 구문 확인 .js 확장자 명시

4. --traceResolution 으로 정확히 추적하기

어디서 모듈을 찾는지 추적하고 싶다면 이 플래그를 쓴다.

tsc --traceResolution 2>&1 | grep -A 5 "Cannot find module"

TypeScript가 어떤 경로를 탐색했는지 전부 출력해준다. 설정 문제인지, 패키지 문제인지 바로 보인다.


5. 실제 사례 — Next.js에서 @/components 경로 에러

Cannot find module '@/components/Button' or its corresponding type declarations.

Next.js 프로젝트에서 흔히 겪는 케이스다.

// tsconfig.json — 이 설정이 없으면 에러 발생
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}

Next.js 13+는 프로젝트 생성 시 자동으로 이 설정을 넣어주지만, 수동으로 구성한 프로젝트나 마이그레이션한 경우 누락되기 쉽다.


6. Jest + TypeScript 환경

Jestl��서도 같은 에러가 발생할 수 있다. Jest는 tsconfig의 paths를 직접 읽지 않기 때문에 별도 설정이 필요하다.

npm install --save-dev babel-jest @babel/preset-typescript ts-jest
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

moduleNameMapper로 tsconfig의 paths와 동일하게 맞춰줘야 한다.


정리

TypeScript cannot find module 에러는 딱 떨어지는 단일 원인이 아니다. 발생 맥락을 보고 위 패턴 중 어디에 해당하는지 진단하는 게 먼저다. --traceResolution으로 탐색 경로를 직접 확인하면 대부분 30분 안에 해결된다.

작성일: 2026-06-26

.env 파일인데 환경변수가 undefined — dotenv 로딩 에러 완전 해결

🔍 검색 키워드: process.env undefined, dotenv not working, .env 파일 적용 안됨, python-dotenv not loading, 환경변수 undefined, docker 환경변수 안됨, next.js env not working

상황

.env 파일을 분명히 만들었는데 코드에서 undefined가 찍힌다.

# .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecretkey
console.log(process.env.DATABASE_URL); // undefined ???

또는 Python에서:

import os
print(os.getenv("SECRET_KEY"))  # None ???

배포 환경에서만 안 된다거나, Docker 안에서만 안 된다거나, 로컬은 되는데 서버에선 안 된다거나. 패턴은 다양하지만 원인은 몇 가지로 좁혀진다.


Node.js — dotenv 관련

원인 1: dotenv를 아예 안 불렀거나 너무 늦게 불렀다

// 잘못된 예 — DB 모듈보다 나중에 dotenv 로드
const db = require('./database'); // 이미 process.env 읽음
require('dotenv').config();       // 너무 늦었다
// 올바른 예 — 진입점 파일 최상단에서 먼저
require('dotenv').config();

const db = require('./database');
const app = require('./app');

TypeScript / ES Modules:

import 'dotenv/config'; // 이 방법이 제일 깔끔하다

// 또는
import dotenv from 'dotenv';
dotenv.config();

import { createConnection } from './db';

원인 2: .env 파일 경로가 다르다

기본적으로 dotenv는 process.cwd() 기준으로 .env를 찾는다. 실행 위치가 프로젝트 루트가 아니면 못 찾는다.

import path from 'path';
import dotenv from 'dotenv';

dotenv.config({
  path: path.resolve(__dirname, '../.env'),
});

원인 3: .env 파일이 .gitignore에 있고 서버에 없다

로컬에서만 됐던 이유가 이거다. .env는 보통 .gitignore에 들어가 있어서 서버에 배포가 안 된다.

# GitHub Actions — 시크릿 주입
steps:
  - name: Create .env file
    run: |
      echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
      echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env

원인 4: 변수명에 공백이나 따옴표 문제

# 잘못된 .env
DATABASE_URL = postgresql://localhost/mydb   # = 주변 공백 금지

# 올바른 .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecret
APP_NAME="My App"  # 값에 공백이 필요하면 따옴표 사용

Python — python-dotenv

from dotenv import load_dotenv
import os

# .env 파일 로드 (현재 디렉토리 기준)
load_dotenv()

# 또는 경로 명시
load_dotenv(dotenv_path='/path/to/.env')

# 이미 설정된 환경변수를 덮어쓰려면
load_dotenv(override=True)

print(os.getenv("DATABASE_URL"))
💡 load_dotenv()는 이미 시스템에 설정된 환경변수는 덮어쓰지 않는다. 테스트 환경에서 .env 값으로 강제하려면 override=True를 써야 한다.
# Django settings.py
import os
from pathlib import Path
from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME'),
        'USER': os.getenv('DB_USER'),
        'PASSWORD': os.getenv('DB_PASSWORD'),
        'HOST': os.getenv('DB_HOST', 'localhost'),
        'PORT': os.getenv('DB_PORT', '5432'),
    }
}

Next.js / React — 프레임워크별 규칙

Next.js는 dotenv를 직접 쓰지 않고 자체 env 로딩 시스템을 쓴다.

  • 서버에서만 쓰는 변수: DATABASE_URL=value
  • 클라이언트(브라우저)에서도 쓰는 변수: NEXT_PUBLIC_API_URL=value (반드시 NEXT_PUBLIC_ 접두사)
// 서버 컴포넌트 / API Route에서만 접근 가능
process.env.DATABASE_URL // ✅

// 클라이언트에서 접근하려면 NEXT_PUBLIC_ 접두사 필수
process.env.NEXT_PUBLIC_API_URL // ✅
process.env.API_URL // 클라이언트에서는 undefined ❌

환경 파일 우선순위:

.env.local > .env.development.local > .env.development > .env
⚠️ NEXT_PUBLIC_ 변수는 빌드 시 정적으로 교체된다. 빌드 후 값을 바꿔도 소용없다. 반드시 빌드 전에 설정해야 한다.

Docker / Docker Compose

# docker-compose.yml
services:
  app:
    build: .
    # 방법 1: env_file 지정 (권장)
    env_file:
      - .env

    # 방법 2: 직접 명시
    environment:
      - DATABASE_URL=postgresql://db:5432/mydb
      - SECRET_KEY=${SECRET_KEY}  # 호스트 환경변수에서
# Docker run
docker run --env-file .env myapp
docker run -e DATABASE_URL=xxx -e SECRET_KEY=yyy myapp
⚠️ Dockerfile에서 COPY .env .를 하면 이미지에 시크릿이 박힌다. .dockerignore에 .env를 넣고, 런타임에 주입하는 방식을 써야 한다.
# .dockerignore
.env
.env.*

상황별 체크리스트

증상원인확인/해결
로컬만 됨, 서버 안 됨.env가 서버에 없음CI/CD 시크릿 주입 또는 서버에 .env 생성
dotenv.config() 했는데 undefinedimport 순서 문제진입점 최상단에서 dotenv 먼저 로드
특정 변수만 undefined변수명 오타, 공백.env 파일 문법 확인
Docker 안에서 undefinedenv_file 미설정env_file 또는 -e 옵션으로 주입
Next.js 클라이언트에서 undefinedNEXT_PUBLIC_ 접두사 누락접두사 추가 후 재빌드
Python에서 Noneload_dotenv() 미호출import 후 load_dotenv() 호출
기존 시스템 변수가 우선override 미설정load_dotenv(override=True)

디버깅 팁

// 로드된 환경변수 키 목록 확인
console.log('ENV keys:', Object.keys(process.env).filter(k => !k.startsWith('npm_')));

// .env 경로 확인
const path = require('path');
console.log('Looking for .env at:', path.resolve(process.cwd(), '.env'));
# .env 파일에서 읽은 값만 확인 (시스템 변수 제외)
from dotenv import dotenv_values
config = dotenv_values(".env")
print(config)

마무리

환경변수 에러는 대부분 세 가지 중 하나다: .env 파일이 없거나, dotenv 로드를 너무 늦게 했거나, 프레임워크별 규칙을 무시했거나.

서버에 배포했을 때 갑자기 안 된다면 .env가 서버에 실제로 존재하는지부터 확인하고, Docker면 --env-file이나 env_file로 주입됐는지 확인한다. Next.js면 클라이언트에서 쓰는 변수에 NEXT_PUBLIC_ 붙이고 재빌드. 이 세 가지가 90%다.

PostgreSQL "FATAL: password authentication failed" 에러 완전 해결 가이드

🔍 검색 키워드: postgresql fatal password authentication failed, psql role does not exist, postgresql connection refused, pg_hba.conf, postgresql 비밀번호 에러

상황

PostgreSQL에 접속하려는데 이런 에러가 뜬다.

FATAL: password authentication failed for user "myapp"

또는

FATAL: role "myapp" does not exist

또는

psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL: Peer authentication failed for user "postgres"

전부 다르게 생겼지만 원인은 비슷한 경우가 많다. 하나씩 짚어보자.


원인 1: 비밀번호가 틀렸다 (가장 흔한 경우)

앱 설정 파일에 넣은 비밀번호와 실제 DB 비밀번호가 다른 거다. .env, application.yml, database.yml 등에서 비밀번호 오타나 환경별 혼용이 잦다.

확인 방법:

# postgres 유저로 직접 접속해서 비밀번호 재설정
sudo -u postgres psql

-- 현재 유저 목록 확인
\du

-- 비밀번호 재설정
ALTER USER myapp WITH PASSWORD 'newpassword';

원인 2: 유저 자체가 없다 (role does not exist)

DB는 있는데 유저를 만든 적이 없거나, 다른 환경에서 만든 유저가 이 환경에는 없는 경우다. 로컬에서 개발하다가 스테이징 DB로 붙으려 할 때 자주 발생한다.

해결:

-- postgres 슈퍼유저로 접속 후
CREATE USER myapp WITH PASSWORD 'yourpassword';

-- 데이터베이스 권한 부여
GRANT ALL PRIVILEGES ON DATABASE mydb TO myapp;

-- 스키마 권한도 줘야 하는 경우
GRANT ALL ON SCHEMA public TO myapp;

원인 3: pg_hba.conf 인증 방식 문제 (Peer auth failed)

Peer authentication failed 에러는 pg_hba.conf의 인증 방식 설정 문제다. 로컬 소켓 접속 시 OS 유저명과 DB 유저명이 같아야 하는 peer 방식으로 설정돼 있을 때 발생한다.

pg_hba.conf 위치 확인:

sudo -u postgres psql -c "SHOW hba_file;"
# 보통 /etc/postgresql/14/main/pg_hba.conf 또는 /var/lib/pgsql/data/pg_hba.conf

pg_hba.conf 수정:

sudo nano /etc/postgresql/14/main/pg_hba.conf

수정 전:

local   all   all   peer

수정 후 (비밀번호 인증으로 변경):

local   all   all   md5

또는 특정 유저만:

local   mydb   myapp   md5
host    mydb   myapp   127.0.0.1/32   md5

변경 후 재시작:

sudo systemctl restart postgresql

원인 4: Docker 환경에서 환경변수 미전달

Docker로 PostgreSQL 띄울 때 POSTGRES_PASSWORD 없이 컨테이너를 올리거나, 앱 컨테이너에 DB 접속 정보를 제대로 안 넘긴 경우다.

# docker-compose.yml 올바른 예시
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: mypassword
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 5

  app:
    build: .
    environment:
      DATABASE_URL: postgresql://myapp:mypassword@db:5432/mydb
    depends_on:
      db:
        condition: service_healthy
💡 depends_on은 컨테이너 시작 순서만 보장한다. PostgreSQL이 실제로 준비됐는지는 healthcheck와 condition: service_healthy로 처리해야 한다.

원인 5: 접속 호스트/포트 오류

Connection refused는 PostgreSQL이 해당 주소에서 리슨하고 있지 않다는 뜻이다.

# PostgreSQL이 실제로 떠 있는지
sudo systemctl status postgresql

# 어느 포트에서 리슨 중인지
sudo ss -tlnp | grep 5432

# 외부 접속 허용 설정 확인
sudo grep listen_addresses /etc/postgresql/14/main/postgresql.conf

외부에서 접속하려면 postgresql.conf에서:

listen_addresses = '*'

그리고 pg_hba.conf에도 원격 접속 허용 라인 추가:

host    all   all   0.0.0.0/0   md5

상황별 체크리스트

증상확인할 것해결책
password authentication failed비밀번호 오타, 환경 혼용ALTER USER ... WITH PASSWORD
role does not exist유저 미생성CREATE USER + 권한 부여
Peer authentication failedpg_hba.conf 설정peer → md5 변경 후 재시작
Connection refusedPostgreSQL 미실행 또는 포트 불일치서비스 상태 확인, listen_addresses 설정
Docker에서만 발생컨테이너 간 네트워크, 환경변수db 호스트명 사용, healthcheck 추가

Node.js 접속 예시 (pg 라이브러리)

const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
  connectionTimeoutMillis: 5000,
  idleTimeoutMillis: 30000,
  max: 10,
});

pool.query('SELECT NOW()', (err, res) => {
  if (err) {
    console.error('DB 연결 실패:', err.message);
  } else {
    console.log('DB 연결 성공:', res.rows[0].now);
  }
});

Python (psycopg2, SQLAlchemy)

import psycopg2
from psycopg2 import OperationalError
import os

def create_connection():
    try:
        conn = psycopg2.connect(
            host=os.getenv("DB_HOST", "localhost"),
            port=int(os.getenv("DB_PORT", 5432)),
            database=os.getenv("DB_NAME"),
            user=os.getenv("DB_USER"),
            password=os.getenv("DB_PASSWORD"),
        )
        print("PostgreSQL 연결 성공")
        return conn
    except OperationalError as e:
        print(f"연결 실패: {e}")
        raise

from sqlalchemy import create_engine
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=3600)

마무리

PostgreSQL 접속 에러는 대부분 세 가지다: 비밀번호 틀림, 유저 없음, pg_hba.conf 설정 문제. 에러 메시지를 정확히 읽으면 원인이 나온다. FATAL: 뒤에 오는 텍스트가 전부다. Connection refused는 PostgreSQL 자체가 안 떠있거나 포트가 다른 거고, authentication failed는 자격증명 문제, role does not exist는 유저 생성을 안 한 거다.

Docker 환경이면 컨테이너 간 네트워크와 healthcheck까지 챙겨야 한다.

목요일

Python 메모리 누수 디버깅 — tracemalloc으로 증가 지점 찾고 순환 참조 걷어내기

🔍 검색 키워드: python 메모리 누수, tracemalloc 사용법, gc.collect 효과 없음, 순환 참조 메모리 누수, gc.get_referrers, 파이썬 RSS 계속 증가

요약: 파이썬에서 메모리가 계속 늘어나는 원인은 대부분 가비지 컬렉터의 실패가 아니라, 전역 캐시나 리스트처럼 아무도 놓아주지 않는 참조다. tracemalloc의 스냅샷 비교로 어느 파일 몇 번째 줄에서 할당이 늘어나는지 특정하고, 그 객체를 붙잡고 있는 범인은 gc.get_referrers()로 확인한 뒤 참조를 끊으면 해결된다.

파이썬에서 메모리 누수는 왜 생기나요?

파이썬은 참조 카운팅으로 객체를 즉시 해제하고, 참조 카운팅이 풀지 못하는 순환 참조는 세대별 가비지 컬렉터가 따로 회수한다. 그래서 참조가 하나라도 살아 있으면 그 객체는 애초에 회수 대상이 아니다. 흔히 말하는 파이썬의 메모리 누수는 대부분 이 경우이고, 코드상으로는 다음 세 가지 형태로 나타난다.

  • 모듈 전역 dict·list·set에 계속 쌓이는데 아무도 비우지 않는 경우(가장 흔하다)
  • 순환 참조가 있는데 GC를 꺼 두었거나 수집 주기 사이에 메모리가 치솟는 경우
  • C 확장 모듈이 직접 할당한 메모리(파이썬 추적 도구로는 보이지 않는다)

테스트 환경

아래 출력은 전부 이 환경에서 직접 실행한 결과다.

  • macOS 27.0.1 (Darwin 27.0.0, arm64)
  • python3.12 --version 출력: Python 3.12.13

재현 1. 전역 캐시가 메모리를 먹는 과정

import tracemalloc, gc

CACHE = {}          # 모듈 전역 캐시 - 아무도 비우지 않는다

def handle(i):
    CACHE[i] = {"payload": "x" * 1000, "idx": i}

tracemalloc.start()
for i in range(1000):
    handle(i)
snap1 = tracemalloc.take_snapshot()

for i in range(1000, 11000):
    handle(i)
snap2 = tracemalloc.take_snapshot()

for stat in snap2.compare_to(snap1, "lineno")[:3]:
    print(stat)
print("CACHE len:", len(CACHE))
print("gc.collect() ->", gc.collect())

직접 실행 결과

leak1.py:6: size=2543 KiB (+2337 KiB), count=21916 (+20000), average=119 B
leak1.py:13: size=312 KiB (+312 KiB), count=10000 (+10000), average=32 B
CACHE len: 11000
gc.collect() -> 0

tracemalloc 결과는 어떻게 읽나요?

각 줄은 파일:줄번호: size=현재 누적 크기 (증가분), count=블록 수 (증가분), average=블록당 평균 형식이다. 위 출력에서 6번째 줄, 즉 CACHE[i] = {...}가 두 스냅샷 사이에 2,337 KiB · 블록 20,000개를 더 썼다. 범인이 한 줄로 특정된 것이다. 13번째 줄은 그 줄을 호출한 반복문이다.

그리고 마지막 줄이 핵심이다. gc.collect()가 0을 반환했다. 회수할 것이 없다는 뜻이다. CACHE가 모든 객체의 참조를 쥐고 있으므로 가비지 컬렉터 입장에서는 전부 멀쩡히 살아 있는 객체다. 메모리가 느는데 gc.collect()를 아무리 불러도 소용없다면 십중팔구 이 상황이다.

호출 경로까지 보고 싶다면

어느 줄인지는 알겠는데 그 줄이 어디서 불렸는지 모를 때는 tracemalloc.start()에 프레임 수를 넘기고 statistics("traceback")을 쓴다.

tracemalloc.start(5)          # 콜 스택 5프레임까지 보관
...
top = snap.statistics("traceback")[0]
print("크기:", top.size // 1024, "KiB / 블록:", top.count)
for line in top.traceback.format():
    print(line)

직접 실행 결과

크기: 72 KiB / 블록: 1
  File "tb.py", line 7
    for i in range(2000): outer(i)
  File "tb.py", line 4
    def outer(i): inner(i)
  File "tb.py", line 3
    def inner(i): CACHE[i] = "x" * 1000

재현 2. RSS로 확인할 때 흔히 속는 지점

import resource, gc

def rss_mb():
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 / 1024

CACHE = []
print("start  RSS:", round(rss_mb(), 1), "MB")
for i in range(50_000):
    CACHE.append({"id": i, "payload": "x" * 500})
print("적재 후 RSS:", round(rss_mb(), 1), "MB  / 항목:", len(CACHE))
CACHE.clear()
gc.collect()
print("clear 후 RSS:", round(rss_mb(), 1), "MB")

직접 실행 결과

start  RSS: 13.2 MB
적재 후 RSS: 24.5 MB  / 항목: 50000
clear 후 RSS: 24.5 MB

5만 건을 담았더니 11MB가 늘었다. 그런데 CACHE.clear()와 gc.collect()를 했는데도 숫자가 24.5MB 그대로다. 메모리가 안 풀린 것처럼 보이지만 사실이 아니다. ru_maxrss는 지금 쓰는 메모리가 아니라 지금까지의 최대치라서 원래 줄어들지 않는다. 해제 여부를 보려면 ru_maxrss가 아니라 tracemalloc의 현재 스냅샷이나 psutil의 현재 RSS를 봐야 한다.

단위도 함정이다. 같은 환경에서 ru_maxrss 원본 값은 13746176이었고, 바이트로 읽으면 13.1MB로 상식적인 값이 된다(킬로바이트로 읽으면 13GB가 되어 말이 안 된다). 파이썬 공식 문서는 이 필드의 단위를 명시하지 않고 각 OS의 getrusage(2) man 페이지를 보라고 안내하므로, 코드에 숫자를 하드코딩하기 전에 실행 환경에서 한 번 확인하는 편이 안전하다.

재현 3. 순환 참조는 정말 누수인가요?

자주 오해하는 부분이다. 실제로 확인해 보자.

import gc

class Node:
    def __init__(self, name):
        self.name = name
        self.peer = None
        self.blob = bytearray(100_000)

def make_cycle():
    a, b = Node("a"), Node("b")
    a.peer = b
    b.peer = a          # 순환 참조
    return

gc.collect()
gc.disable()            # GC를 끄고 참조 카운트만 남긴다
before = len(gc.get_objects())
for _ in range(500):
    make_cycle()
after = len(gc.get_objects())
print("gc.disable() 상태 - 추적 객체 증가:", after - before)

gc.enable()
print("gc.collect()가 회수한 객체 수:", gc.collect())
print("회수 후 추적 객체 증가:", len(gc.get_objects()) - before)

직접 실행 결과

gc.disable() 상태 - 추적 객체 증가: 1000
gc.collect()가 회수한 객체 수: 1000
회수 후 추적 객체 증가: -1

GC를 끈 동안에는 함수가 끝났는데도 객체 1,000개(500쌍)가 그대로 남았고, GC를 켜고 수집하자 1,000개가 전부 회수됐다. 즉 순환 참조 자체는 파이썬이 알아서 처리한다. 성능 때문에 gc.disable()을 해 둔 코드가 있다면 그쪽이 진짜 위험 지점이다. 참고로 공식 문서에 따르면 gc.collect()의 반환값은 "회수된 객체 수와 회수 불가능한 객체 수의 합"이다.

__del__이 있으면 영영 못 지운다던데 사실인가요?

지금은 사실이 아니다. 파이썬 3.4에서 PEP 442가 적용되면서 바뀌었다. 공식 gc 문서는 "Changed in version 3.4: Following PEP 442, objects with a __del__() method don't end up in gc.garbage anymore"라고 명시한다. 직접 확인해 보면 이렇다.

import gc
class WithDel:
    def __init__(self): self.peer = None
    def __del__(self): pass
a, b = WithDel(), WithDel()
a.peer = b; b.peer = a
del a, b
print("gc.collect() ->", gc.collect())
print("gc.garbage:", gc.garbage)

직접 실행 결과

gc.collect() -> 8
gc.garbage: []

회수됐고 gc.garbage는 비어 있다. 오래된 블로그 글을 보고 __del__을 지우러 다닐 필요는 없다.

참조를 붙잡고 있는 범인은 어떻게 찾나요?

tracemalloc이 "이 줄에서 객체가 늘어난다"까지 알려줬다면, 다음 질문은 "그래서 누가 안 놓아주고 있나"다. 여기에 gc.get_referrers()를 쓴다.

import gc
registry = {}
class Session: pass

s = Session()
registry["user-1"] = s
del s                     # 지역 변수는 지웠는데도

target = [o for o in gc.get_objects()
          if type(o).__name__ == "Session"][0]
for r in gc.get_referrers(target):
    print(type(r).__name__, "->", str(r)[:60])

직접 실행 결과

dict -> {'user-1': <__main__.Session object at 0x1097bac90>}
dict -> {'__name__': '__main__', '__doc__': None, '__package__': Non

첫 줄이 범인이다. registry 딕셔너리가 Session을 붙잡고 있다. 두 번째는 모듈 전역 네임스페이스라 항상 따라 나온다. 공식 문서는 get_referrers()에 대해 "디버깅 외의 목적으로는 사용하지 말라"고 경고하고, 정확한 결과를 위해 호출 전에 collect()를 먼저 부르라고 안내한다.

실무에서는 어떤 순서로 해결하나요?

  • 1단계. 앱 시작 지점에서 tracemalloc.start()를 호출하고, 운영 중 주기적으로 스냅샷을 떠 둔다(관리자 전용 엔드포인트나 시그널 핸들러에 붙이면 편하다).
  • 2단계. 메모리가 늘어난 구간의 두 스냅샷을 compare_to(old, "lineno")로 비교해 증가 상위 10줄을 본다.
  • 3단계. 줄이 특정되면 statistics("traceback")으로 호출 경로까지 확인한다.
  • 4단계. 그 줄이 만든 객체를 누가 붙잡고 있는지 gc.get_referrers()로 확인한다.
  • 5단계. 참조를 끊는다. 캐시라면 functools.lru_cache(maxsize=...)처럼 상한이 있는 자료구조로 바꾸고, 수명이 요청 단위인 객체는 전역이 아니라 요청 스코프에 두며, 레지스트리 성격이면 weakref.WeakValueDictionary를 검토한다.

상한을 두는 것만으로 충분한 경우가 많다. 아래는 maxsize를 준 캐시를 1만 번 호출한 결과다.

import functools
@functools.lru_cache(maxsize=128)
def f(i): return "x" * 1000
for i in range(10000): f(i)
print(f.cache_info())

직접 실행 결과

CacheInfo(hits=0, misses=10000, maxsize=128, currsize=128)

1만 번을 호출했지만 캐시에 남은 항목은 128개다. 상한 없는 dict였다면 1만 개가 그대로 쌓였을 것이다.

주의할 점과 한계

  • tracemalloc은 파이썬 할당기를 거친 메모리만 추적한다. C 확장 모듈이 직접 malloc한 영역은 잡히지 않으므로, tracemalloc 수치는 안 느는데 RSS만 느는 경우에는 C 확장을 의심해야 한다.
  • tracemalloc 자체가 메모리와 속도를 쓴다. 프레임 수를 크게 잡을수록 비용이 커지므로 운영 환경에서는 필요한 구간에만 켜는 편이 좋다.
  • RSS가 줄지 않는다고 해서 누수는 아니다. 파이썬이 객체를 해제해도 할당기가 OS에 메모리를 바로 돌려주지 않는 경우가 많다.
  • 이 글에서 직접 재현하지 않은 것: C 확장 모듈의 누수, 멀티프로세스·멀티스레드 환경에서의 누적. 이 경우는 위 도구만으로 부족하다.

정리표

증상확인 방법조치
메모리가 계속 느는데 gc.collect()가 0을 반환tracemalloc compare_to("lineno")참조를 쥔 전역 캐시·리스트를 비우거나 상한 적용
어느 줄인지는 알지만 호출 경로를 모름tracemalloc.start(5) + statistics("traceback")호출 경로상의 적절한 지점에서 참조 해제
누가 참조를 쥐고 있는지 모름gc.collect() 후 gc.get_referrers(obj)지목된 컨테이너에서 제거, weakref 검토
RSS가 줄지 않음ru_maxrss가 아니라 현재 RSS 확인ru_maxrss는 최대치라 원래 줄지 않음
순환 참조가 쌓임gc.isenabled() 확인gc.disable()을 걷어내거나 주기적 collect()
tracemalloc은 그대로인데 RSS만 증가사용 중인 C 확장 목록 점검확장 모듈 쪽 이슈 트래커 확인

자주 묻는 질문(FAQ)

gc.collect()를 주기적으로 호출하면 메모리 누수가 해결되나요?

대부분 해결되지 않는다. 직접 재현한 전역 캐시 예제에서 gc.collect()는 0을 반환했다. 캐시가 참조를 쥐고 있는 한 가비지 컬렉터가 회수할 대상 자체가 없기 때문이다. gc.collect()가 효과를 보는 경우는 순환 참조가 많은데 수집이 미뤄지고 있을 때로 한정된다.

tracemalloc은 운영 서버에 켜도 되나요?

켤 수 있지만 비용이 있다. tracemalloc은 모든 할당의 호출 프레임을 기록하므로 메모리와 CPU를 추가로 쓰고, start()에 넘기는 프레임 수가 클수록 비용이 커진다. 문제가 재현되는 시간대나 특정 워커에만 켜고, 스냅샷을 뜬 뒤 끄는 방식을 권한다.

순환 참조가 생기지 않게 코드를 짜야 하나요?

신경 쓸 필요는 거의 없다. 직접 확인한 결과 순환 참조로 만든 객체 1,000개는 gc.collect() 한 번에 전부 회수됐고, __del__이 있어도 파이썬 3.4부터는 gc.garbage에 남지 않는다. 정말 문제가 되는 상황은 성능 때문에 gc.disable()을 해 둔 경우다.

메모리가 늘어나는데 tracemalloc 수치는 그대로입니다. 왜 그런가요?

tracemalloc은 파이썬 할당기를 거친 메모리만 추적하기 때문이다. NumPy, 이미지 처리, DB 드라이버처럼 C로 작성된 확장 모듈이 자체적으로 할당한 메모리는 집계되지 않는다. 이 경우에는 해당 확장 모듈 쪽의 메모리 관련 옵션이나 알려진 이슈를 먼저 확인해야 한다.

관련 글: Python ModuleNotFoundError 해결 — pip install 했는데 왜 못 찾냐 · Python SSL 인증서 에러 완전 정복: CERTIFICATE_VERIFY_FAILED 원인과 해결법

공식 문서: Python — tracemalloc · Python — gc

최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기

Spring Boot UnexpectedRollbackException 완전 해결 가이드

🔍 검색 키워드: Spring Boot UnexpectedRollbackException, 트랜잭션 롤백 에러, Spring @Transactional 에러, rollback-only 에러, Spring 트랜잭션 전파

이 에러, 왜 뜨는 건가

운영 중에 갑자기 이런 에러를 마주하는 경우가 있다.

org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only

겉으로 보면 "조용히 롤백됐다"는 건데, 왜 롤백됐는지 이유가 안 보인다. 내 코드엔 예외처리도 했고, try-catch도 했는데 왜?

원인: Spring 트랜잭션 전파(Propagation) 구조 이해

핵심 개념

Spring의 기본 트랜잭션 전파 방식은 REQUIRED다. 즉, 이미 트랜잭션이 있으면 그 트랜잭션에 참여(join)한다.

문제는 여기서 발생한다.

[외부 트랜잭션 시작]
  └── 내부 서비스 호출 (REQUIRED → 외부 트랜잭션에 참여)
        └── 내부에서 예외 발생 → 트랜잭션에 rollback-only 마킹
  내부 예외를 try-catch로 잡음
  외부 트랜잭션 커밋 시도
      → BOOM: UnexpectedRollbackException

내부 메서드에서 예외가 발생해서 rollback-only로 마킹됐는데, 외부에서 예외를 잡아버리면 Spring은 "아 괜찮은 거구나"하고 커밋을 시도한다. 하지만 이미 롤백 마킹이 됐기 때문에 UnexpectedRollbackException이 터진다.

레벨별 해결 방법

Level 1 — 기초 (원인 파악부터)

상황 재현 코드:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final PaymentService paymentService;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 주문 저장 로직...

        try {
            paymentService.processPayment(request.getPaymentInfo()); // 내부 트랜잭션 참여
        } catch (Exception e) {
            log.error("결제 실패: {}", e.getMessage()); // 예외 잡음
            // → 이 시점에 트랜잭션은 이미 rollback-only
        }

        // 커밋 시도 → UnexpectedRollbackException 발생!
    }
}

@Service
public class PaymentService {
    @Transactional // REQUIRED (기본값) → 외부 트랜잭션에 참여
    public void processPayment(PaymentInfo info) {
        // 내부 예외 발생
        throw new PaymentException("카드 한도 초과");
    }
}

트랜잭션 상태 디버깅:

@Transactional
public void placeOrder(OrderRequest request) {
    log.info("트랜잭션 활성: {}", TransactionSynchronizationManager.isActualTransactionActive());
    log.info("롤백 마킹: {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());
}

Level 2 — 실무 해결책 (전파 방식 변경)

방법 1: REQUIRES_NEW로 별도 트랜잭션 분리

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    // ↑ 기존 트랜잭션과 완전히 분리된 새 트랜잭션 시작
    public void processPayment(PaymentInfo info) {
        // 이 트랜잭션이 롤백돼도 외부 트랜잭션에 영향 없음
        paymentRepository.save(/* ... */);
    }
}
⚠️ 주의: REQUIRES_NEW는 별도 DB 커넥션을 사용한다. 커넥션 풀 고갈 위험이 있으니 남발하면 안 된다.

방법 2: noRollbackFor 설정

@Transactional(noRollbackFor = PaymentException.class)
public void processPayment(PaymentInfo info) {
    // PaymentException이 발생해도 롤백하지 않음
}

방법 3: 예외를 잡지 말고 던지기

@Transactional
public void placeOrder(OrderRequest request) {
    try {
        paymentService.processPayment(request.getPaymentInfo());
    } catch (PaymentException e) {
        // 잡지 말고 그냥 던진다
        throw e; // 혹은 새 예외로 래핑
        // → 외부 호출자가 트랜잭션 롤백 처리
    }
}

Level 3 — 고급 (아키텍처 관점에서 설계)

이벤트 기반 분리 (트랜잭션 완료 후 처리)

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void placeOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.of(request));

        // 트랜잭션 커밋 후 이벤트 발행
        eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
    }
}

@Component
public class PaymentEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    // ↑ 외부 트랜잭션 커밋 후에 실행 → 트랜잭션 오염 없음
    public void handleOrderPlaced(OrderPlacedEvent event) {
        paymentService.processPayment(event.getOrderId());
    }
}

Facade 패턴으로 트랜잭션 경계 명확히

@Service
public class OrderFacade {
    // @Transactional 없음 → 트랜잭션 경계 없음

    public void placeOrder(OrderRequest request) {
        orderService.saveOrder(request);        // 각각 독립 트랜잭션
        paymentService.processPayment(request); // 각각 독립 트랜잭션
    }
}

@Service
public class OrderService {
    @Transactional // 이 메서드 범위만 트랜잭션
    public void saveOrder(OrderRequest request) { /* ... */ }
}

상황별 해결 체크리스트

상황권장 방법주의사항
내부 예외가 외부와 독립적으로 처리돼야 할 때REQUIRES_NEW커넥션 풀 사용량 증가
특정 예외는 롤백 안 해도 될 때noRollbackFor데이터 정합성 검토 필요
결제 같은 외부 I/O가 있을 때@TransactionalEventListenerAFTER_COMMIT 타이밍 주의
서비스 레이어 설계를 바꿀 수 있을 때Facade 패턴트랜잭션 경계 재설계 필요
빠르게 임시 수정이 필요할 때예외 재던지기호출부에서 처리 필요

자주 하는 실수

실수 1: try-catch에서 예외를 먹어버리기

// ❌ 잘못된 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    // 예외를 삼켜버림 → UnexpectedRollbackException 확정
}

// ✅ 올바른 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    throw new BusinessException("처리 실패", e); // 반드시 다시 던지기
}

실수 2: Self-invocation (같은 클래스 내 메서드 호출)

@Service
public class MyService {
    @Transactional
    public void outer() {
        inner(); // 프록시를 거치지 않음 → @Transactional 무시됨
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
    // → REQUIRES_NEW가 적용되지 않아서 여전히 같은 트랜잭션 사용
}

Self-invocation í•´ê²°:

@Service
@RequiredArgsConstructor
public class MyService {
    private final ApplicationContext context;

    public void outer() {
        MyService proxy = context.getBean(MyService.class); // 프록시 직접 가져오기
        proxy.inner();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
}

정리

UnexpectedRollbackException은 트랜잭션 전파를 모르면 반드시 한 번은 만나는 에러다. 핵심은 하나다.

💡 내부 트랜잭션이 롤백 마킹되면, 같은 트랜잭션에 참여한 외부 트랜잭션도 롤백될 수밖에 없다.

해결은 ‫글 두 방향이다.

  • 트랜잭션을 분리한다 (REQUIRES_NEW, Facade 패턴, 이벤트 기반)
  • 예외를 제대로 다룬다 (먹지 말고 던지기, noRollbackFor)

실무에서는 REQUIRES_NEW를 무분별하게 쓰기보다 @TransactionalEventListener나 Facade 패턴으로 설계 단계에서 트랜잭션 경계를 명확히 하는 게 장기적으로 낫다.

화요일

GitHub Actions OOM 에러 해결: JavaScript heap out of memory 완전 정복

🔍 검색 키워드: GitHub Actions JavaScript heap out of memory, FATAL ERROR Reached heap limit Allocation failed, max-old-space-size GitHub Actions, NODE_OPTIONS 힙 메모리 늘리기, Node.js heap out of memory 빌드, GitHub Actions 빌드 메모리 부족

요약: "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"는 Node.js 프로세스가 V8 힙 상한을 넘겨 메모리를 더 확보하지 못해 강제 종료될 때 나옵니다. workflow의 env에 NODE_OPTIONS=--max-old-space-size=값을 지정해 상한을 올리거나, 빌드 자체의 메모리 사용량(소스맵, 병렬 작업 등)을 줄여서 해결합니다.

JavaScript heap out of memory는 왜 발생하나요?

Node.js는 V8 엔진의 힙(old space) 메모리에 상한을 둡니다. 빌드 도구(webpack, tsc, 번들러 등)가 큰 프로젝트를 처리하다가 이 상한을 넘으면 가비지 컬렉션으로도 공간을 확보하지 못해 프로세스를 중단합니다. 로컬에서는 괜찮다가 GitHub Actions에서만 실패하는 경우는 보통 러너 환경과 Node 버전, 프로젝트 크기가 달라서입니다.

직접 재현한 결과

아래 출력은 로컬에서 직접 실행한 결과이며 GitHub Actions 러너에서 실행한 것은 아닙니다. 빌드 도구 대신, 배열을 계속 늘리는 간단한 스크립트로 힙 상한을 넘기는 상황을 만들었습니다. 빠르게 재현하려고 상한을 64MB로 낮췄습니다.

  • 환경: macOS 27.0.1, Node.js v20.20.2, RAM 24GB
// leak.js
const a = [];
while (true) a.push(new Array(1e5).fill('x'));
$ node --max-old-space-size=64 leak.js
... Mark-Compact 64.2 (98.1) -> 64.2 (98.1) MB, ...
    allocation failure; scavenge might not succeed
FATAL ERROR: Reached heap limit Allocation failed -
JavaScript heap out of memory

종료 코드: 134 (Abort trap: 6)

가비지 컬렉션(Mark-Compact)을 해도 사용량이 줄지 않다가 상한에 닿아 프로세스가 중단됐습니다. 종료 코드는 134였습니다.

힙 상한은 어떻게 확인하고 바꾸나요?

v8 모듈의 getHeapStatistics()로 현재 상한(heap_size_limit)을 확인할 수 있습니다. 직접 확인한 결과는 다음과 같습니다.

$ node -e "console.log(require('v8').getHeapStatistics().heap_size_limit/1024/1024)"
4144   # 이 컴퓨터(RAM 24GB, Node 20)의 기본값(MB)

$ NODE_OPTIONS=--max-old-space-size=2048 node -e "..."
2096   # NODE_OPTIONS로 지정한 값이 적용됨

두 가지를 확인했습니다. 첫째, NODE_OPTIONS로 지정한 값이 실제로 적용됩니다. 둘째, 이 컴퓨터는 기본 상한이 이미 약 4GB라서 --max-old-space-size=4096을 줘도 값이 4144로 같았습니다. 기본 상한은 Node 버전과 시스템 메모리에 따라 달라지므로, 숫자를 무작정 키우기 전에 현재 상한을 먼저 확인하세요. 러너의 기본 상한과 사용 가능한 메모리는 직접 확인하지 못했으며 GitHub 공식 문서의 러너 사양을 확인해야 합니다.

어떻게 해결하나요?

1) NODE_OPTIONS로 힙 상한 올리기

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      NODE_OPTIONS: --max-old-space-size=4096
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build

위 workflow는 일반적인 형태의 예시이며 실제 러너로 실행해 확인하지는 않았습니다. 값은 러너의 실제 메모리보다 크게 잡으면 안 됩니다. 상한이 물리 메모리를 넘으면 힙 오류 대신 운영체제가 프로세스를 강제 종료할 수 있습니다.

2) 빌드 스크립트에만 적용하기

{
  "scripts": {
    "build": "NODE_OPTIONS=--max-old-space-size=4096 vite build"
  }
}

모든 Node 프로세스가 아니라 빌드 한 곳에만 적용하고 싶을 때 씁니다. Windows 러너를 쓴다면 cross-env 같은 도구가 필요합니다.

3) 메모리 사용량 자체를 줄이기

  • 프로덕션 소스맵 생성을 끄거나 줄입니다. 소스맵은 빌드 메모리를 많이 쓰는 대표적인 옵션입니다.
  • 병렬 처리 수를 줄입니다(예: 번들러의 worker 수, 테스트의 --maxWorkers).
  • 타입 검사와 번들을 한 프로세스에서 같이 돌리고 있다면 단계를 나눕니다.
  • 한 번에 처리하는 파일이 너무 크지 않은지 확인합니다.

주의할 점

  • 상한을 올리는 것은 증상을 미루는 방법일 수 있습니다. 메모리 누수가 원인이면 값을 키워도 시간이 조금 늦어질 뿐 다시 실패합니다.
  • GitHub Actions 로그에서 같은 단계가 "Killed"나 종료 코드 137로 끝나면 힙 오류가 아니라 운영체제 수준의 메모리 부족일 가능성이 있습니다. 이 경우는 위 재현과는 다른 증상이며 직접 재현하지는 않았습니다.
  • Node 버전이 바뀌면 기본 힙 상한이 달라질 수 있으므로, setup-node의 node-version을 고정하세요.

정리표

증상원인해결
FATAL ERROR ... heap out of memory (종료 코드 134)V8 힙 상한 초과NODE_OPTIONS=--max-old-space-size 지정
상한을 올려도 계속 실패메모리 누수 또는 과도한 사용소스맵·병렬 수·작업 분할 점검
Killed / 종료 코드 137OS 수준 메모리 부족(문서 기준)상한을 낮추거나 러너 사양 변경

자주 묻는 질문(FAQ)

--max-old-space-size는 단위가 무엇인가요?

메가바이트(MB)입니다. 직접 확인한 결과, 2048을 지정하면 heap_size_limit가 약 2096MB로 표시됩니다.

NODE_OPTIONS는 어디에 넣어야 하나요?

job 또는 step의 env에 넣거나 package.json의 스크립트 앞에 붙입니다. 모든 Node 프로세스에 적용되므로 필요한 범위로만 지정하는 것이 좋습니다.

로컬에서는 되는데 GitHub Actions에서만 실패하는 이유는 무엇인가요?

Node 버전, 시스템 메모리, 프로젝트 의존성 상태가 다를 수 있기 때문입니다. 힙 상한을 확인하는 명령을 workflow에 한 번 출력해 비교해 보세요.

상한을 크게 올리면 항상 해결되나요?

아닙니다. 누수가 원인이면 다시 실패하고, 값이 물리 메모리보다 크면 OS가 프로세스를 종료할 수 있습니다.

참고 문서: Node.js CLI options · GitHub-hosted runners

최종 업데이트: 2026-10-08 · 작성: 내멋스레 사는 이야기

TypeScript "Type is not assignable" 에러 완전 정복

🔍 검색 키워드: TypeScript 타입 에러, Type is not assignable to type, TS2322, TS2345, TypeScript 타입 오류 해결, typescript type error 해결

TypeScript 쓰다 보면 한 번쯤은 이 에러를 만난다.

Type 'string' is not assignable to type 'number'.  ts(2322)

처음엔 당황스럽지만, 패턴을 이해하면 금방 잡힌다. 이 글은 자주 나오는 케이스별로 원인과 해결을 정리했다.


에러가 생기는 이유

TypeScript는 정적 타입 언어다. 컴파일 타임에 타입을 검사하기 때문에, 선언된 타입과 실제 할당값이 다르면 에러를 낸다. Java의 컴파일 에러와 같은 맥락이다.


레벨 1 (초보자) — 기본 타입 불일치

증상

let count: number = "5";  // ❌ TS2322

원인

number 타입으로 선언된 변수에 string을 대입했다.

해결

// 방법 1: 타입에 맞는 값으로 수정
let count: number = 5;

// 방법 2: 타입 변환
let count: number = parseInt("5");

// 방법 3: 타입 선언 수정
let count: string = "5";

레벨 2 (중급자) — 함수 파라미터 타입 불일치

증상

function greet(name: string): string {
  return `Hello, ${name}`;
}
greet(123);  // ❌ TS2345

해결

// 방법 1: 올바른 타입으로 전달
greet("홍길동");

// 방법 2: Union 타입 사용
function greet(name: string | number): string {
  return `Hello, ${String(name)}`;
}

레벨 3 (중급자) — Object 타입 구조 불일치

증상

interface User {
  id: number;
  name: string;
  email: string;
}
const user: User = {
  id: 1,
  name: "홍길동"
  // email 누락 → ❌ TS2322
};

해결

// 방법 1: 빠진 프로퍼티 추가
const user: User = { id: 1, name: "홍길동", email: "hong@example.com" };

// 방법 2: 선택적 프로퍼티로 변경
interface User { id: number; name: string; email?: string; }

// 방법 3: Partial 사용
const partialUser: Partial<User> = { id: 1, name: "홍길동" };

레벨 4 (중급자) — 배열/제네릭 타입 불일치

증상

const ids: number[] = [1, 2, "3", 4];  // ❌ TS2322

function first<T>(arr: T[]): T { return arr[0]; }
const result: number = first(["a", "b"]);  // ❌ TS2322

해결

const ids: (number | string)[] = [1, 2, "3", 4];
const result: string = first(["a", "b"]);

레벨 5 (실무자) — null/undefined 처리

증상

function getUser(id: number): User | null {
  return id === 1 ? { id: 1, name: "홍길동", email: "hong@example.com" } : null;
}
const user: User = getUser(999);  // ❌ TS2322: 'User | null'

해결

// 방법 1: 타입에 null 포함
const user: User | null = getUser(999);

// 방법 2: null 체크 후 사용 (Type narrowing)
const maybeUser = getUser(999);
if (maybeUser !== null) {
  const user: User = maybeUser;
}

// 방법 3: Non-null assertion
const user: User = getUser(1)!;

// 방법 4: Nullish coalescing으로 기본값
const user: User = getUser(999) ?? { id: 0, name: "Guest", email: "" };

레벨 6 (고급자) — 타입 추론 문제

증상

const config = { mode: "development" };  // TypeScript가 string으로 추론
function setup(mode: "development" | "production") {}
setup(config.mode);  // ❌ TS2345

해결

// 방법 1: as const로 리터럴 타입 고정
const config = { mode: "development" } as const;

// 방법 2: 명시적 타입 선언
const config: { mode: "development" | "production" } = { mode: "development" };

// 방법 3: 타입 단언
setup(config.mode as "development" | "production");

상황별 체크리스트

상황확인 포인트
변수 할당 에러선언 타입과 값 타입 일치 여부
함수 인자 에러함수 시그니처와 전달값 타입 비교
객체 에러인터페이스 필수 프로퍼티 누락 여부
null 에러strictNullChecks 활성화 여부 확인
리터럴 타입 에러as const 또는 명시적 타입 선언 필요
any 남용any 대신 unknown + 타입 가드 사용

자주 하는 실수 — any로 도배

// ❌ TypeScript 쓰는 의미 없음
const data: any = fetchData();

// ✅ unknown 쓰고 타입 가드로 좁혀라
const data: unknown = fetchData();
if (typeof data === "string") {
  console.log(data.toUpperCase());
}

마무리

TypeScript 타입 에러는 대부분 세 가지다.

  1. 타입을 잘못 선언했거나
  2. 값이 여러 타입이 될 수 있는데 하나만 선언했거나
  3. null/undefined 처리를 안 했거나

에러 메시지를 읽으면 어느 쪽인지 대부분 나온다. ts(숫자) 에러코드로 TypeScript 공식 문서에서 정확한 설명도 찾을 수 있다.

월요일

MySQL 연결 에러 완전 정복: Too many connections, Connection refused, Access denied 해결법

Python SSL 인증서 에러 완전 정복: CERTIFICATE_VERIFY_FAILED 원인과 해결법

🔍 검색 키워드: CERTIFICATE_VERIFY_FAILED · Python SSL 에러 · ssl.SSLCertVerificationError · Python requests SSL 오류 · macOS Python SSL · Python urllib SSL 인증서 오류

Python으로 외부 API 호출하거나 크롤링하다 보면 이 에러 한 번쯤은 만난다.

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate (_ssl.c:1129)

이거 처음 보면 당황스럽다. 에러 메시지만 봐서는 뭐가 문제인지 감도 안 온다. 원인부터 단계별 해결까지 정리한다.


왜 이 에러가 나는가

Python이 HTTPS 요청을 보낼 때 서버의 SSL 인증서를 검증한다. 이 과정에서 신뢰할 수 있는 루트 인증서(CA) 목록을 참조하는데, 여기에 서버 인증서 체인이 없으면 에러가 난다.

주요 원인은 크게 세 가지다.

  • Python 자체 CA 번들이 오래됨 — macOS에서 Python 3.6+ 공식 배포판은 시스템 인증서를 쓰지 않고 번들로 따로 관리하는데, 이게 업데이트 안 되면 발생한다.
  • 회사 네트워크의 프록시/방화벽 — 기업 환경에서 HTTPS 트래픽을 자체 인증서로 중간에서 가로채는(MITM) 경우, Python이 그 인증서를 모른다.
  • 자체 서명(self-signed) 인증서를 가진 서버 — 내부 개발 서버나 테스트 환경에서 자주 발생.

상황별 체크리스트

상황원인권장 해결책
macOS + Python 공식 설치번들 CA 미업데이트Install Certificates.command 실행
회사 내부 네트워크기업 프록시 인증서기업 CA 인증서 추가
내부 개발 서버self-signed 인증서verify=인증서경로 지정
Docker/CI 환경CA 번들 미포함certifi 패키지 + 환경변수 설정
갑자기 발생 (예전엔 됐는데)Python 또는 OS 업그레이드certifi 재설치

레벨별 해결 방법

초보자 — 일단 돌아가게 만들기 (비추천)

개발 중에 빠르게 확인만 할 때 쓰는 방법이다. 절대 프로덕션에 쓰면 안 된다.

import requests

# SSL 검증 비활성화 — 개발용으로만!
response = requests.get("https://example.com", verify=False)

중급자 — certifi로 CA 번들 업데이트

가장 권장하는 방법이다. certifi는 Mozilla가 관리하는 신뢰할 수 있는 CA 목록을 Python에서 쓸 수 있게 패키징한 라이브러리다.

pip install --upgrade certifi
import requests
import certifi

response = requests.get("https://example.com", verify=certifi.where())
print(response.status_code)

환경변수로 전역 적용 (추천)

# Linux/macOS
export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
export REQUESTS_CA_BUNDLE=$(python -c "import certifi; print(certifi.where())")

macOS 전용 — Install Certificates.command 실행

# 해당 버전의 Install Certificates 스크립트 실행 (버전에 맞게 경로 수정)
open /Applications/Python\ 3.12/Install\ Certificates.command

고급자 — 기업 CA 인증서 추가

import requests

# 단일 파일
response = requests.get("https://internal.company.com", verify="/path/to/company-ca.crt")
import certifi

with open("/path/to/company-ca.crt", "r") as f:
    company_cert = f.read()

with open(certifi.where(), "a") as bundle:
    bundle.write("\n" + company_cert)

Docker/CI 환경

FROM python:3.12-slim

RUN pip install certifi

ENV SSL_CERT_FILE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
ENV REQUESTS_CA_BUNDLE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
# GitHub Actions
- name: Fix SSL certificates
  run: |
    pip install --upgrade certifi
    echo "SSL_CERT_FILE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV
    echo "REQUESTS_CA_BUNDLE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV

urllib / aiohttp 사용자 참고

import ssl, urllib.request, certifi

ctx = ssl.create_default_context(cafile=certifi.where())
req = urllib.request.Request("https://example.com")
with urllib.request.urlopen(req, context=ctx) as response:
    data = response.read()
import aiohttp, ssl, certifi

async def fetch(url):
    ssl_ctx = ssl.create_default_context(cafile=certifi.where())
    connector = aiohttp.TCPConnector(ssl=ssl_ctx)
    async with aiohttp.ClientSession(connector=connector) as session:
        async with session.get(url) as response:
            return await response.text()

절대 하면 안 되는 것

⚠️ 프로덕션에서 이런 코드 보이면 반드시 수정해야 한다.
# ❌ 절대 하지 말 것
import ssl
ssl._create_default_https_context = ssl._create_unverified_context

# ❌ 이것도 마찬가지
requests.get(url, verify=False)

# ❌ 환경변수로 전체 비활성화
# PYTHONHTTPSVERIFY=0

SSL 검증을 끄는 순간 중간자 공격(MITM)에 완전히 노출된다. 개발 편의를 위해 껐다가 프로덕션에 그대로 배포되는 사고가 실제로 발생한다.


정리

우선순위해결책상황
1순위Install Certificates.commandmacOS + 공식 Python 설치
2순위pip install --upgrade certifi + 환경변수대부분의 환경
3순위기업 CA 인증서 추가회사 내부 네트워크
4순위verify=False로컬 개발 일시적 확인 (프로덕션 절대 불가)
💡 실무에서 이 에러를 자주 만나는 패턴은 Python 버전 업그레이드 직후, 또는 새 팀원이 회사 환경 세팅할 때다. 팀 위키에 이 내용 정리해두면 반복 질문 많이 줄어든다.

Redis 연결 에러 완전 정복: ECONNREFUSED 127.0.0.1:6379 트러블슈팅

🔍 검색 키워드: redis connection refused, redis ECONNREFUSED 6379, redis 연결 안됨, ioredis 연결 에러, spring boot redis 연결 실패, node redis ECONNREFUSED, docker redis 연결 에러

Redis 붙이다가 처음 보는 에러 아니다. 누구나 한 번쯤은 밟는다.

Error: connect ECONNREFUSED 127.0.0.1:6379

이거 뜨면 일단 당황하지 말고 순서대로 확인하면 금방 해결된다. 원인은 대부분 세 가지 중 하나다.


에러가 뜨는 주요 상황

상황에러 메시지
Node.js (ioredis)[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
Node.js (node-redis)Error: Redis connection to 127.0.0.1:6379 failed - connect ECONNREFUSED
Spring BootUnable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException
Python (redis-py)redis.exceptions.ConnectionError: Error 111 connecting to localhost:6379.
Docker 컨테이너 간connect ECONNREFUSED 127.0.0.1:6379 (컨테이너 내부에서 호스트 Redis 접근 시도)

레벨 1: 입문자 — Redis가 켜져 있나요?

가장 흔한 원인. Redis 서버가 꺼져 있으면 당연히 붙을 수 없다.

Redis 실행 상태 확인

# 프로세스 확인
ps aux | grep redis-server

# 포트 리스닝 확인
ss -tlnp | grep 6379

# systemd 기반 (Ubuntu/CentOS)
sudo systemctl status redis

Redis 직접 연결 테스트

redis-cli ping
# 정상이면: PONG
# 실패하면: Could not connect to Redis at 127.0.0.1:6379: Connection refused

Redis 실행 방법

# systemd로 시작
sudo systemctl start redis
sudo systemctl enable redis   # 부팅 시 자동 시작

# 백그라운드 실행
redis-server --daemonize yes

레벨 2: 실무자 — 포트/바인딩/방화벽 확인

Redis는 켜져 있는데 연결이 안 되면 이쪽을 본다.

bind 설정 문제

/etc/redis/redis.conf 기본 설정:

# 기본값: 127.0.0.1만 허용 (로컬호스트 전용)
bind 127.0.0.1

# 외부 접속 허용하려면 (주의: 보안 설정 필수)
bind 0.0.0.0

# 변경 후 재시작
sudo systemctl restart redis

방화벽 확인

# UFW (Ubuntu)
sudo ufw status
sudo ufw allow 6379

# 외부에서 포트 테스트
nc -zv <서버IP> 6379

requirepass 설정 시 인증 필요

redis-cli -a yourpassword ping

# 또는 연결 후 AUTH
redis-cli
> AUTH yourpassword
> PING

레벨 3: 고급 — Docker, Kubernetes 환경

이게 제일 헷갈린다. 컨테이너 안에서 127.0.0.1:6379는 컨테이너 자신을 가리킨다.

Docker Compose로 Redis 연결

# docker-compose.yml
version: '3.8'
services:
  app:
    build: .
    environment:
      - REDIS_HOST=redis      # 127.0.0.1이 아니라 서비스명!
      - REDIS_PORT=6379
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
// Node.js - 잘못된 예
const redis = new Redis({ host: '127.0.0.1', port: 6379 });

// 올바른 예
const redis = new Redis({
  host: process.env.REDIS_HOST || 'redis',
  port: parseInt(process.env.REDIS_PORT || '6379'),
});

호스트 머신의 Redis에 접근하는 경우

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      - REDIS_HOST=host.docker.internal

언어별 연결 코드 및 에러 핸들링

Node.js — ioredis

const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD || undefined,
  retryStrategy(times) {
    return Math.min(times * 50, 2000);
  },
  maxRetriesPerRequest: 3,
});

redis.on('error', (err) => console.error('Redis 연결 에러:', err.message));
redis.on('connect', () => console.log('Redis 연결 성공'));

Python — redis-py

import redis, os

r = redis.Redis(
    host=os.getenv('REDIS_HOST', '127.0.0.1'),
    port=int(os.getenv('REDIS_PORT', 6379)),
    password=os.getenv('REDIS_PASSWORD'),
    decode_responses=True,
    socket_connect_timeout=5,
    retry_on_timeout=True,
)

try:
    r.ping()
    print("Redis 연결 성공")
except redis.exceptions.ConnectionError as e:
    print(f"Redis 연결 실패: {e}")

Spring Boot — application.yml

spring:
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      timeout: 5000ms
      lettuce:
        pool:
          max-active: 10
          max-idle: 10
          min-idle: 2

원인별 체크리스트

체크 항목확인 방법조치
Redis 서버 실행 중?ps aux | grep redissystemctl start redis
포트 리스닝 중?ss -tlnp | grep 6379포트 충돌 확인
bind 설정 맞음?redis.conf 확인bind 0.0.0.0
방화벽 열려 있음?ufw status포트 허용
Docker 환경?컨테이너 여부 확인서비스명으로 host 변경
인증 필요?requirepass 설정 확인password 파라미터 추가
TLS 사용 중?Redis 6.0+ 설정 확인tls:// 스킴 및 인증서 설정
원격 서버?네트워크 경로 확인VPN, 보안그룹 확인

자주 하는 실수 TOP 3

1. Docker에서 localhost 씀
컨테이너 안에서 localhost는 컨테이너 자신이다. 다른 컨테이너의 Redis에 붙으려면 서비스명을 써야 한다.

2. 환경변수 안 쓰고 하드코딩
로컬에서 127.0.0.1로 하드코딩해두고 스테이징/프로덕션에 그대로 올리면 터진다. 처음부터 환경변수로 빼두자.

3. Redis 안 뜨고 앱부터 뜸
depends_on은 컨테이너 시작 순서만 보장하지 Redis 준비를 보장하지 않는다. healthcheck를 써야 한다.

redis:
  image: redis:7-alpine
  healthcheck:
    test: ["CMD", "redis-cli", "ping"]
    interval: 10s
    timeout: 5s
    retries: 5

app:
  depends_on:
    redis:
      condition: service_healthy

마무리

Redis 연결 에러의 90%는 위 체크리스트로 해결된다. 나머지 10%는 TLS, 클러스터 모드, Sentinel 설정 같은 고급 주제인데 그건 따로 다루겠다.

에러 메시지를 봤을 때 "Redis가 켜져 있냐 → 주소/포트가 맞냐 → 네트워크가 열려 있냐" 이 순서로만 확인해도 대부분 잡힌다.