Rails나 Ruby에는 '마법'이 너무 많다는 말을 들어본 적이 있을 것입니다. 혹시 스스로 그렇게 말해본 적도 있을지 모르죠.
- 그런 생각은 대체 어디서 비롯된 걸까요?
- 정확히 Rails의 마법이란 무엇일까요?
- 그리고 그 마법을 걷어내려면 어떻게 해야 할까요?
저의 생각은 이렇습니다:
어떤 것이 마법처럼 느껴진다면, 그것은 당신이 모르는 무언가가 있기 때문입니다. 즉, 빠진 정보가 있는 것이죠.
이것은 마술과 같습니다. 속임수의 원리를 알면 더 이상 마법은 존재하지 않습니다.

소프트웨어 개발에서 속임수를 아는 것은 곧 사물이 실제로 어떻게 작동하는지 이해하는 것과 같습니다.
예제로 살펴보기
이 예제는 비밀번호를 안전하게 저장하기 위해 사용되는 해싱 알고리즘인 BCrypt에 관한 것입니다. Ruby에서는 bcrypt 젬(gem)을 통해 구현되어 있습니다.
BCrypt를 사용해 "testing"이라는 단어를 해싱하는 방법은 다음과 같습니다:
require 'bcrypt'
BCrypt::Password.create("testing")
# "$2a$10$3o.xrISG8fqKfzKqDpgKn.3cwjFV//9C9cZ7MuK5S9sNKFBivB7YG"
BCrypt::Password.create("testing")
# "$2a$10$BFb/e3cWuAIyelcmkfGtE.GTifXHxig4IzVbdZs9C1zSpF/Jr6c3W"
BCrypt::Password.create("testing")
# "$2a$10$Q952BVgM783Y4kPvwvxwC.CS2DWUX9jQuDoE6q2R041kQY0ZWPnSe"
매번 다른 해시 값이 생성된다는 점에 주목하세요. 반면 MD5(절대 사용하지 마세요!)나 SHA1 같은 알고리즘은 주어진 문자열에 대해 항상 동일한 출력을 반환합니다.
왜 하필 BCrypt만 다르게 작동하는 걸까요?
곧 그 이유를 설명드리겠지만, 그 전에 먼저 두 개의 bcrypt 해시를 비교하는 방법부터 살펴보겠습니다. 하나는 데이터베이스에 저장된 해시이고, 다른 하나는 사용자 입력(로그인 폼 등)에서 온 것입니다.
BCrypt::Password.new(@user.hash) == params[:password] # true
왼쪽(BCrypt::Password.new)은 BCrypt 객체로, 데이터베이스에 저장된 해시를 인자로 받습니다.
오른쪽(params[:password])은 사용자가 로그인하려고 입력한 평문(plain-text) 비밀번호입니다.
여기서는 올바른 사용자/비밀번호 조합이 사용되었다고 가정합니다.
그렇다면 왜 이 표현식은 true로 평가될까요?
이를 이해하려면 두 가지를 알아야 합니다:
- BCrypt는 '솔트(salt)'라는 것을 사용합니다. 이는 사전 계산된 해시(pre-computed hashes) 공격으로부터 보안을 강화하기 위한 임의의 값으로, 솔트 자체가 해시 문자열 안에 저장됩니다.
- Ruby에서는 문법처럼 보이는 많은 것들이 사실은 메서드일 뿐입니다. 이중 등호 연산자(
==)도 그런 경우입니다.
이를 바탕으로 말씀드리자면, BCrypt는 자체적인 == 메서드를 정의하고 있습니다. 이 메서드는 '솔트' 값을 추출하는 방법을 알고 있어, 비밀번호를 비교할 때 이를 함께 고려할 수 있습니다.
다시 말해:
BCrypt#==는 저장된 해시에서 '솔트' 값을 가져옵니다.
그런 다음 이 솔트를 사용해 평문 비밀번호(사용자 입력)를 해싱하여, 비밀번호가 유효하다면 두 해시가 동일해지도록 만듭니다.
실제 소스 코드를 살펴보면 대략 다음과 같습니다:
def ==(secret)
super(
BCrypt::Engine.hash_secret(secret, @salt)
)
end
super는 부모 클래스에서 같은 이름의 메서드(여기서는 ==)를 호출한다는 점을 기억하세요.
BCrypt::Password의 부모 클래스는 바로 String입니다.
결국 모든 것은 메서드입니다
꼭 이해해야 할 중요한 점은, 몇 가지 키워드와 문법 요소(괄호 등)를 제외하면 Ruby의 모든 것이 클래스와 메서드로 이루어져 있다는 사실입니다.
작업 중인 클래스가 무엇인지만 알면(class 메서드로 확인 가능), 어떤 연산(메서드)을 사용할 수 있는지 항상 파악할 수 있습니다.
하지만 때로는 객체 지정 없이 메서드만 호출하는 경우도 있습니다.
이는 흔히 해당 메서드가 현재 클래스에 정의되어 있다는 의미지만, 항상 그런 것은 아닙니다.
예시:
puts 123
puts는 어디에 정의되어 있을까요? 직접 확인해 봅시다:
method(:puts).owner # Kernel
혼란스러울 수 있는 또 다른 요소는 메타프로그래밍입니다. 메타프로그래밍을 활용하면 프로그램 실행 도중에 메서드를 생성, 수정, 심지어 삭제까지 할 수 있기 때문입니다. 이런 특성들은 코드를 더 불투명하고 난해하게 만듭니다.
어떤 메타프로그래밍 메서드들이 존재하는지, 그리고 그것이 어떻게 작동하는지 미리 파악해 두면 이런 혼란을 크게 줄일 수 있습니다.
마치며
이번 글에서는 사물이 작동하는 원리에 대한 깊은 이해가 '마법'을 걷어내고, 더 나은 코드를 작성하며, 한 단계 더 성장한 개발자가 되는 데 어떻게 도움이 되는지 알아보았습니다.
Ruby 실력을 한층 끌어올리고 싶다면, 기초와 고급 개념 사이의 간격을 메우도록 설계된 제 책 'Ruby Deep Dive'를 확인해 보세요 🙂