프로젝트 회고

part2 중급 프로젝트 개별 보고서

sgyeon 2025. 5. 13. 09:41
반응형
SMALL

 

1. 프로젝트 개요

  • 공통 목적
    각 기능 별 로직 및 코드 이해, 실력 향상
  • 핵심 기능
    유저 인증·인가, 차량 및 고객 등 ENUM을 활용한 CRUD, 대용량 업로드,
    등록된 차량 및 고객을 활용한 계약 및 상태 구현, 대시보드

 

2. 담당한 작업

고객

  • 컨트롤러 설계 및 구현
    Express와 인증 미들웨어(verifyAccessToken.ts)를 활용해 고객 CRUD, 검색, 상세조회, 대용량업로드 작성
  • DTO 정의 및 입력 변환 작성
    Create/UpdateCustomerDTO 인터페이스를 선언, 입력값을 Prisma enum으로 바꿔주는 convertInput 유틸 구현
  • Enum 변환 로직
    한글·영문 라벨을 성별(Gender), 연령대(AgeGroup), 지역(Region) enum으로 매핑하는 toEnum 함수를 작성
  • 데이터 매퍼 구현
    DB에서 가져온 Customer 객체를 소문자·null 처리 및 한글 라벨 필드가 포함된 응답 형태로 변환하는
    toLabeledCustomer 작성
  • 서비스 레이어 작성
    존재 검사, 예외 처리와 CSV 파싱·대용량 등록 기능을 담당하며, Prisma 트랜잭션 및 예외 쓰로우 구현
  • 레포지토리 레이어 구축
    Prisma Client를 통해 고객 CRUD, 검색 메서드와 페이징·트랜잭션 처리를 담당하는 함수들을 구현했습니다.

대시보드

  • 컨트롤러 구현
    getDashboardStatsHandler에서 인증된 companyId를 뽑아 dashboardService.getDashboardStats를 호출
  • 서비스 집계 로직 작성
    Prisma의 aggregate와 groupBy를 이용해 이달·지난달 매출 합계, 진행 중·완료된 계약 건수를 병렬 조회
  • 차종별 매핑 및 그룹핑
    car 테이블에서 모델 타입을 조회해, 계약·매출 데이터를 carId 기준으로 차종별 카운트·매출 합계로 재분류
  • 성장률 계산
    이번 달 매출과 지난 달 매출을 비교해 백분율로 성장률을 산출, 과거 대비 증감치 확인
  • 응답 데이터 포맷팅
    내부 객체(contractsByCarType, salesByCarType)를 { carType, count } 배열 형태로 변환(formatGroup)하여 클라이언트가 바로 사용할 수 있게 정리

 

3. 기술적 성과

기술 스택

  • 언어
    TypeScript
  • 데이터베이스·ORM
    PostgreSQL, Prisma ORM
  • 인증·인가
    JWT, express-jwt 미들웨어, Redis
  • 파일 처리
    multer, csv-parse, csv-parser
  • 입력 검증·타입 안정성
    Superstruct, TypeScript DTO & Enum 매퍼
  • 에러 핸들링
    커스텀 에러 클래스(BadRequest, NotFound, Unauthorized 등), 글로벌 에러 미들웨어
  • 배포·테스트
    Render.com 배포, Postman 테스트 컬렉션, RestClient

 

4. 문제점 및 해결 과정

  • DB 히트 감소
더보기
  • customerService.ts
import { PrismaClient, Prisma } from '@prisma/client';

export const prisma = new PrismaClient();
 
 [...]

 

기존 이런 식으로 코드를 작성하였으나 위와 같은 방식을 사용하면 부하가 증가한다고 하여 아래와 같이 수정을 해보았다.

 

  • customerService.ts
import { Prisma } from '@prisma/client';
import { prisma } from '../lib/prisma';

export const createCustomer = (companyId: number, data: CreateCustomerDTO) => {
  return customerRepo.createCustomer(companyId, data as Prisma.CustomerUncheckedCreateInput);
};

 [ ... ]
 
  return customerRepo.updateCustomer(
    customerId,
    companyId,
    data as Prisma.CustomerUncheckedUpdateManyInput,
  );
};

 

 

import { PrismaClient } from '@prisma/client';
import { prisma } from '../lib/prisma';
export const prisma = new PrismaClient();

위와 같이 하지 않는 이유

export const prisma = new PrismaClient();로 같은 이름으로 다시 선언하려 해서 충돌 발생

 

 

또한 기존 컨트롤러 내에 삭제에 해당하는 부분에서 

 if (result.count === 0) {
    throw new NotFoundError('존재하지 않는 고객입니다');
  }

  res.status(200).json({ message: '고객 삭제 성공' });
};

 

해당 부분이 서비스에 들어가는게 더 적합한것으로 판단,

 

따라서 아래 내용을

 

  • customerController.ts
export const deleteCustomerHandler = async (req: Request, res: Response) => {
  const companyId = (req.user as { companyId: number }).companyId;
  const customerId = Number(req.params.id);
  const result = await customerService.deleteCustomer(customerId, companyId);

  if (result.count === 0) {
    throw new NotFoundError('존재하지 않는 고객입니다');
  }

  res.status(200).json({ message: '고객 삭제 성공' });
};

 

  • customerService.ts
export const deleteCustomer = async (customerId: number, companyId: number) => {
  return prisma.customer.deleteMany({
    where: {
      id: customerId,
      companyId,
    },
  });
 
};

 

 

아래와 같이 수정

(컨트롤러에 있던 내용을 서비스로 이동)

  • customerController.ts
export const deleteCustomerHandler = async (req: Request, res: Response) => {
  const companyId = (req.user as { companyId: number }).companyId;
  const customerId = Number(req.params.id);
  const result = await customerService.deleteCustomer(customerId, companyId);

  await customerService.deleteCustomer(customerId, companyId);

  res.status(200).json({ message: '고객 삭제 성공' });
};

 

  • customerService.ts
export const deleteCustomer = async (customerId: number, companyId: number) => {
  const result = await prisma.customer.deleteMany({
    where: {
      id: customerId,
      companyId,
    },
  });
  if (result.count === 0) {
    throw new NotFoundError('고객을 찾을 수 없습니다.');
  }

  return result;
};
  • 대용량 업데이트 버그 수정
더보기

업로드는 한 것 같은데 DB에 내용이 적용이 안되는 것을 식별함

  • customerController.ts
    1. toLowerCaseCustomer 함수에서 gender의 기본값을 null → ' ' 빈 문자열로 변경
    2. bulkUploadCustomer 핸들러가 버퍼(buffer) 대신 req.file.path를 서비스로 넘기도록 수정
      • 기존: if (!fileBuffer) …, bulkUploadCustomers(companyId, fileBuffer)
      • 변경: if (!req.file?.path) …, bulkUploadCustomers(companyId, req.file.path)

 


수정 중 필수로 들어가는 값인 gender에 null이 사용 된 것을 식별

따라서 위에서 설명한 것 처럼 빈 문자열을 주는 것으로 대체

 

  • customerService.ts
    1. bulkUploadCustomers 시그니처를 buffer: Buffer → filePath: string으로 변경
    2. 내부에서 fs.readFileSync(filePath, 'utf-8')으로 파일을 읽고, BOM(\uFEFF) 제거 후 파싱
    3. CSV 레코드를 맵핑한 뒤 name·gender 필터링 로직 추가해 빈 값은 자동 스킵
    4. createMany 실행 결과(result.count)를 활용해 “성공적으로 N명의 고객을 업로드했습니다”라는 메시지 반환

이 변경으로 버퍼가 비어 있을 때 발생하던 오류를 피할 수 있고, 파일 경로 기반 처리를 통해 Multer 업로드 안정성을 높였습니다. 또한, 업로드한 고객 수를 결과 메시지에 반영해 테스트 과정에서 정상적으로 업데이트 되었는지 확인했습니다.

  • 고객 생성 버그 수정
더보기

 

  • customerController.ts 리팩토링
    1. 요청 바디에서 gender, ageGroup, region 레이블을 분리해 toGenderEnum, toAgeGroupEnum, toRegionEnum 유틸로 Prisma enum 변환 로직을 이동
    2. 응답용 매핑 함수 toLowerCaseCustomer를 외부 customerMapper 모듈로 분리·import

 

  • customerEnumConverter.ts 신규 추가
    • 한국어(남성, 여성, 10대, 20대 등등 …) 및 영어 레이블을 Prisma enum(Gender, AgeGroup, Region)으로 변환하는 toGenderEnum, toAgeGroupEnum, toRegionEnum 함수 정의
import { Gender, AgeGroup, Region } from '@prisma/client';

export function toGenderEnum(label: string): Gender {
  if (!label) throw new Error('성별 값이 없습니다.');
  if (label === '남성') return 'MALE';
  if (label === '여성') return 'FEMALE';

  const value = label.trim().toUpperCase();
  if (value === 'MALE' || value === 'FEMALE') return value as Gender;

  throw new Error(`잘못된 성별 값: ${label}`);
}

export function toAgeGroupEnum(label?: string): AgeGroup {
 

  [...]
 

  if (label in korToEnum) return korToEnum[label];

  const value = label.trim().toUpperCase();
  const enumValues = Object.values(korToEnum);
  if (enumValues.includes(value as Region)) return value as Region;

  throw new Error(`잘못된 지역 값: ${label}`);
}
  • customerMapper.ts 신규 추가
    • DB에서 조회한 Customer 객체의 문자열 필드(gender, ageGroup, region)를 소문자(toLowerCase())로 변환해 반환하는 toLowerCaseCustomer 함수
import { AgeGroup, Customer, Gender, Region } from '@prisma/client';
import { genderToLabel, ageGroupToLabel, regionToLabel } from '../../../types/customerType';

export function toLabeledCustomer(customer: Customer | any) {
  return {
    ...customer,
    gender: customer.gender?.toLowerCase() || '',
    ageGroup: customer.ageGroup?.toLowerCase() ?? null,
    region: customer.region?.toLowerCase() ?? null,
    genderToLabel: customer.gender
      ? genderToLabel[customer.gender.toUpperCase() as Gender] || ''
      : '',
    ageGroupToLabel: customer.ageGroup
      ? ageGroupToLabel[customer.ageGroup.toUpperCase() as AgeGroup] || ''
      : '',
    regionToLabel: customer.region
      ? regionToLabel[customer.region.toUpperCase() as Region] || ''
      : '',
  };
}
  • customerService.ts 단순화
    1. 컨트롤러로 역할이 이동된 변환 로직(유틸 호출·CSV 파싱 등)을 제거
    2. CreateCustomerDTO를 그대로 받아 Prisma.CustomerUncheckedCreateInput으로 매핑하는 책임만 수행하도록 축소

서비스 밑에 있던 내용 EnumConverter로 옮김

 

  • customerType.ts 정리
    • 기존 변환 함수들을 정리·이관하고, 응답용 타입 CustomerForResponse 정의를 유지·정비
import { Gender, AgeGroup, Region } from '@prisma/client';

 [...]

export type CustomerForResponse = {
  id: number;
  companyId: number;
  name: string;
  gender: Gender | null;
  phoneNumber: string;
  ageGroup: AgeGroup | null;
  region: Region | null;
  email: string;
  memo: string | null;
  contractCount: number;
};

 

  • 프론트와 네이밍 불일치 문제 해결
더보기
  • 주요사항
    • 아래 첨부 이미지와 같이 성별, 연령대, 지역이 영어로 출력 되던 것을 한글로 출력되도록 수정
  • 프론트 수정 내용

 

1. 네이밍

const processedRecord = { ...record, gender: 
CUSTOMER_GENDER_MAP[record.gender], ageGroup: record.ageGroup || '-',
region: record.region || '-', }

 

위 내용을 아래와 같이 수정

const processedRecord = { ...record, gender: record.genderToLabel || "-",
ageGroup: record.ageGroupToLabel || "-", region: record.regionToLabel || "-", 
};

 

2. 임포트 밑부분 내용 수정 타입 지정

type CustomersInfoTableProps = {   data: CustomerType[] }

 

위 내용을 아래와 같이 수정

type CustomerWithLabel = CustomerType & {
genderToLabel?: string;
ageGroupToLabel?: string | null;
regionToLabel?: string | null;
};

type CustomersInfoTableProps = {
data: CustomerWithLabel[];
};

 

  • 프론트 관련 수정 이미지 첨부

1. 기존

1. 수정

 

2. 기존

2. 수정

  • 고객 수정이 안되던 버그 수정
더보기
  • 문제 요인
    1. 프론트에서 gender, ageGroup, region 값을 한글("남성", "30대", "서울")로 보냄
    2. 백엔드는 이 값을 enum 타입인 영어 MALE, THIRTIES, SEOUL 로 변환하여 DB에 저장
    3. 하지만 이미 저장된 데이터를 수정할 때, 프론트에서 함께 보내는 genderToLabel, ageGroupToLabel 등의 표시용 필드가 Prisma.customer.update에 포함되어 스키마에 없는 필드 오류 발생
  • 해결 방안
    1. updateCustomer에서 genderToLabel 등의 비DB 필드들을 제거
    2. gender, ageGroup, region에 대해서는 한글이면 영어 enum으로 변환한 후 저장
    3. 불필요한 필드를 제거하고 변환처리 추가

수정 이미지 첨부

 ...

 

5. 협업 및 피드백

더보기

아래 첨부 이미지처럼 PR리뷰를 주로 진행하고, 풀리지 않는 문제나 코드를 작성하던 중 떠오르는 궁금증은 즉시 팀원과 공유하거나 의논하여 토의 및 검색을 통한 문제 해결을 진행하였습니다.

 

  • 첨부 이미지

 

6. 코드 품질 및 최적화

더보기

customerController.ts

  • 기존 형태
export const createCustomer = async (req: AuthenticatedRequest, res: Response) => {
  const companyId = (req.user as { companyId: number }).companyId;

  const { gender, ageGroup, region, ...rest } = req.body;

  const converted = {
    ...rest,
    ...(gender && { gender: toGenderEnum(gender) }),
    ...(ageGroup && { ageGroup: toAgeGroupEnum(ageGroup) }),
    ...(region && { region: toRegionEnum(region) }),
  };

  [...]
};

export const updateCustomer = async (req: AuthenticatedRequest, res: Response) => {
  const companyId = (req.user as { companyId: number }).companyId;
  const customerId = Number(req.params.id);

  const { gender, ageGroup, region, ...rest } = req.body;

  const convertedData = {
    ...rest,
    gender: gender ? toGenderEnum(gender) : undefined,
    ageGroup: ageGroup ? toAgeGroupEnum(ageGroup) : undefined,
    region: region ? toRegionEnum(region) : undefined,
  };

  [...]
};

위와 같이 toGenderEnum, toAgeGroupEnum, toRegionEnum 을 매번 컨트롤러에서 호출 이를 중복이라고 판단. 가독성과 의도 파악이 어려움.

따라서 유틸로 통합하고 반복을 제거 할 것.

customerInputConverter.ts 생성

  • customerInputConverter.ts로 중복 내용 분리 (중복 제거 - 핵심)
import { CreateCustomerDTO, UpdateCustomerDTO } from '../../../dto/customer.dto';
import { toGenderEnum, toAgeGroupEnum, toRegionEnum } from './customerEnumConverter';

export function convertCreateCustomerInput(input: unknown): CreateCustomerDTO {
  const { name, gender, phoneNumber, ageGroup, region, email, memo } = input as Record<
    string,
    string
  >;
  return {
    name,
    gender: toGenderEnum(gender),
    phoneNumber,
    ageGroup: ageGroup ? toAgeGroupEnum(ageGroup) : undefined,
    region: region ? toRegionEnum(region) : undefined,
    email,
    memo,
  };
}

export function convertUpdateCustomerInput(input: unknown): UpdateCustomerDTO {
  const { name, gender, phoneNumber, ageGroup, region, email, memo } = input as Record<
    string,
    string
  >;
  return {
    name,
    gender: toGenderEnum(gender),
    phoneNumber,
    ageGroup: ageGroup ? toAgeGroupEnum(ageGroup) : undefined,
    region: region ? toRegionEnum(region) : undefined,
    email,
    memo,
  };
}

  • gender, ageGroup, region을 함수로 추출해 유지 보수를 쉽게 하기 위함
  • convertCreateCustomerInput라는 함수 명을 통해 ‘이 함수는 고객 등록할 때 입력 값을 변환하는 용도다’ 라는 목적을 명시 (의도 명확화)
  • 컨트롤러에서의 변환 로직이 분리되면서 핵심 흐름만 드러나게 함. (가독성)
  • 수정된 형태
export const createCustomer = async (req: AuthenticatedRequest, res: Response) => {
  const companyId = (req.user as { companyId: number }).companyId;

  const converted = convertCreateCustomerInput(req.body);

  [...]
};

export const updateCustomer = async (req: AuthenticatedRequest, res: Response) => {
  const companyId = (req.user as { companyId: number }).companyId;
  const customerId = Number(req.params.id);

  const converted = convertUpdateCustomerInput(req.body);

  const updated = await customerService.updateCustomer(customerId, companyId, converted);

  [...]

  res.status(200).json(toLabeledCustomer(updated));
};

중간 점검✔ : 1차 수정 후 정상 작동 확인

customerService.ts

  • 기존 형태 ( 생성 부분 )
export const createCustomer = (companyId: number, data: CreateCustomerDTO) => {
  const prismaData: Prisma.CustomerUncheckedCreateInput = {
    ...data,
    companyId,
    gender: toGenderEnum(data.gender),
    ageGroup: toAgeGroupEnum(data.ageGroup),
    region: toRegionEnum(data.region),
  };

  return customerRepo.createCustomer(companyId, prismaData);
};

위 코드에서는 enum 변환 로직을 제거 함

이미 컨트롤러에서 처리하고 있기 때문

따라서 아래와 같이 수정

  • 수정된 형태 ( 생성 부분 )
export const createCustomer = (companyId: number, data: CreateCustomerDTO) => {
  const prismaData: Prisma.CustomerUncheckedCreateInput = {
    ...data,
    companyId,
  };

  return customerRepo.createCustomer(companyId, prismaData);
};
  • 기존 형태 ( 수정 부분 )
export const updateCustomer = async (
  customerId: number,
  companyId: number,
  data: UpdateCustomerDTO,
) => {
  const customer = await customerRepo.getCustomerById(customerId, companyId);
  if (!customer) throw new NotFoundError('고객을 찾을 수 없습니다.');

  const { genderToLabel, ageGroupToLabel, regionToLabel, ...cleanData } = data as any;

  const converted: Prisma.CustomerUncheckedUpdateManyInput = {
    ...cleanData,
    ...(cleanData.gender && { gender: toGenderEnum(cleanData.gender) }),
    ...(cleanData.ageGroup && { ageGroup: toAgeGroupEnum(cleanData.ageGroup) }),
    ...(cleanData.region && { region: toRegionEnum(cleanData.region) }),
  };

  return customerRepo.updateCustomer(customerId, companyId, converted);
};

고객 정보 수정에서도 마찬가지로 enum 변환 로직을 제거 함

사유는 동일함

  • 수정된 형태 ( 수정 부분 )
export const updateCustomer = async (
  customerId: number,
  companyId: number,
  data: UpdateCustomerDTO,
) => {
  const customer = await customerRepo.getCustomerById(customerId, companyId);
  if (!customer) throw new NotFoundError('고객을 찾을 수 없습니다.');

  const { genderToLabel, ageGroupToLabel, regionToLabel, ...cleanData } = data as any;

  const converted: Prisma.CustomerUncheckedUpdateManyInput = {
    ...cleanData,
  };

  return customerRepo.updateCustomer(customerId, companyId, converted);
};

중간 점검✔ : 2차 수정 후 정상 작동 확인

0509 04:45 최종 점검✔: 정상 작동 확인

핵심 목표

  • 중복 제거
  • 의도 명확화
  • 가독성

 

7. 향후 개선 사항 및 제안

당장 떠오르는 것들 ..

  • any 완전히 없애기, null 극 최소화
  • 트랜잭션 처리 더 추가해보기
  • 검증강화
    • jwt 토큰 블랙아웃등 로그아웃 처리나 refreshToken rotation을 통한 정보 탈취 방지
반응형
LIST

'프로젝트 회고' 카테고리의 다른 글

1차 팀프로젝트 Style part 개별 보고서  (1) 2025.03.21