반응형

 

Supabase를 사용하다 보면 Row Level Security(RLS)를 켜놓은 상태에서 UPDATE 쿼리를 실행했는데도 다음과 같은 에러를 만날 수 있습니다:

 

ERROR:  permission denied for table user_profiles

 

“분명히 Policy도 설정했는데 왜 안 되지?” 싶은 분들을 위해, 이 글에서는 이 에러의 근본 원인과 해결 방법을 예제로 함께 정리합니다.

 

🧩 문제 상황

 

🔁 시도한 쿼리

update public.user_profiles
set name = '테스트'
where id = 'e0804eda-xxxxx-xxxxx-xxxxxx-xxxx';

 

 

⚠️ 발생한 에러 로그

ERROR:  permission denied for table user_profiles

 

이 에러는 단순히 RLS 정책이 없어서가 아닙니다. 오히려 RLS는 잘 설정되어 있었지만, PostgreSQL 권한(GRANT) 자체가 누락되어 있어서 발생하는 것입니다.

 


🔍 원인 분석: Supabase의 보안 구조

 

Supabase는 다음 두 단계를 거쳐 요청을 허용합니다:

 

✅ 1. PostgreSQL 권한 (GRANT)

역할(Role)이 테이블에 접근할 수 있는지

예: SELECT, INSERT, UPDATE, DELETE 권한을 가졌는가?

 

✅ 2. Row Level Security (RLS) 정책

해당 row에 접근 가능한 조건을 만족하는가?

예: 로그인한 사용자의 auth.uid()와 row의 id가 일치하는가?

 

즉, GRANT + RLS 둘 다 통과해야 UPDATE가 작동합니다.

 

 

 


🛠️ 해결 방법

 

1️⃣ 테이블 권한 부여

GRANT UPDATE ON public.user_profiles TO authenticated;

 

필요에 따라 SELECT도 함께 부여합니다:

GRANT SELECT ON public.user_profiles TO authenticated;

 

 

 


2️⃣ RLS 활성화

ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;

 

3️⃣ RLS 정책 작성

 

로그인한 사용자가 자신의 프로필만 수정 가능하게 만들기:

CREATE POLICY "Users can update their own profile"
ON public.user_profiles
FOR UPDATE
TO authenticated
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);

 

USING: 해당 row를 조회할 수 있는 조건

WITH CHECK: 해당 row를 수정할 수 있는 조건

 

 


✅ 최종 정리

 

설정항목 설명 예시
GRANT 테이블에 대한 접근 권한 부여 GRANT UPDATE ON ... TO authenticated
RLS ENABLE Row-Level Security 사용 설정 ALTER TABLE ... ENABLE ROW LEVEL SECURITY
POLICY 행(row) 단위 조건 설정 USING, WITH CHECK 조건 작성

 

 


🧪 결과: 정상 동작

 

위 설정이 완료되면, Supabase Auth를 통해 로그인한 사용자가 자신의 id를 가진 row에 대해 다음과 같은 쿼리를 정상적으로 실행할 수 있습니다.

update public.user_profiles
set name = '테스트'
where id = '로그인한_사용자의_id';

 

 

 

✍️ 마무리

 

처음엔 "RLS 정책만 설정하면 된다"고 생각하기 쉽지만, Supabase는 PostgreSQL 위에서 작동하는 만큼 기본 권한 설정도 꼭 필요합니다. 앞으로는 permission denied 에러가 나면 다음 두 가지를 먼저 체크해보세요:

1. GRANT 권한이 있는가?

2. RLS Policy가 올바른가?

 

이 구조만 이해하면, Supabase의 보안 모델을 더 자신 있게 다룰 수 있을 거예요.

 

 

# 해피코딩!

반응형

React의 상태관리를 위한 방법들이 몇가지가 있는데요.

그중에서 zustand를 소개하고자 합니다.

 

상태관리

주요한 상태관리 방식은 다음과 같습니다.

 

방식사용 도구 / 패턴추천 상황

React Context + useReducer 내장 상태 관리 소규모 앱, 글로벌 상태 많지 않을 때
Zustand / Jotai / Recoil 경량 상태관리 중소 규모 앱, 간단하게 쓰고 싶을 때
Redux Toolkit 표준 Redux 중대형 앱, 상태 변화 복잡할 때
BLoC 패턴 (구현형) 커스텀 Hook + 클래스/Stream 구조 Flutter에서 익숙한 개발자, 로직-UI 분리를 철저히 하고 싶을 때
❌ 단순 useState 남발 없음 상태가 많아지면 혼돈 💥

 

상태관리 및 모듈화 (또는 구조화) 하는 것은 소프트웨어 유지보수 및 제사용성을 높이고, 품질 향상을 위해서 진행합니다.

 

DI (Dependancy Injection) 구조는  테스트를 위해서 필요한 요소이기도 합니다.

때문에 DI framework을 고려하기도 하죠.

 -  Factory + DI Framework (ex: Inversify, NestJS 등)

 

 

상태관리 방법 상황별 추천 

소규모 프로젝트 Context + useReducer, Zustand
중/대형 앱, 많은 비즈니스 로직 Redux Toolkit
Flutter BLoC 경험자 커스텀 BLoC 구조 or MobX-State-Tree
단순한 상태만 있다 useState, useContext로 충분

 

 

상태 관리시 고려할 사항이 한가지 더있습니다.

 

전역 상태에 두는 것이 적절한 조건 설명

조건  설명
🔁 여러 컴포넌트에서 공유됨 예: 리스트 → 상세 → 수정
🔒 로그인 유저 전용 데이터 로그인 후 고정된 데이터
📦 비교적 작거나, 페이지 단위 데이터 수십~수백 개 수준

❌ 다만, 데이터 양이 너무 크거나 자주 바뀌면, 메모리/리렌더 성능 문제가 생길 수 있음.

 

 

 

전역 상태로 인한 메모리/성능 이슈

이슈 유형 설명 대응 방법
데이터가 수천~수만 개 예: works가 10,000건 이상 페이징, 무한스크롤, 서버 캐시
모든 상태 변경이 전체 리렌더 유발 Redux/Context에서 너무 많은 컴포넌트가 구독 Zustand 같은 부분 구독 상태관리 추천
works 자체가 큰 객체 파일 포함, 이미지 base64 등 저장 대신 ID만 들고 있고, 세부 정보는 lazy fetch

 

 

✅ 성능 좋게 전역 상태를 유지하는 팁

1. 필요한 데이터만 저장하기

예: 전체 works 배열 대신 worksMap: { [id]: Work } + workIds: string[] 같이 분리 저장

2. 컴포넌트별로 부분 구독

Redux Toolkit + useSelector (메모이제이션)

Zustand는 기본적으로 부분 구독이라 성능 좋음

3. 서버 캐싱 도구 활용

React Query / SWR로 데이터를 “전역처럼” 쓰고 자동 캐싱/패칭 활용

4. LocalStorage / IndexedDB 연계

너무 큰 데이터는 브라우저 저장소에 offload 가능

 

 

React쪽에서 많이 사용하는 상태관리 방법중에 

거의 설정 필요 없이 바로 사용 가능. 작지만 유연한 구조에 딱! 인 녀석이 Zustand입니다.

 

 

Zustand

 

 

 

 

 

Zustand + 서버 캐싱 조합

// useWorkStore.ts
import { create } from 'zustand';

interface Work {
  id: string;
  title: string;
}

interface WorkStore {
  works: Record<string, Work>;
  setWorks: (works: Work[]) => void;
}

export const useWorkStore = create<WorkStore>((set) => ({
  works: {},
  setWorks: (list) =>
    set({
      works: Object.fromEntries(list.map((w) => [w.id, w])),
    }),
}));

 

이러면:

전역 상태 관리

필요한 데이터만 접근

컴포넌트 리렌더 최소화

 

 

✨ 결론

Works 데이터를 전역으로 관리해도 메모리 문제는 거의 없음,

데이터가 너무 크거나, 빈번하게 바뀌거나, 전체 리렌더를 유발하는 구조라면 성능 최적화 필요.

Zustand, Redux Toolkit, React Query 조합을 잘 쓰면 아주 효율적으로 관리 가능

 

 

 Zustand가 인기 많은 이유

 

장점 설명
심플한 API create() 하나로 상태 만들고 끝. boilerplate 거의 없음
Redux보다 가볍고 직관적 action, reducer, slice 다 필요 없음
부분 상태 구독 가능 리렌더 최적화가 자동으로 됨 (성능 굿)
TypeScript 친화적 타입 추론 잘 되고, 구조도 깔끔
서버 상태와도 궁합 좋음 React Query, SWR 등과 같이 쓰기 쉬움
중간 규모 프로젝트에 딱 복잡하지 않으면서 강력함

 

 

실제 사용하는 곳들

Vercel (Zustand 만든 팀)

T3 Stack (Next.js + TypeScript + Tailwind + Zustand + Prisma)

커뮤니티 기반 SaaS, 사이드 프로젝트 등에서 널리 사용됨

 

 

 

프로젝트 규모 Zustand 적합성
소규모/사이드 프로젝트 👍 아주 적합
중간 규모 SaaS 👍 훌륭함
대규모 기업 프로젝트 👍 가능하지만, 팀의 스타일에 따라 Redux 선호할 수도
글로벌 상태가 많지 않은 앱 ✅ 가볍게 도입 가능

 

비교

항목 Zustand Redux Toolkit Context + useReducer
코드 간결함 ✅ 매우 심플 ❌ 비교적 장황함 ⚠️ 구조화 필요
전역 상태 관리 ✅ 가능 ✅ 아주 강력 ⚠️ 퍼포먼스 이슈 가능
리렌더 최적화 ✅ 기본 내장 ⚠️ 직접 memoization 필요 ❌ 전체 리렌더 발생 가능
타입스크립트 ✅ 굿 ✅ 굿 ⚠️ 직접 타입 정의 많음
학습 난이도 ⭐ 쉬움 ❌ 중간 이상 ⭐ 쉬움

 

정리

Zustand는 2024~2025 기준으로 가장 많이 쓰이는 경량 상태 관리 라이브러리 중 하나임

복잡한 Redux 셋업 없이도 전역 상태를 깔끔하게 다룰 수 있고, 성능도 아주 좋음.

 

 

 

설치하기

npm install zustand

 

 

적용하기

Folder 구조

src/
  features/
    works/
      repositories/
        WorkRepository.ts
      stores/
        useWorkStore.ts
      model/
        work.ts

 

 

WorkRepository.ts (함수형)

// features/works/repositories/WorkRepository.ts
import { supabase } from '../../../common/supabase';
import { table } from '../../../common/config/configstring';
import { Work, WorkView } from '../model/work';

export const WorkRepository = {
  async getList(): Promise<WorkView[]> {
    const { data, error } = await supabase.from(table.WORK_VIEW).select('*');
    if (error) console.error('getList error:', error);
    return data ?? [];
  },

  async getListByAssigneeId(assignee: string): Promise<WorkView[]> {
    const { data, error } = await supabase
      .from(table.WORK_VIEW)
      .select()
      .eq('assignee', assignee);
    if (error) console.error('getListByAssigneeId error:', error);
    return data ?? [];
  },

  async getCountByAssigneeId(assignee: string): Promise<number> {
    const { count, error } = await supabase
      .from(table.WORK_VIEW)
      .select('*', { count: 'exact' })
      .eq('assignee', assignee)
      .limit(0);
    if (error) console.error('getCountByAssigneeId error:', error);
    return count ?? 0;
  },

  async getListByFilter(filter: string, offset = 0, cnt = 10): Promise<{ v: WorkView[]; total: number }> {
    const { data, count } = await supabase
      .from(table.WORK_VIEW)
      .select('*', { count: 'estimated' })
      .or(filter)
      .range(offset, offset + cnt);
    return { v: data ?? [], total: count ?? 0 };
  },

  async getById(id: number): Promise<WorkView | null> {
    const { data, error } = await supabase
      .from(table.WORK_VIEW)
      .select()
      .eq('id', id);
    if (error) console.error('getById error:', error);
    return data?.[0] ?? null;
  },

  async insert(work: Work): Promise<Work | null> {
    const { data, error } = await supabase
      .from(table.WORKS)
      .insert(work)
      .select();
    if (error) console.error('insert error:', error);
    return data?.[0] ?? null;
  },

  async update(work: Work): Promise<Work | null> {
    const { data, error } = await supabase
      .from(table.WORKS)
      .update(work)
      .eq('id', work.id)
      .select();
    if (error) console.error('update error:', error);
    return data?.[0] ?? null;
  },

  async delete(id: number): Promise<void> {
    const { error } = await supabase.from(table.WORKS).delete().eq('id', id);
    if (error) console.error('delete error:', error);
  },
};

 

 

useWorkStore.ts (Zustand)

// features/works/stores/useWorkStore.ts
import { create } from 'zustand';
import { Work, WorkView } from '../model/work';
import { WorkRepository } from '../repositories/WorkRepository';

interface WorkState {
  works: WorkView[];
  selectedWork: WorkView | null;
  totalCount: number;
  loading: boolean;

  fetchWorks: () => Promise<void>;
  fetchWorksByAssignee: (assignee: string) => Promise<void>;
  fetchWorksByFilter: (filter: string, offset?: number, cnt?: number) => Promise<void>;
  fetchWork: (id: number) => Promise<WorkView | null>;

  createWork: (work: Work) => Promise<Work | null>;
  updateWork: (work: Work) => Promise<Work | null>;
  deleteWork: (id: number) => Promise<void>;
}

export const useWorkStore = create<WorkState>((set) => ({
  works: [],
  selectedWork: null,
  totalCount: 0,
  loading: false,

  fetchWorks: async () => {
    set({ loading: true });
    const data = await WorkRepository.getList();
    set({ works: data, loading: false });
  },

  fetchWorksByAssignee: async (assignee: string) => {
    set({ loading: true });
    const data = await WorkRepository.getListByAssigneeId(assignee);
    set({ works: data, loading: false });
  },

  fetchWorksByFilter: async (filter, offset = 0, cnt = 10) => {
    set({ loading: true });
    const { v, total } = await WorkRepository.getListByFilter(filter, offset, cnt);
    set({ works: v, totalCount: total, loading: false });
  },

  fetchWork: async (id: number) => {
    const work = await WorkRepository.getById(id);
    set({ selectedWork: work });
    return work;
  },

  createWork: async (work: Work) => {
    const newWork = await WorkRepository.insert(work);
    return newWork;
  },

  updateWork: async (work: Work) => {
    const updated = await WorkRepository.update(work);
    return updated;
  },

  deleteWork: async (id: number) => {
    await WorkRepository.delete(id);
  },
}));

 

 

 

사용 예시

const { works, fetchWorks, loading } = useWorkStore();

useEffect(() => {
  fetchWorks();
}, []);

return loading ? <p>Loading...</p> : <ul>{works.map(w => <li key={w.id}>{w.title}</li>)}</ul>;

 

 

 

# 해피코딩 !!

반응형

 

1. 지형정보 가져오기

Leaflet 을 사용할때 layer는 보통 geojson을 사용하게 됩니다.

 

지형이나 특정 지역을 표시할때, geojson을 구해서 사용해야 하는데, OSM( open street map)을 이용하면, 이미 만들어진 지역의 정보를 구할수 있습니다. 

https://www.openstreetmap.org/#map=19/37.250567/127.078423

 

오픈스트리트맵

오픈스트리트맵 (OpenStreetMap)은 여러분과 같은 사람들이 만들어, 개방형 라이선스에 따라 자유롭게 사용할 수 있는 세계 지도입니다.

www.openstreetmap.org

 

open street map 접속하기

내보내기

 

OSM file 이 만들어집니다.

 

OSM 파일에는 해당 영역의 모든 데이터가 있습니다.

 

2.osm to geojson(OSM 파일을 geojson으로 바꾸기)

OSM 파일을 geojson으로 변경을 위해서는 이를 지원하는 서비스를 이용하거나 아니면 코드로 작성해야 겠죠..^^ (당연한 이야기죠. )

 

https://mygeodata.cloud/

 

MyGeodata Converter | MyGeodata Cloud

--> --> Uploaded Files Type Size Please note that your data will not be shared to anybody. Recently used data is your data that you have recently uploaded and can only be seen by you from your computer and your web browser. You can reuse them for a repeat

mygeodata.cloud

여기에서OSM 파일을 geojson으로 변경이 가능합니다.

그러나 가격 부담 ㅠ_ㅠ

 

 

그래서 저는 직접 만드는 쪽으로 선회했습니다. ^^

 

import codecs
import json
import osm2geojson

# 각 feature에 bbox 추가
def add_bbox_to_features(geojson):
    for feature in geojson['features']:
        geom = feature.get('geometry')
        if not geom:
            continue
        coords = geom.get('coordinates')

        def extract_coords(c):
            if isinstance(c[0], (float, int)):  # Point
                yield c
            else:
                for sub in c:
                    yield from extract_coords(sub)

        min_lat = min_lon = float('inf')
        max_lat = max_lon = float('-inf')

        for lon, lat in extract_coords(coords):
            min_lon = min(min_lon, lon)
            min_lat = min(min_lat, lat)
            max_lon = max(max_lon, lon)
            max_lat = max(max_lat, lat)

        feature['bbox'] = [min_lon, min_lat, max_lon, max_lat]

# OSM 파일 읽기 및 변환
with codecs.open('sample.osm', 'r', encoding='utf-8') as data:
    xml = data.read()

geojson = osm2geojson.xml2geojson(xml, filter_used_refs=False, log_level='INFO')

# feature 단위 bbox 추가
add_bbox_to_features(geojson)

# 저장
with open("result.json", "w", encoding="utf-8") as f:
    json.dump(geojson, f, ensure_ascii=False, indent=2)

 

 

osm2geojson이라는 python package가 있는데 이를 활용하여 간단하게 만들수 있습니다.

(bbox는 사실 필요 없는데, 저는 bbox 활용할 일이 있어서 추가 작성 했습니다.)

 

osm2geojson 예제에 text encoding 관련된 부분에 한글이 고려 안되어있는 부분이 있습니다. 그래서 unicode 텍스트 (이거 뭐랄까 ascii로 unicode를 하나하나 출력) 출력되는데, 이부분 주의가 필요합니다.

with open("result.json", "w", encoding="utf-8") as f:
    json.dump(geojson, f, ensure_ascii=False, indent=2)

 

아무튼 결과물은 다음과 같이 나오게 됩니다.

결과물 (일부만)

{
      "type": "Feature",
      "properties": {
        "type": "way",
        "id": 60274279,
        "tags": {
          "leisure": "park",
          "name": "반달공원",
          "name:en": "Bandal Park",
          "name:ko": "반달공원"
        },
        "nodes": [
          522283226,
          804659125,
          522283225,
          522283224,
          522283223,
          522283222,
          522283221,
          522283220,
          522283219,
          804658865,
          522283218,
          749481489,
          12597050520,
          1041086129,
          522283226
        ],
        "timestamp": "2025-02-17T23:25:53Z",
        "user": "VLD297",
        "uid": 22440181,
        "version": 6
      },
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [
              127.0788144,
              37.2516044
            ],
            [
              127.0790588,
              37.2514582
            ],
            [
              127.0792253,
              37.251231
            ],
            [
              127.0792854,
              37.2509749
            ],
            [
              127.0792393,
              37.2507596
            ],
            [
              127.0791239,
              37.2505404
            ],
            [
              127.0786913,
              37.250087
            ],
            [
              127.0784826,
              37.2499406
            ],
            [
              127.0782648,
              37.2498577
            ],
            [
              127.077969,
              37.2498236
            ],
            [
              127.0776801,
              37.2498829
            ],
            [
              127.0773679,
              37.2500504
            ],
            [
              127.0777277,
              37.2504259
            ],
            [
              127.0780778,
              37.250814
            ],
            [
              127.0788144,
              37.2516044
            ]
          ]
        ]
      },
      "bbox": [
        127.0773679,
        37.2498236,
        127.0792854,
        37.2516044
      ]
    },

 

이렇게 나온 geojson을 leaflet 을 이용하여 layer로 추가하면 멋진 커스터마이징이 가능해지죠!!

 

 

# 해피코딩!!

반응형

 

OSM : Open Street Map

https://planet.openstreetmap.org/

 

Planet OSM

Supporting OSM OSM data is free to use, but is not free to make or host. The stability and accuracy of OSM.org depends on its volunteers and donations from its users. Please consider making an annual recurring gift to OSM to support the infrastructure, too

planet.openstreetmap.org

 

 

구글 맵, 다음 지도, 네이버 맵등을 이용해서 지도 정보를 화면에 출력할 수도 있지만, OSM(Open Street Map)을 이용해보고자 합니다.

(뭔가 더 자유도가 있는 것 같음..)

 

 

OSM 은 우선 사용하는데에는 무료 입니다.

OSM는 어떻게 무료인가요?

OpenStreetMap은 크라우드소싱으로 만들어진 오픈 데이터베이스

ODbL(Open Database License) 하에 무료로 사용할 수 있습니다.

누구든지 상업/비상업 목적으로 자유롭게:

데이터를 보고,

복사하고,

수정하고,

앱/웹에서 사용할 수 있어요.

 

하지만 다음은 꼭 주의하세요

항목설명

🔌 타일 서버 (tile.openstreetmap.org) OSM 데이터는 무료지만 기본 타일 서버는 “공공 기여 기반”이라 과도한 사용 금지
📈 상업적 서비스 웹사이트/앱에서 많은 트래픽이 예상되면 타일 서버를 직접 운영하거나 외부 제공업체(MapTiler 등)를 사용해야 함
📝 저작권 표시 필수 지도에 “© OpenStreetMap contributors” 표기 필요
💥 Rate Limit 기본 서버는 과도한 호출 시 차단당할 수 있음 (특히 앱에서 다수 사용자일 경우)

 

 

 대안 서비스 (타일 서버 대체용)

서비스설명

MapTiler 상업적 사용 OK, 다양한 스타일 제공
OpenMapTiles 자체 호스팅 가능, OSM 기반
Carto 지도 + 데이터 시각화 전문
Mapbox 고성능 상업용 지도 SDK (유료 요금제 있음)

 

 

 

 

실무 예시

작은 규모의 내부 대시보드나 개인 프로젝트 → tile.openstreetmap.org 사용 OK

상업 서비스, 앱 배포, 사용자 많은 시스템 → 별도 타일 서버나 상용 서비스 이용 권장

 

 

OSM 타일 서버 직접 구축하는 법

도구설명

TileServer GL 벡터/래스터 타일 서버, Docker로 간편 구축 가능
Tegola Go 기반 고성능 벡터 타일 서버
openmaptiles.org 미리 생성된 벡터 타일 다운로드 or Docker 배포

 

 

타일 서버 구축

1. 프로젝트 디렉토리 구성

osm-tileserver/
├── docker-compose.yml
└── data/
    └── south-korea.mbtiles   ← 원하는 지역 타일 파일

 

2. docker-compose.yml

 

version: "3"

services:
  tileserver:
    image: klokantech/tileserver-gl
    ports:
      - "8080:80"
    volumes:
      - ./data:/data
    environment:
      - MAPBOX_STYLES=local

 

3. 실행 방법

# 프로젝트 폴더로 이동
cd osm-tileserver

# 실행
docker-compose up -d

 

 

 

4. 접속 확인

 

브라우저에서 접속: http://localhost:8080

 

제공 기능:

웹 기반 지도 뷰어

벡터 타일 (PBF)

래스터 PNG 타일 (/styles/basic/{z}/{x}/{y}.png)

Leaflet/OpenLayers 연동용 URL 자동 제공

 

5. Leaflet 연동 예시

L.tileLayer('http://localhost:8080/styles/basic/{z}/{x}/{y}.png', {
  maxZoom: 18,
  attribution: '© OpenStreetMap contributors'
}).addTo(map);

 

 

.mbtiles 파일은 어디서 받나요?

https://openmaptiles.org/downloads/

대한민국: south-korea.mbtiles 다운로드

용량 수백 MB ~ 수 GB

 

 

6. OSM file을 geojson 으로 변경하기

OSM 맵의 특정 부분을 geojson으로 변경해서 사용하고 싶을 때가 있습니다.

특정 영역의 색상을 표시한다거나 할때, 지도상에서 해당 영역을 찾아서 표시 하고 싶을 때가 있죠.

 

 

# 해피코딩 !!

 

 

 

 

반응형

 

RLS란?

Supabase 본격적으로 사용하다보면, RLS를 접하게 되는데요. 이 RLS 개념이 상당히 편리한 기능입니다. 

Row Level Security , 즉 DB 의 특정 필드의 값을 채크하여 query 시 Row 단위로 제외 시킬 수 있는 방식입니다.

 

id user_id created_at title content team_id
1 mika 11 안녕하세요. 개발 진행 합니다. 1
2 other 12 개발 의뢰 합니다 이런거 개발해 주세요. 2

말로 표현하려니 좀 어려웠는데요.

 

위와 같은 table이 있다고 한다면, user_id 가 mika. 인 데이터만 가져오려고 한다면,

query시 user_id 가 mika 인 조건을 추가해서 client에서 호출하면 됩니다.

그러나, 만약 클라이언트 개발자가 이 과정에서 특정 경우에 조건을 추가하지 않았다면, 다른 사람의 정보들까지 노출되버리겠죠.

이를 시스템 상으로 보완할수 있는 하나의 방법이 RLS 입니다.

RLS에 기본적인 예제로  acceess token에서 auth 정보를 가져와서 접근 권한을 설정할 수 있기 때문에, 로그인한 사용자에게 맞춰서 데이터를 필터링 할 수 있게 됩니다.

 

 

문제는 뷰 (View)

View 는 기본적으로 RLS가 적용 안됩니다.

VIEW로 쿼리할때 데이터가 필터링 안되는지 몇시간 고민하다 찾게 된 사항입니다.

 

만약 View를 만들어서 사용하려면, View를 만들때 RLS가 적용되도록 해야합니다.

WITH ( security_invoker = TRUE )

 

view 생성시 이렇게 security_invoker를 설정해주면 RLS가 적용됩니다.

 

create or replace view
[view이름] WITH ( security_invoker = TRUE ) as
select * form [talbe이름]

 

 

아래와 같은 형식이 되겠죠..

 
create or replace view
public.myview WITH ( security_invoker = TRUE ) as
select
mydata.*
user_profiles.name as user_name,
user_profiles.email as user_email,
org.name as org_name,
sites.name as site_name

from
mydata
left join user_profiles on mydata.user_id = user_profiles.id
left join org on mydata.org_id = org.id
...

 

 

 


ALTER를 이용하여 기존의 View에 적용하는 방법.

View는 RLS 를 설정 할 수 없습니다.  이걸로 좌절하고 주말을 보냈는데, 찾다보니 방법이 있더군요.

ALTER VIEW my_view SET (security_invoker = on);

 

이렇게 설정을 하면 my_view 에서 query하는 table에 RLS가 적용 됩니다.

다만 이렇게 했을때 문제는 my_view 가 접근하는 모든 table에 RLS가 잘 적용 되어있어야 동작 한다는 것입니다.

 

a table은 접근 권한이 있고 b table은 접근 권한이 없으면 퍼미션 에러가 발생합니다.

View 뿐만 아니라, Table 에서도 마찬가지 입니다.

(이건 시스템을 어떻게 설계하느냐의 문제이기 때문에 이런 부분 고려해서 설계하면 좋겠네요)

 

 

RLS를 활용하여 내 서비스의 기능을 발전 시켜 나가보세요~

 

 

#해피코딩!!

반응형

 

Ubuntu PC가 로그아웃 상태에서도 원격 데스크톱(Remote Desktop)에 접속하려면, 일반적인 데스크톱 세션이 아닌 **로그인 화면(GDM)**이나 Xorg 세션에 접근할 수 있도록 설정해야 합니다. 이를 위해서는 몇 가지 설정을 변경해야 합니다.

 

1️⃣ xRDP 설치 및 설정 확인

 

Ubuntu에서 일반적으로 사용하는 원격 데스크톱 서버는 xRDP입니다. xRDP는 기본적으로 로그인된 세션에 연결되도록 설정되어 있지만, 로그아웃 상태에서도 접속 가능하도록 변경할 수 있습니다.

 

 

1. xRDP 설치

sudo apt update
sudo apt install xrdp -y

 

 

2. xRDP 서비스 활성화 및 시작

 

sudo systemctl enable xrdp
sudo systemctl start xrdp

 

3. xRDP 상태 확인

 

sudo systemctl status xrdp

 

 

2️⃣ Xorg 세션으로 강제 설정

 

기본적으로 xRDPXorg 세션을 사용해야 로그아웃 상태에서도 원격 접속이 가능합니다.

sudo nano /etc/xrdp/xrdp.ini

 

[Xorg] 섹션을 찾아서 아래와 같이 설정되어 있는지 확인하세요.

 

 

[Xorg]
name=Xorg
lib=libxorg.so
username=ask
password=ask
ip=127.0.0.1
port=-1

 

 

3️⃣ Polkit 설정 수정 (로그인 화면 접근 허용)

 

Polkit은 인증 프레임워크로, 로그인 화면에서도 원격 세션을 허용하도록 설정해야 합니다.

 

sudo nano /etc/polkit-1/localauthority/50-local.d/45-allow-colord.pkla

 

아래와 같은 내용을 추가합니다:

 

[Allow Colord All Users]
Identity=unix-user:*
Action=org.freedesktop.color-manager.create-device
ResultAny=yes
ResultInactive=yes
ResultActive=yes

 

4️⃣ 세션 관리 설정 (GDM 허용)

 

만약 GDM을 사용 중이라면 GDM 설정을 변경해야 합니다.

 

sudo nano /etc/gdm3/custom.conf

 

아래와 같이 WaylandEnable=false로 설정합니다.

 

[daemon]
# Uncomment the line below to force the login screen to use Xorg
WaylandEnable=false

 

5️⃣ xRDP 재시작

 

모든 설정 후, xRDP 서비스를 재시작합니다.

 

sudo systemctl restart xrdp
sudo systemctl restart gdm3

 

✅ Mac에서 RDP로 접속하기

 

이제 Mac의 Microsoft Remote Desktop 앱을 통해 로그인된 세션이 아니더라도 Ubuntu로 접속할 수 있어야 합니다.

IP 주소: Ubuntu의 로컬 IP

사용자 이름: Ubuntu의 로그인 계정

비밀번호: 계정 비밀번호

 

⚠️ 문제 해결

1. 포트 확인 (기본: 3389)

 

sudo ufw allow 3389/tcp
sudo ufw reload

 

2. xRDP 로그 확인

sudo tail -f /var/log/xrdp.log

sudo tail -f /var/log/xrdp-sesman.log

 

 

반응형

 

 

using (
  created_at > (current_timestamp - interval '1 day')
);
반응형

 

서비스를 개발할때, 필요한 기능 중에 사용자별로 접근가능한 데이터를 제한 하거나, 기능을 제한 하는 것이 필요할 경우가 있습니다.

 

이럴 경우 보통, 사용자가 로그인 하고 나서 사용자의 권한 정보를 서버에 쿼리하고 사용자의 권한에 따라 기능을 제한 하는 형태로 진행하도록 할 수 있죠.

 

보통 사용자 정보에 록(역할, role, permission) 을 두고 처리하는 경우가 많습니다. 

어떻게 그럼 서비스에서는 사용자의 role을 확인할 까요?

 

대략적으로 생각해볼 수 있는 방법이 2가지 정도 인것 같네요.

1. 사용자가 db나 api를 이용할때 해당 api, 또는 db query에서 사용자 정보 table을 참조하여 권한이 있는 사용자인지 확인하는 방법

2. 사용자의 access token에 권한 정보를 추가하여,  db, api에서 token 정보를 decoding 하여 권한 정보를 얻는 방법

 

1번은 매번 DB access 마다 확인을 해야 하는 사항이라 서버에 부하와 성능에 영향을 미치게 됩니다.

2번은 성능에 대한 오버해드가 거의 없습니다. 하지만 사용자의 정보가 변경되었을때 token에 바로 반영되는 것이 아니기 때문에 이를 위한 처리가 필요할 것입니다. (아니면 다음 로그인 까지 무시 하거나.)

 

오늘 정리할 내용은 성능 이슈도 이슈지이지만, access tokend 다루는 것에 중심을 두고 싶어서 2번에 대한 내용을 정리했습니다.

 

Supabase 에서 Auth Hooking 을 통해서 token 에 추가정보를 입력하기

 

Supabase 에 Beta로 현재 Auth Hooking을 지원하고 있고, postgres 에서는 RLS (Row Level Security)를 지원합니다.

 

이를 활용하여 서버쪽에서 auth정보를 이용해서 접근 권한을 설정할 수 있는 방법이 생겼습니다.

 

Access Token flow

 

이런 처리를 통해 기대한 Access Token의 전체적인 모습은 다음과 같습니다.

 

 

Access Token

 

 

Token을 디코딩 결과

{
  "aal": "aal1",
  "access": "user",
  "amr": [
    {
      "method": "password",
      "timestamp": 1738558038
    }
  ],
  "app_metadata": {
    "provider": "email",
    "providers": [
      "email"
    ]
  },
  "aud": "authenticated",
  "email": "xxxxx@xxxx.xxx",
  "exp": 1738633992,
  "iat": 1738630392,
  "is_anonymous": false,
  "iss": "http://127.0.0.1:54321/auth/v1",
  "org_depart_id": null,
  "org_id": "1",
  "phone": "",
  "role": "authenticated",
  "session_id": "b9313b3b-bd86-44ea-b3d7-98c7ca6b9447",
  "sub": "e0804eda-a117-4c51-9f50-66804c889b41",
  "user_metadata": {
    "email": "xxxxx@xxxx.xxx",
    "email_verified": false,
    "phone_verified": false,
    "sub": "e0804eda-a117-4c51-9f50-66804c889b41",
    "username": "name...."
  }
}

 

supabase의 token에  "access", "org_depart_id",  "org_id" 를 추가한 형태입니다.

 

 

그럼 절차를 확인 해보도록 하시죠.

 

DB의 policies 를 살펴보면, 사용자에 따라 접근권한을 설정할때, auth.uid() 같은 함수를 이용해서 현 사용자만 접근 가능하도록 할 수 있죠.

 

그런데 만약 같은 팀원만 볼수 있게 하고 싶다면, 음.. 일단 RLS에서 team에 대한 table join 해서 팀에 소속되어있는지 확인해야 합니다.

(예제에도 그렇게 되어있죠)

그런데 table join이 들어간 코드와 아닌코드의 성능 차이기 약 99% 차이가 난다고 예제에 나와있어요.

table join이 빠지면 99% 성능 향상 ..

 

그런데  만약 auth token에 추가정보를 담고, RLS에서 auth token에 있는 정보를 활용해서 필터링 할 수 있다고 한다면, (지금 시도해보고 있음), ㅅa

table join없이 처리가 가능합니다. 즉 향상된 성능으로 처리할 수 있다는 것이죠.

 

0. custom_jwt_token_hook 추가하기

  • supabase 에서 auth hook을 추가합니다.

Authentication ->  Hooks 를 선택  -> Add Hook

 

이런 화면이 나오죠.

- DB는 postgres 선택하고, schema를 선택해야 하는데 이때 public으로 해도 되지만, 저는 따로 private schema를 만들어두어서 그걸 이용했습니다. (public은 expose되어있어서 불안한 부분이 있거든요)

- 그리고 function을 선택해야 합니다. (지금 구현된 것이 없기 때문에 만들어야 겠죠).

 

  • custom_jwt_token_hook 구현 
  • private schema 를 만들어서 추가 (public schema는 노출 되어있기 때문에 피하는 것이 좋다. )
  • javascript 가 plpgsql보다 익숙하여 plv8 import하여 javacript로  작성하였습니다.
//함수 명: custom_jwt_token_hook

var org_id, access, org_depart_id;

//  -- Fetch the current user's level from the profiles table
  var result = plv8.execute("select id, org_id, access, org_depart_id from public.user_profiles where id = $1", [event.user_id]);
  if (result.length > 0) {
    org_id = result[0].org_id;
    org_depart_id = result[0].org_depart_id;
    access = result[0].access;

  } else {
    org_id = 0;
    org_depart_id = 0;
    access = '';
  }

//  -- Check if 'claims' exists in the event object; if not, initialize it
  if (!event.claims) {
    event.claims = {};
  }
//  -- Update the level in the claims
  event.claims.org_id = org_id;
  event.claims.org_depart_id = org_depart_id;
  event.claims.access = access;


  return event;

 

- user_profiles table을 만들고 이 테이블에 org_id, org_depart_id를 담고 있다.

 

SQL Editor에서 작성하는것이 더 편리합니다. 

create or replace function custom_jwt_token_hook(event jsonb)
returns jsonb
language plv8
as $$

  var org_id, role, org_depart_id;

//  -- Fetch the current user's level from the profiles table
  var result = plv8.execute("select org_id, role, org_depart_id from public.user_profiles where user_id = $1", [event.user_id]);
  if (result.length > 0) {
    org_id = result[0].org_id;
    org_depart_id = result[0].org_depart_id;
    role = result[0].role;

  } else {
    org_id = 0;
    org_depart_id = 0;
    role = '';
  }

//  -- Check if 'claims' exists in the event object; if not, initialize it
  if (!event.claims) {
    event.claims = {};
  }

//  -- Update the level in the claims
  event.claims.org_id = org_id;
  event.claims.org_depart_id = org_depart_id;
  event.claims.role = role;

  return event;
$$;

grant all
  on table public.user_profiles
  to supabase_auth_admin;

revoke all
  on table public.user_profiles
  from authenticated, anon, public;

 

 

1. custom jwt를 통해서 token에 추가정보가 들어간것 확인했습니다.

=> 정상적으로 설정이 완료 되면 token에 정보가 들어간 것을 볼 수 있다.

주의 점은 user_profiles에 접근 권한이 설정 되어있어야 한다는 것입니다.

supabase 의 auth 로직은 supabase_auth_admin role로 동작하기 때문에, user_profiles 의 select에  authenticated, supabase_auth_admin role이 필요합니다.

 

2. 서버에서 RLS 로직에서 추가정보를 꺼내올 수 있는지 확인 필요.

=> 확인 중인 사항 (확인 완료!)

 

아래처럼 org_id 값을 비교하고 싶을때, org_id가 int8 (숫자)인경우라면, 숫자를 문자로 변경해서 비교 해야함.

ALTER POLICY "Enable read access for same org"
ON "public"."devices"
TO authenticated
USING (
  (auth.jwt() ->> 'org_id')::text = org_id::text
);

 

 

 

3.테스트를 위해서 local pc에서 custom jwt 를 테스트 해 볼 수 있는 환경 구축..

가이드에 이렇게 적혀있음

# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"

 

 

 

config.toml 에 다음 과 같이 내용 추가.

[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/private/custom_jwt_token_hook"

 

=> 정상 동작 하는것 확인

 

 

이것을 시도 해보겠습니다. !!

 

 

 


!해피 코딩

 

 

[참고사항]

코드를 작성하고 돌려볼때 user_profiles의 권한 관련된 문제가 발생할 수도 있습니다.

저는 migration하다 문제가 생겨서 골치 아팠었는데요.

1. private/custom_jwt_token_hook을 못찾는 문제가 있었습니다.

환경 문제일 수도 있고, 제가 이것 저것 건드려서 생긴 문제일 수도 있습니다.

ALTER FUNCTION private.custom_jwt_token_hook SECURITY DEFINER;
// 원래 invoker로 설정 되어있어도 동작 했던것 같은데, 
// SECURITY DEFINER 로 설정을 바꿨습니다.

GRANT EXECUTE ON FUNCTION private.custom_jwt_token_hook TO authenticator;
//권한 설정 auth logic이 authenticator로 동작하는데 함수에 대해서 도 추가로 설정
// authenticator 역할이 실행 권한을 설정

 

2. user_profiles 접근 권한 문제 발생 했습니다.

RLS 설정을 하여 문제가 없어보이는 상황(제 짤은 생각으로는.. 그렇습니다.^^;;)이었으나 이상하게 접근 권한에 문제가 계속 생겨서 SQL Editor에서 다음과 같이 권한을 줬습니다. 그 이후 기대했던대로 동작 했습니다.

GRANT SELECT ON TABLE public.user_profiles TO authenticated;

 


3. View 문제

View는 RLS 를 설정 할 수 없습니다.  이걸로 좌절하고 주말을 보냈는데, 찾다보니 방법이 있더군요.

ALTER VIEW my_view SET (security_invoker = on);

 

이렇게 설정을 하면 my_view 에서 query하는 table에 RLS가 적용 됩니다.

다만 이렇게 했을때 문제는 my_view 가 접근하는 모든 table에 RLS가 잘 적용 되어있어야 동작 한다는 것입니다.

a table은 접근 권한이 있고 b table은 접근 권한이 없으면 퍼미션 에러가 발생합니다.

(이건 시스템을 어떻게 설계하느냐의 문제이기 때문에 이런 부분 고려해서 설계하면 좋겠네요)

 

 

[참고] Supabase Custom Access Token Hook

https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook?queryGroups=language&language=sql

 

Custom Access Token Hook | Supabase Docs

Customize the access token issued by Supabase Auth

supabase.com

 

 

+ Recent posts