@senspond
>
안녕하세요. RgbitCode 웹 사이트 개발기에 대한 글을 오랜만에 작성해봅니다. RgbitCode Apps 페이지 개발을 하며 고려했던 내용과 작업노트를 기록해봅니다.
안녕하세요. RgbitCode 웹 사이트 개발기에 대한 글을 오랜만에 작성해봅니다. 사실 그동안 개발을 진행하면서 기록해놔야 하는 내용들이 많이 있었는데요. 귀차니즘으로 인해서 많이 작성하지 못했던것 같습니다.ㅠㅠ 이런 글을 쓰는 것이 생각보다 많은 시간이 걸리는게 문제인 것 같은데, 그런 시간을 단축시킬 수 있는 방법을 고민해봐야 할 것 같습니다.
본래 기획은 아래처럼 애플 OS 의 UI/UX 느낌대로 조금 색다르게 구성해보려고 했는데요
하지만, 다음과 같은 이유로 일반적인 페이지 방식으로 개발하게 되었습니다.
1 . 앱에 어울리는 아이콘을 일일히 만드는게 생각보다 귀찮고 UI 를 새로 꾸미는데 들어가는 수고비용이 크다.
2. 기존의 컨텐츠 목록 보여주는 컴포넌트를 재사용하여 적용하고 본질(내용물)에 집중하는 낳을 것 같다.
3. 자체제작 만드는 것도 귀찮은데 외부 서비스도 링크하여 같이 보여주어 좀더 확장 하는 것이 더 낳을 것 같다.
현재 개발된 모습( RgbitCode Ver0.6 )은 다음과 같습니다. /apps 페이지 경로로 다음과 같은 화면을 볼 수 있는데요.
현재 사이트내에서 사칙연산 계산기 와 Json To XML 변환기 두가지 서비스를 이용하실 수 있는데요.
내부 제작한 서비스랑 외부에 이미 있는 유용한 서비스들을 같이 모아서 구성하려고 하는 쪽으로 개발방향이 달라지면서
다음 배포때는 "RgbitCode에서 만든 신선한 어플리케이션들을 만나보세요" 같은 문구는 삭제될 예정입니다. 그리고 각 서비스들을 태그로 분류를 해서 필터링해서 찾을 수 있도록 하고요.
"RgbitCode에서 만든 신선한 어플리케이션들을 만나보세요" 라고 문구를 써놓고 내부서비스 라고 태그를 달아둔 것은 .. 초기 기획의도와 달라져 가는 과정 중에 들어가게 된 흔적 이랄까요?ㅎㅎ
차후 버전을 위해서 금일 작업 했던 내용을 정리해봅니다. 이런글을 그때 그때 작성해놨다면 사이트를 만들어온 배경과 당시의 생각을 알수가 있고 많은 글들이 있었을 텐데 조금 아쉽기는 하네요. 앞으로는 좀더 써보도록 해보겠습니다.
프론트엔드 에서는 내부/외부 페이지를 구분해서 이동할 수 있는 필요한 기능을 모와서 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;
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. 외부서비스 목록을 추가하거나 설명문구를 변경할 때 조차 소스코드를 다시 빌드해서 배포해야 하는 문제.
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;
@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 [계정]@[아이피]:[경로]
파이썬으로 엑셀파일을 불러와 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 뜨는 경우가 생기고 있네요. 개선할 부분들이 많이 남아있습니다ㅠㅠ
그럼 다음에 또 개발기를 써보도록 하겠습니다.
안녕하세요. Red, Green, Blue 가 만나 새로운 세상을 만들어 나가겠다는 이상을 가진 개발자의 개인공간입니다.
현재글에서 작성자가 발행한 같은 카테고리내 이전, 다음 글들을 보여줍니다
@senspond
>