계기

애써서 만든 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

'[Frontend] > React, TS, JS' 카테고리의 다른 글

[React & Typescript] i18n 적용기  (0) 2024.10.20

 

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에 넣어야 하는 듯 하다.

'[Frontend] > React, TS, JS' 카테고리의 다른 글

[node.js] Google Spreadsheet API 사용기  (3) 2024.10.25

기본 시스템 구조도

멤버 구조

사용자 로그인 방식은 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에 유저 정보들을 넣도록 설계하였다.

https://nykim.work/84

 

반응형 웹 뚝딱 만들기 (1) - 뷰포트 메타태그와 미디어 쿼리

이 글은 공동 기술 블로그(tech.yeon.me)에도 올린 글입니다. (여기에서도 숨겨진 좋은 글을 발견할지도 몰라요!) 프롤로그 모바일 사용자가 점점 늘어나는 요즘 반응형으로 만든 웹 사이트를 쉽게

nykim.work

https://nykim.work/85

 

반응형 웹 뚝딱 만들기 (2) - vw, vh, vmin, vmax, em, rem 속성

프롤로그 지난 글에는 반응형을 위해 필요한 뷰포트 메타태그와 미디어 쿼리에 대해 다뤘었는데, 이번에는 CSS 속성을 통해 좀 더 편하고 쉽게 반응형을 만드는 방법을 알아보려고 합니다 🤟 히

nykim.work

https://velog.io/@cheal3/%EB%B0%98%EC%9D%91%ED%98%95-%EC%9B%B9%EA%B3%BC-%EB%B7%B0%ED%8F%AC%ED%8A%B8

 

반응형 웹과 뷰포트

반응형 웹 나중에는 기술의 발전으로 데스크톱 뿐만 아니라 스마트폰, 태블릿 컴퓨터, 텔레비전 등 대부분의 전자기기에서 웹에 접속 할 수 있게 되었지만, 전자기기들의 화면의 크기가 다른 탓

velog.io

 

날리게 되어 슬픈 마음에 작성중

labelColor = "#1ab394";
labelColor = "#D9556E";
labelColor = "#FF7D95";
labelColor = "#F5B345";
labelColor = "#6B5A3E";
labelColor = "#B3EB44";
labelColor = "#5C6B3F";
labelColor = "#B369F0";
labelColor = "#5C4D69";
labelColor = "#D97C5F";
labelColor = "#B56750";
labelColor = "#5A8AE8";
labelColor = "#5580D9";
labelColor = "#4F45F5";
labelColor = "#0598E6";
labelColor = "#000000";

1. 동적으로 datalist option을 추가하기

HTML

<div>
	<input id="snack" name="snack" list="snackList" type="text"/>
	<datalist id="snackList">
	</datalist>
	<input type="hidden" name="snack" id="snack-hidden">
</div>

JS

var snackData = {};

var snackListOpt = [];


//최초 설정
function addSnackOpt(){
	//response는 받아온 값
  response.forEach(function(res){
  		//data-value가 보낼 값, value가 보여질 값.
      var tmpStr = `<option data-value="${res.money}" value="${res.name}"></option>`
      snackListOpt.push(tmpStr);
  });
  $('#snackList').append(snackListOpt);


  $('#snackList option').each(function(i,el){
      snackData[$(el).data("value")] = $(el).val();
  });
}


//실제 전송할 때
function realValue(){
    var snackMoney = $('#snack').val().trim();
    snackMoney = $('#snackList [value="'+snackMoney+'"]').data('value');
    return snackMoney;
 }

 

삽질이 힘들었다.

 

 

[참고 블로그]

1. https://cbw1030.tistory.com/293

 

[JavaScript] select box에 동적으로 option을 추가해보자

오늘은 팀 프로젝트를 하면서 약간의 삽질을 하면서 알게된 select box에 option을 추가하는 방법을 포스팅하겠습니다. 서울 부산 제주 경기 인천 강원 경상 전라 충청 왼쪽 select box의 값에 매칭되는

cbw1030.tistory.com

2. http://jsfiddle.net/guest271314/j7ehtqjd/13/

#1. 내맘대로 봄

#a490ff / #ff90d1/ #bcff90

 

파스텔 톤. 배경으론 적절치 않다. 특히 초록색 배경에 하얀 글자를 넣으면 대체적으로 보이지 않는 편이다. 너무 예쁜 보라색을 발견해서 넣어봤다.

 

 

#1.5. 내맘대로 봄- 배경

 

#735bff / #ff5bbb / #a5ff5b

 

위의 색을 좀 더 찐하게 한 것. 배경으로 적절하지만 프로젝트랑은 안어울려서 기각. 초록색에 하얀 글자가 마찬가지로 잘 안보인다.

 

 

#2. 국룰 무지개

#ff5e6b / #ff9c40 / #5ce294 / #57abfa / #d690ff

 

배경으로 쓰기 좋은 원색 뙇 컬러. 하얀 글자 넣어도 잘 보임.

회색은 #e0e0e0, 검정은 #222222 가 어울린다.

 

 

 

노란색 잘쓰기 정말 힘들다.초록색은 배경으로 쓰기 정말 한정적이다.분홍색은 참 대단한 색이다. 뭘 하던 분위기를 핑크핑크하게 만든다.

 

문자열을 자를 때, 무심코 음절 갯수로 subString으로 자르게 되면

한글 + 영문 혼용의 상황에서, 굉장히 불편한 상황을 마주할 수 있다.

 

같은 여섯글자일 때, 한글과 영어가 각각

여섯글자입니다

abcde

로 보이는 현상을 마주할 수 있다. 

 

본인은 정해진 넓이의 칸에 글자가 넘치면 자르는 함수를 만드려고 했는데,

글자가 아니라 byte 단위로 잘라야 편안한 UI를 볼 수 있다.

(초보 개발자지만 이런 디테일에 신경쓰는 저, 비정상인가요?)

 

자르는 방법은 아래와 같다.

 

    var textEllipsis = function(data, offset){ //data: 자르고자 하는 str, offset: 자르는 byte 길이
        dataByteLength = (function(s,b,i,c){ //data의 길이를 byte로 변환한다
            for(b=i=0;c=s.charCodeAt(i++);b+=c>>11?3:c>>7?2:1);
            return b;
        })(data);

        if(dataByteLength > offset){
            for(b=i=0;c=data.charCodeAt(i);) {
                b+=c>>7?2:1;
                if(b > offset) break;
                i++;
            }
            return data.substring(0,i)+'…';
        }else{
            return data;
        }
    }

 

 

[참고 블로그]

https://programmingsummaries.tistory.com/239

https://zionh.tistory.com/68

+ Recent posts