AI 에이전트가 Rails API 서버를 호출해서 티켓을 자동 배정하는 디스패처를 만들었다. 로직 자체는 간단한데 붙이는 과정에서 예상치 못한 곳에서 계속 막혔다. 겪은 것들을 기록해 둔다.


1. Ruby 3.0 kwargs 분리 — render_success(key: val) 가 왜 터지나

가장 오래 고생한 것. Rails 컨트롤러에서 응답 헬퍼를 이렇게 호출했다:

render_success(tickets: tickets_list, pagination: pagination_data)

서버 로그에 찍힌 에러:

ArgumentError - unknown keywords: :tickets, :pagination

헬퍼 정의는 이렇다:

def render_success(data, status: :ok)
  render json: { success: true, data: data }, status: status
end

Ruby 2.x에서는 render_success(tickets: ..., pagination: ...) 호출 시 {tickets: ..., pagination: ...} 해시가 data에 들어갔다.

Ruby 3.0부터 키워드 인수와 일반 인수가 완전히 분리됐다. tickets:, pagination: 이 키워드처럼 생겼으니 Ruby 3.0은 이것들을 키워드 인수로 인식한다. 그런데 render_successdata 하나만 positional 인수로 받으니 ArgumentError.

단일 키워드처럼 생긴 것도 마찬가지다:

render_success(ticket: ticket_json(@ticket))
# → ArgumentError: wrong number of arguments (given 0, expected 1)

ticket: 하나도 키워드 인수로 해석되어 data에 아무것도 안 들어간다.

해결: 명시적으로 해시 {}로 감싸면 Ruby가 “이건 해시 리터럴이다"라고 확실히 인식한다.

# 전부 이렇게 바꿔야 한다
render_success({ ticket: ticket_json(@ticket) })
render_success({ tickets: tickets_list, pagination: pagination_data })

프로젝트 전체 컨트롤러를 훑어서 render_success( 뒤에 {가 없는 것을 전부 수정했다. 한 줄짜리는 sed로 일괄 처리:

sed -i '' \
  -e 's/render_success(ticket: \(.*\))/render_success({ ticket: \1 })/g' \
  -e 's/render_success(message: "\(.*\)")/render_success({ message: "\1" })/g' \
  app/controllers/api/v1/tickets_controller.rb

2. Docker restart 는 env_file을 재로드하지 않는다

.env 파일에 환경 변수를 추가하고 컨테이너를 재시작했다:

docker compose restart

그런데 컨테이너 안에서 확인하면 새 변수가 없다.

docker exec mycontainer python3 -c "import os; print(os.environ.get('NEW_VAR', 'MISSING'))"
# → MISSING

원인: docker compose restart는 프로세스만 재시작한다. 컨테이너 자체를 재생성하지 않기 때문에 env_file을 다시 읽지 않는다.

해결: 컨테이너를 재생성해야 한다.

docker compose up -d

up -d는 설정이 바뀐 서비스를 재생성(Recreate)한다. 이렇게 해야 env_file의 새 값이 컨테이너에 반영된다.

Container mycontainer  Recreate
Container mycontainer  Recreated
Container mycontainer  Starting
Container mycontainer  Started

3. Synology NAS에는 crontab 명령이 없다

파이썬 스크립트를 5분마다 실행하는 크론잡을 걸려고 했다.

ssh user@nas "crontab -e"
# → crontab: command not found

Synology DSM은 일반 Linux와 다르게 crontab 명령을 기본으로 제공하지 않는다. /etc/crontab을 직접 편집해야 한다.

# /etc/crontab 형식: 분 시 일 월 요일 실행유저 명령
*/5    *    *    *    *    root    /usr/local/bin/docker exec mycontainer python3 /home/node/script.py >> /path/to/logs/script.log 2>&1

주의할 점:

  • 필드 구분은 이 원칙이지만 공백도 동작한다
  • 실행 유저 컬럼(root)이 일반 사용자용 crontab과 달리 있다
  • sudo 권한으로 편집해야 한다

4. SSH heredoc에서 ! 문자가 문제가 된다

Rails runner로 서버에서 짧은 Ruby 코드를 실행하고 싶었다:

ssh user@server 'bundle exec rails runner "record.update!(key: value)"'

이게 자꾸 실패했다. update!!가 bash에서 히스토리 확장 문자로 해석되는 것이 문제다.

단순 따옴표 안에서도 SSH를 타면 해석 방식이 달라져서 heredoc을 쓰면 더 심각해진다:

# heredoc에서 update! → update\! 로 변환되어 Ruby 문법 에러
ssh user@server << 'EOF'
  record.update!(key: value)
EOF

해결 1: !를 쓰지 않는 메서드로 교체한다. Rails에는 bang 메서드 대신 쓸 수 있는 것들이 있다:

record.update_columns(key: value)   # update! 대신
record.save(validate: false)        # save! 대신

해결 2: 코드를 서버 파일로 먼저 쓴 다음 실행한다.

# Python으로 파일 작성 (! 문자 포함 가능)
ssh user@server "python3 -c \"
with open('/tmp/fix.rb', 'w') as f:
    f.write('''
k = Model.find_by(token: \\\"TOKEN\\\")
k.update_columns(permissions: k.permissions | [\\\"new_perm\\\"])
puts k.reload.permissions.inspect
''')
\""

# 그 다음 실행
ssh user@server 'bundle exec rails runner /tmp/fix.rb'

5. SCP/SFTP가 안 되는 디렉토리 — base64 우회

NAS 서버에 파일을 올리려고 했는데:

scp script.py user@nas:/path/to/dir/
# → scp: /path/to/dir/script.py: Permission denied

디렉토리가 root로 생성되어 있어서 일반 SSH 계정으로는 SCP/SFTP 쓰기가 안 됐다. chmod로 권한을 줘도 SSH 세션에서는 바로 적용이 안 되는 상황.

우회 방법: SSH + base64 인코딩으로 전송한다.

로컬에서:

base64 script.py | ssh user@nas "base64 -d | sudo tee /path/to/dir/script.py > /dev/null"

또는 Python으로 내용을 직접 echo:

CONTENT=$(base64 < script.py)
ssh user@nas "echo '$CONTENT' | base64 -d | sudo tee /path/to/dir/script.py"

SCP는 SFTP 서브시스템을 타지만, 이 방식은 순수 SSH 셸 명령만 사용해서 sudo로 권한 우회가 가능하다.


6. update_column vs update_columns — PostgreSQL 배열 컬럼

마이그레이션에서 PostgreSQL 배열 타입 컬럼을 업데이트하려고 했다:

record.update_column(:permissions, record.permissions + ['new_perm'])

이게 말썽이었다. update_column은 단수형으로, 단일 컬럼만 바꾸고 콜백도 건너뛴다. 배열 연산 결과(Array)를 그대로 넘기면 pg 드라이버가 직렬화를 제대로 못 하는 경우가 있다.

**update_columns (복수형)**을 쓰면 더 안정적으로 동작했다:

new_perms = (record.permissions || []) | ['new_perm']
record.update_columns(permissions: new_perms)

| 연산자는 중복 없이 배열을 합쳐준다. +는 중복을 허용하므로 권한 목록에는 |가 더 적합하다.


7. wip_count 가 DB 컬럼인 줄 알았는데 computed field였다

유저 모델에 wip_count 속성이 있길래 update_columns(wip_count: 0)으로 리셋하려 했다:

can't write unknown attribute 'wip_count'

확인해 보니 DB 컬럼이 아니라 Ruby 메서드였다:

def wip_count
  assigned_tickets.where(aasm_state: %w[assigned in_progress]).count
end

실시간으로 활성 티켓 수를 세는 computed field. 값을 바꾸려면 관련 티켓의 상태를 바꾸거나, max_wip을 조정해야 한다.

# wip_count는 못 바꾸고, max_wip을 늘리면 수용 가능 티켓 수가 늘어난다
user.update_columns(max_wip: 20)

정리

문제핵심 원인해결
render_success(key: val) 500 에러Ruby 3.0 kwargs/positional 분리{} 명시적 해시 래핑
Docker 환경 변수 미반영restart는 컨테이너 재생성 안 함up -d 사용
NAS crontab 없음Synology DSM 특성/etc/crontab 직접 편집
SSH heredoc ! 오류bash 히스토리 확장update_columns! 없는 메서드 사용
SCP Permission deniedroot 소유 디렉토리base64 + SSH tee 우회
PostgreSQL 배열 업데이트update_column 직렬화 이슈update_columns + `
update_columns 오류computed field를 DB 컬럼으로 착각모델 정의 확인 후 max_wip 조정

하루 동안 겪은 것들인데 각각은 사소하지만 연속으로 터지니 꽤 피로했다. 특히 Ruby 3.0 kwargs 변경은 마이그레이션 안 한 프로젝트에서 자주 만날 것 같아서 기록해 둔다.