본문 바로가기

Node.js

Node.js(21) - server 및 환경 세팅, TDD, back-end 회원가입 및 Login 기능 구현

728x90
반응형

1) Node.js

   1-1) server 및 개발 환경 세팅, User 테이블 생성

   1-2) TDD

   1-3) back-end 회원가입 및 Login 기능 구현

 

 

 

 

 

 

 

1) Node.js

1-1) server 및 개발 환경 세팅, User 테이블 생성

$ npm init -y
$ npm install express mysql2 sequelize dotenv

 

기본세팅
|-- config.js
|-- app.js
|-- server.js

 


본격적인 작업을 시작하기 전에 우선 database, 즉 "테이블 스키마"가 필요하므로

테이블 스키마를 어떻게 구성할지 먼저 설계하고 시작해야 한다!

ERD: 테이블 스키마 설계도

 

--> 그렇기에 우리는 먼저 "models"을 만든다!

 

 

 

 

 

server.js

// app.js를 require해 옴!
const app = require("./app");
const { sequelize } = require("./models");
const port = process.env.PORT || 3000;

// 여기에 app.listen을 구현함!
app.listen(port, async () => {
  await sequelize.sync({ force: false });
  console.log(`Database Connected...`);
  console.log(`Running on http://localhost:${port}`);
});

 

 

app.js

// "app"이라는 변수(router, 미들웨어 부분을 포함)를 내보내기만 하는 역할
const express = require("express");
const app = express();

app.use(express.json());

// router

app.use((error, req, res, next) => {
  res.status(500).send(error.message);
});

module.exports = app;

 

 

config.js

const config = {
  db: {
    development: {
      database: "loginServer",
      username: "root",
      password: "Hhsb4114!@",
      host: "127.0.0.1",
      port: "3306",
      dialect: "mysql",
      define: {
        freezeTableName: true,
        timestamps: false,
      },
    },
    test: {
      database: "loginServer_test",
      username: "root",
      password: "Hhsb4114!@",
      host: "127.0.0.1",
      port: "3306",
      dialect: "mysql",
      define: {
        freezeTableName: true,
        timestamps: false,
      },
    },
  },
};

module.exports = config;

 

 

models/index.js

const fs = require("fs");
const path = require("path");

const Sequelize = require("sequelize");
const env = process.env.NODE_ENV || "development";
const config = require("../config")["db"][env];

const sequelize = new Sequelize(
  config.database,
  config.username,
  config.password,
  config
);

// sequelize에 models 객체를 넣어줌
fs.readdirSync(__dirname)
  .filter((file) => file.indexOf("model") !== -1)
  .forEach((file) => {
    require(path.join(__dirname, file))(sequelize, Sequelize);
  });

// models 객체 안에서 관계 설정을 하기 위한 코드
const { models } = sequelize;
for (const key in models) {
  if (typeof models[key].associate !== "function") continue;
  models[key].associate(models);
}

module.exports = {
  Sequelize,
  sequelize,
};

 

 

models/user.model.js

module.exports = (sequelize, Sequelize) => {
  // class 선언
  class User extends Sequelize.Model {
    static initialize() {
      return this.init(
        {
          userid: {
            type: Sequelize.STRING(60),
            primaryKey: true,
          },
          userpw: {
            type: Sequelize.STRING(64),
            allowNull: false,
          },
          username: {
            type: Sequelize.STRING(30),
            allowNull: false,
          },
          provider: {
            type: Sequelize.ENUM("local", "kakao"), // "ENUM"은 각 인자값에 어떤 타입을 쓸 것인지 정하는데 해당 코드는 'local'과 'kakao'라는 String 타입만 받겠다는 의미이다!
            allowNull: false,
            defaultValue: "local",
          },
          snsId: {
            type: Sequelize.STRING(30),
            allowNull: true,
          },
        },
        {
          sequelize, // 해당 코드는 "sequelize: sequelize"를 의미함!
        }
      );
    }
  }

  // class 사용
  User.initialize();
};

 

 

 

 

1-2) TDD

TDD: Test Driven Development의 약자로 ‘테스트 주도 개발’이라고 한다.
TDD는 크게 3가지로 구분되는데 바로 "단위 테스트", "통합 테스트", "부하 테스트"이다.


여기서 "단위 테스트"란 개발자가 수행하고 자신이 개발한 코드 단위(일명, 모듈)를 테스트하는 것을 말한다.
즉, 특정 class의 method들을 하나하나 실행하여 그것들이 정상 동작하는지 테스트하는 것을 말한다.

(코드가 돌아가는 로직에 대해서만 체크함)


단위 테스트의 조건: 독립적이어야 하며, 어떤 테스트도 다른 테스트에 의존하지 않아야 함

단위 테스트를 수행할 수 있는 대표적인 테스팅 프레임워크: Jest
Jest는 facebook에 의해 만들어졌으며, 이는 최소한의 설정으로 동작하며

Test Case를 만들어서 application code가 잘 돌아가는지 확인해 준다.

그렇기에 단위(Unit) 테스트를 수행하기 위해 사용된다.

$ npm install -D jest node-mocks-http supertest

 

여기서 "jest"와 "node-mocks-http"는 단위 테스트할 때 사용하고,

"supertest"는 통합 테스트할 때 사용하기 위해 설치하였다!

 

 

파일명.test.js

package.json
"start":"node server",
"test":"jest"
--> npm run test(위와 같이 package.json 파일에 내용을 추가하면 테스트할 코드를 실행시켜 이상 여부 확인 가능!)

jest: ".test", ".spec" 파일을 찾아서 실행시켜주는 일종의 실행기

 

 

jest 코드 구성요소

  • describe : 첫번째 인수값은 "설명"이고, 두번째 인수값은 "실행시킬 코드(callback 함수)"이다!
  • it : 첫번째 인수값은 "설명"이고, 두번째 인수값은 "실행시킬 코드(callback 함수)"이다!
    • expect(A).toBe(C) : "A"가 "C"인지 체크하는 코드
    • expect(B).toEqual(D) : "B"가 "D"와 동일한지 체크하는 코드

--> 단순히 코드를 실행할 영역을 나눈 것이다!

 

 

 

jest.config.unit.js

module.exports = {
  testEnvironment: "node",
  verbose: true,
};

 

 

lib/jwt.test.js

/*
const sum = (a, b) => a + b;
const obj = {
  userid: "hsb7722",
  userpw: "1234",
};

describe("JWT class test", () => {
  it("decode", () => {
    console.log("hello~~");
  });

  it("encode", () => {});

  it("2+2 = 4 이다!", () => {
    const result = sum(2, 2);
    expect(result).toBe(4);
  });

  it("객체 테스트", () => {
    expect(obj).toEqual({
      userid: "hsb7722",
    });
  });
});
*/

const JWT = require("./jwt");
const crypto = require("crypto");

describe("lib/jwt.js", () => {
  let jwt;
  it("constructor", () => {
    expect(typeof JWT).toBe("function"); // "function"으로 적었지만 실제로는 "class"를 체크하기 위함!(Javascript에서는 class의 type이 function이기 때문!)
    jwt = new JWT({ crypto }); // {crypto: {...}}
    expect(typeof jwt.crypto).toBe("object");
  });

  it("encode", () => {
    expect(typeof jwt.encode).toBe("function");
    const value = { foo: "bar" };
    const base64 = jwt.encode(value);
    expect(base64).toBe("eyJmb28iOiJiYXIifQ");
  });
});

 

 

lib/jwt.js

/*
class JWT {
  constructor({ crypto }) {
    this.crypto = crypto;
  }

  encode(obj) {
    return Buffer.from(JSON.stringify(obj)).toString("base64url");
    // return; --> undefined가 반환됨
  }
}

module.exports = JWT;
*/

class JWT {
  constructor({ crypto }) {
    this.crypto = crypto;
  }

  sign(data, options = {}) {
    const header = this.encode({ tpy: "JWT", alg: "HS256" }); //base64url
    const payload = this.encode({ ...data, ...options }); //base64url
    const signature = this.createSignature([header, payload]);

    // return `${header}.${payload}.${signature}`;
    return [header, payload, signature].join(".");
  }

  // token: string, salt: string
  verify(token, salt) {
    // eyJ0cHkiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyaWQiOiJ3ZWI3NzIyIiwidXNlcm5hbWUiOiJpbmdvbyJ9.uMiI-vrtl0X_u2hg64YZGCOvvlogEYBOwradyX6duyU
    // header, payload --> hash 진행
    // 기존 hash와 새로운 hash를 비교하여 true인지 아닌지 체크
    const [header, payload, signature] = token.split(".");
    const newSignature = this.createSignature([header, payload], salt);
    if (newSignature !== signature) {
      throw new Error("토큰이 이상함! 누가 변조한 것 같음!");
    }

    return this.decode(payload);
  }

  encode(obj) {
    return Buffer.from(JSON.stringify(obj)).toString("base64url");
  }

  decode(base64) {
    return JSON.parse(Buffer.from(base64, "base64").toString("utf-8")); // 객체를 반환함!
  }

  createSignature(base64urls, salt = "web7722") {
    // header.payload .join
    const data = base64urls.join(".");
    return this.crypto
      .createHmac("sha256", salt)
      .update(data)
      .digest("base64url");
  }
}

module.exports = JWT;
 

 

 

 

 

 

1-3) back-end 회원가입 및 Login 기능 구현

 

Authorization
"Bearer"은 인증방법(type) 중의 하나이다!

GET /http/1.1
Authorization : Bearer token
Content-type : application/json

body ...


POST /http/1.1
Authorization : Bearer token
Content-type : application/json

{
  subject: 'asdf',
  content: 'asdf',
  ...
  token: ''
}

 

 

 

 

server.js

// app.js를 require해 옴!
const app = require("./app");
const { sequelize } = require("./models");
const port = process.env.PORT || 3000;

// 여기에 app.listen을 구현함!
app.listen(port, async () => {
  await sequelize.sync({ force: false });
  console.log(`Database Connected...`);
  console.log(`Running on http://localhost:${port}`);
});

 

 

app.js

// "app"이라는 변수(router, 미들웨어 부분을 포함)를 내보내기만 하는 역할
const express = require("express");
const app = express();
const router = require("./routes");

app.use(express.json());

// router
app.use(router);

app.use((error, req, res, next) => {
  res.status(500).send(error.message);
});

module.exports = app;

 

 

config.js

const config = {
  db: {
    development: {
      database: "loginServer",
      username: "본인 MySQL 계정명",
      password: "본인 MySQL 비밀번호",
      host: "127.0.0.1",
      port: "3306",
      dialect: "mysql",
      define: {
        freezeTableName: true,
        timestamps: false,
      },
    },
    test: {
      database: "loginServer_test",
      username: "본인 MySQL 계정명",
      password: "본인 MySQL 비밀번호",
      host: "127.0.0.1",
      port: "3306",
      dialect: "mysql",
      define: {
        freezeTableName: true,
        timestamps: false,
      },
    },
  },
};

module.exports = config;

 

 

jest.config.unit.js

module.exports = {
  testEnvironment: "node",
  verbose: true,
};

 

 

lib/jwt.js

/*
class JWT {
  constructor({ crypto }) {
    this.crypto = crypto;
  }

  encode(obj) {
    return Buffer.from(JSON.stringify(obj)).toString("base64url");
    // return; --> undefined가 반환됨
  }
}

module.exports = JWT;
*/

class JWT {
  constructor({ crypto }) {
    this.crypto = crypto;
  }

  sign(data, options = {}) {
    const header = this.encode({ tpy: "JWT", alg: "HS256" }); //base64url
    const payload = this.encode({ ...data, ...options }); //base64url
    const signature = this.createSignature([header, payload]);

    // return `${header}.${payload}.${signature}`;
    return [header, payload, signature].join(".");
  }

  // token: string, salt: string
  verify(token, salt) {
    // eyJ0cHkiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyaWQiOiJ3ZWI3NzIyIiwidXNlcm5hbWUiOiJpbmdvbyJ9.uMiI-vrtl0X_u2hg64YZGCOvvlogEYBOwradyX6duyU
    // header, payload --> hash 진행
    // 기존 hash와 새로운 hash를 비교하여 true인지 아닌지 체크
    const [header, payload, signature] = token.split(".");
    const newSignature = this.createSignature([header, payload], salt);
    if (newSignature !== signature) {
      throw new Error("토큰이 이상함! 누가 변조한 것 같음!");
    }

    return this.decode(payload);
  }

  encode(obj) {
    return Buffer.from(JSON.stringify(obj)).toString("base64url");
  }

  decode(base64) {
    return JSON.parse(Buffer.from(base64, "base64").toString("utf-8")); // 객체를 반환함!
  }

  createSignature(base64urls, salt = "web7722") {
    // header.payload .join
    const data = base64urls.join(".");
    return this.crypto
      .createHmac("sha256", salt)
      .update(data)
      .digest("base64url");
  }
}

module.exports = JWT;

 

 

models/index.js

const fs = require("fs");
const path = require("path");

const Sequelize = require("sequelize");
const env = process.env.NODE_ENV || "development";
const config = require("../config")["db"][env];

const sequelize = new Sequelize(
  config.database,
  config.username,
  config.password,
  config
);

// sequelize에 models 객체를 넣어줌
fs.readdirSync(__dirname)
  .filter((file) => file.indexOf("model") !== -1)
  .forEach((file) => {
    require(path.join(__dirname, file))(sequelize, Sequelize);
  });

// models 객체 안에서 관계 설정을 하기 위한 코드
const { models } = sequelize;
for (const key in models) {
  if (typeof models[key].associate !== "function") continue;
  models[key].associate(models);
}

module.exports = {
  Sequelize,
  sequelize,
};

 

 

models/user.model.js

module.exports = (sequelize, Sequelize) => {
  // class 선언
  class User extends Sequelize.Model {
    static initialize() {
      return this.init(
        {
          userid: {
            type: Sequelize.STRING(60),
            primaryKey: true,
          },
          userpw: {
            type: Sequelize.STRING(64),
            allowNull: false,
          },
          username: {
            type: Sequelize.STRING(30),
            allowNull: false,
          },
          provider: {
            type: Sequelize.ENUM("local", "kakao"), // "ENUM"은 각 인자값에 어떤 타입을 쓸 것인지 정하는데 해당 코드는 'local'과 'kakao'라는 String 타입만 받겠다는 의미이다!
            allowNull: false,
            defaultValue: "local",
          },
          snsId: {
            type: Sequelize.STRING(30),
            allowNull: true,
          },
        },
        {
          sequelize, // 해당 코드는 "sequelize: sequelize"를 의미함!
        }
      );
    }
  }

  // class 사용
  User.initialize();
};

 

 

routes/index.js

const express = require("express");
const router = express.Router();
const users = require("../src/user/user.route");
const auth = require("../src/auth/auth.route");

router.use("/users", users);
router.use("/auth", auth);

module.exports = router;

 

 

src/user/user.route.js

const express = require("express");
const router = express.Router();
const { userController: controller } = require("./user.module");

router.post("/", (req, res, next) => controller.postSignup(req, res, next));
router.get("/me", (req, res, next) => controller.getMe(req, res, next));

module.exports = router;

 

 

src/user/user.module.js

const {
  sequelize: {
    models: { User },
  },
} = require("../../models");

const UserRepository = require("./user.repository");
const UserService = require("./user.service");
const UserController = require("./user.controller");
const JWT = require("../../lib/jwt");
const crypto = require("crypto");

const jwt = new JWT({ crypto });

const userRepository = new UserRepository({ User });
const userService = new UserService({ userRepository, jwt });
const userController = new UserController({ userService });

module.exports = {
  userController,
};

 

 

src/user/user.controller.js

class UserController {
  constructor({ userService }) {
    this.userService = userService;
  }

  async getMe(req, res, next) {
    try {
      if (!req.headers.authorization) throw new Error("Authorization 없음!!");
      const [type, token] = req.headers.authorization.split(" ");
      if (type !== "Bearer") throw new Error("Authorization Type Error");

      const user = await this.userService.me(token);
      res.json(user);
    } catch (e) {
      next(e);
    }
  }

  async postSignup(req, res, next) {
    try {
      const { userid, userpw, username } = req.body;
      const user = await this.userService.signup({ userid, userpw, username });
      res.status(201).json(user);
    } catch (e) {
      next(e);
    }
  }
}

module.exports = UserController;

 

 

src/user/user.service.js

class UserService {
  constructor({ userRepository, jwt }) {
    this.userRepository = userRepository;
    this.jwt = jwt;
    this.crypto = jwt.crypto;
  }

  async signup({ userid, userpw, username }) {
    try {
      if (!userid || !userpw || !username) throw "내용 없음!!";
      const hash = this.crypto
        .createHmac("sha256", "hsb7722")
        .update(userpw)
        .digest("hex");

      const user = await this.userRepository.addUser({
        userid,
        username,
        userpw: hash,
      });

      return user;
    } catch (e) {
      throw new Error(e);
    }
  }

  async me(token) {
    try {
      const { userid } = this.jwt.verify(token, "web7722");
      const user = await this.userRepository.getUserById(userid);
      return user;
    } catch (e) {
      throw new Error(e);
    }
  }
}

module.exports = UserService;

 

 

src/user/user.repository.js

/*
console.log(this); // {}

console.log(this === module.exports); // true --> 둘 다 빈 객체이므로 같은 메모리 주소를 참조하고 있다는 의미!

// module.exports를 안 적으면 return 값이 빈 객체("{}")이다!

function a() {
  console.log(this); // global
}

a();

exports.a = () => {
  return "aaa";
};

this.a(); // "exports.a()"와 동일한 코드임!
*/

class UserRepository {
  constructor({ User }) {
    this.User = User;
  }

  async addUser(payload) {
    try {
      const user = await this.User.create(payload, { raw: true });
      return user;
    } catch (e) {
      throw new Error(e);
    }
  }

  async getUserById(userid) {
    try {
      const user = await this.User.findOne({
        raw: true,
        where: {
          userid,
        },
      });

      return user;
    } catch (e) {
      throw new Error(e);
    }
  }
}

module.exports = UserRepository;

 

 

src/user/user.repository.test.js

const UserRepository = require("./user.repository");

describe("UserRepository", () => {
  let User, repository;

  // beforeEach: 아래 코드들이 하나 실행될 때마다 해당 함수(beforeEach)를 다시 실행시켜줌!
  beforeEach(() => {
    User = {
      create: jest.fn().mockResolvedValue({}),

      /*
      // 바로 위의 코드와 동일하게 동작함!(return 값이 promise 객체가 떨어짐)
      create: () => {
        return new Promise((resolve, reject) => {
          resolve({});
        });
      },
      */
    };

    repository = new UserRepository({ User }); // UserRepository의 return 값 = {User: {}}
    // console.log(repository);
  });

  it("UserRepository를 잘 가져오는가?", () => {
    expect(typeof UserRepository).toBe("function");
  });

  describe("addUser", () => {
    let payload = {
      userid: "hsb7722",
      userpw: "1234",
      username: "sangbeom",
    };

    it("[try] addUser 메서드 확인", async () => {
      const user = await repository.addUser(payload);
      // expect(User.create).toHaveBeenCalled();
      expect(User.create).toHaveBeenCalledWith(payload, { raw: true });
      expect(user).toEqual({});
    });

    it("[catch] create method가 reject가 발생되었을 때", async () => {
      User.create = jest.fn().mockRejectedValue({});
      await expect(
        async () => await repository.addUser(payload)
      ).rejects.toThrow();
    });
  });
});

 

 

src/auth/auth.route.js

const express = require("express");
const router = express.Router();
const { authController: controller } = require("./auth.module");

router.post("/", (req, res, next) => controller.postLogin(req, res, next));

module.exports = router;

 

 

src/auth/auth.module.js

const {
  sequelize: {
    models: { User },
  },
} = require("../../models");

const AuthRepository = require("./auth.repository");
const AuthService = require("./auth.service");
const AuthController = require("./auth.controller");

const JWT = require("../../lib/jwt");
const crypto = require("crypto");

const jwt = new JWT({ crypto });

const authRepository = new AuthRepository({ User });
const authService = new AuthService({ authRepository, jwt });
const authController = new AuthController({ authService });

module.exports = {
  authController,
};

 

 

src/auth/auth.controller.js

class AuthController {
  constructor({ authService }) {
    this.authService = authService;
  }

  async postLogin(req, res, next) {
    try {
      const { userid, userpw } = req.body;
      const token = await this.authService.token({ userid, userpw });
      res.json({ token });
    } catch (e) {
      next(e);
    }
  }
}

module.exports = AuthController;

 

 

src/auth/auth.service.js

class AuthService {
  constructor({ authRepository, jwt }) {
    this.authRepository = authRepository;
    this.jwt = jwt;
    this.crypto = jwt.crypto;
  }

  // 전달받은 데이터를 암호화하여 hash 값을 만들어야 함
  // repository에 user가 존재하는지 체크해야 됨
  // 그 후 존재한다고 확인되면 return 값으로 token을 보냄
  async token({ userid, userpw }) {
    try {
      if (!userid || !userpw) throw "내용 없음!!";
      const hash = this.crypto
        .createHmac("sha256", "hsb7722")
        .update(userpw)
        .digest("hex");
      const user = await this.authRepository.getUserByInfo({
        userid,
        userpw: hash,
      });

      // user가 존재하는지 안하는지 체크해야 됨
      // user가 존재하면 token을 생성함
      if (!user) throw "아이디와 패스워드가 일치하지 않습니다!";

      const token = this.jwt.sign(user);
      return token;
    } catch (e) {
      throw new Error(e);
    }
  }
}

module.exports = AuthService;

 

 

src/auth/auth.repository.js

class AuthRepository {
  constructor({ User }) {
    this.User = User;
  }

  async getUserByInfo({ userid, userpw }) {
    try {
      const user = await this.User.findOne({
        raw: true,
        attributes: { exclude: ["userpw"] },
        where: {
          userid,
          userpw,
        },
      });

      return user;
    } catch (e) {
      throw new Error(e);
    }
  }
}

module.exports = AuthRepository;