Skip to main content
Summary

로봇은 실패하지 않는 기계가 아니다

로봇팔이 테이블 위의 컵을 집어 오른쪽 트레이에 놓는 장면을 떠올려 보자. 데모 영상에서는 동작이 매끄럽다. 카메라가 컵을 찾고, 로봇팔이 다가가고, 그리퍼가 컵을 집고, 정확히 목표 위치에 내려놓는다. 마치 한 번 계획하면 그대로 성공하는 것처럼 보인다.

하지만 실제 로봇 앞에 서 보면 이야기가 달라진다. 컵은 생각보다 미끄럽고, 조명은 계속 바뀌며, 카메라는 물체의 가장자리를 조금씩 다르게 본다. 로봇팔이 움직이는 동안 테이블이 흔들릴 수도 있고, 그리퍼 손가락이 컵을 살짝 밀어 위치를 바꿀 수도 있다. 경로 계획은 성공했지만 실행 중 관절이 제한에 가까워져 멈출 수 있고, 물체를 집었다고 생각했는데 들어 올리는 순간 바닥에 그대로 남아 있을 수도 있다.

그래서 로봇팔 작업에서 중요한 질문은 "어떻게 한 번에 성공할 것인가?"가 아니다. 더 중요한 질문은 "실패했을 때 로봇이 그것을 알아차릴 수 있는가?"이다.

사람은 물건을 집다가 실패하면 즉시 안다. 손끝에 아무 느낌이 없으면 헛집었다고 판단하고, 물체가 기울어지면 잡는 위치를 바꾸며, 내려놓은 물체가 목표 지점에서 벗어나면 손으로 다시 밀어 맞춘다. 로봇도 이와 비슷한 능력이 필요하다. 단지 로봇은 손끝의 감각 대신 센서 값, 그리퍼 폭, 힘, 카메라 이미지, 경로 계획 결과, 실행 상태를 읽어야 한다.

이번 장에서는 Pick and Place 작업에서 자주 발생하는 실패를 감지하고, 그 실패에 맞는 재시도 전략을 설계한다. 이 능력은 이후 LLM 로봇 에이전트로 이어질 때 특히 중요하다. LLM이 "빨간 컵을 집어 오른쪽에 놓아줘"라고 계획을 만들더라도, 실제 세계에서는 언제든 실패가 발생한다. 이때 하위 로봇 스킬이 실패 원인을 명확히 알려 주어야 상위 에이전트가 다시 계획할 수 있다.

실패를 감지하는 로봇

실패를 하나의 결과로 다루기

이전 장에서 Pick and Place 함수는 다음과 같은 반환값을 사용했다.

return {
    'success': False,
    'stage': 'grasp',
    'reason': 'empty_grasp_suspected'
}

이 구조는 아주 중요하다. 로봇 소프트웨어에서 실패는 예외적으로 터지는 사고가 아니라, 정상적으로 다루어야 하는 실행 결과다. "성공 또는 실패"만으로는 부족하다. 실패가 어느 단계에서 발생했는지, 왜 발생했는지, 다시 시도할 수 있는지까지 알아야 한다.

예를 들어 세 가지 실패를 비교해 보자.

{'success': False, 'stage': 'detect', 'reason': 'object_not_found'}
{'success': False, 'stage': 'grasp', 'reason': 'empty_grasp_suspected'}
{'success': False, 'stage': 'move_to_place', 'reason': 'collision_risk'}

세 결과는 모두 실패지만 대응은 완전히 다르다. 물체를 찾지 못했다면 카메라를 다시 보거나 조명을 바꾸어야 한다. 빈 공간을 집었다면 물체 위치를 다시 검출하고 다른 잡기 후보를 선택해야 한다. 충돌 위험이 있다면 같은 동작을 반복해서는 안 되며, 경로를 바꾸거나 사람에게 주변 장애물을 치워 달라고 요청해야 한다.

따라서 로봇팔 스킬은 내부적으로 항상 다음 질문을 던져야 한다.

  1. 지금 실패가 어느 단계에서 발생했는가?

  2. 센서와 실행 결과 중 어떤 신호가 실패를 말해 주는가?

  3. 같은 목표를 다시 시도해도 되는가, 아니면 계획 자체를 바꾸어야 하는가?

  4. 로봇이 스스로 복구할 수 있는가, 사람을 호출해야 하는가?

좋은 로봇은 실패하지 않는 로봇이 아니다. 실패를 빨리 알아차리고, 위험해지기 전에 멈추며, 가능한 복구 방법을 선택하는 로봇이다.

대표적인 실패는 단계별로 나타난다

Pick and Place는 여러 단계로 이루어진다. 그러므로 실패도 단계별로 나누어 생각하는 편이 좋다. 물체를 보기 전의 실패와 집은 뒤의 실패는 성격이 다르다. 접근 중 실패와 내려놓기 실패도 필요한 대응이 다르다.

작업 흐름을 다시 짧게 정리하면 다음과 같다.

  1. 물체를 검출하고 3D 위치를 계산한다.

  2. 잡기 후보 자세를 만든다.

  3. pre_grasp_pose로 이동한다.

  4. grasp_pose로 천천히 접근한다.

  5. 그리퍼를 닫고 잡힘 여부를 확인한다.

  6. 물체를 들어 올린다.

  7. 목표 위치로 이동한다.

  8. 물체를 내려놓고 후퇴한다.

  9. 최종 위치를 확인한다.

각 단계에는 고유한 실패가 있다. 물체 검출 단계에서는 object_not_found가 발생할 수 있다. 접근 단계에서는 plan_failed, collision_risk, execute_timeout이 발생할 수 있다. 잡기 단계에서는 empty_grasp_suspected, slip_detected, gripper_stall이 나타난다. 내려놓은 뒤에는 place_error_too_large, object_fell, object_unstable 같은 결과가 나올 수 있다.

이렇게 실패 이름을 구체적으로 붙이면 로봇 시스템이 훨씬 똑똑해진다. 이름이 정확하면 대응도 정확해진다. 반대로 모든 실패를 failed 하나로 처리하면, 로봇은 같은 실수를 계속 반복한다.

집기 실패를 감지하는 첫 번째 신호: 그리퍼 폭

가장 쉽게 사용할 수 있는 신호는 그리퍼의 최종 폭이다. 그리퍼에 물체가 제대로 끼어 있다면 손가락은 완전히 닫히지 않는다. 반대로 빈 공간을 집었다면 그리퍼가 거의 끝까지 닫힐 가능성이 크다.

예를 들어 폭이 6cm인 컵을 집는다고 하자. 그리퍼를 닫은 뒤 최종 폭이 1cm라면 이상하다. 컵이 손가락 사이에 있었다면 그렇게까지 닫히기 어렵다. 이 경우 로봇은 "물체를 잡았다"고 믿지 말고 "빈 grasp일 가능성이 높다"고 판단해야 한다.

반대로 최종 폭이 너무 넓어도 문제다. 예상 물체 폭이 6cm인데 그리퍼 폭이 12cm에서 멈췄다면, 그리퍼가 물체를 감싸지 못하고 어딘가에 걸렸을 수 있다. 주변 장애물이나 물체의 위쪽 테두리에 걸렸을 가능성도 있다.

간단한 판단 코드는 다음처럼 만들 수 있다.

def check_grasp_by_width(gripper, expected_width):
    width = gripper.get_width()

    min_valid = expected_width * 0.4
    max_valid = expected_width * 1.4

    if width < min_valid:
        return False, 'empty_grasp_suspected'

    if width > max_valid:
        return False, 'object_not_between_fingers'

    return True, 'grasp_width_ok'

이 방법은 단순하지만 효과적이다. 특히 교육용 로봇팔이나 저가형 그리퍼처럼 힘 센서가 없는 환경에서도 사용할 수 있다. 다만 그리퍼 폭만 믿으면 안 되는 경우도 많다. 스펀지처럼 부드러운 물체는 많이 눌릴 수 있고, 얇은 물체는 잡혀 있어도 폭 변화가 작다. 컵 손잡이나 울퉁불퉁한 장난감처럼 모양이 복잡한 물체는 예상 폭과 실제 접촉 폭이 다를 수 있다.

따라서 그리퍼 폭은 "첫 번째 힌트"로 보는 것이 좋다. 확정 판정이 아니라 의심 신호다. 로봇이 안정적으로 동작하려면 여러 신호를 함께 보아야 한다.

두 번째 신호: 힘, 전류, 접촉 감각

그리퍼가 물체를 누르면 저항이 생긴다. 고급 그리퍼는 손가락 끝의 힘 센서로 이 저항을 측정할 수 있다. 어떤 그리퍼는 별도 힘 센서가 없어도 모터 전류를 통해 간접적으로 힘을 추정할 수 있다. 모터가 더 큰 힘을 내려고 할수록 전류가 증가하기 때문이다.

힘 센서나 전류 값을 사용하면 그리퍼 폭만 볼 때보다 더 많은 것을 알 수 있다. 그리퍼가 적당한 폭에서 멈췄더라도 힘이 거의 없다면 실제로는 물체를 안정적으로 잡지 못했을 수 있다. 반대로 힘이 너무 크면 물체를 찌그러뜨리거나 미끄러뜨릴 위험이 있다.

힘 기반 판단은 다음처럼 생각할 수 있다.

def check_grasp_by_force(gripper, min_force, max_force):
    force = gripper.get_contact_force()

    if force < min_force:
        return False, 'contact_force_too_low'

    if force > max_force:
        return False, 'contact_force_too_high'

    return True, 'grasp_force_ok'

여기서 min_force와 max_force는 물체마다 달라진다. 플라스틱 컵, 나무 블록, 종이 상자는 모두 적절한 힘이 다르다. 단단한 블록은 조금 세게 잡아도 괜찮지만, 종이컵은 같은 힘으로 잡으면 찌그러질 수 있다. 유리컵은 미끄러지기 쉬우므로 힘뿐 아니라 접촉 위치와 기울기도 중요하다.

실전에서는 힘 값을 하나의 순간값으로만 보지 않는 것이 좋다. 시간에 따른 변화를 함께 보면 더 많은 정보를 얻을 수 있다. 예를 들어 그리퍼를 닫는 동안 힘이 천천히 올라가 일정하게 유지되면 안정적인 접촉일 가능성이 높다. 반대로 힘이 잠깐 올라갔다가 갑자기 떨어지면 물체가 손가락 사이에서 빠졌을 수 있다.

def detect_slip(force_history, drop_ratio=0.5):
    if len(force_history) < 3:
        return False

    peak = max(force_history)
    current = force_history[-1]

    if peak > 0 and current < peak * drop_ratio:
        return True

    return False

이런 작은 감지 로직이 로봇을 훨씬 현실적으로 만든다. 로봇은 "명령을 보냈으니 성공했겠지"라고 믿는 대신, 센서가 말해 주는 결과를 확인한다.

세 번째 신호: 물체가 실제로 움직였는가

그리퍼 폭과 힘만으로도 부족할 때가 있다. 가장 확실한 질문은 이것이다. "물체가 로봇과 함께 움직였는가?"

로봇이 물체를 집었다면, 들어 올렸을 때 물체 위치도 함께 올라가야 한다. 카메라나 깊이 센서로 물체를 다시 보면 물체가 테이블 위에 그대로 있는지, 그리퍼 근처로 따라 올라왔는지 확인할 수 있다. 이 방법은 비전 기반 잡힘 확인이라고 부를 수 있다.

간단한 절차는 다음과 같다.

  1. 그리퍼를 닫은 직후 물체 위치를 기록한다.

  2. 로봇팔을 2~3cm 정도만 천천히 들어 올린다.

  3. 카메라로 물체를 다시 검출한다.

  4. 물체의 높이 또는 위치가 그리퍼 이동과 일치하는지 확인한다.

이 작은 들어올리기 테스트는 매우 유용하다. 큰 이동을 하기 전에 실패를 발견할 수 있기 때문이다. 물체를 제대로 잡지 못한 상태로 목표 위치까지 이동하면, 로봇은 빈손으로 움직이거나 중간에 물체를 떨어뜨린다. 하지만 3cm만 들어 올린 뒤 확인하면, 실패하더라도 피해가 작다.

def lift_test(robot, vision, object_id, lift_pose, expected_delta_z=0.03):
    before = vision.get_object_pose(object_id)
    if before is None:
        return False, 'object_lost_before_lift_test'

    ok = robot.move_cartesian(lift_pose, speed_scale=0.05)
    if not ok:
        return False, 'lift_test_motion_failed'

    after = vision.get_object_pose(object_id)
    if after is None:
        # 물체가 그리퍼에 가려져 안 보일 수도 있으므로 바로 실패로 단정하지 않는다.
        return True, 'object_occluded_after_lift'

    dz = after.z - before.z

    if dz < expected_delta_z * 0.5:
        return False, 'object_not_lifted'

    return True, 'lift_test_ok'

여기서 주의할 점이 있다. 들어 올린 뒤 물체가 카메라에서 보이지 않는다고 해서 무조건 실패는 아니다. 그리퍼나 로봇팔이 물체를 가릴 수 있다. 그래서 object_occluded_after_lift처럼 애매한 상태를 따로 두는 것이 좋다. 이 경우 그리퍼 힘이나 폭이 안정적이면 성공으로 볼 수 있고, 둘 다 불안정하면 재시도를 선택할 수 있다.

로봇에서 중요한 것은 완벽한 센서 하나를 찾는 것이 아니다. 불완전한 센서 여러 개를 조합해 더 믿을 만한 판단을 만드는 것이다.

여러 신호를 조합해 잡힘 점수 만들기

그리퍼 폭, 힘, 비전 결과를 각각 따로 판단할 수도 있지만, 실전에서는 하나의 점수로 합치는 방식이 편리하다. 예를 들어 잡힘 가능성을 0부터 1 사이의 값으로 계산하고, 일정 기준 이상이면 성공으로 보는 식이다.

def estimate_grasp_confidence(width_ok, force_ok, lifted_ok):
    score = 0.0

    if width_ok:
        score += 0.35
    if force_ok:
        score += 0.35
    if lifted_ok:
        score += 0.30

    return score

이 예에서는 폭과 힘을 각각 0.35, 들어올리기 결과를 0.30으로 두었다. 실제 가중치는 로봇과 센서 구성에 따라 달라진다. 힘 센서가 매우 정확하다면 힘의 비중을 높일 수 있고, 카메라가 자주 가려진다면 비전의 비중을 낮출 수 있다.

사용 예시는 다음과 같다.

confidence = estimate_grasp_confidence(
    width_ok=True,
    force_ok=True,
    lifted_ok=False
)

if confidence >= 0.7:
    result = {'success': True, 'reason': 'grasp_confident'}
elif confidence >= 0.4:
    result = {'success': False, 'reason': 'grasp_uncertain'}
else:
    result = {'success': False, 'reason': 'grasp_failed'}

grasp_uncertain 같은 상태는 의외로 중요하다. 현실의 로봇에서는 성공과 실패가 항상 선명하게 나뉘지 않는다. 물체가 잡힌 것 같지만 불안정할 때가 있다. 이때 로봇은 빠르게 이동하지 말고 속도를 줄이거나, 물체를 다시 내려놓고 더 좋은 자세로 다시 잡을 수 있다.

위치가 어긋났을 때: 목표와 실제의 차이를 계산하기

물체를 집는 데 성공해도 작업이 끝난 것은 아니다. 로봇은 물체를 목표 위치에 정확히 놓아야 한다. 그런데 실제로는 내려놓은 물체가 목표보다 몇 센티미터 옆에 있을 수 있다. 컵이 목표 트레이 중앙이 아니라 가장자리에 놓이거나, 블록이 지정한 영역을 벗어나는 식이다.

이때 필요한 것이 오차 계산이다. 오차는 목표 위치와 실제 위치의 차이다. 3D 좌표에서 위치 오차는 다음처럼 계산할 수 있다.

import math

def position_error(target_pose, actual_pose):
    dx = actual_pose.x - target_pose.x
    dy = actual_pose.y - target_pose.y
    dz = actual_pose.z - target_pose.z

    distance = math.sqrt(dx*dx + dy*dy + dz*dz)
    return distance, (dx, dy, dz)

예를 들어 목표 위치가 (0.50, 0.20, 0.03)이고 실제 위치가 (0.53, 0.18, 0.03)이라면 x 방향으로 3cm, y 방향으로 -2cm 어긋난 것이다. 전체 거리 오차는 약 3.6cm다. 목표 허용 오차가 2cm라면 실패로 보고 보정해야 한다.

중요한 것은 모든 작업에 같은 허용 오차를 적용하지 않는다는 점이다. 블록을 큰 상자 안에 넣는 작업은 5cm 오차도 괜찮을 수 있다. 반면 작은 홈에 부품을 끼우는 작업은 2mm 오차도 클 수 있다. 따라서 스킬 요청에는 목표 허용 오차를 함께 포함하는 것이 좋다.

place_goal = {
    'target_pose': target_pose,
    'position_tolerance': 0.02,
    'orientation_tolerance': 0.15
}

여기서 orientation_tolerance는 방향 오차 허용값이다. 컵을 단순히 트레이 안에 놓는 작업에서는 방향이 중요하지 않을 수 있다. 하지만 긴 막대를 특정 방향으로 놓거나, 상자를 라벨이 보이게 세워야 한다면 방향 오차도 확인해야 한다.

내려놓은 뒤 보정하는 절차

목표와 실제 위치가 다르다고 해서 항상 전체 Pick and Place를 처음부터 다시 해야 하는 것은 아니다. 오차가 작고 물체가 안정적으로 놓여 있다면, 로봇은 물체를 살짝 밀거나 다시 집어 짧게 이동할 수 있다. 반대로 오차가 크거나 물체가 넘어졌다면 처음부터 다시 인식하고 잡아야 한다.

보정 절차는 보통 다음 순서로 진행한다.

  1. 물체를 내려놓은 뒤 잠시 기다린다.

  2. 카메라로 물체의 실제 위치와 자세를 다시 검출한다.

  3. 목표 위치와 실제 위치의 오차를 계산한다.

  4. 오차가 허용 범위 안이면 성공으로 종료한다.

  5. 오차가 작으면 짧은 보정 동작을 시도한다.

  6. 오차가 크거나 물체가 불안정하면 다시 집기 계획으로 돌아간다.

이를 코드 구조로 표현하면 다음과 같다.

def verify_and_correct_place(robot, vision, object_id, target_pose, tolerance):
    robot.wait(0.5)

    actual_pose = vision.get_object_pose(object_id)
    if actual_pose is None:
        return False, 'placed_object_not_found'

    error, delta = position_error(target_pose, actual_pose)

    if error <= tolerance:
        return True, 'place_verified'

    if error <= tolerance * 3.0:
        ok = try_small_correction(robot, object_id, delta)
        if ok:
            return True, 'place_corrected'
        return False, 'small_correction_failed'

    return False, 'place_error_too_large'

try_small_correction은 상황에 따라 다르게 구현된다. 블록처럼 단단하고 바닥에서 미끄러질 수 있는 물체는 그리퍼나 손가락 옆면으로 살짝 밀 수 있다. 하지만 컵이나 병처럼 넘어질 수 있는 물체는 밀기보다 다시 집는 편이 안전하다. 액체가 들어 있을 가능성이 있는 컵은 더욱 조심해야 한다.

좋은 로봇은 보정 방법을 물체 종류에 따라 선택한다. 모든 물체를 같은 방식으로 다루면 사고가 난다. 블록은 밀어도 되지만 유리컵은 밀면 안 된다. 수건은 집기 어렵지만 밀거나 끌 수 있고, 얇은 카드는 일반 그리퍼보다 흡착이 나을 수 있다.

경로 계획 실패와 충돌 위험은 다르다

로봇팔이 움직이지 못했다고 해서 항상 같은 원인은 아니다. 특히 plan_failed와 collision_risk를 구분하는 것이 중요하다.

plan_failed는 경로 계획기가 목표 자세까지 가는 경로를 찾지 못했다는 뜻이다. 이유는 여러 가지다. 목표가 로봇의 작업 공간 밖에 있을 수도 있고, 관절 한계 때문에 손목 자세를 만들 수 없을 수도 있으며, 계획 시간이 너무 짧아서 해를 찾지 못했을 수도 있다.

반면 collision_risk는 경로가 있더라도 충돌 위험이 크다는 뜻이다. 로봇팔, 그리퍼, 들고 있는 물체가 테이블이나 주변 장애물과 부딪힐 가능성이 있다는 의미다. 이 경우 단순히 같은 계획을 다시 시도하면 안 된다. 장애물 정보를 확인하고, 접근 방향이나 중간 자세를 바꾸어야 한다.

두 실패를 구분하면 대응이 달라진다.

def diagnose_motion_failure(plan_result):
    if plan_result.collision_detected:
        return 'collision_risk'

    if plan_result.goal_out_of_workspace:
        return 'goal_out_of_workspace'

    if plan_result.ik_failed:
        return 'inverse_kinematics_failed'

    if plan_result.timeout:
        return 'planning_timeout'

    return 'plan_failed_unknown'

역기구학, 즉 IK는 엔드 이펙터 목표 자세를 만들기 위해 각 관절 각도를 계산하는 과정이다. 목표 자세가 물리적으로 불가능하면 IK가 실패한다. 예를 들어 로봇팔이 닿을 수 없는 먼 곳을 목표로 하거나, 손목이 불가능한 방향을 향해야 한다면 실패한다.

충돌 위험은 더 세심하게 봐야 한다. 계획기가 충돌을 보고했다면 "어떤 물체와 충돌하는가?"를 알아야 한다. 테이블과 충돌하는지, 주변 상자와 충돌하는지, 로봇 자기 몸체와 충돌하는지에 따라 대응이 다르다.

def describe_collision(collision_info):
    return {
        'link': collision_info.robot_link,
        'object': collision_info.world_object,
        'distance': collision_info.min_distance
    }

만약 그리퍼가 테이블과 충돌한다면 잡기 높이 z가 너무 낮을 수 있다. 팔꿈치 링크가 주변 장애물과 충돌한다면 중간 경유 자세를 추가해야 할 수 있다. 들고 있는 물체가 장애물과 충돌한다면 물체를 더 높이 들어 올리거나, attached object의 크기 설정을 확인해야 한다.

실행 중 멈춤은 또 다른 실패다

경로 계획이 성공해도 실행이 실패할 수 있다. 계획 단계에서는 괜찮아 보였지만, 실제 실행 중에 로봇 컨트롤러가 멈추거나 속도가 제한되거나 충돌 감지로 정지할 수 있다. 센서 노이즈, 관절 오차, 예상치 못한 접촉이 원인일 수 있다.

실행 중 실패는 특히 안전과 관련이 깊다. 계획 실패는 아직 로봇이 움직이기 전의 문제지만, 실행 실패는 이미 로봇이 움직이는 중에 발생한다. 따라서 실행 실패를 감지하면 먼저 로봇을 안전하게 멈추고 현재 상태를 확인해야 한다.

def execute_with_monitoring(robot, plan, monitor):
    robot.start_execution(plan)

    while robot.is_executing():
        state = monitor.read_state()

        if state.emergency_stop:
            robot.stop()
            return False, 'emergency_stop'

        if state.force_too_high:
            robot.stop()
            return False, 'unexpected_contact'

        if state.joint_error_too_large:
            robot.stop()
            return False, 'trajectory_tracking_error'

        if state.timeout:
            robot.stop()
            return False, 'execution_timeout'

    return True, 'execute_ok'

여기서 unexpected_contact는 예상하지 못한 접촉을 뜻한다. 로봇팔이 물체를 집기 위해 접촉하는 것은 정상일 수 있지만, 이동 중 팔꿈치가 장애물에 닿는 것은 위험하다. 힘/토크 센서가 있다면 이런 접촉을 빠르게 감지할 수 있다. 힘 센서가 없다면 관절 오차, 모터 전류, 속도 변화 등을 간접 신호로 사용할 수 있다.

실행 중 실패가 발생하면 바로 재시도하지 말아야 한다. 먼저 로봇의 현재 자세와 주변 상태를 다시 확인해야 한다. 물체를 들고 있는지, 물체를 떨어뜨렸는지, 경로 중간에 멈춰 있는지에 따라 복구 방법이 다르다.

재시도는 무작정 반복이 아니다

초보자는 실패했을 때 같은 명령을 다시 보내는 방식으로 재시도를 구현하곤 한다. 하지만 로봇에서 무작정 반복은 위험하다. 한 번 실패한 이유가 그대로 남아 있다면, 같은 동작은 같은 실패를 다시 만든다. 심하면 물체를 더 멀리 밀거나 충돌을 반복할 수 있다.

좋은 재시도는 실패 원인에 따라 달라져야 한다. 물체를 찾지 못했으면 다시 검출한다. 빈 공간을 집었으면 물체 위치를 업데이트한다. 잡기 자세가 나쁘면 다른 후보를 선택한다. 속도가 빨라 물체가 미끄러졌다면 속도를 줄인다. 충돌 위험이 있으면 경로를 바꾼다.

다음은 실패 이유에 따른 재시도 전략의 예다.

실패 단계실패 이유가능한 대응
detectobject_not_found카메라 재촬영, 조명 변경, 시야 이동, 사용자 확인
approachplan_failed다른 pre-grasp 자세 선택, 목표 자세 완화, 계획 시간 증가
approachcollision_risk장애물 정보 갱신, 접근 방향 변경, 물체를 더 높이 들어 올리기
graspempty_grasp_suspected물체 재검출, grasp pose 보정, 그리퍼 폭 조정
graspcontact_force_too_low더 깊게 접근, 그리퍼 힘 증가, 다른 잡기 후보 선택
liftobject_not_lifted다시 내려놓고 재집기, 잡기 높이 수정, 속도 감소
move_to_placeobject_slipped즉시 정지, 물체 재검출, 사람 호출 또는 재집기
placeplace_error_too_large실제 위치 재검출, 짧은 보정, 다시 집어 놓기

재시도에는 횟수 제한이 필요하다. 같은 작업을 무한 반복하는 로봇은 안전하지 않다. 예를 들어 잡기 실패는 최대 3번까지만 시도하고, 그 이후에는 사람에게 도움을 요청할 수 있다.

retry_policy = {
    'max_detect_retry': 3,
    'max_grasp_retry': 3,
    'max_plan_retry': 2,
    'max_place_correction': 2
}

횟수 제한은 단순한 안전장치이지만 매우 중요하다. 로봇이 계속 실패하는 상황은 대개 환경이나 목표 자체에 문제가 있다는 뜻이다. 컵이 그리퍼보다 너무 크거나, 물체가 카메라 사각지대에 있거나, 사람이 치워야 할 장애물이 있을 수 있다. 이때는 로봇이 고집을 부리는 대신 도움을 요청해야 한다.

재검출: 실패 후 다시 보는 능력

실패 복구에서 가장 기본이 되는 동작은 재검출이다. 로봇이 실패한 뒤에도 물체가 처음 위치에 그대로 있다고 가정하면 안 된다. 접근 중 그리퍼가 물체를 밀었을 수 있고, 집기 실패로 물체가 넘어졌을 수도 있다. 내려놓기 실패 후에는 목표 주변 어딘가에 물체가 있을 수 있다.

따라서 실패 후에는 카메라로 현재 상태를 다시 확인해야 한다. 이를 재검출이라고 하자.

def redetect_target(vision, object_id, max_attempts=3):
    for attempt in range(max_attempts):
        result = vision.detect(object_id)
        if result.found and result.confidence > 0.6:
            return True, result.pose

        vision.adjust_exposure_if_needed()

    return False, None

재검출은 단순히 같은 이미지를 다시 처리하는 것보다 넓게 생각해야 한다. 조명을 조정할 수도 있고, 카메라가 손목에 달려 있다면 시점을 바꿀 수도 있다. 로봇팔이 물체를 가리고 있다면 먼저 후퇴한 뒤 다시 볼 수도 있다.

재검출에서 중요한 점은 "현재 세계를 믿는다"는 것이다. 로봇은 과거에 계산한 좌표에 집착하면 안 된다. 물리 세계는 움직인다. 실패가 발생했다면 세계가 바뀌었을 가능성이 높다. 그러므로 실패 후 첫 번째 복구 동작은 대개 다시 보는 것이다.

다른 집기 후보 선택하기

하나의 물체에는 여러 잡기 후보가 있을 수 있다. 컵은 위에서 집을 수도 있고 옆에서 잡을 수도 있다. 상자는 중심을 잡을 수도 있고 모서리를 피해서 잡을 수도 있다. 블록은 긴 방향 또는 짧은 방향으로 집을 수 있다.

처음 선택한 후보가 실패했다면 같은 후보를 반복하기보다 다른 후보를 선택하는 것이 좋다. 이때 후보마다 점수를 두고, 실패한 후보의 점수를 낮추면 된다.

def select_next_grasp_candidate(candidates, failed_candidates):
    available = []

    for candidate in candidates:
        if candidate.id in failed_candidates:
            candidate.score *= 0.5
        available.append(candidate)

    available.sort(key=lambda c: c.score, reverse=True)
    return available[0]

잡기 후보의 점수는 여러 요소로 계산할 수 있다. 물체 중심에 가까운지, 그리퍼가 접근할 공간이 충분한지, IK가 잘 풀리는지, 충돌 위험이 낮은지, 과거에 성공한 적이 있는지 등을 반영한다.

예를 들어 위에서 잡는 후보가 계속 실패한다면 옆에서 잡는 후보를 시도할 수 있다. 물체 중심을 잡으려다 미끄러졌다면 조금 더 낮은 위치를 잡을 수 있다. 그리퍼가 물체를 밀었다면 접근 거리를 줄이고 마지막 접근 속도를 낮출 수 있다.

핵심은 실패를 학습 신호처럼 사용하는 것이다. 실패한 후보를 기록하면 로봇은 다음 선택을 조금 더 똑똑하게 할 수 있다. 이것이 나중에 모방학습과 강화학습으로 이어지는 중요한 연결점이다.

속도와 가속도를 낮추는 전략

많은 실패는 로봇이 너무 빠르게 움직일 때 발생한다. 잡은 물체가 미끄러지고, 내려놓은 물체가 튀고, 마지막 접근에서 물체를 밀어 버린다. 개발 초기에는 속도를 낮추는 것만으로도 성공률이 크게 올라간다.

특히 다음 상황에서는 속도와 가속도를 줄이는 것이 좋다.

  • 잡힘 신뢰도가 낮지만 작업을 계속 진행해야 할 때

  • 물체가 미끄럽거나 무게 중심이 불안정할 때

  • 카메라 좌표 오차가 크다고 판단될 때

  • 충돌 여유 거리가 작을 때

  • 처음 보는 물체를 다룰 때

속도 조정은 재시도 정책에 포함할 수 있다.

def adjust_speed_for_retry(robot, retry_count):
    if retry_count == 0:
        robot.set_speed_scale(0.3)
        robot.set_accel_scale(0.3)
    elif retry_count == 1:
        robot.set_speed_scale(0.2)
        robot.set_accel_scale(0.2)
    else:
        robot.set_speed_scale(0.1)
        robot.set_accel_scale(0.1)

물론 속도를 낮춘다고 모든 문제가 해결되지는 않는다. 좌표가 틀렸거나 그리퍼가 물체에 맞지 않으면 느리게 움직여도 실패한다. 하지만 속도를 낮추면 실패가 더 안전해지고, 센서가 상황을 관찰할 시간이 생긴다. 로봇 개발에서는 이것만으로도 큰 차이를 만든다.

사람을 호출해야 하는 순간

자율 로봇이라고 해서 모든 문제를 스스로 해결해야 하는 것은 아니다. 오히려 안전한 로봇은 자신의 한계를 알고 사람에게 도움을 요청할 줄 알아야 한다.

사람 호출이 필요한 상황은 명확히 정해 두는 것이 좋다. 예를 들어 물체를 세 번 연속 찾지 못했거나, 충돌 위험이 반복되거나, 예상하지 못한 접촉이 발생했거나, 물체가 파손될 가능성이 있으면 로봇은 멈추고 사람을 불러야 한다.

def should_call_human(failure_history):
    recent = failure_history[-3:]

    if any(f.reason == 'emergency_stop' for f in recent):
        return True, 'emergency_stop_triggered'

    if sum(f.reason == 'collision_risk' for f in recent) >= 2:
        return True, 'repeated_collision_risk'

    if sum(f.reason == 'object_not_found' for f in recent) >= 3:
        return True, 'target_not_visible'

    if sum(f.stage == 'grasp' for f in recent) >= 3:
        return True, 'repeated_grasp_failure'

    return False, 'continue_autonomous_retry'

사람을 호출할 때는 메시지도 구체적이어야 한다. "실패했습니다"라고 말하면 사람이 무엇을 해야 할지 모른다. "빨간 컵이 카메라에서 보이지 않습니다. 컵을 작업 영역 안으로 옮겨 주세요." 또는 "오른쪽 트레이로 가는 경로에 장애물이 있습니다. 테이블 중앙의 상자를 치워 주세요."처럼 원인과 요청을 함께 전달해야 한다.

이 방식은 나중에 LLM 에이전트와 결합하면 더 자연스러워진다. 하위 스킬이 reason을 반환하면, LLM은 그것을 사람이 이해하기 쉬운 문장으로 바꿀 수 있다. 하지만 중요한 결정은 여전히 안전 규칙이 담당해야 한다. LLM이 "괜찮아 보이니 계속해"라고 말하더라도, 충돌 위험이 높으면 로봇은 멈춰야 한다.

실패 감지기를 스킬 안에 넣기

이제 실패 감지와 재시도 전략을 Pick and Place 스킬에 넣어 보자. 구조는 단순하다. 각 단계는 실행 후 확인을 수행하고, 실패하면 원인을 반환한다. 상위 루프는 실패 이유에 따라 복구 동작을 선택한다.

def robust_pick(robot, gripper, vision, object_id, grasp_candidates):
    failed_candidates = set()

    for retry in range(3):
        adjust_speed_for_retry(robot, retry)

        found, object_pose = redetect_target(vision, object_id)
        if not found:
            return {
                'success': False,
                'stage': 'detect',
                'reason': 'object_not_found'
            }

        candidate = select_next_grasp_candidate(
            grasp_candidates,
            failed_candidates
        )

        plan_ok, reason = robot.plan_to_pose(candidate.pre_grasp_pose)
        if not plan_ok:
            failed_candidates.add(candidate.id)
            continue

        exec_ok, reason = robot.execute_last_plan()
        if not exec_ok:
            return {
                'success': False,
                'stage': 'approach',
                'reason': reason
            }

        ok, reason = robot.move_cartesian(candidate.grasp_pose, speed_scale=0.05)
        if not ok:
            failed_candidates.add(candidate.id)
            continue

        gripper.close(force=candidate.force)

        width_ok, _ = check_grasp_by_width(gripper, candidate.expected_width)
        force_ok, _ = check_grasp_by_force(gripper, candidate.min_force, candidate.max_force)

        lifted_ok, lift_reason = lift_test(
            robot,
            vision,
            object_id,
            candidate.lift_test_pose
        )

        confidence = estimate_grasp_confidence(width_ok, force_ok, lifted_ok)

        if confidence >= 0.7:
            return {
                'success': True,
                'stage': 'grasp',
                'reason': 'grasp_verified',
                'confidence': confidence,
                'candidate_id': candidate.id
            }

        failed_candidates.add(candidate.id)
        gripper.open()
        robot.move_cartesian(candidate.safe_retreat_pose, speed_scale=0.05)

    return {
        'success': False,
        'stage': 'grasp',
        'reason': 'max_grasp_retry_exceeded'
    }

이 코드는 완성된 제품 코드라기보다 구조를 보여 주기 위한 예다. 중요한 점은 재시도할 때 단순히 같은 동작을 반복하지 않는다는 것이다. 매번 물체를 다시 보고, 실패한 후보를 제외하거나 점수를 낮추고, 속도를 조정한다.

실전에서는 여기에 더 많은 안전 확인이 들어간다. 로봇 주변에 사람이 있는지, 비상 정지가 눌렸는지, 그리퍼 온도가 올라갔는지, planning scene이 최신인지 확인할 수 있다. 하지만 뼈대는 같다. 실행하고, 확인하고, 실패 이유를 기록하고, 다른 선택을 한다.

실패 기록은 로봇의 기억이다

실패를 감지했다면 반드시 기록해야 한다. 기록하지 않는 실패는 다음 실험에서 반복된다. 로봇 개발자는 로그를 통해 어떤 물체에서 실패가 많은지, 어떤 잡기 후보가 자주 실패하는지, 어떤 경로에서 충돌 위험이 반복되는지 알 수 있다.

로그에는 최소한 다음 정보가 들어가면 좋다.

  • 작업 이름과 대상 물체

  • 실패 단계와 실패 이유

  • 물체의 인식 좌표와 신뢰도

  • 선택한 grasp candidate ID

  • 그리퍼 폭, 힘, 전류 값

  • 계획 성공 여부와 충돌 정보

  • 실제 최종 물체 위치와 목표 오차

  • 재시도 횟수와 최종 결과

예를 들어 한 번의 실패 로그는 다음처럼 저장할 수 있다.

failure_log = {
    'task': 'pick_and_place',
    'object_id': 'red_cup',
    'stage': 'grasp',
    'reason': 'empty_grasp_suspected',
    'object_pose': [0.42, 0.18, 0.03],
    'grasp_candidate_id': 'top_grasp_02',
    'gripper_width': 0.008,
    'contact_force': 0.2,
    'retry_count': 1,
    'timestamp': '2026-05-14T10:30:00'
}

이런 로그는 당장은 디버깅에 쓰인다. 하지만 나중에는 학습 데이터가 된다. 4부에서 다룰 모방학습에서는 사람이 성공적으로 조작한 데이터가 중요하다. 동시에 실패 데이터도 중요하다. 어떤 상태에서 어떤 행동이 실패했는지를 알아야 더 좋은 정책을 만들 수 있기 때문이다.

강화학습에서도 실패 기록은 보상 설계와 환경 개선에 도움이 된다. 예를 들어 로봇이 자꾸 물체를 밀어 버린다면 접근 방향이나 보상 함수를 바꾸어야 한다. LLM 에이전트에서는 실패 로그가 작업 설명과 재계획의 근거가 된다. "이 물체는 위에서 잡으면 자주 실패하므로 옆에서 잡기" 같은 경험 규칙을 만들 수 있다.

예제: 빨간 컵을 집지 못한 로봇

실제 상황을 하나 따라가 보자. 사용자가 "빨간 컵을 오른쪽 트레이에 놓아줘"라고 말했다. 로봇은 카메라로 빨간 컵을 찾고, 위에서 집는 후보를 선택했다. pre_grasp_pose까지는 잘 이동했다. 그다음 천천히 내려가 그리퍼를 닫았다.

그런데 그리퍼 최종 폭이 거의 0에 가까웠다. 힘 센서 값도 낮았다. 로봇은 empty_grasp_suspected를 반환한다. 이때 나쁜 로봇은 그냥 다음 단계로 넘어가 빈손으로 컵을 옮기는 시늉을 한다. 좋은 로봇은 멈추고 복구를 시작한다.

먼저 로봇은 그리퍼를 열고 안전한 높이로 후퇴한다. 그런 다음 카메라로 컵을 다시 본다. 재검출 결과 컵이 처음보다 2cm 뒤로 밀려 있었다. 아마 접근 과정에서 손가락이 컵을 살짝 건드린 것이다.

로봇은 새로운 컵 위치로 grasp pose를 다시 계산한다. 이번에는 위에서 잡는 후보를 조금 수정해, 접근 높이를 1cm 높이고 마지막 접근 속도를 더 낮춘다. 또한 그리퍼 폭을 컵 지름보다 조금 더 넓게 연다. 두 번째 시도에서 그리퍼 폭은 예상 범위 안에 들어왔고, 힘 센서도 적절한 값을 보였다. 로봇은 3cm 들어올리기 테스트를 수행한다. 컵의 높이가 함께 올라갔고, 잡힘 신뢰도는 0.82가 되었다.

이제 로봇은 컵을 안전하게 들어 올려 오른쪽 트레이로 이동한다. 하지만 내려놓은 뒤 검증해 보니 컵이 목표보다 2.8cm 왼쪽에 놓였다. 허용 오차는 2cm였다. 로봇은 이것을 place_error_too_large로 판단한다. 다만 오차가 아주 크지는 않으므로 컵을 다시 완전히 집기보다 짧은 보정 동작을 선택한다. 컵이 넘어지지 않도록 그리퍼를 낮은 속도로 접근시키고, 옆면을 아주 조금 밀어 목표 영역 안으로 넣는다.

마지막으로 카메라가 컵 위치를 다시 확인한다. 오차는 1.1cm로 줄었다. 로봇은 성공을 반환한다.

{
    'success': True,
    'stage': 'done',
    'reason': 'place_corrected',
    'retries': 1,
    'final_error': 0.011
}

겉으로 보기에는 로봇이 컵 하나를 옮긴 것뿐이다. 하지만 내부적으로는 실패 감지, 재검출, 후보 변경, 속도 조정, 위치 보정이 모두 일어났다. 이것이 현실의 로봇팔 작업이다. 성공은 한 번의 완벽한 계획에서 나오는 것이 아니라, 작은 실패를 계속 감지하고 고치는 과정에서 나온다.

상위 에이전트에게 무엇을 알려 줄 것인가

앞으로 우리는 LLM 기반 로봇 에이전트를 만들 것이다. 이때 LLM은 로봇의 모터를 직접 제어하지 않는다. 대신 detect_object, pick, place, check_status 같은 스킬을 호출하고 결과를 받아 다음 계획을 세운다.

그러려면 하위 스킬은 실패를 상위 에이전트가 이해할 수 있는 형태로 반환해야 한다. 너무 낮은 수준의 정보만 주면 LLM이 판단하기 어렵고, 너무 뭉뚱그린 정보만 주면 재계획이 불가능하다.

좋은 반환값은 다음 정보를 포함한다.

response = {
    'success': False,
    'skill': 'pick',
    'stage': 'grasp',
    'reason': 'empty_grasp_suspected',
    'recoverable': True,
    'suggested_action': 'redetect_and_try_alternative_grasp',
    'message': '그리퍼가 거의 완전히 닫혔고 접촉 힘이 낮아 빈 공간을 집은 것으로 보입니다.'
}

여기서 recoverable은 로봇이 스스로 복구할 수 있는지 나타낸다. suggested_action은 상위 에이전트가 다음에 선택할 수 있는 행동 힌트다. message는 사람에게 보여 줄 수 있는 설명이다.

하지만 안전과 관련된 판단은 하위 제어 계층이 강하게 막아야 한다. 예를 들어 충돌 위험, 비상 정지, 사람 접근 감지 같은 상황에서는 LLM이 어떤 계획을 내더라도 로봇은 실행하지 않아야 한다. 상위 에이전트는 똑똑한 계획자일 수 있지만, 안전 제어는 더 낮고 빠른 계층에서 보장되어야 한다.

실패 감지 설계 체크리스트

로봇팔 스킬을 만들 때는 처음부터 실패 감지를 함께 설계하는 것이 좋다. 동작을 모두 만든 뒤 나중에 실패 처리를 붙이려고 하면 구조가 복잡해진다. 각 단계마다 "이 단계가 성공했음을 어떻게 확인할 것인가?"를 물어야 한다.

다음 질문을 체크리스트처럼 사용해 보자.

  • 물체를 검출하지 못했을 때 재촬영 또는 시점 변경을 하는가?

  • grasp pose에 도달하기 전 충돌 위험을 확인하는가?

  • 그리퍼를 닫은 뒤 폭, 힘, 전류 중 하나 이상을 확인하는가?

  • 물체를 조금 들어 올려 실제로 잡혔는지 확인하는가?

  • 이동 중 미끄러짐이나 예상치 못한 접촉을 감지하는가?

  • 내려놓은 뒤 실제 물체 위치를 다시 확인하는가?

  • 목표 오차가 클 때 보정 또는 재집기를 선택하는가?

  • 같은 실패를 무한 반복하지 않도록 재시도 횟수를 제한하는가?

  • 사람이 개입해야 할 조건이 명확한가?

  • 모든 실패 이유와 센서 값을 로그로 남기는가?

이 질문에 답할 수 있다면 로봇팔 스킬은 단순한 데모를 넘어 실제 시스템에 가까워진다. 실패 감지는 부가 기능이 아니라 로봇 지능의 핵심이다.

실패를 다루는 로봇이 더 똑똑해진다

로봇 개발에서 실패는 귀찮은 예외처럼 느껴진다. 하지만 사실 실패는 로봇이 세계를 배우는 가장 중요한 신호다. 물체를 헛집었다면 인식 좌표나 잡기 자세에 문제가 있다는 뜻이다. 들어 올리다 떨어뜨렸다면 힘, 마찰, 속도, 물체 무게 중심을 다시 봐야 한다. 목표 위치에서 벗어났다면 좌표 변환이나 place pose, 보정 절차를 개선해야 한다.

사람도 물건을 한 번에 완벽하게 다루지 않는다. 처음 보는 물건은 조심스럽게 만져 보고, 미끄러우면 더 세게 잡고, 무거우면 양손을 쓰며, 불안정하면 속도를 늦춘다. 로봇도 마찬가지다. 센서로 확인하고, 실패를 분류하고, 다시 시도하고, 필요하면 도움을 요청해야 한다.

이번 장에서 우리는 그리퍼 상태, 힘 센서, 물체 위치 변화를 이용해 집기 실패를 판단하는 방법을 보았다. 또한 내려놓은 물체의 실제 위치를 확인해 목표와의 오차를 계산하고, 작은 보정이나 재집기를 선택하는 절차를 만들었다. 경로 계획 실패와 충돌 위험을 구분해야 하는 이유도 살펴보았다. 마지막으로 재검출, 다른 집기 후보 선택, 속도 감소, 사람 호출 같은 재시도 전략을 정리했다.

다음 장에서는 지금까지 배운 내용을 하나의 프로젝트로 묶는다. 카메라가 물체를 보고, 3D 좌표를 계산하고, 로봇 좌표로 변환하고, MoveIt2로 경로를 계획하고, Pick and Place를 실행한다. 그리고 오늘 만든 실패 감지와 재시도 전략이 그 프로젝트를 단순한 성공 데모가 아니라, 현실에서 버틸 수 있는 로봇 스킬로 만들어 줄 것이다.

책 목록

AI 로봇 에이전트 만들기: 비전·로봇팔·학습·LLM으로 자연어 명령을 실행하는 로봇

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기