새로운 구조 모험기 - BE (NestJs, Typeorm)

11t518s·2022년 10월 4일
1
post-thumbnail

이 글은 새로운 아키텍처 모험기 - BE편 입니다!

FE 구조 모험기에서 말씀드린 것과 동일하게

이번에 저희 서비스가 1주년일 맞이해서 1주년 이벤트를 진행하게 됐습니다!

새로운 이벤트는 새로운 router에서 진행하고,,
새로운 router를 그린다는 사실은 새로운 도전을 못참기 때문에,,
이번에 기존에 유지되던 코딩 기조를 벗어나 새로운 도전을 해봤습니다!

그래서 백엔드 역시 이런 도전을 허락해준 저희 회사 대표님과 개발자 선배님들께 굉장히 감사합니다..


NestJs에서 많이 쓰이는 아키텍쳐 혹은 패턴이 있나요?

아직 명확히 자리잡은 메인 패턴은 없는 듯 합니다.

물론 제가 백엔드를 메인으로 하지 않았어서 그럴 수도 있고, 외국 문물을 잘 못받아들여와서 그런 것도 있는 듯 합니다..

그래서 패턴을 혼자 창작을 하기엔 서버 개발 경험이 많지 않았고,
이런 저런 고민과 자료들을 많이 찾아보면서 아키텍쳐와 패턴을 선택했습니다!

이제 왜 어떤 구조를 선택했고, 제 나름대로 어떻게 해석을 했나에 대한 고민을 이야기 해보겠습니다!

우리에게 가장 좋은 패턴은 무엇일까?

특별한 Nest아키텍쳐를 찾지 못하고, 대신 서버는 어떤 아키텍쳐들이 있을까 찾아봤습니다.

기본적으로 우리나라에서 서버는 Spring이 주를 이루고 있는데 Nest역시 Spring과 유사하게 동작시킬 수 있었습니다.

돌려말하면 NestJs에서 가장 좋은 구조가 없다면 Spring에서 좋은 아키텍쳐와 패턴을 가져오면 된다는 이야기가 됩니다.

그래서 Spring의 디자인 패턴 중 가장 적합한 것을 따라가려 했습니다. 찾아보니 전략 패턴, 템플릿 메서드 패턴, 팩토리 메서드 패턴 등등 너무 많은 패턴들이 있었고, 이것들을 모두 이해하고 적용하기엔 어려웠습니다.

그래서 제가 느끼는 문제가 뭐였는지를 먼저 파악하고 난 후 주변 Spring개발자들에게 좋은 해결책들을 추천 받기로 했습니다.

기존 구조 중 문제라고 생각하는 부분

1. Service에서 모든 로직을 관리 하는 것

router를 살펴보면 대부분 이러한 모습을 보였습니다.

	[routerName]
    	-- [DTO]
        -- [Response]
    	-- routerName.controller.ts
        -- routerName.service.ts
        -- routerName.module.ts

보시면 아시겠지만 module은 의존성을 주입 받고 전달하기 위해 사용되니 제외하게 되고 ,유저에게 정보를 받아오고 정보를 넘겨주는 부분을 담당하는 controller,
그리고 나머지 모든 비즈니스 로직을 담당하는 service 두 부분으로 나뉘게 됩니다.

가장 큰 문제라고 생각했던 부분이 이 부분입니다.

더 정확하게는

entity가 Sevice에 직접 주입되는 것이 문제점을 말하고 싶었습니다.

entity를 직접 주입 받으니 관심사가 다르더라도 필요한 repository가 있으면 직접 entity의 의존성을 주입받고 로직들을 처리해주기 용이해서 이미 있는 로직을 새로 만들어서 사용하게 됐습니다.

즉 관심에 대한 분류가 사라졌고, 조금 더 강제적으로 관심사를 나눠야겠다고 생각했습니다.

2. private가 없다.

단일 책임의 원칙을 지키지 못하는 상황이 있었습니다.

위의 문제와 이어지게 Service에서 너무 많은 로직을 담당하는 것들을 봤고 메서드들이 너무 무거워졌습니다.

조금 고민해보고 우선 문제라고 생각한 부분이 저희 메서드가 모두 public 인 점이었습니다.
조금 더 정확하게는 거의 모든 Service메서드가 controller에 대응됐기 때문입니다.

여기서 생기는 문제점은 또 모든 비즈니스 로직이 각각 필요한 것들을 구현해줬던 것들인데 이러한 것들은 지금 당장 빠르게 만들 수 있지만( 물론 무조건 그렇지 않을 수도 있지만 ) 리팩토링 할 때 또 유사한 로직들이 중복되서 여러 부분을 수정해야하는 문제상황에 놓입니다.

3. implement, extends가 없다.

보다 정확히는 타입에 대한 안정성이 부족했습니다.

비슷한 의미로 사용되기 때문에 동일한 테이블 모습을 갖고 있다면 이전의 타입을 활용했을 때 재용할 수 있는 쿼리들이 생길 수 있다고 생각했습니다.
그런데 이전에 사용됐던 타입을 다시 사용하기보단 새로운 타입을 만들어서 정의했었던 것들이 아쉬웠습니다.

문제들의 근본적인 원인

Service에서 Entity를 직접 관리한다.

물론 다양한 원인이 있겠지만 근본적으로 해당 문제가 가장 크다고 생각했습니다.

어느 Service에서든 Entity를 불러와서 작성했기 때문에 한 컨트롤러에 그에 맞는 Service를 만들고 그 Service에서는 새로운 쿼리를 작성해버릇 했었습니다.

이렇게 하다보니 Service도 커지고 굳이 private한 메서드도 없고, 이전 타입을 굳이 활용할 필요도 없었다고 생각했습니다

해결을 위한 과정

팀적 통일성

기본적으로 팀원들 끼리 코드의 추상화와 확정성에 공감하고 앞으로도 기존 코드를 잘 활용하기로 합의할 필요가 있다고 느꼈습니다.

그렇지만 그러한 방안은 좋지만, 나중에 팀이 커지면 지키기 어려워지는 것이고, 엄격한 코드리뷰 혹은 개인의 엄격한 책임과 개인에 대한 확실한 믿음이 있어야 가능하다고 생각했습니다.

즉 위의 상황은 이상적인 시나리오이고, 가장 먼저 시도해야 했습니다. 그러나 그렇지 않을 가능성이 존재했고 조금 더 강력한 규칙을 정하고 싶었습니다!

레이어드 패턴

그래서 위의 문제를 서버 개발하는 친구들과 주변 개발자님들께 조언을 구했고, 우리 회사의 상황을 고려해서 내린 결론은 레이어드 패턴을 도입하고, 필요하다면 다른 것을 도입해보자는 것이었습니다.

레이어드 패턴을 Nest식으로 표현하면

Request from Client

Controller   => 요청에 응하고 답하는 것을 담당
Service      => 비즈니스 로직을 담당
Repository   => DB에 필요한 정보를 저장하고 수정하고 만들고 삭제

DB           => Entity로 정의된 DB

위와 같이 표현할 수 있습니다.

여기서 중요한 것은 Service 아래에 Repository를 둬서 하나의 레이어를 늘리는데, Entity(각 테이블을 나타내는 DB)는 반드시 하나의 Repository에서만 의존성을 주입받는 형태가 되야하는 것입니다.

기존에 우리 패턴도 유지하자

저희는 원래 Repository가 없었던 것은 아닙니다.
그런데 저희 Repository는 Transaction을 실행시켜야 할 때만 만드는 파일이었습니다.

그런데 해당 패턴 필요하다고 생각했습니다. 왜냐면 Transaction을 진행할 때 흔히 database.ts에 등록하고, module에서 주입받은 Repository가 아닌 별개의 Repository를 사용했기 때문에 분리해주는 것이 좋다고 생각했습니다.

아래의 코드를 예로 들어보겠습니다.

@Injectable()
export class ExampleService {
	    constructor(
        @InjectRepository(Example)
        private exampleRepository: Repository<Example>,
    ) {}
  
	// transaction 없이 의존성 주입받은 repository를 사용하는 형태
  	async example() {
    	const exampleQuery1 = this.exampleRepository.somethingLogic(...)
    }
                                                                    
	// transaction을 사용해야해서 별개의 repository를 사용하는 형태
	async example2() {
          const queryRunner = this.connection.createQueryRunner();
          await queryRunner.connect();
          
          // 트랜젝션 중간 처리
          
    	  const exampleQuery2 = queryRunner.manager.getRepository(Example)
    }
}

예를들어 위의 형태는 @injectRepository로 주입 받은 exampleRepository를 사용하지만,
아래의 형태는 직접 Example을 getRepository( module을 거치지 않고 )해와서 사용하는 차이점이 있습니다.

의존성을 주입받거나 새로 만들어주는 것의 코드적인 차이점은 없지만 해당 의존성 주입을 받았다는 사실만으로 관심사가 크게 달라진다고 생각했습니다.

그리고 transaction을 사용하는 주요한 몇몇 쿼리들이 있었기 때문에 이를 따로 분리해두는 것도 명확하게 인지할 수 있어서 좋다고 생각했습니다.

그래서 Repository를 만들 때 별개의 transaction용 Repository를 만들기로 했습니다.

typeorm 다음 버전을 대비해야한다.

여기 따끈따끈하게 버그를 잡으며 0.3.9 버전이 올라왔습니다.

그렇지만 저희가 사용하는 typeorm은 0.2.34버전입니다.

마이너 업데이트가 있었던 것인데 그 내용중 가장 큰 내용은

EntityRepository가 deprecated 된 것이다.

원래 Repository를 만들어줄 때

@EntityRepository(Example)
export class ExampleRepository extends Repository<Example> {
  async example() {}
}

이런식으로 EntityRepository를 사용해서 만들어줬었는데 이 방식이 사용 불가능해진 것이다.

물론 CustomRepository를 만들 수 있게 의지 강력하신 분들이 만들여주신 좋은 코드들이 있지만 반대로 생각해봤다.

  1. typeorm에서 굳이 deprecated된 방식을 사용해야할까?
  2. 내가 하려는 것은 Repository를 다른 파일에서 관리해주는 방식으로 관심사를 나누려는 것인데 EntityRepository라는 것이 중요할까?
  3. InjectRepository사용하는게 불만족스러웠던 이유는 Service위치에서 작동시키기 때문이지 InjectRepository자체는 유용한 데코레이터가 아닐까?

물론 저 혼자 생각한 것이기 때문에 잘못된 부분이 있을 수 있지만 이러한 의심을 했습니다.

최종 구조

[routerName]
	-- routerName.controller.ts
    -- routerName.service.ts
    -- routerName.transaction.repository.ts
    -- routerName.repository.ts
    -- routerName.module.ts

최종적으로 위처럼 두가지 repository를 사용해서 구조해보기로 했습니다!

이 또한 엄청 긍정적인 효과를 내지 않을 수 있지만, 기존의 문제가 있던 것을 해결해 가는 중간 역에 하나로는 충분히 유의미한 성과를 낼 수 있을 것이라 생각했습니다!

profile
사람들에게 행복을 주는 서비스를 만들고 싶은 개발자입니다!

0개의 댓글