
캡스톤을 하면서 간단한 사이드 프로젝트 하나를 더 하게 되었다.
이번엔 Nest.js랑 MongoDB를 사용하게 돼서 타입 스크립트와 Node 환경에 익숙해지기 위해 공부하며 개발 중이다.
스프링부트랑 비슷한 개념이 많아서 조만간 이러한 것에 대해 다루는 글을 쓸 것이다.
비슷한 점들도 꽤나 많지만 Nest.js는 TypeScript와 Node.js 기반으로 구축되어 스프링과는 달리 비동기 처리가 프레임워크의 근간을 이룬다.
비동기 모델을 깊이 있게 이해하는 것이 Nest.js의 고성능을 잘 활용하는 것이라고 생각한다.
우리가 일상적으로 사용하는 네이버 검색창에서 글자가 입력되는 순간 바로바로 추천 검색어가 갱신되는 것도 바로 이 비동기와 관련이 있기에 이를 다루고 싶었다.
비동기는 즉시 반영이 아니다.
이렇게 순식간에 내가 치는 타자 속도에 맞게 검색어가 변경되는 것을 보고 이것이 비동기라는 말을 하니까 즉시 반영되는 빠른 코드?? 이런 식으로 생각하는 사람들이 꽤 많다.
사실 비동기의 핵심은 속도가 아니라 효율성과 논블로킹이다.
Nest.js에서 데이터베이스 조회 시 await를 사용하는 것처럼 비동기 코드는 결과가 나오기까지 시간이 걸리는 작업을 처리할 때 사용된다.
동기와 비동기를 비교한 표를 보자.

아하~ 그니까 이전 작업이 완료되지 않아도 동시에 가능한건가? 약간 병렬적이네?
같다고 하기엔 어폐가 있으나 느낌은 비슷하긴 하다. 비동기는 병렬일 수도 있으나 한 스레드가 여러 작업을 번갈아가면서 처리하고 병렬은 실제로 여러 스레드/프로세스가 동시에 실행하는 것이다.
일상생활에 빗대어 카페에서 커피를 주문하는 상황을 생각해보자.
동기적 커피 주문은
- 첫 번째 손님이 아아 주문
- 바리스타가 아메리카노를 완전히 만들 때까지 다른 모든 손님은 대기
- 아메리카노 완성 후 두 번째 손님 주문 접수
- 라데를 완전히 만들 때까지 또 모든 손님이 기다림
- 이런 식으로 계속..
아주 비효율적이다.
비동기적 커피 주문은
- 첫 번째 손님이 아아 주문 -> 바리스타가 커피머신에 넣고 추출 시작
- 커피가 내려지는 동안 두 번째 손님 라떼 주문 받기
- 라떼 주문 처리, 세 번째 손님 주문도 받음
- 아메리카노 완성되면 첫 번째 손님한테 전달
- 라떼 완성되면 두 번째 손님한테 전달
대기 시간을 다른 작업에 활용하기에 훨씬 효율적이다.
네이버 검색창에서 무슨 일이 일어나는 걸까?

"맛있는 치킨"이라고 검색한다고 생각해보자.
만약 동기적으로 처리했다면?
사용자: "맛" 입력
브라우저: "잠깐, 서버에 '맛'으로 시작하는 검색어 물어보겠습니다."
서버: "음... 데이터베이스 뒤져보는 중..."
브라우저: "기다리는 중... 사용자가 뭘 해도 반응 안 함"
사용자: "있" 입력하려고 해도 아무 반응 없음
브라우저: "아직 기다리는 중..."
서버: "찾았다! '맛집', '맛있는', '맛난' 이런 게 있어요."
브라우저: "이제야 화면 업데이트하고 다음 글자 받을 수 있음"
사용자: 속 터져서 브라우저 창 닫음
실제 비동기 처리는?
사용자: "맛" 입력
브라우저: "서버에 '맛' 검색어 요청 보냄 (백그라운드)"
브라우저: "사용자는 계속 타이핑해도 됩니다. 제가 여러 일을 동시에 처리할 수 있어요."
사용자: "있" 입력
브라우저: "이전 요청은 진행 중이고, 새로 '맛있' 요청도 보냄"
서버: "'맛' 검색 결과 도착!"
브라우저: "받았다! 근데 사용자가 이미 '맛있'까지 쳤네? 이전 결과는 무시하고 최신 요청을 기다릴게요."
사용자: "는" 입력
브라우저: "또 새로운 '맛있는' 요청 보냄"
서버: "'맛있는' 검색 결과 도착!
브라우저: "화면 업데이트! 사용자는 전혀 끊김 없이 계속 타이핑 중"
느낌이 빡 온다.
"기다리지 않는다." 이게 핵심이다.
기술적으로 어떻게 구현될까?
프론트 먼저 확인해보자.
const searchInput = document.getElementById('searchInput');
let currentRequest = null; // 현재 진행 중인 요청
// 사용자가 타이핑할 때마다 실행
searchInput.addEventListener('input', async (event) => {
const keyword = event.target.value;
// 이전 요청이 있다면 취소 (더 이상 필요 없음)
if (currentRequest) {
currentRequest.abort();
}
// 너무 짧은 검색어는 무시
if (keyword.length < 1) {
return;
}
// 새로운 비동기 요청 시작
currentRequest = new AbortController();
try {
// fetch는 비동기 함수 - 서버 응답을 기다리지만 브라우저는 블록되지 않음
const response = await fetch(`/search/suggestions/${keyword}`, {
signal: currentRequest.signal
});
const suggestions = await response.json();
// 검색 결과로 화면 업데이트
updateSuggestionList(suggestions);
} catch (error) {
if (error.name !== 'AbortError') { // 취소된 요청이 아닌 실제 에러만 처리
console.error('검색 중 에러:', error);
}
}
});
function updateSuggestionList(suggestions) {
const suggestionList = document.getElementById('suggestionList');
suggestionList.innerHTML = ''; // 기존 목록 지우기
suggestions.forEach(suggestion => {
const li = document.createElement('li');
li.textContent = suggestion;
li.addEventListener('click', () => {
searchInput.value = suggestion; // 클릭하면 검색창에 입력
});
suggestionList.appendChild(li);
});
}
여기서 중요한 포인트는 Debouncing이다.
사용자가 타이핑 하는 매 순간마다 서버에 요청을 보내면 서버가 과부하될 수 있기 때문에 보통은 일정 시간 동안 더 이상 입력이 없을 때만 요청을 보낸다.
let timeoutId;
searchInput.addEventListener('input', (event) => {
// 이전 타이머 취소
clearTimeout(timeoutId);
// 300ms 후에 실행될 타이머 설정
timeoutId = setTimeout(() => {
searchSuggestions(event.target.value);
}, 300);
});
이렇게 하면 사용자가 치킨이라고 빠르게 타이핑할 때 "치", "치키", "치킨" 각각에 대해 서버한테 요청 보내지 않고 마지막 치킨에 대해서만 요청을 보낸다.
서버단에서 보자.
// 컨트롤러: HTTP 요청을 받는 입구
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get('suggestions/:keyword')
async getSearchSuggestions(@Param('keyword') keyword: string) {
console.log(`검색어 요청 받음: ${keyword}`);
// 서비스에서 비동기적으로 검색 수행
// await는 결과를 기다리지만, 이 요청을 처리하는 동안 다른 사용자의 요청도 동시에 처리할 수 있음
const suggestions = await this.searchService.findSuggestions(keyword);
return {
keyword,
suggestions,
timestamp: new Date().toISOString()
};
}
}
// 서비스: 실제 비즈니스 로직 처리
@Injectable()
export class SearchService {
constructor(
@InjectModel('PopularSearch') private popularSearchModel: Model<PopularSearch>,
@InjectModel('UserSearch') private userSearchModel: Model<UserSearch>
) {}
async findSuggestions(keyword: string): Promise<string[]> {
console.log(`데이터베이스에서 "${keyword}" 검색 시작`);
// 여러 검색을 병렬로 수행
const [popularSuggestions, userSuggestions] = await Promise.all([
// 인기 검색어에서 찾기
this.findPopularSuggestions(keyword),
// 사용자 검색 기록에서 찾기
this.findUserSuggestions(keyword)
]);
// 결과 합치고 중복 제거 후 정렬
const allSuggestions = [...popularSuggestions, ...userSuggestions];
const uniqueSuggestions = [...new Set(allSuggestions)];
console.log(`"${keyword}" 검색 완료, ${uniqueSuggestions.length}개 결과`);
return uniqueSuggestions.slice(0, 10); // 최대 10개만 반환
}
private async findPopularSuggestions(keyword: string): Promise<string[]> {
// MongoDB에서 비동기 검색
// 이 쿼리가 실행되는 동안 다른 작업들도 계속 진행됨
const results = await this.popularSearchModel
.find({
term: { $regex: `^${keyword}`, $options: 'i' }, // 대소문자 무관하게 시작하는 단어
popularity: { $gte: 10 } // 인기도 10 이상
})
.sort({ popularity: -1 }) // 인기도 순으로 정렬
.limit(5)
.exec();
return results.map(result => result.term);
}
private async findUserSuggestions(keyword: string): Promise<string[]> {
const results = await this.userSearchModel
.find({
term: { $regex: `^${keyword}`, $options: 'i' },
searchCount: { $gte: 2 } // 최소 2번 이상 검색된 것
})
.sort({ lastSearched: -1 }) // 최근 검색 순으로 정렬
.limit(5)
.exec();
return results.map(result => result.term);
}
}
위 코드에서 promise.all()을 사용한 부분이 특히 중요하다.
// 순차적 실행
const popularSuggestions = await this.findPopularSuggestions(keyword); // 100ms 소요
const userSuggestions = await this.findUserSuggestions(keyword); // 150ms 소요
// 총 250ms 소요
// 비동기 방식
const [popularSuggestions, userSuggestions] = await Promise.all([
this.findPopularSuggestions(keyword), // 100ms 소요
this.findUserSuggestions(keyword) // 150ms 소요
]);
// 총 150ms 소요
두 검색이 독립적이기 때문에 동시에 실행할 수 있어서 시간을 크게 단축시킨다.
결국 비동기의 본질은 효율성이다.
동시에 여러 요청을 처리할 수 있다는 건 트릭이 아니라 시스템 자원을 최대한 활용해서 사용자 경험을 끊김 없이 이어주는 좋은 방법 같다.
Nest.js는 이런 비동기 모델을 자연스럽게 받아들이도록 설계된 프레임워크라서 앞으로 잘 활용해봐야겠다.
'웹개발 이모저모' 카테고리의 다른 글
| 카카오 연동기 간단 회고(중계 서버 방식) (0) | 2025.08.06 |
|---|---|
| API 명세서 작성을 공부하고 배운 점 (5) | 2025.07.24 |
| Leaflet.js와 VWorld API로 2D 지도 만들기 (1) | 2025.05.15 |
| SSR vs CSR (0) | 2025.02.04 |
| 간단한 API 설명 (0) | 2024.05.17 |