@senspond

>

개발기>Web 개발기

RgbitCode Apps 페이지 개발을 하며 고려했던 내용과 작업노트

등록일시 : 2024-01-24 (수) 09:57
업데이트 : 2024-01-26 (금) 01:32
오늘 조회수 : 2
총 조회수 : 107

    안녕하세요. RgbitCode 웹 사이트 개발기에 대한 글을 오랜만에 작성해봅니다. RgbitCode Apps 페이지 개발을 하며 고려했던 내용과 작업노트를 기록해봅니다.

    안녕하세요. RgbitCode 웹 사이트 개발기에 대한 글을 오랜만에 작성해봅니다. 사실 그동안 개발을 진행하면서 기록해놔야 하는 내용들이 많이 있었는데요. 귀차니즘으로 인해서 많이 작성하지 못했던것 같습니다.ㅠㅠ 이런 글을 쓰는 것이 생각보다 많은 시간이 걸리는게 문제인 것 같은데, 그런 시간을 단축시킬 수 있는 방법을 고민해봐야 할 것 같습니다.


    초기 기획했던 방향과 개발된 방향

    본래 기획은 아래처럼 애플 OS 의 UI/UX 느낌대로 조금 색다르게 구성해보려고 했는데요


    하지만, 다음과 같은 이유로 일반적인 페이지 방식으로 개발하게 되었습니다.


    1. 1 . 앱에 어울리는 아이콘을 일일히 만드는게 생각보다 귀찮고 UI 를 새로 꾸미는데 들어가는 수고비용이 크다.

    2. 2. 기존의 컨텐츠 목록 보여주는 컴포넌트를 재사용하여 적용하고 본질(내용물)에 집중하는 낳을 것 같다.

    3. 3. 자체제작 만드는 것도 귀찮은데 외부 서비스도 링크하여 같이 보여주어 좀더 확장 하는 것이 더 낳을 것 같다.


    현재 개발된 모습( RgbitCode Ver0.6 )은 다음과 같습니다. /apps 페이지 경로로 다음과 같은 화면을 볼 수 있는데요.


    현재 사이트내에서 사칙연산 계산기Json To XML 변환기 두가지 서비스를 이용하실 수 있는데요.


    내부 제작한 서비스랑 외부에 이미 있는 유용한 서비스들을 같이 모아서 구성하려고 하는 쪽으로 개발방향이 달라지면서

    다음 배포때는 "RgbitCode에서 만든 신선한 어플리케이션들을 만나보세요" 같은 문구는 삭제될 예정입니다. 그리고 각 서비스들을 태그로 분류를 해서 필터링해서 찾을 수 있도록 하고요.


    "RgbitCode에서 만든 신선한 어플리케이션들을 만나보세요" 라고 문구를 써놓고 내부서비스 라고 태그를 달아둔 것은 .. 초기 기획의도와 달라져 가는 과정 중에 들어가게 된 흔적 이랄까요?ㅎㅎ


    작업노트

    차후 버전을 위해서 금일 작업 했던 내용을 정리해봅니다. 이런글을 그때 그때 작성해놨다면 사이트를 만들어온 배경과 당시의 생각을 알수가 있고 많은 글들이 있었을 텐데 조금 아쉽기는 하네요. 앞으로는 좀더 써보도록 해보겠습니다.


    React 에서 내부/외부 링크이동 Hook 구현

    프론트엔드 에서는 내부/외부 페이지를 구분해서 이동할 수 있는 필요한 기능을 모와서 Custom Hook 을 작성했습니다.

    export function useLink(){
        const router = useRouter();
    
       /**
         * 내부 페이지 이동
         * @param url
         */
        const goInnerPage = (url : string) =>{
            void router.push(url)
        }
    
        /**
         * 외부 페이지 이동
         * @param url
         * @param target  default _blank
         */
        const goExternalPage = (url: string , target : string = "_blank") => {
            // 보안취약점을 방지하기 위해서 noopener,noreferrer 옵션
            if(window) window.open(url, target, 'noopener,noreferrer');
        };
    
    
        /**
         * 로딩애니메이션을 보여주고 외부 페이지 이동
         * @param url       : 이동url    필수
         * @param component : 로딩컴포넌트 default : <Loading/>)
         * @param delay     : 딜레이     default : 500)
         */
        const goExternalPageWithLoading = (
    				url: string, 
            component : ReactComponentElement<any> = <Loading/>, 
            delay : number = 500) => {
            const body = document.querySelector("body");
            const element = document.createElement("div");
            createRoot(element).render(component); // 엘리먼트에 로딩 컴포넌트 랜더링
            body.appendChild(element);
    
            // 1초후 페이지 이동
            setTimeout(()=> {
                goExternalPage(url)
                body.removeChild(element);  // 로딩 엘리먼트 삭제
            }, delay)
        };
    
        return { goInnerPage, goExternalPage, goExternalPageWithLoading };
    }

    goExternalPageWithLoading 함수는 외부페이지로 이동하면서 로딩애니메이션 같은 특수효과를 줄때 사용하려고 만들었습니다.. 어플리케이션 목록 페이지에서 외부서비스로 이동할 때 어떤 효과를 넣어주면 좋을것 같아서요.


    그리고 위 코드에서 중요한 부분이 있는데요.

    window.open(url, target, 'noopener,noreferrer');

    target="_blank"를 사용하면 새 탭에서 링크된 페이지가 열리게 됩니다. 이때 어떤사이트에서 부터 외부링크로 가게 되었는지 정보가 실리게 되는데요. 가령 어떤 악의를 가진 해커가 개인정보 탈취 등을 목적으로 악성사이트를 구성 해놓고 이 페이지의 JavaScript에서 window.opener로 부모 윈도우(링크를 건 페이지)의 오브젝트에 접근해서 'window.opener.location = 새로운URL'로 부모 윈도우의 URL을 바꿔치기 해버릴 수 있어 보안상의 큰 문제가 발생될 수 있습니다.


    • noopener: 링크된 페이지에서 window.opener로 부모 윈도우를 참조할 수 없게 만듬.

    • noreferrer: 다른 페이지로 이동 시 링크를 건 페이지의 정보를 브라우저가 Referer HTTP 헤더로 송신하지 않게 함.


    그래서 외부 링크로 a 태그를 작성할 때도 아래처럼 작성해야합니다.

    <a href="연결할 페이지의 URL" target="_blank" rel="noopener noreferrer">새 탭에서 보기</a>

    어지간한 유명한 웹사이트 플랫폼에는 다 적용되어 있는 부분인데, RgbitCode의 웹사이트 관리자페이지의 글쓰기 에디터내에서도 외부링크를 넣을 때도 a 태그에 noopener noreferrer 속성이 들어가도록 코딩되어 있습니다.


    제작한 커스텀 hook 은 아래처럼 사용할 수 있습니다.

    const LinkTest = ()=>{
        const {goInnerPage, goExternalPage, goExternalPageWithLoading} = useLink()
    
        return (
            <div>
                <button onClick={()=> goInnerPage("/blog")}>클릭0</button>
                <button onClick={()=> goExternalPage("https://google.com")}>클릭1</button>
                <button onClick={()=> goExternalPageWithLoading("https://google.com")}>클릭2</button>
                <button onClick={()=> goExternalPageWithLoading("https://google.com", <TechCloud/>)}>클릭3</button>
            </div>
        )
    }
    
    export default LinkTest;


    DB 테이블, 엔티티 설계

    현 코드의 문제점

    export interface AppItem {
        id : number
        url : string
        title : string
        name : string
        version : string
        description : string
        tags : Array<string>
        heroImage? : string
        createAt? : string
        updateAt? : string
        inner: boolean
    }
    export const AppList = new Array<AppItem>();
    // Json2Xml
    AppList.push({
        id : 1,
        url : "/apps/json2xml",
        title : "RgbitCode - Json To XML 변환기",
        version : "1.1",
        name : "",
        description : "JSON 데이터 형식을 XML 형식으로 보기좋게 변환하는 서비스입니다",
        tags : ["내부서비스","DevTools"],
        cardImage : "/images/card/rgbitcode-app.png",
        createAt : "2024-01-21",
        updateAt : "2024-01-23",
        inner : true
    } as AppItem)
    
    // FourbasicCalculator
    AppList.push({
        id : 2,
        url : "/apps/calculator/fourbasic",
        title : "RgbitCode - 사칙연산 계산기",
        version : "1.0",
        name : "f",
        description : "사칙연산 텍스트를 파싱해 사칙연산을 수행해주는 계산기 서비스입니다",
        tags : ["내부서비스","Calculator"],
        cardImage : "/images/card/rgbitcode-app.png",
        createAt : "2024-01-22",
        updateAt : "2024-01-22",
        inner : true
    } as AppItem)


    
    export class AppService {
    
        static findAppList() : Array<AppItem> {
            // TODO : 백엔드 API로 fetch 하고 Next Cache 데이터를 생성하도록 변경해야 함
            return [...AppList].sort((a, b) => b.id - a.id)
        }
    }


    현재 적용되어 있는 버전(ver0.6) 에서는 일단 저렇게 하드코딩 된 데이터를 가지고 만들어져 있는데요. 만약 RgbitCode내부 서비스로만 구성한다면 기능 추가할 때마다 어차피 재배포를 해야하고 AppList에 추가만 해주면 되겠지만.. 개발 방향을 변경함으로써 앞으로 문제가 될 수 있다고 판단 되었는데요. 그 문제점은 다음과 같습니다.


    문제점

    1. 1. 외부서비스 목록을 추가하거나 설명문구를 변경할 때 조차 소스코드를 다시 빌드해서 배포해야 하는 문제.

    2. 2. 목록이 많아지면 질 수록 관리가 용이하지 않음


    그리하여 결국 귀찮지만 DB 화를 시키고 백엔드 API 를 구성하기로 하였습니다.


    현재 RgbitCode 는 postgresql 을 기반으로 Java 백엔드 사용하고 있는데요.

    set search_path to 'blog';
    create table applications(
         id          serial primary key,
         name        varchar(100) unique,
         url         varchar(255),
         detail_url  varchar(255),
         title       varchar(150),
         version     varchar(10),
         description varchar(400),
         tags        varchar(20) ARRAY,
         is_external  boolean,
         is_self_made boolean,
         image_url   varchar(255),
         create_at   timestamp default now(),
         update_at   timestamp default now()
    );
    
    COMMENT ON TABLE applications IS '어플리케이션 목록';
    COMMENT ON COLUMN applications.id IS 'ID';
    COMMENT ON COLUMN applications.name IS '어플리케이션 이름(unique key)';
    COMMENT ON COLUMN applications.url IS '링크 url';
    COMMENT ON COLUMN applications.title IS '어플리케이션 제목';
    COMMENT ON COLUMN applications.description IS '어플리케이션 요약 소개글';
    COMMENT ON COLUMN applications.detail_url IS '어플리케이션 상세정보에 대한 url';
    COMMENT ON COLUMN applications.image_url IS '어플리케이션 이미지 url';
    COMMENT ON COLUMN applications.is_external IS '외부서비스 여부';
    COMMENT ON COLUMN applications.is_self_made IS '자체제작 여부';
    COMMENT ON COLUMN applications.tags   IS '태그';
    COMMENT ON COLUMN applications.create_at IS '등록일시';
    COMMENT ON COLUMN applications.update_at IS '업데이트 일시';

    applications 와 applications_tag 로 1:N 의 관계를 가지도록 두개의 테이블로 정규화를 해서 만들 수도 있지만, 데이터 자체가 정말 공장처럼 많이 만든다고 가정해도 몇백개? 정도 밖에 되지 않을 것이고 프론트엔드에서 모든 데이터를 가져와 캐싱하여 메모리에 올려놓고 프론트엔드에서 필터링 하여 검색하도록 구현할 것이기에 간단하게 한 테이블로 구성을 했습니다.


    그리고 VARCHAR 로 사용하고 콤마(,) 로 이어진 문자열로 저장해도 되지만 데이터가 잘못된 형식으로도 저장될 수도 있고 UNNEST 을 활용해 태그별 목록 카운트 집계를 손쉽게 하기 위해 postgresql 에서 지원하는 ARRAY 타입을 사용했습니다.


    • 태그별 카운트 집계쿼리

    select unnest(tags) as tag ,
           count(id) as count
    from blog.applications
    group by tag
    order by count desc;


    Java Entity 매핑

    @Getter // 모든 멤버변수에 대한 get 메소드를 생성하는 롬복 어노테이션
    @MappedSuperclass
    @EntityListeners(AuditingEntityListener.class)
    @ToString
    public abstract class AbstractBaseEntity {
    
        @CreatedDate
        @Column(name = "create_at")
        private LocalDateTime createAt;
    
        @LastModifiedDate
        @Column(name = "update_at")
        private LocalDateTime updateAt;
    }


    @Entity
    @Getter
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    @EqualsAndHashCode(of = "id", callSuper = false)
    @Table(schema = "blog", name = "applications")
    public class Applications extends AbstractBaseEntity {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Integer id;
    
        @Column(nullable = false, unique = true, length = 100)
        private String name;
    
        @Column(name="title")
        private String title;
    
        @Column(name="description")
        private String description;
    
        @Column(name="url")
        private String url;
    
        @Column(name="detail_url")
        private String detailUrl;
    
        // postgresql
        @Column(name = "tags", columnDefinition = "VARCHAR ARRAY")
        private List<String> tags;
    
        @Column(name = "is_external")
        private boolean isExternal; // '외부서비스 여부'
    
        @Column(name = "is_self_made")
        private boolean isSelfMade; // '자체제작 여부'
    
        @Column(name = "image_url")
        private String imageUrl; // '이미지 링크'
    
    }

    추가적으로 엔티티 클래스에 빌더패턴의 생성자를 작성하고

    간단하게 JPA 레파지토리와 junit 테스트 코드를 작성하고 엔티티 객체를 생성해 save를 하여 로컬DB에 잘 저장되는지를 확인해보았습니다.


    작업목록 만들기 - 엑셀파일

    DB에 데이터들을 집어넣어야 하는데 해더를 DB필드명대로 엑셀로 정리해 대략 20개 정도의 목록을 추려봤네요.



    이런 목록들을 북마크,즐겨찾기로 잘 정리해놓으신 분들도 계시겠지만 저 같은 경우 그런 정리정돈을 정말 못하는 편입니다... 그래서 그렇게 잘 분류해 놓은 것이 없다보니 개발하다가 해당 사이트가 생각이 안나거나 하면 또 다시 구글링을 통해 찾는 수고로움을 해야 했던것 같은데, 이번 기회에 정리해 보는것 같습니다.


    그런데 이 사이트에 적용할 이미지 때문에...두개정도 이미지를 직접 캡쳐해서 만들어보다가 너무 귀찮아서... 그걸 자동화 하는 프로그램을 만들기 시작 했습니다.


    이미지 파일 다운로드 & 압축

    1. 정리된 엑셀파일에서 is_external 필드가 True 인 목록(외부서비스) 링크 목록을 불러온다.

    2. 셀레니움 웹드라이브로 해당 url로 이동하여 정해진 사이즈의 캡쳐를 찍어온다.

    3. 캡쳐된 이미지를 압축하여 경량화 시켜서 특정 디렉토리에 저장한다.


    React를 하다가 Java를 하다가 갑자기 Python입니다...

    from selenium import webdriver
    from selenium.webdriver.chrome.service import Service
    from selenium.webdriver.chrome.webdriver import WebDriver
    from webdriver_manager.chrome import ChromeDriverManager
    from selenium.webdriver.common.by import By
    
    class MyWebDriver():
        # driver: WebDriver = None
    
        def __init__(self, width=1920, height=1080, headless=False):
            options = webdriver.ChromeOptions()
    
            # headless 옵션 설정
            if headless:
                options.add_argument('headless')
            options.add_argument("no-sandbox")
            # 브라우저 윈도우 사이즈
            options.add_argument(f"window-size={width}x{height}")
            options.add_argument('--start-maximized') # 브라우저가 최대화된 상태로 실행
            options.add_argument("disable-gpu")  
            options.add_argument("lang=ko_KR")  
            options.add_argument(
                """user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) 
    							 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36"""
    			  )
            # 드라이버 위치 경로 입력
            self.driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)
    
        def get_driver(self):
            return self.driver
        
        def navigate(self, url):
            self.driver.get(url)
            self.driver.implicitly_wait(1)
        
        def screenshot(self, file_path):
           try:
               el = self.driver.find_element(By.TAG_NAME, 'body')
               el.screenshot(file_path)
           except Exception as e:
              print('selenium screenshot error.')
        
        def quit(self):
            self.driver.quit();

    웹드라이브 클래스를 만들었습니다. 크롬 웹드라이브를 구동시켜 url로 이동하고 캡쳐를 떠오는 기능이 있습니다.

    import pandas as pd
    from os import path
    from os import listdir
    from modules.web_driver import MyWebDriver
    from PIL import Image
    
    ex_dir = path.dirname(path.abspath(__file__))
    excel_file = path.join(ex_dir, *["data", "applications.xlsx"]);
    input_path = path.join(ex_dir, "image")
    output_path = path.join(ex_dir, *["image", "convert"])
    print(input_path, output_path)
    
    
    def work_df_load():
        origin_df = pd.read_excel(excel_file, engine="openpyxl")
    
        # 외부서비스 True 인 목록의 데이터로 필터링
        df = origin_df[origin_df['is_external'] == True]
        df = df[df['isDone'] != True]
        return [origin_df, df]
    
    def edit_excel(odf, cdf, filepath):
        # odf 기준 left join on name
        merge_df = pd.merge(left=odf, right=cdf, how="left", on="name")
        ndf = odf.copy()
    
        for i in ndf.index:
            if ndf.loc[i, "is_external"] == True:
    				   if merge_df.loc[i, "is_done_y"] == True:
               		ndf.loc[i, "is_done"] = True
        ndf.to_excel(filepath, index=False)
    
    def image_chapture(df, headless = True):
        wd = MyWebDriver(headless=headless)
    
        for i in df.index:
            try:
                item = df.loc[i]
                name = item['name']
                url = item['url']
                wd.navigate(url)
                ouput_file = path.join(input_path, f"{name}.jpg")
                wd.screenshot(ouput_file)
                df.loc[i, 'is_done'] = True
            except Exception as e:
                print(name + "실패 : " + e)
                df.loc[i, 'is_done'] = False
                pass
        wd.quit()
        return df
    
    def jpg_image_compressor(dir, w=800, h=500, q=50):
        dir_list = listdir(dir)
        print(dir_list)
        f_list = list(filter(lambda x: x.split(".")[-1] == 'jpg', dir_list))
        for filename in f_list:
            file = path.join(input_path, filename)
            img = Image.open(file)
            img = img.convert('RGB')
            resized_image = img.resize((w, h))
            resized_image.save(path.join(output_path, filename), qualty=q)
    
    
    if __name__ == "__main__":
        # 1. 엑셀파일 로드
        dfs = work_df_load()
        # 2. 이미지 캡쳐
        df = image_chapture(dfs[1])
        # 3. 캡쳐성공 여부를 바탕으로 엑셀 수정
        edit_excel(dfs[0], df, excel_file)
        # 4. 이미지 압축
        jpg_image_compressor(input_path)

    작업한 엑셀파일을 로드해서 이미지를 캡쳐하고 성공여부를 가지고 엑셀파일을 수정하고 마지막으로 작업디렉토리의 이미지들을 압축해 특정경로에 저장하는 것을 구현했습니다.



    위에는 셀레니움으로 스크린샷을 캡쳐오는 파일사이즈이고 아래는 변환시킨 사이즈입니다. 약 1/8 가량 파일사이즈가 줄어든 것을 확인할 수 있었습니다.


    그대로 이미지 FTP 서버에 업로드를 진행.

    scp *.jpg [계정]@[아이피]:[경로]



    엑셀 데이터 DB에 집어넣기

    파이썬으로 엑셀파일을 불러와 postsqldb 에 연결해서 DB에 insert 하는 코드를 작성했습니다.

    import psycopg2
    import pandas as pd
    from os import path
    
    config = {
        "host": "localhost",
        "dbname": "postgresql",
        "user": "user",
        "password": 'password',
        "port": 5432
    }
    ex_dir = path.dirname(path.abspath(__file__))
    
    
    class Databases():
        def __init__(self):
            self.db = psycopg2.connect(**config)
            self.cursor = self.db.cursor()
    
        def __del__(self):
            self.db.close()
            self.cursor.close()
    
        def execute(self, query, args={}):
            self.cursor.execute(query, args)
            row = self.cursor.fetchall()
            return row
    
        def commit(self):
            self.cursor.commit()
    
        def insert(self, schema, table, colums, values):
            sql = f"INSERT INTO {schema}.{table}({colums}) VALUES ({values});"
            print(sql)
            try:
                self.cursor.execute(sql)
                self.db.commit()
            except Exception as e:
                print(" insert DB  ", e)
    
        def type_mapper(self, value):
            if value == True: return "true"
            if value == False: return "false"
            if value == None: return "null"
            if str(value) == "nan": return "null"
            return "'" + str(value).strip() + "'"
    
    if __name__ == "__main__":
        df = pd.read_excel("../data/applications.xlsx", engine="openpyxl")
        df = df.iloc[:, 0:8]
        print(df)
        columns = ",".join(list(df.columns))
    
        db = Databases()
        for i in df.index:
            values = list(map(db.type_mapper, df.values[i].tolist()))
            tags = "','".join(values[5].split(","))
            values[5] = f"ARRAY [{tags}]" # tags field : postgresql array
            values_str = ",".join(values)
            db.insert('blog', 'applications', columns, values_str)


    그리고 이미지 url 을 업데이트 해줬습니다.

    update blog.applications
        set image_url = concat('[이미지서버URL]/images/apps', name, '.jpg')
    where is_external = true;


    마지막으로 Java 백엔드 API를 만들고 프론트엔드 에서 데이터를 가져와 캐시데이터를 생성해 랜더링하도록 약간의 코드를 수정했네요.


    결과물



    구현된 화면을 보니 뿌듯해지네요 ㅎㅎ 목록을 좀더 업데이트하고 태그 목록을 따로 추려서 사이드 바로 넣어주면 더 좋을것 같은데, 좀더 완성도를 끌어올려 다음 배포때 반영 해보겠습니다.


    그런데, 현재 웹사이트 최적화가 조금 필요합니다. 특히 모바일 환경에서 속도를 개선할 필요가 있고, 글쓰기 에디터에서 이미지를 업로드 할 때 특별히 파일 압축을 하고 있지 않다보니 1Mbyte 가 넘는 이미지들도 있고, 사이트 속도에 영향을 주고 있네요. 그리고 웹 에디터에서 이미지를 올렸다가 삭제할때 파일 서버에서도 삭제 되도록 구현을 했지만... 에디터 BODY에는 삭제가 안되어서 이미지404 뜨는 경우가 생기고 있네요. 개선할 부분들이 많이 남아있습니다ㅠㅠ


    그럼 다음에 또 개발기를 써보도록 하겠습니다.

    senspond

    안녕하세요. Red, Green, Blue 가 만나 새로운 세상을 만들어 나가겠다는 이상을 가진 개발자의 개인공간입니다.

    댓글 ( 0 )

    카테고리내 관련 게시글

    현재글에서 작성자가 발행한 같은 카테고리내 이전, 다음 글들을 보여줍니다

    @senspond

    >

    개발기>Web 개발기

    • 웹사이트에 명령프롬프트(터미널) UI 개발

      이번 글은 이 웹사이트(RgbitCode)에 명령프롬프트(터미널) UI 를 개발했던 과정을 정리해보았습니다.
        2023-12-07 (목) 02:15
      1. 블로그 서비스로 혁신창업스쿨 지원했던 후기

        RgbitCode 웹 사이트의 기원이 되는 이야기를 하나 들려드릴까 합니다. 저는 개발자 특화 블로그 서비스로 혁신창업스쿨이라는 정부지원 사업에 지원 했었는데요. 그때 광탈했던 이야기입니다.
          2023-09-05 (화) 01:27
        1. RgbitCode 구글 애드센스 승인 / NextJS 에 구글애드센스 컴포넌트화 해서 집어넣는 방법

          RgbitCode 구글 애드센스 승인이 되었습니다. 구글 애드센스 승인과정과 NextJS 에 구글애드센스 컴포넌트화 해서 집어넣는 방법을 정리해봅니다.
            2024-01-29 (월) 02:45
          1. [현재글] RgbitCode Apps 페이지 개발을 하며 고려했던 내용과 작업노트

            안녕하세요. RgbitCode 웹 사이트 개발기에 대한 글을 오랜만에 작성해봅니다. RgbitCode Apps 페이지 개발을 하며 고려했던 내용과 작업노트를 기록해봅니다.
              2024-01-24 (수) 09:57
            1. NextJs 구글 애드센스 적용시 발생할 수 있는 오류 / CSS Grid 연습

              nextjs로 개발한 Rgbitcode 에 구글애드센스 관련한 문제를 겪게 되었는데요. 그 원인을 찾아나가는 과정과 생각해본 내용을 정리해봅니다.
                2024-01-29 (월) 11:48