Spring-Intern-Series 2

5 minute read

지난 포스팅에 이어서 구체적으로 어떻게 코드를 개선했는지 살펴보도록 하겠다.

1. 우선 전체적인 애플리케이션 서버의 구조를 layered architecture의 형태로 생각했다.

layerd

이미지 출처 : https://www.jianshu.com/p/1241f19905f9

각각의 레이어에 대해 간단하게 설명하자면

  • Presentation Layer : 클라이언트로부터 요청을 받고 적절한 하위 레이어를 호출한 다음 그 반환 결과를 다시 클라이언트로 리턴해주는 역할을 한다.
  • Business Layer : 비지니스 로직이 처리되는 곳이다. 요구사항등에서 미리 정의된 규칙이나 로직이 이 부분에서 수행되고, 데이터에 대한 실질적인 조작, 변환등의 작업이 이루어지는 곳이다.
  • Persistence Layer : 데이터 베이스에 질의를 요청할 수 있는 레이어이다. ORM등을 이용해서 데이터를 객체화시키며 CRUD를 한다.
  • Database Layer : 실제 데이터들이 저장되는 곳이다. 데이터베이스 시스템이라고 생각하면 된다.

2. 실제 구현은 각 레이어들을 클래스로 만들어서 구현하였다.

또한 조금 더 구체적인 설명을 위해 한가지 상황을 생각해보고 이를 구현하는 과정을 진행해보겠다.

  • 회원가입 시나리오.
    1. 클라이언트는 회원 정보(아이디, 비밀번호, 닉네임)에 대한 데이터를 전송한다.
    2. 서버에서 비밀번호를 해싱한다.
    3. 해싱된 비밀번호와 나머지 정보를 데이터베이스에 저장한다.

다음과 같은 간단한 회원가입 시나리오를 내가 구현했던 방법대로 만들어 보도록하겠다.

이에 대한 설명은 레이어들의 순서와 조금 다르게 하도록하겠다.

3. Presentation Layer와 Database Layer

구현하기 가장 간단한 부분이었어서 이 부분을 제일 먼저 설명하도록 하겠다.

  • Presentation Layer

    Nest JS에서는 해당 레이어를 Controller라는 형태로 구현하도록 하고있다. 공식문서

    Controller 클래스를 정의하고 Nest의 DI를 이용해서 Business Layer에 해당하는 Service 클래스를 사용한다.

    @Controller('user')
    export class UserController {
      constructor(
        @Inject('UserService')
        private readonly userService: UserService,
      ) {}
    
      @Post()
      signup(signupDTO):signupDTO{
        return this.userService.signUp(signupDTO);
      }
    }
    

    데코레이터나 DI, DTO등에 관해서는 본 글의 내용에 벗어나기 때문에 따로 다루지는 않겠다.

  • Database Layer

    실제 데이터베이스에 해당되는 곳이다. 따라서 database table을 정의하겠다.

    User table

    column name type
    id int
    password varchar
    nickname varchar

이제부터가 진짜다

3. Persistence Layer

데이터베이스의 테이블을 객체화 시키고, 질의를 요청해야한다.

이는 ORM을 이용할 수 있는데 필자는 TypeORM을 이용하였다.

@Entity()
export class user_entity{
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 30, unique: true })
  @IsString()
  nickname: string;

  @Column({ type: 'varchar' })
  @IsString()
  password: string;
}

TypeORM을 이용해서 다음과 같이 entity class를 만들 수 있다. User 테이블의 컬럼들을 모두 멤버 변수로 가지고 있는 모습이다.

다음으로는 데이터베이스에 질의를 하기 위한 클래스를 구현해야한다 필자는 TypeORM repository pattern을 이용했다.

@Injectable()
export class UserRepository{
  constructor(
    @InjectRepository(user_entity) private readonly userRepository: Repository<user_entity>,
  ) {}

  public insert(user: user_entity) {
    return this.userRepository.insert(user);
  }
}

@InjectRepository 라는 데코레이터를 이용해서 위에서 정의한 user_entity 클래스를 해당 클래스에 불러왔고, user_entity클래스 타입으로 Repository 객체를 선언했다.

간단하게만 설명하자면 usersRepository는 이제 User 테이블에 CRUD에 해당하는 질의를 요청할 수 있는 것이다.

아래 insert메소드로 user를 데이터베이스에 저장할 수 있다.

여기서 타입을 유심히 볼 필요가 있다. 모두 user_entity 타입이다. user_entity 클래스의 객체를 전달받아서 사용하겠다는 이야기이다.

여기까지 구현하고 이제 하이라이트인 Business Layer로 넘어가겠다.

4. Business Layer

사실 지금까지 모든 클래스들이 Business Layer를 위해 존재했다고 말해도 과언이 아니다. 그만큼 Business Layer는 핵심 로직을 포함하고 있고, 중요하다.

우선 Nest에는 해당 레이어를 Service클래스로 구현하라고 한다. 공식문서

@Injectable()
export class UserService{
  constructor(
    @Inject('UserRepository')
    private readonly userRepository: UserRepository,
  ) {}

  public signup({
    id,
    password,
    nickname,
  }: signupDTO): signupDTO{
    // need to implement
}

@Inject 데코레이터로 위에서 구현한 UserRepository 클래스를 불러왔다.

그리고 메소드로써 signup함수를 구현하면 된다.

그럼 그냥 여기서 비밀번호 해싱하고 UserRepository 를 이용해서 데이터베이스에 저장하면 될까?

필자는 여기서 한번더 기능을 나눠 클래스로 만들었다.

export class MyUser extends user_entity {
  constructor(user: user_entity) {
    super();
    this.id= user.id;
		this.password = user.password;
    this.nickname = user.nickname;
  }

  public hashPassword(): {
    try {
      this.password = await bcrypt.hash(this.password, 10);
    } catch (e) {
      throw new InternalServerErrorException();
    }
  }
}

user_entity를 상속받는 MyUser클래스를 정의했다.

그럼 이 클래스는 뭐하는 녀석일까?

바로 모든 비지니스 로직들이 이곳에 정의 될 것이다.

비지니스 로직들을 서비스 함수 내에서 바로 구현할 수도 있었지만 그렇게 하면 몇가지 문제가 발생한다.

  • 비지니스 로직이 변경될 때 함수 내부의 다른 부분에 영향을 미친다.
  • 함수 내부에 다른 부분이 변경될 때 비지니스 로직이 영향을 받을 수 있다.

회원가입 예에서 다른 부분이라 함은 UserRepository에 해당하는 부분이라고 할 수 있다.

지금은 간단한 예라서 이런 문제가 발생하지 않을 수 있지만 비지니스 로직이 복잡해지고, 트랜젝션 처리로 인해 Repository까지 복잡해진다면 문제가 발생할 수 있다.

따라서 User 객체의 데이터를 조작하는 로직은 MyUser클래스에, 이를 데이터베이스에 저장하는 부분은 UserRepository에 나눠 구현함으로써 둘의 결합도를 낮출 수 있다.

클래스 내부를 좀더 자세히 설명해보자면

MyUser는 user_entity를 상속 받았기 때문에 user_entity의 모든 멤버변수 즉, 데이터베이스 컬럼들을 갖고 있다. 또한 user_entity 타입의 객체를 인자로 받아서 이를 deep copy하는 copy constructor를 정의하였다. 이는 회원가입 예에서는 쓰이진 않지만, UserRepository에서 find등의 메소드로 user_entity객체를 반환받고, 이를 MyUser클래스의 메소드로 데이터를 조작해야하는 경우를 위해 만들었다.

(user_entity는 MyUser의 부모 클래스이기 때문에 메소드는 가지지 않는다.)

hashPassword라는 메소드를 정의해서 현재 객체의 password를 암호화 하였다.

그럼 이제 MyUser클래스와 UserRepository를 조합하여 UserService부분을 완성해보자

@Injectable()
export class UserService{
  constructor(
    @Inject('UserRepository')
    private readonly userRepository: UserRepository,
  ) {}

  public signup({
    id,
    password,
    nickname,
  }: signupDTO): signupDTO{
    const user = new MyUser({id, password, nickname});
    user.hashPassword();
    this.userRepository.insert(user);
    return true;
}

MyUser 클래스의 인스턴스인 user를 생성하고, user의 메소드인 hashPassword를 호출하여 객체의 password를 해싱하였다. 이후 userRepository를 이용해서 데이터베이스에 저장했다.

3번에서 UserRepository를 정의할때 타입을 잘보라고 했던 것을 기억해야한다.

분명 UserRepository의 insert메소드는 user_entity타입의 객체를 인자로 받아야한다.

여기서 객체지향의 위대한 특징인 다형성이라는 개념이 사용되었다.

Java에서는 업캐스팅이라는 과정을 통해 부모 타입으로 자동으로 변환되어 인자로 전달되는데 TypeScript에서는 그렇게 작동되는 것같지는 않아보인다.

TypeScript는 구조적인 타입 시스템입니다. 두개의 다른 타입을 비교할 때 어디서 왔는지 상관없이 모든 멤버의 타입이 호환 된다면, 그 타입들 자체가 호환 가능하다고 말합니다.

출처 : https://typescript-kr.github.io/pages/classes.html

Typescript에서 클래스 타입을 비교할 때 모든 멤버 타입이 호환되면 OK라고 하는 것이다.

예를 들어

class Animal {
	name : string
}

class Cat {
	name : string
}

위와 같이 두개의 클래스는 서로 다른 이름으로 정의되었지만 name이라는 멤버의 타입이 string으로 호환되기 때문에 타입이 호환된다고 말할 수 있다.

따라서 다시 회원가입 예에서 MyUser와 user_entity는 서로 호환된다.

4. 그렇다면 이렇게 구현해서 뭐가 좋아진걸까?

이렇게 열심히 구현했지만 이전에 비해 개선된 점이 없다면 시간낭비만 한 꼴이된다.

  • 재사용성이 향상되었다.
    • 클래스를 정의했기 때문에 이를 여러 곳에서 재사용 가능하다.
    • hashPassword라는 메소드를 한 곳에서만 정의하고 다른 곳에서는 호출하는 형식이다.
  • 병렬적으로 개발이 가능해졌다.
    • Service 클래스 내에서도 비지니스로직, 데이터쿼리에 해당하는 부분을 나눴기 때문에 두 부분을 두 사람이 나눠 동시에 개발 가능하다.
    • 클래스나 메소드들의 입출력만 재대로 정의해 놓는다면 충분히 가능하다
    • 또한 각 레이어별로도 독립적인 개발이 가능하다.
  • 확장성이 좋아졌다.
    • 이전 포스팅에서 프로젝트에 User의 타입이 N개로 늘어날 수 있는 구조라고 하였다.
    • 이때 user_entity를 상속하여 A_User, B_User … N_User 를 구현한다면 코드 재사용으로 생산성도 좋아지고, 간편하게 확장할 수 있다.
    • 또한 Repository의 코드의 수정이 없이 확장가능하다.
    • Repository<user_entity> 이렇게 UserRepository의 타입을 정했기 때문에 user_entity를 상속받은 모든 클래스들은 해당 타입으로 호환되고 UserRepository의 메소드를 사용할 수 있다.
  • Encapsulation되었다.
    • 어떤 로직이나 일을 수행하려면 해당 멤버와 메소드를 가진 객체를 사용해야 한다.
    • 따라서 호출하는 쪽에서는 내부 구현을 알 필요가 없고, 결과적으로 비지니스 로직의 수정이 다른 부분의 영향을 끼치지 않게된다.
    • 또한 클래스 멤버들을 private이나 protected로 정의한다면 멤버들이 임의로 변경되지 않는다는 것을 보장할 수 있다.

지금까지 프로젝트 개선을 위해 고민하고 적용했던 것을 간단한 예제로 알아보았다. 혹시라도 이 글을 보는 사람들 중 비슷한 고민이 있다면 도움이 됐기를 바란다.

또한 글에서 잘못된 개념이나 좀 더 수정할 부분이 있다고 생각되면 어떤 것이든 말해주길 바란다.

Reference

NestJs Document

https://medium.com/@phatdev/how-to-build-a-scalable-maintainable-application-with-nestjs-mongodb-apply-the-design-patterns-7b287af61354

https://medium.com/modusign/모두싸인-백엔드-아키텍처-해부하기-a24aeccebd2a

Leave a comment