Ruby on Rails 면접 질문: 2026년 Top 25
가장 자주 묻는 Ruby on Rails 면접 질문 25선. MVC 아키텍처, Active Record, 마이그레이션, RSpec 테스트, REST API에 대한 자세한 답변과 코드 예제.

Ruby on Rails 면접에서는 가장 인기 있는 Ruby 프레임워크에 대한 숙련도, MVC 아키텍처와 Active Record ORM에 대한 이해, 그리고 "Convention over Configuration" 철학에 따라 견고한 웹 애플리케이션을 구축하는 능력을 평가합니다. 본 가이드는 Rails 기초부터 운영 환경에서 사용하는 고급 패턴까지 가장 자주 묻는 25가지 질문을 다룹니다.
채용 담당자는 Rails의 철학을 이해하는 지원자를 높이 평가합니다: "Convention over Configuration", DRY (Don't Repeat Yourself), 그리고 Rails Way 패턴입니다. Rails가 특정 아키텍처적 선택을 하는 이유를 설명할 수 있는 점이 차별화 요소가 됩니다.
Ruby on Rails 기초
질문 1: Ruby on Rails의 MVC 패턴을 설명해 주세요
Model-View-Controller (MVC) 패턴은 Rails 아키텍처의 핵심입니다. 책임을 세 개의 명확한 계층으로 분리해 코드의 유지 보수성과 테스트 용이성을 향상시킵니다.
# app/models/article.rb
# Model은 데이터와 비즈니스 로직을 관리합니다
class Article < ApplicationRecord
# 데이터 검증
validates :title, presence: true, length: { minimum: 5 }
validates :body, presence: true
# 다른 모델과의 연관관계
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
has_many :tags, through: :article_tags
# 재사용 가능한 쿼리를 위한 스코프
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc).limit(10) }
# 라이프사이클 콜백
before_save :generate_slug
private
def generate_slug
self.slug = title.parameterize if title_changed?
end
end# app/controllers/articles_controller.rb
# Controller는 요청을 받아 응답을 조정합니다
class ArticlesController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_article, only: [:show, :edit, :update, :destroy]
def index
@articles = Article.published.recent.includes(:author)
end
def show
@comments = @article.comments.includes(:user)
end
def create
@article = current_user.articles.build(article_params)
if @article.save
redirect_to @article, notice: '게시물이 성공적으로 생성되었습니다.'
else
render :new, status: :unprocessable_entity
end
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body, :published, tag_ids: [])
end
end<%# app/views/articles/show.html.erb %>
<%# View는 데이터를 HTML 형식으로 표시합니다 %>
<article class="article-detail">
<header>
<h1><%= @article.title %></h1>
<p class="meta">
작성자: <%= @article.author.name %> •
<%= l @article.created_at, format: :long %>
</p>
</header>
<div class="content">
<%= simple_format @article.body %>
</div>
<%# 댓글을 위한 partial %>
<%= render @comments %>
</article>일반적인 흐름은 다음과 같습니다. 요청이 Router에 도달하면 적절한 Controller로 전달됩니다. Controller는 데이터를 가져오거나 수정하기 위해 Model과 상호작용한 뒤, 해당 데이터를 View에 전달해 HTML로 렌더링합니다.
질문 2: Active Record란 무엇이며 Rails ORM은 어떻게 동작합니까?
Active Record는 Active Record 패턴을 구현한 Rails의 ORM (Object-Relational Mapping) 입니다. 각 Model 클래스는 데이터베이스 테이블 하나를 표현하고, 각 인스턴스는 한 행을 나타냅니다.
# app/models/user.rb
# Active Record는 컬럼을 속성으로 자동 매핑합니다
class User < ApplicationRecord
# 'users' 테이블이 자동으로 연결됩니다
# 컬럼: id, email, name, created_at, updated_at
has_secure_password # 비밀번호용 BCrypt
has_many :articles, foreign_key: :author_id
has_one :profile, dependent: :destroy
has_and_belongs_to_many :roles
# 검증
validates :email, presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
# 콜백
before_save :normalize_email
# 쿼리를 위한 클래스 메서드
def self.admins
joins(:roles).where(roles: { name: 'admin' })
end
private
def normalize_email
self.email = email.downcase.strip
end
end# Active Record 쿼리 예시
# Rails 콘솔 또는 서비스 내부
# 생성
user = User.create!(email: 'dev@example.com', name: 'Alice', password: 'secret123')
# 조건이 있는 읽기
active_users = User.where(active: true).order(:name)
user = User.find_by(email: 'dev@example.com')
# 체이닝 쿼리 (lazy evaluation)
recent_admins = User.admins
.where('created_at > ?', 1.month.ago)
.includes(:profile)
.limit(10)
# eager loading 으로 N+1 방지
articles = Article.includes(:author, :comments).published
# 업데이트
user.update!(name: 'Alice Martin')
# 트랜잭션
User.transaction do
user.debit_balance!(100)
recipient.credit_balance!(100)
Payment.create!(from: user, to: recipient, amount: 100)
endActive Record는 Ruby 메서드를 최적화된 SQL 쿼리로 변환합니다. where, joins, includes 같은 메서드는 lazy 동작이며, 이터레이션을 시작하거나 to_a를 호출할 때 비로소 쿼리가 실행됩니다.
질문 3: Rails 의 마이그레이션 시스템을 설명해 주세요
마이그레이션을 사용하면 Ruby로 데이터베이스 스키마의 버전을 관리할 수 있습니다. 되돌릴 수 있으며 데이터 구조를 통제된 방식으로 진화시킬 수 있습니다.
# db/migrate/20260203100000_create_products.rb
# 테이블을 생성하기 위한 마이그레이션
class CreateProducts < ActiveRecord::Migration[7.1]
def change
create_table :products do |t|
t.string :name, null: false
t.text :description
t.decimal :price, precision: 10, scale: 2, null: false
t.integer :stock_quantity, default: 0
t.references :category, null: false, foreign_key: true
t.boolean :active, default: true
t.timestamps # created_at 과 updated_at 을 자동 추가
end
# 성능을 위한 인덱스
add_index :products, :name
add_index :products, [:category_id, :active]
end
end# db/migrate/20260203110000_add_slug_to_products.rb
# 기존 테이블을 수정하는 마이그레이션
class AddSlugToProducts < ActiveRecord::Migration[7.1]
def change
add_column :products, :slug, :string
add_index :products, :slug, unique: true
# 기존 슬러그 채우기
reversible do |dir|
dir.up do
Product.find_each do |product|
product.update_column(:slug, product.name.parameterize)
end
end
end
# 채운 후 NOT NULL 로 변경
change_column_null :products, :slug, false
end
end# 필수 마이그레이션 명령
rails db:migrate # 대기 중인 마이그레이션 실행
rails db:rollback # 직전 마이그레이션 취소
rails db:rollback STEP=3 # 직전 3개의 마이그레이션 취소
rails db:migrate:status # 마이그레이션 상태 확인
rails db:seed # db/seeds.rb 실행
rails db:reset # Drop, create, migrate, seed마이그레이션은 되돌릴 수 있어야 합니다. change 메서드는 똑똑하여 일반적인 작업을 자동으로 역으로 수행할 수 있습니다. 복잡한 경우에는 up과 down을 따로 사용하세요.
고급 Active Record
질문 4: Rails 에서 N+1 쿼리를 어떻게 최적화합니까?
N+1 문제는 초기 쿼리 이후 연관관계를 로드하기 위해 N개의 추가 쿼리가 발생하는 상황을 말합니다. Rails는 이 문제를 해결하기 위한 여러 eager loading 메서드를 제공합니다.
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def index
# ❌ N+1 문제: 1개의 쿼리 + 주문당 N개의 쿼리
# @orders = Order.all
# 뷰에서 order.user.name 호출 시 주문마다 쿼리가 발생합니다
# ✅ includes 를 활용한 해결 (eager loading)
@orders = Order.includes(:user, :items)
.where(status: 'completed')
.order(created_at: :desc)
# 총 3개의 쿼리만 발생
end
def show
# includes: 연관관계를 별도로 로드 (2-3개의 쿼리)
@order = Order.includes(items: :product).find(params[:id])
# preload: 별도 로딩을 강제
@order = Order.preload(:items, :user).find(params[:id])
# eager_load: LEFT OUTER JOIN 강제 (1개의 쿼리)
@order = Order.eager_load(:items).find(params[:id])
end
end# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
has_many :items, class_name: 'OrderItem'
has_many :products, through: :items
# 기본 includes 가 적용된 스코프
scope :with_details, -> { includes(:user, items: :product) }
# COUNT 쿼리를 피하기 위한 counter cache
# 필요: add_column :users, :orders_count, :integer, default: 0
belongs_to :user, counter_cache: true
end# Bullet gem 으로 N+1 감지 (development)
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.rails_logger = true
end
# Bullet 은 다음 상황에서 알림을 보여줍니다:
# - N+1 쿼리가 감지된 경우
# - 불필요한 eager loading 이 있는 경우
# - counter cache 를 사용해야 할 경우원칙은 다음과 같습니다. 기본적으로 includes를 사용하고 (Rails가 최적의 전략을 선택), 별도 쿼리를 강제하려면 preload, 연관관계로 필터링할 때는 eager_load를 사용합니다.
질문 5: Rails 의 Scope 와 Query Object 를 설명해 주세요
스코프는 재사용 가능한 쿼리 조건을 캡슐화합니다. 복잡한 쿼리에는 Query Object 가 더 나은 구조와 테스트 용이성을 제공합니다.
# app/models/product.rb
class Product < ApplicationRecord
# 단순 스코프
scope :active, -> { where(active: true) }
scope :in_stock, -> { where('stock_quantity > 0') }
scope :featured, -> { where(featured: true) }
# 매개변수가 있는 스코프
scope :cheaper_than, ->(price) { where('price < ?', price) }
scope :in_category, ->(category) { where(category: category) }
# 체이닝 가능한 스코프
scope :available, -> { active.in_stock }
# joins 를 포함한 스코프
scope :with_recent_orders, -> {
joins(:order_items)
.where('order_items.created_at > ?', 30.days.ago)
.distinct
}
# 서브쿼리를 포함한 스코프
scope :bestsellers, -> {
where(id: OrderItem.group(:product_id)
.order('COUNT(*) DESC')
.limit(10)
.select(:product_id))
}
end# app/queries/products_search_query.rb
# 복잡한 검색을 위한 Query Object
class ProductsSearchQuery
def initialize(relation = Product.all)
@relation = relation
end
def call(params)
@relation = filter_by_category(params[:category])
@relation = filter_by_price_range(params[:min_price], params[:max_price])
@relation = filter_by_search(params[:q])
@relation = apply_sorting(params[:sort])
@relation
end
private
def filter_by_category(category)
return @relation if category.blank?
@relation.where(category_id: category)
end
def filter_by_price_range(min, max)
@relation = @relation.where('price >= ?', min) if min.present?
@relation = @relation.where('price <= ?', max) if max.present?
@relation
end
def filter_by_search(query)
return @relation if query.blank?
@relation.where('name ILIKE ? OR description ILIKE ?',
"%#{query}%", "%#{query}%")
end
def apply_sorting(sort)
case sort
when 'price_asc' then @relation.order(price: :asc)
when 'price_desc' then @relation.order(price: :desc)
when 'newest' then @relation.order(created_at: :desc)
else @relation.order(:name)
end
end
end
# 컨트롤러에서 사용
@products = ProductsSearchQuery.new(Product.active).call(params)스코프는 단순하고 재사용 가능한 조건에 적합합니다. Query Object 는 여러 선택적 필터와 조합 로직이 필요한 복잡한 검색에 적합합니다.
Ruby on Rails 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
라우팅과 컨트롤러
질문 6: Rails 의 RESTful 라우팅은 어떻게 동작합니까?
Rails 는 HTTP 동사를 CRUD 액션과 매핑하는 RESTful 라우트를 권장합니다. 라우터는 URL 을 특정 컨트롤러 호출로 변환합니다.
# config/routes.rb
Rails.application.routes.draw do
# 표준 RESTful 라우트 (7개의 액션)
resources :articles do
# 중첩 라우트
resources :comments, only: [:create, :destroy]
# member 라우트 (인스턴스에 대해 동작)
member do
post :publish
delete :archive
end
# collection 라우트 (컬렉션에 대해 동작)
collection do
get :drafts
get :search
end
end
# namespace 가 있는 API 라우트
namespace :api do
namespace :v1 do
resources :products, only: [:index, :show, :create, :update] do
resources :reviews, shallow: true
end
end
end
# 사용자 정의 라우트
get 'dashboard', to: 'dashboard#index'
# 라우트 제약
constraints(SubdomainConstraint.new) do
resources :admin_settings
end
# 루트 라우트
root 'home#index'
end# rails routes - 생성된 모든 라우트 표시
#
# Verb URI Pattern Controller#Action
# GET /articles articles#index
# POST /articles articles#create
# GET /articles/new articles#new
# GET /articles/:id/edit articles#edit
# GET /articles/:id articles#show
# PATCH /articles/:id articles#update
# DELETE /articles/:id articles#destroy
# POST /articles/:id/publish articles#publish
# GET /articles/drafts articles#drafts생성된 라우트 헬퍼 (article_path(@article), new_article_path) 를 통해 URL 을 동적이고 유지 보수가 용이한 방식으로 참조할 수 있습니다.
질문 7: 컨트롤러의 콜백과 필터를 설명해 주세요
콜백 (before_action, after_action, around_action) 은 컨트롤러 액션 전, 후 또는 주변에서 코드를 실행할 수 있게 해줍니다.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# CSRF 보호는 기본 활성화
protect_from_forgery with: :exception
# 인증을 위한 전역 콜백
before_action :authenticate_user!
# 전역 에러 처리
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def not_found
render json: { error: '리소스를 찾을 수 없습니다' }, status: :not_found
end
def bad_request(exception)
render json: { error: exception.message }, status: :bad_request
end
end# app/controllers/admin/products_controller.rb
class Admin::ProductsController < ApplicationController
# 옵션이 있는 콜백
before_action :require_admin
before_action :set_product, only: [:show, :edit, :update, :destroy]
after_action :log_activity, only: [:create, :update, :destroy]
# 조건부 콜백
before_action :check_stock, only: [:update], if: :stock_changed?
def create
@product = Product.new(product_params)
if @product.save
redirect_to [:admin, @product], notice: '상품이 생성되었습니다.'
else
render :new, status: :unprocessable_entity
end
end
def update
if @product.update(product_params)
redirect_to [:admin, @product], notice: '상품이 업데이트되었습니다.'
else
render :edit, status: :unprocessable_entity
end
end
private
def require_admin
redirect_to root_path unless current_user&.admin?
end
def set_product
@product = Product.find(params[:id])
end
def stock_changed?
params[:product][:stock_quantity].present?
end
def log_activity
ActivityLog.create!(
user: current_user,
action: action_name,
resource: @product
)
end
def product_params
params.require(:product).permit(:name, :price, :description, :stock_quantity)
end
end콜백은 선언 순서대로 실행됩니다. 상속된 콜백을 비활성화하려면 서브클래스에서 skip_before_action을 사용하세요. 비즈니스 로직이 과하게 들어 있는 콜백은 피하고, Service Object 를 우선 고려하세요.
서비스와 아키텍처
질문 8: Rails 에서 Service Object 를 어떻게 구현합니까?
Service Object 는 Model 에도 Controller 에도 속하지 않는 복잡한 비즈니스 로직을 캡슐화합니다. 테스트 용이성을 향상시키고 단일 책임 원칙을 따릅니다.
# app/services/order_processor.rb
# 표준 인터페이스를 가진 Service Object
class OrderProcessor
def initialize(order, payment_method:)
@order = order
@payment_method = payment_method
end
def call
return failure('주문이 이미 처리되었습니다') if @order.processed?
ActiveRecord::Base.transaction do
validate_stock!
process_payment!
update_inventory!
send_confirmation!
@order.update!(status: 'completed', processed_at: Time.current)
end
success(@order)
rescue PaymentError => e
failure("결제 실패: #{e.message}")
rescue InsufficientStockError => e
failure("재고 부족: #{e.message}")
end
private
def validate_stock!
@order.items.each do |item|
unless item.product.stock_quantity >= item.quantity
raise InsufficientStockError, item.product.name
end
end
end
def process_payment!
result = PaymentGateway.charge(
amount: @order.total,
method: @payment_method,
description: "주문 ##{@order.id}"
)
raise PaymentError, result.error unless result.success?
@order.update!(payment_reference: result.transaction_id)
end
def update_inventory!
@order.items.each do |item|
item.product.decrement!(:stock_quantity, item.quantity)
end
end
def send_confirmation!
OrderMailer.confirmation(@order).deliver_later
end
def success(data)
Result.new(success: true, data: data)
end
def failure(error)
Result.new(success: false, error: error)
end
Result = Struct.new(:success, :data, :error, keyword_init: true) do
def success? = success
def failure? = !success
end
end# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
@order = current_user.orders.build(order_params)
if @order.save
result = OrderProcessor.new(@order, payment_method: params[:payment_method]).call
if result.success?
redirect_to @order, notice: '주문이 확인되었습니다!'
else
@order.update!(status: 'payment_failed')
flash.now[:alert] = result.error
render :new, status: :unprocessable_entity
end
else
render :new, status: :unprocessable_entity
end
end
endService Object 패턴은 단순한 규약을 따릅니다: 하나의 클래스, 하나의 책임, 공개 메서드는 call 하나입니다. Result 객체를 반환하면 성공과 실패를 깔끔하게 처리할 수 있습니다.
질문 9: Rails 의 Concerns 를 설명해 주세요
Concerns 는 Models 또는 Controllers 사이에서 코드를 추출해 공유할 수 있게 해줍니다. 깔끔한 include 문법을 위해 ActiveSupport::Concern 을 사용합니다.
# app/models/concerns/sluggable.rb
# 슬러그 생성을 위한 재사용 가능한 Concern
module Sluggable
extend ActiveSupport::Concern
included do
# include 시 실행되는 코드
before_validation :generate_slug, if: :should_generate_slug?
validates :slug, presence: true, uniqueness: true
end
# 클래스 메서드
class_methods do
def find_by_slug!(slug)
find_by!(slug: slug)
end
def sluggable_source(column = :title)
@sluggable_source = column
end
def sluggable_source_column
@sluggable_source || :title
end
end
# 인스턴스 메서드
def to_param
slug
end
private
def should_generate_slug?
slug.blank? || send("#{self.class.sluggable_source_column}_changed?")
end
def generate_slug
source = send(self.class.sluggable_source_column)
return if source.blank?
base_slug = source.parameterize
self.slug = unique_slug(base_slug)
end
def unique_slug(base)
slug = base
counter = 1
while self.class.where(slug: slug).where.not(id: id).exists?
slug = "#{base}-#{counter}"
counter += 1
end
slug
end
end# app/models/article.rb
class Article < ApplicationRecord
include Sluggable
sluggable_source :title # 선택, 기본값은 :title
end
# app/models/product.rb
class Product < ApplicationRecord
include Sluggable
sluggable_source :name
end# app/controllers/concerns/pagination.rb
# 컨트롤러용 Concern
module Pagination
extend ActiveSupport::Concern
included do
helper_method :page_param, :per_page_param
end
private
def paginate(relation)
relation.page(page_param).per(per_page_param)
end
def page_param
params[:page]&.to_i || 1
end
def per_page_param
[params[:per_page]&.to_i || 25, 100].min
end
endConcerns 는 진정으로 공유되는 코드를 위해 사용해야 합니다. Model 을 단지 짧게 보이게 하려고 Concern 을 만들면 복잡성을 줄이는 게 아니라 감추게 됩니다.
RSpec 으로 테스트
질문 10: Rails 의 RSpec 테스트는 어떻게 구성합니까?
RSpec 은 Rails 의 표준 테스트 프레임워크입니다. 좋은 테스트 구조에는 Model spec, Controller spec, Service spec, 통합 테스트가 포함됩니다.
# spec/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
# FactoryBot 의 팩토리
let(:user) { build(:user) }
let(:admin) { build(:user, :admin) }
describe 'validations' do
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
it '이메일 형식을 검증합니다' do
user.email = 'invalid'
expect(user).not_to be_valid
expect(user.errors[:email]).to include('is invalid')
end
end
describe 'associations' do
it { is_expected.to have_many(:articles).dependent(:destroy) }
it { is_expected.to have_one(:profile) }
it { is_expected.to belong_to(:organization).optional }
end
describe '#full_name' do
it '이름과 성을 결합해 반환합니다' do
user = build(:user, first_name: 'John', last_name: 'Doe')
expect(user.full_name).to eq('John Doe')
end
it '성이 없는 경우도 처리합니다' do
user = build(:user, first_name: 'John', last_name: nil)
expect(user.full_name).to eq('John')
end
end
describe '.active' do
it '활성 사용자만 반환합니다' do
active = create(:user, active: true)
inactive = create(:user, active: false)
expect(User.active).to include(active)
expect(User.active).not_to include(inactive)
end
end
end# spec/services/order_processor_spec.rb
require 'rails_helper'
RSpec.describe OrderProcessor do
let(:user) { create(:user) }
let(:product) { create(:product, stock_quantity: 10, price: 100) }
let(:order) { create(:order, user: user, items: [build(:order_item, product: product, quantity: 2)]) }
subject { described_class.new(order, payment_method: 'card') }
describe '#call' do
context '주문이 유효한 경우' do
before do
allow(PaymentGateway).to receive(:charge).and_return(
OpenStruct.new(success?: true, transaction_id: 'txn_123')
)
end
it '주문이 정상적으로 처리됩니다' do
result = subject.call
expect(result).to be_success
expect(order.reload.status).to eq('completed')
end
it '상품 재고를 감소시킵니다' do
expect { subject.call }.to change { product.reload.stock_quantity }.by(-2)
end
it '확인 이메일을 발송합니다' do
expect { subject.call }
.to have_enqueued_mail(OrderMailer, :confirmation)
.with(order)
end
end
context '결제가 실패한 경우' do
before do
allow(PaymentGateway).to receive(:charge).and_return(
OpenStruct.new(success?: false, error: 'Card declined')
)
end
it '실패 결과를 반환합니다' do
result = subject.call
expect(result).to be_failure
expect(result.error).to include('Card declined')
end
it '주문 상태는 변경되지 않습니다' do
expect { subject.call }.not_to change { order.reload.status }
end
end
end
end# spec/requests/api/v1/products_spec.rb
require 'rails_helper'
RSpec.describe 'API V1 Products', type: :request do
let(:user) { create(:user) }
let(:headers) { { 'Authorization' => "Bearer #{user.api_token}" } }
describe 'GET /api/v1/products' do
let!(:products) { create_list(:product, 3, :active) }
it '상품 목록을 반환합니다' do
get '/api/v1/products', headers: headers
expect(response).to have_http_status(:ok)
expect(json_response['data'].size).to eq(3)
end
it '카테고리로 필터링합니다' do
category = create(:category)
categorized = create(:product, category: category)
get '/api/v1/products', params: { category_id: category.id }, headers: headers
expect(json_response['data'].map { |p| p['id'] }).to eq([categorized.id])
end
end
describe 'POST /api/v1/products' do
let(:valid_params) do
{ product: { name: '새 상품', price: 99.99, category_id: create(:category).id } }
end
it '새 상품을 생성합니다' do
expect {
post '/api/v1/products', params: valid_params, headers: headers
}.to change(Product, :count).by(1)
expect(response).to have_http_status(:created)
end
end
end좋은 관행: 데이터에는 let, 메서드/문맥에는 describe, 조건에는 context, 구체적인 단언에는 it 을 사용하세요. 한 테스트는 하나의 동작만 검증해야 합니다.
질문 11: FactoryBot 으로 팩토리는 어떻게 사용합니까?
FactoryBot 을 사용하면 테스트 데이터를 선언적이고 유지 보수가 쉬운 형태로 생성할 수 있습니다. 팩토리는 정적 fixture 를 대체합니다.
# spec/factories/users.rb
FactoryBot.define do
factory :user do
# 고유성을 보장하는 시퀀스
sequence(:email) { |n| "user#{n}@example.com" }
first_name { Faker::Name.first_name }
last_name { Faker::Name.last_name }
password { 'password123' }
confirmed_at { Time.current }
# 변형을 위한 trait
trait :admin do
role { 'admin' }
after(:create) do |user|
user.permissions.create!(name: 'admin_access')
end
end
trait :unconfirmed do
confirmed_at { nil }
end
trait :with_profile do
after(:create) do |user|
create(:profile, user: user)
end
end
trait :with_articles do
transient do
articles_count { 3 }
end
after(:create) do |user, evaluator|
create_list(:article, evaluator.articles_count, author: user)
end
end
# 상속된 팩토리
factory :admin_user do
admin
with_profile
end
end
end# spec/factories/orders.rb
FactoryBot.define do
factory :order do
user
status { 'pending' }
trait :with_items do
transient do
items_count { 2 }
end
after(:create) do |order, evaluator|
create_list(:order_item, evaluator.items_count, order: order)
order.recalculate_total!
end
end
trait :completed do
status { 'completed' }
processed_at { Time.current }
with_items
end
trait :high_value do
after(:create) do |order|
create(:order_item, order: order, quantity: 10, unit_price: 500)
order.recalculate_total!
end
end
end
end# 테스트에서의 사용
RSpec.describe OrderProcessor do
# build: 영구화되지 않는 인스턴스
let(:user) { build(:user) }
# create: DB 에 저장
let(:order) { create(:order, :with_items, user: user) }
# create_list: 여러 인스턴스
let(:products) { create_list(:product, 5) }
# trait 조합
let(:admin) { create(:user, :admin, :with_profile) }
# 속성 덮어쓰기
let(:expensive_order) { create(:order, :with_items, items_count: 10) }
# build_stubbed: 더 빠르며 단위 테스트에 적합
let(:stubbed_user) { build_stubbed(:user) }
end영구화가 필요하지 않을 때는 create 대신 build 또는 build_stubbed 를 우선 사용하세요. 테스트 속도가 크게 향상됩니다.
백그라운드 작업
질문 12: Rails 에서 Active Job 과 Sidekiq 은 어떻게 사용합니까?
Active Job 은 백엔드 (Sidekiq, Resque 등) 와 무관하게 백그라운드 작업을 위한 통일된 인터페이스를 제공합니다. Sidekiq 은 Redis 와의 결합으로 우수한 성능을 제공해 인기가 높습니다.
# app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob
queue_as :default
# 재시도 설정
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 10
discard_on ActiveJob::DeserializationError
# Sidekiq 옵션 (백엔드가 Sidekiq 인 경우)
sidekiq_options retry: 5, backtrace: true
def perform(order_id)
order = Order.find(order_id)
OrderProcessor.new(order).call
rescue ActiveRecord::RecordNotFound
# 큐 등록과 실행 사이에 주문이 삭제된 경우
Rails.logger.warn("Order #{order_id} not found, skipping job")
end
end# app/jobs/batch_email_job.rb
class BatchEmailJob < ApplicationJob
queue_as :mailers
# Sidekiq Enterprise 또는 throttle gem 으로 속도 제한
sidekiq_options throttle: { threshold: 100, period: 1.minute }
def perform(user_ids, template_id)
template = EmailTemplate.find(template_id)
User.where(id: user_ids).find_each do |user|
UserMailer.custom_email(user, template).deliver_later
end
end
end# 작업 큐잉
# 즉시 실행
ProcessOrderJob.perform_later(order.id)
# 지연 실행
ProcessOrderJob.set(wait: 5.minutes).perform_later(order.id)
# 특정 시각에 실행
ProcessOrderJob.set(wait_until: Date.tomorrow.noon).perform_later(order.id)
# 특정 큐에 실행
ProcessOrderJob.set(queue: :critical).perform_later(order.id)
# 동기 실행 (테스트나 디버깅용)
ProcessOrderJob.perform_now(order.id)# config/sidekiq.yml
:concurrency: 10
:queues:
- [critical, 3] # 높은 우선순위, 가중치 3
- [default, 2] # 보통 우선순위, 가중치 2
- [mailers, 1] # 낮은 우선순위, 가중치 1
- [low, 1]
:schedule:
cleanup_job:
cron: '0 3 * * *' # 매일 새벽 3시
class: CleanupJobActive Job 은 백엔드를 추상화하지만, 배치나 속도 제한 같은 백엔드 고유 기능을 사용하려면 선택한 백엔드와 결합이 필요한 경우가 많습니다.
Ruby on Rails 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
API 개발
질문 13: Rails 로 RESTful API 를 어떻게 구축합니까?
Rails 는 API 전용 컨트롤러와 직렬화기를 통해 JSON API 구축을 쉽게 해줍니다. 좋은 API 는 버전이 관리되고 문서화되어 있으며 안전합니다.
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate_token!
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def authenticate_token!
authenticate_or_request_with_http_token do |token, options|
@current_user = User.find_by(api_token: token)
end
end
def current_user
@current_user
end
def not_found(exception)
render json: { error: '리소스를 찾을 수 없습니다', details: exception.message },
status: :not_found
end
def unprocessable_entity(exception)
render json: { error: '검증 실패', details: exception.record.errors },
status: :unprocessable_entity
end
def bad_request(exception)
render json: { error: '잘못된 요청', details: exception.message },
status: :bad_request
end
end
end
end# app/controllers/api/v1/products_controller.rb
module Api
module V1
class ProductsController < BaseController
before_action :set_product, only: [:show, :update, :destroy]
def index
@products = Product.active
.includes(:category)
.page(params[:page])
.per(params[:per_page] || 20)
render json: {
data: ProductSerializer.new(@products).serializable_hash,
meta: pagination_meta(@products)
}
end
def show
render json: ProductSerializer.new(@product, include: [:category, :reviews])
end
def create
@product = Product.new(product_params)
@product.save!
render json: ProductSerializer.new(@product), status: :created
end
def update
@product.update!(product_params)
render json: ProductSerializer.new(@product)
end
def destroy
@product.destroy!
head :no_content
end
private
def set_product
@product = Product.find(params[:id])
end
def product_params
params.require(:product).permit(:name, :description, :price, :category_id)
end
def pagination_meta(collection)
{
current_page: collection.current_page,
total_pages: collection.total_pages,
total_count: collection.total_count
}
end
end
end
end# app/serializers/product_serializer.rb
# jsonapi-serializer gem 을 사용
class ProductSerializer
include JSONAPI::Serializer
attributes :id, :name, :description, :price, :created_at
attribute :formatted_price do |product|
"$#{product.price.to_f.round(2)}"
end
belongs_to :category
has_many :reviews
link :self do |product|
Rails.application.routes.url_helpers.api_v1_product_url(product)
end
endAPI 모범 사례: namespace 를 통해 버전을 관리하고, 적절한 HTTP 코드를 사용하며, 컬렉션은 페이지네이션하고, 명확한 에러 메시지를 제공하세요.
질문 14: Rails 에서 JWT 인증은 어떻게 구현합니까?
JWT (JSON Web Tokens) 는 API 를 위한 인기 있는 무상태 인증 방식입니다. 토큰은 사용자의 신원과 유효 기간을 인코딩합니다.
# app/services/jwt_service.rb
class JwtService
SECRET_KEY = Rails.application.credentials.secret_key_base
ALGORITHM = 'HS256'.freeze
class << self
def encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
payload[:iat] = Time.current.to_i
JWT.encode(payload, SECRET_KEY, ALGORITHM)
end
def decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, algorithm: ALGORITHM)
HashWithIndifferentAccess.new(decoded.first)
rescue JWT::ExpiredSignature
raise AuthenticationError, '토큰이 만료되었습니다'
rescue JWT::DecodeError
raise AuthenticationError, '유효하지 않은 토큰입니다'
end
end
end# app/controllers/api/v1/auth_controller.rb
module Api
module V1
class AuthController < ActionController::API
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JwtService.encode(user_id: user.id)
render json: {
token: token,
user: UserSerializer.new(user),
expires_at: 24.hours.from_now
}
else
render json: { error: '잘못된 인증 정보' }, status: :unauthorized
end
end
def refresh
token = JwtService.encode(user_id: current_user.id)
render json: { token: token, expires_at: 24.hours.from_now }
end
end
end
end# app/controllers/concerns/jwt_authenticatable.rb
module JwtAuthenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_jwt!
end
private
def authenticate_jwt!
header = request.headers['Authorization']
token = header&.split(' ')&.last
raise AuthenticationError, '토큰이 없습니다' unless token
decoded = JwtService.decode(token)
@current_user = User.find(decoded[:user_id])
rescue AuthenticationError => e
render json: { error: e.message }, status: :unauthorized
rescue ActiveRecord::RecordNotFound
render json: { error: '사용자를 찾을 수 없습니다' }, status: :unauthorized
end
def current_user
@current_user
end
end운영 환경에서는 다음을 고려하세요. 리프레시 토큰, 로그아웃 시 토큰 블랙리스트, 짧은 만료 시간 등이 있습니다. devise-jwt 같은 gem 을 사용하면 구현이 단순해집니다.
캐시와 성능
질문 15: Rails 에서 캐시는 어떻게 구현합니까?
Rails 는 여러 단계의 캐시를 제공합니다: fragment caching, Russian Doll caching, low-level caching. 사용 사례에 따라 선택합니다.
# config/environments/production.rb
config.action_controller.perform_caching = true
config.cache_store = :redis_cache_store, {
url: ENV['REDIS_URL'],
namespace: 'myapp:cache',
expires_in: 1.day,
race_condition_ttl: 10.seconds
}<%# app/views/products/index.html.erb %>
<%# 자동 캐시 키를 사용하는 fragment caching %>
<% @products.each do |product| %>
<%# 상품의 updated_at 기준으로 캐시 %>
<% cache product do %>
<%= render product %>
<% end %>
<% end %>
<%# Russian Doll caching - 중첩 캐시 %>
<% cache ['v1', @category] do %>
<h2><%= @category.name %></h2>
<% @category.products.each do |product| %>
<% cache ['v1', product] do %>
<%= render product %>
<% end %>
<% end %>
<% end %>
<%# 조건부 캐시 %>
<% cache_if current_user.nil?, @product do %>
<%= render @product %>
<% end %># app/models/product.rb
class Product < ApplicationRecord
# 부모를 touch 하여 Russian Doll 캐시 무효화
belongs_to :category, touch: true
# 사용자 정의 캐시 키
def cache_key_with_version
"#{super}/#{reviews.maximum(:updated_at)&.to_i}"
end
end# 서비스 내 low-level caching
class DashboardStatsService
def call
Rails.cache.fetch('dashboard:stats', expires_in: 15.minutes) do
{
total_users: User.count,
active_users: User.where('last_sign_in_at > ?', 30.days.ago).count,
total_orders: Order.completed.count,
revenue_mtd: Order.completed.where(created_at: Time.current.beginning_of_month..).sum(:total)
}
end
end
end
# 경쟁 조건 보호가 있는 캐시
Rails.cache.fetch('popular_products', expires_in: 1.hour, race_condition_ttl: 10.seconds) do
Product.bestsellers.limit(10).to_a
end
# 명시적 무효화
Rails.cache.delete('dashboard:stats')
Rails.cache.delete_matched('products:*')Russian Doll caching 은 변경된 조각만 다시 만들기 때문에 효과적입니다. 무효화를 전파하려면 연관관계에 touch: true 를 사용하세요.
질문 16: Rails 애플리케이션의 성능을 어떻게 최적화합니까?
Rails 최적화는 DB 쿼리, 캐시, 에셋, 아키텍처 등 다양한 측면을 다룹니다. 모니터링을 동반한 체계적인 접근이 중요합니다.
# 데이터베이스 최적화
# config/database.yml
production:
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
prepared_statements: true
advisory_locks: true
# app/models/order.rb
class Order < ApplicationRecord
# 자주 사용되는 쿼리를 위한 복합 인덱스
# add_index :orders, [:user_id, :status, :created_at]
# 필요한 컬럼만 선택
scope :summary, -> { select(:id, :status, :total, :created_at) }
# 대량 데이터 배치 처리
def self.process_pending
pending.find_each(batch_size: 1000) do |order|
ProcessOrderJob.perform_later(order.id)
end
end
# 반복 계산 회피
def self.revenue_by_month
completed
.group("DATE_TRUNC('month', created_at)")
.sum(:total)
end
end# 메모리 최적화
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
preload_app!
before_fork do
ActiveRecord::Base.connection_pool.disconnect!
end
on_worker_boot do
ActiveRecord::Base.establish_connection
end# rack-mini-profiler 로 프로파일링
# Gemfile
group :development do
gem 'rack-mini-profiler'
gem 'memory_profiler'
gem 'stackprof'
end
# config/initializers/mini_profiler.rb
if defined?(Rack::MiniProfiler)
Rack::MiniProfiler.config.position = 'bottom-right'
Rack::MiniProfiler.config.start_hidden = true
end# 지연 로딩과 페이지네이션
class ProductsController < ApplicationController
def index
@products = Product.active
.includes(:category, :primary_image)
.page(params[:page])
.per(24)
# 다음 페이지 prefetch
if @products.next_page
Rails.cache.fetch("products:page:#{@products.next_page}", expires_in: 5.minutes) do
Product.active.page(@products.next_page).per(24).to_a
end
end
end
end핵심 도구: 프로파일링은 rack-mini-profiler, N+1 감지는 bullet, 운영 모니터링은 New Relic 또는 Scout 입니다.
보안
질문 17: Rails 의 보안 모범 사례는 무엇입니까?
Rails 는 일반적인 취약점에 대해 기본 보호 기능을 제공합니다. 이러한 보호 기능을 이해하고 올바르게 구성하는 것이 매우 중요합니다.
# CSRF 보호
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# 기본적으로 활성화되며 토큰이 유효하지 않으면 예외를 발생시킵니다
protect_from_forgery with: :exception
# API 의 경우 :null_session 사용
# protect_from_forgery with: :null_session
end
# 뷰에서는 폼에 토큰이 자동으로 포함됩니다
# <%= form_with ... %> 는 authenticity_token 을 포함합니다
# AJAX 요청의 경우
# csrf_meta_tags 의 값으로 X-CSRF-Token 헤더를 추가합니다# SQL 인젝션 방지
# ✅ 보간된 매개변수는 자동으로 이스케이프됩니다
User.where('email = ?', params[:email])
User.where(email: params[:email])
# ❌ 위험 - 직접 보간
User.where("email = '#{params[:email]}'")
# ✅ 동적 ORDER 절의 경우
ALLOWED_SORTS = %w[name created_at price].freeze
sort_column = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort] : 'name'
Product.order(sort_column)# XSS 보호
# Rails 는 뷰의 HTML 을 자동으로 이스케이프합니다
# ✅ 자동으로 이스케이프됨
<%= user.name %>
# ❌ 위험 - 이스케이프되지 않은 콘텐츠
<%== user.bio %>
<%= raw user.bio %>
<%= user.bio.html_safe %>
# ✅ 안전한 HTML 을 위해 sanitize 사용
<%= sanitize user.bio, tags: %w[p br strong em] %># Strong Parameters
class UsersController < ApplicationController
def update
@user.update!(user_params)
end
private
def user_params
# 허용된 속성의 명시적 화이트리스트
params.require(:user).permit(:name, :email, :avatar)
# 관리자만
if current_user.admin?
params.require(:user).permit(:name, :email, :role, :active)
else
params.require(:user).permit(:name, :email)
end
end
end# 보안 헤더
# config/initializers/secure_headers.rb
Rails.application.config.action_dispatch.default_headers = {
'X-Frame-Options' => 'SAMEORIGIN',
'X-XSS-Protection' => '1; mode=block',
'X-Content-Type-Options' => 'nosniff',
'X-Download-Options' => 'noopen',
'X-Permitted-Cross-Domain-Policies' => 'none',
'Referrer-Policy' => 'strict-origin-when-cross-origin'
}
# Content Security Policy
Rails.application.config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self
policy.style_src :self, :unsafe_inline
policy.img_src :self, :data, 'https:'
endbrakeman (정적 보안 분석) 으로 정기적으로 감사하고 bundle audit 로 gem 을 최신 상태로 유지하세요.
질문 18: Rails 에서 인증과 권한을 어떻게 처리합니까?
인증은 신원을 확인하고, 권한은 접근을 제어합니다. Devise 가 인증을 담당하고 Pundit 또는 CanCanCan 이 권한을 담당합니다.
# Devise 설정
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:confirmable, :lockable, :trackable
enum role: { user: 0, moderator: 1, admin: 2 }
def admin?
role == 'admin'
end
end# Pundit 정책
# app/policies/article_policy.rb
class ArticlePolicy < ApplicationPolicy
def index?
true
end
def show?
record.published? || owner_or_admin?
end
def create?
user.present?
end
def update?
owner_or_admin?
end
def destroy?
owner_or_admin?
end
def publish?
user&.admin? || user&.moderator?
end
# 컬렉션을 위한 스코프
class Scope < Scope
def resolve
if user&.admin?
scope.all
elsif user
scope.where(published: true).or(scope.where(author: user))
else
scope.where(published: true)
end
end
end
private
def owner_or_admin?
user&.admin? || record.author == user
end
end# Pundit 을 사용하는 컨트롤러
class ArticlesController < ApplicationController
include Pundit::Authorization
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
def index
@articles = policy_scope(Article).includes(:author).page(params[:page])
end
def show
@article = Article.find(params[:id])
authorize @article
end
def update
@article = Article.find(params[:id])
authorize @article
if @article.update(article_params)
redirect_to @article, notice: '게시물이 업데이트되었습니다.'
else
render :edit, status: :unprocessable_entity
end
end
def publish
@article = Article.find(params[:id])
authorize @article
@article.update!(published: true, published_at: Time.current)
redirect_to @article, notice: '게시물이 발행되었습니다.'
end
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized
flash[:alert] = "이 작업을 수행할 권한이 없습니다."
redirect_back(fallback_location: root_path)
end
endPundit 은 CanCanCan 보다 더 명시적이고 테스트하기 쉽습니다. 각 액션에는 대응하는 정책 메서드가 있고, 스코프가 컬렉션을 자동으로 필터링합니다.
고급 Rails
질문 19: Rails 의 Repository 패턴을 설명해 주세요
Repository 패턴은 데이터 접근 로직을 애플리케이션의 다른 부분과 분리합니다. Rails 는 (다른 패턴인) Active Record 를 사용하지만, 복잡한 경우에는 Repository 가 유용할 수 있습니다.
# app/repositories/base_repository.rb
class BaseRepository
def initialize(model_class)
@model_class = model_class
end
def all
@model_class.all
end
def find(id)
@model_class.find(id)
end
def find_by(attributes)
@model_class.find_by(attributes)
end
def create(attributes)
@model_class.create(attributes)
end
def update(record, attributes)
record.update(attributes)
end
def delete(record)
record.destroy
end
end# app/repositories/product_repository.rb
class ProductRepository < BaseRepository
def initialize
super(Product)
end
def active
@model_class.where(active: true)
end
def in_category(category_id)
@model_class.where(category_id: category_id)
end
def search(query)
@model_class.where('name ILIKE ? OR description ILIKE ?',
"%#{query}%", "%#{query}%")
end
def with_stock
@model_class.where('stock_quantity > 0')
end
def bestsellers(limit: 10)
@model_class
.joins(:order_items)
.group(:id)
.order('COUNT(order_items.id) DESC')
.limit(limit)
end
def for_homepage
active
.with_stock
.includes(:category, :primary_image)
.order(featured: :desc, created_at: :desc)
.limit(12)
end
end# 서비스에서 사용
class ProductSearchService
def initialize(repository: ProductRepository.new)
@repository = repository
end
def call(params)
products = @repository.active
products = products.in_category(params[:category]) if params[:category]
products = products.search(params[:query]) if params[:query].present?
products = products.with_stock if params[:in_stock]
products
end
end
# 모의 객체 (mock) 를 사용한 테스트가 용이합니다
RSpec.describe ProductSearchService do
let(:repository) { instance_double(ProductRepository) }
let(:service) { described_class.new(repository: repository) }
it '카테고리로 필터링합니다' do
products = double('products')
allow(repository).to receive(:active).and_return(products)
allow(products).to receive(:in_category).with(1).and_return(products)
service.call(category: 1)
expect(products).to have_received(:in_category).with(1)
end
endActive Record 자체가 이미 훌륭한 패턴이므로 Rails 에서 Repository 는 선택 사항입니다. 복잡한 쿼리나 저장소 격리가 중요한 경우에 사용하세요.
질문 20: Rails 에서 CQRS 패턴을 어떻게 구현합니까?
CQRS (Command Query Responsibility Segregation) 는 읽기와 쓰기 작업을 분리합니다. Rails 에서는 query 와 command 를 위한 별도의 클래스로 구현됩니다.
# app/commands/base_command.rb
class BaseCommand
include ActiveModel::Validations
def self.call(*args)
new(*args).call
end
def call
return failure(errors) unless valid?
execute
end
private
def execute
raise NotImplementedError
end
def success(data = nil)
CommandResult.success(data)
end
def failure(errors)
CommandResult.failure(errors)
end
end
CommandResult = Struct.new(:success, :data, :errors, keyword_init: true) do
def success? = success
def failure? = !success
def self.success(data)
new(success: true, data: data, errors: [])
end
def self.failure(errors)
new(success: false, data: nil, errors: Array(errors))
end
end# app/commands/orders/create_order_command.rb
module Orders
class CreateOrderCommand < BaseCommand
attr_reader :user, :items, :shipping_address
validates :user, presence: true
validates :items, presence: true
validate :validate_items_availability
def initialize(user:, items:, shipping_address:)
@user = user
@items = items
@shipping_address = shipping_address
end
private
def execute
order = nil
ActiveRecord::Base.transaction do
order = Order.create!(
user: user,
shipping_address: shipping_address,
status: 'pending'
)
items.each do |item|
order.items.create!(
product_id: item[:product_id],
quantity: item[:quantity],
unit_price: Product.find(item[:product_id]).price
)
end
order.calculate_total!
end
OrderCreatedEvent.broadcast(order)
success(order)
rescue ActiveRecord::RecordInvalid => e
failure(e.message)
end
def validate_items_availability
items.each do |item|
product = Product.find_by(id: item[:product_id])
unless product&.stock_quantity&.>= item[:quantity]
errors.add(:items, "상품 #{item[:product_id]} 사용 불가")
end
end
end
end
end# app/queries/orders/user_orders_query.rb
module Orders
class UserOrdersQuery
def initialize(user, params = {})
@user = user
@params = params
end
def call
orders = @user.orders.includes(:items, items: :product)
orders = apply_status_filter(orders)
orders = apply_date_filter(orders)
orders = apply_sorting(orders)
orders.page(@params[:page]).per(@params[:per_page] || 20)
end
private
def apply_status_filter(orders)
return orders unless @params[:status]
orders.where(status: @params[:status])
end
def apply_date_filter(orders)
orders = orders.where('created_at >= ?', @params[:from]) if @params[:from]
orders = orders.where('created_at <= ?', @params[:to]) if @params[:to]
orders
end
def apply_sorting(orders)
case @params[:sort]
when 'oldest' then orders.order(created_at: :asc)
when 'total_desc' then orders.order(total: :desc)
else orders.order(created_at: :desc)
end
end
end
end# CQRS 를 사용하는 컨트롤러
class OrdersController < ApplicationController
def index
@orders = Orders::UserOrdersQuery.new(current_user, filter_params).call
end
def create
result = Orders::CreateOrderCommand.call(
user: current_user,
items: order_params[:items],
shipping_address: order_params[:shipping_address]
)
if result.success?
redirect_to result.data, notice: '주문이 생성되었습니다!'
else
flash.now[:alert] = result.errors.join(', ')
render :new, status: :unprocessable_entity
end
end
endCQRS 는 읽기/쓰기 요구가 비대칭적인 복잡한 애플리케이션에서 빛을 발합니다. 단순한 CRUD 의 경우에는 과도한 설계입니다.
질문 21: Action Cable 로 WebSocket 을 어떻게 처리합니까?
Action Cable 은 Rails 에 WebSocket 을 통합하여 실시간 양방향 통신을 제공합니다. 서버 간 동기화에 Redis 를 사용합니다.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
# 세션 쿠키 기반
if verified_user = User.find_by(id: cookies.encrypted[:user_id])
verified_user
# API 용 JWT 기반
elsif verified_user = verify_jwt_token
verified_user
else
reject_unauthorized_connection
end
end
def verify_jwt_token
token = request.params[:token]
return nil unless token
decoded = JwtService.decode(token)
User.find(decoded[:user_id])
rescue
nil
end
end
end# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
@room = ChatRoom.find(params[:room_id])
# 권한 확인
unless @room.accessible_by?(current_user)
reject
return
end
stream_for @room
# 다른 사용자에게 입장 알림
broadcast_presence(:joined)
end
def unsubscribed
broadcast_presence(:left) if @room
end
def send_message(data)
message = @room.messages.create!(
user: current_user,
content: data['content']
)
# 모든 구독자에게 브로드캐스트
ChatChannel.broadcast_to(@room, {
type: 'message',
message: MessageSerializer.new(message).as_json
})
end
def typing
ChatChannel.broadcast_to(@room, {
type: 'typing',
user: current_user.name
})
end
private
def broadcast_presence(action)
ChatChannel.broadcast_to(@room, {
type: 'presence',
action: action,
user: current_user.name,
online_count: @room.online_users_count
})
end
endimport consumer from "./consumer"
const chatChannel = consumer.subscriptions.create(
{ channel: "ChatChannel", room_id: roomId },
{
connected() {
console.log("Connected to chat")
},
disconnected() {
console.log("Disconnected from chat")
},
received(data) {
switch(data.type) {
case 'message':
this.appendMessage(data.message)
break
case 'typing':
this.showTypingIndicator(data.user)
break
case 'presence':
this.updatePresence(data)
break
}
},
sendMessage(content) {
this.perform('send_message', { content: content })
},
notifyTyping() {
this.perform('typing')
}
}
)Action Cable 은 재연결과 동기화를 자동으로 처리합니다. 운영 환경에서는 Redis 를 어댑터로 구성하고 동시 연결 수에 따라 확장하세요.
질문 22: Rails 에서 멀티테넌시는 어떻게 구현합니까?
멀티테넌시는 한 애플리케이션이 격리된 여러 고객 (테넌트) 에게 서비스를 제공할 수 있게 합니다. 주요 방식은 데이터베이스 레벨, 스키마 레벨, 행 레벨의 세 가지입니다.
# 행 레벨 멀티테넌시 (ActsAsTenant 또는 수동)
# app/models/concerns/tenant_scoped.rb
module TenantScoped
extend ActiveSupport::Concern
included do
belongs_to :tenant
# 현재 테넌트에 대한 기본 스코프
default_scope -> { where(tenant: Current.tenant) if Current.tenant }
# 테넌트 검증
before_validation :set_tenant, on: :create
end
private
def set_tenant
self.tenant ||= Current.tenant
end
end
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :tenant, :user
end# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_current_tenant
private
def set_current_tenant
Current.tenant = resolve_tenant
Current.user = current_user
end
def resolve_tenant
# 서브도메인 기반
if request.subdomain.present? && request.subdomain != 'www'
Tenant.find_by!(subdomain: request.subdomain)
# 헤더 기반 (API 용)
elsif request.headers['X-Tenant-ID'].present?
Tenant.find(request.headers['X-Tenant-ID'])
# 사용자 기반
elsif current_user
current_user.tenant
end
rescue ActiveRecord::RecordNotFound
redirect_to root_url(subdomain: 'www'), alert: '테넌트를 찾을 수 없습니다'
end
end# app/models/project.rb
class Project < ApplicationRecord
include TenantScoped
has_many :tasks
belongs_to :owner, class_name: 'User'
end
# app/models/user.rb
class User < ApplicationRecord
include TenantScoped
has_many :projects, foreign_key: :owner_id
# 관리자는 여러 테넌트에 속할 수 있습니다
has_many :tenant_memberships
has_many :accessible_tenants, through: :tenant_memberships, source: :tenant
end# Apartment gem 으로 스키마 레벨 (PostgreSQL)
# config/initializers/apartment.rb
Apartment.configure do |config|
config.excluded_models = %w[Tenant User]
config.tenant_names = -> { Tenant.pluck(:subdomain) }
end
# 사용
Apartment::Tenant.switch('acme') do
# 이 블록 내의 모든 쿼리는 'acme' 스키마를 사용합니다
Project.all # SELECT * FROM acme.projects
end행 레벨이 가장 단순하지만 누수에 대한 지속적인 주의가 필요합니다. 스키마 레벨은 격리가 더 강하지만 마이그레이션이 복잡해집니다. 보안과 확장성 요구에 따라 선택하세요.
질문 23: Rails 로 마이크로서비스 아키텍처는 어떻게 구성합니까?
Rails 는 HTTP/gRPC 또는 메시지 큐를 통한 통신을 갖춘 마이크로서비스 아키텍처의 기반이 될 수 있습니다. 핵심은 경계를 잘 정의하는 것입니다.
# HTTP 서비스 클라이언트
# app/services/payment_service_client.rb
class PaymentServiceClient
include HTTParty
base_uri ENV.fetch('PAYMENT_SERVICE_URL')
def initialize
@options = {
headers: {
'Content-Type' => 'application/json',
'X-Service-Token' => ENV.fetch('SERVICE_TOKEN')
},
timeout: 10
}
end
def create_charge(amount:, currency:, source:, metadata: {})
response = self.class.post('/charges', @options.merge(
body: { amount: amount, currency: currency, source: source, metadata: metadata }.to_json
))
handle_response(response)
end
def get_charge(charge_id)
response = self.class.get("/charges/#{charge_id}", @options)
handle_response(response)
end
private
def handle_response(response)
case response.code
when 200..299
ServiceResult.success(response.parsed_response)
when 400..499
ServiceResult.failure(response.parsed_response['error'], code: response.code)
else
ServiceResult.failure('서비스를 사용할 수 없습니다', code: response.code)
end
rescue Net::OpenTimeout, Net::ReadTimeout
ServiceResult.failure('서비스 시간 초과')
end
end# Sidekiq/Redis 를 활용한 이벤트 기반 통신
# app/events/order_events.rb
module OrderEvents
class Created
include Wisper::Publisher
def call(order)
broadcast(:order_created, order)
end
end
end
# app/listeners/inventory_listener.rb
class InventoryListener
def order_created(order)
order.items.each do |item|
InventoryServiceClient.new.reserve_stock(
product_id: item.product_id,
quantity: item.quantity,
reference: order.id
)
end
end
end
# config/initializers/wisper.rb
Wisper.subscribe(InventoryListener.new, async: true)
Wisper.subscribe(NotificationListener.new, async: true)# API Gateway 패턴
# app/controllers/api/v1/gateway_controller.rb
module Api
module V1
class GatewayController < BaseController
# 여러 서비스 집계
def dashboard
results = Parallel.map([:orders, :inventory, :analytics], in_threads: 3) do |service|
fetch_from_service(service)
end
render json: {
orders: results[0],
inventory: results[1],
analytics: results[2]
}
end
private
def fetch_from_service(service)
case service
when :orders
OrderServiceClient.new.recent_orders(limit: 5)
when :inventory
InventoryServiceClient.new.low_stock_alerts
when :analytics
AnalyticsServiceClient.new.daily_summary
end
rescue => e
{ error: "#{service} 사용 불가", message: e.message }
end
end
end
endRails 마이크로서비스의 경우: 명확한 API 계약 (OpenAPI) 을 정의하고, 회로 차단기 (gem circuitbox) 를 구현하고, 분산 추적 (gem opentelemetry) 을 사용하세요.
질문 24: Rails 애플리케이션을 운영 환경에 어떻게 배포합니까?
최근 Rails 배포는 컨테이너 또는 PaaS 를 사용합니다. 견고한 운영 환경 구성은 에셋, 데이터베이스, 모니터링을 포괄합니다.
# config/environments/production.rb
Rails.application.configure do
config.cache_classes = true
config.eager_load = true
config.consider_all_requests_local = false
# 에셋
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
config.assets.compile = false
config.assets.digest = true
# 로깅
config.log_level = ENV.fetch('LOG_LEVEL', 'info').to_sym
config.log_tags = [:request_id]
config.logger = ActiveSupport::Logger.new(STDOUT)
.tap { |logger| logger.formatter = Logger::Formatter.new }
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
# 캐시
config.cache_store = :redis_cache_store, {
url: ENV['REDIS_URL'],
expires_in: 1.day
}
# SSL 강제
config.force_ssl = true
config.ssl_options = { hsts: { subdomains: true } }
# Action Mailer
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: ENV['SMTP_HOST'],
port: ENV['SMTP_PORT'],
user_name: ENV['SMTP_USER'],
password: ENV['SMTP_PASSWORD'],
authentication: :plain,
enable_starttls_auto: true
}
end# Dockerfile
FROM ruby:3.3-alpine AS builder
RUN apk add --no-cache build-base postgresql-dev nodejs yarn
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment true && \
bundle config set --local without 'development test' && \
bundle install
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN bundle exec rails assets:precompile
# 운영 이미지
FROM ruby:3.3-alpine
RUN apk add --no-cache postgresql-client tzdata
WORKDIR /app
COPY /app /app
COPY /usr/local/bundle /usr/local/bundle
ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=true
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]# docker-compose.production.yml
version: '3.8'
services:
web:
build: .
environment:
- DATABASE_URL=postgres://user:pass@db/app_production
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
depends_on:
- db
- redis
deploy:
replicas: 3
resources:
limits:
memory: 512M
sidekiq:
build: .
command: bundle exec sidekiq
environment:
- DATABASE_URL=postgres://user:pass@db/app_production
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
deploy:
replicas: 2
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:운영 체크리스트: SSL 필수, ENV 를 통한 비밀 정보, 헬스 체크, 자동화된 DB 백업, 모니터링 (APM + 로그 + 지표), 알림 설정입니다.
질문 25: 알아두어야 할 Rails 7+ 의 새로운 기능은 무엇입니까?
Rails 7+ 는 중요한 변화를 제공합니다: 기본 Hotwire, import map, 강화된 암호화 자격 증명, 다양한 최적화입니다.
# Hotwire - Turbo Frames
# app/views/articles/index.html.erb
<%= turbo_frame_tag "articles" do %>
<% @articles.each do |article| %>
<%= turbo_frame_tag dom_id(article) do %>
<%= render article %>
<% end %>
<% end %>
<%= link_to "더 보기", articles_path(page: @page + 1),
data: { turbo_frame: "articles" } %>
<% end %>
# 실시간 업데이트를 위한 Turbo Streams
# app/controllers/comments_controller.rb
def create
@comment = @article.comments.create!(comment_params.merge(user: current_user))
respond_to do |format|
format.turbo_stream
format.html { redirect_to @article }
end
end
# app/views/comments/create.turbo_stream.erb
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comments_count", @article.comments.count %>
<%= turbo_stream.replace "comment_form", partial: "comments/form", locals: { comment: Comment.new } %># Stimulus 컨트롤러
# app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
import { debounce } from "lodash-es"
export default class extends Controller {
static targets = ["input", "results"]
static values = { url: String }
connect() {
this.search = debounce(this.search.bind(this), 300)
}
async search() {
const query = this.inputTarget.value
if (query.length < 2) return
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`)
this.resultsTarget.innerHTML = await response.text()
}
}# Import Maps (JavaScript 번들러 없이)
# config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
# CDN 에서 가져오는 pin
pin "lodash-es", to: "https://ga.jspm.io/npm:lodash-es@4.17.21/lodash.js"# Active Record Encryption (Rails 7+)
# app/models/user.rb
class User < ApplicationRecord
encrypts :email, deterministic: true # 검색을 허용
encrypts :phone_number # 기본적으로 비결정적
encrypts :ssn, deterministic: true, downcase: true
end
# config/credentials.yml.enc
active_record_encryption:
primary_key: abc123...
deterministic_key: def456...
key_derivation_salt: ghi789...# 쿼리 인터페이스 개선
# Rails 7.1+
# 비동기 쿼리
users = User.where(active: true).load_async
# 쿼리가 실행되는 동안 다른 처리를 진행
# 결과는 users.to_a 로 접근
# Common Table Expressions (CTE)
User.with(
recent_orders: Order.where('created_at > ?', 30.days.ago)
).joins('JOIN recent_orders ON recent_orders.user_id = users.id')
# 자동 inverse_of 감지
class Author < ApplicationRecord
has_many :books # inverse_of 자동 감지
end
# 기본 strict loading (N+1 방지)
class ApplicationRecord < ActiveRecord::Base
self.strict_loading_by_default = true
endRails 7+ 는 단순함 (기본적으로 Webpack 없음) 과 Hotwire 의 HTML-over-the-wire 를 선호합니다. 이 접근 방식은 JavaScript 의 복잡성을 줄이면서 현대적인 사용자 경험을 제공합니다.
결론
Ruby on Rails 면접에서는 전체 프레임워크에 대한 숙련도와 그 규약에 대한 이해가 평가됩니다. 기억해 두어야 할 핵심 내용은 다음과 같습니다.
✅ 기초: MVC, Active Record, 마이그레이션, 검증, 연관관계
✅ 아키텍처: Service Object, Concerns, Query Object, CQRS 패턴
✅ 성능: N+1 쿼리, 캐시 (fragment, Russian Doll, low-level), eager loading
✅ 테스트: RSpec, FactoryBot, request spec, 테스트 모범 사례
✅ 보안: CSRF, SQL 인젝션, XSS, Strong Parameters, 인증/권한
✅ API: RESTful 설계, JWT, 직렬화기, 버전 관리
✅ 운영: 백그라운드 작업, WebSocket, 배포, 모니터링
Rails 의 철학 (Convention over Configuration, DRY, Rails Way) 은 모든 아키텍처 결정을 안내합니다. 이러한 원칙을 익히고 언제 그것에서 벗어날지 아는 것이 견고한 전문성을 보여줍니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

ActiveRecord: Ruby on Rails에서 N+1 쿼리 문제 해결하기
ActiveRecord로 Rails의 N+1 쿼리를 탐지하고 해결하는 완전한 가이드입니다. includes, preload, eager_load와 자동 탐지 도구를 익혀보십시오.

Ruby on Rails 7: 리액티브 애플리케이션을 위한 Hotwire와 Turbo
Rails 7에서 Hotwire와 Turbo 완벽 가이드. Turbo Drive, Frames, Streams로 JavaScript 없이 리액티브 애플리케이션을 구축하는 방법.

Action Cable과 WebSocket 완벽 가이드: 2026년 Ruby on Rails 기술 면접 대비
Ruby on Rails Action Cable과 WebSocket 심층 분석. 커넥션, 채널, 브로드캐스팅, Rails 8 Solid Cable, Redis 스케일링, 테스트 패턴까지 기술 면접에서 자주 출제되는 핵심 내용을 코드 예제와 함께 정리합니다.