본문 바로가기

Web Development/ETC

백엔드 데이터 요청 로직 개선 사례 공유

백엔드 이슈를 처리하는 과정에서 데이터 요청 로직을 수정하게 되었고,

그 과정에서 발견한 개선 포인트와 실제 적용한 개선 내용을 공유드립니다.


1. 불필요한 컬럼 조회 제거 (Attributes 명시)

기존 문제

기존 코드에서는 필요한 컬럼만 선택하지 않고, 연관된 모델 전체 컬럼을 조회하고 있었습니다.

이로 인해 JOIN 대상에 content와 같은 대용량 컬럼이 포함되어 성능 저하 가능성이 있었습니다.

개선 내용

각 include 구문에 attributes 옵션을 명시하여 실제로 필요한 컬럼만 조회하도록 수정했습니다.

개선 효과

  • JOIN 시 사용되는 메모리 사용량 감소
  • (특히 content와 같은 대용량 컬럼이 JOIN 대상에 포함되지 않도록 제한)
  • DB ↔ 서버, 서버 ↔ 프론트엔드 간 전송되는 데이터 크기 감소
  • 전체 쿼리 성능 및 응답 속도 개선

코드 예시

// 개선 전
const { rows: recentItems, count: totalCount } =
  await Item.findAndCountAll({
    where: ...,
    include: [
      {
        model: Book,
        required: true,
        include: [
          {
            model: Story,
            include: [
              ...
            ],
          },
        ],
      },
    ],
    order: [['opened_at', 'desc']],
    offset: (req.query.page - 1) * limit,
    limit,
    distinct: true,
  });

// 개선 후
const { rows: recentItems, count: totalCount } =
  await Item.findAndCountAll({
    where: ...,
    include: [
      {
        model: Book,
        attributes: ['id', 'status', 'type', 'title', 'opened_at', 'thumbnail'],
        required: true,
        include: [
          {
            model: Story,
            attributes: ['id', 'status', 'type', 'title', 'opened_at', 'thumbnail'],
            include: [
              ...
            ],
          },
        ],
      },
    ],
    order: [['opened_at', 'desc']],
    offset: (req.query.page - 1) * limit,
    limit,
    distinct: true,
  });

2. N + 1 쿼리 문제 해결

기존 문제

메인 데이터를 조회한 이후, 반복문을 돌며 연관 데이터를 개별 조회하는 구조였습니다.

  • 메인 데이터 조회: 1회
  • 루프 내 추가 조회: N회
  • 내부 루프에서 통계 조회: N × M회

데이터 수가 증가할수록 쿼리 수가 기하급수적으로 늘어나는 구조였습니다.

개선 내용

  • 메인 조회 결과에서 필요한 ID만 추출
  • WHERE IN 조건을 활용해 연관 데이터 및 통계 데이터를 한 번에 조회

개선 효과

  • 데이터 개수와 무관하게 쿼리 횟수 고정
  • DB 부하 감소
  • 응답 시간 예측 가능성 향상

코드 예시

// 개선 전
// 1. 메인 목록 조회 (1번)
const { rows: recentItems } = await Item.findAndCountAll(...);

for (let i = 0; i < recentItems.length; i++) {
  // 2. 루프마다 Linker 조회 (N번)
  await Item.scope('withLinker').findOne(...);

  // 3. 인기순일 경우, 내부 루프에서 통계 조회 (N * M번)
  for (let j = 0; j < list.length; j++) {
    await ItemClick.findOne(...);
  }
}

// 개선 후
// 1. 메인 목록 조회 (1번)
const { rows } = await Item.findAndCountAll(...);

// 2. ID 목록 추출 후 Linker 한 번에 조회 (1번)
const itemLinkers = await ItemLinker.findAll({
  where: { item_id: { [Op.in]: itemIds } },
});

// 3. 통계 데이터 한 번에 조회 (1번)
const clickStats = await ItemClick.findAll({
  where: { item_id: { [Op.in]: targetIds } },
});

3. 페이지네이션 정확도 개선

기존 문제

Sequelize의 subQuery 기본 동작에 의존하여 페이지네이션을 처리하고 있었습니다.

JOIN 조건이 많고, 특히 1:N 관계가 포함된 경우:

  • JOIN 결과로 row 수가 증가
  • 추가 where 조건이 적용되면
  • limit으로 요청한 수보다 적은 데이터가 반환되는 문제가 발생할 수 있었습니다.

개선 내용

  • subQuery: false 옵션을 명시
  • 모든 JOIN 이후에 정확한 where 조건을 적용하도록 쿼리 구조 개선

개선 효과

  • 항상 요청한 개수만큼의 데이터 반환 보장
  • 페이지네이션 동작의 예측 가능성 확보

코드 예시

subQuery: false, // 모든 JOIN 이후 필터링
where: {
  [Op.and]: [
    // 조건에 맞지 않아 껍데기만 남은 데이터 제거
    {
      [Op.or]: [
        { '$Story.id$': { [Op.ne]: null } },
        ...
      ],
    },
  ],
},

4. 응답 데이터 구조 개선 (응답 품질 향상)

기존 문제

DB에서 조회한 데이터를 거의 그대로 응답으로 반환하고 있었습니다.

  • 프론트엔드에서 데이터 구조를 이해하기 위해
    • 백엔드 코드 또는 DB 구조를 직접 확인해야 함
  • 필요 없는 필드 노출 가능성 존재
  • null 처리 로직이 프론트엔드에 분산됨

개선 내용

  • 서버에서 응답 데이터를 명시적으로 가공
  • 프론트엔드에서 바로 사용할 수 있도록 구조를 평탄화
  • null 방어 로직을 서버에서 처리

개선 효과

  • 프론트엔드 개발 생산성 향상
  • 응답 스펙 명확화
  • 불필요한 데이터 노출 방지

코드 예시

const items = rows.map(item => {
  return {
    id: plainItem.id,
    title: plainItem.title,
    series: {
      ...
    }, // 계층 구조 단순화
    metrics: {
      click_count: ...
    }, // 통계 정보 그룹화
  };
});

 

4. 개선 결과

레거시 API 리팩토링을 통해
응답 속도 80% 단축 (242ms → 48ms) 및 데이터 페이로드 99% 경량화 (528kB → 5kB)