배경

예를 들어,

[GET] /some/list

라는 API를 프론트 단에서 보낼 때, 검색, 페이지네이션, 필터같이 여러 기능이 필요할 때가 있을 것이다.

그럼 우리는 아래와 같이 파라미터를 보내서 해결한다.

[GET] /some/list?q=a&page=0 ...

오늘의 주인공은 바로 저 뒤 파라미터 녀석.

 

간혹 query parameter에 + 나 띄어쓰기를 넣으면, 다음과 같이 쿼리가 보내질 때가 있다.

만약 'eng+lish' 를 보냈다고 해보자.

?q=eng%2Blish

+가 %2B 와 같이 변했다.

이렇게 영어를 제외한 특수문자를 안전하게 전송하기 위한 방법을 인코딩이라고 한다.

인코딩

이는 웹에서 데이터를 안전하게 전송하기 위함이다. 예를 들어

الصباح رباح 라는 아랍어를 웹에서 전송하기 위해서는 ا  ل ص ب ا ح   ر ب ا ح 문자 각각을 보내는 것 보다, %D8%A7%D9%84%D8%B5%D8%A8%D8%A7%D8%AD%20%D8%B1%D8%A8%D8%A7%D8%AD 와 같이 정해진 규칙에 맞춰 인코딩해 보내는 것이 더 안전하다.

그리고 ?나 &같은 특수 기호들이, 기호로써 일하는지 아니면 사용자의 입력값에 있는 것인지 구분을 해야한다.

이를 위해 영어를 제외한 특수문자를 인코딩하는 규칙이 있고, 이 규칙에는 여러 종류가 있다.

오늘은 javascript 함수를 기준으로 보자.

 

encodeURI vs encodeURIComponent

멀리 갈 것도 없다. 당장 크롬 개발자 도구를 켜서 확인할 수 있다.

둘다 "한글" 이라는 글자를 encode했을 때, 똑같은 결과가 나오는 것을 알 수 있다.

그럼 둘의 차이는 무엇일까?

바로 띄어쓰기(space)와 +를 대하는 태도이다.

정확히는, 예약 문자를 다루는 태도가 다르다.

mdn(https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/encodeURI)에 따르면

 

encodeURI()는 완전한 URI를 형성하는데 필요한 문자는 인코딩 하지 않습니다. 또한, 예약된 목적을 가지지는 않지만 URI가 그대로 포함할 수 있는 몇 가지 문자("비예약 표식")도 인코딩 하지 않습니다.

var set1 = ";,/?:@&=+$#"; // 예약 문자
var set2 = "-_.!~*'()"; // 비예약 표식
var set3 = "ABC abc 123"; // 알파벳 및 숫자, 공백

console.log(encodeURI(set1)); // ;,/?:@&=+$#
console.log(encodeURI(set2)); // -_.!~*'()
console.log(encodeURI(set3)); // ABC%20abc%20123 (공백은 %20으로 인코딩)

console.log(encodeURIComponent(set1)); // %3B%2C%2F%3F%3A%40%26%3D%2B%24%23
console.log(encodeURIComponent(set2)); // -_.!~*'()
console.log(encodeURIComponent(set3)); // ABC%20abc%20123 (공백은 %20으로 인코딩)

 

URI의 Generic Syntax를 구성하기 위한 요소 말고는 변형하지 않는 친구이다.

아, API를 보낼 때는 encodeURIComponent를 써야 제대로 파라미터가 가겠구나!

라는 생각을 할 수 있겠다.

그러던 중 사소한 디테일도 발견했다.

 

사소한 디테일

우리의 코드가 %25ED%2595%259C%252B%25EA%25B8%2580 같은 식으로 파라미터를 보내고 있었다.

이렇게 생긴 애들은 대부분 encode를 두 번 거친 친구들이다. 어딘가 단단히 꼬인 경우인데...

오늘 있었던 일은 이랬다.

한+글 을 보내면

위 사진은 이해를 돕기 위해 제작했다.

어찌된 영문인지 자꾸 %25ED%2595%259C%2520%25EA%25B8%2580 로 보내지는 것이다.

이 곳에는 두 개의 문제가 있었다.

1. 왜 두 번 인코딩 되는가?

2. 왜 +가 띄어쓰기로 인식되는가?

1번 두 번 인코딩 문제는 encode 함수를 전부 검색해서, axios에서도 한 번, 보내줄 때도 한 번 하는 부분을 찾아 금방 해결했지만

2번 문제는 쉽사리 해결되지 않았었는데...

한참을 찾던 중 발견했다.

 

navigate를 확인하자

그렇다. 종종 검색 내역이나 페이지수를 저장하려고 사용자의 URI에 keyword 정보를 넘기는 경우가 꽤 많은데,

구글도 이렇게 남긴다.

이렇게 경우를 넘길 때 encode를 안 해주고 있었던 것!!

window.location.search 를 직접 교체해서 쓰고 있었다.

여러분은 부디 react에서 몹시 잘 제공해주는 useSearchParams hook을 예쁘게 쓰시길 바란다.

 

++ 2025.04.01 추가

놀랍게도, setSearchParams를 써도 encodeURI를 해버린다.

조금 원시적인 방법이지만 어쩔 수 없이 아래처럼 해야겠다.

navigate(pathname + encodeURIComponent(_query), { replace: true });

 

토큰, 꼭 어떤 데이터를 담아야 할까?

이 글의 시작은 회원가입 관련 이메일 인증 과정을 진행하던 도중

A: "토큰 자체가 어떤 의미를 담고 있으니 ... "

B: "그냥 랜덤 생성한 값도 토큰 아니냐"

에 대한 논쟁에서 시작되었다.

 

오늘의 결론을 미리 스포일러하자면, 랜덤 생성해서 의미를 담지 않은 값도 토큰이다.

그러나 이 결론을 찾기 위해 분석한 과정이 꽤 도움이 되었기 때문에 분석 내용을 공유하고자 한다.

토큰이란?

토큰(Token). 많이 쓰지만 막상 그 단어가 무슨 뜻이냐? 라고 하면 혼란스러울 사람들이 있을 것이다. 오늘은 이에 대해 한 번 살펴보고자 한다.

 

위키피디아(https://en.wikipedia.org/wiki/Token) 에서는 다음과 같이 설명하고 있다.

Token, an object (in software or in hardware) which represents the right to perform some operation

어떤 작업을 수행할 수 있는 권한을 나타내는 객체

 

자주 들어본 용어인 Session, Security, Access Token이 있고 

그 외에도 Tokenization, Invitaion token, Token Ring, in Petri net 등의 표현으로 사용되는 듯 하다.

경제, 언어, 암호화폐 등에서도 사용되고 있다.

즉, 토큰이라는 단어의 쓰임새는 굉장히 넓기 때문에, 우리가 어떤 의미로 쓰는가가 중요하다고 볼 수 있겠다.

 

그렇다면 웹 인증 상황에서 토큰은 어떤 의미로 쓰일까?

웹 통신의 특징

웹 통신에서 가장 많이 사용하는 HTTP는 stateless한 특성이 있어 서버가 클라이언트의 이전 상태를 보존하지 않는다.

이 특성으로 인해 우리는 이전 상태를 보존하기 위해 많은 방법을 고민해왔고, 그 중 하나가 토큰 기반 인증 방식이다.

토큰 기반 인증 방식은 다른 방법 중 하나인 Session 기반 인증 방식의 단점을 보완하기 위해 나타났다.

 

Session 기반 인증 방식을 간단히 설명하자면, 각 유저의 세션 정보를 전부 서버의 메모리에 저장해서 관리하는 방식이다.

서버에서 계속 세션 정보를 유지해야 해서, DB 과부하 등의 문제가 있다.

 

토큰 기반 인증

토큰 기반 인증(Token-based authentication)은 두 개의 종류로 나눌 수 있다.

1. 일반 토큰 기반 인증

의미 없는 문자열로 구성되어 있다. 주로 DB에 저장해 일치하는지 아닌지를 통해 검사한다.

-> 의미가 없기 때문에 ID 정보, 만료 기간 등 인증에 필요한 정보를 담을 수 없다.

-> DB에 저장하기 때문에 검사하거나 처리할 때 DB I/O가 생겨 부담이 생긴다.

 

단, 무조건 나쁜 것은 아니고 단순한 프로젝트나 빠르게 개발할 경우에는 해당 방법이 효율적일 때도 있다.

2. 클레임(Claim) 토큰 기반 인증

토큰 자체에 의미를 담은 토큰(클레임이란 정보의 조각을 뜻한다.)이다. 이를 암호화/해석하는 알고리즘에는 여러 종류가 있으며, 가장 대표적으로 JWT가 있고, 이를 활용한 예제로 OAuth가 있다. 

 

활용 예시

그렇다면 각 인증을 어떤 경우에 사용하는 것이 효율적일까?

GPT에 의하면 다음 이메일 인증 / 비밀번호 재설정의 예시에서는 랜덤 토큰을 쓰는 것이 효율적이라고 하는데,
여러 사람들의 의견도 궁금하다.

✅ 어떤 방식이 좋을까?

기준 랜덤 토큰 (Stateful) JWT (Stateless)
🔐 보안성 ✅ 높음 (유효성 DB로 체크) ⚠️ 중복 클릭 막기 어려움
🚀 속도 ✅ 빠름 ✅ 빠름
🔁 재사용 방지 ✅ 토큰 삭제 가능 ❌ 서버가 통제 불가능
🧠 관리 ✅ 단순 복잡해질 수 있음

👉 이메일 인증 / 비밀번호 재설정은 거의 항상 랜덤 토큰 + DB 저장 방식 사용!

 

(보안성에 왜 중복 클릭 막기 어려움이 들어가 있는지는 의문이지만, GPT는 늘 이런식이니 넘어가자.)

기타

추가로 이런 저런 질문이 떠올라서, 간단하게 정리해봤다.

이메일 인증 / 비밀번호 재설정 상황에서 물어본 내용이다.

 

질문 답변
랜덤 토큰이면 충분한가요? ✅ 네, 32 bytes 정도면 안전하고 널리 사용됨
사용자 정보가 토큰에 포함돼야 하나요? ❌ 보통 필요 없음 (DB에서 토큰으로 사용자 찾으면 됨)
JWT를 써도 되나요? 🤔 가능은 하지만, 대부분의 인증 링크에는 부적합 (무효화 어려움)

 

추가로, 랜덤 토큰을 User table에 저장하는 것이 맞는지에 대한 질문도 해봤다.

토큰 관리 전략에는 사이즈 검토도 중요해 보인다.

상황 User 테이블에 넣기  별도 테이블로 분리
단순한 사이드 프로젝트 ✅ 괜찮음 ❌ 굳이 분리 X
보안이 중요한 상용 서비스 ⚠️ 비추천 ✅ 추천
토큰 종류가 많거나 로그 남겨야 하는 경우 ❌ 관리 어려움 ✅ 필수

 

결론

1. 의미를 담지 않은 값도 토큰이다.

2. 어떤 토큰 인증 방식을 사용할지는 프로젝트 규모나 토큰 활용 정도에 따라 구분하자.

버전 업그레이드: 9.14.2 -> 9.15.0

Error accessing Google Sheets API: Error: error:1E08010C:DECODER routines::unsupported

 

기존에 잘 쓰던 방식이 지난주쯤을 기점으로 작동하지 않았다.

지금으로부터 5주 전인 11월 22일에, google-auth-library의 최신 버전인 9.15.0 버전으로 업그레이드했는데 (11월 1일에 릴리즈되었다: https://github.com/googleapis/google-auth-library-nodejs/releases/tag/v9.15.0)

보니까 인증 방식 관련 정보가 바뀐 것 같아서, 다시 재정비를 해주었다.

기존 코드에서 인증 부분만 변경했다.

 

기존 코드

const { google } = require('googleapis');
require('dotenv').config();

// 인증 정보
const client = new google.auth.JWT(
    process.env.GOOGLE_CLIENT_EMAIL,
    null,
    process.env.GOOGLE_PRIVATE_KEY,
    ['https://www.googleapis.com/auth/spreadsheets'] // Google Sheets API 권한
);

 

바꾼 코드

const docs = require('@googleapis/docs');

const auth = new docs.auth.GoogleAuth({
  keyFile: './src/locale/authFile.json',
  scopes: ['https://www.googleapis.com/auth/spreadsheets'], // Google Sheets API 권한
});

 const googleSheets = google.sheets({ version: 'v4', auth: auth });

 

기존 코드에서 googleapis 라이브러리를 쓰던 것을 @googleapis/docs로 변경했다. (인증 서브모듈만 필요해서)

JWT는 여기저기서 찾은 정보를 억지로 끼워맞춰서 돌아가고 있었는데,

문서에서 앞부분에 확실히 있는 GoogleAuth를 사용했다.

기존처럼 JWT를 사용하고 싶다면 공식문서(https://www.npmjs.com/package/google-auth-library)의 JSON Web Tokens 부분을 참고하자.

전반적으로 roro패턴으로 바뀐 듯 하다.

npx create-next-app@latest

  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "next": "15.1.2"
  },

 

위와 같이 처음 next app을 만들고, yarn dev 를 하면

 

우측 하단에 이런 에러가 뜨는 경우가 있다.

정말 아무 것도 건드리지 않았는데 뜨는 경우인데... 에러 로그를 자세히 보면,

(개인적으로 제일 해결하기 어려운) Hydration error 가 발생했다고 뜬다.

하지만 맨 처음 만들자마자 생긴 에러라서, 뭔가 버전에 에러가 있나 싶었더니, 에러 맨 아랫줄의 "브라우저 확장 프로그램"이 문제였다.

 

혹시 아래 확장프로그램 중 하나가 있다면 삭제해보자.

깔끔하게 사라진다.

필자는 ColorZilla였다.

 

1 - ColorZilla

2 - Wappalyzer

3 - Urban VPN

4 - LastPass

5 - Hacker Vision

6 - WhatFont

7 - Video Speed Controller for HTML videos

8 - Glot

9 - AI Grammar Checker & Paraphraser – LanguageTool

10 - Grammarly

11 - Invert

12 - Dashlane

 

 

출처

https://www.reddit.com/r/nextjs/comments/1gabiqn/hydration_error_when_installing_nextjs_15/?rdt=49864

 

From the nextjs community on Reddit

Explore this post and more from the nextjs community

www.reddit.com

 

login을 위해서, 같은 form 안에 아래와 같이 input을 구성했다. 

 

return (
	<form onSubmit={_onSubmit}>
    		<input name='id' ... />
        	<input name='password' type='password' ... />
	</form>
)

 

id input에서 tab을 통해 이동하면, 찰나에 OS ime가 잠깐 한글로 바뀌었다가 영어로 바뀌는 케이스가 있다.(테스트 환경: macOS)

이걸 위해서는 정말 간단하게 하나 추가해주면 된다.

그것은 바로 ...

inputMode='text'

return (
	<form onSubmit={_onSubmit}>
    		<input name='id' ... />
        	<input name='password' type='password' inputMode='text' ... />
	</form>
)

 

inputmode="text"의 동작 원리

  • 목적: 브라우저나 운영체제에 "텍스트 입력이 필요하다"는 메세지를 전달하기 위함
  • IME(입력기) 상태: inputmode="text"를 사용하더라도 IME는 기존 상태를 유지합니다.
    • 만약 한글 IME가 활성화된 상태라면 한글이 입력됩니다.
    • 영어 상태라면 영어가 입력됩니다.

inputmode 값에 따른 동작 예시

inputmode 값 키보드 동작 (모바일 기준) IME 영향
none 키보드가 아예 나타나지 않음 IME 영향 없음
text 일반 텍스트 키보드 (기본값) IME 상태 유지
numeric 숫자 전용 키패드 IME 비활성화
tel 전화번호 키패드 (숫자 및 기호 제공) IME 비활성화
email 이메일 입력에 최적화된 키보드 IME 상태 유지
url URL 입력에 최적화된 키보드 IME 상태 유지

 

 

 

좀 더 자세한 이유는 아래로 ...

- https://run-a-way.tistory.com/70

 

[Unity] password input field 에 대한 고찰 (IME 사용법)

log in UI 부분은 개발하고 손뗀지 오래되었는데, QA 에서 다시한번 검토해 달라는 요청이 왔다. id 쪽은 문제 없는데... password가 문제. inputFiled 세팅을 잘못해 주었는지 한글이 마구 들어가는 것이

run-a-way.tistory.com

 

계기

애써서 만든 i18n json 파일을 좀더 효율적으로 쓰고 싶었다.

원래도 번역자(스프레드시트) <-> 개발자(json)으로 소통하고 있었는데,

번역자가 여러명이라 싱크도 맞추기 힘들고, 개발자 입장에서는 코드에 한번 시트에 한번 계속 입력해주는게 귀찮았기 때문에

한 줄로 해결할 수 있는 방법을 찾고 싶었다.

그래서 Google spreadsheet API를 활용해서 json을 -> 엑셀로, 엑셀을 -> json으로 옮기는 툴을 만들었다,

i18n-scanner 라는 라이브러리도 있었는데 그냥 json만 불러오는게 빠를 것 같아서 안 썼다.

 

준비물

구글 서비스 계정 생성

https://console.cloud.google.com/apis/credentials 에 가서

1. 프로젝트 생성하고 서비스 계정을 생성하고 키에 가서 JSON 형태의 비공개 키를 만들어 다운받는다.

2. API 및 서비스 > 라이브러리에 가서 Google Sheets API를 사용한다.

3. 제어하고 싶은 구글 스프레드시트 파일에서 json 파일에 있는 client_email 주소를 공유한다.

위 과정의 결과물로 JSON 파일, 스프레드시트 Document ID, sheet ID를 얻었으면 끝.

 

패키지 설치

React & typescript 환경에서 코드 내부에서 버튼 하나 누르면 동기화되는 그런 시스템을 생각했는데

안타깝게도 그렇게 구축하는 것과 그냥 node XXX.js 를 해버리는 것의 큰 차이가 없었다. (이유는 후술)

따라서 react 프로젝트 안에서 돌아가는 js를 만들었다. 프로젝트는 그냥 package.json을 위해 희생당했다고 보면 된다.

이런저런 많은 패키지를 테스트해보긴 했는데 결국 필요한건 하나였다.

yarn add -D googleapis

https://www.npmjs.com/package/googleapis 하나만 설치해주자.

 

연동 테스트

const { google } = require('googleapis');
require('dotenv').config();

// 인증 정보
const client = new google.auth.JWT(
    process.env.GOOGLE_CLIENT_EMAIL,
    null,
    process.env.GOOGLE_PRIVATE_KEY,
    ['https://www.googleapis.com/auth/spreadsheets'] // Google Sheets API 권한
);

// Google Sheets API 호출 함수
async function getSheetData() {
  try {
    const googleSheets = google.sheets({ version: 'v4', auth: client });

    // 스프레드시트에서 데이터를 읽어옴
    const response = await googleSheets.spreadsheets.values.batchGet({
      spreadsheetId: DOCID, // 스프레드시트 ID
      ranges: sheets, // 읽고자 하는 데이터 범위
    });

    const sheetData = response.data.valueRanges.map((valueRange, index) => {
      return {
        sheet: sheets[index],
        data: valueRange.values,
      };
    });

    return sheetData;
  } catch (error) {
    console.error('Error accessing Google Sheets API:', error);
  }
}

// 함수 실행
getSheetData().then((data)=>{
	console.log(data);
});

 

문제의 new google.auth.JWT가, 브라우저에서는 동작을 안 하는 바람에 반나절정도 삽질했다. 보안 상의 이유로 서버에서만 동작한다고 한다. 자료도 없고, 블로그들 코드가 죄다 안되어서 고생을 좀 했다. 오늘 기준으로 잘 동작된다.

 

파일 경로+이름이 {path}/a.js 라면, node {path}/a.js 하면 된다. 참 쉽죠?

 

이 코드만 적용 성공했다면, 이제 읽어오는 것은 두려움 없이 할 수 있다.

맨 아래 실행 함수의 .then 부분에 파일을 저장하는 로직을 넣으면 된다. 이건 찾아보면 많이 나오니까 직접 해보자. 쉽다. (fs.writeFileSync 를 썼다)

 

process.env의 두 값은 JSON에서 CLIENT_EMAIL과 PRIVATE_KEY env에 적용한 값이다.

env를 잘 쓰려면 require('dotenv').config(); 를 꼭 import 해줘야 한다. (.env 파일을 만드는 것도 포함하여)

 

참고로 batchGet은 여러 시트를 불러올 때 사용하는 것으로, 그냥 get으로 사용할 수도 있다. update도 마찬가지.

테스트를 통해 값 읽어오기는 했으니 이제 시트에 업데이트도 해보자.

 

시트에 업데이트 하기

async function updateGoogleSheet() {

  try {
    const googleSheets = google.sheets({ version: 'v4', auth: client });

    // 스프레드시트에 데이터를 추가
    const response = await googleSheets.spreadsheets.values.batchUpdate({
      spreadsheetId: DOCID, // 스프레드시트 ID
      valueInputOption: 'RAW', // 데이터를 입력할 방식 RAW || USER_ENTERED
      resource: {
        data: [
          {
            majorDimension: 'ROWS', // 행 단위로 데이터 처리
            range: 'test!A1', // 데이터를 추가할 위치
            values: **YOUR DATA**,
          },
          ...
        ],
      },
    });

    console.log(`${response.data} cells updated.`);
  } catch (error) {
    console.error('Error updating Google Sheets API:', error);
  }
}

// 함수 실행
updateGoogleSheet();

 

여기는 사실상 batchUpdate 함수가 어떻게 쓰이는지만 알면 금방 할 수 있을 것이라고 생각한다.

  • valueInputOption은 입력된 데이터를 그대로 쓸지, 엑셀에 사용자가 입력할 때처럼 자동변환 해줄지 하는 옵션이다.
  • majorDimension은 데이터 처리 방식인데, ROWS냐 COLUMNS냐를 고를 수 있다. 2차원 배열 읽는 순서다.
    • [[1, 2, 3], [4, 5, 6]] 를
    • ROWS 로 넣으면 1 2 3 / 4 5 6이 되고
    • COLUMNS 로 넣으면 1 4 / 2 5 / 3 6이 된다.
  • range는 {시트이름}!{범위} 식의 포맷으로 엑셀 범위 지정할때랑 똑같이 쓰면 된다.
  • values에는 위에서 예시를 들었던 [[1, 2, 3], [4, 5, 6]] 처럼 2차원 배열을 넣으면 된다.

마찬가지로 여기서는 readdirSync 를 통해서 json(locale)파일을 읽어왔다. (코드에서는 생략했다.)

 

 

후기

자료가 정말정말 없었다. 왜 다른 사람들은 다 잘 되었을까?

지금 생각해보면... 말도 안되는 짓(주로 JWT)을 하고 있었기 때문이 아닐까...

내가 만드는 코드가, 어디서 돌아가고 있는지

어느 프레임워크를 쓰고 있는지, 어떤 방식으로 빌드되는지 아는 것은 중요한 것이라고 생각한다.

환경 변수에 대해서도 조금 더 알게 되었다.

누군가가 이 글을 보고 고통에서 해방되길...

 

 

참고한 블로그

https://ui.toast.com/weekly-pick/ko_20210303

https://soojae.tistory.com/67

https://uni-s-code.tistory.com/42

https://www.npmjs.com/package/google-auth-library

https://www.npmjs.com/package/google-spreadsheet

 

0. 계기

기존 프로젝트에서 다중 언어(3개 이상)를 제공하고 있었는데,
단순히 json 만 parsing하는 방식을 사용해서 언어 관리가 쉽지 않았다
(미번역 단어를 찾기나, 언어별로 missing key를 찾는 등의 일이 어려웠다.)
이에 i18n Ally와 같은 extension을 사용해 언어를 관리하고자 멀고도 험난한 여정을 거친 적용기다.

교훈: 처음 전략을 잘 세우자 (프로젝트 덩치가 조금 커서 거의 2주일을 소모했다.)

 

기존 json 정리 -> i18n 도입(초기 설정 -> i18n Ally -> 파일에 적용)의 순서를 거쳤다.

1. 기존 json 정리 (일주일+ 소모)

  • 기존 json이 약 7개 이상의 파일로 구성되어있었기에 이를 하나로 합체하였다.
  • 화이트라벨링 관련 표현도 따로 파일이 존재했는데, 이것은 i18n의 별도 네임스페이스로 두기 위해 남겼다.
  • 중복 표현&key를 제거하며 일부 정리를 거쳤다.
  • 다른 검토가 필요한 표현은 타 부서와의 협의를 위해 우선 두었다.

2. i18n 도입 (일주일 소모)

  • 초기 설정
    • 패키지 설치
    • 루트 또는 /src에 i18n.ts 파일을 만든다.
yarn add i18next i18next i18next-browser-languagedetector i18next-http-backend
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';

// i18n 초기 설정
i18n
  .use(Backend) // 서버에서 언어 파일을 가져오는 기능
  .use(LanguageDetector) // 사용자의 브라우저 언어를 감지하는 기능
  .use(initReactI18next) // react-i18next 연결
  .init({
    fallbackLng: 'en', // 기본 언어 설정
    returnEmptyString: false, // json에서 value가 "" 형태인 항목도 fallback 처리하도록 하는 플래그
    debug: true, // 개발 중 디버그 모드 활성화
    interpolation: {
      escapeValue: false, // 리액트는 기본적으로 XSS 공격을 방지하므로 별도 이스케이프가 필요 없음
    },
    ns: [**Your Namespaces**],
    defaultNS: '**Your Default Namespace**',
    backend: {
      loadPath: '**Your Path of Locale Files**/{{lng}}/{{ns}}.json', // 언어 리소스 파일 경로
    },
  });

export default i18n;

 

  • i18n Ally 설정
    • extension을 위해, `.vscode/settings.json` 에 아래와 같이 설정해준다.
"i18n-ally.localesPaths": ["Your Path of Locale Files"],
"i18n-ally.enabledFrameworks": ["react-i18next"],
"i18n-ally.keystyle": "nested"
// if using namespace...
"i18n-ally.defaultNamespace": "Your Default Namespace",
"i18n-ally.namespace": true,

 

  • 실제 사용: 함수형 컴포넌트
    • useTranslation hook을 사용하여 함수형 컴포넌트 내부에서 아래와 같이 사용할 수 있다.
import { useTranslation } from 'react-i18next';

...
// default namespace만 사용할 경우
const { t, i18n } = useTranslation();
// namespace를 더 사용할 경우
const { t, i18n } = useTranslation([a, b, ...]);

console.log(t('test')) // default namespace는 생략 가능
console.log(t('a:test'))
console.log(i18n.language) // 현재 사용중인 언어
...

 

  • 실제 사용: 파일
    • 아래처럼 그냥 import해서 쓸 수도 있다. (권장하진 않는다.)
import { t } from 'i18next';
import i18n from 'src/i18n';
import type { WithTranslation } from 'react-i18next';
import { withTranslation } from 'react-i18next';

class Test extends React.PureComponent<PropsType & WithTranslation, StateTypes> {
	constructor(props: PropsType & WithTranslation) {
    	super(props);
    	...

    render() {
        const { t } = this.props;
        return <p>{t('test')}</p>;
	}
 }

export default withTranslation()(Test);

// 뭔가로 이미 감싸고 있었다면
export default connect(...)(withTranslation()(Test));

// forwardRef를 써야한다면
const TestWithRef = React.forwardRef((props: WithTranslation, ref: React.Ref<Test>) => {
  return <Test {...props} ref={ref} />;
});

export default withTranslation()(TestWithRef);

3. 결론

  • 언어 설정은 초반에 잘하자.
  • 화이트라벨링을 더 잘 적용할 수 있는 방법이 있으면 좋겠다.
  • namespace를 좀더 명확하게 가져올 수 있는 방식이 있었으면 좋겠다. : 으로 구분하는 방식이 보기 어려운 것 같다.(어처피 ally 쓸거지만)

 

다음은 이렇게 변환한 i18n json을 가지고 구글 스프레드시트에 연동하는 tool을 만든 후기를 작성하겠다.

 

** 여담

Uncaught Error: A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.  에러가 발생한다면

<Suspense> 로 감싸져있는지 확인하자.

이유는 여기 블로그에 잘 나와있다.

https://velog.io/@leejungho9/Error-A-component-suspended-while-responding-to-synchronous-input.-This-will-cause-the-UI-to-be-replaced-with-a-loading-indicator.-To-fix-updates-that-suspend-should-be-wrapped-with-startTransition

 

[Error] A component suspended while responding to synchronous input.

최근 리액트로 프로젝트를 진행하다 다음과 같은 에러를 마주했다. A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading

velog.io

 

** 여담2

locale 파일을 public에 넣지 않으면 실제 서비스 할 때 정보를 못 불러오는 문제가 있었다.

원인은 아직 파악 못했지만 public에 넣어야 하는 듯 하다.

기본 시스템 구조도

멤버 구조

사용자 로그인 방식은 OAuth로만.

사용자 별로 파티를 갖고 있으며, 파티에는 파티 호스트가 존재한다.

+ 사용자는 뱃지를 가지고 있음

파티 별로 챌린지가 존재하며, 챌린지 내에 사용자 별 세부 목표(타겟)이 존재한다.

파티 내에는 포스팅이 존재하며, 포스팅은 타겟과 챌린지를 갖는다.

코멘트를 통해 포스팅을 인증할 수 있다.

 

 

ERD

badge

user.badge

user

user.party

party

// 사용자 목록
Table user as U {
  id int [pk] //type 결정 필요
  user_id varchar //사용자 아이디
  name varchar //사용자 이름
  status varchar //사용자 상태
  created_at timestamp
  modified_at timestamp
}

// 사용자 - 뱃지 정보
Table user.badge as UB {
  id int [pk] //type 결정 필요
  user_id int //사용자 id
  badge_id int //뱃지 id
  created_at timestamp
  modified_at timestamp
}

// 뱃지 목록
Table badge as B { 
  id int [pk] //type 결정 필요
  name varchar //뱃지 이름
  description varchar //설명
  condition varchar //획득 조건 (form 지정 필요)
  created_at timestamp
  modified_at timestamp
}

// 파티 - 사용자 정보
Table user.party as UP {
  id int [pk] //type 결정 필요
  user_id int //사용자 id
  party_id int //파티 id
  party_alias varchar //파티 내에서 사용할 이름
  is_admin boolean //관리자 여부
  created_at timestamp
  modified_at timestamp
}

// 파티 목록
Table party as P {
  id int [pk] //type 결정 필요
  name varchar //파티 이름
  
  //입장관련
  party_code varchar //파티 코드
  question varchar //질문
  answer varchar //답변
  
  rules varchar //인증 룰 정보
  
  creater varchar //파티 생성자 
  created_at timestamp
  modified_at timestamp
 }

 Ref: UB.user_id > U.id
 Ref: UB.badge_id > B.id
 Ref: UP.user_id > U.id
 Ref: UP.party_id > P.id
 
// ----------------------------------------------
 
// 파티 관련 정보: 파티 단위 챌린지
Table party.challenge as PC {
  id int [pk]
  party_id int //파티 id
  name varchar //챌린지 이름
  description varchar //챌린지 설명

  start_at timestamp //챌린지 시작일
  end_at timestamp //챌린지 종료일
  
  created_at timestamp
  modified_at timestamp
}

 Ref: PC.party_id > P.id
 
// ----------------------------------------------
 
//유저 관련 정보: 개인 목표  
Table user.target as UT {
  id int [pk]
  name varchar //목표 이름
  description varchar //목표 설명
  
  party_id int //파티 id
  owner_id int //목표 소유자 id
  challenge_id int //생성 당시 파티 챌린지 id
  
  created_at timestamp
  modified_at timestamp
 }
  
// 유저 관련 정보: 인증
Table user.certification as UCE {
  id int [pk]
  is_ok boolean //인증 성공 여부
  post_id int //포스트 id
  granter_id int //인증 부여자 id
  
  party_id int //파티 id
  target_id int //개인 목표 id
  owner_id int //개인 목표의 사용자 id
  
  created_at timestamp
  modified_at timestamp
}

// 유저 관련 정보: 유저 포스팅
Table user.post as UPO {
  id int [pk]
  image varchar //이미지는 어떻게 저장하지 ???
  description varchar //포스팅 내용
  
  party_id int //파티 id
  owner_id int //글쓴 사용자 id
  target_id int //포스트가 속한 개인 목표 id
  
  created_at timestamp
  modified_at timestamp
 }
 
 // 유저 관련 정보: 코멘트
Table user.comment as UCO {
  id int [pk]
  title varchar //코멘트 제목 - 필요한지?
  description varchar //코멘트 내용
  
  party_id int //파티 id
  owner_id int //코멘트쓴 사용자 id
  post_id int //포스트 id
  certification_id int //코멘트에서 판단한 인증 id
  
  created_at timestamp
  modified_at timestamp
 }
 
 
 Ref: UT.party_id > P.id
 Ref: UT.owner_id > UP.id
 Ref: UT.challenge_id > PC.id

 Ref: UCE.party_id > P.id
 Ref: UCE.target_id > UT.id
 Ref: UCE.owner_id > UP.id
 
 Ref: UCE.post_id > UPO.id
 Ref: UCE.granter_id > UP.id
 
 Ref: UPO.party_id > P.id
 Ref: UPO.owner_id > UP.id
 Ref: UPO.target_id > UT.id
 
 Ref: UCO.party_id > P.id
 Ref: UCO.owner_id > UP.id
 Ref: UCO.post_id > UPO.id
 Ref: UCO.certification_id > UCE.id

 
//----------------------------------------------//

기본적으로 user Table을 기준으로 설계하였다.

유저 개인이 파티, 챌린지, 타겟 상관 없이 히스토리들을 볼 수 있었으면 좋겠다고 생각해서 되도록 하위 정보(post, comment, certification에 유저 정보들을 넣도록 설계하였다.

+ Recent posts