검색

레이블이 spring boot인 게시물을 표시합니다. 모든 게시물 표시
레이블이 spring boot인 게시물을 표시합니다. 모든 게시물 표시

2024년 10월 2일

코딩 - Spring Boot 환경에서 설정 정보 암호화

"Config Server 을 구성할 필요가 없는 경우 주요한 설정을 어떻게 암호화를 할 것인가"

Spring Boot 에서 Config Server를 사용하지 않고도 암호화를 적용하는 방법은 여러 가지가 있다. 특히, 민감한 데이터를 보호하기 위해 Jasypt 와 같은 라이브러리를 사용하여 암호화를 적용, 애플리케이션의 설정 파일이나 환경 변수에 저장된 비밀번호, API 키 등의 민감한 정보를 안전하게 암호화할 수 있다. 

◼︎ 환경

  • Model : MacBook Pro (14-inch, 2021)
  • CPU : Apple M1 Pro
  • MENORY : 16GB
  • DISK : 512 GB SSD
  • OS : macOS 13.2.4 (22F66)
  • TOOLS : Visual Studio Code, Java 11, Gradle, Docker
  • Version Control : GitHub
  • Programming Language : Java
  • Back-End Framework : Spring Boot 2.7.12, Spring Security 5.7.7 
  • DBMS : MySql 8.0.33
  • Cloud : OCI (free tier account

Jasypt ?

Jasypt (Java Simplified Encryption) 는 자바 애플리케이션 속성 파일, 데이터베이스 비밀번호, 또는 기타 중요한 데이터를 쉽게 암호화하고 복호화할 수 있도록 도와주는 것이다. Jasypt는 복잡한 암호화 알고리즘을 이해하거나 구현할 필요 없이 간단한 API를 통해 보안을 향상시킬 수 있도록 설계되었다.

주요 특징

  1. 암호화 및 복호화 기능: 비밀번호, 속성 값 등의 중요한 데이터를 쉽게 암호화하고, 필요한 시점에 복호화
  2. 구성 파일 보호: 애플리케이션의 application.properties 또는 application.yml과 같은 설정 파일에서 중요한 정보(예: 데이터베이스 연결 정보)를 암호화
  3. 알고리즘 지원: AES, DES, PBE 등 다양한 암호화 알고리즘을 지원
  4. 설정이 용이: 환경 변수나 명령줄 인자를 통해 암호화 키를 전달하고, 자동으로 암호화된 데이터를 복호화할 수 있는 기능을 제공
  5. 스프링 통합: 스프링 프레임워크와 통합되어 스프링 부트 (Spring Boot) 애플리케이션에서 손쉽게 사용

Jasypt는 특히 애플리케이션 보안 향상을 위해 중요한 정보를 암호화하는 데 유용하며, 자바 기반 프로젝트에서 많이 사용된다. 


Jasypt를 사용한 암호화 적용 방법

재십트(jasypt) 는 스프링 부트 (Spring Boot) 와의 통합을 돕기 위해 jasypt-spring-boot-starter 라이브러리를 제공한다. 이를 사용하면 Spring Boot 의 환경 설정 파일(application.properties, application.yml)에서 암호화된 값을 쉽게 처리할 수 있다.  아래는 gradle 기반 스프링 부트 (Spring Boot) 프로젝트에 재십트(jasypt) 암호화를 적용하는 것을 설명한다.

❶ 의존성 추가: build.gradle 에 jasypt-spring-boot-starter 라이브러리 의존성을 추가한다.


implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.4"

❷ 암호화된 값 사용: 애플리케이션의 application.properties 또는 application.yml 파일에 민감한 데이터를 암호화된 상태로 저장할 수 있다. 암호화된 값은 ENC(...) 형식으로 표기한다.

application.yml


datasource:
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
url: jdbc:log4jdbc:postgresql://localhost:5432/studio_db
username: ENC(amX5KdNShNl7jMYsHfpWccYeGhDWHD9bXsxXQSZGuUI=)
password: ENC(4EyexAjWphLdRG6NaDaPb2S2c6gU8PZr/QgLM7e4nY4=)

❸ Jasypt Encryptor 설정: 재십트(jasypt) 가 암호화된 값을 복호화할 수 있도록 JasyptStringEncryptor 를 설정해야 한다.  스프링 부트 (Spring Boot) 애플리케이션에서는 암호화를 위한 비밀번호를 설정하는 것으로 간단하게 구현할 수 있다.

비밀번호를 전달하는 방법은 여러가지 방법이 있다.

  1. 환경변수로 전달
  2. CI/CD 도구에서 지원하는 시크릿 관리 기능을 사용하여 전달
  3. AWS Secrets Manager, HashiCorp Vault, Azure Key Vault 등의 비밀 관리 서비스 사용하여 전달
  4. 사용자 정의 JasyptStringEncryptor 을 구현하여 원하는 곳에서 비밀번호를 로드하여 직접 사용
아래는 환경변수를 사용하여 비밀번호를 전달하는 방법이다.

application.yml

jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD} # 환경 변수로 암호화 비밀번호 설정


JASYPT_ENCRYPTOR_PASSWORD 환경변수에 비밀번호를 세팅.

Linux/macOS:

export JASYPT_ENCRYPTOR_PASSWORD=your-encryption-password
./gradlew bootRun

Windows:

set JASYPT_ENCRYPTOR_PASSWORD=your-encryption-password
gradlew bootRun

❹ 암호화 : Jasypt CLI(Command Line Interface)를 사용하여 값을 암호화하거나 복호화할 수 있다. 예를 들어, 다음과 같이 명령어를 사용하여 값을 암호화할 수 있다.

encrypt input="yourSecretValue" password="your-encryption-password"

jasypt 1.9.3 (binaries and javadocs)

암호화된 값을 ❷ 암호화된 값 사용 과 같이 application.yml 설정에 기술하면 된다.

재십트(jasypt) 암호화

재십트(jasypt) 에서 디폴트로 적용되는 암호화 알고리즘과 관련된 주요 값들은 다음과 같다. 이 값들은 재십트(jasypt)를 기본 설정으로 사용할 때 적용되는 값 들이다.


① 디폴트 암호화 알고리즘 
  • PBEWithMD5AndDES: 기본적으로 Jasypt는 PBE (Password-Based Encryption) 알고리즘 중 하나인 PBEWithMD5AndDES를 사용한다. PBEWithMD5AndDES는 MD5 해시 알고리즘과 DES(Data Encryption Standard)를 사용한 암호화 알고리즘이다.
  • 이 알고리즘은 기본적인 보안성을 제공하지만, 현대적 암호화 표준에 비해 상대적으로 약한 것으로 간주되기 때문에 더 강력한 알고리즘을 사용할 수 있도록 설정을 변경하는 것이 좋다.

② 디폴트 해시 알고리즘 
  • MD5재십트(jasypt)는 기본적으로 MD5 해시 알고리즘을 사용하여 암호화 키를 처리한다. MD5는 널리 사용되지만, 충돌 공격에 취약하다는 보안 문제로 인해 요즘은 SHA-256 또는 SHA-512와 같은 더 강력한 해시 알고리즘이 선호된다.

③ 키 생성 반복 횟수
  • 1000번 반복: Jasypt는 기본적으로 암호화 키를 생성할 때 1000번의 반복을 수행한다. 이 값은 비밀번호 기반 암호화의 보안성을 높이기 위한 중요한 요소로 이 값을 더 높여 보안을 강화할 수 있다.

④ 디폴트 Salt Generation (솔트 생성)
  • 랜덤 솔트: 재십트(jasypt)는 암호화 시 자동으로 랜덤 솔트(Salt) 값을 생성한다. 솔트는 암호화된 값의 보안성을 높이기 위해 추가되는 값으로, 암호화된 텍스트의 패턴화를 방지하는 역할을 한다.

⑤ 디폴트 출력 포맷 
  • Base64: 재십트(jasypt)는 암호화된 텍스트를 기본적으로 Base64 로 인코딩하여 출력한다. Base64는 바이너리 데이터를 텍스트로 인코딩하는 방식으로, 암호화된 값을 쉽게 문자열로 표현할 수 있게 한다.

⑥ IV(Initialization Vector, 초기화 벡터) 
  • 기본적으로 Jasypt는 PBEWithMD5AndDES 알고리즘을 사용할 때 초기화 벡터(IV)를 생성하지 않는데 알고리즘 자체가 IV를 사용하지 않기 때문이다. 그러나 다른 알고리즘, 예를 들어 AES와 같은 알고리즘을 사용할 때는 IV가 자동으로 처리된다. 

⑦ 디폴트 암호화기(PBE String Encryptor) 
  • 재십트(jasypt)는 기본적으로 **StandardPBEStringEncryptor**를 사용하여 문자열을 암호화 및 복호화한다.

이와 같은 기본 설정들은 애플리케이션의 보안 요구에 맞게 조정할 수 있으며, 기본값을 사용하더라도 어느 정도의 보안을 제공하지만, 더 강력한 알고리즘 변경 권장된다. 

아래는 보안 수준에 따른 알고리즘 및 설정 값을 정리한 것이다. 운영 환경이라면 최소 중간 수준이상의 알고리즘 적용이 필요하다.

  • 낮은 보안 수준
    • 알고리즘 : PBEWithMD5AndDES
    • 키 획득 반복 횟수 : 1000
    • 키 길이 : 56
    • 솔트 사용 : ✓
    • IV 사용 : ✗
    • 설명 : 기본 Jasypt 설정. MD5와 DES를 사용하는 알고리즘으로 비교적 낮은 보안 수준. 과거에 널리 사용되었으나 현대의 공격에 취약.
    • 권장 사용 환경 : 테스트 환경 또는 민감하지 않은 데이터를 암호화할 때.
  • 보통 보안 수준
    • 알고리즘 : PBEWithSHA1AndDESede
    • 키 획득 반복 횟수 : 1000
    • 키 길이 : 168
    • 솔트 사용 : ✓
    • IV 사용 : ✗
    • 설명 : SHA-1 해시 알고리즘과 3DES 암호화 알고리즘 사용. DES보다 더 안전하지만, SHA-1이 약해져 더 강력한 알고리즘이 권장됨.
    • 권장 사용 환경 : 테스트 환경 또는 민감하지 않은 데이터를 암호화할 때.
  • 중간 보안 수준
    • 알고리즘 : PBEWithSHA256And128BitAES-CBC-BC
    • 키 획득 반복 횟수 : 1000
    • 키 길이 : 168
    • 솔트 사용 : ✓
    • IV 사용 : ✓
    • 설명 : SHA-256 해시 알고리즘과 AES-128 비트 암호화를 사용. AES는 고성능과 높은 보안성을 제공. IV를 사용하여 보안성을 강화.
    • 권장 사용 환경 : 내부 시스템의 암호화 작업이나 중요하지만 최고 수준의 보안을 요구하지 않는 환경.
  • 높은 보안 수준
    • 알고리즘 : PBEWithHMACSHA512AndAES_256
    • 키 획득 반복 횟수 : 1000
    • 키 길이 : 256
    • 솔트 사용 : ✓
    • IV 사용 : ✓
    • 설명 : HMAC-SHA512 해시 알고리즘과 AES-256 비트 암호화를 사용. AES-256은 강력한 보안을 제공하며, HMAC을 사용해 무결성 보장.
    • 권장 사용 환경 : 금융 서비스, 헬스케어 시스템 등 민감한 데이터를 보호해야 하는 환경.
  • 매우높은  보안 수준
    • 알고리즘 : PBEWithHMACSHA512AndAES_256
    • 키 획득 반복 횟수 : 5000~10000
    • 키 길이 : 256
    • 솔트 사용 : ✓
    • IV 사용 : ✓
    • 설명 : 암호화와 해시에서 최고 수준의 보안. 반복 횟수를 늘리고 AES-256을 사용하여 비밀번호 기반 암호화의 보안을 최대한 강화.
    • 권장 사용 환경 : 금융 서비스, 헬스케어 시스템 등 민감한 데이터를 보호해야 하는 환경.
  • 최고  보안 수준
    • 알고리즘 : PBKDF2WithHmacSHA512 + AES_256
    • 키 획득 반복 횟수 : 10000~50000
    • 키 길이 : 256
    • 솔트 사용 : ✓
    • IV 사용 : ✓
    • 설명 : PBKDF2는 비밀번호 기반 키 도출 기능으로 반복 횟수와 HMAC-SHA512를 사용해 높은 보안성을 제공. AES-256과 함께 사용하면 최상의 보안을 제공.
    • 권장 사용 환경 : 정부, 군사 시스템, 민감한 데이터를 보호하는 최고 수준의 보안이 요구되는 환경

중간 보안 요구 이상에서는 AES-256과 함께 PBEWithHMACSHA512AndAES_256 또는 PBKDF2WithHmacSHA512를 사용하는 것이 권장. AES-256은 현재 가장 안전한 암호화 표준 중 하나이며, HMAC-SHA512는 암호화 데이터의 무결성을 보장. 반복 횟수(Iterations)를 5000번 이상으로 설정하여 암호화 키 도출 과정의 보안을 더욱 강화하는 것을 권장.

특히 개인정보와 같은 민감한 데이터 보호가 필요한 환경에서는 AES-256HMAC-SHA512를 사용한 설정을 적극 추천.

중간 수준이상의 암호화 적용

테스트 환경에서는 디폴트 암호화 알고리즘 이외의 암호화를 사용하려는 경우 오류가 발생하여 진행이 불가 하였다. 이런 이유로 환경 이슈에 독립적으로 동작하도록 보니캐슬(Bouncy Castle) 암호화 프로바이터를 사용하였다. (맥 환경이 문제는 아닌가 추정만 하였다.)

❶ 의존성 추가: build.gradle 에 보니캐슬(Bouncy Castle) 라이브러리 의존성을 추가한다.

❷ 재십트(jasypt) 에 보니캐슬(Bouncy Castle) 설정 

Jasypt 설정 예시 (AES 256 사용):

application.yml

encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD} # 환경 변수로 암호화 비밀번호 설정
algorithm: "PBEWithSHA256And256BitAES-CBC-BC" # Bouncy Castle 알고리즘 사용
providerName: "BC" # Bouncy Castle 프로바이더 지정
key-obtention-iterations: 1000
pool-size: 1
salt-generator-classname: "org.jasypt.salt.RandomSaltGenerator"
string-output-type: "base64"


PBEWithSHA256And256BitAES-CBC-BC: 보니캐슬(Bouncy Castle)이 제공하는 AES-256 알고리즘이다.

❸ Bouncy Castle 프로바이더 등록
Java 환경에서 보니캐슬(Bouncy Castle)을 암호화 프로바이더로 등록해야 하며, 이 작업은 JVM이 시작될 때 수행할 수 있다. 스프링 부트 (Spring Boot) 에서는 Application.java 에 추가하여 프로바이터를 등록할 수 있다.


import java.security.Security;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
SpringApplication.run(MyApplication.class, args);
}
}



좀더 자동화된 방법을 고민하다 재십트(jasypt) 라이브러리가 추가되어 있고 보니캐슬(Bouncy Castle) 암호화 라이브러리가 있는경우 보니캐슬(Bouncy Castle) 프로파이더를 추가 하고  디폴트 암호화(StandardPBEStringEncryptor) 모듈을 생성하도록 자동화된 설정을 구현하여 사용하였다.

다만 이경우 Jasypt CLI(Command Line Interface)를 사용하여 값을 암호화하거나 복호화 하는 것이 어려워저 추가로 암호화를 위한 RESTful API 을 추가하여 사용할 수 있도록 하였다.

특정 값을 암호화하려면 아래와 같이 curl 을 사용하여 암호화하고 yml 파일에 ENC() 함수를 사용하여 암호화된 값으로 설정을 추가하면 된다.

curl localhost:8080/data/encrypt -s -d 암호화할 데이터 


2024년 8월 9일

코딩 - Vue3 : Uppy 을 이용한 파일 업로드

  ◼︎ 환경

  • Model : MacBook Pro (14-inch, 2021)
  • CPU : Apple M1 Pro
  • MENORY : 16GB
  • DISK : 512 GB SSD
  • OS : macOS 13.2.4 (22F66)
  • TOOLS : Visual Studio Code, Java 11, Gradle, Docker
  • Version Control : GitHub
  • Programming Language : Java, Vue3
  • Front-End Framework : Vue 3.4.30, Vuetify 3.6.10, ag-grid-vue3 31.3.2
  • Back-End Framework : Spring Boot 2.7.12, Spring Security 5.7.7 
  • DBMS : MySql 8.0.33
  • Cloud : OCI (free tier account


1. 업로드 라이브러리 & 모듈 조사 

Vue 3 환경에서 파일업로드 구현을 위한 인기있는 라이브러리 또는 모듈들에는  여러 가지가 있다. 추가로 사용성 확인을 위하여 npmjs.com 사이트를 활용하였다.

➀ Dropzone
  • Weekly Downloads: 363,089
  • Version: 6.0.0-beta.2
  • Last Publish: 3년 전
  • 특징: 가장 많이 사용되는 파일 업로드 라이브러리 중 하나이며, Vue.js 에서도 널리 사용되고 있음. 하지만, Vue 3 공식 지원이 부족할 수 있음.
➁ Vue3 Dropzone
  • Weekly Downloads: 10,754
  • Version: 2.2.1
  • Last Publish: 8개월 전
  • 특징: Dropzone.js의 Vue 3 통합 버전으로, Vue 3 프로젝트에서 드래그 앤 드롭 파일 업로드를 쉽게 구현할 수 있음.
➂ Vue Advanced Cropper
  • Weekly Downloads: 82,508
  • Version: 2.8.9
  • Last Publish: 2달 전
  • 특징: 이미지 크롭 기능을 제공하는 파일 업로드 라이브러리로, Vue 3와의 호환성이 높고, 비교적 최근에 업데이트되었음.
➃ Uppy
  • Weekly Downloads: 23,383
  • Version: 4.1.0
  • Last Publish: 10일 전
  • 특징: 강력한 모듈화 파일 업로드 라이브러리로, 여러 소스에서 파일을 업로드할 수 있으며, Vue 3와의 호환성도 좋음.
➄ Vue FilePond
  • Weekly Downloads: 21,154
  • Version: 7.0.4
  • Last Publish: 1년 전
  • 특징: 사용자 친화적인 인터페이스를 제공하며, 다양한 파일 포맷을 지원. 다만, 최근에 업데이트가 없음.
➅ Vue Uploader
  • Weekly Downloads: 115
  • Version: 3.37.1
  • Last Publish: 1년 전
  • 특징: 다운로드 수가 비교적 적으며, 최근 업데이트가 없는 편임. 작은 프로젝트나 간단한 용도로 적합할 수 있음.

간단하게 요약해보면 아래와 같다.
  • 가장 많이 사용되는 라이브러리: Dropzone은 여전히 가장 많은 다운로드 수를 기록하고 있으며, 널리 사용되고 있다. 하지만 Vue 3에 대한 직접적인 지원이 부족할 수 있다. 
  • Vue 3 에 적합한 라이브러리: Vue Advanced Cropper, Uppy, Vue FilePond, Vue3 Dropzone 등은 Vue 3에서 사용할 수 있는 좋은 선택이다.
개인적으로는 Vue3 Dropzone, Vue FilePond 은 사용해 보았고 이번에는 대용량 파일 업로드가 강점이라고 하는 Uppy 을 사용해 보았다. 


2. Uppy 

Uppy는 강력하고 모듈화된 파일 업로드 라이브러리로, 다양한 파일 소스에서 업로드를 지원하며 파일 업로드 경험을 쉽게 관리할 수 있도록 설계되었다. Uppy는 최신 웹 기술을 활용하며, 파일 업로드를 직관적으로 구현할 수 있도록 다양한 플러그인을 제공한다. Vue.js와의 통합도 지원하여, Vue 프로젝트에서 Uppy의 기능을 쉽게 사용할 수 있다.

  • 모듈식 구조: 필요한 기능만 선택적으로 사용할 수 있도록 다양한 플러그인으로 구성되어 있다. 예를 들어, 파일 드래그 앤 드롭, 웹캠, Google Drive, Dropbox 등 다양한 소스를 지원한다. 
  • 다양한 파일 소스: 로컬 파일뿐만 아니라 원격 소스(예: Google Drive, Dropbox, Instagram)에서도 파일을 업로드할 수 있다.

  • Vue.js 통합: @uppy/vue 패키지를 통해 Vue.js 프로젝트에 쉽게 통합할 수 있다.

  • 커스터마이징 가능: 업로드 UI와 동작을 프로젝트의 요구에 맞게 커스터마이징할 수 있다. 또한, 업로드 진행률 표시, 취소, 재시도 등의 기능도 제공. ⇨ UI 커스터마이징이 비교적 용의했다. 


3. Uppy 설치 

Uppy와 Vue 통합 패키지를 설치한다.

npm install @uppy/core @uppy/dashboard @uppy/drag-drop @uppy/file-input @uppy/progress-bar
@uppy/vue @uppy/xhr-upload --save


4. Uppy 을 이용한 파일 업로드  

업로드 구현은 아래와 같은 순서로 코딩하면 된다. 

❶ Uppy 컴포넌트 임포트
<script setup lang="ts">
// import upload component(uppy)
import Uppy from '@uppy/core';
import { Dashboard } from '@uppy/vue';
import XHRUpload from '@uppy/xhr-upload';
import '@uppy/core/dist/style.css';
import '@uppy/dashboard/dist/style.css';


❷ Vue 컴포넌트에서 Dashboard 사용
<div class="dashboard-container">
<Dashboard :uppy="uppy"
inline="true"
:height="200"
:note="'Images only, up to 10MB'"
:metaFields="metaFields" />
</div>

@uppy/vue에서 가져온 Dashboard 컴포넌트를 사용하여 Vue 템플릿에서 Uppy 대시보드를 직접 렌더링. inline 속성을 사용 대시보드를 <div> 요소에 직접 렌더링 되도록 설정.  

metaFields 을 사용하여 업로드할 파일과 관련된 추가적인 메타데이터를 수집하고, 사용자로부터 입력받을 수 있게 한다. 이를 통해 파일에 대한 추가 정보를 서버로 전송하거나, 파일 처리 로직에서 메타데이터를 활용할 수 있다.   → 이부분은 확인 할 수 없었다.

❸ Uppy 인스턴스를 생성하고 파일업로드 플러그인을 설정
const fileUploadUrl = computed(() => {
return `${import.meta.env.VITE_API_URL}/data/secure/mgmt/resources/images/${props.imageId}/upload`
})
const uppy = new Uppy({
autoProceed: false, // 수동으로 업로드 시작
restrictions: {
maxFileSize: 10000000, // 1최대 파일 크기: 10MB
maxNumberOfFiles: 5, // 업로드 가능한 최대 파일 수
minNumberOfFiles: 1, // 업로드 가능한 최소 파일 수
allowedFileTypes: ['image/*']
}
})
.use(XHRUpload, {
endpoint: fileUploadUrl.value, // 업로드할 서버 엔드포인트
fieldName: 'file', // 서버에 전송되는 파일의 필드 이름
formData: true, // FormData를 사용하여 파일을 업로드 (멀티파트 사용)
headers: {
...authHeader(),
},
retryDelays: [0, 1000, 3000, 5000]
});

참로로 authHeader 는 JWT 토큰 관련 값을 정의하는 내용이다. 이런 방식으로 헤더에 값을 추가할 수 있다.

Uppy 는 여러가지 다수의 업로드 방식을 지원하고 있고 여기에서는 가장 일반적인 XHRUpload 을 적용했다.
  1. XHRUpload: 가장 일반적인 파일 업로드 방식으로, 대부분의 서버에서 지원.
  2. Tus: 대규모 파일 업로드 및 네트워크 문제로 인한 업로드 중단 시 재개를 지원하는 프로토콜
  3. Multipart: multipart/form-data 형식으로 서버에 파일을 전송.
  4. S3 Multipart: AWS S3에 대규모 파일을 멀티파트 방식으로 업로드.
  5. Transloadit: 클라우드 기반 업로드 서비스와 통합
❹ 파일 업로드시 추가 정보 전송
파일을 업로드 할때 추가 정보를 전송하려면 uppy 의 upload 이벤트를 사용하여 추가하면된다. 이경우 멀티파트 파마메터 형식으로 추가된 값들이 전달된다.

uppy.on('upload', (data) => {
uppy.setMeta({
objectId: dataRef.value.objectId ,
objectType: dataRef.value.objectType,
link: createSharedLink.value,
description: dataRef.value.description
});
});


❺ onBeforeUnmount 을 사용 컴포넌트가 파괴되기 전에 Uppy 인스턴스를 정리

onBeforeUnmount(() => {
uppy.destroy();
});

❻ 업로드 영역 크기 조정하기 
Dashboard 의 height 을 사용해도 높이가 조정 되지 않기 떄문에 CSS 을 아래와 같은 방식으로 수정해 적용해야 한다.
<style scoped>
::v-deep .uppy-Dashboard {
height: 250px; /* 원하는 높이로 설정 */
max-height: 100%;
display: flex;
flex-direction: column;
}
</style>


그림1. 파일 업로드 화면
그림2. 파일을 추가한 화면

그림3. 파일 업로드가 성공한 경우

➐ 서버 프로그램
XHRUpload 업로드 모듈의 경우 formData 옵션을 사용하면 multipart/form-data 형식으로 서버에 전송되기 때문에 기존 업로드 프로그램을 사용하여 업로드를 구현할 수 있다.

@PostMapping(value = { "/images/{imageId:[\\p{Digit}]+}/upload" }, produces = MediaType.APPLICATION_JSON_VALUE)
public List<Image> upload(
@PathVariable Long imageId,
@RequestParam(value = "objectType", defaultValue = "-1", required = false) Integer objectType,
@RequestParam(value = "objectId", defaultValue = "-1", required = false) Long objectId,
@RequestParam(value = "description", required = false) String description,
@RequestParam(value = "link", defaultValue = "false", required = false) Boolean createLink,
@RequestParam("file") List<MultipartFile> files) throws NotFoundException, IOException, UnAuthorizedException {

User user = SecurityHelper.getUser();
List<Image> list = new ArrayList<>();

for (MultipartFile mpf : files) {
String fileName = StringUtils.cleanPath(mpf.getOriginalFilename());
InputStream is = mpf.getInputStream();
log.debug("upload <file name:{}, size:{}, type:{}>", fileName, mpf.getSize(), mpf.getContentType());
// 업로드 파일을 처리한다.

list.add(newImage);
}
return list;
}

5. 대용량 파일 업로드

Uppy 에서 대용량 파일 업로드는 Tus 프로토콜을 사용하여 구현 할 수 있다.  Tus는 대규모 파일 업로드를 효율적으로 처리할 수 있도록 설계된 오픈 프로토콜로 Uppy는 Tus를 통해 파일을 청크 단위로 업로드하고, 중단된 업로드를 재개하는 등의 기능을 제공한다. 

npm install  @uppy/tus

Uppy 에서는 간단하게 XHRUpload 파일 업로드 모듈을 Tus 로 변경해주면 된다.

import Tus from '@uppy/tus';
const tusFileUploadUrl = computed(()=>{
return `${import.meta.env.VITE_API_URL}/data/secure/mgmt/resources/files/${props.imageId}/tus`;
})
uppy.use(Tus, {
endpoint: tusFileUploadUrl.value, // Tus 서버 엔드포인트
chunkSize: 5 * 1024 * 1024, // 5MB 청크 사이즈
headers: {
...authHeader(),
},
retryDelays: [0, 1000, 3000, 5000]
})
;

클라이언트 부분과 다르게 서버는 Tus 프로토콜에 따른 서버 프로그램을 새로 구현해야한다. 

Tus 프로토콜 

Tus 프로토콜은 클라이언트와 서버 간 4단계로 파일을 업로드합니다.

❶ 파일 생성(Create):
  • 클라이언트는 서버에 새로운 업로드를 생성하도록 요청한다. 
  • 이 요청에는 파일의 크기와 메타데이터가 포함될 수 있다.
  • 서버는 업로드에 고유한 URL을 생성하고 클라이언트에게 반환한다.
  • 파일 및 메타 정보는 이때만 전송된다. 이후에는 데이터만 전송된다.
❷ 파일 업로드(Upload):
  • 클라이언트는 생성된 URL을 사용하여 파일의 특정 바이트를 서버에 업로드한다.
  • 서버는 클라이언트가 업로드한 바이트를 확인하고, 다음 업로드 시 어디서부터 시작할지 오프셋(Upload-Offset)을 반환한다.
❸ 업로드 재개(Resume):
  • 업로드 중단 시 클라이언트는 서버에서 제공한 오프셋을 사용하여 중단된 지점부터 업로드를 재개할 수 있다.
❹ 업로드 완료(Completion):
  • 모든 바이트가 성공적으로 업로드되면 업로드가 완료된다.

프로토콜 메시지

  • OPTIONS: 서버가 Tus 프로토콜을 지원하는지 확인하고, 지원하는 기능을 확인하기 위해 클라이언트가 전송. 서버는 지원하는 버전, 최대 파일 크기 등의 정보를 응답. 
  • POST: 새로운 업로드를 생성하기 위해 사용. 서버는 업로드 URL을 응답합니다. 
  • HEAD:업로드 상태를 확인하기 위해 사용. 서버는 현재 업로드된 바이트의 오프셋을 응답. 
  • PATCH: 파일의 특정 바이트를 업로드하기 위해 사용. 서버는 업로드된 바이트를 저장하고, 새로운 오프셋을 응답. ❷❸ 
  • DELETE:업로드를 취소하거나 삭제하기 위해 사용. 서버는 업로드된 데이터를 삭제합니다.

그림4. 파일 업로드 프로세스


다음은 파일이 업로드되면 유니크한 아이디를 생성하고 메타 정보는 meta.json 형태로 저장하여 청크파일 업로드를 구현하는 예이다. 



2024년 7월 8일

코딩 - Vue3 & Spring : AG-Grid 페이징, 필터 구현하기

  ◼︎ 환경

  • Model : MacBook Pro (14-inch, 2021)
  • CPU : Apple M1 Pro
  • MENORY : 16GB
  • DISK : 512 GB SSD
  • OS : macOS 13.2.4 (22F66)
  • TOOLS : Visual Studio Code, Java 11, Gradle, Docker
  • Version Control : GitHub
  • Programming Language : Java, Vue3
  • Front-End Framework : Vue 3.4.30, Vuetify 3.6.10, ag-grid-vue3 31.3.2
  • Back-End Framework : Spring Boot 2.7.12, Spring Security 5.7.7 
  • DBMS : MySql 8.0.33
  • Cloud : OCI (free tier account)

AG-Grid 을 이용한 그리드 구현

Vue3 에서 AG-Grid 는 다음과 같은 3단계 과정을 통하여 만들 수 있다.

① Vue3 용 ag-grid-vue3 패키지 설치

npm install --save ag-grid-vue3

② AG-Grid CSS 스타일 추가

import "ag-grid-community/styles/ag-grid.css"; // Mandatory CSS required by the grid
import "ag-grid-community/styles/ag-theme-quartz.css"; // Optional Theme applied to the grid

③ AG-Grid 설정 ( 그리드를 구현한 vue 파일에서 )
gridOptions를 사용하여 그리드의 전반적인 공통 설정을 관리하고, columnDefs를 사용하여 각 컬럼의 설정을 관리한다. 이를 통해 그리드의 설정을 더 쉽게 체계적으로 관리할 수 있다. 


<template>
<AgGridVue class="ag-theme-quartz"
:gridOptions="gridOptions"
:columnDefs="columnDefs"
:rowData="gridData"
@grid-ready="onGridReady"
style="height:600px;"></AgGridVue>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';

import { AgGridVue } from "ag-grid-vue3"; // Vue Data Grid Component
import type { GridApi, IGetRowsParams } from 'ag-grid-community';
import type { GridOptions, ColDef } from 'ag-grid-community';

// ag-grid
const loader = ref(false);
const gridData = ref([]);
const gridApi = ref<GridApi | null>();

// ag-grid options for common.
const gridOptions: GridOptions = {
defaultColDef: {
flex: 1,
minWidth: 100,
resizable: true,
sortable: true,
filter: true,
cellStyle: {
display: 'flex',
alignItems: 'center'
}
},
columnTypes: {
string: {
filter: 'agTextColumnFilter',
filterParams: { suppressAndOrCondition: true },
cellStyle: { textAlign: 'center' },
},
number: {
filter: 'agNumberColumnFilter',
filterParams: { suppressAndOrCondition: true },
cellStyle: { textAlign: 'right' },
},
},
};


// define grid coloums
const columnDefs = [
{ field: 'groupId', headerName: 'ID', type: 'number',
width: 100, sortable: true },
{ field: 'name', headerName: 'Name', sortable: true , type:"text",
flex:3 , cellRenderer : GroupCellRenderer}, // 사용자 정의 셀 렌더러.
{ field: 'description', headerName: 'Description', type: 'string',
width: 50, sortable: false , filter:false},
];

const onGridReady = (params: any) => {
gridApi.value = params.api;
};

  • gridOptions : 그리드의 전반적인 설정을 정의. (defaultColDef, rowData, paginationPageSize, cacheBlockSize, rowSelection, getRowHeight 등)
  • columnDefs: 각 컬럼의 설정을 정의. 각 컬럼에 대해 headerName, field, sortable, filter, cellStyle, cellRenderer 등을 설정.
  • rowData: 그리드에 표시할 데이터를 설정.
  • onGridReady: 그리드가 준비되면 gridApi 인스턴스을 저장하여 그리드 제어에 사용

서버 사이드 페이징

서버 사이드 페이징 기능을 사용하지 않는 경우라면 간단하게 아래와 같이 rowData 에 해당하는 gridData 객체에 데이터를 강제로 주입해주기만 하면 쉽게 구현이 가능하지만 페이지, 필터, 소팅 등의 기능들을 서버에서 처리하는 것은 조금 더 많은 수고가 필요하다.  


onMounted(async () => {
fetch(`https://api.example.com/data`)
.then(response => response.json()) .then(data => { gridData.value = data.rows; }) .catch(error => { console.error(error); });
});


유료 버전을 사용하는 경우라면 gridOptions 의 rowModelType 값을 'serverSide' 로 변경하고 추가로 datasource 를 설정하면 쉽게 구현이 되는것으로 보이지만 무료 커뮤니티 버전은 좀더 복잡한 과정을 진행해야 한다.

rowModelType 은 그리드가 데이터를 로드하고 관리하는 방식을 결정한다. AG-Grid는 다양한 데이터 로딩 및 관리 요구를 충족시키기 위해 여러 가지 rowModelType을 제공하며, 각 타입은 특정 사용 사례에 최적화되어 있다. 무료 버전에서 서버 사이드 페이징을 구현하려면 rowModelType 값을 infinite 을 사용하여 구현해야 한다.


<AgGridVue class="ag-theme-quartz"
:rowModelType="'infinite'"
:gridOptions="gridOptions"
:columnDefs="columnDefs"
:pagination="true"
:paginationPageSize="pageSize"
:cacheBlockSize="cacheBlockSize"
@grid-ready="onGridReady"
style="height:600px;"></AgGridVue>


페이징 기능을 사용하기 위하여 아래와 같은 설정을 추가한다. 가독성을 고려하여 gridOptions 에 추가하지 않고 AgGridVue 의 개별 속성 값을 직접 설정하는  방식을 사용했다.  
  • pagination그리드에서 페이징 기능을 사용할지를 결정하는 옵션. 기본적으로 pagination 속성을 true로 설정하면 페이징이 활성화된다. 페이징이 활성화되면 그리드는 한 번에 일정한 수의 행만 표시하고, 사용자가 페이지를 전환할 수 있도록 페이지 내비게이션 컨트롤을 제공한다.
  • paginationPageSize한 페이지에 표시할 행(row)의 수를 설정하는 옵션. 이 속성을 설정하면 사용자가 그리드 페이지를 전환할 때마다 이 수만큼의 행이 표시되며 기본값은 100이다. (참고로 서버에서 데이터를 페이징하여 가져오는 값과는 구분된다.)
  • cacheBlockSize : infinite 또는 serverSide row model을 사용할 때 한 번에 서버에서 가져올 데이터의 블록 크기를 설정하는 옵션. 이 속성은 서버에서 데이터를 페이징 방식으로 가져올 때 유용하며 기본값은 100이다. (-> 서버에 전달하는 페이징 크기 값)
이제 데이터를 가져오는 datasource 에 대한 코딩이 필요하다. datasource 설정은 유연성을 고려하여 onGridReady 이벤트에서 설정한다. 주의할 것은 이벤트에서 인자로 전달되는 params 값을 사용하여 페이지 정보를 서버에 전달해야 하는 점이다.
 

function sortString (sort: SortModelItem[] ) {
if( sort.length > 0 ) {
return sort[0].colId + ',' + sort[0].sort ||''.toUpperCase();
}
return null;
}

const onGridReady = (params: any) => {
gridApi.value = params.api;
const dataSource = {
getRows: async (params: IGetRowsParams) => {
const page = Math.floor(params.startRow / pageSize.value);
const pageSizeValue = params.endRow - params.startRow;
const filterModel = params.filterModel;
const sortModel = params.sortModel;
try {
dataStore.setSort(sortModel);
dataStore.setFilter(filterModel);
dataStore.setPage(page);
filtersActive.value = Object.keys(filterModel).length > 0;
await axios.post(`https://api.example.com/data`,
JSON.stringify(filterModel),
{
params: {
...{
page: page,
size: cacheBlockSize.value,
sort: sortString(sortModel),
},
},
        })
        .then((response) => {
        const data = response.data;
        gridData.value = data
        total.value = data.totalElements;
        params.successCallback(gridData.value, data.totalElements );
        ).catch(error => {
    console.error(error);
        });
params.successCallback(gridData.value, total.value);
} catch (error) {
params.failCallback();
console.error(error);
}
}
};
params.api.setGridOption('datasource', dataSource);
};


위의 예는 서버에 post 방식으로 sorting, paging, filter 정보를 전달하고 있는데 Spring JPA 를 사용하고 있다면 아주 쉽게 서버 프로그램을 만들어 볼 수 있다.

추가로 서버에 데이터를 전송할 때에 Map 형식의  AG-Grid 필터값을 객체 배열로 변환하고 필터 타입을 약어로 변경하여 서버로 전송하도록 아래와 같은 변환 함수를 사용 변환하여 전송했다.


export const convertAgGridFilterToServerFormat = (agFilterModel) => {
const kendoFilters = Object.keys(agFilterModel).map((colId) => {
const filter = agFilterModel[colId];
let operator;
switch (filter.filterType) {
case 'text':
operator = convertNumberFilterType(filter.type);
break;
case 'number':
operator = convertNumberFilterType(filter.type);
break;
case 'boolean':
operator = 'eq';
break;
case 'date':
operator = convertNumberFilterType(filter.type);;
break;
default:
operator = 'eq';
}
return {
field: colId,
operator: operator,
value: filter.filter
};
});
return {
logic: 'and',
filters: kendoFilters
};
};

// 숫자 필터 타입 변환 함수
const convertNumberFilterType = (type) => {
switch (type) {
case 'equals':
return 'eq';
case 'notEqual':
return 'ne';
case 'lessThan':
return 'lt';
case 'lessThanOrEqual':
return 'lte';
case 'greaterThan':
return 'gt';
case 'greaterThanOrEqual':
return 'gte';
case 'contains':
return 'contains'
default:
return 'eq';
}
};

아래는 별도의 store 객체로 분리하여 서버 통신 모듈을 구성한 예이다.

import { API_HEADERS, authHeader, convertAgGridFilterToKendo } from "@/util/helpers";
import axios from "axios";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useAlertStore } from "../../alert.store";
import { GroupModel } from "@/types/models/GroupModel";
import type { SortModelItem } from 'ag-grid-community';

export const usePageableGroupsStore = defineStore("pageable-groups-store", () => {
// error state store
const alertStore = useAlertStore();

// state
const isLoaded = ref<boolean>(false);
const dataItems = ref<GroupModel[]>([]);
// pageable
const total = ref<number>(0);
const page = ref<number>(1);
const pageSize = ref<number>(20);
const sort = ref<SortModelItem[]>([]);
const filter = ref({ logic: "and", filters:[] });

// getters
const getById = computed(() => {
return (groupId: number) =>
dataItems.value.find((item) => item.groupId === groupId);
});

const getByName = computed(() => {
return (name: string) => dataItems.value.find((item) => item.name === name);
});

// setters
function setSort(newValue: SortModelItem[]) {
sort.value = newValue;
}
function setFilter(newValue:any) {
filter.value = convertAgGridFilterToKendo(newValue) ;
}

function setPage(newVal: number): void {
if (page.value === newVal) return;
page.value = newVal;
isLoaded.value = false;
}
// actions
async function loadById(groupId: number) {
const headers = { ...API_HEADERS, ...authHeader() };
const category = await axios.get(
`${ import.meta.env.VITE_API_URL }/data/secure/mgmt/security/groups/${groupId}`,
{ headers: headers }
)
.then((response) => {
const data = response.data;
return data;
})
.catch((err) => {
alertStore.error(err);
});
return category;
}

async function saveOrUpdate(item: GroupModel) {
const isNew = item.groupId === 0;
const headers = { ...API_HEADERS, ...authHeader() };
await axios
.post(
`${import.meta.env.VITE_API_URL}/data/secure/mgmt/security/groups/${item.groupId}`,
JSON.stringify(item),
{ headers: headers }
)
.then((response) => {
const dataItems = response.data;
isLoaded.value = false;
})
.catch((err) => {
alertStore.error(err);
});
}


function sortString () {
if( sort.value.length > 0 ) {
return sortField() + ',' + sortDir()||''.toUpperCase();
}
return null;
}
function sortField() {
if (sort.value.length > 0) {
return sort.value[0].colId;
}
return null;
}

function sortDir() {
if (sort.value.length > 0) {
return sort.value[0].sort ;
}
return null;
}

function pagableParams(){
return {
page: page.value,
size: pageSize.value,
sort: sortString(),
}
}

function filterParams() {
var params = {};
if (filter.value.filters.length > 0) {
filter.value.filters.forEach( item =>{
params[item.field] = item.value;
})
}
return params;
}


async function fetch() {
const headers = { ...API_HEADERS, ...authHeader() };
if( filter.value.filters.length > 0){
return await axios.post(
`${import.meta.env.VITE_API_URL}/data/secure/mgmt/security/groups:find`,
JSON.stringify(filter.value),
{
params: {
...pagableParams(),
},
headers: headers })
.then((response) => {
const data = response.data;
dataItems.value = [];
total.value = data.totalElements;
data.content.forEach((item) => {
dataItems.value.push(item);
});
isLoaded.value = true;
}).catch( err => {
alertStore.error(err);
});
}else{
await axios
.get( `${import.meta.env.VITE_API_URL}/data/secure/mgmt/security/groups`, {
params: {
...pagableParams()
},
headers: headers })
.then((response) => {
const data = response.data;
dataItems.value = [];
total.value = data.totalCount;
data.items.forEach((item) => {
dataItems.value.push(item);
});
isLoaded.value = true;
})
.catch((err) => {
alertStore.error(err);
});
}
}
return {
isLoaded,
dataItems,
total,
page,
pageSize,
getById,
getByName,
setFilter,
loadById,
fetch,
saveOrUpdate,
setPage,
setSort,
};
});



서버 프로그램 

Vue 프로그램에서 요청을 처리하기 위한 서버 프로그램은 스프링 컨트롤러를 이용하여 코딩하면 된다. 아래 컨트롤러 예제는 페이징과 소팅 값은 파라메터 형태로  필터 값은 바디 형태로 바인딩 하고 있다. (-> 페이징 및 소팅은 Spring 에서 제공하는 Pageable 을 활용하여 바인딩)


@PostMapping(value = "/data", produces = MediaType.APPLICATION_JSON_VALUE)
public Page<Group> findGroups(@RequestBody FilterModel filter, @PageableDefault(size = 100, sort = "userId", direction = Sort.Direction.DESC) Pageable pageable) {
return groupService.findGroups(filter.getFilters(), pageable);
}


필터는 별도의 필터 클래스를 정의하여 사용한다. 위 코드에서 groupService 는  에 해당하는 소스는 데이터베이스에서 데이터를 조회하기 위하여 JPA Repositry 을 기반으로 하는 GroupRepository 을 호출하게 된다. GroupRepository 클래스는 필터에 따른 동적 쿼리를 지원하기 위하여 JpaRepository 와 JpaSpecificationExecutor 을 상속 받도록 한다. 

groupService 서비스 클래스는 ⑴ 필터 객체(FilterModel) 배열과 페이징(Pageable) 객체를 인자로 받아  ⑵ Specification 객체를 생성하고 ⑶ Repository 객체의 findAll 함수를 호출하여 Page 형식으로 데이터를 응답받아 리턴하는 형태로 구현한다.