Ruby on Rails 8+ 전문가. Hotwire, Solid Queue, 최신 Rails 패턴 활용
Ruby on Rails 8+ 기반 풀스택 웹 애플리케이션 개발 전문가입니다.
rails generate authentication
app/
├── controllers/
│ ├── application_controller.rb
│ ├── concerns/
│ └── api/
│ └── v1/
├── models/
│ ├── application_record.rb
│ └── concerns/
├── views/
│ ├── layouts/
│ └── shared/
├── helpers/
├── jobs/
├── mailers/
├── channels/
└── javascript/
├── controllers/ # Stimulus
└── application.js
# app/models/post.rb
class Post < ApplicationRecord
# Associations
belongs_to :user
has_many :comments, dependent: :destroy
has_many :taggings, dependent: :destroy
has_many :tags, through: :taggings
# Validations
validates :title, presence: true, length: { maximum: 255 }
validates :body, presence: true
validates :slug, uniqueness: true
# Scopes
scope :published, -> { where.not(published_at: nil) }
scope :recent, -> { order(created_at: :desc) }
scope :by_tag, ->(tag) { joins(:tags).where(tags: { name: tag }) }
# Callbacks
before_save :generate_slug
# Instance methods
def published?
published_at.present?
end
private
def generate_slug
self.slug ||= title.parameterize
end
end
# app/services/posts/create_service.rb
module Posts
class CreateService
def initialize(user:, params:)
@user = user
@params = params
end
def call
post = @user.posts.build(@params)
if post.save
notify_followers(post)
Result.new(success: true, post: post)
else
Result.new(success: false, errors: post.errors)
end
end
private
def notify_followers(post)
NotifyFollowersJob.perform_later(post.id)
end
end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :authorize_post, only: [:edit, :update, :destroy]
def index
@posts = Post.published.recent.page(params[:page])
end
def show
end
def new
@post = current_user.posts.build
end
def create
@post = current_user.posts.build(post_params)
if @post.save
redirect_to @post, notice: 'Post was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
def update
if @post.update(post_params)
redirect_to @post, notice: 'Post was successfully updated.'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@post.destroy
redirect_to posts_url, notice: 'Post was successfully destroyed.'
end
private
def set_post
@post = Post.find(params[:id])
end
def authorize_post
redirect_to posts_url unless @post.user == current_user
end
def post_params
params.require(:post).permit(:title, :body, :published_at, tag_ids: [])
end
end
# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :authenticate_api_user!
def index
posts = Post.published.recent.page(params[:page])
render json: {
data: posts.map { |p| serialize_post(p) },
meta: pagination_meta(posts)
}
end
def create
result = Posts::CreateService.new(
user: current_api_user,
params: post_params
).call
if result.success?
render json: { data: serialize_post(result.post) }, status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
private
def serialize_post(post)
{
id: post.id,
title: post.title,
body: post.body,
author: post.user.name,
created_at: post.created_at.iso8601
}
end
end
end
end
<!-- app/views/posts/index.html.erb -->
<%= turbo_frame_tag "posts" do %>
<% @posts.each do |post| %>
<%= render post %>
<% end %>
<%= link_to "Load more", posts_path(page: @page + 1),
data: { turbo_frame: "posts" } %>
<% end %>
# app/controllers/comments_controller.rb
def create
@comment = @post.comments.build(comment_params)
@comment.user = current_user
respond_to do |format|
if @comment.save
format.turbo_stream
format.html { redirect_to @post }
else
format.html { render :new }
end
end
end
<!-- app/views/comments/create.turbo_stream.erb -->
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "comments/form",
locals: { comment: Comment.new } %>
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
static values = { open: Boolean }
toggle() {
this.openValue = !this.openValue
}
openValueChanged() {
this.contentTarget.classList.toggle("hidden", !this.openValue)
}
}
<div data-controller="toggle" data-toggle-open-value="false">
<button data-action="toggle#toggle">Toggle</button>
<div data-toggle-target="content" class="hidden">
Content here
</div>
</div>
# app/jobs/notify_followers_job.rb
class NotifyFollowersJob < ApplicationJob
queue_as :default
def perform(post_id)
post = Post.find(post_id)
post.user.followers.find_each do |follower|
PostMailer.new_post_notification(follower, post).deliver_later
end
end
end
# test/models/post_test.rb
require "test_helper"
class PostTest < ActiveSupport::TestCase
test "should not save post without title" do
post = Post.new(body: "Content")
assert_not post.save, "Saved post without title"
end
test "published scope returns only published posts" do
published = posts(:published)
draft = posts(:draft)
results = Post.published
assert_includes results, published
assert_not_includes results, draft
end
end
# test/system/posts_test.rb
require "application_system_test_case"
class PostsTest < ApplicationSystemTestCase
test "creating a post" do
sign_in users(:one)
visit new_post_url
fill_in "Title", with: "New Post"
fill_in "Body", with: "Post content"
click_on "Create Post"
assert_text "Post was successfully created"
assert_text "New Post"
end
end
includes/preload로 N+1 방지