Ruby on Rails에는 CarrierWave, Paperclip, Dragonfly 등 다양한 파일 업로드 젬이 존재합니다. 각각 고유한 특징을 지니고 있으며, 여러분도 이미 그중 하나 이상을 사용해 보셨을 가능성이 큽니다.
하지만 오늘은 Janko Marohnić가 만든 비교적 새롭지만 매우 훌륭한 솔루션인 Shrine을 소개하려 합니다. 다른 유사한 젬들과 달리 Shrine은 모듈 방식(modular approach)을 채택하고 있어, 모든 기능이 개별 모듈(Shrine 용어로는 플러그인)로 패키징됩니다. 유효성 검증이 필요하면 플러그인을 추가하고, 파일 처리가 필요해도 플러그인을 추가하면 됩니다. 이러한 접근 방식 덕분에 어떤 모델에 어떤 기능을 제공할지 손쉽게 제어할 수 있다는 점이 매우 매력적입니다.
이 글에서는 다음 내용을 다룹니다:
- Rails 애플리케이션에 Shrine 통합하기
- 전역 설정 및 업로더별 설정 구성하기
- 파일 업로드 기능 추가하기
- 파일 처리(썸네일 생성) 구현하기
- 유효성 검증 규칙 적용하기
- 추가 메타데이터 저장 및 Amazon S3 클라우드 스토리지 활용하기
Shrine 통합하기
먼저 기본 테스트 스위트 없이 새로운 Rails 애플리케이션을 생성합니다:
rails new FileGuru -T
이 데모는 Rails 5를 기준으로 작성되었지만, 대부분의 개념은 버전 3과 4에도 동일하게 적용됩니다.
Gemfile에 Shrine 젬을 추가합니다:
gem "shrine"
그리고 다음 명령어를 실행합니다:
bundle install
이제 Photo라는 이름의 모델이 필요합니다. Shrine은 파일 관련 정보를 _data 접미사로 끝나는 특수 텍스트 컬럼에 저장합니다. 해당 마이그레이션을 생성하고 적용합니다:
rails g model Photo title:string image_data:text
rails db:migrate
참고로 구버전 Rails에서는 마이그레이션 명령어가 다음과 같습니다:
rake db:migrate
Shrine의 설정 옵션은 전역과 모델별 두 가지 수준에서 지정할 수 있습니다. 전역 설정은 물론 이니셜라이저 파일 안에서 진행하며, 여기에 필요한 파일과 플러그인을 연결합니다. Shrine의 플러그인은 기능 조각들을 별도 모듈로 분리하여 사용 가능한 모든 기능을 완전히 제어할 수 있게 해줍니다. 예를 들어 유효성 검증, 이미지 처리, 첨부 파일 캐싱 등을 위한 플러그인이 존재합니다.
우선 두 가지 플러그인을 전역으로 추가하겠습니다. 하나는 ActiveRecord 지원용이고, 다른 하나는 로깅 설정용입니다. 또한 파일 시스템 스토리지를 설정합니다:
config/initializers/shrine.rb
require "shrine"
require "shrine/storage/file_system"
Shrine.plugin :activerecord
Shrine.plugin :logging, logger: Rails.logger
Shrine.storages = {
cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
store: Shrine::Storage::FileSystem.new("public", prefix: "uploads/store"),
}
로거는 파일 처리에 소요된 시간 등 디버깅 정보를 콘솔에 출력해 주며, 실무에서 상당히 유용합니다.
2015-10-09T20:06:06.676Z #25602: STORE[cache] ImageUploader[:avatar] User[29543] 1 file (0.1s)
2015-10-09T20:06:06.854Z #25602: PROCESS[store]: ImageUploader[:avatar] User[29543] 1-3 files (0.22s)
2015-10-09T20:06:07.133Z #25602: DELETE[destroyed]: ImageUploader[:avatar] User[29543] 3 files (0.07s)
업로드된 모든 파일은 public/uploads 디렉터리에 저장됩니다. Git 추적 대상에서 제외하는 것이 좋으므로 해당 폴더를 무시 목록에 추가합니다:
.gitignore
public/uploads
다음으로 모델별 설정을 담게 될 특별한 '업로더' 클래스를 생성합니다. 우선은 빈 클래스로 시작합니다:
models/image_uploader.rb
class ImageUploader < Shrine
end
마지막으로 이 클래스를 Photo 모델에 포함시킵니다:
models/photo.rb
include ImageUploader[:image]
[:image]는 폼 작성 시 사용할 가상 속성(virtual attribute)을 추가합니다. 위 코드는 아래와 같이 표현할 수도 있습니다:
include ImageUploader.attachment(:image)
# 또는
include ImageUploader::Attachment.new(:image)
이제 모델에 Shrine 기능이 탑재되었으니 다음 단계로 넘어갑니다.
컨트롤러, 뷰, 라우트 구성
이 데모에서는 사진을 관리하는 단 하나의 컨트롤러만 필요합니다. index 페이지가 루트 역할을 하게 됩니다:
pages_controller.rb
class PhotosController < ApplicationController
def index
@photos = Photo.all
end
end
뷰는 다음과 같습니다:
views/photos/index.html.erb
<h1>Photos</h1>
<%= link_to 'Add Photo', new_photo_path %>
<%= render @photos %>
@photos 배열을 렌더링하기 위해 파셜(partial)이 필요합니다:
views/photos/_photo.html.erb
<div>
<% if photo.image_data? %>
<%= image_tag photo.image_url %>
<% end %>
<p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>
image_data?는 ActiveRecord가 제공하는 메서드로, 레코드에 이미지가 있는지 확인합니다.
image_url은 원본 이미지 경로를 반환하는 Shrine 메서드입니다. 물론 작은 썸네일을 표시하는 것이 훨씬 좋지만, 그 부분은 뒤에서 다루겠습니다.
필요한 라우트를 모두 추가합니다:
config/routes.rb
resources :photos, only: [:new, :create, :index, :edit, :update]
root 'photos#index'
기본 준비가 끝났습니다. 이제 본격적인 부분으로 들어가겠습니다!
파일 업로드 구현
이 섹션에서는 실제로 파일을 업로드하는 기능을 추가하는 방법을 알아보겠습니다. 컨트롤러 액션은 매우 간단합니다:
photos_controller.rb
def new
@photo = Photo.new
end
def create
@photo = Photo.new(photo_params)
if @photo.save
flash[:success] = 'Photo added!'
redirect_to photos_path
else
render 'new'
end
end
주의할 점은 Strong Parameters에서 image_data가 아니라 image 가상 속성을 허용해야 한다는 것입니다.
photos_controller.rb
private
def photo_params
params.require(:photo).permit(:title, :image)
end
new 뷰를 생성합니다:
views/photos/new.html.erb
<h1>Add photo</h1>
<%= render 'form' %>
폼 파셜도 간단합니다:
views/photos/_form.html.erb
<%= form_for @photo do |f| %>
<%= render "shared/errors", object: @photo %>
<%= f.label :title %>
<%= f.text_field :title %>
<%= f.label :image %>
<%= f.file_field :image %>
<%= f.submit %>
<% end %>
여기서도 image_data가 아닌 image 속성을 사용한다는 점에 유의하세요.
마지막으로 에러를 표시할 또 다른 파셜을 추가합니다:
views/shared/_errors.html.erb
<% if object.errors.any? %>
<h3>The following errors were found:</h3>
<ul>
<% object.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
이것으로 기본적인 이미지 업로드를 바로 시작할 수 있습니다.
유효성 검증
데모 앱을 완성하려면 아직 할 일이 많습니다. 가장 큰 문제는 사용자가 어떤 종류의 파일이든 임의의 크기로 업로드할 수 있다는 점입니다. 따라서 유효성 검증을 지원하는 플러그인을 추가합니다:
config/initializers/shrine.rb
Shrine.plugin :validation_helpers
ImageUploader에 검증 로직을 설정합니다:
models/image_uploader.rb
Attacher.validate do
validate_max_size 1.megabyte, message: "is too large (max is 1 MB)"
validate_mime_type_inclusion ['image/jpg', 'image/jpeg', 'image/png']
end
여기서는 1MB 미만의 JPG 및 PNG 이미지만 업로드를 허용했습니다. 필요에 맞게 규칙을 자유롭게 조정하세요.
MIME 타입
또 하나 중요한 점은, 기본적으로 Shrine이 HTTP Content-Type 헤더를 기반으로 파일의 MIME 타입을 판단한다는 것입니다. 이 헤더는 브라우저가 전달하며 파일 확장자만을 근거로 설정되므로, 항상 바람직하지 않습니다.
파일 내용을 기반으로 MIME 타입을 판단하고 싶다면 determine_mime_type 플러그인을 사용하세요. 다른 모델에는 이 기능이 필요 없을 수 있으므로, 업로더 클래스 내부에 포함시키겠습니다:
models/image_uploader.rb
plugin :determine_mime_type
이 플러그인은 기본적으로 리눅스의 file 유틸리티를 사용합니다.
첨부 이미지 캐싱
현재 사용자가 잘못된 데이터로 폼을 전송하면 폼이 에러 메시지와 함께 다시 표시됩니다. 하지만 문제는 첨부했던 이미지가 사라져 사용자가 다시 선택해야 한다는 점입니다. cached_attachment_data 플러그인을 사용하면 아주 쉽게 해결할 수 있습니다:
models/image_uploader.rb
plugin :cached_attachment_data
이제 폼에 숨김 필드(hidden field)를 추가하기만 하면 됩니다.
views/photos/_form.html.erb
<%= f.hidden_field :image, value: @photo.cached_image_data %>
<%= f.label :image %>
<%= f.file_field :image %>
사진 편집 기능
이제 이미지를 업로드할 수 있지만 편집할 방법이 없으므로 바로 해결해 보겠습니다. 해당 컨트롤러 액션은 다소 단순합니다:
photos_controller.rb
def edit
@photo = Photo.find(params[:id])
end
def update
@photo = Photo.find(params[:id])
if @photo.update_attributes(photo_params)
flash[:success] = 'Photo edited!'
redirect_to photos_path
else
render 'edit'
end
end
동일한 _form 파셜을 재활용합니다:
views/photos/edit.html.erb
<h1>Edit Photo</h1>
<%= render 'form' %>
좋습니다. 하지만 아직 부족합니다. 사용자가 업로드된 이미지를 삭제할 수 없기 때문입니다. 이를 허용하려면 또 다른 플러그인이 필요합니다:
models/image_uploader.rb
plugin :remove_attachment
이 플러그인은 :remove_image라는 가상 속성을 사용하므로, 컨트롤러에서 이를 허용해야 합니다:
photos_controller.rb
def photo_params
params.require(:photo).permit(:title, :image, :remove_image)
end
레코드에 첨부 파일이 있는 경우 삭제용 체크박스를 표시하면 됩니다:
views/photos/_form.html.erb
<% if @photo.image_data? %>
Remove attachment: <%= f.check_box :remove_image %>
<% end %>
썸네일 이미지 생성
현재는 원본 이미지를 그대로 표시하고 있는데, 미리보기 용도로는 최선이 아닙니다. 사진이 클 경우 화면을 과도하게 차지할 수 있습니다. CSS의 width, height 속성으로 줄일 수도 있지만, 이 역시 좋지 않습니다. 스타일로 이미지를 작게 보여도 사용자는 여전히 용량이 큰 원본 파일을 다운로드해야 하기 때문입니다.
따라서 초기 업로드 시점에 서버 측에서 작은 미리보기 이미지를 생성하는 것이 훨씬 효율적입니다. 이를 위해서는 두 개의 플러그인과 두 개의 추가 젬이 필요합니다. 먼저 젬부터 추가합니다:
gem "image_processing"
gem "mini_magick", ">= 4.3.5"
Image_processing은 Shrine 저자가 만든 특수 젬으로, 이미지 조작을 위한 고수준 헬퍼 메서드를 제공합니다. 이 젬은 ImageMagick의 Ruby 래퍼인 mini_magick에 의존합니다. 즉, 이 데모를 실행하려면 시스템에 ImageMagick이 설치되어 있어야 합니다.
새 젬들을 설치합니다:
bundle install
이제 의존성과 함께 플러그인을 포함시킵니다:
models/image_uploader.rb
require "image_processing/mini_magick"
class ImageUploader < Shrine
include ImageProcessing::MiniMagick
plugin :processing
plugin :versions
# 기타 코드...
end
Processing은 이미지를 조작(축소, 회전, 포맷 변환 등)할 수 있게 해주는 플러그인입니다. Versions는 하나의 이미지를 여러 변형(variant)으로 저장할 수 있게 해줍니다. 이 데모에서는 'original'과 'thumb'(300x300으로 리사이즈) 두 가지 버전을 저장하겠습니다.
이미지를 처리하고 두 버전을 저장하는 코드는 다음과 같습니다:
models/image_uploader.rb
class ImageUploader < Shrine
process(:store) do |io, context|
{ original: io, thumb: resize_to_limit!(io.download, 300, 300) }
end
end
resize_to_limit!는 image_processing 젬이 제공하는 메서드로, 이미지가 300x300보다 크면 축소하고 작으면 그대로 둡니다. 원본의 가로세로 비율도 유지해 줍니다.
이제 이미지를 표시할 때 image_url 메서드에 :original 또는 :thumb 인자를 전달하기만 하면 됩니다:
views/photos/_photo.html.erb
<div>
<% if photo.image_data? %>
<%= image_tag photo.image_url(:thumb) %>
<% end %>
<p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>
폼 안에서도 동일하게 적용할 수 있습니다:
views/photos/_form.html.erb
<% if @photo.image_data? %>
<%= image_tag @photo.image_url(:thumb) %>
Remove attachment: <%= f.check_box :remove_image %>
<% end %>
업로드 완료 후 처리 과정의 임시 파일을 자동으로 삭제하려면 delete_raw 플러그인을 추가하면 됩니다:
models/image_uploader.rb
plugin :delete_raw
이미지 메타데이터
이미지를 렌더링하는 것 외에도 메타데이터를 가져올 수 있습니다. 예를 들어 원본 사진의 용량과 MIME 타입을 표시해 보겠습니다:
views/photos/_photo.html.erb
<div>
<% if photo.image_data? %>
<%= image_tag photo.image_url(:thumb) %>
<p>
Size <%= photo.image[:original].size %> bytes<br>
MIME type <%= photo.image[:original].mime_type %><br>
</p>
<% end %>
<p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>
그렇다면 이미지 크기(가로세로)는 어떻게 알 수 있을까요? 안타깝게도 기본적으로 저장되지 않지만, store_dimensions 플러그인을 사용하면 가능합니다.
이미지 크기(Dimensions) 저장
store_dimensions 플러그인은 fastimage 젬에 의존하므로 먼저 추가합니다:
gem 'fastimage'
설치를 잊지 마세요:
bundle install
이제 플러그인을 포함시킵니다:
models/image_uploader.rb
plugin :store_dimensions
그리고 width와 height 메서드를 사용해 크기를 표시합니다:
views/photos/_photo.html.erb
<div>
<% if photo.image_data? %>
<%= image_tag photo.image_url(:thumb) %>
<p>
Size <%= photo.image[:original].size %> bytes<br>
MIME type <%= photo.image[:original].mime_type %><br>
Dimensions <%= "#{photo.image[:original].width}x#{photo.image[:original].height}" %>
</p>
<% end %>
<p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>
또한 가로와 세로를 담은 배열(예: [500, 750])을 반환하는 dimensions 메서드도 사용할 수 있습니다.
클라우드 스토리지로 전환하기
개발자들은 업로드 파일 호스팅을 위해 클라우드 서비스를 자주 선택하며, Shrine 역시 이를 지원합니다. 이 섹션에서는 파일을 Amazon S3에 업로드하는 방법을 알아보겠습니다.
먼저 Gemfile에 두 개의 젬을 추가합니다:
gem "aws-sdk", "~> 2.1"
group :development do
gem 'dotenv-rails'
end
aws-sdk는 S3 SDK 연동에 필수이며, dotenv-rails는 개발 환경에서 환경변수를 관리하는 데 사용됩니다.
bundle install
진행하기 전에 S3 API 접근을 위한 키 페어(key pair)를 발급받아야 합니다. Amazon Web Services Console에 로그인(또는 회원가입)한 후 Security Credentials > Users로 이동합니다. S3 파일 조작 권한을 가진 사용자를 생성하세요. 다음은 S3 전체 접근 권한을 부여하는 간단한 정책(policy)입니다:
{
"Version": "2016-11-14",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
]
}생성된 사용자의 키 페어를 다운로드하세요. 루트(root) 액세스 키를 사용할 수도 있지만, 보안상 매우 위험하므로 절대 권장하지 않습니다.
다음으로 파일을 호스팅할 S3 버킷을 생성하고, 프로젝트 루트에 설정 파일을 만듭니다:
.env
S3_KEY=YOUR_KEY
S3_SECRET=YOUR_SECRET
S3_BUCKET=YOUR_BUCKET
S3_REGION=YOUR_REGION
이 파일은 절대 공개해서는 안 되며, 반드시 Git 추적에서 제외해야 합니다:
.gitignore
.env
이제 Shrine의 전역 설정을 수정하여 새로운 스토리지를 도입합니다:
config/initializers/shrine.rb
require "shrine"
require "shrine/storage/s3"
s3_options = {
access_key_id: ENV['S3_KEY'],
secret_access_key: ENV['S3_SECRET'],
region: ENV['S3_REGION'],
bucket: ENV['S3_BUCKET'],
}
Shrine.storages = {
cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
store: Shrine::Storage::S3.new(prefix: "store", **s3_options),
}
이것으로 끝입니다! 애플리케이션의 다른 부분은 변경할 필요가 없으며, 바로 새 스토리지를 테스트할 수 있습니다. S3에서 키 관련 오류가 발생한다면, 키와 시크릿을 정확히 복사했는지, 후행 공백이나 보이지 않는 특수 문자가 없는지 확인하세요.
마치며
이 글의 끝에 도달했습니다. 이제 Shrine 사용에 자신감이 생겼고, 여러분의 프로젝트에 적용하고 싶어지셨기를 바랍니다. 이번 글에서 다룬 기능 외에도 파일과 함께 추가 컨텍스트를 저장하는 기능, 직접 업로드(direct upload) 메커니즘 등 더욱 다양한 기능들이 존재합니다.
따라서 Shrine의 공식 문서와 웹사이트를 살펴보며 사용 가능한 모든 플러그인을 꼼꼼히 확인해 보시기 바랍니다. 이 젬에 대해 추가 질문이 있다면 언제든지 남겨주세요. 끝까지 읽어주셔서 감사합니다. 다음에 또 만나요!