@BeforeUpdate의 편의성과 위험성 (in typeORM)

해당 포스트는 작성자의 본 블로그에서 작성 후 velog에 옮겨 적었습니다.
http://dev4us.tistory.com/18

최근 큰 매력을 느껴 GraphQL와 typeORM을 사용하며 즐거움을 느끼고 있던 평화로운 코딩 중에
갑작스럽게 오류가 발생하는 일이 있었습니다.

이에 디버깅을 시도하였고, 번번이 실패하여 최근 괴로워한 경험이 있었습니다.

원인은 언제나 그렇듯, 함수나 라이브러리 기능상의 문제가 아닌 사용자, 즉 유저 불량(작성자라고 읽습니다.)이었습니다.

이번 포스트에서는 제가 직접 겪었던 문제의 표면적인 현상과 디버깅을 위해 헤맸던 과정, 조치 과정을 남겨보려합니다.

앞으로는 'BugFix' 라는 카테고리로 개발 중 겪었던 오류 사례, 해결법을 포스트로 남겨보려 합니다.

1. 문제 인식

문제는 사이드 프로젝트로 진행하던 클론 프로젝트 내 에서 발생했습니다

계속되는 개발 중 토큰을 발급받기 위하여 미리 가입해놓은 계정으로 로그인을 시도하자, 갑작스럽게 패스워드가 일치하지 않는다는 에러가 발생한 것입니다.

사전에 제법 간단한 패스워드로 설정해두었지만 확실한 확인을 위해 몇 차례 반복하였으나 로그인은 진행되지 않았습니다.

이에 바로 콘솔 로그를 통해 '입력한 패스워드가 해쉬화된 값'과 '저장되어 있던 패스워드의 해쉬' 를 비교해보았습니다.

"Wrong Password, Check your password!"

콘솔 로그를 통한 에러를 확인한 뒤 다소 어색한 부분을 느꼈습니다.

회원가입 직후, 로그인을 시도할 시에는 문제 없이 토큰을 발급 받아 다른 기능들을 사용했기 때문입니다.

저는 먼저 이 문제를 "일정 시간이 지나면 회원가입을 해놓은 계정으로 로그인이 불가능한 현상" 이라는 초점을 잡고 디버깅에 나섰습니다.

2. 죄 없는 bcrypt 때려 잡기

위에서 작성했듯, 회원가입 직 후에는 문제 없이 로그인을 진행하였습니다.

bcrypt를 사용한 지 그리 오래 되지 않았기 때문에 암호화, 비교 과정에 대한 원리 보다는 bcrypt 자체의 목적과 유용함에 대해서만 눈길을 주고 있었습니다.

"혹시 salt나 해쉬화된 값에 '유효 기간'이라는 개념이 있지 않을까?" 라는 궁금증으로 bcrypt 부터 분석하게 되었습니다.

'salt' 를 의심하다.

bcrypt를 통하여 암호화를 진행하는 과정 중 'salt'는 필수 요소입니다.

해쉬화 과정 중 '암호화 키'와 같은 역할을 하고 있는데요.

같은 plainText라고 하더라도, 이 salt에 따라서 해쉬화 된 키는 달라집니다.

KakaoTalk_20190116_150207086.png

서비스를 개발하면서 암호를 저장하고, 비교하여 로그인을 해주는 부분은 일상 있는 일이지만

이번 '프로젝트' 에서 이 부분에 의문을 가진 이유는 'auto-gen a salt' 을 사용하였기 때문입니다.

bcrypt.hash(myPlaintextPassword, salt, function(err, hash) {
        // Store hash in your password DB.
 });

최초에는 'auto-gen a salt' 방식을 위 해쉬를 생성해주는 hash() 함수에서

두번째 인자 항목인 salt에 '상수'를 넣어줌으로써 해당 횟수에 따라 무작위로 salt를 만들어

해쉬를 생성하는 방식으로만 알고 있었는데 이는 잘못된 생각이었습니다.

"매번 무작위로 salt를 만들어준다면, 매번 해쉬를 비교할 때 어떻게 salt를 비교하는가?" 라는 의문이 생기고

바로 검색을 통해 생각을 바로 잡았습니다.

이에 대한 해답은 해쉬화된 해쉬 코드의 양식에 있었습니다.

$2a  /  $10  / $vI8aWBnW3fID.ZQ4/zo1G....

위와 같은 해쉬는 아래와 같이 3개의 형식으로 나누어 볼 수 있습니다.


$2a

  • 사용된 bcrypt 알고리즘 식별 정보

$10

  • 키 유도 함수 반복 횟수 (위 해쉬에서는 2^10 번 반복한 값, 12번 이상을 권장함)

$ vI8aWBnW3fID.ZQ4/zo1G....

  • 위 두가지 정보를 통하여 텍스트가 해쉬화된 값

위 내용을 보고 '유효 기간'과 같은 개념은 존재하지 않는 것을 확인하여

의심 했던 부분은 오류와는 별다른 관계가 없음을 확인했습니다.

bcrypt에 대한 개념과 사용법에 대한 자세한 내용은 이후, 별도 포스트로 작성하도록 하겠습니다.

3. 로직을 검토하다

개발 중 계속해서 사용해오던 회원가입과 로그인 기능이였기에, 로직을 먼저 의심하지 않았지만

문제는 결국 로직에 있었습니다.

바로 이 포스트의 제목에 포함되어 있는 '@BeforeUpdate()'가 문제였습니다.

typeORM 내 @BeforeUpdate() 는 해당 테이블의 레코드가 변경되면

엔티티 파일 내에 지정된 함수가 자동적으로 실행 되는 하는 편리한 함수입니다.

저는 해쉬화를 진행해주는 함수에 이 설정을 걸어두었습니다. (관련 소스 하단에 첨부하였습니다)

허나 부작용으로 '회원 정보 변경' 이외의 사용자에 관련된 내용(프로필 사진, 위치 정보, 유저명, 운전 상태) 변경을 시도하여도

@BeforeUpdate()를 설정해둔 '해쉬화' 함수가 반복해서 실행되어 발생한 문제였던 것입니다.

<@BeforeUpdate()를 통하여 해쉬화가 반복되고 있던 과정>

KakaoTalk_20190116_155402438.png

<문제가 있던 해당 User entity 코드>
public comparePassword(password: string): Promise<boolean> {
    return bcrypt.compare(password, this.password);
  }

  @BeforeInsert()
  @BeforeUpdate()
  async savePassword(): Promise<void> {
    if (this.password) {
      const hashedPassword = await this.hashPassword(this.password);
      this.password = hashedPassword;
    }
  }

  private hashPassword(password: string): Promise<string> {
    return bcrypt.hash(password, BRCYPT_ROUNDS);
  }
}
export default User;

위와 같은 문제를 파악한 뒤 고민을 한 끝에,

해쉬화 함수는 'Record Insert (회원가입), Recode.password Update(비밀번호 변경)' 에만

사용된다는 것을 고려하여 엔티티 '외부'에 해쉬화 함수를 배치하여

위 두가지 경우인 회원가입, 비밀번호 변경 시 에만 해당 함수를 사용하게끔 하자는 결론을 냈습니다.

<이에 외부에 배치된 해쉬화 함수 코드>
import bcrypt from "bcrypt";

const encryptToHash = prevPlainText => {
  const BCRYPT_ROUNDS = 10;
  const nextHash = bcrypt.hash(prevPlainText, BCRYPT_ROUNDS);
  return nextHash;
};
<변경된 회원정보 변경 API>
export default encryptToHash;

const resolvers: Resolvers = {
  Mutation: {
    UpdateUserProfile: privateResolver(
      async (
        _,
        args: UpdateUserProfileMutationArgs,
        { req }
      ): Promise<UpdateUserProfileResponse> => {
        const user: User = req.user;
        const notNull: any = cleanNullArgs(args);

        if (notNull.password !== null) {
          user.password = await encryptToHash(notNull.password);
          user.save();
          delete notNull.password;
        }
...
<변경된 회원가입 API>
const resolvers: Resolvers = {
  Mutation: {
    EmailSignUp: async (
      _,
      args: EmailSignUpMutationArgs
    ): Promise<EmailSignUpResponse> => {
      try {
        const { email } = args;
        const existingUser = await User.findOne({ email });

        if (existingUser) {
          return {
            ok: false,
            error: "You should Log in Instead",
            token: null
          };
        } else {
          const phoneVerification = await Verification.findOne({
            payload: args.phoneNumber,
            verified: true
          });
          if (phoneVerification) {
            args.password = await encryptToHash(args.password);
            const newUser = await User.create({ ...args }).save();
...      

4. 마무리

typeORM 과 같은 개발을 돕는 라이브러리에는 제가 아직 모르는 편리한 기능이 너무나 많은 것 같습니다.
하지만 편리한 기능일수록 부작용이나 위험성이 따른다는 점을 다시 한번 느꼈습니다.
에러가 발생하여 어려움을 겪고 고생하였지만,
평소에 사용법만 간략하게 익혀 사용해오던 bcrypt, typeORM 와 같은 라이브러리에 대하여 보다 상세하게 알아보는 시간을 가졌기에, 위 라이브러리에 대한 이해도와 숙련도 증가를 느끼는 시간이었습니다.

디버깅에 대해 '성공적(?)' 이였다는 말은 쉽게 꺼내지 못하겠습니다.

해당 포스트에 잘못된 내용이 있거나 다른 방법으로 구현하신 경험, 조언해주실 부분이 있다면 언제든 연락주시기 바랍니다.

여러분들의 생각은 어떨지 궁금합니다.

항상 긍정적인 시선으로 열린 지식 공유를 기다리고 있습니다.
감사합니다.

끝.

Debug Commit


  1. bugFix password to hash on utils
    https://github.com/dev4us/Uber_dev4us/commit/6051695a16273247ba68324e07effd2812d6ef2c
  1. Bugfix EmailSignup, encrypt password before Insert to db
    https://github.com/dev4us/Uber_dev4us/commit/f41d23596d24f2f46c2d6e8ed2d46cf34efc0a57