배포 주소
프론트: https://5-favmoving-team2-fe.vercel.app/
백엔드: https://thehun-ideas-operates-invisible.trycloudflare.com/
개요
이사 시장에서는 무분별한 가격 책정과 무책임한 서비스 등으로 인해 정보의 투명성 및 신뢰도가 낮은 문제가 존재합니다. 이러한 문제를 해결하기 위해, 소비자가 원하는 서비스와 주거 정보를 입력하면 이사 전문가들이 견적을 제공하고 사용자가 이를 바탕으로 이사 전문가를 선정할 수 있는 매칭 서비스를 제작합니다. 이를 통해 소비자는 견적과 이사 전문가의 이전 고객들로부터의 후기를 확인하며 신뢰할 수 있는 전문가를 선택할 수 있고, 소비자와 이사 전문가 간의 간편한 매칭이 가능합니다.
일정
2025.05.15 ~ 2025.07.12
프로젝트 팀 구성
제가 맡은 부분은 백엔드였고 팀 구성은 아래와 같습니다.
백엔드 - 3명, 프론트엔드 - 2명
기술 스택
백엔드
- Nest.js
- TypeORM
- PostgreSQL
- TypeScript
- AWS
- Render
프론트엔드
- Next.js App Router
- TypeScript
- MUI
아키텍처
여기에 시스템 다이어그램 혹은 플로우 차트 사용할 예정
Swagger 문서

프론트 작업 시 참고하기 위한 swagger 문서 작업을 진행했습니다.
기능 설명
[메인 화면]

처음 랜딩페이지 화면입니다. 로그인 하지 않은 사용자도 접근할 수 있습니다.

비로그인 사용자도 기사님 리스트를 확인할 수 있습니다. 각 리스트를 클릭하면 기사님 상세 페이지로 넘어가게 됩니다.
커서 기반 무한스크롤이 구현되어 있으며, 필터링과 정렬 조건도 query Builder를 사용하여 구현했으며, 데이터 정합성이 최소화 되도록 커서 값을 정렬 기준 커서, idNum 기준 커서로 두고 분기처리를 했습니다.


로그인은 소셜 로그인과 일반 로그인 둘 다 가능합니다.
(실제 서비스가 아니기 때문에 일부 OAuth 로그인은 안될 수 있습니다.)
로그인이 완료되면, 프로필 등록 페이지로 넘어가고 손님은 프로필 등록을 진행하지 않으면 손님 전용 기능을 사용할 수 없습니다.

기사님 상세 페이지입니다. 찜하기와 지정 견적 요청은 로그인 이후에 가능합니다.



이사 지역을 선택하고, 이사 예정일을 선택하고 이사 형태를 선택하면 최종적으로 손님의 견적이 작성이 됩니다.



기사님 계정으로 견적 리스트를 확인할 수 있고 견적을 보내거나 만약 지정 요청일 경우 반려가 가능합니다.
견적을 보내고나면 보낸 견적 조회에서 확인하실 수 있습니다.



손님은 대기 중인 견적에서 견적 요청을 확정할 수 있고 확정하고 나면 받았던 견적에서 확인할 수 있습니다. (알림)



이제 이사날짜가 지나면(자정 마다) 크론탭을 이용하여 자동으로 이사 완료처리가 되며 알림이 도착합니다.
그러면 손님은 기사님에게 리뷰를 작성할 수 있습니다. 작성한 리뷰는 "내가 작성한 리뷰" 페이지에서 확인할 수 있습니다.


그러면 기사님 상세 페이지에서 평점 및 각 점수 별 데이터 개수를 확인할 수 있으며, 아래에는 페이지네이션이 적용된 리뷰 리스트를 확인할 수 있습니다. 또한 이 평점 및 리뷰 개수 데이터는 다른 기사 리스트 조회 등에서도 조회할 수 있습니다.
문제 해결 과정
1. Render 배포 시 메모리 초과 문제 해결
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
원인:
- 빌드 없이 ts-node나 tsconfig-paths/register를 런타임에 사용하는 구조였다면,
- TypeScript를 실시간으로 해석하면서 많은 메모리를 사용
해결책:
- 실행을 ts-node(개발 환경)가 아닌 js(배포 환경)로 하여 메모리 적게 사용하도록 설정.
2. OAuth 접속 경로(uri)에서 특정 파람 값 가져오지 못하는 문제 해결
원인:
//auth.controller.ts
@Get("google/:role/login")
@UseGuards(AuthGuard("google"))
googleLogin() {
}
일반적으로 위 처럼 처음 접속하는 컨트롤러를 구현하고나서 가드로 넘어가게 된다.
그러나, 그렇게 되면 googleLogin 함수 내부의 로직은 무시되고 바로 넘어가게 되기 때문에
Param값을 제대로 가져오지 못한다. (AuthGuard가 내부 로직 무시)
예상 해결책:
- session 정보 저장 후 가져오기
- 세션을 추가적으로 관리하는 것이 과하다고 판단하여 선택하지 않음.
- redirect URL 직접 설정하여 쿼리에 state값 저장 해놓고 나중에 strategy에서 넘겨 받는 방법.
- 별도 세션 설정없이 가능해서 편리하지만, AuthGuard를 우회해서 사용해야 하고 별도로 보안 작업을 하지 않으면 위험할 수 있다.
최종 선택 해결책:
직접 URL 설정하여 나중에 넘겨받는 방법 채택.
//컨트롤러
@Get("google/:role/login")
async setRoleAndRedirect(@Param("role") role: string, @Res() res: Response) {
const state = encodeURIComponent(JSON.stringify({ role }));
const clientId = this.configService.get("GOOGLE_CLIENT_ID");
const redirectUri = this.configService.get("GOOGLE_REDIRECT_URI");
const redirectUrl =
"https://accounts.google.com/o/oauth2/v2/auth" +
`?client_id=${clientId}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=email profile` +
`&state=${state}`;
return res.redirect(redirectUrl);
}
//strategy
const state = req.query.state;
if (typeof state !== "string") {
throw new UnauthorizedException("역할 정보가 올바르지 않습니다.");
}
const decoded = decodeURIComponent(state);
const parsed = JSON.parse(decoded);
const role = parsed.role;
향후 리팩토링 시 고려해볼만한 방법
- Custom Guard
- Service Abstraction
고민했던 부분
Promise.all()과 트랜잭션 둘 중에 뭘 선택해야할까?
async postAssignMover(
userId: string,
userType: string,
moverId: string,
): Promise<AssignMover> {
// userType이 mover라면 에러
if (userType === "mover") {
throw new UnauthorizedException(
"기사 계정으로는 지정 기사 요청을 할 수 없습니다.",
);
}
// userId에 해당하는 quotation을 찾아본다
const quotation = await this.quotationRepository.findOne({
where: {
customerId: userId,
status: "PENDING", // 아직 진행중인 견적일 때
},
});
if (!quotation) {
throw new NotFoundException("견적 요청을 먼저 진행해주세요.");
}
const isExistsAssign = await this.assignMoverRepository.exists({
where: {
moverId,
customerId: userId,
},
});
if (isExistsAssign) {
throw new BadRequestException("이미 지정한 기사입니다.");
}
const prev = quotation.assignMover ?? [];
const updatedAssignMovers = Array.from(new Set([...prev, moverId]));
await this.quotationRepository.update(
{ id: quotation.id },
{ assignMover: updatedAssignMovers },
);
// 찾은 quotationId, moverId 바탕으로 assignMover 생성
const newAssignMover = this.assignMoverRepository.create({
moverId,
quotationId: quotation?.id,
customerId: userId,
status: "PENDING",
});
await this.assignMoverRepository.save(newAssignMover);
return newAssignMover;
}
위는 기존코드, 잘 작동하지만 몇 가지 예상되는 문제점이 존재했음.
- 데이터 무결성 보장이 어려움.
- 여러 번의 DB 조작이 들어가서 성능 상 문제 발생할 가능성이 높았음.
하지만 둘 다 만족하는 방법은 떠오르지 않았고 둘 중에 하나를 선택하기로 했음.
예상되는 해결책
- 트랜잭션을 사용한다.
- 데이터 무결성을 보장할 수 있다.
- 성능 상 저하가 생길 수 있다.
- Promise.all()을 사용한다.
- 성능 상 이점을 가져갈 수도 있다.
- 순차적인 작업을 보장할 수 없게 된다.
최종 선택한 방법
트랜잭션을 사용한다.
이유: 현재 작성한 코드는 순차적인 작업이 보장되어야 하기 때문에 트랜잭션을 사용하기로 했다.
그리고 Promise.all()의 사용은 마찬가지의 이유로 순차적 작업 보장을 못받기 때문에 선택하지 않았다.
수정 후 코드
async postAssignMover(
userId: string,
userType: string,
moverId: string,
): Promise<AssignMover> {
// userType이 mover라면 에러
if (userType === "mover") {
throw new UnauthorizedException(
"기사 계정으로는 지정 기사 요청을 할 수 없습니다.",
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const quotation = await queryRunner.manager.findOne(Quotation, {
where: {
customerId: userId,
status: "PENDING",
},
});
if (!quotation) {
throw new NotFoundException("견적 요청을 먼저 진행해주세요.");
}
const isExistsAssign = await queryRunner.manager.exists(AssignMover, {
where: {
moverId,
customerId: userId,
},
});
if (isExistsAssign) {
throw new BadRequestException("이미 지정한 기사입니다.");
}
const prev = quotation.assignMover ?? [];
const updatedAssignMovers = Array.from(new Set([...prev, moverId]));
await queryRunner.manager.update(Quotation, quotation.id, {
assignMover: updatedAssignMovers,
});
const newAssignMover = queryRunner.manager.create(AssignMover, {
moverId,
quotationId: quotation.id,
customerId: userId,
status: "PENDING",
});
await queryRunner.manager.save(AssignMover, newAssignMover);
await queryRunner.commitTransaction();
// 알림 생성
const notiSegments: NotificationTextSegment[] = [
{
text: `새로운 `,
isHighlight: false,
},
{
text: `지정 견적 요청`,
isHighlight: true,
},
{
text: `이 도착했어요`,
isHighlight: false,
},
];
await this.notificationService.createNotification(moverId, {
type: "QUOTE_ARRIVED",
segments: notiSegments,
});
return newAssignMover;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
결과 및 성과
- 무한 스크롤 + 복잡한 정렬 조건 구현 (복합 쿼리키 사용)
- Nest.js 구조 이해 및 DTO 적극 사용
- 개발 환경과 프로덕션 환경에 따른 토큰 저장방식(로컬, 쿠키) 분기처리
- 트랜잭션 관리 및 예외 처리 패턴 학습
캐싱 컬럼 VS 실제 데이터 기반 카운트

결론적으로는 위처럼 캐싱 컬럼 기반으로 구현함.
캐싱컬럼
장점: 자주 조회되는 집계값(예: 좋아요 수, 평균 평점 등)을 실시간으로 계산하지 않고 컬럼에 저장해 두면 읽기 요청이 훨씬 빨라짐.
단점: 쓰기(좋아요 추가/삭제, 리뷰 생성 등)마다 해당 컬럼을 업데이트해야 해서 트랜잭션 복잡도가 늘어나고, 동시성 이슈로 값이 일시적으로 어긋날 수 있음.
실제 데이터 기반 조회
장점: 테스트 데이터를 넣었을 때 실제 데이터 기반이기 때문에 계산이 틀릴 걱정이 줄어들 수 있음.
단점: 복잡한 쿼리 구조, 성능 저하(ex: N+1 문제), 정렬 기준으로 사용하기 어려움
캐싱 컬럼을 선택한 이유
- 자주 조회되는 데이터들로 예상되어 조회 성능이 중요했음
- 복잡한 정렬 및 스크롤 구현이 요구되었기 때문에 캐싱 컬럼을 사용하는게 적합하다고 판단.
- 조회 시 여러 번 데이터 조회 및 계산 로직을 넣는 것보다는 캐싱 컬럼을 사용하여 생성 시에만 +1 하는 식으로 하는게 작업 측면에서 효율적임.
향후 계획
- 데이터 정합성 및 성능을 고려한 리팩토링 작업
- 시드 데이터 도입
- 테스트 코드 작성
- QA를 진행(시나리오 테스트)하며 실제 요구사항과 일치하지 않는 부분이 있는지 점검 후 수정 작업
'프로젝트' 카테고리의 다른 글
| 공부의 숲 프로젝트 회고록 (2) | 2025.07.11 |
|---|---|
| 독스루 프로젝트 회고록 (0) | 2025.04.16 |
