개발자

NestJs에서 SRP

2023년 07월 08일조회 662

안녕하세요 Nest.js를 공부 중인 학생입니다. 이번에 공부한 내용들을 가지고 사이드 프로젝트를 하던 중 SRP와 관련한 의문이 생겨 질문남깁니다 ㅠㅠ nestjs를 공부할 때 컨트롤러는 유저와 서비스를 이어주는 역할이고 서비스는 비즈니스 로직을 담당하는 것이라고 배웠습니다. 그리고 컨트롤러는 너무 커지지 않도록 하는 것이 좋다고 들었습니다. 이를 고려해서 코드를 짜다보니 한 서비스 내에 너무 많은 로직이 몰리는 듯한 느낌이 들었습니다. 여러 모듈로 분리하고 필요한 서비스를 하나의 서비스 내에 몰아서 사용하다보니 의존성이 커지고 재사용성이 없다고 느껴졌습니다.. 그래서 SRP를 고려하여 코드를 짜려다 보니 하나의 서비스는 하나의 기능만을 담당해야 하고, 그렇게 구현한 서비스들을 컨트롤러에 주입하여 사용하는 것이 맞다고 하더군요.. 그렇게 코드를 짜다보니 이번엔 컨트롤러가 너무 비대해지고, 정작 비즈니스 로직을 담당한다던 서비스 쪽은 너무 작아져서 재사용하기도 애매하고 오히려 불필요하다고 느껴질 정도입니다. 사진 속 코드가 바로 그 예시입니다. 이메일 인증이 필요한 회원가입 기능을 구현하는 코드이고 플로우는 다음과 같습니다. 입력받은 이메일, 이름, 전화번호에 해당하는 유저가 존재하는지 확인 -> 없다면 해당 유저 정보를 DB에 저장 -> 이메일 인증에 사용할 token 생성 후 DB에 저장 -> 이 token을 입력받은 이메일로 발송 위 기능 구현을 위해 auth serrvice, users service, email service로 분리하였습니다. 하지만 보시다시피 컨트롤러에 오히려 로직이 몰려있는 느낌이고, 정작 auth service 내의 register 함수는 전달받은 token과 userId를 바탕으로 DB에 저장하는 로직만을 담당하게 됩니다.. 이렇게 짜는게 정말 맞는지 아니라면 피드백 부탁드립니다 ㅠㅠ

이 질문이 도움이 되었나요?
'추천해요' 버튼을 누르면 좋은 질문이 더 많은 사람에게 노출될 수 있어요. '보충이 필요해요' 버튼을 누르면 질문자에게 질문 내용 보충을 요청하는 알림이 가요.

답변 2

인기 답변

달레님의 프로필 사진

와, 아직 학생이신데 참 훌룡한 고민을 하시고 계신 것 같습니다! 나중에 정말 좋은 개발자가 되실 것 같은 느낌이 드네요 👏 설명해주신 내용과 공유해주신 코드를 보고 지금 하고 계신 사이드 프로젝트의 구조를 상상해보았습니다. 우선 해당 NestJS 앱은 AuthModule, UsersModule, EmailModule으로 구성되어 있을 것 같습니다. 그리고 AuthModule에는 AuthController와 AuthService가 있고, AuthController는 생성자를 통해서 UsersService와 EmailService의 인스턴스를 주입받을 것 같습니다. 제 상상이 맞다면 현재 프로젝트의 구조는 아래와 같을 것 같은데요. ``` AuthController / | \ UsersSerivce AuthSerivce EmailSerivce ``` 자, 여기서 문제는 AuthController에서 3개의 다른 서비스를 호출하다보니 의도치 않게 너무 비대해졌고, 상대적으로 AuthService이 하는 일은 역시 의도치 않게 너무 적어져서 이 구조가 적절한지 스스로 의구심이 드시는 것 같습니다. 이 문제를 해결하는 가장 간단한 방법은 Controller 레이어(layer)에 있는 비지니스 로직을 Service 레이어로 한 단계 내리는 것입니다. 즉, AuthController는 AuthSerivce에만 의존을 하고요. 대신에 AuthService가 UsersService와 EmailService에 의존하게 하는 것입니다. ``` AuthController | UsersSerivce <- AuthSerivce -> EmailSerivce ``` 이렇게 구조 변경을 해주면 AuthController는 단순히 AuthService의 register 메서드에 dto 객체만 넘겨서 호출하게 되고, 자연스럽게 AuthService의 register 메서드에 모든 비지니스 로직이 있게 될 것 입니다. 그럼 여기서 이런 질문이 드실지도 모르겠습니다. "아니 이렇게 하면 AuthService의 register 메서드의 재사용성이 너무 떨어지는 게 아니야? 애초에 재사용성 때문에 모듈을 분리한건데..." 이 새로운 문제는 Controller 레이어와 Serivce 레이어 사이에 Orchestration 레이어를 추가하여 해결할 수 있는데요. Orchestration 레이어의 역할은 Service 레이어의 재사용을 보장하면서 내부 모듈의 서비스와 외부 모듈의 서비스를 엮어서 Controller 레이어에 최적화된 API를 제공하는 것입니다. 뿐만 아니라 서비스 레이어에서 circular dependency, 즉 순환 의존을 방지하는 효과도 있습니다. ``` AuthController | AuthOrchestration / | \ UsersSerivce AuthService EmailSerivce ``` 이렇게 새로운 레이어를 추가하면 AuthService는 다른 모듈의 Service에서도 문제없이 사용할 수 있겠죠? 원하시던 바와 같이 Controller 레이어에서는 비지니스 로직이 사라지게 되었고, Service 레이어는 재사용성을 확보하게 되었습니다! 하지만 여전히 뭔가 찜찜하죠? "이거 애플리케이션 구조가 너무 복잡해 거 아니야? 이게 맞나??" 이런 생각이 드신다면 우리 다시 맨 처음에 하셨던 질문으로 돌아가봅시다. > "컨트롤러는 너무 커지지 않도록 하는 것이 좋다고 들었습니다. 이를 고려해서 코드를 짜다보니 한 서비스 내에 너무 많은 로직이 몰리는 듯한 느낌이 들었습니다. 여러 모듈로 분리하고 필요한 서비스를 하나의 서비스 내에 몰아서 사용하다보니 의존성이 커지고 재사용성이 없다고 느껴졌습니다." 여러 모듈로 분리하시기 전에는 NestJS 앱이 아마 아래와 같은 간단한 구조였을 거에요. 대신 AuthService의 register 메서드의 코드가 말씀하신 것처럼 좀 길었겠지요. ``` AuthController | AuthSerivce ``` 하지만 모듈을 분리했더니 이 메서드의 복잡도는 줄어들었을지 몰라도 결과적으로 애플리케이션이 전체 구조는 상당히 복잡해졌다는 것을 알 수 있습니다. 이 것은 소프트웨어 설계에서 오버 아키텍처(Over Architecture)할 때 나타나는 전형적인 부작용인데요. 지금 하시는 사이드 프로젝트가 실제로 얼마나 복잡한지는 모르겠지만 혼자 하시는 프로젝트라면 이 정도의 아키텍처는 득보다 실이 더 많을 수 있습니다. 무엇보다 개발 생산성이 크게 떨어질테니까요. 제 설명이 좀 장황했던 것 같은데요... 요점을 정리를 해보자면, 애플리케이션을 설계하실 때 SRP를 명목으로 모듈화를 지나치게 빨리 하지 않도록 주의하셔야 합니다. 현재 UsersService가 실제로 얼마나 많은 곳에서 사용되고 있는지 한번 살펴보세요. 보통 UsersService는 관리자 UI가 생기고 별도의 사용자 관리 API가 필요해질 때 모듈화를 해도 크게 늦지 않거든요. 사실 인증/접근 제어와 사용자 정보 관리는 매우 밀접하기 때문에 소규모 애플리케이션에서는 일부로 인증 모듈과 사용자 관리 모듈을 분리하지 않는 경우도 많습니다. 재사용성이라는 것은 실제로 해당 모듈이 여러 곳에서 필요할 때 의미가 있거든요. 지나치게 미리 확보해놓은 재사용성은 오히려 불필요한 오버헤드가 되는 경우가 더 많습니다. 마지막으로 말씀드리고 싶은 부분은 SRP에서 Single Responsibility, 즉 단일 역할이라는 것은 애플리케이션에서 정의하기 나름입니다. 예를 들어, 대규모 애플리케이션에서는 Email을 보내고, SMS를 보내고, 모바일 Push 알람을 보내는 것이 서로 다른 모듈이 될 수도 있지만 소규모 애플리케이션에서는 그냥 메세징(messsaging)을 담당하는 모듈로 퉁칠 수도 있습니다. 따라서 단일 역할이라는 말 자체에 너무 집착하시기 보다는 애플리케이션의 규모에 따라서 점진적으로 모듈화해 보시리고 조언드리고 싶습니다. 좀 어려울 수도 있는 내용이라서 나름 그림까지 그려서 설명을 드렸는데 아무쪼록 이해가 잘 되셨으면 좋겠네요 😅

고지완님의 프로필 사진

고지완

작성자

백엔드 취준2023년 07월 09일

진짜 정말 너무너무 감사드립니다.. 하나하나 예시를 들어주시고 가능한 대안들까지 모두 말씀해주시니 덕분에 너무 잘 이해되었습니다!!! 특히 오버 아키텍처로 인해 득보다 실이 더 많은 상태라는 말이 크게 와닿았습니다.. 한가지만 더 질문을 드리고 싶습니다!! 간단한 영화예매 Api를 구성 중이라 과한 모듈화는 필요없다는 것은 이해했습니다. 또한 현재 서비스에는 관리자 페이지가 필요하여 유저모듈을 분리해둔 상태이고 유저와 관련된 어떤 비즈니스 로직이 추가될지 몰라 모듈은 지금처럼 두는 것이 좋다고 생각이 들었습니다! 그렇다면 auth controller의 로직을 auth service에 몰아두고 auth service가 email/users service에 의존하도록 한 후 설명해주신 orchestration 레이어를 추가하여 재사용성을 늘리는 방안이 맞을 것 같습니다! 하지만 더 생각을 해보니 auth service는 단순 회원가입 및 로그인 시의 로직을 담당하기 위한 것이기에 재사용성에 큰 의미를 두어 레이어를 추가하는 것은 오버 아키텍처라고 생각이 들었습니다. 말씀드리지 않았지만 현재 db접근을 위한 레포지토리 레이어도 분리가 되어있어 이 이상의 레이어를 나누는 것은 생산성이 너무 떨어질 것 같아 실이 더 많은 것 같습니다! 그래서 정리하자면 현재 모듈은 최소한으로 분리된 상태이기에 재사용성을 위해서는 서비스에서 다른 레이어를 추가하도록 해야하지만 이미 레포지토리 레이어까지 분리되어 있어 재사용하지 않을 auth service를 위해 이를 구현하기엔 생산성이 너무 떨어지는 오버 아키텍처가 될듯합니다. 그래서 결국은 제일 처음에 auth service에 email/user service를 주입해주는 방식을 채택하게 되었습니다..! 괜찮은 선택이겠죠..??

달레님의 프로필 사진

달레

Apollo GraphQL Software Engineer2023년 07월 09일

오, 대박... 제 설명을 완벽하게 이해하신 것 같아요! 👍 네, 저도 동일한 이유로 auth service에 email/user service를 주입해주는 방식을 선택했을 것 같습니다. 추가로 말씀해주신 부분을 감안하면 이 구조가 가장 밸런스가 좋아 보이네요. 혹여 auth service가 현재 예상과 다르게 재사용할 일이 생기더라도 그 때 레이어를 추가해도 늦지 않을 테고요. 이제 하산하셔도 될 것 같습니다. 농담이고요 ㅋㅋ 그럼 설계 역량이 출중한 소프트웨어 개발자로 성장하기 바랍니다! 😁

고지완님의 프로필 사진

고지완

작성자

백엔드 취준2023년 07월 09일

너무너무너무 감사합니다!!! 좋은 하루 되세요!!

수민님의 프로필 사진

수민

Frontend Engineer2023년 07월 12일

와 답변 대박이네요.... 👍

커리어리 AI 봇님의 프로필 사진

안녕하세요. 제시하신 이슈에 대해서 의견을 공유해보겠습니다. 먼저 SRP(Single Responsibility Principle)는 각 클래스나 모듈이 하나의 책임만을 가져야 한다는 원칙을 말합니다. 이것이 반드시 하나의 메서드나 함수만을 가져야 한다는 의미는 아닙니다. 어떤 경우에는 서비스가 여러 개의 메서드를 가질 수 있지만, 그 메서드들은 모두 같은 '책임'을 갖게 됩니다. 보내주신 코드를 직접 보진 못했지만, 문제를 파악하기 위해선 서비스의 분리와 컨트롤러의 역할을 재정의하는 것이 필요해 보입니다. 제가 이해한 바로는 `authService`, `usersService`, `emailService` 세 가지의 서비스를 가지고 있으시다고 하셨습니다. 이 경우 한 가지 방법으로는 `authService`에서 `usersService`와 `emailService`를 주입받아 사용하는 것입니다. 그렇게 하면 각 서비스의 역할이 분명해지고, 컨트롤러에서의 로직 부담이 줄어들게 됩니다. 이 방법은 서비스 간에 상호 작용이 필요한 경우에 특히 유용합니다. 예를 들어 `authService.register()` 메소드는 다음과 같이 변경될 수 있습니다. 이렇게 구현하면, 컨트롤러에서는 단순히 요청을 받아 `authService.register()`를 호출하고, 응답을 반환하는 것만을 담당하게 됩니다. 이 방식은 SRP를 준수하면서도 서비스의 재사용성을 유지하고, 코드의 가독성을 높여줍니다. 다만 이러한 방식은 순환 의존성이 생길 수 있다는 점을 주의해야 합니다. 이를 해결하기 위해서는 서비스를 추가적으로 분리하거나, 인터페이스를 활용하는 등의 방법을 고려해보세요. 이렇게 코드를 변경하면 컨트롤러의 로직이 많이 간소화되고, 각각의 서비스도 자신의 역할에 충실하게 될 것입니다. 이 방식이 답이라고 할 수는 없지만, 하나의 해결 방안으로서 참고해보시면 좋을 것 같습니다.

1async register(userDto: CreateUserDto) {
2    const { email, name, phoneNumber } = userDto;
3
4    // 1. 유저가 존재하는지 확인
5    const isUserExists = await this.usersService.isUserExists(email, name, phoneNumber);
6    if (isUserExists) throw new Error('User already exists');
7
8    // 2. 유저 정보를 DB에 저장
9    const newUser = await this.usersService.createUser(userDto);
10
11    // 3. 인증 토큰 생성 후 DB에 저장
12    const token = await this.generateToken(newUser.id);
13
14    // 4. 이메일 발송
15    await this.emailService.sendEmail(email, token);
16
17    return newUser;
18}

커리어리 AI 봇의 답변을 평가해 주세요!

지금 가입하면 모든 질문의 답변을 볼 수 있어요!

현직자들의 명쾌한 답변을 얻을 수 있어요.

또는

이미 회원이신가요?

목록으로
키워드로 질문 모아보기

실무, 커리어 고민이 있다면

새로운 질문 올리기

지금 가입하면 모든 질문의 답변을 볼 수 있어요!