본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
3장 : 조회·필터·정렬

SELECT 기본 조회

회원 게시판에서 투영과 조건식을 분리해 필요한 게시글만 정확히 조회합니다.

게시글 목록 화면에 필요한 값은 제목, 조회수, 작성 시각뿐인데 SELECT *로 긴 본문까지 가져온다면 결과는 나오더라도 좋은 조회라고 보기 어렵습니다.

조회는 테이블을 그대로 복사하는 일이 아니라, 소비자가 필요한 열과 행을 명시하는 규칙입니다.

앞 장의 누적 membersposts는 그대로 보존하고, 조회 결과를 모두 같게 재현할 독립 query_post_demo를 이용합니다.

이 문서에서는 fixture를 한 번 만든 뒤 데이터를 바꾸지 않고 읽는 범위를 다듬습니다.

열을 고르는 투영, 행을 고르는 조건식, 결과 이름을 정하는 별칭을 한 쿼리 안에서 서로 다른 책임으로 읽어 보겠습니다.


3장 조회 fixture 준비

앞의 선택 연습을 실행하면 누적 posts의 행 수와 자동 생성 ID가 달라질 수 있습니다.

SELECT 결과를 교재와 일치시키기 위해 3장은 독립 테이블 query_post_demo를 사용합니다.

아래 블록은 이 장에서 쓸 14행을 고정하며, 누적 모델의 posts는 바꾸지 않습니다.

ch3_query_fixture.sql
DROP TABLE IF EXISTS query_post_demo;

CREATE TABLE query_post_demo (
  id BIGINT UNSIGNED NOT NULL,
  author_id BIGINT UNSIGNED NOT NULL,
  title VARCHAR(80) NOT NULL,
  content VARCHAR(5000) NOT NULL,
  view_count BIGINT UNSIGNED NOT NULL,
  created_at DATETIME(6) NOT NULL,
  published_at DATETIME(6) NULL,
  status VARCHAR(20) NOT NULL,
  PRIMARY KEY (id)
) ENGINE = InnoDB;

INSERT INTO query_post_demo (
  id, author_id, title, content, view_count,
  created_at, published_at, status
) VALUES
  (1, 1, 'SELECT 기초', 'SELECT의 열 선택을 설명합니다.', 25, '2026-07-10 08:00:00', '2026-07-10 08:25:00', 'PUBLISHED'),
  (2, 1, 'SQL WHERE', 'WHERE 경계값을 복습합니다.', 50, '2026-07-11 09:00:00', '2026-07-11 09:50:00', 'PUBLISHED'),
  (3, 2, 'NULL 복습', 'NULL 비교 규칙을 정리합니다.', 40, '2026-07-11 10:00:00', '2026-07-11 10:40:00', 'PUBLISHED'),
  (4, 3, '설치 메모', 'MySQL 설치 절차를 정리합니다.', 10, '2026-07-11 11:00:00', '2026-07-11 11:10:00', 'PUBLISHED'),
  (5, 1, '정렬 연습', '완전한 정렬 키를 설명합니다.', 35, '2026-07-12 13:30:00', '2026-07-12 14:05:00', 'PUBLISHED'),
  (6, 2, 'MySQL 인덱스', '인덱스 기초를 소개합니다.', 25, '2026-07-12 15:00:00', '2026-07-12 15:25:00', 'PUBLISHED'),
  (7, 3, '설치 메모', 'Docker 설치 절차를 보충합니다.', 10, '2026-07-12 16:00:00', '2026-07-12 16:10:00', 'PUBLISHED'),
  (8, 2, '고급 SQL 복습', '숨김 처리된 고급 SQL 글입니다.', 45, '2026-07-13 12:00:00', '2026-07-13 12:40:00', 'HIDDEN'),
  (9, 1, 'SQL 함수', '숨김 처리된 함수 예제입니다.', 30, '2026-07-13 13:00:00', '2026-07-13 13:25:00', 'HIDDEN'),
  (10, 2, 'LIKE 복습', 'LIKE 패턴을 설명합니다.', 20, '2026-07-13 14:00:00', '2026-07-13 14:20:00', 'PUBLISHED'),
  (11, 1, '정렬 동률', '동률 정렬을 설명합니다.', 40, '2026-07-13 15:00:00', '2026-07-13 15:40:00', 'PUBLISHED'),
  (12, 1, 'LIMIT 실습', 'LIMIT 경계를 설명합니다.', 45, '2026-07-13 15:00:00', '2026-07-13 15:45:00', 'PUBLISHED'),
  (13, 3, 'JOIN 계획', '아직 공개하지 않은 JOIN 초안입니다.', 0, '2026-07-14 09:00:00', NULL, 'DRAFT'),
  (14, 2, 'GROUP BY 검토', '작성 중인 GROUP BY 글입니다.', 15, '2026-07-14 10:00:00', NULL, 'DRAFT');

SELECT COUNT(*) AS fixture_rows FROM query_post_demo;

fixture_rows=14인지 확인한 뒤 뒤의 쿼리를 실행합니다.

ch3-2~ch3-4도 이 테이블을 계속 사용합니다.

해당 문서로 바로 진입했다면 이 블록부터 먼저 실행합니다.


과도한 열 조회의 문제

초기 구현은 SQL을 짧게 만들려고 모든 게시글을 읽은 뒤 애플리케이션에서 30회 이상인 행만 남깁니다.

이 방식은 데이터가 작을 때는 티가 나지 않지만, 전송량과 메모리 사용이 테이블 크기에 따라 커지고 어떤 조건으로 읽었는지가 SQL에 남지 않습니다.

개인정보나 긴 본문 컬럼이 추가되면 필요 없는 값까지 경계를 넘어옵니다.

범위가 드러나지 않는 조회
USE board_lab;

SELECT *
FROM query_post_demo;

실행 자체는 성공하지만 10회 게시글과 아직 게시되지 않은 게시글, 긴 메모까지 모두 반환합니다.

화면에서 필요한 일부 행과 열만 기대했다면 다음처럼 과한 결과가 오류입니다.

오류 실행 결과
id | author_id | title       | content                         | view_count | created_at          | published_at        | status
1  | 1         | SELECT 기초 | SELECT의 열 선택을 설명합니다. | 25         | 2026-07-10 08:00:00 | 2026-07-10 08:25:00 | PUBLISHED
2  | 1         | SQL WHERE   | WHERE 경계값을 복습합니다.      | 50         | 2026-07-11 09:00:00 | 2026-07-11 09:50:00 | PUBLISHED
3  | 2         | NULL 복습   | NULL 비교 규칙을 정리합니다.    | 40         | 2026-07-11 10:00:00 | 2026-07-11 10:40:00 | PUBLISHED
4  | 3         | 설치 메모   | MySQL 설치 절차를 정리합니다.   | 10         | 2026-07-11 11:00:00 | 2026-07-11 11:10:00 | PUBLISHED
...| ...       | 나머지 10행 | ...                             | ...        | ...                 | ...                 | ...

SELECT *는 현재 스키마의 모든 컬럼을 결과 규칙에 암묵적으로 포함합니다.

나중에 private_note나 큰 JSON 컬럼이 추가되면 호출 코드를 바꾸지 않아도 결과 폭이 달라집니다.

애플리케이션 필터는 DBMS가 줄일 수 있었던 행을 네트워크 밖까지 보낸 뒤 버리므로 책임의 위치도 잘못됩니다.


SELECT와 WHERE의 역할

SELECT 목록에는 결과 소비자가 실제로 사용할 표현식만 둡니다.

WHERE는 각 행에 대해 참·거짓·알 수 없음을 평가하고 참인 행만 다음 단계로 보냅니다.

비교식은 왼쪽과 오른쪽의 값 관계를 표현하고, AND는 모든 조건을, OR는 하나 이상의 조건을 만족할 때 참입니다.

괄호 없이 섞으면 ANDOR보다 먼저 평가되므로 업무 문장을 괄호로 드러내는 편이 안전합니다.

별칭은 저장된 컬럼명을 바꾸지 않고 결과의 의미를 소비자 언어로 번역합니다.

열 선택, 비교 연산자, AND·OR·NOT, AS는 표준 SQL의 핵심입니다.

MySQL은 문자열 비교가 정렬 규칙의 대소문자 규칙을 따르므로 이메일 같은 값의 비교 정책은 컬럼 정렬 규칙과 함께 확인해야 합니다.

WHERE view_count >= 30처럼 컬럼을 그대로 비교하는 형태는 이후 인덱스 설계에서도 읽기 쉽습니다.


업무 질문의 SQL 변환

요구사항을 “공개 게시글 중 30회 이상이며 특정 회원의 기록”으로 먼저 문장화한 뒤 열과 행을 나눠 작성합니다.

author_id는 연결 조건, status와 시간은 업무 필터입니다.

필요한 게시글만 조회
SELECT
  id,
  title AS post_title,
  view_count AS views,
  created_at
FROM query_post_demo
WHERE author_id = 1
  AND status = 'PUBLISHED'
  AND view_count >= 30
ORDER BY created_at, id;

DBMS가 조건을 만족한 행과 네 컬럼만 반환합니다.

다음 결과라면 SQL만 읽어도 화면 데이터 규칙을 확인할 수 있습니다.

개선 실행 결과
id | post_title | views | created_at
2          | SQL WHERE    | 50      | 2026-07-11 09:00:00
5          | 정렬 연습      | 35      | 2026-07-12 13:30:00
11         | 정렬 동률      | 40      | 2026-07-13 15:00:00
12         | LIMIT 실습    | 45      | 2026-07-13 15:00:00

열 별칭은 결과 이름을 안정시키지만 같은 SELECT 단계에서 만든 별칭을 WHERE에서 바로 쓰는 방식은 피합니다.

논리적으로 WHERE가 먼저 평가되기 때문입니다.

조건이 반복될 때도 무작정 뷰나 계산 컬럼을 만들기 전에, 필터가 업무 규칙인지 특정 화면의 조회 조건인지 구분해야 합니다.


조건별 행 수 확인

복합 조건은 완성본만 보지 말고 각 조건식이 몇 행을 줄이는지 확인하면 오타와 우선순위 오류를 빨리 찾을 수 있습니다.

조건별 건수를 한 번에 세어 기준선을 만듭니다.

predicate별 통과 행 수
SELECT
  COUNT(*) AS all_rows,
  SUM(author_id = 1) AS author_rows,
  SUM(author_id = 1 AND status = 'PUBLISHED') AS published_rows,
  SUM(
    author_id = 1
    AND status = 'PUBLISHED'
    AND view_count >= 30
  ) AS final_rows
FROM query_post_demo;

MySQL에서는 참인 비교식이 숫자 문맥에서 1로 계산되는 성질을 이용한 진단 예제입니다.

표준 SQL로 이식하려면 SUM(CASE WHEN ... THEN 1 ELSE 0 END) 형태를 사용합니다.

기준 행 수가 14 → 6 → 5 → 4로 줄어드는지 확인하면 어느 조건에서 예상이 달라졌는지 찾기 쉽습니다.

투영을 좁히는 일은 단순한 미관 문제가 아닙니다.

보조 인덱스에 필요한 컬럼이 모두 있으면 InnoDB가 테이블 행을 다시 읽지 않는 커버링 경로를 선택할 가능성도 생깁니다.

반대로 열을 적게 썼다고 항상 빨라지는 것은 아니며, 조건 선택도와 인덱스는 7장에서 실행 계획으로 검증합니다.

지금은 먼저 결과 규칙을 정확히 만드는 데 집중합니다.

실습에서는 같은 질문을 세 번 실행해 보세요.

첫 번째는 전체 행을 확인하고, 두 번째는 WHERE만 적용해 통과 행을 검증하고, 마지막에 SELECT 목록을 줄입니다.

이 순서를 따르면 결과가 비었을 때 원인이 열 표현식인지 조건식인지 바로 분리할 수 있습니다.

운영 코드에서는 값을 문자열로 이어 붙이지 말고 준비된 구문의 바인딩 변수를 사용합니다.

author_id, 최소 조회수, 상태는 모두 외부 입력이 될 수 있으며 바인딩은 SQL 주입 방어와 타입 전달을 함께 돕습니다.

또한 조회 시점에 데이터가 바뀔 수 있다는 점도 기억해야 합니다.

서로 다른 두 SELECT 사이에서 행이 추가되면 단계별 건수가 달라질 수 있으므로, 정확히 같은 스냅샷 비교가 필요할 때는 트랜잭션 범위를 의도적으로 정합니다.

이 문서의 건수 진단은 학습 데이터가 고정된 상황을 전제로 합니다.

리뷰할 때는 SELECT 목록과 WHERE 조건을 서로 다른 체크리스트로 봅니다.

결과에 식별자가 빠지면 화면에서 행을 안정적으로 갱신할 수 없고, 단위가 다른 숫자를 value 같은 이름으로 반환하면 소비자가 오해합니다.

반대로 내부 FK나 개인정보를 습관적으로 반환하면 결합과 노출 범위가 커집니다.

조건 쪽에서는 삭제·상태·소유자 범위가 누락되지 않았는지 먼저 확인합니다.

특히 멀티테넌트 서비스의 소유자 조건은 단순 검색 옵션이 아니라 권한 경계입니다.

같은 쿼리를 관리자와 일반 사용자가 공유한다면 권한 조건식을 호출자 선택에 맡기지 말고 접근 계층에서 강제해야 합니다.

정확한 투영과 조건식은 성능 최적화보다 먼저 지켜야 할 데이터 규칙이자 보안 규칙입니다.

컬럼명이 같은 테이블을 나중에 조인하면 author_id처럼 출처가 모호해질 수 있으므로 별칭과 테이블 별칭을 함께 사용합니다.

숫자와 문자열을 암묵적으로 비교하는 쿼리도 피합니다.

MySQL의 형 변환이 값을 예상 밖으로 맞다고 판단할 수 있으므로 파라미터 타입을 컬럼 타입과 맞추고 경고를 확인해야 합니다.

마지막으로 결과가 0행인 경우를 오류와 구분하세요.

조건에 맞는 데이터가 없는 것은 정상 결과일 수 있으며, 애플리케이션은 빈 목록과 데이터베이스 장애를 같은 예외로 처리하지 않아야 합니다.


조회 범위 검토 기준

판단 축확인할 질문
호출자가 실제로 쓰는 컬럼과 표현식만 선택했는가?
필터가 DBMS에서 수행되고 업무 문장과 일치하는가?
논리AND와 OR가 섞인 조건을 괄호로 명확히 했는가?
이름별칭이 값의 의미와 단위를 드러내는가?

운영 조회에서는 결과 예시를 API 규칙처럼 함께 남기는 것이 좋습니다.

열 순서에 의존하는 코드보다 이름으로 읽는 코드를 사용하고, SELECT *는 구조를 탐색하는 일회성 콘솔 조회 정도로 범위를 제한합니다.


연습 문제

author_id = 2인 공개 게시글 중 조회수가 20회 이상 90회 이하인 행을 찾으세요.

결과에는 title, view_count, created_at만 포함하고, 조회수 열은 views라는 이름으로 반환합니다.

OR를 사용하지 말고 두 비교식을 작성해 보세요.

해설과 예시 답안

하한과 상한을 각각 AND로 연결하면 범위의 포함 여부가 분명합니다.

다음 문서에서 같은 조건을 BETWEEN으로 줄여 비교합니다.

SELECT
  title,
  view_count AS views,
  created_at
FROM query_post_demo
WHERE author_id = 2
  AND status = 'PUBLISHED'
  AND view_count >= 20
  AND view_count <= 90
ORDER BY created_at;

20회와 90회인 경계 행도 포함되어야 합니다.

경계를 제외하려면 ><로 바꿔야 하므로 요구사항의 ‘이상·이하’를 연산자에 그대로 옮겼는지 확인합니다.


핵심 정리

  • SELECT 목록은 결과 열의 규칙이고 WHERE는 통과할 행의 조건입니다.
  • 필요 없는 열과 행을 DBMS 밖으로 보낸 뒤 버리지 않습니다.
  • 복합 조건식은 업무 문장과 평가 우선순위를 괄호로 드러냅니다.
  • 조건을 단계별 건수로 검증하면 잘못된 필터를 빠르게 찾을 수 있습니다.

다음 문서에서는 연속 범위, 값 목록, 문자열 패턴을 각각 가장 직접적인 연산자로 표현합니다.