웹앱
c8f74a0 기준입니다.Campfire는 방·개인 대화·파일 공유·검색·브라우저 알림·봇 연동을 갖춘 웹 채팅 애플리케이션입니다. 저장소 이름은 once-campfire이고 제품 이름은 Campfire입니다. 한 조직이 자기 서버에서 운영하는 구성이 중심이며, 운영 가이드도 여러 고객 조직을 완전히 분리하려면 인스턴스를 각각 배포하도록 설명합니다.
공부할 때는 기능 목록보다 메시지의 이동 경로를 보세요. 메시지 하나를 따라가면 저장·화면 갱신·접근 권한·검색·비동기 작업이 하나의 제품 안에서 연결되는 모습을 배울 수 있습니다.
Rails는 접수 직원입니다. 회원인지 확인하고, 어느 방에 글을 남길 수 있는지 판단합니다. SQLite는 대화 원본과 검색용 목록을 보관하는 기록실입니다.
Action Cable과 Redis는 방에 있는 사람에게 새 소식을 전달하는 방송 설비입니다. Resque 작업자는 푸시 알림처럼 뒤에서 처리할 일을 맡습니다. 건물 하나에 들어 있어도 각 부서의 책임은 다릅니다.
| 사용자 행동 | 주요 소스 | 배울 질문 |
|---|---|---|
| 방에 글 쓰기 | MessagesController#create | 누가 썼는지와 어느 방인지 어디서 결정할까? |
| 새 글 실시간 수신 | Message::Broadcasts#broadcast_create | 저장된 내용을 다른 브라우저에 어떻게 보낼까? |
| 대화 검색 | Message.search_reachable | 검색 속도와 비공개 방의 권한을 함께 지킬 수 있을까? |
| 자리 비운 동안 알림 | Room::MessagePusher#push | 누구에게 알릴지, 누구에게는 알리지 않을지 어떻게 고를까? |
| 파일에 미리보기 붙이기 | Message::Attachment#process_attachment | 미리보기 실패가 메시지 자체의 실패여야 할까? |
HTML의 태그, HTTP 요청과 응답, 데이터베이스의 행과 기본 키 정도를 알고 있으면 시작할 수 있습니다. Ruby를 처음 접한다면 class는 객체의 설계도, def는 메서드 정의, include는 기능 모듈을 섞는 문법으로 읽으세요. Rails의 짧은 선언은 그 뒤에서 어떤 SQL과 콜백을 실행하는지까지 생각해야 이해됩니다.
# app/models/message.rb의 관계 선언 발췌
belongs_to :room, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_rich_text :body
# 뜻: 방에 속하고, 기본 작성자는 현재 사용자이며, 본문은 서식 있는 글이다.
# touch: true는 연결된 방의 갱신 시각도 바꾼다는 뜻이다.
“폼이 요청을 보낸다”에서 멈추지 말고, 회원 확인 → 방 접근 확인 → 저장 → HTML 생성 → 방송 → 임시 메시지 정리까지 자기 말로 설명해 보세요. 이후 검색과 알림을 붙여 읽으면 됩니다.
GitHub 설명은 “구독 없이 쓰는 아주 단순한 그룹 채팅”을 내세웁니다. MIT 라이선스로 코드를 읽고 수정할 수 있고, docs/self-hosting.md는 Docker 이미지와 영구 볼륨으로 직접 운영하는 절차를 설명합니다. 매달 서비스 사용료를 내는 모델과는 다른 소유·운영 방식입니다. 다만 서버 비용, 백업, 업데이트에 드는 노력까지 없어지는 것은 아닙니다.
선정 시점의 위치는 GitHub Trending Ruby Monthly #5입니다. 순위만으로 실제 유입 경로나 인기 상승의 원인을 증명할 수는 없습니다. 여기서 주목할 이유로 보는 것은 완성된 채팅 제품의 코드를 작은 단위로 추적할 수 있다는 학습 가치입니다.
| 관점 | Slack의 공식 소개 | Campfire의 소스·운영 가이드 |
|---|---|---|
| 중심 기능 | 채널에서 사람·정보·업무 도구를 연결 | 방·개인 대화·검색·첨부·알림에 집중 |
| 확장 방식 | Marketplace 앱과 Slack API | 봇 API, 웹훅, 직접 소스 수정 |
| 코드를 배울 때 | 공개 API의 사용법을 중심으로 학습 | 요청 처리부터 SQL·템플릿까지 내부 구현을 추적 |
| 이 글의 비교 범위 | 공식 소개에 나온 채널·연동 방향 | 단일 조직의 자체 운영 구조 |
Slack의 채널과 연동 설명은 Slack 공식 소개를 기준으로 합니다. 요금이나 전체 기능의 우열을 비교하는 표는 아닙니다. Campfire의 장점은 이 목적에서 대화 도구의 내부를 이해하고 자기 요구에 맞게 바꿀 수 있다는 점입니다.
이 저장소의 매력은 기능의 개수보다, 한 서비스를 이해하는 데 필요한 코드와 운영 설정이 함께 있다는 데 있습니다. 모노리스 구조여서 컨트롤러와 모델, 화면, 배포 구성을 같은 저장소에서 이어 읽을 수 있습니다.
README.md에는 Rails 외 여러 언어 구현과의 비교가 있습니다. Rails 열은 방 페이지 4,101요청/초, 메시지 쓰기 330요청/초를 기재합니다. 조건은 Ryzen AI MAX+ 395·RAM 32GB, 애플리케이션당 하드웨어 코어 4개, 동시 클라이언트 16개입니다. 이 수치를 모든 서버의 예상 처리량으로 바꾸어 읽으면 안 됩니다.
또한 bench/README.md가 설명하는 저장소 내 HTTP 비교 도구는 한 Puma 작업자·스레드 5개를 사용하고, Thruster·TLS·gzip을 측정에서 제외합니다. README의 다른 구현 비교와 로컬 최적화 도구의 측정 조건을 같은 것으로 간주하지 마세요. 이 수치는 README의 보고값이며, 임의의 배포 환경에 대한 처리량 보장이 아닙니다.
자동차의 시험장 최고 속도와 출퇴근 평균 속도는 다릅니다. 캐시된 페이지 읽기와 새 메시지 쓰기도 서로 다른 일을 합니다. 숫자를 보기 전에 무엇을 읽고 썼는지, 어떤 장치를 거쳤는지부터 보세요.
버전의 기준은 .ruby-version, Gemfile, Gemfile.lock입니다. Gemfile은 허용 범위와 가져올 위치를 선언하고, 잠금 파일은 구체적으로 선택된 버전과 Git 리비전을 기록합니다. 예를 들어 puma ~> 8.0과 실제 선택된 8.0.2는 서로 다른 정보입니다.
| 구성 요소 | 확인한 버전 | 이 제품에서의 역할 |
|---|---|---|
| Ruby | 4.0.7 | .ruby-version과 Docker의 RUBY_VERSION이 일치 |
| Rails | 8.2.0.alpha | 라우팅, ORM, 화면 렌더링, 실시간 통신, 파일 저장을 통합 |
| sqlite3 | 2.9.6 | Ruby에서 SQLite에 접근하는 젬. SQLite 엔진 자체의 버전과 다름 |
| redis | 5.4.1 | Redis 서버에 접속하는 Ruby 클라이언트 |
| Puma | 8.0.2 | 프로세스와 스레드로 HTTP 요청 처리 |
| Resque / resque-pool | 2.7.0 / 0.7.1 | Redis 기반 작업 큐와 작업자 프로세스 관리 |
| bcrypt | 3.1.22 | User.has_secure_password 선언과 연결되는 비밀번호 해시 |
| web-push / kredis | 3.1.0 / 1.8.0 | 브라우저 푸시 전송 / Redis 기반 자료구조 지원 |
| image_processing | 1.14.0 | 이미지 변환. 시스템 라이브러리 libvips와 함께 사용 |
Rails는 Gemfile에서 rails/rails의 main을 지정하지만, 잠금 파일은 e3d5c569d217c56c022b04acb127a8c24dbe9685를 가리킵니다. config/application.rb도 config.load_defaults 8.2를 사용합니다. 따라서 “최신 Rails 안정판 예제”로 읽기보다 특정 개발 리비전에 묶인 애플리케이션으로 읽어야 합니다.
room.messages.create!처럼 표현하지만 실제로는 SQL이 실행됩니다. 마지막의 !는 실패 시 예외를 던지는 메서드라는 관례입니다.| 구성 요소 | 잠금 파일·설정의 근거 | 읽을 위치 |
|---|---|---|
| Turbo Rails | turbo-rails 2.0.16, Git 30cd8fcc… | app/views/messages/create.turbo_stream.erb |
| Stimulus Rails | stimulus-rails 1.3.4 | app/javascript/controllers/ |
| Lexxy | lexxy 0.9.24 | 본문 입력기와 app/javascript/lib/rich_text/ |
| Importmap Rails | 2.2.2, Git 51c1a531… | config/importmap.rb |
| Propshaft | 1.3.2, Git dc979db8… | 정적 자산의 경로와 배포용 자산 처리 |
| Rails Request.js | Importmap 주석의 @0.0.8 | vendor/javascript/@rails--request.js |
| Highlight.js | 벤더 파일을 Importmap으로 연결 | vendor/javascript/highlight.js/core.js, 언어별 모듈 |
표의 Turbo Rails와 Stimulus Rails 버전은 Rails 통합 젬의 버전입니다. 브라우저용 Turbo·Stimulus의 독립 패키지 버전과 혼동하지 마세요. Highlight.js는 config/importmap.rb에 실제 연결된 벤더 파일을 기준으로 읽으세요.
package.json은 없고, app/javascript/application.js가 Turbo와 초기화 코드, 컨트롤러를 불러옵니다. 모듈 이름을 실제 파일 URL에 연결하는 곳은 Importmap입니다. 화면의 기본 모양은 서버가 만들고, 입력·스크롤·임시 표시 같은 상호작용은 브라우저의 작은 모듈들이 맡습니다.
예를 들어 app/javascript/controllers/composer_controller.js는 전송과 초안 저장을, app/javascript/controllers/messages_controller.js는 새 메시지와 임시 메시지의 정리를 맡습니다. app/javascript/models/message_paginator.js는 이전·다음 페이지를 가져옵니다. HTML 중심 구조에도 JavaScript는 필요하며, 역할을 나누는 방식이 핵심입니다.
# Procfile 발췌
web: bundle exec thrust bin/start-app
redis: redis-server config/redis.conf
workers: FORK_PER_JOB=false INTERVAL=0.1 bundle exec resque-pool
# Dockerfile에서 확인하는 큰 단계
Ruby 4.0.7 slim
→ 시스템 패키지 설치
→ 별도 build 단계에서 bundle install, assets:precompile
→ 최종 이미지에 젬과 애플리케이션 복사
→ UID 1000 사용자로 bin/boot 시작
bin/boot의 ProcessMonitor는 Procfile을 읽고 각 프로세스를 시작하며 종료 신호를 전달합니다. 웹 앞단의 Thruster 0.1.23은 thrust 명령으로 연결됩니다. bin/start-app은 남은 PID 파일을 지우고 db:prepare 후 Rails 서버를 시작합니다.
Docker에는 curl, libsqlite3-0, libvips, libjemalloc2, ffmpeg, redis가 설치됩니다. 이 운영체제 패키지들은 설치 명령에 버전이 고정되어 있지 않습니다. Redis 젬 5.4.1을 Redis 서버 버전이라고 쓰면 안 됩니다. 개발용 bin/setup의 Redis 이미지는 redis:7이고 CI 서비스는 버전 없는 redis입니다.
| 검사 축 | 실제 설정 | 배울 점 |
|---|---|---|
| 보안·문법 | .github/workflows/ci.yml의 Brakeman, RuboCop | 기능 테스트와 정적 검사의 역할이 다름 |
| 템플릿 | bin/rails herb:check, 잠금 파일의 Herb 0.11.0 | 서버 템플릿도 검사 대상 |
| 동작 | Redis 서비스와 시스템 패키지를 준비한 Rails·브라우저 테스트 | 실제 의존 환경이 필요한 검증을 분리 |
| 공급망 | Action SHA 고정, actionlint·zizmor | CI 설정 자체도 검토할 코드 |
| 이미지 배포 | .github/workflows/publish-image.yml의 linux/amd64·linux/arm64, Cosign 서명 | CPU 아키텍처와 이미지 출처까지 배포에 포함 |
config/ci.rb의 로컬 검사 묶음에는 젬 감사, Importmap 감사, 시드 재생성도 있습니다. GitHub Actions의 작업 목록과 로컬 bin/ci의 단계는 완전히 같지 않습니다. 이름만 보고 “동일한 검사”로 가정하지 말고 두 파일을 비교해 보세요.
위 도식의 Web Push 전송과 봇 웹훅 호출은 외부 서비스로 나가는 경계입니다. Redis는 대화 원본을 보관하는 주 데이터베이스가 아닙니다. config/cable.yml은 실시간 중계에 Redis를 지정하고, config/environments/production.rb는 작업 큐와 공유 캐시에도 Redis를 사용합니다. 대화 원본의 중심은 SQLite입니다.
전송의 출발점은 app/javascript/controllers/composer_controller.js의 #submitMessage()입니다. 브라우저가 만든 clientMessageId로 임시 메시지를 넣고, 다음 화면 프레임을 기다린 다음 폼을 전송합니다. 기다리는 동안에도 사용자는 자신이 쓴 글을 볼 수 있습니다.
# app/controllers/messages_controller.rb — create의 핵심 발췌
set_room
@message = @room.messages.create_with_attachment!(message_params)
@message_html = render_to_string partial: "messages/message",
formats: :html, locals: { message: @message }
@message.broadcast_create(html: @message_html)
deliver_webhooks_to_bots
# app/views/messages/create.turbo_stream.erb
<%= turbo_stream.append dom_id(@message.room, :messages), @message_html %>
같은 @message_html이 전송자의 HTTP 응답과 방의 방송에 사용됩니다. app/models/message/broadcasts.rb의 broadcast_create가 broadcast_append_to room, :messages를 호출합니다. “어느 대상을 어떻게 고칠지”와 HTML 조각을 전하는 방식은 Turbo Streams 공식 문서의 기본 모델과 같습니다.
submitEnd()가 실패한 임시 메시지를 표시합니다.여기서 중요한 구분은 확정 메시지의 DOM ID와 임시 메시지를 연결하는 ID입니다. app/helpers/messages_helper.rb의 message_tag는 확정 DOM ID를 dom_id(message), 즉 서버 기본 키에서 만듭니다. 사용자가 고른 client_message_id는 별도 데이터 속성으로 전달합니다.
// app/javascript/controllers/messages_controller.js의 #reconcilePendingMessage() 핵심 발췌
const clientMessageId = target.dataset.clientMessageId
if (target.dataset.messageId && clientMessageId) {
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
if (pending !== target) pending.remove()
})
}
확정 메시지를 받으면 같은 임시 ID를 가진 임시 요소만 제거합니다. 사용자가 다른 메시지의 임시 ID를 재사용해도 실제 메시지의 서버 ID까지 바꿀 수는 없습니다. 이 의도는 test/system/message_id_spoofing_test.rb에 두 사용자 브라우저를 이용한 시나리오로 남아 있습니다. 임시 ID는 인증 수단도, 유일한 전송을 보장하는 키도 아닙니다.
RoomScoped#set_room은 소속을 조회할 때 Current.user.memberships.find_by!(room_id: ...)를 사용합니다. 전체 Room에서 번호를 찾는 것과 달리, 현재 사용자의 소속에서 찾기 때문에 다른 방 번호를 넣는 것만으로 접근 범위를 넓힐 수 없습니다.
| 입구 | 파일·메서드 | 접근 범위의 기준 |
|---|---|---|
| 일반 메시지 요청 | app/controllers/concerns/room_scoped.rb#set_room | 현재 사용자의 Membership |
| 봇 메시지 API | app/controllers/messages/by_bots_controller.rb#set_room | 인증된 봇의 Current.user.rooms |
| 검색 결과 | Message.search_reachable | 사용자가 접근 가능한 메시지와 회원 관계 |
| 실시간 구독 | RoomMessagesChannel#subscribed | 서명된 스트림을 검증한 뒤 현재 방 소속 재확인 |
| 기존 세션 연결 | ApplicationCable::Connection#beat | 일정 간격으로 세션이 여전히 존재하는지 확인 |
특히 app/channels/room_messages_channel.rb는 verified_stream_name_from_params만 믿지 않습니다. subscribable_room(user, stream_name)이 스트림 이름에서 방을 찾아 user.rooms.find_by로 확인합니다. 서명은 이름이 변조되지 않았음을 증명하지만, 그 사용자가 지금도 회원인지는 증명하지 않기 때문입니다.
Membership의 after_destroy_commit는 원격 연결을 다시 설정합니다. 재접속 뒤에는 새 회원 검사가 실행됩니다. 기본 Turbo::StreamsChannel로 우회하는 길도 RoomStreamsAreAuthorized#subscribed가 막습니다. 설정 연결은 config/initializers/turbo_streams_authorization.rb에 있습니다.
유효한 모양의 출입증과 오늘도 유효한 입주 자격은 다릅니다. 퇴실한 사람이 예전 출입증을 보관하고 있을 수 있습니다. 스트림 서명을 확인한 다음 현재 회원 명부를 확인하는 이유입니다.
app/channels/application_cable/connection.rb의 SESSION_CHECK_INTERVAL = 1.minute도 살펴보세요. 연결 시점에만 로그인 상태를 검사하면 로그아웃 이후에도 열린 소켓이 남을 수 있습니다. 즉시 연결 종료 요청에 더해 하트비트에서 세션을 주기적으로 재확인합니다. 네트워크 지연까지 포함한 “항상 즉시 차단”을 보장한다고 읽어서는 안 됩니다.
db/migrate/20231215043540_create_initial_schema.rb는 fts5(body, tokenize=porter) 가상 테이블 message_search_index를 만듭니다. 메시지 기본 키와 FTS5의 rowid를 연결하고, HTML을 걷어낸 본문이나 첨부 파일 이름을 색인합니다.
# app/models/message.rb — 콜백 발췌
after_save :record_creation, if: :previously_new_record?
after_create_commit -> { room.push_later(self) }
# record_creation의 본문
create_in_index
room.unread_memberships(self)
# app/models/message/searchable.rb — 색인 쓰기의 핵심
execute_sql_with_binds \
"insert into message_search_index(rowid, body) values (?, ?)",
id, plain_text_body
새 메시지의 색인과 미읽음 상태는 저장 트랜잭션 안에서 바뀝니다. 색인 삽입이나 미읽음 갱신이 실패하면 메시지와 관련 행도 되돌아가도록 구성했습니다. test/models/message/creation_test.rb에는 이 실패를 일부러 발생시키고 메시지·첨부·색인·미읽음 상태가 함께 복구되는지 확인하는 코드가 있습니다.
수정·삭제는 생성과 시점이 다릅니다. Message::Searchable의 색인 수정은 after_update_commit, 제거는 after_destroy_commit에 연결되어 있습니다. 따라서 앞의 원자성 설명은 새 메시지 생성 경로에 해당하며, 모든 색인 갱신이 원본과 같은 트랜잭션이라는 뜻은 아닙니다.
반대로 Web Push 작업을 넣는 after_create_commit은 DB 확정 뒤에 실행됩니다. Rails 콜백 문서에서 after_save와 after_commit의 시점을 비교해 보세요. 외부에 알림을 보낸 뒤 DB 저장을 되돌려도 이미 보낸 알림을 회수할 수는 없습니다.
같이 성공해야 하는 데이터는 같은 트랜잭션에 묶고, 되돌릴 수 없는 외부 효과는 저장이 확정된 뒤에 시작합니다. 다만 DB 커밋과 Redis 큐 등록 사이까지 하나의 원자적 처리로 보장하는 구조는 아닙니다. 프로세스 종료나 큐 장애까지 다루려면 별도의 전달 보장 설계가 필요합니다.
검색은 search_reachable(user, query, size: 100)에서 메시지 ID가 큰 순서로 일치 결과 최대 1,000개를 먼저 살펴보고 접근 가능한 결과를 고릅니다. 비공개 방의 대량 기록 뒤에 오래된 접근 가능 결과가 가려지면, 권한을 적용한 관계로 다시 검색하는 경로가 있습니다. match_terms는 단어를 인용해 AND 같은 입력을 FTS 연산자가 아닌 검색어로 취급합니다.
app/models/response_cache.rb의 ResponseCache는 기본 작업자당 64MiB의 응답 캐시입니다. 한 항목은 최대 1.megabyte, 키는 최대 2.kilobytes로 제한합니다. 별도의 FragmentCache도 MemoryStore.new(size: 64.megabytes)를 사용합니다. 두 예산은 합쳐서 64MiB가 아닙니다.
# ResponseCache#current_version의 핵심 발췌
version = @observer.get_first_value("PRAGMA data_version")
if @version != version
@entries.clear
@bytes = 0
@version = version
end
[ @database, @namespace, @version ]
여기서 @observer는 별도의 지속적인 읽기 전용 SQLite 연결입니다. 다른 연결에서 커밋하면 data_version 변화를 관찰하여 응답 항목을 지웁니다. 이 값은 전역 커밋 번호가 아니라 같은 연결에서 이전 값과 비교해야 하는 값입니다. SQLite 공식 설명과 구현의 연결 수명을 함께 읽으세요.
app/controllers/concerns/cached_responses.rb는 방 권한 확인 뒤 응답을 재사용하며, 키에는 사용자 ID, 세션 토큰, 전체 요청 경로, 호스트, 언어, 압축 방식, Turbo Frame 헤더 등이 들어갑니다. 캐시가 있다는 이유로 다른 사용자의 HTML을 돌려주거나 로그인 검사를 건너뛰면 안 됩니다.
RecordCache.fetch는 공유 Active Record 객체를 그대로 돌려주는 대신 불변 JSON 스냅샷에서 요청별 객체를 재구성합니다. 같은 시점의 버전인지 다시 검사하고, 트랜잭션 중에는 우회합니다. 또 ResponseCache#synchronize_render는 16개 고정 잠금으로 중복 렌더링을 줄여, URL마다 잠금 객체가 끝없이 늘지 않게 합니다.
렌더링 결과, 데이터 스냅샷, 권한 판단은 수명이 다릅니다. 사용자·세션을 키에 반영하고, DB 변화에 반응하고, 저장량을 제한하는 조건을 함께 읽어야 캐시 설계를 이해할 수 있습니다.
Controller는 요청을 받고, Model은 데이터와 규칙을 다루며, View는 표시할 HTML을 만듭니다. app/channels/는 오래 열려 있는 실시간 연결을, app/jobs/는 요청 이후 수행할 일을 맡습니다. 폴더가 따로 있다고 반드시 별도 서비스인 것은 아닙니다.
app/models/message.rb의 include Attachment, Broadcasts, Mentionee, Pagination, Searchable는 메시지 기능을 여러 파일로 나눕니다. app/models/message/searchable.rb 안의 이름은 Message::Searchable입니다. Rails의 이름 규칙과 자동 로딩을 함께 익히면 “이 상수는 어느 파일에서 왔지?”라는 질문에 답하기 쉬워집니다.
| 클래스 | 주요 차이 | 소스에서 볼 것 |
|---|---|---|
Rooms::Open | 인스턴스 내 활성 사용자에게 방 소속을 부여 | grant_access_to_all_users, 새 사용자의 공개 방 가입 처리 |
Rooms::Closed | 허용한 일부 사용자만 소속 | 클래스 본문이 비어 있어도 기본 Room 규칙을 상속 |
Rooms::Direct | 같은 참여자 집합의 개인 대화방을 검색 | find_or_create_for, 참여자 수와 집합을 확인하는 SQL |
type 열로 여러 클래스의 데이터를 구분하는 방법입니다. Campfire의 rooms에 Rooms::Open 같은 이름이 저장됩니다. 방 종류별 테이블을 만들지 않고 공통 관계를 공유할 수 있습니다.Room#direct_rooms_keep_their_type은 개인 대화방을 다른 종류로 바꾸지 못하게 합니다. 기존 참여자가 개인 대화에 동의했다는 사실을 방 유형 변경으로 무너뜨리지 않으려는 규칙입니다. 클래스 분류가 실제 사용자 약속과 연결되는 예입니다. 다만 find_or_create_for의 이름만 보고 동시 요청에서도 중복이 절대 없다고 추론해서는 안 됩니다.
| 종류 | 파일 수 | 물리적 줄 수 |
|---|---|---|
| Ruby | 202개 | 6,459줄 |
| JavaScript | 72개 | 3,885줄 |
| CSS | 26개 | 3,378줄 |
| ERB | 81개 | 2,274줄 |
| YAML | 6개 | 129줄 |
| Ruby 테스트 | 107개 | 9,191줄 |
앞의 다섯 행은 커밋 c8f74a0의 추적 파일 중 app/·config/·db/·lib/에 있는 해당 확장자를 집계했습니다. 주석·빈 줄·생성된 스키마를 포함하며, Ruby 테스트 행은 별도로 test/**/*.rb를 셌습니다. 벤더 코드와 테스트까지 포함한 전체 추적 파일의 Ruby는 316개·16,181줄, JavaScript는 87개·11,139줄입니다.
물리적 줄 수는 난이도나 품질의 점수가 아닙니다. README의 Rails 5.5 KLOC는 백엔드와 템플릿의 실행 코드만 세고, 단순 HTML·테스트·의존성·생성 파일 등을 제외하는 기준입니다. 위 수치와 차이가 나는 것은 집계 범위와 방법이 다르기 때문입니다.
처음부터 모든 폴더를 읽기보다, 기능 하나를 골라 라우트·컨트롤러·모델·템플릿·테스트를 이어 읽는 편이 효과적입니다.
belongs_to, has_many, scope, after_save를 단순 문법으로 외우지 마세요. app/models/message.rb와 app/models/room.rb에서 관계를 종이에 그린 뒤, 생성·수정·삭제 때 어떤 연관 데이터가 달라지는지 적어 보세요.
실습 아이디어: with_presentation에 연결된 작성자·본문·첨부·반응 관계를 목록으로 만드세요. 메시지 40개를 표시하면서 작성자를 한 명씩 따로 조회하면 어떤 비용이 생기는지, 미리 읽는 방식이 왜 필요한지 설명해 보세요.
preload와 includes를 사용합니다. 페이지 객체의 preload_associations는 캐시에 없는 항목만 필요한 관계를 읽도록 돕습니다.app/javascript/controllers/composer_controller.js의 saveDraft()는 composer-draft-${this.roomIdValue} 키로 초안을 localStorage에 저장합니다. 이것은 서버에 저장된 메시지가 아닙니다. 임시 전송 표시, 확정 메시지, 작성 중 초안은 서로 다른 상태입니다.
실습 아이디어: submit()부터 #reconcilePendingMessage()까지의 상태 변화를 그리세요. 실패 표시가 생겼을 때 입력한 본문은 어디에 남는지, 재전송 기능을 붙이려면 어떤 데이터가 더 필요한지 검토해 보세요.
app/javascript/models/message_paginator.js는 IntersectionObserver로 처음·마지막 메시지가 보이는지 감지합니다. MAX_MESSAGES = 300과 MAX_MESSAGES_LEEWAY = 20을 두고, 초과량이 여유치보다 커지면 새로 불러온 쪽의 반대편 요소를 정리합니다. 최신 메시지를 추가하면 위쪽을, 이전 페이지를 추가하면 아래쪽을 줄입니다. 서버 페이지 크기 Message::Pagination::PAGE_SIZE = 40과 브라우저 보관 한도는 다른 설정입니다.
Message#plain_text_body는 서식 없는 본문을 추출하고, 본문이 없으면 첨부 이름을 사용합니다. Message::Searchable은 생성·수정·삭제에 맞춰 FTS5를 갱신합니다. 검색 엔진을 외부에 두지 않아도 색인과 원본을 일치시키는 문제는 남습니다.
실습 아이디어: “첨부만 있는 글”, “본문 수정”, “반응만 추가”, “검색어가 AND인 경우”의 예상 색인을 적어 보세요. test/models/message/searchable_test.rb에서 반응 추가가 불필요한 색인 재작성을 만들지 않는지 확인하는 의도를 읽으세요.
현재 토크나이저는 porter입니다. SQLite FTS5 문서에 따르면 Porter 처리는 영어 어간 추출을 위한 것입니다. 한국어 형태소 검색이나 임의 부분 문자열 검색이 자동으로 제공된다고 기대하면 안 됩니다. 실제 한국어 검색 품질은 조사·띄어쓰기·부분 일치 사례를 모아 따로 평가할 주제입니다.
인증은 “누구인가”, 인가는 “이 방을 볼 수 있는가”입니다. Authentication::SessionLookup#find_session_by_cookie로 사용자 세션을 찾은 다음에도, 방의 회원 관계를 확인해야 합니다. RoomMessagesChannelTest에는 회원이었던 사용자의 재구독 거절 사례가 있습니다.
실습 아이디어: HTTP 요청·검색·WebSocket 구독·봇 API 네 입구를 표로 만들고, 각 입구의 인증 정보와 접근 범위를 채우세요. 화면에서 버튼을 숨기는 것과 서버에서 요청을 거절하는 것의 차이도 설명해 보세요.
Message::BroadcastUnreadRoomJob는 회원별 방송을, Room::PushMessageJob는 브라우저 알림을 맡습니다. Room#destroy_later는 먼저 소속을 제거하고 Room::DestroyJob에 실제 정리를 맡깁니다. 메시지를 하나씩 지워 다른 쓰기가 사이에 진행될 여지를 줍니다.
실습 아이디어: “사용자가 응답을 받기 전에 끝나야 할 일”과 “뒤에서 해도 되는 일”을 나누고 근거를 적으세요. 첨부의 process_attachment는 현재 생성 호출 안에서 실행되므로, 파일 미리보기까지 모두 작업 큐로 넘어간다고 가정하지 마세요.
알림 대상 선택은 Room::MessagePusher#relevant_subscriptions에 있습니다. 비활성 사용자를 제외하고, 자기 메시지를 제외하며, 연결 상태와 알림 설정을 반영합니다. 외부 전송 코드를 읽기 전에 누가 알림을 받아야 하는지부터 표로 만드는 연습이 좋습니다.
기술 이름을 나열하는 대신, 각 기술이 책임지는 상태와 실패했을 때 남는 상태를 설명해 보세요.
SECURITY.md는 관리자를 서버 운영자로 보는 신뢰 모델을 명시합니다. 일반 회원이 붙인 URL의 미리보기는 RestrictedHTTP::PrivateNetworkGuard로 목적지를 검증하지만, 관리자가 설정한 봇 웹훅은 내부 주소를 허용합니다. 같은 HTTP 호출이어도 입력을 고르는 주체와 권한이 달라 정책이 다릅니다.
첨부 인증도 경계를 세밀하게 봐야 합니다. config/initializers/active_storage_authentication.rb는 직접 업로드 메타데이터 생성과 디스크 쓰기에 세션 검사를 더하지만, 모든 다운로드에 방별 회원 검사를 추가하는 코드는 아닙니다. 서명된 파일 URL을 보유한 사람의 접근과 현재 방 소속의 의미를 구분하세요.
| 항목 | 확인 가능한 요구·구성 | 초보자가 볼 지점 |
|---|---|---|
| 배포 환경 | Docker가 동작하는 머신, 이미지 대상은 Linux amd64·arm64 | CPU 아키텍처에 맞는 이미지 사용 |
| 개발 환경 | Ruby 4.0.7, SQLite, ffmpeg, Redis 및 필요한 시스템 라이브러리 | docs/development.md, bin/setup |
| Ruby 의존성 | 잠금 파일의 Bundler 4.0.13 | Gemfile만 보고 다른 버전을 임의 선택하지 않기 |
| 영구 저장 | /rails/storage를 볼륨으로 보존 | 저장소 루트 기준 DB는 storage/db/, 첨부는 storage/files/, 스냅샷은 storage/backups/ |
| 네트워크 | 컨테이너 HTTP·HTTPS 80·443, 내부 Redis 기본 6379 | Redis의 내부 포트와 외부 웹 공개 포트를 구분 |
| 브라우저 | Safari 17.2, Chrome 120, Firefox 121, Opera 104 이상을 지정 | AllowBrowser::VERSIONS, IE는 차단 |
| CPU·RAM 최소치 | 개발·운영 문서에 수치로 명시된 최소 사양 없음 | 활성 사용자·첨부·작업자 수로 예산을 따로 산정 |
| GPU | 이 구성에는 GPU 의존성이나 GPU 작업 경로가 없음 | 중심 자원은 CPU·RAM·디스크 I/O |
브라우저 숫자는 코드가 선언한 접근 기준이며, 모든 기능을 해당 버전에서 각각 검증했다는 뜻은 아닙니다. 개발 준비 스크립트에는 Homebrew·pacman·apt 분기가 있지만, 이를 근거로 모든 OS에서 동일한 설치가 보장된다고 해석해서는 안 됩니다.
개발 가이드는 bin/setup을 통합 준비 절차로 안내하지만, 실제 apt 분기는 libsqlite3-0와 ffmpeg를 설치하고 mise가 없으면 중단합니다. 또한 Docker·CI의 시스템 패키지 목록에는 libvips가 있지만 이 준비 스크립트의 패키지 목록에는 없습니다. 안내의 큰 흐름과 OS별 사전 준비 범위를 구분해서 읽으세요.
config/puma.rb는 웹 작업자 수의 기본을 (Concurrent.processor_count * 0.666).ceil로 계산합니다. 스레드는 기본 최소·최대 5개이고, 환경변수 WEB_CONCURRENCY, RAILS_MAX_THREADS, RAILS_MIN_THREADS로 조정합니다. DB 풀의 미지정 기본값은 10이므로 스레드의 기본 5와 같지 않습니다.
# 설정을 읽고 계산하는 예: 프로세스가 CPU 4개를 인식한다고 가정
웹 작업자 기본값 = ceil(4 × 0.666) = 3
웹 스레드 수 = 작업자마다 기본 5
작업 큐 작업자 = ceil(4 × 0.5) = 2
# 응답 캐시와 조각 캐시의 설정 예산만 계산하는 별도 예
웹 작업자 2개 × (응답 64 MiB + 조각 64 MiB) = 256 MiB
# Ruby 객체·DB 연결·Redis·작업 큐·미디어 변환 메모리는 별도다.
이는 설정식의 계산 예이며 실제 RAM 사용량 측정치가 아닙니다. JOB_CONCURRENCY 환경변수 설정도 Puma 파일에 있지만, config/resque-pool.yml의 기본 작업자 수 식은 별도로 확인해야 합니다. 이름이 비슷하다고 같은 설정이 자동 적용된다고 가정하지 마세요.
필요한 RAM은 사용자 수 하나로 정해지지 않고, 프로세스 수·캐시 예산·첨부 처리의 최대 작업량을 합쳐 판단해야 합니다. 응답 캐시만 끄는 CAMPFIRE_RESPONSE_CACHE_MB=0은 조각 캐시의 고정 64MiB 예산까지 없애는 설정이 아닙니다.
Message::Attachment의 썸네일 크기는 최대 1200 × 800입니다. 미리보기 대상의 픽셀 상한은 250_000_000이며, 이 숫자는 업로드 파일의 바이트 제한이 아닙니다. 해상도를 읽지 못하거나 상한을 넘는 이미지·영상에는 미리보기를 만들지 않습니다. 작은 압축 파일도 해제하면 큰 메모리를 사용할 수 있습니다.
저장 공간을 계획할 때는 대화 DB + 원본 첨부 + 미리보기 + WAL + 백업 스냅샷 + 여유 공간을 보세요. lib/rails_ext/time_limited_video_previewer.rb에는 외부 미리보기 명령의 TIME_LIMIT = 10초가 있습니다. 이 제한이 전체 요청의 메모리나 모든 이미지 처리 시간을 제한하는 것은 아닙니다.
script/admin/prepare-backup의 Backup.create는 SQLite3::Backup API로 일관된 DB 스냅샷을 storage/backups/에 만듭니다. hooks/pre-backup은 이 스크립트를 호출하고, hooks/post-restore는 스냅샷을 복원한 뒤 기존 -wal·-shm 파일을 제거합니다.
실행 중인 SQLite 파일 하나만 복사하는 방식은 운영 가이드의 절차와 다릅니다. 스냅샷과 첨부를 함께 보존하고, 별도 머신에도 백업을 둘 공간을 계획하세요. 컨테이너를 지워도 데이터가 남는 성질은 Docker 볼륨 문서에서 더 배울 수 있습니다.
config/redis.conf는 appendonly no, save ""로 Redis 디스크 영속화를 끕니다. 대화 원본은 SQLite에 남지만, Redis의 작업 큐까지 영구 보존된다고 생각하면 안 됩니다. 큐에 남은 알림·정리 작업을 재시작 뒤 어떻게 다룰지도 운영 설계의 일부입니다.
앞의 설명을 따라 읽는 데 서버 실행은 필요하지 않습니다. 아래 1번은 소스 읽기만으로 완성할 수 있습니다. 실행·변경이 필요한 나머지 과제는 개인 실습 환경과 별도 데이터로 진행하세요. 각 과제의 결과물은 “동작했다” 한마디보다 전후 상태와 실패 사례를 포함하는 편이 좋습니다.
목표: 클릭한 순간부터 다른 사용자의 화면에 도착할 때까지, 파일과 메서드의 순서를 연결합니다.
config/routes.rb에서 방 아래의 메시지 라우트를 찾습니다.app/javascript/controllers/composer_controller.js의 #submitMessage()에서 임시 표시와 실제 폼 전송 순서를 적습니다.MessagesController#create에서 방 선택·저장·렌더링·방송을 표시합니다.Message::Broadcasts#broadcast_create와 app/views/messages/create.turbo_stream.erb가 어떤 HTML을 공유하는지 연결합니다.#reconcilePendingMessage()까지 이어 확정 메시지 수신 뒤 정리를 설명합니다.완료 기준: “같은 메시지가 HTTP 응답과 방송 경로로 도착할 수 있다”는 점과 서버 기본 키·임시 ID의 차이를 지도에 표시합니다. 작성자를 왜 폼에서 직접 받지 않는지도 설명합니다.
목표: localStorage의 초안과 서버 메시지를 구분하고, 입력 경험을 작게 개선합니다.
saveDraft(), #restoreDraft(), #reset()이 같은 키를 사용하는지 확인합니다.roomIdValue와 연결해 설명합니다.완료 기준: 복구 안내가 연결 때마다 중복되지 않고, 입력을 지우거나 전송했을 때 오래된 초안이 되살아나지 않습니다. 같은 브라우저를 여러 계정이 공유한다면 현재 키에 사용자 식별이 없다는 점도 검토합니다.
목표: 검색 기능의 유무와 원하는 검색 품질을 구분합니다. 먼저 porter를 유지한 상태에서 기대 결과를 정의합니다.
| 저장할 예문 | 검색어 | 관찰할 질문 |
|---|---|---|
| 배포 자동화 문서 | 배포 | 분리된 단어를 찾을 수 있는가? |
| 배포자동화 문서 | 배포 | 단어 내부의 부분 일치를 지원하는가? |
| 서버를 점검했습니다 | 서버 | 조사가 붙은 표현도 기대대로 찾는가? |
| salt AND pepper | AND | 연산자로 해석되지 않고 단어로 검색되는가? |
진행: SearchesController#query의 입력 정리, match_terms, FTS5 토큰화를 차례로 읽습니다. 개인 테스트 DB에서 결과를 기록하고, 필요할 때만 다른 토크나이저를 비교하는 별도 실험을 만드세요.
완료 기준: 실제 일치·불일치를 표에 채우고, 변경 시 기존 색인의 재구축 필요성까지 설명합니다. 토크나이저 교체를 한국어 형태소 분석과 같은 기능으로 표현하지 않습니다.
목표: 한 번 로그인한 사용자의 권한이 계속 유효한지 검증합니다. test/channels/room_messages_channel_test.rb와 test/controllers/cached_responses_test.rb를 출발점으로 삼습니다.
완료 기준: 성공·거절의 결과뿐 아니라 해당 검사가 어느 파일에서 이루어지는지 남깁니다. 일반 채널로 바꾸어 구독하는 우회도 테스트에 포함합니다.
목표: 캐시가 빠르다는 사실보다 오래된 상태를 언제 버리는지가 중요함을 확인합니다. 실습용 DB의 두 연결을 이용하고 원본 운영 데이터는 사용하지 않습니다.
완료 기준: 변경 전후 응답의 차이, 관찰 연결의 수명, 새 버전에 오래된 항목이 들어가지 않는 조건을 설명합니다. 성능을 비교한다면 캐시 예산·작업자 수·응답 검증 조건을 함께 기록합니다.
# docs/development.md의 개발 흐름 — 개인 실습 사본에서 검토할 명령
bin/setup
bin/dev
# 모델·요청·채널 테스트와 브라우저 시스템 테스트
bin/rails test
bin/rails test:system
bin/setup은 단순히 폴더 안의 파일만 준비하는 명령이 아닙니다. 패키지 설치, Ruby 설치, 젬 설치, DB 준비, Redis 컨테이너 시작과 검사 도구 설치를 수행합니다. 실행 전에 파일을 읽고 자신의 실습 환경에서 필요한 변경 범위를 확인하세요. --reset은 DB·첨부 초기화 경로가 있으므로 기본 학습 순서에서 필요하지 않습니다.
주당 3~5시간을 가정한 학습 계획입니다. 이미 아는 문법은 빠르게 지나가되, 각 주의 결과물을 남기세요. 처음부터 캐시나 Web Push 연결 풀을 모두 이해하려고 하기보다 요청과 데이터 관계를 먼저 익히는 편이 좋습니다.
| 주차 | 학습 내용과 소스 | 남길 결과물 |
|---|---|---|
| 1주차 | Ruby 메서드·블록·모듈, Rails 라우팅과 MVC. config/routes.rb, MessagesController, Message 읽기 |
요청에서 템플릿까지의 지도와 Ruby 문법 메모 10개 |
| 2주차 | 관계형 모델·외래 키·고유 인덱스·STI. Room, Membership, User, DB 마이그레이션 읽기 |
사용자·방·소속·메시지 관계도와 삭제 시 변화 표 |
| 3주차 | Turbo Streams·Stimulus·낙관적 UI. app/javascript/controllers/composer_controller.js, app/javascript/controllers/messages_controller.js, 메시지 뷰 읽기 |
초안·임시 전송·확정·실패의 상태 전이도 |
| 4주차 | SQLite FTS5·트랜잭션·콜백. Message::Searchable, 생성 테스트, 페이지 조회용 인덱스 읽기 |
한국어 검색 사례 표와 저장 실패 때 되돌릴 항목 목록 |
| 5주차 | 세션·스트림 권한·캐시 무효화·CSP. RoomMessagesChannel, ResponseCache, InlineScriptHashes 읽기 |
권한 회수 시나리오와 캐시 키에 필요한 정보의 근거 |
| 6주차 | Docker·작업 큐·WAL·백업·CI. Procfile, Dockerfile, SqliteWalCheckpoint, 운영 가이드 읽기 |
기동·종료·복구 흐름도와 실습 환경 복구 기록 |
Room.find(id)와 Current.user.rooms.find(id)의 차이를 설명할 수 있나요?학습의 끝은 모든 파일을 읽는 것이 아니라, 기능을 바꿀 때 영향을 받을 데이터·화면·권한·작업을 스스로 찾을 수 있는 상태입니다.
성능에 관심이 있다면 lib/rails_ext/sqlite_wal_checkpoint.rb를 읽으세요. 기본 간격 0.25초, 실패 시 최대 30.0초까지의 대기 증가, 파일 잠금을 통한 실행 주체 선택, PRAGMA wal_checkpoint(PASSIVE)가 연결됩니다. config/database.yml의 wal_autocheckpoint: 0과 Puma의 포크 전후 훅까지 함께 봐야 완성됩니다.
브라우저 보안에 관심이 있다면 InlineScriptHashes#script_sources를 읽으세요. 커밋 c8f74a0은 Importmap과 호스트에 따라 정해지는 스크립트 허용 목록을 재사용합니다. 캐시 키에는 Importmap JSON, 기본 URL, 호스트·포트, 상대 경로 루트가 들어가고 마지막 결과 하나만 보관합니다. 성능을 높이면서 임의 호스트 입력으로 캐시가 끝없이 커지지 않게 한 선택입니다.
학습 로드맵은 건물을 층별로 한 번씩 둘러보는 코스가 아닙니다. 같은 택배를 접수대·기록실·방송실·보관 창고의 시선으로 다시 따라가는 코스입니다. 반복할수록 부서 사이의 약속이 보입니다.
| 용어 | 쉬운 설명 |
|---|---|
| Rails | 요청 처리·DB·화면·작업 큐를 연결하는 Ruby 웹 프레임워크. 이 저장소는 8.2 알파 리비전을 사용합니다. |
| MVC | 데이터와 규칙, 화면, 요청 처리를 Model·View·Controller로 나누는 기본 구조입니다. |
| Active Record | DB 행을 Ruby 객체처럼 다루는 도구입니다. 짧은 메서드 호출 뒤에 SQL이 있습니다. |
| Concern | 관련 메서드와 설정을 묶어 클래스에 섞는 모듈 구성 방식입니다. 메시지의 검색·방송 기능이 예입니다. |
| STI | 같은 테이블의 type 값으로 클래스를 구분하는 상속입니다. 세 종류 방이 rooms를 공유합니다. |
| Membership | 사용자와 방의 소속 관계입니다. 알림 선호, 미읽음, 접속 상태도 이 관계에 붙습니다. |
| 인증 / 인가 | 누구인지 확인하는 일 / 그 사람이 이 자원에 접근할 수 있는지 판단하는 일입니다. |
| 세션 | 서버가 유지하는 로그인 기록입니다. 서명 쿠키의 토큰으로 Session을 찾아 사용자를 확인합니다. |
| Action Cable | Rails의 WebSocket 연결·채널 기능입니다. 이 구성에서는 Redis가 여러 프로세스 사이의 방송을 중계합니다. |
| Turbo Streams | 대상 요소와 변경 동작, HTML 조각을 보내 화면 일부를 고치는 방식입니다. |
| Stimulus | HTML 요소에 작은 JavaScript 컨트롤러를 연결하는 도구입니다. 입력·스크롤·전송 보조에 사용됩니다. |
| Importmap | 모듈 이름을 브라우저가 가져올 파일 URL과 연결하는 지도입니다. config/importmap.rb가 출발점입니다. |
| Propshaft | CSS·JavaScript 같은 정적 자산을 배포에 맞게 다루는 Rails 자산 도구입니다. |
| Lexxy / Action Text | 서식 있는 글을 입력하는 편집기 / Rails에서 서식 있는 본문을 모델과 연결하는 기능입니다. |
| 낙관적 UI | 서버 응답 전에 임시 결과를 보여주는 방식입니다. 성공 확인과 실패 처리가 뒤따라야 합니다. |
| DOM ID | 브라우저 화면 요소의 식별자입니다. 확정 메시지는 서버 기본 키에서 만든 값을 사용합니다. |
| FTS5 | SQLite 안에서 전문 검색용 색인을 제공하는 가상 테이블 기능입니다. |
| 토크나이저 | 본문을 검색 단위로 나누는 구성 요소입니다. 무엇을 같은 단어로 볼지에 영향을 줍니다. |
| 트랜잭션 | 여러 DB 변경을 하나의 성공·실패 단위로 묶는 장치입니다. 도중 실패하면 함께 되돌릴 수 있습니다. |
| after_commit | DB 저장이 최종 확정된 뒤 실행하는 Rails 콜백입니다. 외부 작업을 시작하는 시점에 유용합니다. |
| Active Job / Resque | Rails의 작업 예약 인터페이스 / 운영 환경에서 Redis 큐를 통해 그 작업을 실행하는 도구입니다. |
| Web Push / VAPID | 브라우저에 알림을 보내는 방식 / 알림 발신 애플리케이션을 식별하기 위한 키 기반 방식입니다. |
| Active Storage | 첨부 원본과 메타데이터, 미리보기를 Rails 모델에 연결하는 파일 관리 기능입니다. |
| 캐시 무효화 | 원본이 바뀌었을 때 재사용하던 결과를 버리는 일입니다. 저장하는 것만큼 조건 설계가 중요합니다. |
| data_version | 같은 SQLite 관찰 연결에서 다른 연결의 커밋을 감지하는 값입니다. 전역 시계처럼 비교하면 안 됩니다. |
| WAL / 체크포인트 | 변경을 별도 로그에 먼저 기록하는 방식 / 로그의 변경을 본 DB 파일에 반영하는 작업입니다. |
| CSP | 브라우저가 어떤 출처·해시의 코드를 실행할지 제한하는 응답 정책입니다. 이 코드에서는 Importmap 내용과 연결됩니다. |
| SSRF | 사용자가 고른 주소로 서버가 요청하게 하여 의도하지 않은 내부 자원에 접근시키는 공격 유형입니다. |
| 영구 볼륨 | 컨테이너 교체와 별개로 데이터를 보존하는 공간입니다. Campfire의 /rails/storage에 연결합니다. |
| 단일 테넌트 | 한 인스턴스가 한 조직을 담당하는 운영 방식입니다. 방을 여러 개 만드는 것과 별개입니다. |
용어를 다시 읽을 때는 “이 기술이 없다면 어느 기능을 내가 직접 만들어야 할까?”라고 물어보세요. 예를 들어 Turbo Streams를 빼면 화면 갱신 규약을, FTS5를 빼면 검색 구조를, 작업 큐를 빼면 요청과 느린 외부 호출의 관계를 다시 설계해야 합니다.
| 주제 | 시작할 파일 | 함께 읽을 코드 |
|---|---|---|
| 의존성 | Gemfile.lock | Gemfile, .ruby-version, Dockerfile |
| 메시지 전송 | MessagesController | Message::Attachment, Message::Broadcasts, 전송 응답 뷰 |
| 임시 표시 | messages_controller.js | app/javascript/controllers/composer_controller.js, MessagesHelper#message_tag |
| 검색·저장 시점 | Message::Searchable | Message#record_creation, 생성·검색 테스트, 초기 마이그레이션 |
| 구독 권한 | RoomMessagesChannel | RoomStreamsAreAuthorized, Membership, 채널 테스트 |
| 캐시 | ResponseCache | CachedResponses, RecordCache, FragmentCache |
| HEAD 변경 | InlineScriptHashes | script_sources의 키·단일 항목 캐시 |
| 측정 조건 | 벤치마크 안내 | bench/compare_http.rb, bench/response_contract.rb |
| 항목 | 확인 내용 |
|---|---|
| 저장소·주 언어 | basecamp/once-campfire · Ruby |
| 선정 위치 | GitHub Trending Ruby Monthly #5 · 2026-10-23 |
| 스타 / 포크 | 4,801 / 821 · 2026-10-23 GitHub 기준 |
| 저장소 생성일 | 2025-08-15 |
| 최근 push | 2026-10-21 · 분석한 커밋의 작성일과는 별개 |
| 최신 릴리스 | v1.5.6 · 2026-10-21 |
| 해설 기준 커밋 | c8f74a0 · 작성일 2026-10-19 / 커밋일 2026-10-21 · “Compute a page's script sources once per importmap and host” |
| 핵심 코드 규모 | Ruby 6,459줄 · JavaScript 3,885줄 · CSS 3,378줄 · ERB 2,274줄 · app/config/db/lib 범위 |
| 테스트·전체 파일 | Ruby 테스트 107개·9,191줄 · 전체 Git 추적 파일 788개 |
| 주요 버전 | Ruby 4.0.7 · Rails 8.2.0.alpha · sqlite3 젬 2.9.6 · Puma 8.0.2 |
| 라이선스 | MIT · 저장소의 MIT-LICENSE |
저장소 생성일은 제품이 처음 만들어진 날짜와 같다는 뜻이 아닙니다. 예를 들어 마이그레이션 파일에는 2023~2024년의 날짜도 있습니다. GitHub 저장소 메타데이터와 소스 안의 개발 흔적은 서로 다른 정보를 나타냅니다.
시점: 코드 해설은 2026-10-21에 반영된 커밋 c8f74a0(작성일 2026-10-19) 기준입니다. 2026-10-21 릴리스 v1.5.6 및 최근 push의 날짜만으로 코드가 일치한다고 판단할 수는 없습니다.
재현성: Rails는 main의 특정 알파 리비전에 묶여 있습니다. 헤더 중심 CSRF 보호 같은 설정을 다른 Rails 버전에 그대로 복사하지 마세요. 동작 설명은 정적 코드에 드러난 흐름과 테스트의 의도에 한정됩니다. 환경별 실행 성공·CI 통과·실제 처리량을 보장하지 않습니다.
운영: 단일 조직 모델, Redis 큐의 비영속 설정, 동기 첨부 처리, DB 커밋과 외부 큐 사이의 장애 가능성을 고려해야 합니다. 자체 호스팅은 업데이트·백업·장애 대응을 운영자가 맡는 방식입니다.
데이터 접근: 공개 방은 인스턴스 사용자에게 열립니다. 첨부 다운로드의 서명 URL은 방의 현재 회원 자격과 동일한 장치가 아닙니다. 운영의 비밀값과 URL 취급 정책을 따로 정해야 합니다. FTS5의 Porter 설정만으로 한국어 형태소 검색까지 보장되지 않습니다.