# Ruby on Rails 면접 질문: 2026년 Top 25
> 가장 자주 묻는 Ruby on Rails 면접 질문 25선. MVC 아키텍처, Active Record, 마이그레이션, RSpec 테스트, REST API에 대한 자세한 답변과 코드 예제.
- Published: 2026-02-03
- Updated: 2026-04-27
- Author: Anthony Fillion-Maillet
- Tags: ruby on rails, ruby, 면접, active record, 기술 면접
- Reading time: 22 min
---
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 아키텍처의 핵심입니다. 책임을 세 개의 명확한 계층으로 분리해 코드의 유지 보수성과 테스트 용이성을 향상시킵니다.
```ruby
# 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
```
```ruby
# 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
```
```erb
<%# app/views/articles/show.html.erb %>
<%# View는 데이터를 HTML 형식으로 표시합니다 %>
<%= simple_format @article.body %>
<%# 댓글을 위한 partial %>
<%= render @comments %>
```
일반적인 흐름은 다음과 같습니다. 요청이 Router에 도달하면 적절한 Controller로 전달됩니다. Controller는 데이터를 가져오거나 수정하기 위해 Model과 상호작용한 뒤, 해당 데이터를 View에 전달해 HTML로 렌더링합니다.
### 질문 2: Active Record란 무엇이며 Rails ORM은 어떻게 동작합니까?
Active Record는 Active Record 패턴을 구현한 Rails의 ORM (Object-Relational Mapping) 입니다. 각 Model 클래스는 데이터베이스 테이블 하나를 표현하고, 각 인스턴스는 한 행을 나타냅니다.
```ruby
# 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
```
```ruby
# 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)
end
```
Active Record는 Ruby 메서드를 최적화된 SQL 쿼리로 변환합니다. `where`, `joins`, `includes` 같은 메서드는 lazy 동작이며, 이터레이션을 시작하거나 `to_a`를 호출할 때 비로소 쿼리가 실행됩니다.
### 질문 3: Rails 의 마이그레이션 시스템을 설명해 주세요
마이그레이션을 사용하면 Ruby로 데이터베이스 스키마의 버전을 관리할 수 있습니다. 되돌릴 수 있으며 데이터 구조를 통제된 방식으로 진화시킬 수 있습니다.
```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
```
```ruby
# 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
```
```bash
# 필수 마이그레이션 명령
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 메서드를 제공합니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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 가 더 나은 구조와 테스트 용이성을 제공합니다.
```ruby
# 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
```
```ruby
# 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 는 여러 선택적 필터와 조합 로직이 필요한 복잡한 검색에 적합합니다.
## 라우팅과 컨트롤러
### 질문 6: Rails 의 RESTful 라우팅은 어떻게 동작합니까?
Rails 는 HTTP 동사를 CRUD 액션과 매핑하는 RESTful 라우트를 권장합니다. 라우터는 URL 을 특정 컨트롤러 호출로 변환합니다.
```ruby
# 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
```
```bash
# 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) 은 컨트롤러 액션 전, 후 또는 주변에서 코드를 실행할 수 있게 해줍니다.
```ruby
# 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
```
```ruby
# 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 에도 속하지 않는 복잡한 비즈니스 로직을 캡슐화합니다. 테스트 용이성을 향상시키고 단일 책임 원칙을 따릅니다.
```ruby
# 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
```
```ruby
# 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
end
```
Service Object 패턴은 단순한 규약을 따릅니다: 하나의 클래스, 하나의 책임, 공개 메서드는 `call` 하나입니다. Result 객체를 반환하면 성공과 실패를 깔끔하게 처리할 수 있습니다.
### 질문 9: Rails 의 Concerns 를 설명해 주세요
Concerns 는 Models 또는 Controllers 사이에서 코드를 추출해 공유할 수 있게 해줍니다. 깔끔한 include 문법을 위해 `ActiveSupport::Concern` 을 사용합니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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
end
```
Concerns 는 진정으로 공유되는 코드를 위해 사용해야 합니다. Model 을 단지 짧게 보이게 하려고 Concern 을 만들면 복잡성을 줄이는 게 아니라 감추게 됩니다.
## RSpec 으로 테스트
### 질문 10: Rails 의 RSpec 테스트는 어떻게 구성합니까?
RSpec 은 Rails 의 표준 테스트 프레임워크입니다. 좋은 테스트 구조에는 Model spec, Controller spec, Service spec, 통합 테스트가 포함됩니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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 를 대체합니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 테스트에서의 사용
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 와의 결합으로 우수한 성능을 제공해 인기가 높습니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 작업 큐잉
# 즉시 실행
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)
```
```ruby
# 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: CleanupJob
```
Active Job 은 백엔드를 추상화하지만, 배치나 속도 제한 같은 백엔드 고유 기능을 사용하려면 선택한 백엔드와 결합이 필요한 경우가 많습니다.
## API 개발
### 질문 13: Rails 로 RESTful API 를 어떻게 구축합니까?
Rails 는 API 전용 컨트롤러와 직렬화기를 통해 JSON API 구축을 쉽게 해줍니다. 좋은 API 는 버전이 관리되고 문서화되어 있으며 안전합니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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
end
```
API 모범 사례: namespace 를 통해 버전을 관리하고, 적절한 HTTP 코드를 사용하며, 컬렉션은 페이지네이션하고, 명확한 에러 메시지를 제공하세요.
### 질문 14: Rails 에서 JWT 인증은 어떻게 구현합니까?
JWT (JSON Web Tokens) 는 API 를 위한 인기 있는 무상태 인증 방식입니다. 토큰은 사용자의 신원과 유효 기간을 인코딩합니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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. 사용 사례에 따라 선택합니다.
```ruby
# 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
}
```
```erb
<%# 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 %>
<%= @category.name %>
<% @category.products.each do |product| %>
<% cache ['v1', product] do %>
<%= render product %>
<% end %>
<% end %>
<% end %>
<%# 조건부 캐시 %>
<% cache_if current_user.nil?, @product do %>
<%= render @product %>
<% end %>
```
```ruby
# 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
```
```ruby
# 서비스 내 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 쿼리, 캐시, 에셋, 아키텍처 등 다양한 측면을 다룹니다. 모니터링을 동반한 체계적인 접근이 중요합니다.
```ruby
# 데이터베이스 최적화
# 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
```
```ruby
# 메모리 최적화
# 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
```
```ruby
# 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
```
```ruby
# 지연 로딩과 페이지네이션
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 는 일반적인 취약점에 대해 기본 보호 기능을 제공합니다. 이러한 보호 기능을 이해하고 올바르게 구성하는 것이 매우 중요합니다.
```ruby
# 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 헤더를 추가합니다
```
```ruby
# 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)
```
```ruby
# 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] %>
```
```ruby
# 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
```
```ruby
# 보안 헤더
# 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:'
end
```
`brakeman` (정적 보안 분석) 으로 정기적으로 감사하고 `bundle audit` 로 gem 을 최신 상태로 유지하세요.
### 질문 18: Rails 에서 인증과 권한을 어떻게 처리합니까?
인증은 신원을 확인하고, 권한은 접근을 제어합니다. Devise 가 인증을 담당하고 Pundit 또는 CanCanCan 이 권한을 담당합니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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
end
```
Pundit 은 CanCanCan 보다 더 명시적이고 테스트하기 쉽습니다. 각 액션에는 대응하는 정책 메서드가 있고, 스코프가 컬렉션을 자동으로 필터링합니다.
## 고급 Rails
### 질문 19: Rails 의 Repository 패턴을 설명해 주세요
Repository 패턴은 데이터 접근 로직을 애플리케이션의 다른 부분과 분리합니다. Rails 는 (다른 패턴인) Active Record 를 사용하지만, 복잡한 경우에는 Repository 가 유용할 수 있습니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 서비스에서 사용
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
end
```
Active Record 자체가 이미 훌륭한 패턴이므로 Rails 에서 Repository 는 선택 사항입니다. 복잡한 쿼리나 저장소 격리가 중요한 경우에 사용하세요.
### 질문 20: Rails 에서 CQRS 패턴을 어떻게 구현합니까?
CQRS (Command Query Responsibility Segregation) 는 읽기와 쓰기 작업을 분리합니다. Rails 에서는 query 와 command 를 위한 별도의 클래스로 구현됩니다.
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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
end
```
CQRS 는 읽기/쓰기 요구가 비대칭적인 복잡한 애플리케이션에서 빛을 발합니다. 단순한 CRUD 의 경우에는 과도한 설계입니다.
### 질문 21: Action Cable 로 WebSocket 을 어떻게 처리합니까?
Action Cable 은 Rails 에 WebSocket 을 통합하여 실시간 양방향 통신을 제공합니다. 서버 간 동기화에 Redis 를 사용합니다.
```ruby
# 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
```
```ruby
# 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
end
```
```javascript
// app/javascript/channels/chat_channel.js
import 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 에서 멀티테넌시는 어떻게 구현합니까?
멀티테넌시는 한 애플리케이션이 격리된 여러 고객 (테넌트) 에게 서비스를 제공할 수 있게 합니다. 주요 방식은 데이터베이스 레벨, 스키마 레벨, 행 레벨의 세 가지입니다.
```ruby
# 행 레벨 멀티테넌시 (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
```
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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 또는 메시지 큐를 통한 통신을 갖춘 마이크로서비스 아키텍처의 기반이 될 수 있습니다. 핵심은 경계를 잘 정의하는 것입니다.
```ruby
# 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
```
```ruby
# 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)
```
```ruby
# 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
end
```
Rails 마이크로서비스의 경우: 명확한 API 계약 (OpenAPI) 을 정의하고, 회로 차단기 (gem `circuitbox`) 를 구현하고, 분산 추적 (gem `opentelemetry`) 을 사용하세요.
### 질문 24: Rails 애플리케이션을 운영 환경에 어떻게 배포합니까?
최근 Rails 배포는 컨테이너 또는 PaaS 를 사용합니다. 견고한 운영 환경 구성은 에셋, 데이터베이스, 모니터링을 포괄합니다.
```ruby
# 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
# 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 --from=builder /app /app
COPY --from=builder /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"]
```
```yaml
# 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, 강화된 암호화 자격 증명, 다양한 최적화입니다.
```ruby
# 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 } %>
```
```ruby
# 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()
}
}
```
```ruby
# 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"
```
```ruby
# 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...
```
```ruby
# 쿼리 인터페이스 개선
# 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
end
```
Rails 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) 은 모든 아키텍처 결정을 안내합니다. 이러한 원칙을 익히고 언제 그것에서 벗어날지 아는 것이 견고한 전문성을 보여줍니다.
---
Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack.
HTML version of this page: https://sharpskill.dev/ko/blog/ruby-on-rails/ruby-on-rails-interview-questions