워크스루 03 — PID 제어기로 1차원 운동 제어

왜 하필 PID냐면요

PID 제어기는 산업계에서 가장 널리 쓰이는 자동 제어 알고리즘입니다. 드론이 호버링을 유지하는 데도, 3D 프린터가 노즐 온도를 일정하게 잡는 데도, 자동차 크루즈 컨트롤에도, 심지어 화학 공장의 밸브 제어에도 같은 PID가 들어 있습니다. 코드 자체는 한 줄이면 끝나는데, 이득값(P, I, D) 세 개를 어떻게 잡느냐에 따라 시스템이 부드럽게 정착할 수도, 미친 듯이 진동할 수도, 영영 목표에 못 닿을 수도 있습니다.

이 워크스루에서는 가장 단순한 1차원 문제 — 질량이 있는 물체를 위치 0에서 위치 1로 옮기기 — 로 PID를 직접 시뮬레이션합니다. P를 키우면 어떻게 되는지, D는 뭘 해주는지, I는 왜 필요한지를 그래프로 확인하게 됩니다. 책으로 백 번 읽는 것보다 빠릅니다.

준비물

  • Octave 설치
  • 외부 패키지는 필요 없습니다. (control 패키지를 쓸 수도 있지만, 여기선 직접 적분으로 구현해 보는 게 학습에 더 좋습니다.)

개념 — 한 단락 요약

목표 위치 $r$, 현재 위치 $x$가 있을 때 오차 $e = r – x$를 봅니다. PID 제어기는 이 오차로부터 제어 입력 $u$(여기서는 힘)를 이렇게 만듭니다:

$$u(t) = K_p \cdot e(t) + K_i \int_0^t e(\tau) d\tau + K_d \cdot \frac{de(t)}{dt}$$

세 항의 직관은 이렇습니다:

  • P (비례) — 오차가 크면 크게 밀어라. 단순하지만 정착 시 잔류 오차가 남기 쉽습니다.
  • I (적분) — 과거 오차의 누적. 잔류 오차를 지워줍니다. 너무 크면 진동과 오버슈트를 키웁니다.
  • D (미분) — 오차의 변화율. 빠르게 다가오면 미리 제동을 겁니다. 잡음에 민감합니다.

질량 $m$에 힘 $u$가 작용하면 가속도 $a = u/m$이 되고, 이걸 시간으로 적분하면 속도, 또 적분하면 위치입니다. 이 둘을 한 루프에서 굴리면 시뮬레이션 끝입니다.

단계별로 가봅시다

1단계 — 시스템과 시뮬레이션 틀

% pid_sim.m
clear; close all;

% --- 시스템 파라미터 ---
m  = 1.0;        % 질량 [kg]
b  = 0.1;        % 마찰 계수 (속도에 비례)

% --- 시뮬레이션 파라미터 ---
dt  = 0.01;      % 시간 스텝 [s]
T   = 5.0;       % 총 시뮬레이션 시간 [s]
N   = round(T/dt);
t   = (0:N-1)' * dt;

% --- 목표 ---
r = 1.0;         % 목표 위치 (계단 입력)

% --- PID 이득 ---
Kp = 10.0;
Ki = 0.0;
Kd = 0.0;

이게 골격입니다. 시스템이 어떻게 움직이는지 정의했고, 제어할 목표(0→1로 가기)도 정했습니다.

2단계 — 시뮬레이션 루프

% 상태
x       = 0;     % 위치
v       = 0;     % 속도
e_int   = 0;     % 적분 누적
e_prev  = r - x; % 직전 오차 (미분 계산용)

% 로깅
x_log = zeros(N, 1);
u_log = zeros(N, 1);

for k = 1:N
  % 오차
  e = r - x;

  % PID 제어 입력
  e_int = e_int + e * dt;
  e_dot = (e - e_prev) / dt;
  u = Kp * e + Ki * e_int + Kd * e_dot;
  e_prev = e;

  % 시스템 동역학: m * v_dot = u - b*v
  a = (u - b * v) / m;
  v = v + a * dt;
  x = x + v * dt;

  x_log(k) = x;
  u_log(k) = u;
end

오일러 적분(가장 단순한 형태)입니다. 더 정확한 방법(룽게-쿠타 등)도 있지만 이 정도 시스템엔 충분합니다.

3단계 — 결과 그리기

figure;
subplot(2, 1, 1);
plot(t, r * ones(N, 1), 'g--', 'LineWidth', 1.5); hold on;
plot(t, x_log, 'b-', 'LineWidth', 1.5);
xlabel('시간 [s]'); ylabel('위치 [m]');
legend('목표', '실제 위치');
title(sprintf('Kp=%.1f, Ki=%.1f, Kd=%.1f', Kp, Ki, Kd));
grid on;

subplot(2, 1, 2);
plot(t, u_log, 'r-', 'LineWidth', 1.2);
xlabel('시간 [s]'); ylabel('제어 입력 (힘) [N]');
title('제어 입력');
grid on;

Kp=10, Ki=0, Kd=0의 기대 결과: 위치가 빠르게 1 근처로 올라가다가 살짝 못 미친 채 자리 잡습니다(잔류 오차). 진동이 좀 있고, 시간이 지나도 정확히 1에는 도달하지 않습니다.

4단계 — 세 이득의 효과를 하나씩 봅시다

Kp, Ki, Kd만 바꿔가며 각 케이스를 돌려보고 비교합니다.

케이스 Kp Ki Kd 무엇을 보게 되나
A 1 0 0 너무 작은 P — 응답이 굼뜸
B 50 0 0 큰 P — 진동, 오버슈트
C 10 0 5 P+D — 진동 줄어듦
D 10 5 0 P+I — 잔류 오차 사라짐, 다만 오버슈트
E 10 5 5 풀 PID — 가장 균형

케이스마다 그래프를 직접 보면, “P는 응답 속도, I는 정확도, D는 진동 억제”라는 교과서 한 줄이 살아 있는 직관으로 자리 잡습니다.

자동화하고 싶다면:

% 여러 케이스 한 번에 비교
cases = {
  '작은 P',      1,  0,  0;
  '큰 P',        50, 0,  0;
  'P+D',         10, 0,  5;
  'P+I',         10, 5,  0;
  '풀 PID',      10, 5,  5;
};

figure;
hold on;
plot(t, r * ones(N, 1), 'k--', 'LineWidth', 1.5);

for i = 1:size(cases, 1)
  Kp = cases{i, 2};
  Ki = cases{i, 3};
  Kd = cases{i, 4};

  % 위 시뮬레이션 루프를 다시 돌림 (함수로 빼면 더 깔끔)
  x = 0; v = 0; e_int = 0; e_prev = r;
  xs = zeros(N, 1);
  for k = 1:N
    e = r - x;
    e_int = e_int + e * dt;
    e_dot = (e - e_prev) / dt;
    u = Kp * e + Ki * e_int + Kd * e_dot;
    e_prev = e;
    a = (u - b * v) / m;
    v = v + a * dt;
    x = x + v * dt;
    xs(k) = x;
  end

  plot(t, xs, 'LineWidth', 1.2);
end

legend(['목표'; cases(:, 1)]);
xlabel('시간 [s]'); ylabel('위치 [m]');
title('PID 이득 변화에 따른 응답');
grid on;

다섯 곡선이 한 그림에 겹쳐 그려집니다. 시각적으로 꽤 강력한 그림입니다.

5단계 — 외란(disturbance)을 넣어봅시다

PID의 진짜 가치는 예상치 못한 외란에 적응하는 능력입니다. 시뮬레이션 중간에 갑자기 외력이 잠깐 작용한다고 해봅시다.

a = (u - b * v) / m; 줄을 이렇게 바꿉니다:

% 2초쯤에 갑자기 외력 -5 N이 0.2초 동안 작용
disturbance = 0;
if t(k) > 2.0 && t(k) < 2.2
  disturbance = -5.0;
end
a = (u - b * v + disturbance) / m;

P=10, I=5, D=5 같은 풀 PID로 돌려보세요. 2초쯤에 위치가 푹 꺼졌다가 PID가 알아서 다시 끌어올리는 모습이 보일 겁니다. 제어가 매력적인 이유가 이겁니다.

AI에게 물어보기 좋은 시점

1단계 후:

“이 시뮬레이션에서 마찰 항 -b*v는 왜 필요한가요? 마찰을 0으로 하면 어떻게 될 것 같나요? 그리고 오일러 적분이 정확도에 어떤 영향을 미치나요? 더 정확한 방법으로 바꾸면 코드가 어떻게 달라지나요?”

Kp만 크게 키운 케이스를 본 직후:

“Kp=50으로 키웠더니 위치가 1을 넘어갔다가 다시 내려오는 진동이 생겼습니다. 물리적으로 왜 일어나는 건가요? ‘너무 강한 P는 진동을 만든다’를 미분방정식 관점에서 짧게 설명해 주세요.”

Ki를 추가한 케이스 후:

“P만 쓸 때는 위치가 1에 정확히 도달 못 했는데, I를 더하니 도달합니다. 적분 항의 의미로 설명해 주세요. 그리고 ‘I 와인드업’이라는 현상이 있다던데, 무엇이고 언제 문제가 되나요?”

외란 실험 후:

“외란 실험에서 PID가 회복하는 게 인상적입니다. 회복 시간을 줄이려면 어느 이득을 어떻게 조정해야 하나요? 트레이드오프는요?”

튜닝 일반 질문:

“실제 산업에서는 PID 이득을 어떻게 정하나요? Ziegler-Nichols 방법을 이 시뮬레이션에도 적용해 보고 싶어요. 단계별로 알려주세요.”

막힐 만한 곳

  • 발산 (위치가 무한대로 감): Kp나 Kd가 너무 크면 수치 발산합니다. 시간 스텝 dt를 줄이거나(0.001 등) 이득을 낮추세요.
  • D 항이 잡음에 너무 민감: 실제 측정값에 잡음이 있으면 e_dot 계산이 폭발합니다. 이 시뮬레이션에는 잡음이 없어 괜찮지만, 실전에서는 D 항에 저역통과 필터를 겁니다.
  • 그래프가 너무 빨리/느리게 끝남: T를 조절해서 정착 과정이 잘 보이게 하세요.
  • 케이스 비교 코드가 복붙으로 길어짐: 시뮬레이션 루프를 함수로 빼면 깔끔합니다. AI에게 “이 루프를 simulate_pid(...) 함수로 빼줘”라고 하면 됩니다.

확장 아이디어

  • 목표를 시간에 따라 바꾸기: r을 상수가 아니라 사인파(r = sin(2*pi*0.5*t))로. PID가 따라갈 수 있을까요?
  • 여러 자유도: 2D 위치 제어 (x, y 각각 PID)
  • control 패키지로 같은 일 다시: tf, feedback, step으로 같은 시스템을 분석해서 시간 영역 시뮬레이션과 일치하는지 비교.
  • 자동 튜닝: Ziegler-Nichols 같은 고전 튜닝 공식 구현.
  • 비선형 시스템: 마찰을 속도의 제곱에 비례하게 바꾸거나, 액추에이터 포화(saturation)를 넣어보기.

내가 해본 기록

2026-07-26 — AI 에이전트와 함께 첫 실행

스크립트: wt03_pid.m, 그래프: wt03_pid_cases.png, wt03_pid_disturbance.png (환경: Linux + Octave 8.4, headless)

P/PI/PID 등 5개 이득 조합의 계단 응답 비교

다섯 케이스 결과 (오버슈트 / 잔류 오차 / 2% 정착 시간):

  • small P (1,0,0): 0.854 / 0.183 / 정착 못 함 — 굼뜨고 부정확합니다
  • large P (50,0,0): 0.979 / 0.519 / 정착 못 함 — 5초 내내 진동합니다
  • P+D (10,0,5): 0.012 / 0.000 / 1.21 s — 이 시스템에선 의외의 최강자
  • P+I (10,5,0): 2.560 / 2.472 / 정착 못 함 — I가 진동을 격하게 키웁니다
  • full PID (10,5,5): 0.217 / 0.020 / 4.98 s
  • 가장 의외였던 발견: 교과서는 “I가 잔류 오차를 지운다”고 하는데, 이 시스템에서는 P+D가 이미 잔류 오차 0.000을 달성합니다. 그럼 I는 왜 있는 걸까요? 알고 보니 마찰(-b·v)이 정상상태에서 힘을 요구하지 않는 시스템이라 I의 존재 이유가 약했던 겁니다. I의 가치는 5단계 외란 실험에서야 드러났습니다 — 지속적인 외란이나 부하가 있어야 I가 필요해집니다.
  • 외란 실험: 2.0~2.2초에 -5 N을 주니 기준 주행 대비 최대 -0.124 m 편차, 0.64초 만에 회복했습니다. 측정 방법에서도 배움이 있었는데요 — 시스템이 아직 정착 전이라 “목표와의 차이”로는 외란 효과가 안 보이고, 외란 없는 기준 주행과의 편차로 봐야 했습니다.
  • 확장도 해봤습니다 — 사인파 추종 + control 패키지 교차 검증 (wt03_ext_tracking.m):
  • 0.5 Hz 사인파 목표를 풀 PID로 추종시키니 정착 후 RMS 오차 0.461, 위상 지연 약 0.18 s. 고정 목표는 잘 잡던 이득이 움직이는 목표에는 한 박자 늦습니다. 추종(tracking)은 조정(regulation)과 다른 문제라는 걸 몸으로 확인했습니다.
  • 교차 검증에서 이번 실습 최고의 배움이 나왔습니다. 손으로 짠 오일러 시뮬과 control 패키지 feedback(C*G,1)의 계단 응답이 처음엔 최대 0.63이나 어긋났습니다. 어느 쪽이 버그인지 한참 찾았는데… 둘 다 맞는 코드였습니다. 원인은 미분 킥(derivative kick) — 이상적 PID는 계단 목표의 미분에서 임펄스를 만드는데, 손 시뮬레이션은 e_prev = r 초기화로 그 킥을 자기도 모르게 억제하고 있었던 겁니다. “t=0에 계단이 도착했다”로 초기화(e_prev = 0)를 맞추니 차이가 0.068로 뚝 떨어졌습니다. 같은 시스템도 초기 조건 하나로 다른 문제가 됩니다.
  • 막혔던 곳 (Octave 함정 2개): (1) 스크립트 안의 함수 정의는 MATLAB과 달리 실행 순서상 먼저 나와야 합니다 — 파일 끝에 두면 'simulate_pid' undefined. (2) legend 라벨로 'position'을 쓰면 그래프 속성 이름과 충돌해 에러가 납니다. 좀 억울했습니다.
  • 남은 확장: Ziegler-Nichols 자동 튜닝, 액추에이터 포화.

 

댓글 남기기

이 사이트는 Akismet을 사용하여 스팸을 줄입니다. 댓글 데이터가 어떻게 처리되는지 알아보세요.