Post

레일즈와 스프링의 차이점 비교

동기

이번에 당근마켓 인턴으로 입사하게 되었는데, 소속된 팀에서 루비온레일즈를 사용한다.

아직 근무를 시작한 건 아니라서 한번 이리저리 살펴보고 있는데, 기존에 공부하던 자바 스프링과 여러 차이점이 존재해서 글로 기록해본다.

해당 차이점들은 개발 공부하던 내가 그냥 개인적으로 느낀 것일뿐, 사람마다 다를 수 있다.

레이어, 계층 구조

스크린샷 2023-12-28 122358

보통 자바 스프링 사용 시 쓰는 방법이다.

사람마다 사용 기준이 조금씩 다를 수는 있지만, 나는 주로 트랜잭션 처리를 서비스 레이어에서 많이 구현했다. 코드를 보면 조금 더 명확할 것 같은데, 다음과 같은 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Service
@RequiredArgsConstructor
@Transactional
public class DailyScoreService {

    private final DailyScoreRepository dailyScoreRepository;

    public void addScore(DailyScore dailyScore) {
        dailyScoreRepository.save(dailyScore);
    }

    public List<DailyScore> getScore(User user) {
        return dailyScoreRepository.findAllByUser(user);
    }


    public List<DailyScore> getTopThree() {
        return dailyScoreRepository.findAll(Sort.by(Sort.Direction.DESC, "createdDate"))
                .stream()
                .filter(distinctByKey(i -> i.getUser().getUserId()))
                .sorted(Comparator.comparing(DailyScore::getTodayEarnPrice).reversed())
                .limit(3)
                .collect(Collectors.toList());

    }

    private <T> Predicate<T> distinctByKey(Function<? super T, Object> keyExtractor) {
        Map<Object, Boolean> seen = new ConcurrentHashMap<>();
        return t -> seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null;
    }

}

일종의 쿼리 구현체인 레포지토리를 사용해서 서비스에서 구현해주는 방식을 많이 사용했다.

계층이 명확해지고 여러 레포지토리의 트랜잭션을 묶어서 처리할 수 있는 장점이 있지만, 가끔 서비스의 역할이 너무 단조롭다는 생각도 해 본 적이 있다. 예시를 들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class HelloController {

    private final HelloService helloService;

    @GetMapping
    public Object helloToUser(long id){
        return helloService.findById(id);
    }

}


public class HelloService {

    private final HelloRepository helloRepository;

    public Object findById(long id){
        return helloRepository.findById(id);
    }

}

극단적이긴 하지만 가끔 이런식으로 너무 단조롭게 코드가 나오면, 서비스를 작성하는게 오히려 번거롭다는 생각도 들긴 했다.

그래서 컨트롤러에서 레포지토리를 직접 참조할까 싶기도 했는데 이러면 프로젝트의 코드 일관성이 깨진다는 생각이 들어서 되도록이면 서비스 클래스를 작성했다.

그런데 레일즈는 이게 장점인지 단점인지 아직 모르겠는데, 스프링에서 흔히 보는 서비스 레이어가 잘 보이지 않는 것 같다. 레일즈로 구현된 discourse 라는 서비스의 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AboutController < ApplicationController
  requires_login only: [:live_post_counts]

  skip_before_action :check_xhr, only: [:index]

  def index
    return redirect_to path("/login") if SiteSetting.login_required? && current_user.nil?

    @about = About.new(current_user)
    @title = "#{I18n.t("js.about.simple_title")} - #{SiteSetting.title}"
    respond_to do |format|
      format.html { render :index }
      format.json { render_json_dump(AboutSerializer.new(@about, scope: guardian)) }
    end
  end

  # ...
end

처음 봤을 때는 충격먹었는데, 컨트롤러에서 직접 About이라는 모델을 참조하고 있다.

조금 있다가 밑에 기술할건데, 레일즈와 스프링은 서로 다른 DB 접근 패턴을 가지고 있다. (Active Record vs Data Mapper(repository)) 그래서 모델을 참조하고 있다는 말이 repository를 직접 쓰고 있다고 이해해도 일단 무방할 것 같다.

그리고 또 충격먹은게 스프링을 사용할 때는 약간 의무적으로 response dto를 작성하는 경향도 있었는데, 레일즈는 entity 그대로 사용하는 경우도 많은 것 같아서 문화충격이었다.

데이터베이스 접근

레일즈와 스프링은 데이터베이스를 다루는데도 서로 다른 방식을 사용하고 있다.

레일즈에서는 Active Record라는 패턴을 사용하는데, 모델에 쿼리가 작성되는 방식이다. 스프링 JPA와 비교해본다면 entity에 repository가 합쳐진 느낌으로 봐도 될 것 같다.

예시 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Post < ActiveRecord::Base

  # ...
  belongs_to :user
  belongs_to :topic

  belongs_to :reply_to_user, class_name: "User"

  has_many :post_replies
  has_many :replies, through: :post_replies
  has_many :post_actions, dependent: :destroy
  has_many :topic_links
  has_many :group_mentions, dependent: :destroy

  # ...

  def reply_notification_target
    return if reply_to_post_number.blank?
    Post.find_by(
      "topic_id = :topic_id AND post_number = :post_number AND user_id <> :user_id",
      topic_id: topic_id,
      post_number: reply_to_post_number,
      user_id: user_id,
    ).try(:user)
  end


  # ...
end

위의 클래스가 레일즈의 모델, 스프링에서는 엔티티라고 일단 비슷하다고 봐도 될 듯 하다.

스프링 JPA를 많이 사용하던 유저라면 컬럼에 대한 정의는 어디에 있을지 궁금할 수 있는데, 레일즈는 연관관계에 대한 정보만 모델에 정의할 뿐 컬럼에 대한 속성은 다른 클래스에서 별도로 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CreatePosts < ActiveRecord::Migration[4.2]
  def change
    create_table :posts do |t|
      t.integer :user_id, null: false
      t.integer :forum_thread_id, null: false
      t.integer :post_number, null: false
      t.text :content, null: false
      t.text :formatted_content, null: false
      t.timestamps null: false
    end

    add_index :posts, %i[forum_thread_id created_at]
  end
end

이런 식으로 테이블 정보에 대한 클래스가 별도로 존재한다.

모델을 생성도 레일즈에 위임할 수 있는데, ‘rails generate model 블라블라 속성들’ 이런 식으로 커맨드를 날리면 관련된 파일들(테스트 클래스, 더미 데이터 등등)을 자동으로 생성해준다.

또 다른 점이 있다면 트랜잭션 명시 방식인데, 스프링에서는 @Transactional 어노테이션을 통해서 선언했다면 레일즈는 함수처럼 뭔가 독특하게 기입한다. 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
    Post.transaction do
      self.skip_validation = true

      update!(hidden: true, hidden_at: Time.zone.now, hidden_reason_id: reason)

      Topic.where(
        "id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)",
        topic_id: topic_id,
      ).update_all(visible: false)

      UserStatCountUpdater.decrement!(self)
    end

아직 다른 코드들을 충분히 보지 못해서 느끼는 것일 수 있지만 이러면 트랜잭션 전파가 어떻게 되는지 모르겠다. 모델마다 모두 트랜잭션 메서드를 사용해야 한다면 코드가 어떻게 나올지 잘 모르겠다.

컨테이너, DI

여긴 내가 아직 충분히 다른 코드들을 보지 못해서 말하기 조심스럽긴 한데, 레일즈에서 아직 스프링 컨테이너 같은 의존성 관리 기능을 확인하지 못한 것 같다.

이 생각을 하게 된게 비즈니스 로직, 서비스 객체를 new로 생성하는 코드를 여럿 봐서 아마도 없는게 아닐까 조심스레 추측중이다.

그러면 DB 커넥션 객체를 매번 new로 생성해야 하는지 궁금할 수 있는데, 레일즈의 모델들은 코드에서 바로 접근이 가능했던 걸 보면 정적 객체로 자동 등록되는 것 같고, 내부 코드를 이리저리 둘러보니 싱글톤으로 커넥션, 스레드풀 생성까지 잘 관리되고 있는 것 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
module ActiveRecord
  extend ActiveSupport::Autoload
  
  # ...

  # Sets the async_query_executor for an application. By default the thread pool executor
  # set to +nil+ which will not run queries in the background. Applications must configure
  # a thread pool executor to use this feature. Options are:
  #
  #   * nil - Does not initialize a thread pool executor. Any async calls will be
  #   run in the foreground.
  #   * :global_thread_pool - Initializes a single +Concurrent::ThreadPoolExecutor+
  #   that uses the +async_query_concurrency+ for the +max_threads+ value.
  #   * :multi_thread_pool - Initializes a +Concurrent::ThreadPoolExecutor+ for each
  #   database connection. The initializer values are defined in the configuration hash.
  singleton_class.attr_accessor :async_query_executor
  self.async_query_executor = nil

  def self.global_thread_pool_async_query_executor # :nodoc:
    concurrency = global_executor_concurrency || 4
    @global_thread_pool_async_query_executor ||= Concurrent::ThreadPoolExecutor.new(
      min_threads: 0,
      max_threads: concurrency,
      max_queue: concurrency * 4,
      fallback_policy: :caller_runs
    )
  end
  
  # ...

end

스프링에서 빈으로 등록하던 객체들은 레일즈에서는 static으로(self.등등) 관리하거나 새로 생성해서 사용해야 할 것 같다.

라우팅 방식

1
2
3
4
5
6
7
8
9
10
@Controller
public class HelloController {

    @GetMapping("/index")
    public void helloToUser(){
    
    }

}

스프링은 http 메서드와 경로를 컨트롤러에 작성해주는 방식이다.

클래스에서 관련 정보를 확인할 수 있고, 코드와 문서가 따로 작동하는 경우를 방지할 수 있지만 각각의 url을 확인하려면 모든 컨트롤러들을 확인해봐야할 수 있다.

1
2
3
4
5
6
7
8
Rails.application.routes.draw do
  root "hello#index"

  get "/hello/:id", to: "hello#show_hello"

  match "/404", to: "errors#not_found", via: :all
  match "/500", to: "errors#internal_server_error", via: :all
end

레일즈는 조금 다른 방식을 취하고 있는데, routes.rb 라는 파일에 사용할 경로와 http 메서드를 기입해주는 방식이다.

하나의 파일에서 모든 정보를 확인할 수 있어 관리가 편한 점이 있지만, 컨트롤러에서 관련 정보를 확인하기 위해 routes 파일을 왔다갔다 해야 할 수 있다.

참고

  • asyraffff, Open-Source-Ruby-and-Rails-Apps, https://github.com/asyraffff/Open-Source-Ruby-and-Rails-Apps
  • mastodon, mastodon, https://github.com/mastodon/mastodon
  • gitlabhq, gitlabhq, https://github.com/gitlabhq/gitlabhq
  • discourse, discourses, https://github.com/discourse/discourse
  • 내 경험, 루비온레일즈 내부 코드
This post is licensed under CC BY 4.0 by the author.

© . Some rights reserved.

Using the Jekyll theme Chirpy