중첩 댓글 기능 구현하기

이 글에서는 댓글기능을 스레드 방식으로 중첩해서 보이도록 기능개선을 해 볼 것이다.

레일스를 이용하여 프로젝트를 작성할 때 특정 글에 대한 댓글기능을 구현하는 것은 매우 단순하고 쉬운 편이다. 물론 구현 방식에 따라 그 난이도는 다를 수 있지만, 이에 대해서는 인터넷 상에 이미 많은 자료가 소개되어 있다.

이 글에서는 댓글기능을 스레드방식으로 중첩해서 보이도록 기능 개선을 해 볼 것이다.

구현로직은 사실은 매우 간단하다. 댓글 모델에 상위 객체로 연결할 수 있도록 외래키를 추가하고 댓글 모델에 self join 관계 선언을 해 주면 된다.

이 글에서 작성한 샘플 프로젝트는 아래와 같은 작업환경에서 개발했다.

개발환경

– 루비 2.3.1
– 레일스 5.0.0.1

터미널의 커맨드라인에서 아래과 같이 프로젝를 생성한다.

$ rails new nested_comments 

이제 Gemfile을 열고 파일 상단에 아래의 젬 파일 목록 추가한다.

Gemfile 구성과 젬 설치

gem 'bootstrap'
source 'https://rails-assets.org' do
  gem 'rails-assets-tether', '>= 1.1.0'
end
gem 'simple_form'
gem 'devise'
gem "bootstrap_flash_messages", "~> 1.0.1"
gem 'jquery-ui-rails'

Gemfile에 위에 열거된 젬 목록을 추가하고 커맨드라인에서 bundle install 한다. 각각의 젬에 대해서 간단하게 설명한다.

1) Bootstrap 젬 설치

assets/javascripts/application.js 파일을 열고 //= require 'tether'//= require 'bootstrap-sprockets' 코드라인을 //= require 'jquery' 라인 아래에 추가한다. rails-assets-tether 젬은 Bootstrap 젬의 부속 젬이며 //= require tether 라인을 추가한다.

//= require jquery
//= require tether
//= require bootstrap-sprockets
//= require jquery_ujs
//= require turbolinks
//= require_tree .

assets/stylesheets/application.css 파일의 확장자를 .scss로 변경하고 파일 내용을 모두 삭제한 후 아래와 같이 작성한다.

$enable-flex: true;
@import 'bootstrap';

$enable-flex: true;flexbox를 사용할 수 있도록 설정한다. 이 것은 선택사항이므로 독자의 재량에 따라서 사용하지 않아도 된다.

2) Simple_form 젬 설치

form_for 헬퍼 메소드보다 더 간단하게 폼 구문을 작성할 수 있는 simple_form 젬은 다음과 같이 옵션을 추가하여 설치시 bootstrap의 클래스를 기본적으로 사용하도록 할 수 있다. 터미널을 열고 아래와 같이 명령을 실행한다.

$ rails g simple_form:install --bootstrap

3) Devise 젬 설치

이 젬은 사용자 등록 및 인증을 도와주는 것으로 3단계의 과정을 거쳐 설치하게 된다.

첫째, 설치

$ rails g devise:install

설치작업을 끝나면 몇가지 조치사항에 대한 안내문이 보여지는데, 각자의 취향에 맞게 선별하여 추가적인 셋업과정을 진행한다.

둘째, 뷰 템플릿을 커스터마징하기 위한 뷰 파일 생성

$ rails g devise:views

이로써 views/devise/ 폴더에 devise의 기능별 뷰 파일들이 생성된 것을 확인할 수 있다.

셋째, 전용 User 모델의 생성

$ rails g devise User

apps/models/user.rb 파일 내용을 보면 다양한 Devise 젬의 기능들이 열거되어 있는데 각자의 필요에 따라 해당 모듈을 첨삭할 수 있다. 여기서는 편의상 디폴트 상태로 진행할 것이다.

4) Bootstrap_flash_messages 젬 설치

레일스의 flash 메시지 표시를 도와주는 젬으로 대개는 애플리케이션 레아이웃의 특정 부위에 ERB 코드를 삽입하면 된다.

애플리케이션 레이아웃 파일은 다음과 같이 작성한다. views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title>RORLA-Blog</title>
    <meta name="viewport" content="width=device-width, user-scalable=no">
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
    <%= favicon_link_tag 'rorlab_logo.png' %>
  </head>

  <body>
    <div class="container">
      <header class='row'>
        <%#= render "shared/header" %>
      </header>
      <div id="trunk" class='row'>
        <main class='col-md-9'>
          <div id="flash_messages"><%= flash_messages(:close) %></div>
          <%= yield %>
        </main>
        <aside class='col-md-3'>
          <%#= render "shared/sidebar" %>
        </aside>
      </div>
      <footer class='row'>
        <%#= render 'shared/footer' %>
      </footer>
    </div>
  </body>
</html>

현재로써는, 위에서 사용한 shared/header, shared/sidebar, shared/footer 파셜을 아직 작성하지 않았기 때문에 “#” 문자를 두어 렌더링되지 않게 하였다.

각 파셜은 다음과 같이 작성한다.

views/shared/_header.html.erb

<h1>
  RORLA-Blog
</h1>

views/shared/_sidebar.html.erb

<% unless current_page?(root_path) %>
  <%= link_to 'Home', root_path, class: 'btn btn-outline-primary btn-block my-1' %>
<% end %>

<% if user_signed_in? %>
  <%= link_to 'New Post', new_post_path, class: 'btn btn-outline-primary btn-block my-1' %>
  <%= link_to "Profile", edit_user_registration_path, class: 'btn btn-outline-primary btn-block my-1' %>
  <%= link_to 'Log out', destroy_user_session_path, method: :delete, data: {confirm:"Are you sure?"}, class: 'btn btn-outline-primary btn-block my-1' %>
<% else %>
  <%= link_to "Log in", new_user_session_path, class: 'btn btn-outline-primary btn-block my-1' unless current_page?(new_user_session_path) %>
  <%= link_to "Sign up", new_user_registration_path, class: 'btn btn-outline-primary btn-block' unless current_page?(new_user_registration_path) %>
<% end %>

views/shared/_footer.html.erb

<p>This is a footer partial</p>

이제 3개의 파셜을 작성했기 때문에 각각의 리소스가 작성된 후에 레이아웃 파일에서 코멘트 문자는 삭제하면 된다.

5) jQuery-ui-rails 젬 설치

jQuery용 UI를 레일스에서 사용하기 위한 젬으로 application.jsapplication.scss 파일에 각각 아래와 같이 추가한다.

assets/javascripts/application.js

//= require jquery
//= require tether
//= require bootstrap-sprockets
//= require jquery-ui
//= require jquery_ujs
//= require turbolinks
//= require_tree .

4번 코드라인에 //= require jquery-ui을 추가한다.

assets/stylesheets/application.scss

$enable-flex: true;
@import 'bootstrap';
@import 'jquery-ui';

3번 코드라인에 @import 'jquery-ui'을 추가한다.

커스텀 CSS 파일의 작성

이 프로젝트에서 사용할 CSS 파일은 아래와 같다.

assets/stylesheets/styles.scss 파일을 생성하고 레이아웃과 일부 커스터마이징이 필요한 부분에 대한 CSS를 작성한다.

*:last-child {
  margin-bottom: 0;
}

[class*='outline'] {
  background-color: white;
}

.form-actions {
  border-top: 1px solid #ccc;
  background-color: #eaeaea;
  padding: 1em 1em 2em;
  margin: 1em 0;
}

header {
  padding: 2em .5em;
  text-align: center;
  border-bottom: 1px solid #b3e3f2;
  h1 { font-weight: 800; flex-grow: 1; }
  background-color: #bfecfa;
  // margin-bottom: 1em;
}

#trunk {
  min-height: 35em;
  main {
    // flex-grow: 8;
    // border-left: 1px solid #ccc;
    background-color: #fafafa;
    padding: 1em;
    h1, h2 {
      font-weight: 800;
      margin-bottom: .8em;
      color: #aaa;
    }
  }
  aside {
    // flex-grow: 4;
    background-color: #bfecfa;
    padding: 1em;
  }
}

footer {
  padding: 1em;
  text-align: center;
  border-top: 1px solid #b3e3f2;
  color: #bbb;
  background-color: #bfecfa;
  p { text-align: center; flex-grow: 1; }
}

.checkbox input {
  margin-right: .5em;
}

.form-group.has-error {
  border: 1px solid darkred;
  padding: 1em;
  background-color: white;
  color: darkred;
  input, textarea, .select {
    color: darkred;
    border-color: darkred;
  }
}

assets/stylesheets/posts.scss

#posts {
  .no_content {
    min-height: 35em;
    display: flex;
    justify-content: center;
    align-items: center;
    @extend .text-danger;
    p {
      flex-grow: 1;
      align-self: center;
    }
  }
}
.post {
  margin: 1em 0 2em 0;
  .post_title {
    font-size: 1.5em;
    font-weight: 800;
    color: #bbb;
    line-height: 1.1em;
    margin-bottom: .2em;
    padding-bottom: .3em;
    border-bottom: 1px dashed #ccc;
  }
  .post_info {
    @extend .d-block;
    text-align:right;
    margin-bottom: .5em;
  }
  .post_content {}
}

#flash_messages .alert {
  margin-bottom: 1em;
}

assets/stylesheets/comments.scss

.comments-widget {
  border: 1px solid #ccc;
  border-radius: 5px;
  margin-bottom: 1em;
  padding: 1em;
  box-shadow: 0 10px 6px -6px #777;
}

h2.comments-header {
  color: rgb(247, 147, 124) !important;
  margin-bottom: 0.3em !important;
}

ul.comments-list {
  padding-left: 1.3em;
  & > li {}
  ul { padding-left: 1.3em; }
}

li.comment {
  margin-bottom: .5em !important;
  &:last-child {
    margin-bottom: 0;
  }
  .comment-content {

  }
  .comment-info {
    color: #bbb;
    margin-bottom: .5em;
    a {
      color: #bbb;
      &:hover {
        @extend .text-primary;
      }
    }
  }
  margin-bottom: 1.3em;
  ul.replies_for_comment {
    margin-top: 1em;
  }
}

.comment-form {
  margin-top: 1em;
  margin-bottom: .5em;
}

위의 SCSS 파일은 application.scss 파일에 import해야 한다.

$enable-flex: true;
@import 'bootstrap';
@import 'jquery-ui';
@import 'styles';
@import 'posts';
@import 'comments';

Post 리소스 생성하기

$ rails g Post user:references title content:text

user:references 옵션은 마이그레이션 파일에 user_id:integer 속성을 추가하고 Post 모델 클래스 파일에는 belongs_to :user 코드라인을 자동으로 추가해 준다.

Comment 리소스 생성하기

$ rails g model Comment commentable:references{polymorphic} user:references content:text

commentable:references{polymorphic}은 마이그레이션 파일에 commentable_id:integer 속성을 외래키로 추가할 때 commentable_type:string 속성도 추가되도록 해 준다. 물론 Comment 모델에 belongs_to :commentable, as: :polymorphic 코드 라인이 자동으로 추가된다.

Post 모델 관계선언

그러나 Post 모델 클래스에는 has_many :comment ... 코드라인을 직접 추가해 주어야 한다.

class Post < ApplicationRecord
  belongs_to :user
  has_many :comments, as: :commentable, dependent: :destroy
end

as: :commentable 옵션은 post 객체를 commentable 이라는 이름의 객체로써 사용할 수 있도록 해 준다. 즉, comment 객체에 대해서 comment.commentable 같이 호출하여 특정 comment 객체의 post 객체를 불러올 수 있게 된다. dependent: :destroy 옵션은 특정 post 객체를 삭제할 때 연결된 comment들을 모두 동시에 삭제할 수 있도록 해 준다.

Comment 모델 관계선언

class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :commentable, polymorphic: true
end

이로써 comment 객체는 다형성(polymorphic)의 commentable 객체에 귀속하게 된다.

댓글 중첩을 위한 조치

중첩댓글을 구현하기 위한 가장 핵심적인 단계는 기존 Comment 모델에 parent_id라는 외래키를 추가하는 것이다.

터미널 커맨드라인에서 아래와 같이 명령을 실행하고, db:migrate 까지 실행한다.

$ rails g migration add_parent_to_comments parent:references

이제 Comment 모델 클래스 파일을 열고 아래와 같이 관계 선언을 해 준다. 즉, 모든 comment 객체는 parent 객체를 가지게 되며 특정 comment는 복수 개의 reply 객체를 가질 수 있다. 이를 구현하기 위해서 자체참조를 이용하는데, class_name으로 "Comment" 클래스명(문자열)을 가지도록 한다.

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
  belongs_to :parent, class_name: 'Comment', optional: true
  has_many :replies, class_name: 'Comment', foreign_key: :parent_id, dependent: :destroy
end

그러나, 레일스 5부터는 belongs_to 메소드를 사용할 때 주의할 점이 생겼다. 즉, 해당 키가 없이 레코드를 생성할 때 optional: true 옵션을 추가해 주어야 에러가 발생하지 않게 된다것이다.

이제부터는 빈 comment 객체를 생성할 때 두가지로 생각할 수 있다.

하나는 최상위 루트 댓글을 생성할 때이고, 다른 하나는 기존 댓글에 답변 댓글을 작성할 때이다.

# 최상위 루트 comment 객체를 생성할 때
@comment = @commentable.comments.build
# 기존 comment 객체에 대해서 답변 comment 객체를 생성할 때
@reply = @commentable.comments.build(parent: @comment)

그리고 댓글을 입력하는 섹션에는 각각에 대해서 아래와 같이 작성한다.

# 최상위 루트 comment 객체를 생성할 때
<%= render "comments/form", comment: @comment %>
# 기존 comment 객체에 대해서 답변 comment 객체를 생성할 때
<%= render "comments/form", comment: @reply $>

또한, 댓글 입력을 위한 폼 파셜은 아래와 같이 작성한다.

<%= simple_form_for [comment.commentable, comment], remote: true do | f | %>
  <%= f.hidden_field :parent_id %>
  <%= f.input :content, label: false, placeholder: (f.object.parent_id.nil? ? 'Share your idea.' : 'Leave your reply.'), input_html: { rows: 5 } %>
  <%= link_to "Cancel", "#", 'data-reply': comment.parent_id.present?, class: 'cancel-comment-link btn btn-primary btn-sm float-xs-right ml-1' if comment.persisted? or comment.parent %>
  <%= f.submit class: 'btn btn-primary btn-sm float-xs-right' %>
<% end %>

이 글에서는 댓글의 추가/수정/삭제/답변 기능을 레일스의 ajax 기능을 이용하여 구현한다.

따라서 댓글 폼에서 form_for(simple_form 젬을 사용할 경우에는 simple_form_for) 메소드에 remote: true 옵션을 추가한다.

댓글 폼에서 글을 입력하기 전에 루트 댓글(즉, parent_id 값이 nil 인 댓글)을 작성할 것인지, 아니면 기존 댓글에 대한 답변댓글(즉, parent_id 값이 답변을 달고자 하는 댓글의 id 값을 가지는 댓글)을 작성할 것이지는 사용자가 결정하게 된다.

댓글 폼에 글을 입력하고 서밋하면 URI 패턴 '/posts/:id/comments', HTTP 메소드 POST의 조합으로 매핑되는 comments#create 액션이 호출된다.

def create
  @comment = @commentable.comments.new(comment_params)
  @comment.user = current_user
  respond_to do |format|
    if @comment.save
      format.html { redirect_to @commentable, notice: "Comment was successfully created."}
      format.json { render json: @comment }
      format.js
    else
      format.html { render :back, notice: "Comment was not created." }
      format.json { render json: @comment.errors }
      format.js
    end
  end
end

simple_form_for 메소드에서 remote: true 옵션을 지정했기 대문에 comments#create 액션이 호출된 후 format.js 포맷이 렌더링된다.

따라서, create.js.erb 파일을 views/comments/ 폴더에 생성한 후 아래와 같이 작성한다.

// create.js.erb (CommentsController#create)
<% if @comment.parent %>
  $comment_parent = $("#comment_<%= @comment.parent.id %> > ul");
  if(!$comment_parent.length){
    $("#comment_<%= @comment.parent.id %>").append("<ul></ul>");
    $comment_parent = $("#comment_<%= @comment.parent.id %> > ul");
  }
  $comment_form = $("#comment_<%= @comment.parent.id %> .comment-form form");
<% else %>
  $comment_parent = $("#comments-widget-of-commentable-<%= @commentable.id %> .comments-list");
  $comment_form = $("#comments-widget-of-commentable-<%= @commentable.id %> .comment-form form");
<% end %>
$comments_count = $("#comments-count-of-commentable-<%= @commentable.id %>");
<% if @comment.errors.empty?  # if no erros on creating a comment... %>
  $comments_count.html("<%= @commentable.comments.size %>").effect('highlight',{}, 1000);
  $comment_parent.append("<%=j render @comment %>").find('li').last().effect("highlight", {}, 1000);
  $comment_form[0].reset();
  <% if @comment.parent %>
    $comment_form.remove();
    // console.log("start");
    $comment = $("#comment_<%= @comment.parent.id %>")
    // console.log("end");
    $restore_link = $comment.find('a.delete-comment-link')[0]
    $reply_link = $comment.find('a.reply-comment-link')[0]
    $reply_link.href = $restore_link.href + "/reply"
  <% end %>
  $("#comment_new_chars_counter").html("Remaining : 255");
<% else # if some errors occurs on creating a comment... %>
  $comment_form.before("<div class='alert alert-warning alert-dismissible fade in' role='alert'><button type='button' class='close' data-dismiss='alert' aria-label='Close'><span aria-hidden='true'>&times;</span></button><%= @comment.errors.full_messages.join('') %></div>").prev().delay(1500).slideUp();
<% end %>

댓글이 성공적으로 생성되면, ajax 기능이 동작하여 페이지 이동없이 해당글의 하단에 방금 작성된 댓글이나 답급이 즉각적으로 보여진다.

대개 posts#show 액션 뷰 파일의 하단에 댓글이 보여지도록 한다. widget 이라는 파셜(views/comments/_widget.html.erb)을 생성하고 아래와 같이 commentable 이라는 파셜 변수에 @post를 넘겨 준다.

<%= render "comments/widget", commentable: @post %>

widget 파셜은 다음과 같이 작성한다.

<div id="comments-widget-of-commentable-<%= commentable.id %>" class="comments-widget">
  <h2 class="comments-header">
    Comments
    <small id="comments-count-of-commentable-<%= commentable.id %>">
      <%= commentable.comments.size %>
    </small>
  </h2>
  <ul class="comments-list">
    <%= render commentable.comments.where(parent: nil) %>
  </ul>
  <!-- comment-form -->
  <%= render "comments/form", comment: commentable.comments.build  %>
</div>

이제, 위의 코드가 작동하기 위해서는 댓글 파셜(partial)이 필요하다. views/comments/_comment.html.erb

<li id="comment_<%= comment.id %>" class="comment">
  <div class="comment-content">
    <%= comment.content %>
  </div>
  <div class="comment-info">
    <small>-
      <%= comment.user.name %> &bull;
      <%= localize(comment.created_at, format: :long) %> &bull;
      <%= link_to "Edit", edit_polymorphic_path([comment.commentable, comment]), class: 'edit-comment-link', remote: true  %> &bull;
      <%= link_to "Destroy", [comment.commentable, comment], method: :delete, class: 'delete-comment-link', data:{confirm:"Are your sure?"}, remote: true %> &bull;
      <%= link_to "Reply", polymorphic_path([:reply, comment.commentable, comment]), class: 'reply-comment-link', remote: true  %>
    </small>
  </div>
  <% if comment.replies.any? %>
    <ul>
      <%= render comment.replies %>
    </ul>
  <% end %>
</li>

위에서 14~18번 코드라인이 댓글 중첩이 가능토록 해 주는 핵심 코드다. 16번 코드라인의 comment.replies 객체들은 Comment 클래스의 객체들이기 때문에 긍극적으로 views/comments/_comment.html.erb 파셜을 렌더링하게 된다는 것에 대한 이해가 필요하다.

댓글을 ajax 기능을 이용하여 삭제할 때는 다음과 같이 views/comments/destroy.js.erb 파일을 작성한다.

$comments_count = $("#comments-count-of-commentable-<%= @commentable.id %>");
<% if @comment.errors.empty? %>
  $comments_count.html("<%= @commentable.comments.size %>").effect('highlight',{}, 1000);
  $("#comment_<%= @comment.id %>").effect("highlight", { color: '#f7937c' }, 500).slideUp('fast');
<% else %>
  $("#comments-widget-of-commentable-<%= @commentable.id %> .comments-header").after("<div class='alert alert-warning alert-dismissible fade in' role='alert'><button type='button' class='close' data-dismiss='alert' aria-label='Close'><span aria-hidden='true'>&times;</span></button><%= @comment.errors.full_messages.join('') %></div>").next().delay(1500).slideUp('fast');
<% end %>

댓글을 ajax 기능을 이용하여 수정할 때는 다음과 같이 views/comments/edit.js.erb 파일을 작성한다.

// edit.js.erb
<% if @comment.errors.empty? %>
  $("#comment_<%= @comment.id %> > .comment-info .edit-comment-link").removeAttr("href");
  $("#comment_<%= @comment.id %> > .comment-info").after("<%=j render('comments/form', comment: @comment) %>");
<% else %>
  $("#comments-widget-of-commentable-<%= @commentable.id %> .comments-header").after("<div class='alert alert-warning alert-dismissible fade in' role='alert'><button type='button' class='close' data-dismiss='alert' aria-label='Close'><span aria-hidden='true'>&times;</span></button><%= @comment.errors.full_messages.join('') %></div>").next().delay(1500).slideUp('fast');
<% end %>

// On editing a comment, call to counter of comment.
$("#comment_<%= @comment.id %> form textarea").trigger('keyup');

댓글을 수정한 후 업데이트할 때는 다음과 같이 views/comments/update.js.erb 파일을 작성한다.

// update.js.erb (CommentsController#create)
$comment_form = $("#comment_<%= @comment.id %> .comment-form form");
$comment = $("#comment_<%= @comment.id %>");
<% if @comment.errors.empty?  # if no erros on creating a comment... %>
  $comment.html("<%=j render @comment %>").find('li').last().effect("highlight", {}, 1000);
  $comment_form[0].remove();
<% else # if some errors occurs on creating a comment... %>
  $comment_form.before("<div class='alert alert-warning alert-dismissible fade in' role='alert'><button type='button' class='close' data-dismiss='alert' aria-label='Close'><span aria-hidden='true'>&times;</span></button><%= @comment.errors.full_messages.join('') %></div>").prev().delay(1500).slideUp();
<% end %>

댓글에 대한 답변댓글을 ajax 기능으로 작성할 때는 다음과 같이 reply.js.erb 파일을 작성한다.

// reply.js.erb
<% if @comment.errors.empty? %>
  $("#comment_<%= @comment.id %> > .comment-info .reply-comment-link").removeAttr("href");
  $("#comment_<%= @comment.id %> > .comment-info").after("<%=j render('comments/form', comment: @reply) %>");
<% else %>
  $("#comments-widget-of-commentable-<%= @commentable.id %> .comments-header").after("<div class='alert alert-warning alert-dismissible fade in' role='alert'><button type='button' class='close' data-dismiss='alert' aria-label='Close'><span aria-hidden='true'>&times;</span></button><%= @comment.errors.full_messages.join('') %></div>").next().delay(1500).slideUp('fast');
<% end %>

최종 comments 컨트롤러 클래스의 내용은 다음과 같다. apps/controllers/comments_controller.rb

class CommentsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_commentable
  before_action :set_comment, only: [ :reply, :edit, :update, :destroy ]

  def reply
    @reply = @commentable.comments.build(parent: @comment)
  end

  def create
    @comment = @commentable.comments.new(comment_params)
    @comment.user = current_user
    respond_to do |format|
      if @comment.save
        format.html { redirect_to @commentable, notice: "Comment was successfully created."}
        format.json { render json: @comment }
        format.js
      else
        format.html { render :back, notice: "Comment was not created." }
        format.json { render json: @comment.errors }
        format.js
      end
    end
  end

  def edit
  end

  def update
    respond_to do |format|
      if @comment.update(comment_params)
        format.html { redirect_to @commentable, notice: "Comment was successfully updated."}
        format.json { render json: @comment }
        format.js
      else
        format.html { render :back, notice: "Comment was not updated." }
        format.json { render json: @comment.errors }
        format.js
      end
    end
  end

  def destroy
    @comment.destroy if @comment.errors.empty?
    respond_to do |format|
      format.html { redirect_to @commentable, notice: "Comments was successfully destroyed."}
      format.json { head :no_content }
      format.js
    end
  end

  private

  def set_commentable
    resource, id = request.path.split('/')[1,2]
    @commentable = resource.singularize.classify.constantize.find(id)
  end

  def set_comment
    begin
      @comment = @commentable.comments.find(params[:id])
    rescue => e
      logger.error "#{e.class.name} : #{e.message}"
      @comment = @commentable.comments.build
      @comment.errors.add(:base, :recordnotfound, message: "That record doesn't exist. Maybe, it is already destroyed.")
    end
  end

  def comment_params
    params.require(:comment).permit(:content, :parent_id)
  end
end

comments 컨트로러의 모든 액션이 호출되기 전에 최우선으로 set_commentable 메소드가 호출되도록 before_action 매크로로 지정해 놓았다. 이로 인해 각 액션에서는 @commentable 인스턴스 변수를 통해서 post 객체에 접근할 수 있게 된다. 요청된 URL 경로에서 polymorphic 관계선언으로 연결되는 객체를 재생하는 방법을 set_commentable 메소드 코드에서 유심해 살펴보기 바란다.

댓글 취소 클릭 이벤트 핸들러와 댓글 남은 글자수를 표시하기 위한 keyup 이벤트 핸들러를 다음과 같이 coffeescript로 작성한다. assets/coffeescripts/comments.coffee

$(document).on "click", '.cancel-comment-link', (e) ->
  e.preventDefault()
  replied = $(this).data('reply')
  # console.log replied
  $comment = $(this).closest('.comment')
  # console.log $comment
  $form = $(this).closest('form')
  $restore_link = $comment.find('a.delete-comment-link')[0]
  if replied is true
    $reply_link = $comment.find('a.reply-comment-link')[0]
    # console.log $reply_link
    $reply_link.href = "#{$restore_link.href}/reply"
  $edit_link = $comment.find('a.edit-comment-link')[0]
  # console.log $edit_link
  $edit_link.href = "#{$restore_link.href}/edit"
  $form.remove()

$(document).on 'keyup', '.comment_content textarea', (e) ->
  comment_id = $(this).data('comment-id')
  counter = $("#comment_#{comment_id}_chars_counter")
  charsRemaining = 255 - ($(this).val().length)
  counter.text "Remaining : #{charsRemaining}"
  counter.css 'color', if charsRemaining < 0 then 'red' else '#818a91'
  return

# Handle 401 error on ajax call.
$(document).ajaxError (_, xhr)->
  window.location = '/users/sign_in' if xhr.status == 401

마지막으로 최종 라우트 파일을 다음과 같이 작성한다. config/routes.rb

Rails.application.routes.draw do
  root "posts#index"
  resources :posts do
    resources :comments, except: [:index, :new, :show] do
      member do
        get :reply
      end
    end
  end
  devise_for :users

  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

요약

지금까지 중첩댓글을 작성하는 방법을 살펴 보았다. 코드에 대한 설명을 가능한한 자세히 곁들이고자 노력했지만 지면상으로 독자들의 이해를 돕기 위해서 노력한다는 것이 매우 어려운 일임을 알게 되었다. 따라서 다소 설명이 부족하다고 판단될 경우 이해가지 않는 부분을 댓글로 남겨주면 성심성의껏 부연설명할 것이다. 아래에 소스코드의 URL 주소를 남겨 놓았다.

*샘플 프로젝트 소스 : https://github.com/luciuschoi/vagrant-test

글쓴이: 최효성

외과전문의,웹프로그래밍,컴퓨터 일러스트레이션 / Surgeon, Medical Illustration, Web Programmer

2 thoughts on “중첩 댓글 기능 구현하기”

    1. 음, 정답은 이 부분만 별도로 분리해서 레일스 엔진으로 만들어 젬으로 배포하면 간단히 젬만 설치하여 사용할 수 있도록 하는 것입니다. 시간날 때 한번 시도해 보겠습니다.
      https://github.com/elight/acts_as_commentable_with_threading/blob/master/README.md
      검색 중에 발견한 이 젬을 사용하면 중첩(thread)이 가능한 댓글 기능을 추가할 수 있을 것으로 생각되는데, 시간이 없어 직접 테스트해 보진 못했습니다.

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중