# 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 형식으로 표시합니다 %>

<%= @article.title %>

작성자: <%= @article.author.name %> • <%= l @article.created_at, format: :long %>

<%= 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