Ruby on Rails 면접 질문: 2026년 Top 25

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

Ruby on Rails 면접 질문 - 완벽 가이드

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 형식으로 표시합니다 %>
<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 클래스는 데이터베이스 테이블 하나를 표현하고, 각 인스턴스는 한 행을 나타냅니다.

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 메서드는 똑똑하여 일반적인 작업을 자동으로 역으로 수행할 수 있습니다. 복잡한 경우에는 updown을 따로 사용하세요.

고급 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 는 여러 선택적 필터와 조합 로직이 필요한 복잡한 검색에 적합합니다.

Ruby on Rails 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

라우팅과 컨트롤러

질문 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 은 백엔드를 추상화하지만, 배치나 속도 제한 같은 백엔드 고유 기능을 사용하려면 선택한 백엔드와 결합이 필요한 경우가 많습니다.

Ruby on Rails 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

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 %>
  <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 %>
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
app/javascript/channels/chat_channel.jsjavascript
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) 은 모든 아키텍처 결정을 안내합니다. 이러한 원칙을 익히고 언제 그것에서 벗어날지 아는 것이 견고한 전문성을 보여줍니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#ruby on rails
#ruby
#면접
#active record
#기술 면접

공유

관련 기사