투사체 물리 시뮬레이터 구현하기
언리얼 엔진 구현 데모 : https://x.com/i/status/1923483412205011161
투사체 물리 시뮬레이터 구현하기

언리얼 엔진 구현 데모 : https://x.com/i/status/1923483412205011161
개발 배경
기존 게임 엔진의 물리 시스템은 다양한 요소 — 예를 들어 프레임 타이밍, 부동소수점 연산 오차 등 — 에 영향을 받아, 동일한 입력값을 주더라도 결과가 달라지는 경우가 발생합니다. 이러한 비결정성은 특히 멀티플레이 환경에서 큰 문제가 됩니다. 예를 들어, 하나의 탄도 시뮬레이션이 서버와 클라이언트에서 서로 다른 충돌 위치나 타이밍을 생성할 경우, 판정 오류나 싱크 불일치가 생기게 됩니다. 또한, 야구 게임의 커브볼, 축구 게임의 감아 차기 등과 같은 특수한 탄도 표현이 필요한 경우, 일반적인 물리 엔진만으로는 원하는 효과를 안정적이고 정밀하게 구현하기 어렵습니다. 이러한 문제를 해결하기 위해, 언제 어디서나 동일한 입력에 대해 항상 같은 결과를 보장하는 예측 가능한 물리 시뮬레이션이 필요합니다. 이는 단순한 게임플레이뿐 아니라, 리플레이 시스템, AI 학습용 데이터 생성, 디버깅 및 QA 작업에 있어서도 매우 중요한 요소입니다.
강체(Rigid Body) 시뮬레이션
물리 시뮬레이션에서 투사체(Projectile)는 총알, 공, 화살, 수류탄 등과 같이 중력의 영향을 받으며 날아가는 물체를 의미합니다. 현실의 투사체는 외부 힘에 의해 미세한 변형이 일어나기도 하지만, 대부분의 게임 및 시뮬레이션에서는 연산 효율성을 위해 이를 무시하고 강체(Rigid Body) 모델을 사용합니다. 강체(Rigid Body)란 모양이 변하지 않는 물체를 뜻합니다. 즉, 외부에서 힘이 가해져도 형태는 변하지 않고 위치와 회전만 변하게 됩니다.
강체 시뮬레이션에서는 공이나 여러 오브젝트가 움직일 때, 위치(Position)와 회전(Rotation)을 따로 계산한 후 나중에 합성하는 방식을 자주 사용합니다. 이렇게 해도 물리 시뮬레이션이 정확한 이유는, 물리 법칙이 선형 운동과 각운동을 독립적으로 다루기 때문입니다.
선형 운동과 회전 운동은 서로 영향을 주지 않으며, (복잡한 물체나 공기 저항 같은 특수한 경우를 제외하면) 대부분의 게임 엔진은 이 두 운동을 별도의 벡터와 쿼터니언 연산으로 처리합니다.
마지막에 이 둘을 합쳐 하나의 Transform(위치 + 회전 + 스케일)으로 표현하면, 실제 움직임을 자연스럽고 효율적으로 구현할 수 있습니다.
물리 시뮬레이션의 기본 요소
투사체의 물리적 움직임을 정확하게 시뮬레이션하기 위해서는 다음과 같은 핵심 요소들을 고려해야 합니다.
질량 (Mass)
투사체가 가진 무게로, 운동의 가속도를 결정하는 기본 변수입니다. 뉴턴의 제2법칙인 F = m * a (힘 = 질량 × 가속도)에서 질량은 가속도를 산출하는 데 사용됩니다.
반경 (Radius) 투사체를 구형으로 가정할 때의 반지름입니다. 충돌 판정 및 공기 저항 계산 등에 활용됩니다.
중력 벡터 (Gravity Vector)
투사체가 받는 중력 가속도를 나타냅니다. 일반적으로 지구 중력 가속도인 980 cm/s²로 표현하며, 아래 방향을 향하는 벡터로 설정합니다.
// 속도 변화 업데이트 (중력가속도 적용)
LinearVelocity += GravityVector * DeltaTime;
위치 (Position)
- 월드 공간에서 투사체가 존재하는 좌표입니다. 시뮬레이션 시 투사체의 현재 위치를 나타내고, 매 프레임마다 업데이트됩니다.
- 위치 업데이트 공식: v = v0 + a Δt , x = x0 + v Δt
- *힘(F)**이 작용하면, 선형 가속도는
a = F / m - 중력, 마찰력 등은 모두 선형 운동에 작용하는 외력입니다.
회전 (Rotation)
- 회전은 질량 분포(관성 텐서), 각속도(ω), 각가속도(α), 토크(τ) 등을 통해 계산됩니다.
- 회전 업데이트 공식: ω = ω0 + α Δtθ = θ0 + ω Δt
- 토크(τ)가 작용하면, 각가속도는
α = τ / I
선형 속도 (Linear Velocity)
- 투사체가 움직이는 방향과 속도를 나타냅니다. 언리얼 엔진에서는 기본 단위로
cm/s를 사용합니다.
각속도 (Angular Velocity)
- 투사체의 회전 속도를 의미하며, 단위는
rad/s입니다. 회전하는 물체의 운동을 표현하는 데 필요합니다.
마그누스 효과(Magnus Effect)
- 회전하는 구체가 공기 중을 이동할 때 회전에 의해 공기 흐름이 비대칭이 되면서 횡력이 발생합니다.
- 예: 축구공의 감아차기, 탁구 스핀, 테니스 탑스핀
// 마그누스로 인한 횡력 적용
if (spinSpeed > MinSpin)
{
angularVelocity = spinAxis * spinSpeed;
FVector magnusForce = FVector::CrossProduct(-direction * speed, angularVelocity) * SpinMagnusFactor;
linearVelocity += magnusForce * StepInterval;
spinSpeed *= SpinFrictionScale;
}
관성 텐서 (Inertia Tensor)
- 물체의 질량 분포에 따른 회전에 대한 저항을 수학적으로 나타낸 행렬입니다. 강체 회전 운동의 핵심 요소로, 회전 가속도를 계산하는 데 사용됩니다.
// 구체 관성 텐서 공식 (대각행렬 성분)
// I = (2/5) * m * r^2 | m = 0.5kg (질량) | r = 0.11m (반지름)
// I = 2/5 * 0.5 * 0.11^2 = 0.00242 kg·m²
float BaseInertia = 0.4f * BallMass * BallRadius * BallRadius;
FVector ScaledInertia = FVector(BaseInertia) * InertiaTensorScale;
InvInertiaTensor.X = (ScaledInertia.X > KINDA_SMALL_NUMBER) ? (1.0f / ScaledInertia.X) : 0.0f;
InvInertiaTensor.Y = (ScaledInertia.Y > KINDA_SMALL_NUMBER) ? (1.0f / ScaledInertia.Y) : 0.0f;
InvInertiaTensor.Z = (ScaledInertia.Z > KINDA_SMALL_NUMBER) ? (1.0f / ScaledInertia.Z) : 0.0f;
항력 (Drag)
- 물체가 유체(공기, 물) 속을 움직일 때 유체가 물체의 운동을 방해하는 힘
- 속도의 제곱에 비례, 운동 방향에 반대 방향으로 작용
- 실제 물리적인 유체 저항력, 속도에 따라 비선형적으로 증가
float CD = 0.5f; // 항력 계수(튜닝)
float Rho = 0.000001225f; // 공기 밀도 1.225 kg·m⁻³ ( 1.225f / 1e6f kg·cm⁻³ )
if (speed > SMALL_NUMBER)
{
// 너무 느릴 땐 항력 생략,
FVector drag = -0.5f * Rho * CD * ballArea * speed * linearVelocity; // F_D
FVector accel = drag * InvMass; // a = F/m
linearVelocity += accel * StepInterval;
}
선형 댐핑 (Linear Damping)
- 투사체의 선형 운동 속도를 감소시키는 저항력으로, 공기 저항이나 마찰에 의해 발생하는 속도 감소를 시뮬레이션합니다.
LinearVelocity *= FMath::Clamp(1.0f - LinearDamping * DeltaTime, 0.0f, 1.0f);
회전 댐핑 (Angular Damping)
- 투사체의 회전 속도를 감소시키는 힘으로, 회전 운동의 점진적 감속을 표현합니다.
마찰 (Friction)
- 투사체가 다른 물체와 접촉할 때 발생하는 저항력입니다. 충돌 시 속도 감소와 회전 변화를 일으킵니다.
- 쿠롱 마찰 모델(Coulomb friction) : 두 표면이 맞닿아 있을 때 작용하는 마찰력의 크기와 방향을 간단하게 설명해 줍니다.
// 충돌 임펄스 (법선 반응)
const float impulseMagnitude = -(1.0f + Restitution) * vRel * InvMass;
const FVector impulse = impulseMagnitude * hit.ImpactNormal;
LinearVelocity += impulse * InvMass;
// 접선 속도
const FVector vN = FVector::DotProduct(LinearVelocity, hit.ImpactNormal) * hit.ImpactNormal;
FVector vT = LinearVelocity - vN;
if (!vT.IsNearlyZero())
{
FVector tangentDir = vT.GetSafeNormal();
float maxFrictionImpulse = Friction * impulseMagnitude;
float frictionImpulseMag = FMath::Clamp(-FVector::DotProduct(vT, tangentDir) * InvMass, -maxFrictionImpulse, maxFrictionImpulse);
FVector frictionImpulse = frictionImpulseMag * tangentDir;
LinearVelocity += frictionImpulse * InvMass;
}
탄성 (Restitution)
- 투사체와 다른 물체가 충돌할 때 반발력을 나타내는 계수입니다. 0에 가까우면 충돌 후 에너지가 거의 소멸되고, 1에 가까우면 완전 탄성 충돌로 반발력이 큽니다.
Image Source : MechanicsMap(Moore_et_al.)
// 접촉점 상대 위치 벡터 (접촉점 - 구 중심)
const FVector r = hit.ImpactPoint - pos;
// 접촉점의 상대 속도 계산
// 구의 접촉점 속도 = 구의 선형 속도 + (구의 각속도 × (접촉점 - 구의 중심))
const FVector ContactVelocity = LinearVelocity + FVector::CrossProduct(AngularVelocity, r);
// 상대 속도 노멀 방향 성분
const float vRel = FVector::DotProduct(ContactVelocity, hit.ImpactNormal);
// 충돌 임펄스 크기 계산 (질량, 관성 텐서, 회전 마찰 등 생략, 단순화)
float impulseMagnitude = -(1.0f + Restitution) * vRel;
// (선택) 충격 임펄스 클램핑으로 과도한 임펄스 방지
impulseMagnitude = FMath::Clamp(impulseMagnitude, 0.f, MaxAllowedImpulse);
// 임펄스 벡터 (법선 방향으로 impulseScalar 곱)
const FVector impulse = impulseMagnitude * hit.ImpactNormal;
바람 (Wind)
- 투사체가 날아가는 동안 받는 바람의 영향으로, 운동 방향과 속도에 추가적인 외력을 가합니다.
충돌 처리 과정 개요

The compression and expansion phases of a collision between two solid bodies — image source
- 다음 위치 계산 먼저 현재 위치와 속도를 바탕으로 Δt(델타 타임) 동안 이동할 위치를 예측합니다. 여기서는 오일러 적분법을 사용해 선형 속도와 중력 가속도를 적용하고, 감쇠(Damping)도 함께 반영합니다.
- 충돌 감지 (Sweep Test)
SweepSingleByChannel함수를 이용해 현재 위치에서 예측 위치까지 투사체의 충돌 범위(CollisionShape)를 쭉 이동시키며 언리얼의 물리 월드에 Sweep 트레이스 하여 충돌 지점(Contact Point) 을 찾고, 해당 지점에서 반사 벡터로 튕겨 나가면서 시뮬레이션합니다. - **충돌 반응 계산
- **충돌 지점에서의 위치를 조정하여 물체가 벽이나 바닥에 걸리지 않고 정확히 접촉하도록 만듭니다.
- 물리 재질(
PhysMaterial)에서 마찰력과 탄성 계수를 가져와 충돌 후 물체의 속도를 조절합니다. - 충돌 임펄스 계산을 통해 선형 속도와 회전 속도를 업데이트합니다.
- 마찰에 의한 접선 속도 감쇠와 탄성에 의한 반발 속도를 반영해 최종 속도를 산출합니다.
- 회전 방향(스핀) 속도도 마찰과 감쇠를 적용해 줄어들게 합니다.
- **서브스텝(Substep) 재귀 호출
- **충돌 후에도 남은 시간이 있을 경우, 남은 시간만큼 충돌 처리를 재귀적으로 수행해 여러 충돌을 연속적으로 안정적으로 처리합니다. 이를 통해 시간 간격이 길어도 여러 충돌이 정확히 처리되는 효과를 얻습니다.
- 게임 물리 시뮬레이션에서 Δt가 크면 한 프레임에 여러 충돌이 발생하거나, 빠른 속도로 지나치는 충돌을 놓치는 경우가 있습니다. 이를 방지하기 위해 한 프레임의 물리 시간을 여러 개의 작은 시간 간격(서브스텝)으로 나누어 여러 차례 충돌 처리를 수행합니다.
- 아래 코드에서는 재귀적으로 남은 시간을 계속 분할하며 충돌을 반복 처리하는 형태로 서브스텝 방식을 구현했습니다. 이렇게 하면 투사체가 벽을 뚫고 지나가거나 튕김 현상이 비현실적으로 발생하는 문제를 줄이고, 충돌 처리의 정확성과 안정성을 높일 수 있습니다.
int HandleCollision(World, InvMass, CollisionShape, pos, LinearVel, AngularVel, spinSpeed, DeltaTime, Depth)
{
// 무한 루프 방지
if (Depth > 10 || DeltaTime <= small_value) return Depth;
// 다음 속도 및 위치 계산 (오일러 적분)
// 속도 변화 업데이트 (중력가속도 적용)
LinearVel += Gravity * DeltaTime;
// 선형 감쇠 적용
LinearVel *= Clamp(1 - LinearDamping * DeltaTime, 0, 1);
// 다음 위치 예측
nextPos = pos + LinearVel * DeltaTime;
// 충돌 검사
hit = Sweep(World, pos, nextPos, CollisionShape);
if (hit)
{
// 충돌 시간 계산
timeToHit = DeltaTime * hit.Time;
pos += LinearVel * timeToHit;
// 물리 재질 기본값 설정
Friction = DefaultFriction;
Restitution = DefaultRestitution;
// 충돌한 물리 재질에서 속성 가져오기
if (hit.PhysMaterial)
{
Friction = hit.PhysMaterial.Friction;
Restitution = hit.PhysMaterial.Restitution;
}
// 접촉점 상대 위치 벡터
r = hit.ImpactPoint - pos;
// 접촉점 속도: 선형 + 회전 속도
vContact = LinearVel + Cross(AngularVel, r);
// 상대 속도 노멀 방향 성분
vRel = Dot(vContact, hit.Normal);
// 충돌 임펄스 크기 계산
impulseMag = -(1 + Restitution) * vRel * InvMass;
impulse = impulseMag * hit.Normal;
// 선형 속도 업데이트
LinearVel += impulse * InvMass;
// 각속도 업데이트
AngularVel += Cross(r, impulse) * InvInertiaTensor;
// 충돌 후 속도 조정 (마찰 및 탄성 반영)
vN = Dot(LinearVel, hit.Normal) * hit.Normal;
vT = LinearVel - vN;
LinearVel = -Restitution * vN + Clamp(1 - Friction, 0, 1) * vT;
// 스핀 감쇠 적용
spinSpeed *= SpinFrictionScale;
// 남은 시간 재귀 처리
remainingTime = DeltaTime - timeToHit;
return HandleCollision(World, InvMass, CollisionShape, pos, LinearVel, AngularVel, spinSpeed, remainingTime, Depth + 1);
}
else
{
// 충돌 없으면 위치 갱신
pos = nextPos;
return Depth;
}
}
시뮬레이션 결과 스냅샷 저장
정확하고 반복 가능한 물리 시뮬레이션을 위해, 물리 시뮬레이션의 결과들을 스냅샷(Snapshot) 형태로 저장하고, 이를 기반으로 리플레이(Replay) 기능을 제공합니다.
- 프레임 단위로 물리 상태 기록 시간 흐름에 따라 물체의 위치, 속도, 회전, 입력 값 등 핵심 물리 데이터를 저장합니다.
- 결정론적 물리 시뮬레이션 시뮬레이터가 동일한 초기 상태와 입력을 받을 경우 항상 같은 결과를 재현합니다.
USTRUCT(BlueprintType)
struct FBallSnapshot
{
GENERATED_BODY()
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector Position;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FQuat Rotation;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector Direction;
// 축구 기준 2000 cm/s ~ 4000 cm/s or 70 km/h ~ 145 km/h (100 cm/s = 3.6 km/h)
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
float Speed;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector SpinAxis;
// 축구 회전 킥 기준 20~90 rad/s
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
float SpinSpeed;
};
리플레이
- 저장된 스냅샷 또는 입력 시퀀스를 기반으로, 물리 시뮬레이션을 정확하게 재현할 수 있습니다.
- 슬로우 모션, 일시 정지, 프레임 단위 탐색 등이 가능하여, 디버깅이나 하이라이트 영상 생성에 유용합니다.
- 클라이언트와 서버에서 동일한 리플레이를 실행하여, 물리 동기화 오류를 손쉽게 비교 및 분석할 수 있습니다.
// 주어진 재생 시간에 맞춰 공의 위치와 회전을 계산하는 함수
void GetBallPositionAndRotationAtTime(
float playbackTime,
FVector* OutPosition,
FRotator* OutRotation)
{
// 스냅샷이 2개 미만이거나 시뮬레이션 간격이 0 이하면 초기값 반환
if (CachedSnapshotsCount < 2 || SimulationStepInterval <= 0.0f)
{
*OutPosition = FVector_ZeroVector;
*OutRotation = FRotator_ZeroRotator;
return;
}
// 전체 시뮬레이션 시간 계산
float totalDuration = (CachedSnapshotsCount - 1) * SimulationStepInterval;
// 재생 시간 범위 제한
float clampedTime = Clamp(playbackTime, 0.0f, totalDuration);
// 인덱스 계산 (현재 구간과 다음 구간)
int indexA = (int)(clampedTime / SimulationStepInterval);
int indexB = indexA + 1;
// 구간 내 보간 인자 계산 (0~1)
float localAlpha = (clampedTime - indexA * SimulationStepInterval) / SimulationStepInterval;
// 위치 보간 (선형 보간)
FVector posA = CachedSnapshots[indexA].Position;
FVector posB = CachedSnapshots[indexB].Position;
*OutPosition = Lerp(posA, posB, localAlpha);
// 회전 보간 (쿼터니언 Slerp 후 Rotator 변환)
FQuat rotA = CachedSnapshots[indexA].Rotation;
FQuat rotB = CachedSnapshots[indexB].Rotation;
FQuat slerpedRot = Slerp(rotA, rotB, localAlpha);
*OutRotation = QuatToRotator(slerpedRot);
}
스포츠 게임 응용 사례
- 탁구 — 백스핀 공의 양력이 아래로 작용 → 공이 천천히 떨어지고 상대방 코트에 떨어질 때 튀는 각도도 작아짐 상대가 받기 어렵고, 뒤로 밀리는 느낌 작은 공, 가벼운 질량 덕분에 미세한 스핀 변화도 궤적에 크게 반영됨
- 테니스 — 탑스핀 (Topspin) 공에 전방향 회전을 걸어 공이 빠르게 떨어지도록 하는 샷 주로 베이스라인 근처에서 많이 사용 공은 빠르게 이동하면서도 네트 너머 코트 안으로 정확히 떨어짐 강한 스핀이 바운스 후 공을 앞으로 크게 튀게 만들어 공격적인 플레이 가능
- 테니스 — 슬라이스 (Slice) 공에 뒷회전(백스핀)을 주어 낮게 뜨고 천천히 튀는 샷 상대가 타이밍 맞추기 어렵고, 공이 상대 코트에서 낮게 바운스 방어, 변화구, 네트 앞에서의 드롭샷으로 활용 속도는 느리지만 궤적이 낮고 공이 떠오르는 각도가 작음
- 축구 — 감아차기 (Curve kick) 공에 옆 방향 마그누스 힘이 발생해 커브(휘는 궤적)를 만듦 질량이 크지만 스핀과 속도가 크기 때문에 충분히 휘어짐 골키퍼가 예측하기 어려운 궤적 형성 가능
- 야구 — 커브볼 (Curveball) 주로 수직축과 수평축 사이로 강한 스핀이 걸림 (앞쪽으로 떨어지는 탑스핀과 비슷) 공의 스핀 축과 속도 벡터의 수직 방향으로 양력이 작용하여 공이 곡선으로 휘어짐 공이 타자에게 다가오면서 크게 휘어져 떨어지는 것처럼 보임
- 야구 — 슬라이더 (Slider) 커브볼과 달리 공이 더 옆으로 회전하며 빠른 속도로 던져짐 옆으로 강한 힘이 작용해 공이 옆으로 급격히 휘어짐 타자가 예상한 직선 궤적에서 좌우로 급격히 빗나가 타격이 어렵게 됨
- 야구 — 포크볼 (Forkball) 스핀이 적거나 약하게 앞쪽 회전 (탑스핀과 같은 방향) 타자가 공이 떠오른다 예상하다가 급격히 떨어지는 착시 효과
- 야구 — 패스트볼(4-심) 뒤쪽 회전(백스핀) , 중력에 맞서는 양력이 생겨 낙하 속도가 줄어듦 타자 눈에는 “붕 뜨는” 느낌, 의외로 덜 떨어져 방망이 위쪽을 스치기 쉬움
스포츠 볼 종류별 특성
질량이 작고 반지름이 작을수록 동일한 스핀에서 가속도 (= 궤적 변화) 가 크다. 표면 구조도 마그누스 계수와 항력계수를 바꿔 궤적에 큰 영향.
- 탁구공 ≈ 40 mm , 2.7 g 매우 작고 가벼워 스핀 변화가 곧바로 궤적에 반영 깎아치기(백스핀), 드라이브
- 야구공 ≈ 73 mm , 145 g 마그누스 힘 크지만 질량도 커서 중간 정도 휨 커브, 슬라이더, 포크볼..
- 골프공 ≈ 43 mm 45 g 딤플 + 강스핀 → 적절한 양력·비거리·커브 드로우/페이드, 백스핀 어프로치
- 축구공 ≈ 220 mm , 410–450 g 큰 크기+무게 → 부드러운 곡선, 공기저항 큼 프리킥, 감아차기, 무회전 슛
- 테니스공 ≈ 67 mm , 57 g 중간 크기·질량, 스핀/속도 균형 좋음 탑스핀, 슬라이스, 로브
- 농구공 ≈ 240 620–650 g 질량 커서 스핀에 의한 공중 곡선은 작음, 대신 백스핀으로 림 터치 시 감속·리바운드 제어 점프슛 백스핀, 훅슛
- 배구공 ≈ 210 260–280 g 비교적 가벼워 무회전(플러터) 시 공기 난류로 급변 서브·스파이크 플러터, 탑스핀 서브
- 크리켓볼 ≈ 73 155–163 g 솔기(seam) 비대칭과 스핀으로 스윙/커터 궤적 인스윙·아웃스윙, 레그 스핀
- 럭비 아메리칸 풋볼 장축 ≈ 235~245 단축 ≈ 185 ~197 410–460 g 타원체(럭비공)는 스파이럴로 회전축을 속도벡터에 맞춰 마그누스·항력을 최소화해 직진성을 높임 스파이럴 회전 시 공기저항 최소화, 무회전 패스는 플러터·마그누스 혼합 스파이럴 패스, 푸트펀트
- 볼링공 ≈ 218 mm , 5.4–7.2 kg 매우 무거워 공중 마그누스 미미, 레인 마찰·코어 편심 스핀으로 곡선 훅볼, 백업볼
메타데이터
- post_id
- caa09b00f62d
- slug
- 커스텀-볼-물리-시뮬레이터-구현하기-caa09b00f62d
- url
- https://medium.com/@3devnote/%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%B3%BC-%EB%AC%BC%EB%A6%AC-%EC%8B%9C%EB%AE%AC%EB%A0%88%EC%9D%B4%ED%84%B0-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-caa09b00f62d
- canonical_url
- https://medium.com/@3devnote/%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%B3%BC-%EB%AC%BC%EB%A6%AC-%EC%8B%9C%EB%AE%AC%EB%A0%88%EC%9D%B4%ED%84%B0-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-caa09b00f62d
- author_url
- https://medium.com/@3devnote
- status
- ok
- fetched_at
- 2026-07-19 20:53:55