SQL Server: Joins, Indexes, TRIM, and Temp Tables

Jul 22 2026 · 7 min · Sieon

English version

This note summarizes the SQL Server fundamentals that show up repeatedly in real tuning work: join strategies, table storage, nonclustered indexes, string cleanup, and temporary storage choices.

The main lesson is that SQL performance is not only about writing valid T-SQL. It is about understanding how SQL Server chooses to read, join, sort, and store data.

Logical joins and physical joins

When we write SQL, we usually describe a logical join:

SELECT *
FROM dbo.Orders AS o
INNER JOIN dbo.Customers AS c
    ON o.CustomerId = c.CustomerId;

INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN, and CROSS JOIN describe the result set. SQL Server still has to decide how to physically execute that join. Common physical join strategies include Nested Loops Join, Merge Join, and Hash Join.

Nested Loops Join behaves like a nested loop. For each row from the outer input, SQL Server looks for matching rows from the inner input. It can be very fast when the outer input is small and the inner side has a useful index. It can become expensive when SQL Server has to repeat a large lookup many times.

Merge Join works well when both inputs are already ordered by the join key. SQL Server can scan both ordered inputs and merge matching rows. If the inputs are not already sorted, SQL Server may need an extra Sort operation, which can be expensive.

Hash Join builds a hash table from the smaller input and probes it with the larger input. It can be effective for large data sets when useful indexes are not available, but it needs memory. If memory is not enough, SQL Server may spill to tempdb.

Join hints such as LOOP, HASH, and MERGE should usually be a last resort. They force the optimizer toward one physical strategy. A hint that helps today can become a bad plan after data distribution, statistics, or indexes change.

APPLY in SQL Server

CROSS APPLY and OUTER APPLY are useful when the right side needs to be evaluated for each row from the left side. CROSS APPLY behaves like an inner join because it only returns rows with a matching result. OUTER APPLY behaves like a left join because it keeps the left row even when the right side returns nothing.

A common pattern is fetching the most recent related row per parent row:

SELECT e.EQP_ID,
       last_lot.LOT_ID,
       last_lot.CREATED_AT
FROM dbo.Equipment AS e
OUTER APPLY (
    SELECT TOP (1) l.LOT_ID,
           l.CREATED_AT
    FROM dbo.LotHistory AS l
    WHERE l.EQP_ID = e.EQP_ID
    ORDER BY l.CREATED_AT DESC
) AS last_lot;

Heap, clustered index, and nonclustered index

A heap is a table without a clustered index. It has no guaranteed storage order. A clustered index stores the table itself in a B-tree structure ordered by the clustered key. The leaf level of the clustered index contains the actual data rows.

A nonclustered index is a separate B-tree that stores index keys and a row locator. It helps SQL Server find rows quickly, but if the query needs columns that are not in the nonclustered index, SQL Server may have to go back to the heap or clustered index. That extra operation is a lookup.

This is why SELECT * can be expensive. Even if SQL Server can seek into a nonclustered index, it may need repeated lookups to fetch the rest of the columns.

Scan, seek, and lookup

A scan means SQL Server starts reading through a table or index. A scan is not always bad. If the query needs a large part of the table, one scan can be cheaper than thousands of random lookups.

A seek means SQL Server jumps into a useful position in an index. A seek is not always good. A seek can still read a very large range.

A lookup means SQL Server found a row through a nonclustered index, then had to fetch missing columns from the base table. A small number of lookups is fine. A large number of lookups can dominate query cost.

The practical question is not only whether the plan says Scan or Seek. The better question is how many rows SQL Server read, how many rows it returned, and how many logical reads the query produced.

TRIM and data quality

Small string issues can break matching logic. If a user copies and pastes TRAY001 with a leading space, the value may look correct on screen but fail equality comparisons in the database.

SELECT TRIM(' test ') AS Result;
-- Result: test

In production systems, it is better to normalize input before storage instead of trimming only at query time. Normalize at the UI, API, stored procedure, and database boundary where appropriate.

Table variable vs temp table

Table variables are convenient for small scoped data sets:

DECLARE @TB_AAA TABLE
(
    ID      VARCHAR(20),
    EQP_ID  VARCHAR(20),
    LOT_CNT INT
);

Temp tables are often better when the intermediate result is larger, reused multiple times, needs indexes, or affects cost-based optimization:

CREATE TABLE #TEMP01
(
    ID      VARCHAR(20),
    EQP_ID  VARCHAR(20),
    LOT_CNT INT
);

The rule of thumb is simple: use table variables for small, simple, scoped data. Use temp tables when row count, statistics, indexes, or plan quality matter.

Korean notes

예전에 Velog에 정리해두었던 SQL Server 메모를 다시 읽어보니, 단순 문법보다 실무에서 자주 부딪히는 지점들이 많았다. Join이 왜 느려지는지, 인덱스가 왜 있어도 Scan이 나오는지, 복사한 공백 하나 때문에 기준정보 매칭이 왜 깨지는지, table variable과 temp table을 언제 나눠 써야 하는지 같은 문제들이다.

이 글은 회사에서 공부한 SQL Server 메모를 기준으로, SQL Server에서 자주 쓰는 기본기를 다시 정리한 것이다. Database 카테고리의 SQL Server 시리즈 첫 글로, Join과 Index, 문자열 정리, Temp Table을 한 번에 묶었다.

  • Join 알고리즘과 Join hint
  • Heap, clustered index, nonclustered index
  • LTRIM, RTRIM, TRIM으로 입력값 정리하기
  • Table variable과 temp table의 차이

1. SQL Server의 Join은 논리 Join과 물리 Join을 나눠서 봐야 한다

SQL을 작성할 때 우리가 쓰는 것은 보통 논리 Join이다.

SELECT *
FROM dbo.Orders AS o
INNER JOIN dbo.Customers AS c
    ON o.CustomerId = c.CustomerId;

여기서 INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN, CROSS JOIN은 결과 집합의 의미를 정의한다. 반면 SQL Server가 실제로 어떤 방식으로 두 입력을 결합할지는 실행 계획에서 결정된다. Microsoft 문서도 SQL Server의 물리 Join 방식으로 Nested Loops Join, Merge Join, Hash Join, Adaptive Join을 설명한다.

실무에서 먼저 기억할 세 가지는 다음이다.

  • Nested Loops Join
  • Merge Join
  • Hash Join

Nested Loops Join

Nested Loops Join은 이름 그대로 중첩된 반복문처럼 동작한다.

for each row in outer input:
    find matching rows in inner input

외측 입력에서 들어오는 행 수가 적고, 내측 입력에 적절한 인덱스가 있으면 매우 빠를 수 있다. 예를 들어 주문 한 건의 상세 정보나 특정 고객의 거래 내역처럼 작은 범위를 반복적으로 찾는 OLTP 쿼리에 잘 맞는다.

하지만 외측 입력이 커지고 내측 입력을 매번 많이 읽어야 한다면 비용이 커진다. 인덱스가 없거나 선택도가 낮으면 같은 조회를 수천 번, 수만 번 반복하는 모양이 될 수 있다.

메모에 적어둔 “2중 for loop 형태”라는 표현이 맞다. 다만 중요한 것은 반복 자체가 나쁜 것이 아니라, 반복할 때마다 내측 입력을 얼마나 싸게 찾을 수 있느냐다.

Merge Join

Merge Join은 양쪽 입력이 Join key 기준으로 정렬되어 있을 때 강하다. 이미 정렬된 두 목록을 앞에서부터 비교하면서 병합하는 방식이다.

정렬된 인덱스를 그대로 사용할 수 있으면 효율적이지만, 정렬이 안 되어 있다면 먼저 Sort 비용이 필요하다. 그래서 Merge Join은 보통 다음 상황에서 잘 맞는다.

  • 양쪽 입력이 Join key로 이미 정렬되어 있음
  • 인덱스 순서를 활용할 수 있음
  • 비교해야 할 데이터가 어느 정도 큼
  • Scan 기반으로 넓은 범위를 처리함

메모에 적어둔 “스캔 위주의 조인 방식”이라는 표현은 이런 맥락에서 이해하면 된다. Merge Join은 단건 탐색보다는 정렬된 큰 흐름을 순차적으로 읽는 데 강하다.

Hash Join

Hash Join은 작은 쪽 입력으로 hash table을 만들고, 큰 쪽 입력을 읽으면서 hash key로 매칭하는 방식이다.

build phase: smaller input -> hash table
probe phase: larger input -> lookup in hash table

인덱스를 활용하기 어렵고, 결과 정렬이 필요 없으며, 비교할 데이터가 큰 경우에 효과적일 수 있다. 랜덤 액세스를 반복하거나 Sort를 강제로 수행하는 대신, 메모리에 hash table을 만들어 탐색한다.

대신 hash table을 만드는 비용과 메모리 사용량이 있다. 메모리가 부족하면 tempdb를 사용하게 되어 성능이 떨어질 수 있다. 따라서 Hash Join은 “인덱스가 없을 때 무조건 빠른 방식”이 아니라, 큰 데이터 집합을 처리할 때 옵티마이저가 선택할 수 있는 중요한 물리 Join 전략으로 보는 것이 좋다.

Join hint는 마지막 수단이다

SQL Server는 LOOP, HASH, MERGE Join hint를 지원한다.

SELECT p.Name, pr.ProductReviewID
FROM Production.Product AS p
LEFT OUTER HASH JOIN Production.ProductReview AS pr
    ON p.ProductID = pr.ProductID;

하지만 Join hint는 옵티마이저에게 특정 Join 전략을 강제한다. Microsoft 문서도 Join hint는 일반적으로 마지막 수단으로 사용하라고 권고한다. 통계, 인덱스, 데이터 분포가 바뀌면 예전에 빠르던 hint가 나중에는 오히려 느린 계획을 고정할 수 있기 때문이다.

또 하나 주의할 점은 LOOP hint는 RIGHT JOIN이나 FULL JOIN과 함께 지정할 수 없다는 점이다.

2. INNER JOIN과 APPLY

SQL Server에서는 APPLY도 자주 헷갈린다.

간단히 보면:

  • CROSS APPLY는 매칭되는 결과가 있는 행만 반환한다는 점에서 INNER JOIN처럼 사용할 수 있다.
  • OUTER APPLY는 매칭 결과가 없어도 왼쪽 행을 유지한다는 점에서 LEFT JOIN처럼 사용할 수 있다.

예를 들어 각 설비별 가장 최근 작업 이력을 가져오고 싶을 때 OUTER APPLY가 읽기 좋은 경우가 있다.

SELECT e.EQP_ID,
       last_lot.LOT_ID,
       last_lot.CREATED_AT
FROM dbo.Equipment AS e
OUTER APPLY (
    SELECT TOP (1) l.LOT_ID,
           l.CREATED_AT
    FROM dbo.LotHistory AS l
    WHERE l.EQP_ID = e.EQP_ID
    ORDER BY l.CREATED_AT DESC
) AS last_lot;

LEFT JOIN으로도 표현할 수 있지만, “왼쪽 행마다 오른쪽 서브쿼리를 평가한다”는 구조가 필요한 경우에는 OUTER APPLY가 더 자연스럽다.

3. 테이블 저장 구조: Heap과 Clustered Index

SQL Server rowstore 테이블은 크게 두 가지 형태로 볼 수 있다.

  1. Heap: clustered index가 없는 테이블
  2. Clustered index table: clustered index key 기준으로 B+ tree 구조에 저장된 테이블

Heap

Heap은 clustered index가 없는 테이블이다. 데이터는 특정 key 순서로 정렬되어 저장되지 않는다. SQL Server 문서에 따르면 heap의 데이터 순서는 예측할 수 없고, 반환 순서를 보장하려면 ORDER BY를 사용해야 한다.

Heap의 장점은 단순하다.

  • 정렬 순서를 유지할 필요가 없다.
  • clustered index tree를 유지하는 비용이 없다.
  • staging table처럼 자주 비우고 다시 채우는 테이블에 유리할 수 있다.

하지만 큰 heap에 적절한 nonclustered index가 없다면 특정 행을 찾기 위해 table scan이 필요하다. 또한 업데이트로 인해 행 크기가 커지면 forwarded record가 생겨 scan 성능이 나빠질 수 있다.

그래서 일반 업무 테이블은 특별한 이유가 없다면 잘 고른 clustered index를 가지는 편이 보통 더 안정적이다.

Clustered Index

Clustered index는 테이블 자체를 index key 기준의 B+ tree 형태로 저장한다. 즉 leaf level에 실제 데이터 행이 있다.

이 구조는 다음 상황에서 유리하다.

  • 특정 key로 범위 검색이 많음
  • 정렬된 결과가 자주 필요함
  • Join key나 lookup 기준으로 자주 사용됨

다만 clustered index key는 nonclustered index에도 row locator로 들어갈 수 있으므로, 너무 넓거나 자주 바뀌는 key는 부담이 될 수 있다.

Nonclustered Index

Nonclustered index는 테이블과 별도로 만들어지는 B+ tree다. index key와 row locator를 가지고 있고, 필요한 경우 실제 데이터 행을 찾으러 heap 또는 clustered index로 이동한다.

이때 nonclustered index에 없는 컬럼을 추가로 가져오기 위해 원본 테이블을 찾아가는 작업을 보통 lookup이라고 부른다.

예를 들어 다음 쿼리에서 col3, col5에는 인덱스가 있지만 SELECT * 때문에 다른 컬럼까지 필요하다면 lookup이 발생할 수 있다.

SELECT *
FROM dbo.TIndex
WHERE col3 = 1
  AND col5 = '2013-05-09 02:36:41.947';

소량 lookup은 괜찮지만, 많은 행에서 lookup이 반복되면 Nested Loops와 결합되어 성능 문제가 커질 수 있다. 이때는 필요한 컬럼만 선택하거나, include column을 가진 covering index를 검토할 수 있다.

4. Scan, Seek, Lookup을 구분하기

실행 계획을 볼 때 Scan, Seek, Lookup은 반드시 구분해야 한다.

Scan

Scan은 테이블 전체 또는 인덱스의 leaf level을 넓게 읽는 방식이다.

Scan이 항상 나쁜 것은 아니다. 큰 범위를 대부분 읽어야 한다면 seek를 반복하는 것보다 scan이 더 싸다. 문제는 아주 적은 행만 필요한데도 적절한 인덱스가 없어 전체를 읽는 경우다.

Seek

Seek는 인덱스 tree를 타고 조건에 맞는 범위나 값을 찾아 들어가는 방식이다.

SELECT *
FROM dbo.TIndex
WHERE col3 = 1
  AND col5 = '2013-05-09 02:36:41.947';

col3, col5 조합에 맞는 인덱스가 있고 조건 선택도가 좋다면 seek가 가능하다.

Lookup

Lookup은 nonclustered index에서 찾은 row locator를 이용해 heap이나 clustered index로 원본 행을 다시 찾는 작업이다.

Lookup 자체가 나쁜 것은 아니지만, 반복 횟수가 많아지면 비용이 커진다. 실행 계획에서 Key Lookup이나 RID Lookup이 많이 반복된다면 다음을 확인한다.

  • 정말 SELECT *가 필요한가?
  • 필요한 컬럼만 include한 인덱스를 만들 수 있는가?
  • 결과 행 수가 예상보다 많은가?
  • 통계가 오래되어 cardinality estimate가 틀렸는가?

5. TRIM: 복사한 공백 하나가 기준정보 매칭을 깨뜨릴 때

업무 UI에서 기준정보를 저장할 때 사용자가 값을 복사해서 붙여넣는 경우가 많다. 이때 앞뒤 공백까지 같이 저장되면 화면에는 비슷해 보여도 DB 값은 다르다.

예를 들어 사용자는 TRAY001을 입력했다고 생각하지만 실제 저장값이 ' TRAY001'이면 비교 조건이 맞지 않을 수 있다.

-- 기존
WHERE TRAY_ID = @TRAY_ID

-- 앞쪽 공백 제거
WHERE TRAY_ID = LTRIM(@TRAY_ID)

LTRIM은 문자열 왼쪽의 공백을 제거한다. RTRIM은 오른쪽 공백을 제거한다.

SELECT LTRIM(' test')  AS LeftTrimmed;
SELECT RTRIM('test ')  AS RightTrimmed;

SQL Server 2017 이상에서는 TRIM을 사용해 양쪽 공백을 한 번에 제거할 수 있다.

SELECT TRIM(' test ') AS Result;
-- Result: test

실무에서는 비교 조건에만 TRIM을 붙이는 것보다, 저장 전에 입력값을 정규화하는 편이 더 좋다. 같은 값이 공백 포함/미포함으로 섞여 저장되면 인덱스 활용과 데이터 품질 모두 나빠질 수 있기 때문이다.

가능하면 다음 순서로 보는 것이 좋다.

  1. UI에서 입력값 trim
  2. API 또는 procedure 입구에서 trim
  3. DB 저장 전 최종 trim
  4. 이미 저장된 데이터 정리

6. Table Variable과 Temp Table

SQL Server에서 임시로 데이터를 담을 때는 table variable과 temp table을 자주 쓴다.

Table Variable

문법은 다음과 같다.

DECLARE @TB_AAA TABLE
(
    ID      VARCHAR(20),
    EQP_ID  VARCHAR(20),
    LOT_CNT INT
);

table variable은 선언된 batch, stored procedure, function 안에서만 사용할 수 있고, scope가 끝나면 자동으로 정리된다. Microsoft 문서도 table variable은 작은 규모의 쿼리나 plan 변화가 크지 않은 경우에 이점이 있다고 설명한다.

다만 예전 SQL Server 버전에서는 table variable에 통계가 없어서 옵티마이저가 행 수를 잘못 추정하는 문제가 자주 있었다. SQL Server 2019의 compatibility level 150부터 table variable deferred compilation이 개선을 제공하지만, 큰 데이터나 복잡한 Join에서는 여전히 temp table이 더 나은 경우가 많다.

Temp Table

Local temp table은 # prefix를 사용한다.

IF OBJECT_ID('tempdb..#TEMP01') IS NOT NULL
    DROP TABLE #TEMP01;

CREATE TABLE #TEMP01
(
    ID      VARCHAR(20),
    EQP_ID  VARCHAR(20),
    LOT_CNT INT
);

Local temp table은 tempdb에 만들어지고, 생성한 session 범위에서 사용할 수 있다. 같은 이름의 temp table을 여러 session에서 만들어도 SQL Server 내부적으로 이름을 구분한다. 그래서 서로 다른 session에서 #TEMP01을 만들어도 충돌하지 않는다.

SQL Server의 tempdb는 local/global temporary table, table variable, sort/hash 작업의 work table 등 여러 임시 객체를 담는 시스템 데이터베이스다. SQL Server 인스턴스가 재시작될 때 다시 생성되고, local temporary table은 생성한 session이 종료되면 자동으로 삭제된다.

언제 Temp Table을 쓰는가

내 기준으로는 다음 상황이면 temp table을 먼저 검토한다.

  • 담아야 할 데이터가 많다.
  • 중간 결과를 여러 번 재사용한다.
  • 복잡한 Join을 단계별로 나누고 싶다.
  • 모델링만으로 쿼리 복잡도를 줄이기 어렵다.
  • 반복되는 서브쿼리를 한 번 계산해서 재사용하고 싶다.
  • 가공된 데이터만 다음 단계에서 사용하고 싶다.
  • 중간 결과에 인덱스를 추가해야 한다.

반대로 작은 row set을 잠깐 담고, 복잡한 cost-based plan 선택이 중요하지 않다면 table variable이 더 간단할 수 있다.

정리

SQL Server 성능 문제는 대부분 “문법을 몰라서”보다 “실행 방식이 어떻게 바뀌는지 몰라서” 생긴다.

  • Join 문법은 같아도 실제 실행은 Nested Loops, Merge, Hash로 달라진다.
  • Heap과 clustered index는 데이터 저장 방식 자체가 다르다.
  • Nonclustered index는 빠른 탐색을 도와주지만 lookup 비용을 만들 수 있다.
  • Scan은 항상 나쁜 것이 아니고, Seek는 항상 좋은 것도 아니다.
  • LTRIM, RTRIM, TRIM은 단순 함수처럼 보이지만 기준정보 품질 문제를 막는 데 중요하다.
  • Table variable과 temp table은 scope, 통계, 인덱스, 데이터 크기를 기준으로 선택해야 한다.

처음에는 실행 계획이 복잡해 보이지만, 결국 핵심은 단순하다. SQL Server가 데이터를 어떻게 저장하고, 어떻게 읽고, 어떤 중간 구조를 만들어 Join하는지를 보면 쿼리 튜닝의 방향이 훨씬 명확해진다.

References

  1. Joins (SQL Server)
  2. Join hints (Transact-SQL)
  3. Index architecture and design guide
  4. TRIM (Transact-SQL)
  5. tempdb database