본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 23. 22:36

LLM이 OpenSCAD 코드 생성에 실패하는 이유와 해결 방법

요약

LLM이 OpenSCAD를 이용한 절차적 3D 모델링 코드를 생성할 때 발생하는 기하학적 오류의 원인을 분석합니다. 좌표계 혼동, CSG 연산의 상태 유지 실패, 모듈 구성의 복잡성 등 모델이 공간 추론에서 겪는 한계를 다룹니다.

핵심 포인트

  • LLM은 텍스트 패턴은 이해하지만 3D 공간 추론에는 취약함
  • 좌표계 관습(Z-up vs Y-up) 혼동으로 인한 기하학적 오류 발생
  • 중첩된 CSG 연산 시 고체와 빈 공간의 상태 유지 실패
  • 오일러 각 회전 순서 적용 오류로 인한 구조적 결함

저는 최근 아주 기묘한 사이드 프로젝트에 저녁 시간을 쏟고 있습니다. 바로 언어 모델(Language Models)이 파라메트릭(Parametric) 건축 모델을 위한 OpenSCAD 코드를 생성하도록 만드는 일입니다. 간단한 요청이죠, 그렇지 않나요? "남쪽 벽 중앙에 창문이 있는 3m x 4m 크기의 방을 생성해줘." 제가 처음 이 시도를 했을 때, 모델은 80줄의 자신감 넘치는 코드를 반환했습니다. 하지만 렌더링(Rendering) 결과물은 후회라는 감정을 형상화한 추상 예술이라고밖에 설명할 수 없는 형태였습니다. 만약 여러분이 절차적 3D 생성(Procedural 3D Generation)을 위해 LLM을 사용해 본 적이 있다면, 아마 똑같은 벽에 부딪혀 보았을 것입니다. 코드는 파싱(Parse)됩니다. 심지어 렌더링도 됩니다. 하지만 기하학적 구조(Geometry)가 미묘하고도 화가 날 정도로 잘못되어 있습니다. 벽은 공중에 떠 있고, 창문은 바닥을 뚫고 지나가며, 회전(Rotation)은 잘못된 방향으로 돌아갑니다. 왜 이런 일이 발생하는지, 그리고 실제로 저에게 효과가 있었던 방법은 무엇인지 설명해 보겠습니다.

증상: 거짓말을 하는 코드

제가 "박공지붕(Pitched roof)이 있는 간단한 집"을 요청했을 때 계속해서 받았던 출력물의 실제 예시입니다:

// Floor
cube([10, 8, 0.2]);

// Walls
translate([0, 0, 0.2])
difference() {
cube([10, 8, 3]);
translate([0.2, 0.2, 0.2])
cube([9.6, 7.6, 2.8]);
}

// Roof
translate([0, 0, 3.2])
rotate([0, 45, 0])
// <-- 이것이 버그입니다
cube([10, 8, 0.2]);

겉보기에는 괜찮아 보입니다. 오류 없이 렌더링됩니다. 하지만 저 지붕은요? 잘못된 축을 중심으로 회전되어 있고 원점(Origin)에서 벗어나 있어서, 결국 단두대처럼 벽을 베어버립니다. 모델은 "pitched roof"와 "rotate"라는 단어는 알고 있었습니다. 하지만 그 단어들이 3D 공간에서 무엇을 의미하는지는 알지 못했습니다.

근본 원인: 공간 추론은 텍스트 추론이 아니다

몇 가지 프롬프팅(Prompting) 전략을 옮겨보고 OpenSCAD 문서를 수없이 읽어본 결과, 저는 여기서 세 가지 뚜렷한 실패 모드가 발생하고 있다고 생각합니다:

  1. 좌표계 혼동 (Coordinate frame confusion)
    OpenSCAD는 Z축이 위를 향하는 오른손 좌표계(Right-handed coordinate system)를 사용합니다. 온라인의 많은 튜토리얼은 Y축을 위로 취급합니다 (Blender나 Unity의 습관이 스며든 것이죠). 학습 데이터(Training data)는 이러한 관습들이 뒤섞여 있기 때문에, 모델은 이를 평균화하여 터무니없는 결과를 만들어냅니다.

회전(Rotations)은 문제를 더욱 악화시킵니다. rotate([x, y, z])는 오일러 각(Euler angles)을 특정 순서로 적용하는데, 이 순서를 거꾸로 적용하기 매우 쉽기 때문입니다. 2. CSG 연산에 대한 멘탈 모델(Mental model)의 부재. CSG(Constructive Solid Geometry) 연산 — 합집합(union), 차집합(difference), 교집합(intersection) — 은 매 단계마다 무엇이 고체(solid)이고 무엇이 빈 공간(void)인지 추적할 것을 요구합니다. 언어 모델(Language models)은 이러한 상태(state)를 유지하지 못합니다. 모델은 "차집합은 뺀다는 의미이다"라는 패턴 매칭(pattern-match)은 수행하지만, 두세 번의 중첩된 연산이 지나면 어떤 형상이 피감수(minuend)인지 놓쳐버립니다. 3. 모듈 구성(Module composition)의 빠른 재귀화. 실제적인 OpenSCAD 아키텍처 코드는 매개변수(parameters)를 가진 모듈을 사용합니다. 두 개의 매개변수화된 모듈을 중첩하고 각 레벨에서 변환(transforms)을 적용하면, 모델은 자신이 현재 어떤 좌표계(coordinate space)에 있는지 놓치게 됩니다. 저는 창문(window) 모듈은 로컬 좌표(local coordinates)를 가정하는 반면, 벽(wall) 모듈은 월드 좌표(world coordinates)를 전달하여 발생하는 출력을 본 적이 있습니다. 결과는 창문이 주차장에 있는 식이었죠. 해결책: 생성 표면(Generation Surface)을 제한하라. 가장 큰 개선은 모델에게 OpenSCAD를 직접 작성하도록 요청하지 않는 것에서 왔습니다. 대신, 모델이 구조화된 중간 표현(intermediate representation)을 생성하게 한 다음, 이를 결정론적(deterministic)인 코드를 통해 OpenSCAD로 변환합니다. 그 패턴은 다음과 같습니다: # 1단계: 모델은 OpenSCAD가 아닌 JSON을 생성합니다.

schema = {
  "walls": [
    {"start": [0, 0], "end": [10, 0], "height": 3, "thickness": 0.2}
  ],
  "openings": [
    {
      "wall_index": 0,
      "type": "window",
      "position": 5.0, # 벽의 시작점으로부터의 거리
      "width": 1.2,
      "height": 1.0,
      "sill": 0.9
    }
  ]
}

그다음 작은 Python 스크립트가 JSON을 순회(walk)하며 OpenSCAD를 출력합니다. 모델은 더 이상 좌표계(coordinate frames)에 대해 추론할 필요가 없습니다. 그저 "이 벽은 여기서 여기까지 이어진다"라는 관점에서 생각하기만 하면 됩니다. 이는 2D 문제이며, LLM은 3D보다 2D 문제를 훨씬 더 극적으로 잘 처리합니다.

def emit_wall(wall):
dx = wall['end'][0] - wall['start'][0']
dy = wall['end'][1] - wall['start'][1']
length = (dx2 + dy2) ** 0.5 # angle in degrees, atan2 handles all quadrants correctly
angle = math.degrees(math.atan2(dy, dx))
return (
f"translate([{wall['start'][0]}, {wall['start'][1]}, 0])\n"
f" rotate([0, 0, {angle}])\n"
f" cube([{length}, {wall['thickness']}, {wall['height']}]);\n"
)
변환(transform) 코드는 지루하고, 결정론적이며, 테스트가 가능합니다. LLM이 창의적인 부분(벽은 어디에 배치해야 하는가?)을 담당하고, 코드가 취약한 부분(어떻게 정확하게 회전시킬까?)을 담당하는 식입니다. 렌더링 전에 검증하기 (Validate Before You Render)
두 번째 해결책: 출력물을 절대 신뢰하지 마세요. 저는 생성된 모든 모델을 뷰어에서 열기 전에 항상 유효성 검사(validation pass)를 거칩니다. 대부분의 실패 사례를 잡아내는 몇 가지 확인 사항은 다음과 같습니다:
def validate_model(model):
errors = []

방의 벽은 폐쇄 루프(closed loops)를 형성해야 합니다.

for room in model.get('rooms', []):
if not is_closed_polygon(room['walls']):
errors.append(f"Room {room['id']} has open walls")

개구부(opening)는 부모 벽 안에 맞아야 합니다.

for opening in model['openings']:
wall = model['walls'][opening['wall_index']]
wall_length = distance(wall['start'], wall['end'])
if opening['position'] + opening['width'] > wall_length:
errors.append(
f"Opening at wall {opening['wall_index']} "
f"extends past wall end"
)

문턱(sill) + 높이는 벽의 높이를 초과해서는 안 됩니다.

if opening['sill'] + opening['height'] > wall['height']:
errors.append("Opening taller than wall")
return errors
유효성 검사에 실패하면, 저는 오류를 모델에 다시 입력하고 수정하도록 요청합니다. 이 반복 과정은 OpenSCAD가 되기 전에 기하학적 오류의 약 90%를 잡아냅니다.
예방 팁 (Prevention Tips)
제가 세 주말 전 알았더라면 좋았을 몇 가지 사항들:

  1. 사소한 것이 아니라면 자유 형식(free-form) OpenSCAD 생성을 피하세요.
  2. 제약된 중간 형식(constrained intermediate format)과 결정론적 변환기(deterministic transformer)를 사용하세요.
  3. 모델의 공간 추론 능력은 기하학 20줄을 넘어서면 무너집니다.
  4. 프롬프트에서 단위를 명시적으로 지정하세요.

"3 meters"는 3 OpenSCAD 단위로 해석되지만, 모델은 가끔 파일 중간에 밀리미터 (mm) 단위를 섞어 쓰기도 합니다. 하나를 선택하여 스키마 (schema)에서 강제하세요. $fn을 일관되게 사용하십시오. 모델은 객체 간의 면 (facet) 수를 다르게 설정하는 경향이 있어, 하나의 실린더는 매끄러운데 인접한 실린더는 육각형으로 보이는 등의 이상한 시각적 아티팩트 (visual artifacts)를 초래합니다. 생성되는 모든 파일의 상단에 전역적으로 설정하십시오. 검증을 위해 헤드리스 모드 (headless mode)로 렌더링하세요. OpenSCAD에는 CLI가 있습니다: openscad -o output.stl input.scad. 만약 코드가 파싱(parsing)되었더라도 퇴화된 메쉬 (degenerate mesh, 부피가 0이거나 non-manifold인 상태)를 생성한다면, 이는 무언가 잘못되었다는 강력한 신호입니다. 프롬프트에 예시를 포함하세요. 시스템 프롬프트에 포함된 몇 개의 고품질 OpenSCAD 스니펫 (snippets)은 그 어떤 언어적 지시보다 출력 품질 향상에 더 큰 도움이 됩니다. 모델은 당신이 원하는 컨벤션 (convention)을 직접 봐야 합니다. OpenSCAD를 넘어 더 넓게 적용되는 교훈은 다음과 같습니다: 모델이 어떤 일을 잘하지 못한다면, 그 일을 잘하게 만들려고 애쓰지 마세요. 남은 부분이 모델이 이미 잘하는 영역이 될 때까지 문제를 축소하십시오. Text-to-JSON은 모델이 할 수 있는 일입니다. 좌표계 대수 (Coordinate-frame algebra)는 할 수 없는 일입니다. 정확히 맞아야 하는 부분은 결정론적 코드 (deterministic code)가 담당하게 하십시오.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0