BigQuery PIPE 사용법 : SQL의 새로운 형태


  • 2024년 연말에 새로 생긴 BigQuery Pipe 사용법에 대해 작성한 글입니다
    • 이 글은 2025년 3월 초 기준으로 작성되었습니다
    • 파이프 문법 Cheatsheet가 궁금하면 BigQuery 가이드북을 방문해주세요
  • 키워드 : BigQuery Pipe, BigQuery PIPE syntax



BigQuery Pipe Syntax

기존 SQL의 문제점

  • SQL은 데이터 작업을 위한 표준 언어가 되었고, 대부분의 SQL 엔진 대부분 비슷한 문법을 사용함
    • 하지만 SQL은 배우기 어렵고, 사용하기 어렵고, 확장하기 어려움
    • 이런 문제를 해결하기 위해 새로운 언어를 개발해서 제안한 사람들이 있으나, 새로운 언어를 채택하는 과정은 반발심도 있어서 쉽지 않음
  • SQL은 다음과 같은 문제점이 존재함
    • 고정된 구문 순서
    • 중복된 구문(예 : 필터링)
    • 서브 쿼리 사용
    • 내부 -> 외부의 데이터 흐름
    • 동일한 컬럼들의 반복

고정된 구문 순서

  • SELECT - FROM - WHERE - GROUP BY 순서가 고정되어 있어서 유연성이 떨어짐
    • 문법과 의미 관점에서 순서가 다름
  • 데이터는 FROM에서 나오기 때문에, FROM 절부터 생각해야 함
    • 그러나 쿼리의 시작이 SELECT라 헷갈리게 됨
    • 처음 SQL을 학습할 때 많은 분들이 이 부분에 대해 어려워 함

중복된 구문(예 : 필터링)

  • 데이터를 필터링할 때, WHERE, HAVING, QUALIFY를 사용할 수 있음
    • 이 3개가 사용되는 경우는 모두 다르지만, 결국에 데이터를 필터링할 때 사용됨. 그래서 처음 SQL을 공부하는 사람이 헷갈리게 됨. 언제 WHERE? 언제 HAVING?
  • 아래 쿼리를 보면 WHERE, HAVING, QUALIFY에 대해 작성되어 있음. 궁극적으로 이걸 하나로 통일할 수는 없을까?
SELECT
  customer_name,
  order_date,
  SUM(amount) AS total,
  RANK() OVER (PARTITION BY customer_name ORDER BY SUM(amount) DESC) AS rk
FROM customers 
JOIN orders ON customers.id = orders.customer_id
WHERE region = 'East'           -- 집계 전 필터링
GROUP BY customer_name, order_date
HAVING SUM(amount) > 1000       -- 집계 후 필터링
QUALIFY rk = 1  

서브 쿼리 사용

  • 다중 집계, 계산된 표현의 재사용 등을 위해 서브 쿼리를 사용해야 함
    • 서브 쿼리는 들여쓰기가 많아져서 가독성을 해치고, 편집할 때 실수를 유발할 가능성이 생김
  • 간단한 연산이지만 서브 쿼리가 필요한 경우
    • 두 번 이상 합산하기
    • 지원되는 세 곳(WHERE, HAVING, QUALIFY) 이외의 위치에 필터링을 해야할 때, 서브 쿼리로 한번 감싸서 처리해야 함
  • BigQuery ML을 사용해서 임베딩 모델을 예측하고, 그 후에 예측할 때 서브 쿼리를 사용해야 함(2번 연속으로 서브 쿼리 사용)
SELECT *
FROM ML.PREDICT(
  MODEL `my_project.imdb_classifier`,
  (
    SELECT *
    FROM ML.PREDICT(
      MODEL `my_project.nnlm_embedding_model`,
      (SELECT '<text>' AS input, 7 AS rating)
    )
  )
)

내부 -> 외부의 데이터 흐름

  • 서브 쿼리, JOIN 등이 혼합된 쿼리를 볼 때 제일 안쪽에 있는 FROM을 확인하고, 바깥쪽으로 쿼리문을 읽어야 함
    • 이 과정이 직관적이지 않고, 쿼리를 보는 시간을 늘림
    • 아래 쿼리를 보면 FROM customer 부터 이해를 하고, 그 안에 있는 부분 => 바깥을 이해해야 함
SELECT
  c_count,
  COUNT(*) AS custdist
FROM (
  SELECT
    c_custkey,
    COUNT(o_orderkey) c_count
  FROM customer
  LEFT OUTER JOIN orders ON c_custkey = o_custkey
  AND o_comment NOT LIKE '%unusual%packages%'
  GROUP BY c_custkey
) AS c_orders
GROUP BY
  c_count
ORDER BY
  custdist DESC,
  c_count DESC;

동일한 컬럼들의 반복

  • SELECT, GROUP BY, ORDER BY 등에 중복된 컬럼을 사용하는 경우가 있음
  • 하나의 컬럼을 변경할 때, 다른 곳을 모두 변경해야 함(물론 GROUP BY는 GROUP BY ALL로 대체할 수 있긴 함)
  • 아래 쿼리에서 SELECT에 있는 컬럼을 바꾸면 GROUP BY, ORDER BY도 수정해야 할 수 있음
SELECT
  department,
  employee_name,
  salary, 
  RANK() OVER (PARTITION BY department ORDER BY salary DESC) as rank
FROM employees
GROUP BY
  department,
  employee_name,
  salary
ORDER BY
  department,
  rank



GoogleSQL의 시도 : PIPE 구문 추가

  • GoogleSQL에선 새로운 언어를 만드는 방법이 아닌 기존 SQL 기능을 확장해서 문제를 해결하려고 함
    • SQL에 data flow syntax를 추가했고 확장하기 쉽고 유연하게 되었음
    • data flow syntax는 쉘 커맨드의 파이프 구문, 스파크의 체이닝과 유사하다고 보면 됨
  • SQL에서 표준 절은 정의된 하나의 순서로 발생함. 파이프 구문을 사용하면 임의의 순서로 연산을 만들 수 있음
FROM Produce
|> WHERE item != 'bananas' AND category IN ('fruit', 'nut')
|> AGGREGATE COUNT(*) AS num_items, SUM(sales) AS total_sales
   GROUP BY item
|> ORDER BY item DESC

BigQuery PIPE 문법의 특징

  • Linear Flow : 순차적 실행
  • Reduced Nesting : 서브쿼리를 사용하지 않음
  • 재생산성이 가능하고 직관적임
  • FROM 부터 시작함
  • 파이프 연산자로 |>을 사용함
  • 파이프 연산자는 유효한 쿼리 뒤에 계속 추가할 수 있음
  • 아래 이미지를 보면 파이프 문법에선 의미론적인 순서와 동일해짐


파이프 문자(|>)를 사용하는 이유

  • 친숙함과 선례 관점
    • (1) 유닉스 파이프는 널리 이해되는 패러다임으로, 이 구문과 동작을 친숙하고 이해하기 쉽게 만듦
    • (2) 다른 데이터 처리 언어(예: KQL)도 파이프 구문을 사용함
    • (3) 파이프와 같은 데이터 흐름은 Pandas DataFrames와 Apache Beam 등의 데이터 처리 프레임워크에서도 사용됨
  • 기술적 관점
    • (1) 파이프 연산자를 사용하면 어디서 구문이 시작되는지 명확히 알 수 있음
    • (2) 파싱하는 방법이 간단해져서 확장성이 증가됨
    • (3) 파이프 연산자는 이전 단계의 결과에 존재하는 컬럼만 접근할 수 있음. 명확해지고 예측이 가능해짐
    • (4) 파이프 연산자를 사용해서 순차적으로 연산을 진행할 수 있음
  • 가독성 관점
    • (1) 언제 파이프 연산자로 전환되는지 인식하기 수월함
    • (2) 여러 절로 구성된 파이프 연산자를 하나의 논리적인 단위로 인식할 수 있게 해줌

        |> AGGREGATE SUM(sales) AS total_sales, AVG(price) AS avg_price
           GROUP BY region, product
      
    • (3) 구조적 가시성 : 쿼리를 순차적인 N개의 연산으로 표현 가능. 쿼리의 논리적 흐름을 시각적으로 더 잘 반영함
  • 파이프 문자로 |을 사용할 수 있지만, 비트 연산에 사용되기 때문에 헷갈리고 충돌이 생기게 됨. 그래서 |>을 선택함




BigQuery PIPE 문법

  • 공식 문서에서 확인할 수 있고, 앞으로 계속 늘어날 것으로 예상
  • 일반적으로 사용하는 FROM, SELECT, JOIN, WHERE, LIMIT, ORDER BY은 동일하다고 생각하면 됨
  • 아래에선 PIPE 문법 중 특별한 부분만 기록



SET

  • SELECT * REPLACE(expression AS col)와 동일한 표현
  • 컬럼을 다른 값으로 대체함
(
  SELECT 1 AS x, 11 AS y
  UNION ALL
  SELECT 2 AS x, 22 AS y
)
|> SET x = x * x, y = 3;

/*
+---+---+
| x | y |
+---+---+
| 1 | 3 |
| 4 | 3 |
+---+---+
*/


EXTEND

  • SELECT *, new_col과 동일한 표현
  • 기존 테이블의 모든 컬럼에 새로운 컬럼 추가
    • 파이프 문법에선 이전 결과에 나오는 컬럼만 선택할 수 있어서 EXTEND를 사용해야 할 수 있음
(
  SELECT 'apples' AS item, 2 AS sales
  UNION ALL
  SELECT 'carrots' AS item, 8 AS sales
)
|> EXTEND item IN ('carrots', 'oranges') AS is_orange;

/*
+---------+-------+------------+
| item    | sales | is_orange |
+---------+-------+------------+
| apples  | 2    | FALSE     |
| carrots | 8    | TRUE      |
+---------+-------+------------+
*/


AGGREGATE

  • 전체 테이블 또는 컬럼의 값에 대한 집계 수행
  • 전체 테이블 집계

      -- Full-table aggregation
      (
        SELECT 'apples' AS item, 2 AS sales
        UNION ALL
        SELECT 'bananas' AS item, 5 AS sales
        UNION ALL
        SELECT 'apples' AS item, 7 AS sales
      )
      |> AGGREGATE COUNT(*) AS num_items, SUM(sales) AS total_sales;
        
      /*
      +-----------+-------------+
      | num_items | total_sales |
      +-----------+-------------+
      | 3        | 14        |
      +-----------+-------------+
      */
    
  • GROUP BY와 함께 사용해서 집계

      -- Aggregation with grouping
      (
        SELECT 'apples' AS item, 2 AS sales
        UNION ALL
        SELECT 'bananas' AS item, 5 AS sales
        UNION ALL
        SELECT 'apples' AS item, 7 AS sales
      )
      |> AGGREGATE COUNT(*) AS num_items, SUM(sales) AS total_sales
         GROUP BY item;
        
      /*
      +---------+-----------+-------------+
      | item   | num_items | total_sales |
      +---------+-----------+-------------+
      | apples  | 2       | 9         |
      | bananas | 1       | 5         |
      +---------+-----------+-------------+
      */
    
  • GROUP AND ORDER BY를 사용하면 컬럼을 반복적으로 입력하지 않아도 괜찮음

      -- Aggregation with grouping and order by
      (
        SELECT 'apples' AS item, 2 AS sales
        UNION ALL
        SELECT 'bananas' AS item, 5 AS sales
        UNION ALL
        SELECT 'apples' AS item, 7 AS sales
      )
      |> AGGREGATE COUNT(*) AS num_items, SUM(sales) AS total_sales
         GROUP AND ORDER BY item;
    


PIVOT

  • StandardSQL의 PIVOT 함수를 사용할 수도 있음
  • 열(Row)을 컬럼(Column)으로 회전
(
  SELECT "kale" AS product, 51 AS sales, "Q1" AS quarter
  UNION ALL
  SELECT "kale" AS product, 4 AS sales, "Q1" AS quarter
  UNION ALL
  SELECT "kale" AS product, 45 AS sales, "Q2" AS quarter
  UNION ALL
  SELECT "apple" AS product, 8 AS sales, "Q1" AS quarter
  UNION ALL
  SELECT "apple" AS product, 10 AS sales, "Q2" AS quarter
)
|> PIVOT(SUM(sales) FOR quarter IN ('Q1', 'Q2'));

/*
+---------+----+------+
| product | Q1 | Q2  |
+---------+-----------+
| kale    | 55 | 45  |
| apple   | 8  | 10  |
+---------+----+------+
*/

UNPIVOT

  • 컬럼(Column)을 열(Row)로 회전
(
  SELECT 'kale' as product, 55 AS Q1, 45 AS Q2
  UNION ALL
  SELECT 'apple', 8, 10
)
|> UNPIVOT(sales FOR quarter IN (Q1, Q2));

/*
+---------+-------+---------+
| product | sales | quarter |
+---------+-------+---------+
| kale   | 55    | Q1    |
| kale   | 45    | Q2    |
| apple  | 8     | Q1    |
| apple  | 10    | Q2    |
+---------+-------+---------+
*/

UNION ALL BY NAME

  • UNION ALL을 할 때 이름으로 자동으로 매칭해줌(UNION ALL만 사용하면 순서도 맞춰줘야 함
SELECT 1 AS one_digit, 10 AS two_digit
|> UNION ALL BY NAME
    (SELECT 20 AS two_digit, 2 AS one_digit);

/*
+-----------+-----------+
| one_digit | two_digit |
+-----------+-----------+
| 1        | 10      |
| 2        | 20      |
+-----------+-----------+
*/


CALL

  • 테이블 값 함수(Table Value Function, TVF) 호출
FROM input_table
|> CALL tvf1(arg1)
|> CALL tvf2(arg2, arg3);


WINDOW

  • 윈도우 함수 계산하고 싶을 때 사용
    • 윈도우 함수는 AGGREGATE와 사용하는 것이 아니고 WINDOW를 명시해서 사용해야 함
(
  SELECT 'apples' AS item, 2 AS sales
  UNION ALL
  SELECT 'bananas' AS item, 5 AS sales
  UNION ALL
  SELECT 'carrots' AS item, 8 AS sales
)
|> WINDOW SUM(sales) OVER() AS total_sales;

/*
+---------+-------+-------------+
| item   | sales | total_sales |
+---------+-------+-------------+
| apples  | 2    | 15        |
| bananas | 5    | 15        |
| carrots | 8    | 15        |
+---------+-------+-------------+
*/


DROP

  • 컬럼을 제외하고 싶을 때 사용
SELECT 'apples' AS item, 2 AS sales, 'fruit' AS category
|> DROP sales, category;

/*
+--------+
| item  |
+--------+
| apples |
+--------+
*/


RENAME

  • 컬럼의 이름을 변경하고 싶은 경우 사용
SELECT 1 AS x, 2 AS y, 3 AS z
|> AS t
|> RENAME y AS renamed_y
|> SELECT *, t.y AS t_y;

/*
+---+-----------+---+-----+
| x | renamed_y| z | t_y |
+---+-----------+---+-----+
| 1 | 2       | 3 | 2  |
+---+-----------+---+-----+
*/


AS

  • Input 테이블에 대해 Alias를 지정하고 싶을 때 사용
    • WITH base 대신 AS를 바로 사용해서 쓸 수 있음
(
  SELECT "000123" AS id, "apples" AS item, 2 AS sales
  UNION ALL
  SELECT "000456" AS id, "bananas" AS item, 5 AS sales
) AS sales_table
|> AGGREGATE SUM(sales) AS total_sales GROUP BY id, item
-- The sales_table alias is now out of scope. We must introduce a new one.
|> AS t1
|> JOIN (SELECT 456 AS id, "yellow" AS color) AS t2
   ON CAST(t1.id AS INT64) = t2.id
|> SELECT t2.id, total_sales, color;


/*
+-----+-------------+--------+
| id  | total_sales| color  |
+-----+-------------+--------+
| 456 | 5         | yellow |
+-----+-------------+--------+
*/


언제 유용할까?

  • BigQuery 처음 학습하는 분들에게 추천
    • 그러나 회사에서 BigQuery를 사용하지 않으면 애매함(다른 DB에선 아직 지원을 안할 수 있으므로)
  • Text to SQL을 구현할 때 추천
    • 기존의 SQL 쿼리에서 파싱이 어려웠는데, 이젠 명확하게 쿼리를 분석하기 좋음
    • 자연어로 쿼리를 생성할 때, 파이프를 사용하는 것이 더 유리할 것으로 예상
    • 유사하게 Programmatic하게 쿼리를 생성할 때도 추천
  • 데이터 엔지니어링 파이프라인
    • 데이터 엔지니어링 파이프라인 중 Flow가 중요한 경우 추천
    • 쉘 커맨드에서 데이터를 처리하는 것처럼 사용할 수 있음


문법 정리



StandardSQL vs Pipe 쿼리 비교

  • 초보자를 위한 BigQuery(SQL) 입문 강의에서 진행한 연습 문제 쿼리를 비교
  • StandardSQL

      SELECT
        p.type1,
        COUNT(tp.id) AS pokemon_cnt
      FROM (
        SELECT
          id,
          trainer_id,
          pokemon_id,
          status
        FROM basic.trainer_pokemon
        WHERE
          status IN ("Active", "Training")
      ) AS tp
      LEFT JOIN basic.pokemon AS p
      ON tp.pokemon_id = p.id
      WHERE
        type1 = "Grass"
      GROUP BY
        type1
      ORDER BY
        2 DESC 
    
  • PIPE

      FROM basic.trainer_pokemon AS tp
      |> WHERE status IN ('Active', 'Training')
      |> LEFT JOIN basic.pokemon AS p
        ON tp.pokemon_id = p.id  
      |> WHERE type1 = 'Grass'
      |> AGGREGATE
        COUNT(tp.id) AS pokemon_cnt
        GROUP BY p.type1
      |> ORDER BY pokemon_cnt DESC
    

  • 확실히 더 직관적이고, 짧아짐


Firebase, 앱 로그 데이터의 퍼널 쿼리 비교

  • BigQuery 활용편(퍼널 분석, 리텐션 분석)에서 진행한 쿼리 비교
  • StandardSQL

      WITH base AS (
        SELECT
          event_date,
          event_timestamp,
          event_name,
          user_id,
          user_pseudo_id,
          platform,
          MAX(IF(event_param.key = "firebase_screen", event_param.value.string_value, NULL)) AS firebase_screen,
          MAX(IF(event_param.key = "session_id", event_param.value.string_value, NULL)) AS session_id
        FROM advanced.app_logs
        CROSS JOIN UNNEST(event_params) AS event_param
        WHERE
          event_date BETWEEN "2022-08-01" AND "2022-08-18"
        GROUP BY ALL
      ), filter_event_and_concat_event_and_screen AS (
        SELECT
          * EXCEPT(event_name, firebase_screen, event_timestamp),
          CONCAT(event_name, "-", firebase_screen) AS event_name_with_screen,
          DATETIME(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul') AS event_datetime
        FROM base
        WHERE
          event_name IN ("screen_view", "click_payment")
      )
      SELECT
        event_date, # 일자별로 퍼널별 유저 수 쿼리
        event_name_with_screen,
        CASE 
          WHEN event_name_with_screen = "screen_view-welcome" THEN 1
          WHEN event_name_with_screen = "screen_view-home" THEN 2
          WHEN event_name_with_screen = "screen_view-food_category" THEN 3
          WHEN event_name_with_screen = "screen_view-restaurant" THEN 4
          WHEN event_name_with_screen = "screen_view-cart" THEN 5
          WHEN event_name_with_screen = "click_payment-cart" THEN 6
        ELSE NULL
        END AS step_number,
        COUNT(DISTINCT user_pseudo_id) AS cnt
      FROM filter_event_and_concat_event_and_screen
      GROUP BY ALL
      HAVING step_number IS NOT NULL
      ORDER BY event_date, step_number
    
  • PIPE
    • 좀 더 간결하게 만들 수 있을 것 같지만 일단 이정도로 올려봄
      FROM advanced.app_logs
      |> CROSS JOIN UNNEST(event_params) AS event_param
      |> WHERE event_date BETWEEN "2022-08-01" AND "2022-08-18" AND event_name IN ("screen_view", "click_payment")
      |> AGGREGATE
         MAX(IF(event_param.key = "firebase_screen", event_param.value.string_value, NULL)) AS firebase_screen,
         MAX(IF(event_param.key = "session_id", event_param.value.string_value, NULL)) AS session_id
         GROUP BY event_date, event_timestamp, event_name, user_id, user_pseudo_id, platform
      |> SELECT
          * EXCEPT(event_name, firebase_screen, event_timestamp),  
          CONCAT(event_name, "-", firebase_screen) AS event_name_with_screen,
          DATETIME(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul') AS event_datetime
      |> EXTEND
          CASE
            WHEN event_name_with_screen = "screen_view-welcome" THEN 1
            WHEN event_name_with_screen = "screen_view-home" THEN 2      
            WHEN event_name_with_screen = "screen_view-food_category" THEN 3
            WHEN event_name_with_screen = "screen_view-restaurant" THEN 4       
            WHEN event_name_with_screen = "screen_view-cart" THEN 5
            WHEN event_name_with_screen = "click_payment-cart" THEN 6        
            ELSE NULL
          END AS step_number
      |> WHERE step_number IS NOT NULL    
      |> AGGREGATE
           COUNT(DISTINCT user_pseudo_id) AS cnt
           GROUP BY event_date, event_name_with_screen, step_number
      |> ORDER BY event_date, step_number
    

StandardSQL -> PIPE 마이그레이션 가이드

  • 1주일 정도 파이프 문법을 사용하면서 어떻게 옮겨야 할까? 생각해보고 작성한 내용입니다. 추후에 더 추가될 수도 있음
  • FROM 절은 항상 쿼리의 시작
    • 기존: FROM 절이 SELECT 절 뒤에 위치
    • 파이프: FROM 절을 쿼리의 가장 앞으로 이동
  • JOIN은 FROM 절 안에 포함하거나 파이프 연산자로 분리
    • 기존: FROM 절 안에서 JOIN 사용
    • 파이프: FROM 절 안에 JOIN을 그대로 사용하거나, 별도의 파이프 연산자로 분리
  • WHERE 절은 필터링이 필요한 위치에 파이프 연산자를 사용하여 배치
    • 기존: WHERE 절은 FROM 절과 GROUP BY 절 사이에 위치
    • 파이프: WHERE 절을 필요한 위치에 파이프 연산자로 연결
  • SELECT 절은 주로 쿼리의 뒷부분에 파이프 연산자를 사용하여 배치
    • 기존: SELECT 절이 쿼리의 맨 앞에 위치
    • 파이프: SELECT 절을 필요한 위치에 파이프 연산자로 연결, 주로 쿼리의 뒷부분에 배치
  • GROUP BY와 집계 함수는 AGGREGATE 파이프 연산자를 사용
    • 기존: GROUP BY와 집계 함수를 SELECT 절과 함께 사용
    • 파이프: GROUP BY와 집계 함수를 AGGREGATE 파이프 연산자로 결합하여 사용
  • 서브쿼리는 파이프 연산자를 사용하여 단계적으로 처리
    • 기존: 여러 개의 중첩된 서브쿼리 사용
    • 파이프: 파이프 연산자를 사용하여 서브쿼리를 단계적으로 연결(AS 사용)
  • 불필요한 절과 구문은 제거하고 쿼리를 간소화한다.
    • 기존: 복잡하고 중복된 구문 사용
    • 파이프: 파이프 연산자를 활용하여 쿼리를 간소화하고 가독성 향상



참고 자료


  • 글 작성하는데 걸린 시간 : 167분
    • 하고자 하는 이야기, 개요 정리 : 10분
    • 초안 글 작성 : 112분
    • 초안 후 수정 : 45분
  • 이 개념에 대한 학습은 150분 정도 소요

카일스쿨 유튜브 채널을 만들었습니다. 데이터 사이언스, 성장, 리더십, BigQuery 등을 이야기할 예정이니, 관심 있으시면 구독 부탁드립니다 :)

PM을 위한 데이터 리터러시 강의를 만들었습니다. 문제 정의, 지표, 실험 설계, 문화 만들기, 로그 설계, 회고 등을 담은 강의입니다

이 글이 도움이 되셨거나 다양한 의견이 있다면 댓글 부탁드립니다 :)

Buy me a coffeeBuy me a coffee





© 2017. by Seongyun Byeon

Powered by zzsza