pytest xdist session fixture 여러 번 실행될 때

3줄 요약
- pytest-xdist는 워커마다 따로 수집하고 따로 실행하기 때문에,
scope="session"픽스처가 워커 수만큼 반복 실행됩니다. - 테스트 24개·준비 1.5초 기준으로 실측하니 실행 시간은
-n 01.62초에서-n 81.85초로 거의 같았고, 실제 피해는 로그인·시드·외부 API 호출이 8번 일어나는 부작용이었습니다. - 공식 FileLock 레시피로 준비를 1회로 줄일 수 있지만
yieldteardown을 붙이면 준비가 다시 3번 돌았으니, teardown 없는return형태로 먼저 붙여 보시면 됩니다.
병렬 실행을 켜는 일은 명령줄 한 글자 추가로 끝납니다. pytest에 -n 8만 붙이면 워커 여덟 개가 뜨고, 테스트가 나뉘어 돌고, 전체 시간이 줄어듭니다. 문제는 그다음 주에 생깁니다. 스테이징 계정의 로그인 로그가 갑자기 여덟 배로 늘어나고, 시드 데이터를 넣는 스크립트가 같은 사용자 레코드를 여러 번 만들려다 유니크 제약에 걸리고, 외부 결제 샌드박스가 분당 호출 한도를 넘겼다며 429를 던집니다. 저희 팀에서는 이 증상이 "병렬을 켜니 테스트가 플레이키해졌다"는 말로 보고됐습니다. 플레이키가 아니었습니다.
원인은 한 줄로 정리됩니다. scope="session"이라고 적어 둔 픽스처가 한 번이 아니라 워커 수만큼 실행됩니다. 여기서 선택지는 세 갈래입니다. 그냥 워커마다 도는 것을 받아들이고 준비 작업을 워커별로 격리하는 길, 분배 방식을 바꿔서 같은 그룹이 한 워커에 모이게 하는 길, 잠금 파일로 준비를 딱 한 번만 실행시키는 길입니다.
결론을 먼저 적습니다. 이 문제는 "느려지는 문제"가 아닙니다. 제가 준비 작업 1.5초·테스트 24개로 직접 재 보니 벽시계 시간은 단일 프로세스 1.62초와 워커 여덟 개 1.85초로 사실상 같았습니다. 준비 작업이 병렬로 동시에 돌기 때문입니다. 그래서 시간을 아끼려고 손댈 이유는 없고, 손대야 하는 이유는 부작용이 여덟 번 일어난다는 것 하나입니다. 그리고 공식 문서의 잠금 파일 레시피는 그 부작용을 1회로 줄여 주지만, 거기에 정리 코드를 붙이는 순간 준비가 다시 여러 번 실행되는 역효과가 났습니다. 아래는 그 과정을 순서대로 적은 기록입니다.
1. 워커는 각자 수집하고 각자 실행합니다
픽스처 스코프 다섯 단계가 뜻하는 범위
pytest의 스코프는 다섯 개뿐입니다. 공식 문서의 픽스처 스코프 절은 각각의 파괴 시점을 이렇게 정의합니다. function은 테스트 하나가 끝나면, class는 클래스의 마지막 테스트 정리 시점에, module은 모듈의 마지막 테스트 정리 시점에, package는 그 패키지와 하위 디렉터리의 마지막 테스트 정리 시점에, session은 테스트 세션이 끝날 때 파괴됩니다.
여기서 놓치기 쉬운 단어가 "세션"입니다. 저는 오랫동안 세션을 "제가 엔터를 한 번 눌러 시작한 실행 전체"로 읽었습니다. 단일 프로세스에서는 그 해석이 맞습니다. 병렬에서는 틀립니다.
| 스코프 | 파괴 시점(공식 문서 표현) | 병렬에서 실제 실행 횟수 |
|---|---|---|
function |
테스트가 끝날 때 | 테스트 수만큼 (변화 없음) |
class |
클래스의 마지막 테스트 정리 시점 | 클래스가 쪼개지면 그 이상 |
module |
모듈의 마지막 테스트 정리 시점 | 모듈이 쪼개지면 그 이상 |
package |
패키지·하위 디렉터리의 마지막 테스트 정리 시점 | 패키지가 쪼개지면 그 이상 |
session |
테스트 세션이 끝날 때 | 워커 수만큼 |
워커 프로세스가 스코프를 처음부터 다시 시작합니다
pytest-xdist 문서는 이 성질을 감추지 않고 명시합니다. session 스코프 픽스처를 한 번만 실행하기 절의 첫 문장이 "pytest-xdist는 각 워커 프로세스가 자기 수집을 수행하고 전체 테스트의 부분집합을 실행하도록 설계되었다"입니다. 이어서 서로 다른 프로세스의 테스트가 높은 스코프의 픽스처를 요청하면 픽스처 코드가 한 번 이상 실행되며, 이는 기대를 깨뜨린다고 적혀 있습니다. 즉 버그가 아니라 설계입니다.
제 해석으로는 이 문장이 자동화 아키텍처의 판단 기준을 하나 만들어 줍니다. 워커는 같은 저장소를 공유하는 별개의 실행 주체이므로, 워커 사이에 공유되어야 하는 것은 픽스처가 아니라 파일이나 데이터베이스처럼 프로세스 밖에 있는 무언가여야 합니다. 픽스처 캐시는 프로세스 안에 있습니다.
실측: 준비 작업이 워커 수만큼 늘어납니다
말로만 두면 감이 오지 않으니 최소 재현을 만들었습니다. 모듈 네 개에 테스트 여섯 개씩 스물네 개를 두고, session 픽스처와 module 픽스처가 각각 실행될 때마다 워커 아이디와 프로세스 아이디를 파일에 기록하게 했습니다. 준비 작업의 무게는 time.sleep(1.5)로 흉내 냈습니다. 로그인 API 왕복이나 시드 데이터 생성이 대체로 이 정도 걸립니다.
# conftest.py — 워커별 실행 횟수를 세기 위한 최소 재현
import os, time
import pytest
LOG = "/tmp/demo/fixture_calls.log"
def _log(kind, worker_id):
# worker_id는 pytest-xdist가 제공하는 픽스처입니다. -n0에서는 "master"가 들어옵니다
with open(LOG, "a") as f:
f.write(f"{kind}\t{worker_id}\t{os.getpid()}\n")
@pytest.fixture(scope="session")
def api_token(worker_id):
_log("session:api_token", worker_id)
time.sleep(1.5) # 로그인 API 왕복을 흉내 낸 지연
return f"token-{worker_id}"
@pytest.fixture(scope="module")
def seeded_user(worker_id):
_log("module:seeded_user", worker_id)
return f"user-{worker_id}"
단일 프로세스에서는 session:api_token이 한 줄, module:seeded_user가 네 줄 기록됐습니다. 예상대로입니다. -n 4로 바꾸자 session:api_token이 네 줄로 늘어났고, 프로세스 아이디가 전부 달랐습니다. gw0, gw1, gw2, gw3이 각자 자기 토큰을 만든 것입니다. -n 8에서는 여덟 줄이었습니다.
이 기록에서 저는 worker_id 픽스처를 쓰는 습관을 하나 얻었습니다. 워커별로 달라야 하는 값은 픽스처 안에서 worker_id를 문자열에 섞어 만들면 충돌이 사라집니다. 문서도 계정 분리 예시를 같은 방식으로 제시합니다. -n0으로 병렬을 끄면 worker_id가 "master"가 되므로 분기 한 줄로 두 경우를 모두 처리할 수 있습니다.
2. 시간은 거의 그대로였고 부작용만 늘었습니다
실행 시간 실측
같은 저장소에서 워커 수만 바꿔 벽시계 시간을 재고, 준비 작업이 몇 번 실행됐는지 함께 셌습니다. Python 3.10.12, pytest 9.1.1, pytest-xdist 3.8.0, 물리 코어 4개인 Ubuntu 22.04 컨테이너입니다.
| 실행 | 벽시계 시간 | 준비 작업 실행 횟수 |
|---|---|---|
-n 0 (병렬 없음) |
1.62초 | 1회 |
-n 4 |
1.76초 | 4회 |
-n 8 |
1.85초 | 8회 |
-n 4 + 잠금 파일 |
1.87초 | 1회 |
-n 8 + 잠금 파일 |
2.19초 | 1회 |
숫자를 처음 봤을 때 저는 표를 다시 만들었습니다. 준비 작업을 여덟 번 하는데 왜 시간이 0.23초밖에 늘지 않는지 이해가 되지 않았기 때문입니다. 이유는 단순했습니다. 여덟 번이 순서대로 도는 게 아니라 여덟 프로세스에서 동시에 돕니다. 그래서 벽시계로는 한 번 값에 프로세스 기동 비용만 더해집니다.
첫 번째 함정: 시간을 근거로 판단하기
여기가 첫 번째 함정입니다. 병렬 도입 검토서를 쓸 때 우리는 대체로 시간만 봅니다. 시간만 보면 이 문제는 존재하지 않는 것처럼 보입니다. 심지어 잠금 파일로 준비를 1회로 줄이면 시간이 오히려 늘어납니다. 위 표에서 -n 8은 1.85초였는데 잠금 파일을 붙인 쪽은 2.19초였습니다. 먼저 잠금을 잡은 워커가 준비를 끝낼 때까지 나머지 일곱이 기다리기 때문입니다. 최적화라고 생각하고 넣은 코드가 시간 지표를 나쁘게 만듭니다.
그래서 판단 근거를 시간에서 부작용 횟수로 옮겨야 합니다. 저는 지금 이 항목을 자동화 리뷰 체크리스트에 "높은 스코프 픽스처가 외부에 남기는 흔적이 몇 개인가"라는 질문으로 넣어 두었습니다.
부작용이 실제로 어디서 터지는가
| 준비 작업 유형 | 워커 8개일 때 실제로 일어나는 일 | 터지는 지점 |
|---|---|---|
| 로그인해서 토큰 발급 | 같은 계정으로 8회 로그인 | 동시 세션 제한, 이전 토큰 무효화 |
| 시드 사용자 생성 | 같은 이메일로 8회 삽입 시도 | 유니크 제약 위반 |
| 외부 샌드박스 호출 | 분당 호출 8배 | 429 rate limit |
| 테스트 DB 마이그레이션 | 8개 프로세스가 같은 스키마에 동시 적용 | 락 대기, 부분 적용 |
| 파일·디렉터리 초기화 | 한 워커가 지우는 동안 다른 워커가 읽음 | 간헐 실패 |
이 표의 마지막 줄이 뒤에 나올 가장 비싼 함정과 이어집니다.
3. 분배 방식을 바꾸면 모듈 스코프가 먼저 회복됩니다
기본값은 순서를 보장하지 않습니다
분배 옵션 문서에 따르면 --dist load가 기본값이고, 대기 중인 테스트를 순서 보장 없이 남는 워커에 보냅니다. 그래서 같은 모듈의 테스트가 여러 워커로 흩어지고, 모듈 스코프 픽스처가 모듈 수보다 많이 실행됩니다.
loadscope는 테스트 함수는 모듈 단위로, 테스트 메서드는 클래스 단위로 묶어 그룹을 통째로 한 워커에 보냅니다. 클래스 기준이 모듈 기준보다 우선합니다. loadfile은 파일 단위로 묶습니다. loadgroup은 xdist_group 마크가 같은 테스트를 한 워커에 모으므로, 브라우저 종류처럼 파라미터가 그룹을 결정하는 경우에 적합합니다. worksteal은 처음에 균등 분배한 뒤 여유가 생긴 워커가 다른 워커의 큐에서 테스트를 가져오는 방식으로, 소요 시간 편차가 큰 스위트에서 load보다 낫고 픽스처 재사용도 비슷하거나 더 좋다고 문서가 적고 있습니다.
실측: 모듈 픽스처 15회에서 4회로
모듈 네 개, 테스트 스물네 개, 워커 네 개로 분배 모드만 바꿔 봤습니다.
--dist |
session 픽스처 실행 | module 픽스처 실행 |
|---|---|---|
load (기본) |
4회 | 15회 |
loadfile |
4회 | 4회 |
loadscope |
4회 | 4회 |
worksteal |
4회 | 4회 |
모듈이 네 개인데 모듈 픽스처가 열다섯 번 돌았습니다. 테스트가 흩어질 때마다 픽스처가 다시 만들어졌다는 뜻입니다. 분배 모드를 loadfile로만 바꿨더니 네 번으로 떨어졌습니다. 명령줄 한 줄이고, 코드를 고칠 필요가 없습니다.
주의할 점은 session은 어떤 모드에서도 4회로 유지된다는 것입니다. 분배 방식은 한 워커 안에서의 묶음만 조정하므로, 워커 경계를 넘는 문제는 해결하지 못합니다. 그래서 순서는 이렇게 잡는 편이 낫습니다. 먼저 loadfile이나 loadscope로 모듈·클래스 스코프의 낭비를 없애고, 그래도 남는 session 스코프만 다음 장의 방법으로 처리하는 것입니다.
; pytest.ini — 팀 기본값을 파일에 박아 두면 로컬과 CI가 갈라지지 않습니다
[pytest]
addopts = -n auto --dist loadfile
-n auto는 물리 코어 수를 씁니다. 논리 코어를 쓰려면 -n logical이고 이때는 psutil이 필요합니다. CI 러너에서 코어 수와 무관하게 고정하고 싶으면 PYTEST_XDIST_AUTO_NUM_WORKERS 환경변수나 pytest_xdist_auto_num_workers 훅으로 값을 정할 수 있으며, 둘을 같이 쓰면 훅이 우선합니다.
4. 한 번만 실행하게 만드는 방법과 가장 비싼 함정
공식 레시피는 잠금 파일입니다
xdist에는 session 픽스처를 정확히 한 번 실행하는 내장 기능이 없습니다. 문서는 대신 파일 잠금으로 프로세스 간 통신을 하는 방법을 제시합니다. 먼저 잠금을 잡은 워커가 데이터를 만들어 파일에 쓰고, 나머지는 그 파일을 읽습니다. 핵심은 tmp_path_factory.getbasetemp().parent로 워커 전체가 공유하는 상위 임시 디렉터리를 얻는 부분입니다. 워커별 임시 디렉터리는 서로 다르지만 그 부모는 같습니다.
# conftest.py — 공식 문서 레시피를 그대로 옮긴 형태 (teardown 없음)
import json, os, time
import pytest
from filelock import FileLock
def _produce_token():
# 실제로는 로그인 API 호출이나 시드 데이터 생성이 들어갑니다
time.sleep(1.5)
return {"token": "abc123", "pid": os.getpid()}
@pytest.fixture(scope="session")
def api_token(tmp_path_factory, worker_id):
if worker_id == "master":
# 병렬이 아닐 때는 pytest 픽스처 캐시가 알아서 1회로 처리합니다
return _produce_token()
root = tmp_path_factory.getbasetemp().parent # 워커 전체가 공유하는 상위 디렉터리
fn = root / "token.json"
with FileLock(str(fn) + ".lock"): # 먼저 잡은 워커만 생성합니다
if fn.is_file():
data = json.loads(fn.read_text())
else:
data = _produce_token()
fn.write_text(json.dumps(data))
return data
-n 4와 -n 8 모두에서 준비 작업이 정확히 1회 실행됐습니다. 앞의 실측표에 이미 적은 대로 시간은 조금 늘어납니다. 부작용 1회를 시간 0.3초로 사는 거래라고 보시면 됩니다.
가장 비싼 함정: 정리 코드를 붙이면 준비가 다시 돕니다
여기서 제가 실제로 시간을 가장 많이 버렸습니다. 위 레시피에는 정리 코드가 없습니다. return으로 끝납니다. 저는 만들어 둔 토큰 파일을 세션이 끝날 때 지우는 게 위생적이라고 생각해서 yield로 바꾸고 뒤에 fn.unlink()를 붙였습니다. 이렇게 하면 정리가 워커마다 실행됩니다. 그리고 워커들의 종료 시점은 어긋납니다.
워커별 소요 시간을 일부러 다르게 만든 뒤 -n 4로 돌려서 이벤트를 세어 봤습니다.
| 이벤트 | 실행 횟수 | 뜻 |
|---|---|---|
setup (준비 작업) |
3회 | 1회로 줄이려고 넣은 잠금이 무력화됐습니다 |
teardown |
4회 | 워커마다 한 번씩, 설계대로입니다 |
deleted (토큰 파일 삭제) |
3회 | 아직 실행 중인 워커의 발밑에서 삭제됐습니다 |
먼저 끝난 워커가 정리 단계에서 공유 파일을 지웁니다. 그러면 뒤늦게 픽스처를 요청한 워커는 파일이 없다고 판단해 준비 작업을 다시 실행합니다. 잠금은 정상 동작했지만 잠금이 지키는 대상이 사라졌으니 의미가 없어진 것입니다. 만약 그 준비 작업이 토큰 발급이 아니라 시드 데이터 생성이었다면, 다른 워커가 그 데이터를 읽는 중에 지워지면서 간헐 실패로 나타납니다. 병렬을 켠 뒤 원인을 못 찾던 플레이키의 정체가 이 구조였던 적이 있습니다.
# 이렇게 하지 마십시오 — 공유 자원을 워커 정리 단계에서 지우는 형태
@pytest.fixture(scope="session")
def api_token(tmp_path_factory, worker_id):
root = tmp_path_factory.getbasetemp().parent
fn = root / "token.json"
with FileLock(str(fn) + ".lock"):
data = json.loads(fn.read_text()) if fn.is_file() else _create(fn)
yield data
fn.unlink(missing_ok=True) # ← 다른 워커가 아직 쓰고 있어도 지웁니다
대응은 두 가지입니다. 첫째, 공유 자원의 정리는 픽스처에서 하지 않고 CI 잡의 마지막 단계나 pytest_sessionfinish의 컨트롤러 쪽에서만 하는 것입니다. xdist.is_xdist_controller()로 컨트롤러를 판별할 수 있습니다. 둘째, 애초에 지울 필요가 없게 만드는 것입니다. 임시 디렉터리는 pytest가 알아서 회수하고, 테스트 데이터는 실행마다 고유한 이름을 붙이면 남아도 충돌하지 않습니다.
실행마다 고유한 이름이 필요할 때
xdist는 testrun_uid 픽스처와 PYTEST_XDIST_TESTRUNUID 환경변수를 제공합니다. 실행 하나를 다른 실행과 구분하는 값이라 데이터베이스 이름이나 시드 레코드 접두어에 붙이기 좋습니다. 워커 단위로 나눠야 하는 값에는 worker_id, 로그 파일처럼 프로세스 단위로 갈라야 하는 것에는 PYTEST_XDIST_WORKER를 씁니다. 워커 총수는 PYTEST_XDIST_WORKER_COUNT에 들어 있어 계정 풀 크기를 맞출 때 쓸 수 있습니다.
5. 도입 난이도와 유지보수 비용
세 방법 비교
| 항목 | --dist loadfile로 묶기 |
잠금 파일로 1회 실행 | 워커별로 격리 |
|---|---|---|---|
| 도입 난이도 | 낮음. 옵션 한 줄 | 중간. conftest 20줄 + filelock 의존성 | 중간. 계정·데이터 풀 준비가 필요 |
| 유지보수 비용 | 낮음 | 높음. 잠금·정리·타임아웃을 계속 신경 써야 합니다 | 중간. 풀 고갈과 정리 관리 |
| 팀 학습곡선 | 하루. 옵션 의미만 알면 됩니다 | 3~5일. 프로세스 간 통신 개념이 필요합니다 | 2~3일 |
| 해결 범위 | 모듈·클래스 스코프만 | session 스코프까지 | session 스코프까지 |
| QA 4~6명 팀 권장도 | 먼저 하십시오 | 부작용이 실제로 문제일 때만 | 계정을 넉넉히 만들 수 있으면 좋습니다 |
자동화가 초기 단계인 팀이라면 순서를 지키는 편이 낫습니다. 옵션 한 줄로 얻을 수 있는 것을 먼저 얻고, 그다음에 코드가 늘어나는 방법으로 넘어가는 것입니다. 잠금 파일 레시피는 문서에도 "모든 경우에 통하지는 않지만 출발점은 된다"는 단서가 붙어 있습니다. 저는 이 단서를 "이 코드는 우리 팀 저장소에서 계속 손이 가는 코드가 된다"는 뜻으로 읽습니다.
수집 결과가 워커마다 같아야 합니다
병렬을 켜고 나면 예전에 없던 오류가 하나 더 생깁니다. 알려진 제약 문서는 테스트의 순서와 개수가 워커 사이에서 일치해야 한다고 못 박습니다. 대표적인 사고가 set으로 파라미터를 넘기는 코드입니다. 집합은 순서를 보장하지 않으니 워커마다 다른 순서로 수집합니다. 직접 재현해 보니 이런 오류가 났습니다.
==================================== ERRORS ====================================
_____________________________ ERROR collecting gw1 _____________________________
Different tests were collected between gw0 and gw1. The difference is:
--- gw0
+++ gw1
@@ -1,7 +1,7 @@
-test_unordered.py::test_unordered[e]
test_unordered.py::test_unordered[g]
-test_unordered.py::test_unordered[f]
+test_unordered.py::test_unordered[c]
해법은 문서가 그대로 알려 줍니다. 리스트로 바꾸거나 sorted()로 감싸면 됩니다. 딕셔너리 키나 제너레이터를 파라미터로 넘기는 코드도 같은 눈으로 다시 보시는 편이 좋습니다.
같은 문서에 적힌 제약 두 개가 디버깅 습관도 바꿉니다. -s(--capture=no)는 xdist에서 동작하지 않습니다. 워커의 표준 출력을 전달하는 기능이 통신 계층에 없기 때문이며, 지원 계획도 없다고 적혀 있습니다. 같은 이유로 --pdb는 분배 중에는 비활성화됩니다. 그래서 실패한 테스트를 찾는 단계까지는 병렬로, 원인을 파는 단계는 -n0으로 나누는 것이 문서가 권하는 방식입니다. 팀 규칙으로 만들어 두면 신규 입사자가 헤매는 시간이 줄어듭니다.
6. 권할 만한 경우 / 권하지 않는 경우
권할 만한 경우는 이렇습니다. 스위트에 준비 비용이 큰 픽스처가 있고 그 준비가 외부 시스템에 흔적을 남기는 경우, 즉 로그인·시드·마이그레이션·외부 샌드박스 호출이 걸려 있는 경우입니다. 이때는 부작용 횟수가 곧 사고 확률이니 잠금 파일까지 가는 값이 있습니다. 모듈이나 클래스 단위 픽스처가 무거운 경우도 권합니다. --dist loadfile 한 줄로 열다섯 번이 네 번으로 줄어드는 종류의 개선이라 비용이 거의 없습니다.
권하지 않는 경우도 분명합니다. 준비 작업이 순수 계산이거나 프로세스 안에서만 끝나는 경우에는 워커마다 반복돼도 실질적인 손해가 없으니 잠금 파일을 넣을 이유가 없습니다. 테스트 스위트가 아직 백 개 미만이고 전체 실행이 1분 안에 끝난다면 병렬 자체를 미루는 편이 낫습니다. 병렬은 실행 시간을 줄이는 대신 진단 난이도를 올립니다. 그리고 픽스처에서 공유 자원을 지우는 정리 코드가 이미 여러 개 있는 저장소라면, 병렬을 켜기 전에 그 정리 코드부터 걷어내야 합니다. 순서를 거꾸로 하면 원인을 찾기 어려운 간헐 실패를 얻게 됩니다.
마지막으로 병렬을 켠 뒤 30분 안에 확인할 수 있는 점검 스크립트를 남깁니다. 준비 작업이 몇 번 실행되는지 세는 것이 전부이고, 그 숫자가 워커 수와 같으면 이 글의 이야기가 그대로 적용됩니다.
#!/usr/bin/env bash
# 높은 스코프 픽스처가 워커마다 반복 실행되는지 확인합니다
# 사전 준비: conftest.py의 해당 픽스처 첫 줄에 아래 한 줄을 넣습니다
# open("/tmp/fx.log","a").write("setup\n")
set -euo pipefail
for N in 0 4 8; do
: > /tmp/fx.log
START=$(date +%s)
pytest -q -n "$N" > /dev/null 2>&1 || true
echo "워커 $N개 | 소요 $(( $(date +%s) - START ))초 | 준비 실행 $(wc -l < /tmp/fx.log)회"
done
# 판정
# 준비 실행이 워커 수와 같다 → 부작용이 워커 수만큼 발생합니다. 4장을 적용하십시오
# 준비 실행이 모듈 수를 넘는다 → --dist loadfile 로 먼저 줄이십시오
# 소요 시간이 거의 같다 → 시간을 근거로 개선을 정당화하지 마십시오
모바일 테스트를 병렬로 돌릴 때 생기는 포트 충돌은 Appium 병렬 실행 포트 설정 글에 정리했습니다. 로그인 상태를 여러 테스트가 나눠 쓰는 방법은 Playwright storageState로 로그인을 재사용하는 글에서 다뤘습니다.
댓글
댓글 쓰기