Antd를 먼저 도입해서 쓰고 있었는데, 기능은 많지만 빌드 용량이 너무 무거워서 Mantine으로 점진적으로 교체중이다.

vscode의 cost impact라는 확장프로그램만 봐도 antd는 mantine의 2~3배는 찍는달까

다만, mantine은 도입초기, 서양에서 만든거 답게 padding이 넉넉한 편이라 전체를 뜯어고치고픈 욕구가 치미는데, 사이트의 문서가 너무 부실하여 provider에서 적용가능한 설정을 고치고 또 고치고 난리도 아니었다. 
styles나 vars등으로 고칠 수 있는 부분이 꽤 많지만, css로만 가능한 영역도 혼재해 있어서 시행착오가 제법 컸달까. 쓰다보니 설계가 좋은 편이라 커스텀해서 쓰기 잘 되어 있는건 맞는데, 아쉬운 부분도 있어서 요즘은 해당 프로젝트를 git으로 받아서 코드를 보면서, 컴포넌트를 고쳐 쓰는 경우가 잦아지고 있다.

예를 들면, datepickerinput, monthpickerinput, yearpickerinput이 따로 있다.
모를때는 따로 따로 import해서 썼지만, 코드를 살펴보니 datepickerinput에서 minLevel이라는 속성만 쓸 수 있다면, 하나로 퉁칠 수 있는 구조였다. 실제로 써보면 타입에러는 나지만 동작은 한다.ㅋㅋㅋ
이렇게 간단한 것도 있지만, 생각보다 거대한 경우도 있는데, datepickerinput을 보면 좌우에 이전달, 다음달 이동 버튼은 있지만, 전년, 후년 이동 버튼은 없다. 이걸 넣고 싶은데 header에 버튼을 넣는 방법이 없다. 코드를 보면 이게 calendar 컴포넌트를 재사용하는데 여기에 그 기능이 없기 때문이다. 고로 그 header에 그 버튼을 넣고 싶다면, calendar 컴포넌트부터 고쳐야 하고, 그 고친것을 datepicker에 넣어줄 수 없기 때문에 또 datepicker 컴포넌트를 고쳐야 하며, datepickerinput 컴포넌트에서 그걸 PickerInputBase 컴포넌트로 감싸야 최종 사용이 가능하다.

또다른 케이스로는 quarterpickerinput이 필요했다. 그래서 calendar, monthpicker, monthpickerinput, monthlist, yearlevel, yearlevelgroup 컴포넌트 코드를 받아서 month를 quarter로 고쳤.....

딴길로 샜는데 아무튼, 결과적으로보니 커스텀해서 쓰기좋게 잘되어있긴 한데, 써보니 styles나, vars로만 하는 건 한계가 있어서 css로 병행해야 하는 부분도 있는데, 앞에도 적었지만, 사이트의 문서가 많이 부실하여 그로 인한 러닝커브가 다소 높은 편이랄까. 특히 vars가 이게 타입으로 잘 뜨는 것도 있지만, 어떤건 없지만 되는 것도 있고ㅋㅋㅋㅋ 그냥 개발자도구로 보면서 고치는게 된달까...
이렇게 보면 고생스러워 보이긴
결과적으로 페이지에서 antd 컴포넌트 하나둘 덜어낼때마다 빌드후 페이지 용량이 nnKb씩 줄어드는거보면 뿌듯하긴 하다. 

그리고 mantine이 요즘 열일하는 느낌이라 응원하게 된다. v9부터는 select와 multiselect의 value를 숫자도 쓸 수 있게 해주고, tree select도 생겼고(아직 value는 문자열밖에 안되지만),  splitter도 추가되기도 했고 (다만, toggle 기능을 내장안해놓는 바람에 SplitButtonGroup, Splitter.context, Splitter, SplitterPane 코드 받아서 수정한건 함정)
schedule 패키지도 9.3까지 테스트 했을때는 버그가 있어서 쓰지 못했지만, 9.4에는 그토록 원했던 resource view들도 생겼다.

그 사이 antd는 요즘 버전만 올라가지 새 기능은 별로 없어서 한동안 살펴보지 않다가, 최근에 확인해보니 Masonry라는 높이가 다른 벽돌식 레이아웃 요소라던지,  BorderBeam이라는 아주 깜찍한(?) 컴포넌트가 생기긴 했다.

antd가 무거워서 그렇지 기능이 좋긴 좋은데, 묶어놓은 컴포넌트들을 안에꺼만 쪼개서도 불러올 수 있게 해줬다면 이런 수고는 안 했을려나?
antd의 form과 cacader는 현재로선 대체 불가라 antd provider를 걷어내는 날이 올까 싶다.

1. react-data-grid

npm: https://www.npmjs.com/package/react-data-grid

demo: https://comcast.github.io/react-data-grid/

demo code: https://github.com/Comcast/react-data-grid/tree/main/website

특징: headless 아님

장점: 컬럼 순서 변경하기 등은 자체 제작하여 내장되어있고, 가상화가 기본 동작으로, 별다른 커스텀 없이 가볍게 동작함. 그룹화, export 기능등 예제도 풍부함.

단점: 정렬 코어, 필터 코어 등은 내장이 안되어 있어서 자체 제작 필요. 그 외 커스텀 속성을 추가할 만한 자리를 기본제공하지 않아서 타입을 약간 손볼 필요있음. 컬럼 숨기기는 없는데 여긴 rows, columns 같이 한번에 지정하는 grid 방식이라 사실상 columns를 새로 보내야 해서... 이 리렌더는 좀 무거운 편.

사담으로는 베타 버전 하나 올라갈때도 내부 코드가 엄청 바뀌고, 내부에서 사용하는 라이브러리도 잘 바뀌는 편이라 여러가지로 공부가 됨(p)

 

 

2. tanstack table (구 react table)

npm: https://www.npmjs.com/package/@tanstack/react-table

demo: https://tanstack.com/table/latest/docs/framework/react/examples/basic

특징: headless 임

장점: 내장된 필터가 다양해서 골라쓰는 맛이 있음. 출력용 값과 그룹핑용 값 등을 달리하는데 아무 문제 없으며, 커스텀 속성을 추가할 만한 자리가 기본 제공되어 있어 커스텀해서 쓰기 좋음. 컬럼 숨기기 기능도 기본 제공. 기본으로 출력용 컬럼, 그룹핑용 컬럼, 검색용 컬럼 다 다르게 설정이 가능한 것도 개인적으로는 큰 장점.

단점: 가상화가 기본이 아닌데, dnd 결합한 방식의 예제가 있지만 굉장히 느림. 컬럼순서변경도 심하게 느림... memo를 적절하게 써서 리렌더를 줄이는 것도 중요

virtualized-rows 예제는 노답이고 virtualized-rows-experimental는 낫지만 이것도 실사용은 못할 수준으로 느린데 transform으로 row 위치 변경하던걸 react-data-grid 코드를 분석해서 그 방식으로 뜯어고치니 쓸만해지긴 함.

더보기

 

table {
	display: grid;
	text-align: center;
	flex-grow: 1;
	block-size: 350px;
	overflow: auto;
	grid-template-rows:
		calc(var(--header-rows) * var(--header-row-height) + var(--header-filter-height)) auto
		calc(var(--summary-rows) * var(--summary-row-height));
    ... 
    /* column-resizing-performant예제에서 소개한 방식대로 컬럼 크기들도 미리 변수로 선언해 놓으면
    th, td에서 width 크기 확인 호출 안해도 되서 좀 더 빠른 느낌 */
}
tbody {
	display: grid;
    grid-template-rows: repeat(var(--rows-count), var(--row-height));
}
tbody tr {
    grid-row-start: calc(var(--virtual-row-index) + 1);
    display: flex;    
    ... 
    /* 이 방식은 position: absolute 이거 안해도 됨  */
}

 

컬럼들도 grid columns로 처리하면 더 빨라지기도 하고, auto-fit등을 써서 남은 공간도 채워서 알차게 쓸 수 있을 것 같긴 한데, 컬럼 숨기기 할 때 지금 방식이 주어진 예제에서 크게 고칠게 없어서 지금은 이 정도만 했음. 나중에 시간이 되면 이 부분도 좀 더 개선해볼 수 있지 않을까 생각중.

excel로 export 하는 건 react-data-grid 코드를 참고해서 붙여봤는데 table 태그를 쓰다보니 header group 을 colspan, rowspan 등을 쓸 수 있어서 결과물이 잘 나옴(?). 다만, colspan은 주는거 그대로 쓸 수 있지만, rowspan이 0만 나와서 새로 계산해서 만들어야 했다는 건 함정

 

react-daga-grid는 key, name으로 key가 고정이라 주어진 columns를 가지고 가공하기 편한 부분도 있고, 자료구조가 심플해서 러닝커브가 완만한 편인데, tanstack table은 header가 고정이지만, 영어권이 아닌 우리나라는 컬럼명 출력으로 써야 하다보니 재쳐놓고나면, id고, accessorKey가 옵션이고,  accessorFn만 나중에 공식적(?)으로 확실하게 꺼내쓸 수 있어서 가공할때 다소 불편한 구석이 있다. 데이터도 구조가 복잡해서 처음에 익숙해지기 까지 다소 시간이 걸리는 편.

둘 다 장단이 확실한 편인데 가볍게 도입해서 쓰기에는 react-data-grid가 좋고,
주어진 예제만 믿지 말고, 지속적으로 뜯어고쳐가며 헤비하게 쓸 생각이라면 tanstack table이 좋겠다라는 결론이다.

 

import { useEffect, useMemo, useRef, useState } from 'react'
import { faker } from '@faker-js/faker'

type Person = {
	id: number
	// userId: string
	firstName: string
	lastName: string
	age: number
}

const range = (len: number) => {
	const arr: number[] = []
	for (let i = 0; i < len; i++) {
		arr.push(i)
	}
	return arr
}

const newPerson = (num: number): Person => {
	return {
		id: num,
		// userId: faker.string.uuid(),
		firstName: faker.person.firstName(),
		lastName: faker.person.lastName(),
		age: faker.number.int(40),
	}
}

const ROW_HEIGHT = 35

const rows = range(2000).map((index): Person => newPerson(index))
const rowCount = rows.length

export const VirtualScroll = () => {
	const gridRef = useRef<HTMLDivElement>(null)

	const [scrollTop, setScrollTop] = useState(0)
	const [clientHeight, setClientHeight] = useState(0)

	const handleScroll = ({ currentTarget: { scrollTop } }: React.UIEvent<HTMLDivElement>) => setScrollTop(scrollTop)

	useEffect(() => {
		if (!gridRef.current) return
		const handleResize = () => {
			if (!gridRef.current) return
			setClientHeight(gridRef.current.clientHeight)
		}

		const { scrollTop, clientHeight } = gridRef.current

		setScrollTop(scrollTop)
		setClientHeight(clientHeight)
		window.addEventListener('resize', handleResize)

		return () => {
			window.removeEventListener('resize', handleResize)
		}
	}, [])

	const { startIndex, endIndex } = useMemo(() => {
		const viewportHeight = clientHeight
		const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT))
		const visibleRowCount = Math.min(rowCount - startIndex, Math.ceil(viewportHeight / ROW_HEIGHT))
		const endIndex = startIndex + visibleRowCount

		return { startIndex, endIndex }
	}, [clientHeight, scrollTop])

	const visibleRows = rows.slice(startIndex, endIndex)

	return (
		<div style={{ flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
			{/* <div style={{ display: 'flex', gap: '1em' }}>
				<span>scrollTop: {scrollTop.toLocaleString()}px</span>
				<span>clientHeight: {clientHeight.toLocaleString()}px</span>
				<span>startNode: {startIndex.toLocaleString()}</span>
				<span>visibleNodesCount: {endIndex - startIndex}</span>
			</div> */}
			<div
				ref={gridRef}
				style={{
					display: 'grid',
					blockSize: 350,
					flexGrow: 1,
					gridTemplateRows: `repeat(${rows.length}, 35px)`,
					overflow: 'auto',
					border: '1px solid gray',
				}}
				onScroll={handleScroll}
			>
				{visibleRows.map((item, i: number) => (
					<div
						key={item.id}
						style={{
							gridRowStart: startIndex + i + 1,
							display: 'flex',
							gap: '1em',
							height: ROW_HEIGHT,
							borderBottom: '1px solid gray',
						}}
					>
						<span>{i + 1}</span>
						<span>{item.id}</span>
						{/* <span>{item.userId}</span> */}
						<span>{item.firstName}</span>
						<span>{item.lastName}</span>
						<span>{item.age}</span>
					</div>
				))}
			</div>
		</div>
	)
}

브라우저 resizing 까지는 대응했지만, debounce 미적용이라...

다양한 루트로 얻은 소스를 짬뽕한거라 일관성은 없지만 대략 사용방법을 터득하기에는 괜찮을 것 같아서 정리해봄

최종 채택은 2번으로 했지만 나머지도 기록 삼아 남김

 

1. 클라이언트 - heic2any: webp 바로 전환은 안되서 png 거쳐서 변환. 갤럭시 울트라 200M 사진은 처리불가

'use client'

import { useState } from 'react'

export function FileUpload() {
	const [file, setFile] = useState<any>()

	// 파일 선택 시 처리
	const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return
		
		const isHeic =
			file.type === 'image/heic' ||
			file.name.toLowerCase().endsWith('.heic') ||
			file.name.toLowerCase().endsWith('.heif')

		if (isHeic) {
			try {
				const heic2any = (await import('heic2any')).default
				// 1. HEIC/HEIF 파일을 PNG Blob으로 변환
				const pngBlob = await heic2any({
					blob: file,
					toType: 'image/png',
				})

				// 2. PNG Blob을 File 객체로 변환 및 WebP로 Canvas 변환
				const convertedFile = await convertToWebP(pngBlob, file.name)
				setFile(convertedFile)
			} catch (error) {
				console.error('### HEIC 변환 실패:', error)
				alert('이미지 변환에 실패했습니다. 다른 이미지를 시도해주세요.')
			}
		} else {
			setFile(file)
		}
	}

	// Blob을 WebP 포맷 File 객체로 변환하는 함수
	const convertToWebP = (blob: Blob | Blob[], originalName: string): Promise<File> => {
		return new Promise((resolve, reject) => {
			const img = new Image()
			const url = URL.createObjectURL(Array.isArray(blob) ? blob[0] : blob)

			img.onload = () => {
				URL.revokeObjectURL(url)
				const { width, height } = imageResize(img.width, img.height)

				// Canvas 생성
				const canvas = document.createElement('canvas')
				canvas.width = width
				canvas.height = height
				const ctx = canvas.getContext('2d')
				if (!ctx) throw new Error('Canvas context를 생성할 수 없습니다.')
				ctx.drawImage(img, 0, 0)
				// ctx.drawImage(img, 0, 0, targetWidth, targetHeight)

				canvas.toBlob(
					(webpBlob) => {
						if (!webpBlob) throw new Error('WebP 변환에 실패했습니다.')
						const newName = originalName.replace(/\.(heic|heif)$/i, '.webp')
						const webpFile = new File([webpBlob], newName, { type: 'image/webp' })
						resolve(webpFile)
					},
					'image/webp',
					0.8,
				)
			}
			img.onerror = (err) => reject(err)
			img.src = url
		})
	}

	// 폼 전송
	const handleSubmit = async (e) => {
		e.preventDefault()
		if (!file) return alert('파일을 선택해주세요.')

		const formData = new FormData()
		formData.append('image', file)
	}

	return (
		<form onSubmit={handleSubmit}>
			<input type="file" accept="image/*" onChange={handleFileChange} />
			<button type="submit">전송</button>
		</form>
	)
}

 

2. 클라이언트 - heic-decode: heic2any가 100%라면 60% 정도의 속도로 빠르게 처리하나 빌드 후 라이브러리 용량이 다소 나감. 갤럭시 울트라 200M 사진은 처리불가

'use client'

import { useState } from 'react'

export function FileUpload() {
	const [loading, setLoading] = useState(false)

	const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return

		setLoading(true)
		let fileToSend: File | Blob = file

		const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif')

		if (isHeic) {
			try {
				const arrayBuffer = await file.arrayBuffer()

				// 1. HEIC 내부의 원본 픽셀 데이터(RGBA) 추출
				const decode = (await import('heic-decode')).default // 399.78 KB => 910.64 KB
				const { width, height, data } = await decode({ buffer: new Uint8Array(arrayBuffer) })
				const { width: targetWidth, height: targetHeight } = imageResize(width, height)

				// 3. 임시 오프스크린 캔버스 생성하여 리사이징 처리
				const canvas = document.createElement('canvas')
				canvas.width = targetWidth
				canvas.height = targetHeight
				const ctx = canvas.getContext('2d')

				if (!ctx) throw new Error('Canvas context를 생성할 수 없습니다.')

				// 원본 거대 데이터를 임시 ImageData 객체로 만듦
				const originalImageData = new ImageData(new Uint8ClampedArray(data), width, height)

				if (width === targetWidth && height === targetHeight) {
					// 크기가 동일하면 그대로 그림
					ctx.putImageData(originalImageData, 0, 0)
				} else {
					// 임시 캔버스를 거쳐 브라우저 하드웨어 가속을 이용해 부드럽게 축소(Downscale)합니다.
					const tempCanvas = document.createElement('canvas')
					tempCanvas.width = width
					tempCanvas.height = height
					tempCanvas.getContext('2d')?.putImageData(originalImageData, 0, 0)

					// 최종 캔버스에 축소해서 그리기
					ctx.drawImage(tempCanvas, 0, 0, targetWidth, targetHeight)
				}

				// 4. 🔥 [수정] 캔버스 내용을 WebP Blob으로 변환
				const webpBlob = await new Promise<Blob | null>((resolve) => {
					canvas.toBlob((blob) => resolve(blob), 'image/webp', 0.8)
				})

				if (!webpBlob) throw new Error('WebP 변환에 실패했습니다.')

				// 5. 🔥 [수정] 서버 전송용 File 객체 확장자를 .webp로 재포장
				const newFileName = file.name.replace(/\.(heic|heif)$/i, '.webp')
				fileToSend = new File([webpBlob], newFileName, { type: 'image/webp' })
			} catch (error) {
				console.error('브라우저 내 초고해상도 변환 중 에러 발생:', error)
				alert('이미지가 너무 커서 변환에 실패했습니다. 일반 JPG/PNG를 사용해주세요.')
				setLoading(false)
				return
			}
		}

		// 6. Next.js Route Handler로 FormData 전송
		try {
			const formData = new FormData()
			formData.append('file', fileToSend)
		} catch (err) {
			console.error(err)
		} finally {
			setLoading(false)
		}
	}
	return (
		<>
			<div>
				<input type="file" accept=".heic,.heif" onChange={handleFileChange} disabled={loading} />
				{loading && <p>이미지 최적화 및 변환 중... 잠시만 기다려주세요 (화면을 끄지 마세요)</p>}
			</div>
		</>
	)
}

 

3. 클라이언트 - @imagemagick/magick-wasm: 갤럭시 울트라 200M 사진도 처리가 가능하나 속도가 압도적으로 느리다.

'use client'
import NextImage from 'next/image'
import { useState } from 'react'

async function initMagick() {
    const { initializeImageMagick } = await import('@imagemagick/magick-wasm')
	const response = await fetch('/magick.wasm') // public 경로. 해당 파일은 \node_modules\@imagemagick\magick-wasm\dist 안에 있음
	const wasmBuffer = await response.arrayBuffer()
	await initializeImageMagick(wasmBuffer)
}
initMagick()

export function FileUpload() {
	const [outputUrl, setOutputUrl] = useState({ src: '', width: 0, height: 0 })

	const handleConvert = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return
		const { ImageMagick } = await import('@imagemagick/magick-wasm')

		// 2. Read file as Uint8Array
		const arrayBuffer = await file.arrayBuffer()
		const data = new Uint8Array(arrayBuffer)

		// 3. Perform transformation
		ImageMagick.read(data, (image) => {
			const { width, height } = imageResize(image.width, image.height)
			image.resize(width, height)
			image.format = 'WEBP'

			// 4. Write back to a Blob for display
			image.write((outputData) => {
				const url = window.URL.createObjectURL(new Blob([new Uint8Array(outputData).buffer], { type: 'image/webp' }))
				setOutputUrl({ src: url, width, height })
			})
		})
	}

	return (
		<div>
			<input type="file" onChange={handleConvert} />
			{outputUrl.src && <NextImage {...outputUrl} alt="Converted" />}
		</div>
	)
}

function imageResize(width: number, height: number) {
	const MAX_WIDTH = 4000
	const MAX_HEIGHT = 4000
	let targetWidth = width
	let targetHeight = height

	if (width > MAX_WIDTH || height > MAX_HEIGHT) {
		if (width > height) {
			targetWidth = MAX_WIDTH
			targetHeight = Math.round((height * MAX_WIDTH) / width)
		} else {
			targetHeight = MAX_HEIGHT
			targetWidth = Math.round((width * MAX_HEIGHT) / height)
		}
	}
	return { width: targetWidth, height: targetHeight }
}

 

4. 서버 - heic-convert, sharp: 갤럭시 울트라 200M 사진은 처리불가

import { NextRequest, NextResponse } from 'next/server';
import convert from 'heic-convert'
import sharp from 'sharp'

async function handler(req: NextRequest) {


	// ...전략
	
		const formdata = await req.formData();

		const newFormData = new FormData()

		for (const [key, value] of formdata.entries()) {
			if (!value) {
				newFormData.append(key, '')
			} else if (value instanceof File === false) {
				newFormData.append(key, value)
			} else {
				const file = value
				const newFileName = file.name.split('.')
				const ext = newFileName.pop()

				if (!ext || ['heic', 'heif'].includes(ext.toLowerCase()) === false) {
					newFormData.append(key, value)
				} else {
					const outputBuffer = await convert({
						buffer: new Uint8Array(await file.arrayBuffer()) as unknown as ArrayBuffer,
						format: 'PNG',
					})
					// 2. 변환된 버퍼를 sharp로 처리 (리사이징 등)
					const webpBuffer = await sharp(outputBuffer).webp({ quality: 80 }).toBuffer()

					// 3. Web API의 File 객체 형태로 만들어 FormData에 추가
					const finalFile = new File([webpBuffer as any], `${newFileName}.webp`, { type: 'image/webp' })
					newFormData.append(key, finalFile)
				}
			}
		}

// ...후략		
}

export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const OPTIONS = handler;

 

공통함수

export function imageResize(width: number, height: number) {
	const MAX_WIDTH = 4000
	const MAX_HEIGHT = 4000
	let targetWidth = width
	let targetHeight = height

	if (width > MAX_WIDTH || height > MAX_HEIGHT) {
		if (width > height) {
			targetWidth = MAX_WIDTH
			targetHeight = Math.round((height * MAX_WIDTH) / width)
		} else {
			targetHeight = MAX_HEIGHT
			targetWidth = Math.round((width * MAX_HEIGHT) / height)
		}
	}
	return { width: targetWidth, height: targetHeight }
}

 

 

에디터를 결정하기에 앞서 여러가지를 테스트해봤다.

- slate: 데이터가 json 형태라 기존 데이터와 호환성 문제로 fail

- react-draft: 데이터가 객체형태라 viewer로 따로 써야하고 어쩌고 하는데 무엇보다 반응속도라 느려서 fail

- quill: toolbar를 세부항목까지 자유롭게 커스텀이 가능한 점은 좋았으나 표나 이미지 업로드 기능같은걸 내가 구현해야한다는 점에서 내가 게을러서 fail

- jodit: 무료는 로고 존재감도 쎄고 문서가 불친절해서 보류했다가 tiny mce로 결정해서 fail

- tiny mce: 문서가 매우 잘 되어있고 커뮤니티 버전으로도 어지간한 기능은 다 있어서 최종 결정하게 되었다.

 

import dynamic from 'next/dynamic'
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react'

const Editor = dynamic(() => import('@tinymce/tinymce-react').then((mode) => mode.Editor), {
	ssr: false,
})

// plugin 기능 import 역할
const tinymcePlugins = [
	'link',
	'lists', // 기본 ol, ul
	'image',
	'autoresize',
	'autolink',
	'advlist', // ol, ul 불릿이 좀 더 여러가지가 생김
	'table',
	'preview',
	'searchreplace', // 치환기능
	'visualblocks', // 태그를 실루엣으로 보여주어 태그가 꼬일경우 편집하는데 도움됨
	'code', // 코드삽입기능ㄴㄴ html 태그로 보여주는 기능임
	'fullscreen',
	'media', // 동영상
	'accordion', // 티스토리 기준 접은글 기능
	'autosave', // localstrage에 저장함
]

// toolbar에 해당 기능을 나열함. plugin이 필요한 기능의 경우 위에도 아래에도 둘 다 들어가야함
const tinymceToolbar =
	'undo redo | blocks fontsize |' +
	'bold italic underline strikethrough forecolor backcolor |' +
	'bullist numlist blockquote|' +
	'alignleft aligncenter alignright alignjustify | outdent indent |' +
	'link image table accordion media | removeformat visualblocks searchreplace restoredraft | fullscreen preview code'

// 온갖 설정은 여기에 다 넣는다고 보면 됨
const editorInitOption = {
    language: 'ko_KR', // 한국어팩
    plugins: tinymcePlugins,
    toolbar: tinymceToolbar,
    color_default_background: '#E67E23', // fontBackColor 기본 선택은 되지만 "@tinymce/tinymce-react": "^4.3.0" 기준 pallete에서 선택은 안되어 있다.
	color_default_foreground: window.matchMedia('(prefers-color-scheme: dark)').matches ? '#fff' : '#000', // fontColor 기본 선택
    
    // dark skin 적용
    // toolbar css 변경 방법은 찾고 있다.
    skin: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'oxide-dark' : 'oxide',
    // dark 대신 css 파일 경로를 넣어줄수도 있다.
    // dark 적용후 import 되는 css 파일을 받아서 dark 기본색인 #222f3를 수정후 사용하면 좋다.
    content_css: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default',
    
    autosave_interval: '20s',
    autosave_restore_when_empty: true, // 빈 편집기에 마지막 저장데이터 복원여부 확인
    autosave_retention: '30m', // 30분 넘은 저장데이터는 삭제
    autosave_prefix: 'tinymce-autosave-{path}{query}-{id}-',
    // min_height: 250,
    // max_height: document.querySelector('#__next').offsetHeight - 250,
    menubar: false,
    branding: false,
    statusbar: false,
    
    // automatic_uploads: false, // true가 기본값
    // 이미지를 업로드할 서버 api 주소. 
    // 다음과 같은 형태로 반환해야 정상적으로 동작한다. 
    // { "location": "folder/sub-folder/new-location.png" }
    images_upload_url: '/api/image-uploads',  
    images_upload_base_path: '/api/files', // 업로드한 파일의 중복경로
    images_upload_credentials: true, // 인증정보가 cookie면 true
    file_picker_callback: (callback, value, meta) => {	
        const input = document.createElement('input')
        input.setAttribute('accept', 'image/*')
        input.setAttribute('type', 'file')
        input.onchange = function () {
            const file = this.files[0]
            const reader = new FileReader()
            reader.onload = function () {
                const id = 'blobid' + new Date().getTime()
                const blobCache = tinymce.activeEditor.editorUpload.blobCache
                const base64 = reader.result.split(',')[1]
                const blobInfo = blobCache.create(id, file, base64)
                blobCache.add(blobInfo)
                callback(blobInfo.blobUri(), { alt: file.name })
            }
            reader.readAsDataURL(file)
        }
        input.click()
    },
    
    // 이미지 업로드 중 커스텀 기능을 넣어야한다면 이걸로 처리해야 함
    // 위에서 설정한 항목중 images_upload_url, images_upload_base_path, images_upload_credentials를 쓰지 않고 내부 코드을 사용함
    // convert_urls: false, // 아래에서 resolve가 절대경로를 반환하는 거라면 false 기본값은 true임
    // images_upload_handler: (blobInfo, progress) => 
    //   
    // 	new Promise((resolve, reject) => {
    //		const images_upload_url = '/api/image-uploads' // 이미지 업로드 api 주소
    // 		const xhr = new XMLHttpRequest()
    // 		xhr.withCredentials = true // 인증정보가 cookie면 true
    // 		xhr.open('POST', images_upload_url)

    // 		xhr.upload.onprogress = (e) => {
    // 			progress((e.loaded / e.total) * 100)
    // 		}

    // 		xhr.onload = () => {
    // 			if (xhr.status === 403) {
    // 				reject({ message: 'HTTP Error: ' + xhr.status, remove: true })
    // 				return
    // 			}
    // 			if (xhr.status < 200 || xhr.status >= 300) {
    // 				reject('HTTP Error: ' + xhr.status)
    // 				return
    // 			}

    // 			const json = JSON.parse(xhr.responseText)
    // 			if (!json || typeof json.location != 'string') {
    // 				reject('Invalid JSON: ' + xhr.responseText)
    // 				return
    // 			}
    
    //			/* 커스텀 설정 넣을 곳 */

    // 			resolve(json.location) // 경로
    // 		}

    // 		xhr.onerror = () => {
    // 			reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status)
    // 		}

    // 		const formData = new FormData()
    // 		formData.append('file', blobInfo.blob(), blobInfo.filename())

    // 		xhr.send(formData)
    // 	}),
}

export default function Editor() {
	const editorRef = useRef(null)
    
    // editorRef.current.getContent()
    
	return <Editor
        onInit={(e, editor) => (editorRef.current = editor)}
        apiKey="API키"
        initialValue={에디터에 출력할 기본값이 있다면 여기}
        init={editorInitOption}
    />
}

toolbar css는 일단.... _doucument 파일에다 <NextScript /> 아래에 <link rel="stylesheet" href="" /> 태그를 삽입하는 방식으로 일단 처리하려고 한다. 대신에 @media (prefers-color-scheme: dark) { } 로 감싸주었다.

필요한 라이브러리를 찾았는데, 혹은 정착한 라이브러리라 하더라도 더 좋은게 있는데 놓치고 있진 않는가 확인하는 용도로 http://npmtrends.com 사이트를 이용하곤 한다.

어쩌다 다국어 관련하여 https://npmtrends.com/next-i18next-vs-next-translate 이걸 확인해보니 애정(?)하는 next-translate의 이용자 수가 미미한 것에 자극받아 귀차니즘을 무릎쓰고 이 글을 쓰게 되었다.

 

1. next-i18next

next.js 기반에서 가장 많이 쓰는 라이브러리고, 위의 두번째 링크를 타고 가서 봐도 알 수 있듯이 사용자 수가 급속히 늘고 있다. 나도 실제로 가장 처음으로 도입했었었다. 그렇게 몇 달 쓰다가 매 페이지마다 serverSideTranslations 이런거 넣어주는거에 현타가 와서 next-translate 테스트 후 바로 넘어가게 되었다. 

import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import { useTranslation } from "next-i18next"

function Page () {
  const { t } = useTranslation("button")

  return (
    <div>
      <p>{t("ok")}</p>
    </div>
  );
};

export default Page

// 이거 페이지마다 해줘야 함
export const getStaticProps = async ({ locale }) => ({
  props: {
    ...(await serverSideTranslations(locale, ["common", "button", ... ])), // 해당 페이지에서 쓸 파일명 나열
  },
});

그러다보니 next-i18next에 대해서 많이 잊었다가 이 글을 쓰기 위해 리마인드하기 위해 검색하다 보니 url에 /ko/ /en/ 이런것도 붙었었던 것 같기도 하고 아무튼 뭔가 많이 너저분했었던 기억만 남아있을 정도랄까?

 

2. next-translate

 next.config.js에서 

const nextTranslate = require('next-translate-plugin')
const nextConfig = {
	.
    .
    .
	...nextTranslate(),
}
module.exports = nextConfig

이 설정과 

/locale/에 언어별로 json  파일을 두고,

i18n.json에서는 pages라는 항목에 경로별로 어떤 파일을 적용할껀지 설정해준다. 여기가 바로 핵심적으로 감동했던 부분이었다.

{
	"locales": [
		"ko",
		"en",
        .
        .
        .
	],
	"defaultLocale": "ko",
	"pages": {
		"*": [
			"common",
			"button",
			"navigation",
            .
            .
            .
		]
	}
}
// "*" 은 경로=1.x 버전일때 저렇게 경로를 안 잡으면 components 폴더 안의 파일들이 안되길래 급한 마음에 저렇게 하고 그 후 테스트를 놨었는데 다른 해결책이 있을 수도 있다.
// []에는 /locales/ 하위 언어별 파일명

마지막으로 매 페이지에서는 다음과 같은 방식으로 쓰기만하면 된다.

import useTranslation from 'next-translate/useTranslation'
import getT from 'next-translate/getT'

// Front
function Page () {
	const { t } = useTranslation('') // '' 안에 locale의 특정 파일명을 넣을 수도 있지만 비워두면 다 불러옴
	return 
    	<>
            <button>{t('button:back')}</button>
            <button>{t('button:confirm.ok')}</button>
            <button>{t('button:confirm.cancel')}</button>
    	</>
}
// button => 파일명
// back, confirm.ok, confirm.cancel => json key

export default Page

// Server
export async function getServerSideProps(context) {
     const { locale } = context
    const t = await getT(locale, '') // '' 안에 특정 파일명 가능
    
    console.log(t('button:back');
    
    return {
        props: {}
    }
}

1.xx버전때 설정한 이후로 쭉 쓰고 있는데 초기 설정 당시 useTranslation를 매 페이지에서 하니 부하가 있는 듯해 react의 context에서 한번 한 후 꺼내 쓰는 방식으로 해서 속도를 드라마틱하게 개선했었는데 그 후 관련 테스트를 안해서 요즘은 모르겠지만 아무튼 사용성이 편해서 잘 쓰고 있다.

 

결론: next-translate 쓰세요!

인증이 된 사용자만 파일 다운로드가 가능해야하는 상황이라서 일반적인 a href를 쓸 수 없었다. 이런 경우 이런 식으로 파일 다운로드를 진행해야하는 것 같았다. 

간단해보이는 소스지만, 이 조합을 찾아내기까지 하루이상의 시간이 소요되었기 때문에 다음에는 헤매지 않기 위해 기록해둔다.

 

1. Express

import { join } from 'path';

...

router.get('/subpath/:id', async (req, res, next) => {
    const id = req.params.id;

    const filePath = join(__dirname, `서버에 저장된하위경로`);
    const filename = '파일명.확장자';
    
    // 1안
    res.setHeader('Content-Disposition', `attachment; filename=다른파일명가능`); // 필수
    res.sendFile(filePath + filename);

    // 2안
    res.download(filePath + filename); // 서버에 저장된 파일명으로만 가능

    // 3안
    res.setHeader('Content-Disposition', `attachment; filename=다른파일명가능`); // 필수
    const stream = fs.createReadStream(filePath + filename);
    stream.pipe(res);
});

출처: 불특정 다수

 

2. Front

const authHeader = 'token값'
const url = `/api/subpath/${id}`
let filename = '' // 서버에서 보내는 파일명이 담길 변수

fetch(url, { headers: { 'X-Auth-Token': authHeader } })
    .then((response) => {
        if (!response.ok) {
            throw Error(response.statusText)
        }

        const header = response.headers.get('Content-Disposition')
        const parts = header.split(';')
        filename = parts[1].split('=')[1].replaceAll('"', '')

        return response.blob()
    })
    .then((blob) => {
        if (blob != null) {
            var url = window.URL.createObjectURL(blob)
            var a = document.createElement('a')
            a.href = url
            a.download = filename
            document.body.appendChild(a)
            a.click()
            a.remove()
        }
    })
    .catch((err) => {
        console.log(err)
    })

출처: https://christosmonogios.com/2022/05/10/Use-the-Fetch-API-to-download-files-with-their-original-filename-and-the-proper-way-to-catch-server-errors/

 

front의 경우 이거 말고도 new ReadableStream에 new Response 동원해서 하는 복잡한 소스도 있었는데 위가 더 심플하고 속도도 체감될 정도로 빨라서 이 걸로 낙찰했다.

 

'Javascript' 카테고리의 다른 글

정렬 sort  (0) 2021.04.01
jQuery to VanillaJS  (0) 2021.03.16
키보드를 이용한 Input text 포커스 이동  (0) 2021.03.15
String to Date / Date to String  (0) 2020.12.17
숫자에 콤마와 언콤마를 편하게  (0) 2016.06.27

trigger를 통해서 값이 insert되면 외부 프로그램, 혹은 API를 호출하고 싶었다.

그때마다 검색되는 UDF인 sys_exec() 는 my_global.h가 없어 컴파일이 안되어 사용할 수 없는 상황이었다.

(MySQL 5에서는 있었지만 8 혹은 마이너 버전부터 제외한다는 글을 본 것 같다.)

mysql 내부에서 해결하는 것은 포기하고 돌도 돌다 node.js에 binlog를 이용하여 테이블을 감시하는 것을 찾았다.

https://www.npmjs.com/package/@vlasky/mysql-live-select

원래는 목적으로는 특정 레코드의 컬럼까지 감시가 가능한 것 같지만, 나는 테이블만 감시하는 목적으로 사용하려고 했고, 제공된 예제를 아래와 같이 수정하니 목적을 달성할 수 있게 되었다.

const LiveMysql = require('@vlasky/mysql-live-select');
const LiveMysqlKeySelector = require('@vlasky/mysql-live-select/lib/LiveMysqlKeySelector');

const settings = {
  host: '호스트주소',
  user: '아이디',
  password: '비밀번호',
  database: 'DB명'
};

const liveConnection = new LiveMysql(settings);

liveConnection
  .select('select version()', [], LiveMysqlKeySelector.Index(), [
    {
      table: '테이블명',
      condition: function (row, newRow, rowDeleted) {
        if (row && newRow === null) {
          if (rowDeleted === false) {
            // insert 상황            
          } else {
            // delete 상황
          }
        } else {
          // update 상황
        }
        return true;
      }
    }
  ]);

설치 innologica.github.io/vue2-daterange-picker/#installation :무사히 완료

npm i vue2-daterange-picker --save
or
yarn add vue2-daterange-picker

 

가장 기본 용법인 이건 된다.

// 컴포넌트 등록
import DateRangePicker from 'vue2-daterange-picker'
import 'vue2-daterange-picker/dist/vue2-daterange-picker.css'

Vue.component('DateRangePicker', DateRangePicker)


// 실사용
<template>
  <date-range-picker
    v-model="daterange" 
    :locale-data="{
      direction: 'ltr',
      firstDay: 0,
      format: 'yyyy-mm-dd',
      separator: ' - ',
      applyLabel: '확인',
      cancelLabel: '취소',
      weekLabel: '주',
      customRangeLabel: '기간',

      daysOfWeek: ['일', '월', '화', '수', '목', '금', '토'],
      monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
     }"
  />
</template>
<script>
import DateRangePicker from 'vue2-daterange-picker'
</script>

근데, 나는 props의 디폴트 값이 마음에 들지 않았다.

그리고 혼자 오해를 했다. 용법 innologica.github.io/vue2-daterange-picker/#usage : Register the component 이걸 하면 default props를 overriding 해줄거라고

내 잘못이 맞긴 한데 내가 파악한 구조상으로는 default props를 설정하려면 DateRangePicker.vue를 새로 만들던가 해야 할 것 같다. 기존 거 고쳐봤자 업데이트하면 날라갈 것 같으니 말이다.

그리고 매뉴얼이 진짜 불친절하다 props이름이랑 밑에 설명이랑 매칭이 바로 바로 안되서 일일히 _넣어서 확인해봐야했다.

시간도 버렸고 안 쓰기로 했지만 또 언젠가 쓰게 될지도 모르니까 오늘 정리한 것은 기록으로 남겨두겠다.

 

node_modules\vue2-daterange-picker\dist\vue2-daterange-picker.css에서 나는 문제가 있어서 수정한 부분은 아래와 같다.

// original
.daterangepicker.openscenter[data-v-7d8c7845]{
    ...
    left:50%;
    ...
}

// modify
.daterangepicker.openscenter[data-v-7d8c7845]{
    ...
    left:100%;
    ...
}

 

매뉴얼 중 props 관련하여 이름순 말고 관련있는 설정끼리 묶어봤다.

/** 날짜 설정과 관련된 옵션 **/
:minDate [String, Date], // 최소
:maxDate [String, Date], // 최대
:localeData Object,  // 로케일 데이터 포함한 객체
:dateRange [Object], // v-model prop에서 사용함 startDate와 endDate 를 포함하는 객체여야며 Date로 파싱할 수 있는 문자열이어야 함
:dateFormat Function, 
// function(classes, date) - 2 개의 매개 변수를받는 특수한 prop 유형 함수:
// "classes" - 컴포넌트의 로직이 정의한 클래스,
// "date" - 현재 처리 된 날짜.
// 렌더링 된 날짜에 적용 할 Vue 클래스 객체를 반환해야 함.


/** input 상태 관련 **/
:disabled Boolean, // 비활성화 상태
:readonly Boolean, // readonly 여부


/** input class명 관련 **/
:controlContainerClass [Object, String], // class명

/** 달력 모양 관련 **/
:ranges [Object, Boolean], // 오늘, 어제, 이번달, 올해, 지날달 범위 선택으로 뜨는것. 숨기려면 false로 설정할 수 있음
:opens String, // :ranges 목록의 위치를 지정하는 것임 "center", "left", "right", "inline"라는데 제대로 동작 안한다

:showWeekNumbers Boolean, // 주수표시
:showDropdowns Boolean, // 달력 위에 월 및 연도 선택에 대한 드롭 다운을 표시
:alwaysShowCalendars Boolean, // false로 설정하고 미리 정의 된 범위 중 하나를 선택하면 달력이 숨겨진다고 함
:singleDatePicker [Boolean, String], // 단일 캘린더만 표시

:timePicker Boolean, // 달력 아래에 시간 (시간 / 분) 선택에 대한 드롭 다운을 표시
:timePickerIncrement Number, // 분 드롭 다운에서 분 단위
:timePicker24Hour Boolean, // 24시간제 여부
:timePickerSeconds Boolean, // 시/분을 제외한 초를 선택

:appendToBody Boolean, // 드롭 다운 요소를 본문 끝에 추가하고 동적으로 크기 / 위치를 지정할지 여부
:calculatePosition Function, // appendToBody true일 경우 설정함


/** 달력 이벤트 관련 **/
:linkedCalendars Boolean, // 달력선택 연동
:autoApply Boolean, // 선택한 범위를 자동으로 적용
:closeOnEsc Boolean, // esc키로 드롭다운을 닫을 지 여부

여기까지만 봐서 정리도 여기까지만 한다.

 

// 숫자 배열

const arr = [5, 2, 6, 11, 8, 20];

// 오름차순
arr.sort(function(a, b) {
    return a - b;
});

// 내림차순
arr.sort(function(a, b) {
    return b - a;
});

 

// 문자 배열

const arr = ['cherry', 'peach', 'strawberry', 'apple'];

// 오름차순
arr.sort();

// 내림차순
arr.sort(function(a, b) {
	return (a > b)? -1 : ((a < b)? 1 : 0);
});

 

// 객체 배열

const arr = [
  { key: 10, value: '휴대폰' },
  { key: 5, value: '이어폰' },
  { key: 21, value: '가방' },
  { key: 8, value: '보조배터리' },
  { key: 16, value: '전화기' },
];

// 객체 내 숫자 기준
arr.sort(function(a, b) {
    return a.key - b.key;
});

// 객체 내 문자열 기준
arr.sort(function(a, b) {
	return (a.value > b.value)? -1 : ((a.value < b.value)? 1 : 0);
});

 

+ Recent posts