본문 바로가기

Dev Log

API 개발 과정에서 반복되는 SQL 질문

728x90

실무에서 PostgreSQL + MyBatis 기반의 API를 개발하다 보니, SQL을 작성할 때마다 반복되는 고민들이 생겼다. 그동안 시행착오를 겪으며 정리해온 내용을 이번 글에 공유하려고 한다.

예를 들어,

  • WHERE 조건 순서는 중요한가?
  • JOIN 순서는 성능에 영향을 줄까?
  • API 응답 데이터에 depth 작성시 많이 사용되는 코드 형태는?

단순한 질문인데도 매번 헷갈리는 질문이다. 

이 질문들을 기반으로 SQL을 작성할 때 알아두면 좋은 원칙 + PostgreSQL & MyBatis 기준 실행 과정을 가볍게 정리해보려고 한다.

1. SQL 작성할 때 가지면 좋은 기본 원칙

SQL은 여러 DB 종류(PostgreSQL, MySQL 등), 여러 함수, 다양한 기능 등이 존재한다.

특히 PostgreSQL은 기능이 굉장히 많아서 잘만 활용하면 성능이 매우 좋아질 수 있다.

하지만… 문제는 가독성이다.

SQL은 다음과 같은 이유로 쉽게 복잡해진다.

  • 컬럼 개수가 많아지고
  • JOIN이 많아지고
  • 익숙하지 않은 함수까지 들어가면

코드를 읽는 개발자의 부담이 크게 증가한다.

그래서 개인적으로는 이렇게 정리한다.

성능을 고려하되 “누가 봐도 읽히는 쿼리”가 가장 좋은 쿼리다.

 

실행 계획은 DB가 최적화해주지만,

가독성은 개발자가 책임져야 하는 영역이기 때문이다.

2-1. WHERE 조건 순서는 중요한가?

✅ 결론

WHERE 절의 순서는 성능에 거의 영향을 주지 않는다.

왜냐하면 PostgreSQL(또는 MySQL)은

SQL을 그대로 실행하지 않고 "옵티마이저(Optimizer)"가 가장 빠른 순서로 자동 재배치하기 때문이다.

즉, 아래 두 SQL은 속도가 동일하다.

WHERE a = 10 AND b > 5 AND c < 100

WHERE c < 100 AND b > 5 AND a = 10

그럼 정말로 “순서가 의미 없다면”,

DB는 내부적으로 어떻게 실행 계획을 세우길래 이렇게 되는 걸까?


2-2. PostgreSQL + MyBatis 기준으로 실행 과정을 이해해보자

예제로 아래 MyBatis XML을 보자.

<select id="getUserNames">
  SELECT name
  FROM users
  WHERE age > #{age};
</select>

자바에서 이렇게 호출한다고 해보자.

mapper.getUserNames(20);

1.  MyBatis가 SQL 템플릿을 읽고, Prepared SQL 문자열을 생성한다.

실제로 DB에 보내는 SQL은 다음과 같다.

SELECT name
FROM users
WHERE age > $1;

여기서 $1은 바인딩 파라미터이다.

2. MyBatis가 값(20)을 PostgreSQL 드라이버에 전달한다.

$1 = 20

3. PostgreSQL 옵티마이저가 실행 계획을 세운다.

PostgreSQL은 SQL을 받는 순간 아래 작업을 한다.

  • 문법 검사
  • 가능한 인덱스 탐색
  • WHERE 조건 순서 재정렬
  • 비용(cost) 기반 최적 실행 계획 생성

즉, 우리가 적은 WHERE 순서는 그대로 사용되지 않는다.

4. PostgreSQL이 최적화된 순서로 실행하여 데이터를 스캔하고 결과를 반환한다.

예를 들어 age 컬럼에 인덱스가 있으면:

  • Index Scan 수행
  • 조건(age > 20)에 맞는 row 빠르게 찾아냄
  • 필요한 컬럼(name)만 읽어 반환

5. MyBatis가 ResultType/ResultMap으로 Java 객체로 매핑한다.


2-3. PostgreSQL 옵티마이저는 어떤 기준으로 WHERE 조건 순서를 재정렬할까?

PostgreSQL은 조건을 보면서 이렇게 생각한다.

“이 조건들 중, 어떤 조건을 먼저 적용해야 데이터가 가장 많이 줄어들까?

그리고 무엇이 가장 비용이 적을까?”

 

즉, 데이터를 가장 빨리 줄여주는 조건부터 검사한다.

 

예를 들어:

조건 전체 100만 중 몇 개? 비고

id = 10 1개 가장 강력함 (선택도 가장 낮음)
age > 10 95만 개 거의 줄이지 못함

따라서 옵티마이저는 아래 순서로 실행한다.

  1. id = 10
  2. age > 10

2-4. WHERE 재정렬 기준 정리

1. 선택도(Selectivity)

가장 핵심 기준.

  • 결과가 적게 나오는 조건 → 우선순위 높음
  • 결과가 많이 나오는 조건 → 우선순위 낮음

2. 인덱스를 사용할 수 있는지

  • 인덱스 활용 가능 → 높게 평가됨
  • 인덱스 사용 불가 → 우선순위 떨어짐

예시:

  • name LIKE 'abc%' → 인덱스 사용 O
  • name LIKE '%abc%' → 인덱스 사용 X

3. 비용 기반 최적화 (Cost-based Optimization)

PostgreSQL은 각 실행 경로의 비용(cost) 을 계산한다:

  • 디스크 읽기 횟수
  • 인덱스 depth
  • 페이지 수
  • 예상 반환 row 수
  • 조인 비용 포함

이 정보로 가장 비용이 낮은 실행 계획을 만든다.


2-5. “그럼 실제 데이터를 읽기 전까지, DB는 어떻게 이 비용을 계산할 수 있지?”

여기가 핵심이다.

👉 PostgreSQL과 MySQL은 “통계(Statistics)”를 기반으로 미리 추론한다.

DB는 테이블과 컬럼에 대해 다음 정보를 이미 알고 있다.

  • 값의 분포 (히스토그램)
  • NULL 비율
  • 가장 자주 등장하는 값(MCV)
  • distinct 개수
  • 중복률 (카디널리티)
  • 인덱스 depth
  • 테이블/인덱스 페이지 수

이걸 기반으로 옵티마이저가 “추정 결과 row 수”를 계산하고

그걸 기준으로 WHERE 조건 순서를 정한다.


2-6. 통계는 실시간으로 업데이트될까?

❌ 아니다.

데이터가 INSERT/UPDATE 되었다고 해서

통계가 즉시 업데이트되지는 않는다.

PostgreSQL

  • autovacuum(auto-analyze)이
  • “일정 비율 이상 row가 수정되면” 통계를 다시 계산

MySQL(InnoDB)

  • 테이블이 열릴 때
  • 또는 일정 시간 간격
  • 샘플링 기반으로 통계를 업데이트 (덜 정확함)

필요하면 직접 실행 가능

ANALYZE your_table;

3. JOIN 조건 나열 순서는 성능에 영향을 줄까?

SQL을 작성하다 보면 JOIN 조건 안에서 이런 고민을 할 때가 있다.

JOIN ... ON A = X AND D = Y

vs.

JOIN ... ON D = Y AND A = X

즉, JOIN ON 안에 나열하는 조건 순서를 바꾸면 성능이 달라질까? 결론부터 얘기하자면 성능에는 아무런 영향을 주지 않는다. PostgreSQL/MySQL 옵티마이저가 JOIN 조건도 내부적으로 재정렬해서 실행하기 때문에 WHERE 절이든 JOIN ON 절이든, 논리적으로 “AND”로 연결된 조건은 순서가 의미에 영향을 주지 않음.

 

하지만 가독성 측면에서는 순서를 잘 작성하는 것이 중요하다.

 

1. “핵심 조인 키(Primary Join Key)”를 먼저 둔다

외래키(FK), 기본적인 연결 조건을 앞에 두면 읽기 좋다.

JOIN orders o
  ON u.id = o.user_id
 AND o.status = 'DONE'

 

2. 부가적인 필터 조건은 뒤에 둔다

JOIN 자체가 아니라, 조인된 테이블의 추가 조건은 뒤로.

ON u.id = o.user_id
AND o.deleted_at IS NULL

 

3. 사람 관점에서 “중요도”가 높은 조건 → “덜 중요한 조건” 순으로

이건 팀마다 다르지만, 보통 로직 흐름이 이해되는 방향으로 정렬한다.

4. API 응답 데이터에 depth 작성시 많이 사용되는 코드 형태는?

API 개발을 하다 보면, 단순 리스트가 아니라 아래처럼 depth가 있는 구조를 내려줘야 하는 상황이 자주 생긴다.

{
  year: 2025,
  month: 10,
  dates: [
    {
      date: 20251001,
      availableCount: 0,
      reserveCount: 0,
      availableYn: "N",
      timeSlots: [
        {
          timeCode: "0900",
          availableCount: 0,
          reserveCount: 0,
          availableYn: "N"
        }
      ]
    }
  ]
}

❓ 고민 포인트 1: MyBatis XML(resultMap)으로 Depth를 직접 매핑할까?

MyBatis는 association, collection 태그를 사용해서

객체 안에 객체, 리스트 안에 객체를 매핑할 수 있다.

하지만 실제로 사용해보면 다음과 같은 문제가 생긴다.

✖ XML 방식에서 느낀 점

  • depth가 3단계 이상 되는 순간 XML이 너무 길어지고 복잡해짐
  • 태그 누락, 괄호 실수 등 휴먼 에러 증가
  • 구조 변경 시 XML부터 수정해야 해서 유지보수 어려움
  • JSON 스펙이 바뀌면 SQL, XML까지 전부 영향을 받음

그래서 코드 관리와 유지보수 측면에서

지금 프로젝트에는 맞지 않았다고 판단했다.

 

❓ 고민 포인트 2: SQL은 Flat하게 가져오고, Service에서 조립할까?

내가 선택한 방식은 **SQL 결과는 Flat(단일 레코드 형태)**으로 가져오고,

Service 레벨에서 필요한 depth 구조로 다시 조립하는 형태다.

예를 들어,

date, timeCode, availableYn, availableCount …

이런 단일 라인 형태로 데이터를 받아오고

Service에서 date 그룹 → timeSlot 그룹으로 변환한다.

✔ 선택한 이유 (장점)

  • SQL은 “조회”에만 집중 → 단순하고 유지보수가 쉬움
  • 응답 구조 변경 시, Service 단만 수정하면 OK
  • Swagger 문서(DTO 기반) 작성이 훨씬 편함
  • XML이 가볍고 오류 가능성이 적어짐
  • 테스트 코드 작성도 수월함

그래서 아래처럼 두 가지 DTO를 분리했다.

1. MyBatis 매핑용 Flat DTO

SQL 결과만 담는 용도.

MyBatis는 이 DTO에 단순 매핑만 수행한다.

@Getter
@Setter
@ToString
public class CheckupReservationAvailableDate {

    private String date;

    private String dayAvailableYn;
    private int dayAvailableCount;
    private int dayReserveCount;

    private String timeCode;
    private String timeName;

    private String timeAvailableYn;
    private int timeAvailableCount;
    private int timeReserveCount;
}

2. API 응답용 Response DTO

Service에서 데이터 조립 후,

최종적으로 Swagger나 클라이언트에 전달되는 구조.

package com.mediplussolution.hime.mainapi.checkup.checkupplan.vo.reservation;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.io.Serializable;
import java.util.List;

@Schema(description = "예약 가능 날짜 응답")
@Setter
@Getter
@ToString
public class CheckupReservationAvailableResponse implements Serializable {

    @Schema(description = "예약 연도", example = "2025")
    private String year;

    @Schema(description = "예약 월", example = "10")
    private String month;

    @Schema(description = "예약 가능 날짜 목록")
    private List<AvailableDate> dates;

    @Setter
    @Getter
    @ToString
    public static class AvailableDate implements Serializable {

        @Schema(description = "예약 날짜 (YYYYMMDD)", example = "20251001")
        private String date;

		@Schema(description = "예약 가능 여부", example = "Y")
		private String availableYn;

		@Schema(description = "예약 가능 일 수", example = "13")
		private int availableCount;

		@Schema(description = "예약 예약 수", example = "13")
		private int reservedCount;

        @Schema(description = "예약 가능 시간 목록")
        private List<TimeSlot> timeSlots;

        @Setter
        @Getter
        @ToString
        public static class TimeSlot implements Serializable {

            @Schema(description = "시간", example = "0900")
            private String timeCode;

            @Schema(description = "예약 시간명", example = "09:00")
            private String timeName;     

            @Schema(description = "예약 가능 여부", example = "Y")
            private String availableYn;

            @Schema(description = "예약 가능 건수", example = "3")
            private int availableCount;

            @Schema(description = "예약된 건수", example = "0")
            private int reservedCount;
        }
    }
}

✅ 결론

  • WHERE / JOIN 조건 순서는 성능에 거의 영향 없다.
  • → DB 옵티마이저가 더 빠른 순서로 자동 재정렬한다.
  • 중요한 것은 인덱스와 통계 정보가 최신인지다.
  • 깊은 depth의 API 응답은 SQL이 아니라 Service 레이어에서 조립하는 것이 실무적으로 가장 안정적이다.
728x90