화요일

ERR_REQUIRE_ASYNC_MODULE 해결 — require()로 ESM 불러올 때 top-level await 에러

🔍 검색 키워드: ERR_REQUIRE_ASYNC_MODULE, require esm top-level await, node require esm 에러, experimental-print-required-tla, ERR_REQUIRE_ESM 차이, CJS에서 ESM 불러오기

요약: require()는 ES 모듈을 동기적으로 평가하기 때문에, 불러오려는 모듈이나 그 모듈이 import하는 그래프 어딘가에 top-level await가 하나라도 있으면 Node.js는 ERR_REQUIRE_ASYNC_MODULE을 던진다. 호출하는 쪽을 await import()로 바꾸거나 모듈 쪽의 top-level await를 init() 같은 함수 안으로 옮기면 해결된다. 에러가 가리키는 파일에 await가 보이지 않을 때는 --experimental-print-required-tla 플래그로 실제 위치를 찾는다.

어떤 상황에서 ERR_REQUIRE_ASYNC_MODULE이 발생하나요?

Node.js 공식 문서의 버전 기록에 따르면 require()로 ES 모듈을 불러오는 기능(require(esm))은 v22.0.0과 v20.17.0에서 추가됐고, v23.0.0 · v22.12.0 · v20.19.0부터 --experimental-require-module 플래그 없이 동작한다. v23.5.0 · v22.13.0 · v20.19.0부터는 실험 경고도 기본으로는 출력되지 않고, v25.4.0에서 실험 기능 딱지가 떨어졌다.

그래서 Node 버전을 올리고 나면 예전에 보던 ERR_REQUIRE_ESM은 사라지는데, 대신 이 에러를 만나는 경우가 생긴다. 공식 문서가 정리한 require()가 받아줄 수 있는 ES 모듈의 조건은 다음과 같다.

  • 모듈 그래프가 완전히 동기적일 것. 즉 top-level await가 없을 것
  • 그리고 다음 중 하나: 확장자가 .mjs이거나
  • 확장자가 .js이고 가장 가까운 package.json에 "type": "module"이 있거나
  • 확장자가 .js이고 가장 가까운 package.json에 "type": "commonjs"가 없으면서 파일이 ES 모듈 문법을 포함할 것

이 글의 주제는 첫 번째 조건이다. 나머지를 모두 만족해도 await 하나면 require()는 실패한다.

테스트 환경

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

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

재현: 어디까지 되고 어디서 터지는가

1단계. top-level await가 없으면 require()로 잘 불러와진다

// sync.mjs
export const name = "sync-esm";
export function greet() { return "hello from ESM"; }

// main.cjs  (CommonJS)
const mod = require("./sync.mjs");
console.log("loaded:", mod.name, "/", mod.greet());

직접 실행 결과

$ node main.cjs
loaded: sync-esm / hello from ESM

Node 20.20.2에서는 경고 한 줄 없이 그냥 동작했다. 플래그를 붙이지 않았다.

2단계. top-level await를 넣는 순간 터진다

// tla.mjs
const config = await Promise.resolve({ port: 3000 });  // top-level await
export const port = config.port;

// main-tla.cjs
const mod = require("./tla.mjs");
console.log("port:", mod.port);

직접 실행 결과 (긴 줄은 가독성을 위해 접었다. 종료 코드는 1이었다.)

$ node main-tla.cjs
node:internal/modules/esm/module_job:433
      throw new ERR_REQUIRE_ASYNC_MODULE(filename, parentFilename);
      ^

Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM
graph with top-level await. Use import() instead. To see where the
top-level await comes from, use --experimental-print-required-tla.
  From /tmp/tla-repro/main-tla.cjs
  Requiring /tmp/tla-repro/tla.mjs
    at ModuleJobSync.runSync (node:internal/modules/esm/module_job:433:13)
    at ModuleLoader.importSyncForRequire (node:internal/modules/esm/loader:389:47)
    at loadESMFromCJS (node:internal/modules/cjs/loader:1363:24)
  code: 'ERR_REQUIRE_ASYNC_MODULE'

3단계. 에러가 가리키는 파일에는 await가 없을 수 있다

가장 헷갈리는 지점이다. 직접 require하는 파일이 아니라 그 파일이 import하는 그래프 전체가 검사 대상이다.

// deep.mjs  - 여기에만 await가 있다
export const value = await Promise.resolve(42);

// entry.mjs  - await가 전혀 없다
import { value } from "./deep.mjs";
export const doubled = value * 2;

// main-nested.cjs
const mod = require("./entry.mjs");
console.log("doubled:", mod.doubled);

직접 실행 결과

$ node main-nested.cjs
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM
graph with top-level await. ...
  From /tmp/tla-repro/main-nested.cjs
  Requiring /tmp/tla-repro/entry.mjs

에러는 entry.mjs를 지목하지만 실제 원인은 deep.mjs다. entry.mjs를 아무리 들여다봐도 await는 없다.

에러 메시지가 가리키는 파일에 await가 없는데 어떻게 찾나요?

Node.js가 제공하는 --experimental-print-required-tla 플래그를 쓰면 된다. 공식 CLI 문서의 설명은 "ES 모듈 그래프가 top-level await를 포함해 require()될 수 없을 때, 이 플래그는 Node.js가 그 위치를 찾아 출력하게 한다"이다.

직접 실행 결과

$ node --experimental-print-required-tla main-nested.cjs
Error: unexpected top-level await at
file:///tmp/tla-repro/deep.mjs:1
export const value = await Promise.resolve(42);
                     ^

파일과 줄 번호, 해당 소스 줄까지 정확히 찍어준다.

이 플래그를 쓸 때 주의할 점

Node 20.20.2에서는 이 플래그가 모듈을 실제로 평가하면서 await를 찾는다. 즉 모듈 최상단의 부수 효과가 실행된다. 아래는 모듈 본문에 console.log 한 줄을 넣고 비교한 결과다.

// side.mjs
console.log("[side effect] module body executed");
export const v = await Promise.resolve(1);

$ node main-side.cjs
caught: ERR_REQUIRE_ASYNC_MODULE

$ node --experimental-print-required-tla main-side.cjs
[side effect] module body executed
Error: unexpected top-level await at file:///tmp/tla-repro/side.mjs:2

플래그가 없으면 모듈 본문이 실행되지 않고, 플래그를 켜면 실행된다. DB 연결이나 파일 쓰기 같은 부수 효과가 모듈 최상단에 있다면 운영 환경에서 이 플래그를 켜지 않는 편이 안전하다. 공식 문서의 버전 기록을 보면 v26.5.0에서 "모듈을 평가하지 않고 top-level await를 출력한다"로 동작이 바뀌었다.

4단계. 내 코드가 아니라 node_modules 안의 패키지가 원인일 때

실무에서는 보통 이 형태로 만난다. ESM 전용 패키지 안에 top-level await가 있으면 CommonJS 쪽에서는 require()로 쓸 수 없다.

// node_modules/my-esm-pkg/package.json
{ "name": "my-esm-pkg", "type": "module", "exports": "./index.js" }

// node_modules/my-esm-pkg/index.js
const token = await Promise.resolve("secret-token");
export function getToken() { return token; }

// main-pkg.cjs
const { getToken } = require("my-esm-pkg");

직접 실행 결과

$ node --experimental-print-required-tla main-pkg.cjs
Error: unexpected top-level await at
file:///tmp/tla-repro/node_modules/my-esm-pkg/index.js:1
const token = await Promise.resolve("secret-token");
              ^

원인: 왜 이런 제약이 있나요?

require()는 호출한 자리에서 값을 반환하는 동기 함수다. 반면 top-level await가 있는 ES 모듈은 평가가 끝날 때까지 기다려야 하는 비동기 모듈이라 반환값이 Promise의 완료에 묶인다. 동기 호출 안에서 비동기 완료를 기다릴 방법이 없으므로 Node.js는 이 조합을 아예 막고 에러를 던진다. 공식 문서도 "require()되는 모듈 또는 그 모듈이 import하는 모듈 그래프에 top-level await가 있으면 ERR_REQUIRE_ASYNC_MODULE이 던져지며, 이 경우 import()로 비동기 모듈을 불러와야 한다"고 못 박는다.

어떻게 해결하나요?

해결 1. 호출하는 쪽을 동적 import()로 바꾼다

가장 확실한 방법이다. CommonJS 파일에서도 import()는 쓸 수 있다.

// fixed.cjs
async function main() {
  const mod = await import("./entry.mjs");
  console.log("doubled:", mod.doubled);
}
main();

직접 실행 결과

$ node fixed.cjs
doubled: 84

단점은 전염성이다. 호출부가 동기 함수였다면 async로 바꾸고 그 위로도 await를 전파해야 한다. 모듈 로딩 시점이 바뀌므로 초기화 순서에 의존하는 코드는 함께 점검해야 한다.

해결 2. 모듈 쪽에서 top-level await를 걷어낸다

내가 작성한 모듈이라면 이 방법이 더 낫다. 초기화를 최상단이 아니라 함수 안으로 옮긴다.

// before - CommonJS 소비자를 막는다
export const db = await connect(process.env.DB_URL);

// after - 초기화를 함수로 뺀다
let db;
export async function init() {
  if (!db) db = await connect(process.env.DB_URL);
  return db;
}

라이브러리를 배포한다면 더 중요하다. require() 가능한 그래프에 top-level await를 새로 넣는 것은 CommonJS 사용자 전부에게 깨지는 변경이므로 semver major로 다뤄야 한다.

해결 3. 어떤 require가 ESM을 끌어오는지 추적한다

프로젝트가 크면 어느 require가 ESM을 당겨오는지부터 모를 수 있다. --trace-require-module=all을 붙이면 CJS가 ESM을 require()할 때마다 알려준다.

$ node --trace-require-module=all main.cjs
loaded: sync-esm / hello from ESM
(node:19931) ExperimentalWarning: CommonJS module /tmp/tla-repro/main.cjs
is loading ES Module /tmp/tla-repro/sync.mjs using require().
Support for loading ES Module in require() is an experimental feature
and might change at any time

주의할 점과 한계

ERR_REQUIRE_ESM과 혼동하지 않기

두 에러는 원인이 다르다. ERR_REQUIRE_ESM이 지금도 나온다면 require(esm)이 꺼져 있다는 뜻이고, 보통 Node 버전이 낮거나 NODE_OPTIONS에 플래그가 박혀 있는 경우다. 끄고 실행해 보면 차이가 분명하다.

$ node --no-experimental-require-module main.cjs
Error [ERR_REQUIRE_ESM]: require() of ES Module /tmp/tla-repro/sync.mjs
from /tmp/tla-repro/main.cjs not supported.
Instead change the require of /tmp/tla-repro/sync.mjs to a dynamic
import() which is available in all CommonJS modules.
  code: 'ERR_REQUIRE_ESM'

$ node --no-require-module main.cjs
node: bad option: --no-require-module

참고로 짧은 이름인 --no-require-module은 Node 20.20.2에서는 인식되지 않았다. 20.x LTS에서는 --no-experimental-require-module을 써야 한다.

require(esm)의 반환값은 module.exports가 아니다

에러를 넘기고 나면 다음에 걸리는 지점이다. require(esm)은 CommonJS의 module.exports가 아니라 모듈 네임스페이스 객체를 돌려준다. default export는 .default에 들어간다.

// def.mjs
export default { kind: "default-export" };
export const extra = "named";

// main-def.cjs
const mod = require("./def.mjs");

직접 실행 결과

typeof: object
keys: __esModule,default,extra
mod.default: {"kind":"default-export"}
mod.kind: undefined

default export가 있으면 상호 운용을 위해 __esModule: true가 함께 붙는다. 공식 문서는 이 속성이 실험적이며 ES 모듈을 CommonJS로 변환하는 도구용이니 직접 작성하는 CommonJS 코드가 여기에 의존하지 말라고 안내한다. 모듈 작성자가 반환값을 직접 정하고 싶다면 export { Point as 'module.exports' } 형태를 쓸 수 있지만, 그러면 named export는 CommonJS 소비자에게 보이지 않는다.

이 글이 다루지 않는 것

번들러(webpack, Vite, esbuild)가 개입하는 경우는 동작이 달라질 수 있다. 위 결과는 모두 번들러 없이 Node 20.20.2에서 직접 실행한 것이다.

정리표

구분내용
에러 코드ERR_REQUIRE_ASYNC_MODULE
메시지require() cannot be used on an ESM graph with top-level await. Use import() instead.
발생 조건require() 대상 모듈 또는 그 모듈이 import하는 그래프에 top-level await가 있음
require(esm) 기본 활성v23.0.0 · v22.12.0 · v20.19.0 이상
원인 위치 찾기node --experimental-print-required-tla app.cjs
호출부 해결const mod = await import("./x.mjs")
모듈 해결top-level await를 init() 같은 함수 안으로 이동
혼동 주의ERR_REQUIRE_ESM은 require(esm)이 꺼져 있을 때 나오는 다른 에러
추적 플래그node --trace-require-module=all app.cjs

자주 묻는 질문(FAQ)

Node 버전만 올리면 해결되나요?

아니다. Node 20.19.0 이상이면 동기 ES 모듈은 require()로 불러올 수 있지만, top-level await가 포함된 모듈은 버전과 무관하게 계속 ERR_REQUIRE_ASYNC_MODULE을 던진다. 버전을 올렸는데도 에러가 남아 있다면 top-level await가 원인이다.

에러가 가리키는 파일에 await가 없는데 왜 에러가 나나요?

검사 대상이 그 파일 하나가 아니라 그 파일이 import하는 모듈 그래프 전체이기 때문이다. 직접 재현해 보면 await가 없는 entry.mjs를 require해도 그 아래 deep.mjs의 await 때문에 에러가 난다. node --experimental-print-required-tla로 실행하면 실제 await의 파일명과 줄 번호가 출력된다.

top-level await를 쓰는 라이브러리를 CommonJS 프로젝트에서 쓰려면 어떻게 하나요?

require()를 await import()로 바꾸는 것이 유일하게 확실한 방법이다. 호출하는 함수를 async로 바꾸고 그 위로 await를 전파해야 한다. 라이브러리에 CommonJS 빌드가 함께 제공되는지, top-level await가 없는 이전 버전이 있는지도 확인할 만하다.

--experimental-print-required-tla를 운영 환경에서 켜도 되나요?

권장하지 않는다. Node 20.20.2에서 직접 확인한 결과, 이 플래그를 켜면 await 위치를 찾기 위해 모듈 본문을 실제로 평가한다. 모듈 최상단에 DB 연결이나 파일 쓰기 같은 부수 효과가 있으면 그대로 실행된다. 로컬 디버깅에서만 쓰는 편이 안전하다.

관련 글: .env 파일인데 환경변수가 undefined — dotenv 로딩 에러 완전 해결 · TypeScript cannot find module 해결 — ts(2307) 에러 완벽 정리

공식 문서: Node.js — Loading ECMAScript modules using require() · Node.js CLI — --experimental-print-required-tla

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

수요일

FastAPI async 블로킹 완벽 해결 — async def인데 요청이 순서대로 처리되는 이유와 이벤트 루프 막힘 해결법

요약: FastAPI의 async def 엔드포인트 안에서 time.sleep, requests, 동기 DB 드라이버 같은 동기 코드를 실행하면 이벤트 루프가 막혀 모든 요청이 순차 처리됩니다. async를 빼고 def로 선언하거나, httpx·asyncio.sleep 같은 비동기 라이브러리로 교체하거나, asyncio.to_thread로 블로킹 구간만 스레드로 넘기면 해결됩니다. CPU 바운드 작업은 프로세스로 분리하세요.

🔍 검색 키워드: FastAPI async 블로킹, FastAPI 이벤트 루프 막힘, FastAPI 응답 느림 async def, FastAPI run_in_threadpool, asyncio to_thread 사용법, FastAPI 동시 요청 처리 지연

증상: async def 엔드포인트인데 동시 요청이 순서대로 처리된다

FastAPI는 비동기 프레임워크인데, 엔드포인트 하나가 느려지면 전혀 관계없는 /health 같은 다른 API까지 함께 느려지거나 타임아웃이 나는 경우가 있습니다. 부하 테스트를 해 보면 동시 요청 수만큼 응답 시간이 선형으로 늘어납니다.

$ hey -n 20 -c 20 http://localhost:8000/report
Response time histogram:
  0.503 [1]  |■
  1.004 [1]  |■
  ...
 10.012 [1]  |■
Total:  10.0121 secs   # 0.5초짜리 작업 20개가 순차 실행됨

WARNING: Task exception was never retrieved / uvicorn: Timeout in /health probe

원인: 이벤트 루프 스레드를 동기 코드가 점유한다

async def 함수는 이벤트 루프 스레드 하나에서 실행됩니다. 이 안에서 await 없이 오래 걸리는 동기 코드를 실행하면, 그동안 루프가 멈춰 다른 모든 요청이 대기합니다. 대표적인 원인은 다음과 같습니다.

  • time.sleep(): 비동기 sleep이 아니라 스레드 전체를 멈춤
  • requests 라이브러리: 동기 HTTP 호출이 루프를 블로킹
  • 동기 DB 드라이버: psycopg2, pymysql, 동기 SQLAlchemy 세션 사용
  • CPU 바운드 작업: 이미지 처리, pandas 대용량 연산, 암호화 해시 등
  • 파일 I/O: 대용량 파일을 open().read()로 동기 처리
import time
import requests
from fastapi import FastAPI

app = FastAPI()

@app.get("/report")
async def report():
    time.sleep(0.5)                     # 루프 전체 블로킹
    r = requests.get("https://api.example.com/data")  # 동기 호출도 블로킹
    return r.json()

해결방법 1: async를 빼고 def로 선언하기

FastAPI는 일반 def 엔드포인트를 스레드풀에서 실행합니다. 내부에서 동기 라이브러리를 쓸 수밖에 없다면 async를 제거하는 것이 가장 간단한 해결책입니다.

@app.get("/report")
def report():                            # async 제거 → 스레드풀에서 실행
    time.sleep(0.5)
    return requests.get("https://api.example.com/data").json()

해결방법 2: 진짜 비동기 라이브러리로 교체

async def를 유지하려면 내부 호출도 모두 await 가능한 라이브러리여야 합니다. requests는 httpx, time.sleep은 asyncio.sleep으로 바꿉니다.

import asyncio
import httpx

@app.get("/report")
async def report():
    await asyncio.sleep(0.5)
    async with httpx.AsyncClient() as client:
        r = await client.get("https://api.example.com/data")
    return r.json()

해결방법 3: 블로킹 구간만 스레드로 넘기기

async def 안에 동기 코드가 일부만 섞여 있다면 해당 구간만 to_thread 또는 run_in_threadpool로 감쌉니다.

import asyncio
from starlette.concurrency import run_in_threadpool

@app.get("/report")
async def report():
    data = await asyncio.to_thread(load_legacy_data)        # Python 3.9+
    text = await run_in_threadpool(render_pdf_text, data)   # Starlette 제공
    return {"text": text}

해결방법 4: CPU 바운드는 프로세스로 분리

스레드는 GIL 때문에 CPU 연산을 가속하지 못합니다. 무거운 연산은 ProcessPoolExecutor나 Celery 같은 워커로 분리하고, uvicorn 워커 수도 함께 늘립니다.

from concurrent.futures import ProcessPoolExecutor
pool = ProcessPoolExecutor(max_workers=4)

@app.get("/heavy")
async def heavy():
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(pool, cpu_heavy_job, 42)
    return {"result": result}

# 실행: uvicorn main:app --workers 4

블로킹 위치 진단하기

asyncio 디버그 모드를 켜면 루프를 일정 시간 이상 붙잡은 코루틴을 경고로 알려 줍니다.

PYTHONASYNCIODEBUG=1 uvicorn main:app
# Executing <Task ...> took 0.503 seconds  ← 이 시간만큼 루프가 막혔다는 뜻

정리표

원인증상해결
time.sleep, requests 등 동기 I/O동시 요청이 순차 처리됨def로 선언 또는 httpx/asyncio.sleep으로 교체
동기 DB 드라이버DB 쿼리 중 다른 API 정지asyncpg, async SQLAlchemy 또는 to_thread
CPU 바운드 연산연산 중 health check 타임아웃ProcessPool, Celery, uvicorn --workers
원인 불명 지연특정 요청 후 전체 지연PYTHONASYNCIODEBUG=1로 블로킹 코루틴 추적

핵심은 async def 안에서는 await 없이 오래 걸리는 코드를 실행하지 않는다는 원칙입니다. 동기 코드가 섞여 있다면 def로 선언하거나 스레드·프로세스로 넘겨 이벤트 루프를 항상 비워 두세요.

자주 묻는 질문(FAQ)

FastAPI에서 async def와 def 중 무엇을 써야 하나요?

함수 안에서 호출하는 라이브러리가 모두 await 가능한 비동기 라이브러리(httpx, asyncpg 등)라면 async def를, requests나 동기 DB 드라이버처럼 동기 라이브러리를 쓴다면 def를 사용하세요. def 엔드포인트는 FastAPI가 스레드풀에서 실행하므로 이벤트 루프를 막지 않습니다.

async def 안에서 동기 함수를 호출하면 왜 전체 API가 느려지나요?

async def는 이벤트 루프 스레드 하나에서 실행되기 때문입니다. await 없이 오래 걸리는 동기 코드가 실행되는 동안 루프가 멈춰, 같은 프로세스의 다른 모든 요청도 대기합니다.

CPU를 많이 쓰는 작업은 to_thread로 해결되나요?

대부분 해결되지 않습니다. 파이썬의 GIL 때문에 스레드는 CPU 연산을 병렬로 가속하지 못합니다. ProcessPoolExecutor, Celery 같은 별도 워커, 또는 uvicorn --workers로 프로세스를 늘리는 방식이 필요합니다.

이벤트 루프를 막는 코드는 어떻게 찾나요?

PYTHONASYNCIODEBUG=1 환경변수로 서버를 실행하면 일정 시간 이상 루프를 점유한 코루틴이 경고로 출력됩니다. 출력된 Task 위치에서 동기 I/O나 긴 연산을 찾으면 됩니다.

관련 글: Go goroutine leak 완벽 해결 — 고루틴이 계속 늘어나는 원인과 pprof 진단법

참고 문서: FastAPI Concurrency and async / await

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

Terraform state lock 에러 해결 — Error acquiring the state lock 원인과 force-unlock 안전하게 쓰는 법

요약: Terraform의 "Error acquiring the state lock"은 다른 실행이 state lock을 잡고 있거나, 비정상 종료로 lock이 해제되지 않아 발생합니다. 먼저 Lock Info로 실행 중인 작업이 없는지 확인한 뒤 -lock-timeout으로 대기하고, 실행 주체가 종료된 경우에만 terraform force-unlock LOCK_ID로 해제하세요.

🔍 검색 키워드: Terraform state lock 에러, Error acquiring the state lock 해결, terraform force-unlock 사용법, Terraform S3 DynamoDB lock, Terraform apply 중단 lock 해제, terraform lock ID

증상: terraform plan/apply가 state lock 에러로 멈춘다

팀 단위로 Terraform을 쓰거나 CI에서 apply를 돌리다 보면, 아무 변경도 하지 않았는데 다음과 같은 에러와 함께 명령이 중단되는 경우가 있습니다. 이 에러는 원격 백엔드(S3+DynamoDB, Terraform Cloud 등)에 state lock이 남아 있어서 발생합니다.

Error: Error acquiring the state lock

Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:        3f2a9c1e-7b64-1d0e-9a3f-6c1b2d4e5f70
  Path:      my-bucket/prod/terraform.tfstate
  Operation: OperationTypeApply
  Who:       runner@ci-worker-12
  Version:   1.6.6
  Created:   2026-09-30 01:12:44 +0000 UTC

원인: 다른 실행이 진행 중이거나, 비정상 종료로 lock이 남음

Terraform은 state 파일이 동시에 수정되어 깨지는 것을 막기 위해 plan/apply 전에 lock을 잡고, 끝나면 해제합니다. lock이 남는 대표적인 경우는 다음과 같습니다.

  • 정상적인 동시 실행: 다른 팀원이나 CI 파이프라인이 실제로 apply 중인 경우
  • 비정상 종료: Ctrl+C 강제 종료, CI job 취소·타임아웃, 네트워크 단절로 unlock이 실행되지 못한 경우
  • DynamoDB 잔여 항목: S3 백엔드의 lock 테이블에 이전 실행의 LockID 항목이 그대로 남은 경우
  • 병렬 파이프라인 설계 문제: 같은 state를 쓰는 job이 동시에 트리거되도록 구성된 경우

해결방법 1: 먼저 진짜 실행 중인지 확인하기

가장 중요한 단계입니다. 에러 메시지의 Who, Created, Operation을 확인해 지금도 실제로 실행 중인 작업인지 먼저 확인하세요. 실행 중인 apply의 lock을 강제로 풀면 state가 손상될 수 있습니다. 해당 CI job이 이미 종료됐거나 담당자가 중단했음을 확인한 뒤에만 다음 단계로 넘어갑니다.

해결방법 2: 대기 옵션으로 자동 재시도

잠깐 겹친 실행이라면 lock이 풀릴 때까지 기다리게 할 수 있습니다. CI에서는 이 옵션을 기본으로 두는 것이 좋습니다.

terraform plan -lock-timeout=5m
terraform apply -lock-timeout=5m

해결방법 3: terraform force-unlock으로 안전하게 해제

실행 주체가 이미 죽었다고 확인됐다면, 에러 메시지에 나온 Lock ID로 강제 해제합니다.

terraform force-unlock 3f2a9c1e-7b64-1d0e-9a3f-6c1b2d4e5f70

Do you really want to force-unlock?
  Enter a value: yes

Terraform state has been successfully unlocked!

해결방법 4: S3 + DynamoDB 백엔드에서 lock 항목 직접 삭제

force-unlock이 실패하는 경우(권한 문제, 항목 손상 등)에는 lock 테이블의 항목을 직접 확인하고 삭제할 수 있습니다. 이 방법은 최후의 수단이며, 삭제 전에 반드시 항목 내용을 확인하세요.

aws dynamodb get-item --table-name terraform-locks \
  --key '{"LockID":{"S":"my-bucket/prod/terraform.tfstate"}}'

aws dynamodb delete-item --table-name terraform-locks \
  --key '{"LockID":{"S":"my-bucket/prod/terraform.tfstate"}}'

재발 방지: 백엔드 설정과 CI 구성

lock 자체는 문제가 아니라 안전장치입니다. 남는 lock을 줄이려면 백엔드에 lock 테이블을 명시하고, CI에서는 같은 state에 대해 job이 직렬로만 실행되도록 제한합니다.

terraform {
  backend "s3" {
    bucket         = "my-bucket"
    key            = "prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}
# GitHub Actions: 같은 state에 대한 apply를 직렬화
concurrency:
  group: terraform-prod
  cancel-in-progress: false

정리표

상황확인 방법조치
다른 실행이 진행 중Who/Created 확인, CI 로그-lock-timeout으로 대기
비정상 종료로 lock 잔존실행 주체가 종료됐는지 확인terraform force-unlock LOCK_ID
force-unlock 실패DynamoDB 항목 조회항목 확인 후 delete-item
반복 발생CI 동시 실행 여부concurrency 직렬화, timeout 설정

핵심은 lock을 풀기 전에 실행 중인 작업이 없는지 반드시 확인하는 것입니다. 확인 없이 force-unlock을 습관처럼 쓰면 두 실행이 동시에 state를 쓰게 되어 복구하기 어려운 손상이 생길 수 있습니다.

자주 묻는 질문(FAQ)

terraform force-unlock을 써도 안전한가요?

실행 중인 plan/apply가 없다는 것을 확인한 뒤에만 안전합니다. lock은 state 동시 수정을 막는 장치이므로, 작업이 살아 있는 상태에서 강제로 풀면 두 실행이 동시에 state를 써서 손상될 수 있습니다. Lock Info의 Who, Created 값과 CI 로그로 먼저 확인하세요.

Error acquiring the state lock이 계속 반복되는 이유는 무엇인가요?

CI에서 같은 state를 쓰는 job이 동시에 실행되거나, job이 취소·타임아웃되어 unlock이 실행되지 못하는 경우가 대부분입니다. concurrency 그룹으로 apply를 직렬화하고 -lock-timeout을 지정해 두면 재발을 줄일 수 있습니다.

-lock=false 옵션을 사용해도 되나요?

lock을 아예 잡지 않는 옵션이라 동시 실행 시 state가 손상될 위험이 있어 권장하지 않습니다. 읽기 전용 plan처럼 영향이 없음을 확실히 아는 경우에만 제한적으로 고려하세요.

force-unlock에 필요한 Lock ID는 어디서 확인하나요?

에러 메시지의 Lock Info 블록에 있는 ID 값입니다. 터미널 출력이 사라졌다면 DynamoDB lock 테이블 항목의 Info 필드에서도 확인할 수 있습니다.

참고 문서: terraform force-unlock · State Locking

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