PostgreSQL 자료형 - 배열

nokui·2024년 6월 18일

PostgreSQL

목록 보기
1/1

Array type

토이 프로젝트를 진행하던 중, 태그 정보를 저장해야 할 일이 생겼다.
기존에 사용하던 MariaDB의 경우 정규형을 지키기 위해서 태그 관련 테이블을 별도로 빼서 사용해왔는데, PostgreSQL 에는 Array 자료형을 지원해준다는 것을 발견, 태그 정보를 저장하기에 적합할 거라 생각해서 이번 기회에 한 번 사용해보기로 마음먹었다. 관련 자료형부터 차근 차근 정리해보자.

1. 배열 타입?

PostgreSQL 에서는 Java의 배열과 같이, 같은 타입의 데이터들을 배열 형태로 저장하여 사용할 수 있다.

말 그대로 배열 형태로 데이터가 저장된다. 물론 Java에서와 같이 배열안에는 같은 데이터 타입의 자료만이 담겨야 한다. (Integer 배열에는 Integer만)

차이점이라면 PostgreSQL의 Array 타입은 선언할 때 크기를 지정하지 않아도 된다. (아니 오히려 크기 지정이 기능적으로 의미가 없다)

크기를 지정해도 실제로는 무시하고 동작된다는 것을 확인할 수 있다.

그럼에도 문서화 시에 개발자의 의도를 명확히 하고 코드 가독성을 좋게 만들기 위해서는 가능한한 크기를 명시해 주는 것이 좋다.


2. 사용 방법

2-1. 선언

Java와 마찬가지로 대괄호(자료형[ ])를 사용해서 선언한다.
일차원 배열 만이 아니라 다차원 배열도 가능하니 참고할 것

1) 선언 예시

// 1차원 배열인 pay_by_quarter 와 2차원 배열인 schedule을 선언

CREATE TABLE sal_emp (
    name            text,
    pay_by_quarter  integer[],
    schedule        text[][]
);

2-2. Input

중괄호 ({ }) 와 구분자로 콤마 (,) 를 사용해서 값을 입력한다. 중괄호 대신 Array[] 형태로 입력할 수도 있다.

1) 입력 예시

// 1차원 배열(pay_by_quarter)과 2차원 배열(schedule)에 각각 값을 입력한다. 아래 두 구문은 같은 결과를 보인다.

INSERT INTO sal_emp
    VALUES ('Bill',
    '{10000, 10000, 10000, 10000}',
    '{{"meeting", "lunch"}, {"training", "presentation"}}');

INSERT INTO sal_emp
    VALUES ('Carol',
    '{20000, 25000, 25000, 25000}',
    '{{"breakfast", "consulting"}, {"meeting", "lunch"}}');
    
---------------------------------------------------------------

INSERT INTO sal_emp
    VALUES ('Bill',
    ARRAY[10000, 10000, 10000, 10000],
    ARRAY[['meeting', 'lunch'], ['training', 'presentation']]);

INSERT INTO sal_emp
    VALUES ('Carol',
    ARRAY[20000, 25000, 25000, 25000],
    ARRAY[['breakfast', 'consulting'], ['meeting', 'lunch']]);    

#### 2) 실행 결과 ``` SELECT * FROM sal_emp; name | pay_by_quarter | schedule -------+---------------------------+------------------------------------------- Bill | {10000,10000,10000,10000} | {{meeting,lunch},{training,presentation}} Carol | {20000,25000,25000,25000} | {{breakfast,consulting},{meeting,lunch}} (2 rows) ```

3) 정확한 입력 필요

선언한 배열에 맞게끔 데이터를 입력해주는 것이 중요하다. 이차원 배열을 선언했는데 데이터가 하나밖에 안들어가거나 하면 입력시 에러가 발생한다.

// schedule 에 하나의 값 (meeting)만 입력을 하면 에러가 발생한다.

INSERT INTO sal_emp
    VALUES ('Bill',
    '{10000, 10000, 10000, 10000}',
    '{{"meeting", "lunch"}, {"meeting"}}');
ERROR:  multidimensional arrays must have array expressions with matching dimensions

2-3. Modify

Update문을 사용해서 전체/일부 값의 변경이 가능하다. 입력 때와 마찬가지로 중괄호와 콤마를 통해서 변경할 수도 있고, Arrray[]를 사용해서 변경할 수 도있다.

// 중괄호를 사용한 변경

UPDATE sal_emp SET pay_by_quarter = '{25000,25000,27000,27000}'
    WHERE name = 'Carol';
    
// ARRAY를 사용한 변경     
UPDATE sal_emp SET pay_by_quarter = ARRAY[25000,25000,27000,27000]
    WHERE name = 'Carol';
    
// 단일 인덱스 값 변경     
UPDATE sal_emp SET pay_by_quarter[4] = 15000
    WHERE name = 'Bill';
    
// 슬라이싱 변경 (1, 2번째 인덱스 변경)     
UPDATE sal_emp SET pay_by_quarter[1:2] = '{27000,27000}'
    WHERE name = 'Carol';

2-4. Select

조회도 Java의 배열과 마찬가지로 배열의 index를 사용해 해당 index 값을 가져올 수 있다.
단, Java랑 다르게 index는 1부터 시작한다 [1] ~ [n]

1) 조회 예시

//세 번째 인덱스 값을 조회한다. 

SELECT pay_by_quarter[3] FROM sal_emp;

 pay_by_quarter
----------------
          10000
          25000
(2 rows)

// 첫 번째 인덱스와 두 번째 인덱스의 값이 달라진 유저를 호출 한다.

SELECT name FROM sal_emp WHERE pay_by_quarter[1] <> pay_by_quarter[2];

 name
-------
 Carol
(1 row)

2) 배열 슬라이싱

배열을 잘라서 원하는 부분만을 가져오는 것도 가능하다.

// 첫 번째 배열의 1번부터 2번까지 인덱스 선택, 두 번째 배열의 1번 인덱스만을 호출

SELECT schedule[1:2][1:1] FROM sal_emp WHERE name = 'Bill';

        schedule
------------------------
 {{meeting},{training}}
(1 row)

// 첫 번째 배열의 1번부터 2번까지 인덱스 선택, 두 번째 배열의 1, 2번 인덱스를 호출(= :2)

SELECT schedule[1:2][2] FROM sal_emp WHERE name = 'Bill';

                 schedule
-------------------------------------------
 {{meeting,lunch},{training,presentation}}
(1 row)

//첫 번째 배열의 1번부터 2번까지 배열 선택, 두 번째 배열의 2번부터 끝 인덱스까지 호출

SELECT schedule[:2][2:] FROM sal_emp WHERE name = 'Bill';

        schedule
------------------------
 {{lunch},{presentation}}
 
// 첫 번째 배열의 전체 배열 선택, 두 번째 배열의 1번 인덱스만을 호출

SELECT schedule[:][1:1] FROM sal_emp WHERE name = 'Bill';

        schedule
------------------------
 {{meeting},{training}}
(1 row)

3) 배열 크기를 넘어선 조회

배열의 크기를 넘어서 조회하게되면 에러가 발생하지는 않지만 null값이 반환된다. 백엔드에서 처리할 때 주의하기.

4) WHERE 조건 절에서 사용

SELECT 문에서 범위를 지정하는게 아니라, 주어진 조건이 배열에 포함되는지 또는 일치하는지를 가지고 조건문을 만들 수 있다.

SELECT * FROM sal_emp WHERE pay_by_quarter[1] = 10000 OR
                            pay_by_quarter[2] = 10000 OR
                            pay_by_quarter[3] = 10000 OR
                            pay_by_quarter[4] = 10000;

물론 위와 같이 조건문을 사용하면 쿼리가 지저분해지고, 배열의 크기를 모를 경우 사용하기가 곤란한 단점이 있다. 위 조건문을 아래와 같이도 처리 가능하다.

SELECT * FROM sal_emp WHERE 10000 = ANY (pay_by_quarter);

배열의 값 중 하나라도 10000과 일치하면 조건문을 충족시킨다.
물론 ANY가 있다면 ALL도 있다.

SELECT * FROM sal_emp WHERE 10000 = ALL (pay_by_quarter);

이건 배열의 모든 값이 10000인 경우에만 조건을 충족시킨다.

SELECT * FROM sal_emp WHERE pay_by_quarter && ARRAY[10000];

&& ARRAY[10000]를 사용하면 10000과 겹치는 배열이 있는지를 조사한다. (=ANY 랑 비슷하게 사용 가능)

5) 기타 조회

배열의 차원이나 크기 등을 조회할 수도 있다.

SELECT array_dims(schedule) FROM sal_emp WHERE name = 'Carol';

 array_dims
------------
 [1:2][1:2]
(1 row)


SELECT array_upper(schedule, 1) FROM sal_emp WHERE name = 'Carol';

 array_upper
-------------
           2
(1 row)


SELECT array_length(schedule, 1) FROM sal_emp WHERE name = 'Carol';

 array_length
--------------
            2
(1 row)


SELECT cardinality(schedule) FROM sal_emp WHERE name = 'Carol';

 cardinality
-------------
           4
(1 row)

3. Mybatis, JPA(Hybernate)

Mybatis나 JPA에서는 기본적으로 배열 타입을 지원하지 않는다. 배열을 사용하기 위해서는 사용자 정의 핸들러를 만들어주고(Mybatis) UserType 클래스를 정의하거나 별도의 라이브러리를 사용하는것이 필요하다(JPA).

1) Mybatis - Handler

Handler 정리

2) JPA - 라이브러리 사용

UserType 클래스를 일일히 정의하기보다 기존에 있는 라이브러리를 사용하기로 함. hypersistence-utils 를 사용했다.

  1. build.gradle 디펜던시 추가

  2. entity 정의

package com.t4e1.minihub.command.history.aggregate;

import io.hypersistence.utils.hibernate.type.array.ListArrayType;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Type;

import java.util.List;

@Entity
@Table(name="history")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public class History {

    @Id
    private int id;
    @Column
    private String title;
    @Column
    private String content;
    @Type(ListArrayType.class)
    @Column(name = "tags", columnDefinition = "varchar[]")
    private List<String> tags;
    @Column
    private String pwd;
}
  1. 테스트 코드 - ListArrayType.class를 사용했으므로 List로 입력해준다.
    @DisplayName("기록 추가 테스트")
    @Test
    @Transactional
    void addRecordTest() {

        HistoryDTO historyDTO = new HistoryDTO(0,"테스트", "테스트내용",
                new ArrayList<>(Arrays.asList("tag1", "tag2", "tag3")), "1234");

        Assertions.assertDoesNotThrow(
                () -> {
                    historyService.addRecord(historyDTO);
                }
        );
    }

4. 정규형 위배?

1 정규형을 지키기 위해 하나의 컬럼에는 하나의 정보만 들어가는 것을 원칙으로 했다. 근데 Array 타입을 쓰면 정규형에 위배되는게 아닌지?

여기에 대해서는 딱히 물어볼 데가 없어서 GPT의 답을 첨부.

PostgreSQL의 array 타입을 사용하면 하나의 도메인에 여러 값을 저장할 수 있기 때문에, 전통적인 데이터베이스 설계 원칙 중 하나인 1정규화(1NF)를 위반하는 것처럼 보일 수 있습니다. 하지만 실제로는 여러 상황에서 array 타입을 사용하는 것이 유용할 수 있으며, 정규화를 완전히 대체하기보다는 특정 상황에서의 최적화 도구로 사용됩니다. 다음은 PostgreSQL의 array 타입을 사용하는 이유와 그 장단점에 대해 설명합니다.

1. 배열 타입 사용의 장점
1.1. 성능 향상
조인이 많은 복잡한 쿼리는 성능에 영향을 미칠 수 있습니다. 배열을 사용하면 조인 연산을 줄이고 단일 테이블 조회로 필요한 데이터를 얻을 수 있어 성능이 향상될 수 있습니다.

1.2. 간단한 데이터 모델링
특정 응용 프로그램에서는 데이터 모델이 간단해지고 관리하기 쉬워집니다. 예를 들어, 사용자 프로필에 여러 개의 전화번호를 저장해야 하는 경우, 배열을 사용하면 이를 쉽게 처리할 수 있습니다.

1.3. 편리한 데이터 처리
데이터를 한 번에 읽고 쓰는 작업이 더 쉽고 간편할 수 있습니다. 예를 들어, JSON 데이터 구조를 데이터베이스에 저장하고 한 번에 처리하는 경우가 있습니다.

2. 배열 타입 사용의 단점
2.1. 정규화 원칙 위반
배열을 사용하면 1정규화 원칙을 위반하게 되며, 이는 데이터 중복 및 무결성 문제를 야기할 수 있습니다.

2.2. 쿼리 복잡성 증가
배열 내부의 특정 요소를 검색하거나 업데이트하는 쿼리가 복잡해질 수 있습니다. SQL 표준 함수가 아닌 PostgreSQL 고유의 함수들을 사용해야 할 수도 있습니다.

2.3. 데이터 무결성 및 일관성 문제
배열 내의 데이터가 독립적으로 관리되지 않으므로, 데이터 무결성을 유지하기 어려울 수 있습니다. 예를 들어, 배열 내의 특정 값이 다른 테이블의 외래 키와 일치해야 하는 경우, 이를 강제하기 어렵습니다.

3. 언제 배열을 사용할지 결정하기
배열을 사용할지 여부는 다음과 같은 상황을 고려하여 결정해야 합니다:

3.1. 데이터 관계의 복잡성
데이터 간의 관계가 복잡하고 조인이 많은 경우, 정규화를 통해 데이터를 분리하는 것이 좋습니다. 반면, 단순한 관계의 데이터를 처리할 때는 배열을 사용하는 것이 더 효율적일 수 있습니다.

3.2. 데이터 접근 패턴
데이터를 읽고 쓰는 패턴이 중요합니다. 전체 데이터를 한 번에 읽고 쓰는 경우 배열이 유용할 수 있습니다. 하지만 특정 요소를 자주 업데이트하거나 검색해야 하는 경우, 정규화된 테이블 구조가 더 나을 수 있습니다.

3.3. 성능 요구사항
성능이 중요한 경우, 배열을 사용하여 조인을 줄이고 쿼리 속도를 높일 수 있습니다. 그러나 데이터 무결성과 일관성을 유지하는 것이 더 중요하다면 정규화를 선택해야 합니다.

결론
PostgreSQL의 array 타입은 특정 상황에서 유용할 수 있는 강력한 도구입니다. 하지만 정규화 원칙을 완전히 무시해서는 안 되며, 배열 타입을 사용하는 경우와 정규화를 사용하는 경우를 신중하게 평가해야 합니다. 배열을 사용하는 것이 더 나은지, 정규화를 통해 데이터 무결성과 일관성을 유지하는 것이 더 나은지 상황에 따라 결정해야 합니다.

정리하자면, 배열을 사용하는 것이 정규화를 대체하는 것은 아니며, 특정한 요구사항과 상황에서 최적화된 솔루션을 제공할 수 있습니다. 언제 배열을 사용할지, 언제 정규화를 사용할지는 데이터 모델링의 주요 결정 사항 중 하나입니다.

5. 사용 후기

배열 타입 사용은 지양하는 것이 좋을것 같다. 편의성과 이식성이 너무 떨어지는 것 같기 때문.
우선 간단하게 생각한 것과 달리 생각외로 필요한 작업이 많았다. 기본적으로 Mybatis나 JPA 에서 배열 타입을 지원하지 않기 때문에 사용하려면 별도의 핸들러나 UserType 클래스를 구현하는 작업이 필요해서 테이블을 조인시키는 것보다 오히려 이런걸 찾아보는데 수고가 더 들었다.
또한 향후 다른 DBMS로의 마이그레이션이 있을 수도 있다는 걸 생각하면 이런 특수한 데이터 타입을 사용하는 것은 문제가 될 것이기에, 배열 자료형은 특별한 이유가 없는 이상은 사용하지 않도록 하자...


profile
프로젝트 진행하면서 정리용

0개의 댓글