최근 인프랩 면접에서 얻게 된 코드 리뷰를 통해 현업에서 사용하고 있는 코드들에 대해 되짚어 보고 있다.
값진 경험을 통해 메타인지를 할 수 있게 되었고, 오답이라고 생각하는 부분에 대한 리팩토링을 진행하고 있다.
특히 이번에는 면접 당시에 서비스 레이어와 인프라(DB) 레이어 사이의 의존성에 대해 피드백을 들었던 부분을 바탕으로 실제 코드를 개선하고 그 과정에서 꼬리에 꼬리를 무는 의문들을 정리해보는 시간을 가지고자 한다.
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
findAll(): Promise<User[]> {
return this.usersRepository.find();
}
findOne(id: number): Promise<User | null> {
return this.usersRepository.findOneBy({ id });
}
async remove(id: number): Promise<void> {
await this.usersRepository.delete(id);
}
}
위 코드는 NestJS 공식문서의 Database 섹션의 예제 코드이다.
이런 예제를 바탕으로, 현업에서의 유저 정보를 가져오는 코드 또한 아래처럼 작성되어 있었고, 그대로 사용중이었다.
@Injectable()
export class WeddingUserService {
constructor(
@InjectRepository(WeddingUser)
private readonly wUserRepository: Repository<WeddingUser>,
@InjectRepository(UserExtra)
private readonly userExtraRepository: Repository<UserExtra>,
) {}
async me(userId: string): Promise<UserInfoDto> {
const wUser: WeddingUser | null = await this.findOne({ where: { id: 'test1' }, relations: ['UserExtra'] });
//custom exception
if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
return new UserInfoDto.create(wUser);
}
}
많은 기술 블로그들이나 Nest 사용자들이 서사하고 있지만, 서비스 레이어에서 직접적으로 DB에 의존적인 이 예제 코드들은 비즈니스만을 담당해야하는 서비스 레이어의 책임에 위배되고 과한 책임과 ORM에 대한 직접적인 의존을 하게 된다.
예를 들어, TypeORM에서 Prisma로 교체해야 할 때, 서비스 레이어에서 데이터베이스 접근도 직접적으로 수행하고 있기 때문에, 서비스의 데이터베이스 엑세스 코드를 모두 바꿔주어야한다.
@Injectable()
export class WeddingUserService {
constructor(private readonly prisma: PrismaService) {}
async me(userId: string): Promise<UserInfoDto> {
const wUser: WeddingUser | null = await this.prisma
.weddingUser
.findUnique({
where: { id: 'test1' },
include: { UserExtra: true },
});
//custom exception
if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
return new UserInfoDto.create(wUser);
}
}
그래서 보통 DB Access 레이어를 구현하여 직접적인 책임을 없애고 DB(ORM)에 대한 의존을 낮춘다.
@Injectable()
export class WeddingUserRepository extends Repository<WeddingUser> {
constructor(private dataSource: DataSource) {
super(WeddingUser, dataSource.createEntityManager());
}
async findOneByWedId(webId: string): Promise<WeddingUser | null> {
return await this.findOne({ where: { id: 'test1' }, relations: ['UserExtra'] });
}
}
@Injectable()
export class WeddingUserService {
constructor(
private readonly wUserRepository: WeddingUserRepository,
) {}
async me(userId: string): Promise<UserInfoDto> {
const wUser: WeddingUser | null = await this.wUserRepository.findOneByWebId(userId);
//custom exception
if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
return new UserInfoDto.create(wUser);
}
}
더 의존적인 부분을 느슨하게 하기 위해서 레포지토리의 인터페이스를 도입했다.
export interface IWeddingUserRepository {
findOneOrThrowByWedId(webId: string): Promise<WeddingUser>;
}
@Injectable()
export class WeddingUserRepository
extends Repository<WeddingUser>
implements IweddingUserRepository {
constructor(private dataSource: DataSource) {
super(WeddingUser, dataSource.createEntityManager());
}
async findOneByWedId(webId: string): Promise<WeddingUser | null> {
return await this.findOne({ where: { id: 'test1' }, relations: ['UserExtra'] });
}
}
@Injectable()
export class WeddingUserService {
constructor(
private readonly wUserRepository: IWeddingUserRepository,
) {}
async me(userId: string): Promise<UserInfoDto> {
const wUser: WeddingUser | null = await this.wUserRepository.findOneByWebId(userId);
//custom exception
if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
return new UserInfoDto.create(wUser);
}
}
최근 면접에서 심도있게 답변을 하지 못했던,
단순히 서비스 레이어는 레포지토리의 인터페이스에만 의존하고 있으므로 레포지토리 자체의 구현의 변경에 영향을 받지 않도록 설계했습니다. 를 넘어선 무언가에 대해 생각을 확장해보아야 했다.
데이터를 Fetch하는 라이브러리의 구현 메서드 자체는 변할지 모르지만, 서비스에서 호출되는 레포지토리의 함수명 자체가 변하지 않는 이상 추상화 하는 것이 정말 의미가 있을까? 또한 해당 인터페이스를 여러 저장소에서 가져온다거나 하는 상황이 실제로 발생할까?
> 현재 서비스는 실제로 서비스 내부의 고객 데이터를 외부에서 사용하는 경우는 있을 수 있지만, 외부의 데이터를 고객 데이터와 합쳐서 처리해야 하는 경우는 없을 것이다.
레포지토리가 의존하고 있는 무언가가 변경되더라도 테스트 작성에 더 용이할까?
> 인터페이스에 의존한 Mock 객체를 사용하게 되면, 실제 데이터베이스에 접근하지 않고 단위 테스트를 수행할 수 있지만 데이터 I/O가 없는 단위 테스트에 의미가 있을 뿐, 실제 저장소 연결을 필요로 하는 통합 테스트 등에는 이점이 없는 것 같다. 단위 테스트는 현재 Fixture를 통해 충분히 관리되고 있다.
좀 더 클린한 코드를 작성하기 위한 리팩토링 중에 레이어의 인터페이스 사용은 위와 같은 판단의 근거들을 통해 사용하지 않기로 결정했다.
(관련해서 피드백을 해주신다면 소정의 성의를 돌려드리겠습니다!!! 많관부)
정답은 없어도 오답은 있다는 말처럼, 면접 간에 내가 선택하고 작성한 코드에 대해 명확하고 자신감 있게 판단의 근거를 제시하지 못해 아쉬었다. 물론 기술 면접은 통과했었지만, 내면에 정의된 기술적 지식이 조금 더 있었다면 보다 나은 코드 리뷰를 할 수 있지 않았을까. 하는 아쉬움이 남는다.
최근 조영호 님의 오브젝트 책을 구매했다. 객체 지향 쪽에서의 조금 더 나은 구조와 코드 설계를 위해 학습하고 있다.
이런 학습들을 하다보면, 기술 선택 뿐 아니라 내가 작성해나가는 코드 한 줄 한 줄에 어떤 판단의 근거를 가지고 작성했는지 조금 더 명확하게 말할 수 있지 않을까. 이런 것들이 쌓이다보면 개발자로서의 중간 목표인 오프라인 세션에서 나의 지식과 경험을 공유하는 사람. 으로 한 발 더 다가갈 수 있으리라 믿는다.
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!