#3 1003개의 스레드 ✕ 해제
이온디
이온디 1개월 전
시리즈 ← 이전 편 바이브코딩의 위험성 ③ — 사람은 잊지만 코드는 잊지 않는다 사고는 위험한 자유의 부산물이 아니라, 안전망 부재의 결과다. — 시리즈 3편 / 회고와 안전망 설계 바이브코딩의 양면성 "바이브코딩(vibe coding)"이라는 말이 유행어가 되었다. AI에게 분위기와 의도만 던져주면 코드가 술술 나오는, 자연어로 개발하는 시대. 코드 한 줄 모르는 사람도 며칠이면 자기 사이트를 만든다. 우리는 더 이상 if-else나 for-loop을 외울 필요가 없다. 그 자리에 들… 시리즈 ← 이전 편 바이브코딩의 위험성 ③ — 사람은 잊지만 코드는 잊지 않는다 사고는 위험한 자유의 부산물이 아니라, 안전망 부재의 결과다. — 시리즈 3편 / 회고와 안전망 설계 바이브코딩의 양면성 "바이브코딩(vibe coding)"이라는 말이 유행어가 되었다. AI에게 분위기와 의도만 던져주면 코드가 술술 나오는, 자연어로 개발하는 시대. 코드 한 줄 모르는 사람도 며칠이면 자기 사이트를 만든다. 우리는 더 이상 if-else나 for-loop을 외울 필요가 없다. 그 자리에 들어선 건 기획·아이디어·도메인 이해다. 좋은 시대가 온 게 맞다. 그런데 좋은 시대는 새로운 종류의 사고도 함께 들고 온다. 이번 사고가 바로 그 예시였다. 평범한 모델 파일 하나를 추가하면서, alembic env.py에 import 한 줄을 추가하지 않았다. 그것 하나로 1091줄짜리 폭탄 마이그레이션이 만들어졌다. 사람은 한 줄을 잊었을 뿐인데, 자동화 도구는 그 망각을 30개 테이블의 DROP 문으로 증폭시켰다. AI 도구든 alembic 같은 자동화 스크립트든, 자동화는 인간의 실수를 대신 봐주지 않는다. 오히려 증폭시킨다. "조심하자"는 답이 아니다 가장 흔한 사후 대처는 "다음부터 조심하자"다. 그리고 가장 자주 실패하는 대처도 "다음부터 조심하자"다. env.py에 import를 빠뜨렸을 때 누구도 일부러 그런 게 아니다. 새 모델 추가하느라 정신없는 와중에, env.py라는 한참 떨어진 파일을 동시에 떠올리는 게 어려웠을 뿐이다. 인간은 작업 중에 컨텍스트를 좁힌다. 그게 결함이 아니라 본성이다. 그래서 답은 사람의 주의력에 의존하지 않는 구조다. 인간이 잊을 수 있는 자리를 자동화로 채우는 것. 이번 사고에서 우리가 한 일은 사실 그 한 가지였다. for _info in pkgutil.iter_modules(_models_pkg.__path__): if _info.name.startswith("_") or _info.name == "base": continue importlib.import_module(f"app.models.{_info.name}") 이 6줄이 의미하는 바는 단순하다. 누구도 더 이상 import를 까먹을 수 없다. 모델 파일이 디렉터리에 있으면 자동으로 잡힌다. 잊을 수 있는 자리 자체를 없앤 것이다. 3중 방어 — 사고를 몇 단계에서 막을 것인가 좋은 시스템은 한 군데에서 막는 게 아니라, 여러 군데에서 같은 사고를 잡는다. 첫 번째 방어선이 뚫려도 두 번째가 있고, 두 번째가 뚫려도 세 번째가 있어야 한다. 이번 일을 계기로 마이그레이션 사고에 대한 3중 방어를 설계했다. 1차 방어 — 모델 메타데이터 자동 등록 방금 본 그것. 누락 자체가 일어나지 않도록 구조를 바꾼다. 2차 방어 — Pre-commit gate 혹시라도 1차가 뚫려서 위험한 마이그레이션이 만들어졌다면, commit 단계에서 차단한다. 신규 마이그레이션 파일에 drop_table이나 drop_index가 일정 개수 이상이면 git이 commit을 거부하도록 한다. # .git/hooks/pre-commit NEW_MIGRATIONS=$(git diff --cached --name-only --diff-filter=A | grep '^alembic/versions/.*\.py$') for f in $NEW_MIGRATIONS; do DROPS=$(grep -cE "drop_table|drop_index" "$f") if [ "$DROPS" -ge 3 ]; then echo "❌ $f 에 drop 작업이 $DROPS 건 있습니다. 의도된 변경인지 확인하고 --no-verify로 우회하세요." exit 1 fi done 3차 방어 — Pre-deploy gate 다른 사람이 다른 환경에서 commit한 게 production까지 흘러왔다고 해도, 배포 직전에 한 번 더 막는다. 배포 스크립트(server.sh)에서 alembic upgrade를 돌리기 전에 dry-run을 먼저 시킨다. SQL=$(alembic upgrade --sql current:head) if echo "$SQL" | grep -qE "DROP TABLE|TRUNCATE"; then echo "❌ 배포 중단 — 위험한 SQL 감지" exit 1 fi alembic upgrade head 이 세 단계 중 어느 한 곳도 사람의 주의력에 의존하지 않는다. 자고 있어도, 출근길에 정신없어도, 이미 쳤던 명령을 의식 없이 다시 쳐도 — 안 터진다. 백업 자동화와 binlog 이번 사고에서 가장 손발이 묶였던 순간은 binlog가 OFF라는 걸 확인했을 때였다. 점-시간 복구라는, 데이터베이스의 마지막 안전망이 처음부터 없었던 것이다. binlog는 MySQL의 변경 이력을 별도 파일에 기록하는 기능이다. 이게 켜져 있으면 "어제 14:50:59 시점으로 복구해줘"가 가능하다. 꺼져 있으면 그냥 마지막 백업 시점까지밖에 못 돌아간다. 켜는 건 한 줄이다. /etc/mysql/mysql.conf.d/mysqld.cnf에: log_bin = /var/log/mysql/mysql-bin.log expire_logs_days = 7 expire_logs_days = 7이 중요하다. 안 적으면 binlog가 무한히 쌓여서 디스크가 꽉 찬다. 7일이면 보통의 사고 추적엔 충분하고, 디스크 부담도 적다. 그리고 binlog가 있어도 풀 백업은 따로 필요하다. 복구는 "풀 백업 + 그 이후의 binlog" 조합으로 이뤄지기 때문이다. 풀 백업은 cron으로 자동화한다. # 매일 새벽 3시 백업 0 3 * * * /home/.../scripts/db_backup.sh >> /home/.../private/backup.log 2>&1 # 매일 새벽 3시 30분, 14일 이전 백업 자동 삭제 30 3 * * * find /home/.../private/ -name '*-*.sql.gz' -mtime +14 -delete 이 두 줄이면 매일 새벽 백업이 만들어지고, 14일 이전 백업이 자동 정리된다. 한 번 설정하면 잊고 살아도 된다. 디테일이 안전을 만든다 이번 작업에서 사소해 보이지만 중요했던 디테일들을 적어둔다. set -euo pipefail — 백업 스크립트 첫 줄. 한 줄이라도 실패하면 즉시 멈춘다. 이게 없으면 mysqldump가 중간에 실패해도 gzip은 부분 데이터를 압축하면서 "성공" 종료 코드를 반환할 수 있다. 부분 백업은 백업이 아니다. 이 한 줄이 그 차이를 만든다. --single-transaction — InnoDB에서 락 없이 일관된 스냅샷을 떠준다. production 트래픽에 영향을 거의 안 주면서 데이터 일관성을 보장. --routines --triggers --events — 이걸 빼먹으면 트리거·뷰·저장 프로시저가 백업에 안 들어간다. 복원 후에 알 수 없는 동작이 사라진 채로 운영되는 상황이 생긴다. OUT_DIR="$HOME/web/.../private" — 절대 경로 대신 $HOME을 쓰면 다른 서버나 다른 계정에서도 그대로 동작한다. 그리고 백업 위치가 웹에서 노출되지 않는 디렉터리인지 반드시 확인한다. chmod +x를 로컬에서 미리 — rsync는 파일 권한을 그대로 복사한다. 로컬에서 +x를 안 했으면 production에서 또 chmod를 해야 한다. 작은 일이지만, 매번 잊는 종류의 일이다. ~/.ssh/config 별칭 등록 — 매번 풀 도메인 치는 대신 ssh mars 한 단어로 접속. 작은 편의지만, 이런 작은 마찰이 쌓이면 보안 작업도 귀찮아진다. (NEUTRALIZED) 같은 명시적 표시 — 비운 마이그레이션의 docstring 첫 줄에 이렇게 넣어두면, dry-run 출력에도 그대로 떠서 "지금 처리되는 게 무력화된 파일이 맞다"는 즉각적인 시각 확인이 된다. 6개월 뒤의 자기 자신을 위한 친절. 이런 디테일들은 하나하나는 사소하지만, 사고 한가운데에서 마음을 가라앉히는 건 결국 이런 작은 확실성들이다. 다 끝난 뒤에 남은 것 이번 사고로 잃은 데이터는 단순 로그 정도였고 다시 채울 수 있는 수준이었다. 하지만 만약 폭탄이 발견되지 않은 채 다음 배포 때 터졌다면? 그땐 30개 테이블이 진짜로 사라졌을 것이다. 그중에는 단순 로그만 있는 게 아니었다 — 고객 관리, 마케팅 캠페인, 뉴스레터 구독자, 게시판 SEO 설정 같은, 다시 채우려면 외부 자료 없이는 불가능한 데이터들이 섞여 있었다. 운이 좋았다. 그런데 운이 좋았다는 건 시스템이 안전했다는 게 아니라, 단지 이번엔 누군가 일찍 알아챘다는 뜻일 뿐이다. 다음 사고는 늦게 발견될 수도 있다. 자동화의 시대에는, 사고가 너무 빨리 너무 광범위하게 퍼진다. 알아챈 시점엔 이미 늦은 경우가 많다. 그래서 우리는 안전망을 코드에 박아둔다. env.py의 자동 import, pre-commit hook, pre-deploy gate, 매일 백업, binlog. 사람은 잊지만 코드는 잊지 않으니까. 바이브코딩이 위험한 게 아니다. 안전망 없는 자동화가 위험한 것이다. 자연어로 개발하는 시대에, 우리가 새로 배워야 할 건 아마 코드 그 자체가 아니라 — 자동화의 사각지대를 미리 메우는 감각일 것이다. 당신의 바이브코딩은 안전한가요? AI에게 코드를 맡겼다면, 안전망도 같이 맡겼는지 한 번 점검해보세요. 7가지 항목으로 끝나는 무료 자가 진단. 바이브코딩 안전 체크리스트 받기 → 시리즈 끝. 다음에는 더 안전한 코드로 만나길.
이온디
이온디 1개월 전
시리즈 다음 편 → 바이브코딩의 위험성 ① — 어제는 분명히 괜찮았다 AI에게 코드를 맡기는 시대, 우리는 어떤 안전망을 가지고 있을까? 한 번의 자동화가 30개 테이블을 날릴 뻔한 이야기. — 시리즈 1편 / 사건 발견과 추적 평범한 아침, 작은 위화감 오늘 아침 관리자 화면에 접속했더니 몇몇 메뉴가 비어 있었다. SEO 설정, 재무 거래내역, 호스팅 생성 로그. 그리고 발송 로그도. 처음에는 단순히 "아직 데이터를 안 채워서 그런가 보다" 싶었다. 신규 기능 위주의 메뉴들이라서 그럴… 시리즈 다음 편 → 바이브코딩의 위험성 ① — 어제는 분명히 괜찮았다 AI에게 코드를 맡기는 시대, 우리는 어떤 안전망을 가지고 있을까? 한 번의 자동화가 30개 테이블을 날릴 뻔한 이야기. — 시리즈 1편 / 사건 발견과 추적 평범한 아침, 작은 위화감 오늘 아침 관리자 화면에 접속했더니 몇몇 메뉴가 비어 있었다. SEO 설정, 재무 거래내역, 호스팅 생성 로그. 그리고 발송 로그도. 처음에는 단순히 "아직 데이터를 안 채워서 그런가 보다" 싶었다. 신규 기능 위주의 메뉴들이라서 그럴 만도 했다. 그런데 이상했다. 며칠 전 분명히 본 적이 있는 데이터들이었다. 이게 왜 비어 있지? 처음 떠올린 가설은 단순했다. eondcms는 Rhymix(xe_* 테이블)를 그대로 쓰면서 신규 기능은 eond_* 테이블에 적재하는 구조다. 신규 테이블이 새로 만들어진 거라, 이전 데이터가 없는 게 자연스럽다. 깔끔한 설명이었다. 그런데 깔끔한 설명일수록 의심해야 한다는 걸, 8년 프리랜서 생활이 가르쳐준 본능이 있었다. "원래 있었다"는 증언 테이블 행 수를 직접 세어 봤다. eond_site — 4개 행. SEO는 사이트별로 채우면 되는 정상 상태. eond_bank_transactions — 0개. 이전엔 있었다. eond_hosting_setup_log — 0개. 이것도 있었다. "원래 있었다"는 기억이 옳다면, 신규 테이블 가설은 무너진다. 데이터가 사라진 것이다. 이쯤에서 가장 먼저 점검하는 건 MySQL의 binlog. 점-시간 복구(point-in-time recovery)가 가능한 유일한 안전망이다. SHOW VARIABLES LIKE 'log_bin'; OFF. 이 한 줄로 모든 복구 옵션이 사라졌다. 백업 디렉터리도 비어 있었다. 추적할 수 있는 흔적이 통째로 사라진 셈이다. 어제는 정상이었다 다행인지 불행인지, 사라진 건 "단순 로그 정도"였다. 글, 회원, 댓글, 파일 같은 핵심 데이터는 멀쩡했다. 다시 채워 넣으면 되는 수준. 그렇다고 그냥 넘어갈 일은 아니었다. 이런 사고는 다음에도 일어날 수 있고, 다음엔 운이 안 좋을 수도 있으니까. 추적의 단서를 찾기 위해 시점부터 좁혔다. 어제는 괜찮았던 걸로 기억함 이 한 줄이 결정적이었다. 어제까지 정상이었고, 오늘 사라졌다. 그렇다면 그 사이에 누군가 무엇을 했다. git log를 펼쳤다. efd252f 2026-04-28 14:51:03 feat: major update — inquiry-to-project flow, notification logging, market price, xe_ table integration 오늘 오후 2시 51분에 거대한 커밋이 하나 들어가 있었다. "major update"라는 말이 이미 의심스러웠지만, 그보다 눈에 띈 건 변경된 파일 수였다. 모델 추가, 라우터 추가, 서비스 추가, 마이그레이션 파일들 — 그중에서 한 줄이 유독 튀었다. 1091 +++ alembic/versions/a6ae466f8b07_add_random_order_to_board_config.py 마이그레이션 파일 하나가 1091줄. 게다가 이름은 "board_config에 random_order 추가". 컬럼 하나 추가하는 데 1091줄? 이름과 다른 내용 파일을 열어봤다. """add_random_order_to_board_config Revision ID: a6ae466f8b07 Create Date: 2026-04-27 16:36:16.654089 """ def upgrade() -> None: op.create_table('eond_hosting_plan', ...) op.drop_table('eond_project_quote_items') op.drop_table('eond_outreach_template') op.drop_table('eond_notification_log') op.drop_table('eond_marketing_activity') ... 이름은 컬럼 추가인데, 내용은 30개가 넘는 테이블을 DROP하는 코드였다. 사라진 테이블 목록을 훑어보니 정확히 오늘 사라진 데이터들이 거기에 있었다. eond_bank_transactions (재무 거래내역) ✓ eond_hosting_setup_log (호스팅 생성 로그) ✓ eond_clients (고객 관리) ✓ eond_audit_log, eond_blog_posts, eond_member_login_log eond_marketing_* 시리즈 거의 전부 eond_newsletter_*, eond_post_seo, eond_board_seo eond_publish_queue, eond_writing_material, eond_wiki_pages ... 범인을 찾았다고 생각했다. 어제(4/27 16:36) 만들어진 이 파일이, 오늘 배포(4/28 14:51) 시점에 production에서 실행되며 데이터를 다 날려버렸다고. 시간 순서도 완벽하게 맞아떨어졌다. 그런데 정말 그랬을까? 결정적 한 줄 확신을 굳히기 전에 production에서 alembic 상태를 확인했다. $ alembic current c3d4e5f6a1b2 이 한 줄이 모든 추리를 뒤집었다. c3d4e5f6a1b2는 폭탄 마이그레이션(a6ae466f8b07)의 바로 직전 단계였다. 즉 production은 아직 폭탄을 실행하지 않은 상태였다. 1091줄의 DROP 문은 production DB에 한 줄도 닿지 않았다. 그렇다면 오늘 비어버린 데이터는 누가 지웠나? 솔직히 말하자면 — 모른다. binlog가 꺼져 있어서 추적할 흔적이 없다. 어쩌면 어떤 수동 작업, 어쩌면 다른 환경과의 혼동, 어쩌면 우리가 모르는 어떤 reset 스크립트의 흔적. 영원히 미궁이다. 하지만 진짜 무서운 건 그게 아니다. 폭탄은 아직 안 터졌을 뿐, 신관은 빠져 있는 상태로 차에 실려 있다는 것. 다음 배포 때 누가 무심코 alembic upgrade head를 누르는 순간, 그제서야 30개 테이블이 진짜로 사라진다. 이번엔 운 좋게 발견했지만, 발견하지 못했다면? 이 시점부터 작업의 성격이 바뀌었다. 사고 조사가 아니라 사고 예방으로. 다음 편 — ② 범인은 autogenerate였다 — 폭탄 마이그레이션 해체
이온디
이온디 1개월 전
2025. 06. 17 초고 작성 개발자들을 위한 나라는 없다 누구나 개발자가 될 수 있는 시대, 진짜 개발자의 가치를 찾아서 PDF 다운로드 온라인으로 읽기 새벽 3시, 모니터 앞에서 에너지드링크를 마시며 버그와 씨름하던 그 시절이 있었다. ChatGPT가 내가 3일간 짠 코드를 5분 만에 뚝딱 만들어내는 걸 보고 나서, 문득 이런 생각이 들었다. "개발자들을 위한 나라… 2025. 06. 17 초고 작성 개발자들을 위한 나라는 없다 누구나 개발자가 될 수 있는 시대, 진짜 개발자의 가치를 찾아서 PDF 다운로드 온라인으로 읽기 새벽 3시, 모니터 앞에서 에너지드링크를 마시며 버그와 씨름하던 그 시절이 있었다. ChatGPT가 내가 3일간 짠 코드를 5분 만에 뚝딱 만들어내는 걸 보고 나서, 문득 이런 생각이 들었다. "개발자들을 위한 나라는 정말 없는 건 아닐까?" 하지만 몇 달 후, 완전히 다른 결론에 도달했다. 이 책은 그 과정의 기록이다. AI 시대에 개발자의 가치가 사라지는 게 아니라, 모든 사람이 개발자가 되는 시대가 오고 있다는 이야기. 목차 프롤로그 개발자라는 직업의 종말과 시작 1장 10년 차 개발자가 ChatGPT에게 밀린 날 2장 AI가 5분 만에 해치운 나의 3일짜리 작업 3장 "코딩 몰라도 앱 만든다"는 거짓말과 진실 4장 카페 사장이 만든 POS 시스템 5장 AI 개발 도구 완전 정복 가이드 6장 코딩을 몰라도 되는 것 vs 반드시 알아야 하는 것 7장 일반인 개발자의 현실적 한계와 극복법 8장 전문 개발자의 생존 전략 9장 모든 직업이 개발자를 포함하는 시대 10장 사이드프로젝트의 종말, 개인프로젝트의 시작 에필로그 개발자로 살아남는다는 것의 새로운 의미 작성 시점 안내 이 책은 2025년 6월의 AI 기술 환경을 기준으로 작성되었습니다. AI의 발전 속도는 상상을 초월하기 때문에, 일부 내용은 현재 시점과 다를 수 있습니다. 당시의 기록으로 읽어주세요. 브라우저가 PDF 뷰어를 지원하지 않습니다. PDF를 다운로드하세요.
이온디
이온디 1개월 전
개요 Figma MCP (Model Context Protocol) 서버는 AI 코딩 도구(Claude Desktop, Claude Code)가 Figma 디자인 파일에 직접 접근할 수 있게 해주는 연결 도구입니다. 디자이너가 만든 디자인을 AI가 읽고, 이를 바탕으로 정확한 코드를 생성할 수 있습니다. MCP가 해결하는 문제 기존 방식 MCP 활용 디자인 스펙을 수동으로 복사 AI가 직접 디자인 파일 참조 색상값, 간격 등 일일이 확인 자동으로 디자인… 개요 Figma MCP (Model Context Protocol) 서버는 AI 코딩 도구(Claude Desktop, Claude Code)가 Figma 디자인 파일에 직접 접근할 수 있게 해주는 연결 도구입니다. 디자이너가 만든 디자인을 AI가 읽고, 이를 바탕으로 정확한 코드를 생성할 수 있습니다. MCP가 해결하는 문제 기존 방식 MCP 활용 디자인 스펙을 수동으로 복사 AI가 직접 디자인 파일 참조 색상값, 간격 등 일일이 확인 자동으로 디자인 토큰 추출 디자인 변경 시 재확인 필요 실시간으로 최신 디자인 반영 스크린샷 캡처 후 설명 노드 ID로 정확한 요소 지정 1. MCP 서버 유형 비교 Figma MCP를 사용하는 방법은 3가지가 있습니다: 항목 figma-mcp (npm) figma-desktop 원격 MCP 설치 방식 npm/npx Figma 앱 내장 불필요 인증 방식 API 토큰 자동 (앱 로그인) OAuth 속도 빠름 가장 빠름 보통 오프라인 사용 ❌ ✅ ❌ Figma 앱 필요 ❌ ✅ ❌ 토큰 관리 직접 관리 불필요 자동 권장 조합 figma-mcp + figma-desktop 동시 사용을 권장합니다: - figma-mcp: 파일 추가, 댓글 읽기/쓰기 - figma-desktop: 빠른 디자인 컨텍스트 조회, 스크린샷 # 현재 설정 확인 claude mcp list # 예상 출력: # figma: npx figma-mcp - ✓ Connected # figma-desktop: http://127.0.0.1:3845/mcp (HTTP) - ✓ Connected 2. figma-mcp 설치 (API 토큰 방식) 2.1 Figma Access Token 발급 Figma 설정 접속 Personal access tokens 섹션으로 이동 Generate new token 클릭 설정: Token name: Claude MCP (원하는 이름) Expiration: No expiration 또는 기간 설정 Scopes: ✅ File content (Read-only) - 필수 ✅ Comments (Read and write) - 선택 Generate token 클릭 생성된 토큰 복사 (figd_로 시작) ⚠️ 중요: 토큰은 생성 직후 한 번만 표시됩니다. 안전한 곳에 저장하세요! 2.2 환경 변수로 토큰 저장 (권장) # ~/.zshrc 또는 ~/.bashrc에 추가 echo 'export FIGMA_ACCESS_TOKEN="figd_your_token_here"' >> ~/.zshrc source ~/.zshrc 2.3 Claude Code에 MCP 추가 # figma-mcp 추가 claude mcp add figma -e FIGMA_ACCESS_TOKEN=figd_xxx -- npx figma-mcp 또는 환경 변수 사용 시: claude mcp add figma -e FIGMA_ACCESS_TOKEN=$FIGMA_ACCESS_TOKEN -- npx figma-mcp 2.4 연결 확인 claude mcp list # figma: npx figma-mcp - ✓ Connected 3. figma-desktop 설치 (Desktop 앱 방식) Figma Desktop 앱에 내장된 MCP 서버를 사용하는 방법입니다. 별도의 토큰 없이 Figma 앱 로그인만으로 인증됩니다. 3.1 사전 요구사항 Figma Desktop 앱 설치 (웹 버전 아님) Figma 계정 로그인 상태 3.2 MCP 서버 활성화 Figma Desktop 앱 실행 Dev Mode 활성화: Shift + D 또는 우측 상단 토글 Inspect 패널 열기 (우측 사이드바) MCP server 섹션 찾기 Enable desktop MCP server 클릭 3.3 Claude Code에 추가 # HTTP 전송 방식으로 추가 claude mcp add --transport http figma-desktop http://127.0.0.1:3845/mcp 3.4 연결 확인 claude mcp list # figma-desktop: http://127.0.0.1:3845/mcp (HTTP) - ✓ Connected 3.5 figma-desktop의 장점 장점 설명 토큰 불필요 Figma 앱 로그인으로 자동 인증 가장 빠른 속도 로컬 앱에서 직접 데이터 제공 오프라인 지원 캐시된 파일은 오프라인에서도 접근 가능 실시간 동기화 현재 열린 파일의 최신 상태 반영 추가 도구 get_design_context, get_screenshot 등 제공 3.6 figma-desktop 전용 도구 도구 설명 get_design_context 선택된 노드의 디자인 정보 (색상, 폰트, 간격 등) get_screenshot 노드의 스크린샷 이미지 생성 get_metadata 파일/노드의 메타데이터 조회 get_variable_defs 디자인 변수(토큰) 정의 조회 4. 실전 활용법 4.1 Figma 파일 분석하기 기본 요청: 이 Figma 파일을 분석해줘: https://www.figma.com/design/ABC123/My-Design 상세 요청 (권장): 이 Figma 디자인의 Header 섹션(node-id: 41-3)을 분석해줘: https://www.figma.com/design/ABC123/My-Design?node-id=41-3 분석 내용: - 사용된 색상 팔레트 - 컴포넌트 구조 - 반응형 브레이크포인트 4.2 node-id 활용하기 Figma URL에서 node-id를 추출하는 방법: URL: https://www.figma.com/design/ABC123/Design?node-id=41-3 ^^^^ 이 부분이 node-id Claude에서 사용 시: "41-3" 또는 "41:3" 형태 모두 가능 특정 노드만 조회: node-id 41-3의 디자인 정보를 가져와줘 4.3 컴포넌트 → 코드 변환 HTML/CSS 변환: 이 Figma 프레임(node-id: 41-3)을 HTML로 구현해줘: 요구사항: - Tailwind CSS 사용 - 반응형 지원 (모바일/데스크톱) - 시맨틱 HTML 구조 React 컴포넌트: 이 버튼 컴포넌트(node-id: 52-10)를 React + TypeScript로 만들어줘: 요구사항: - Tailwind CSS 스타일링 - 호버/포커스 상태 구현 - 접근성 고려 (ARIA) 4.4 디자인 토큰 추출 이 Figma 파일에서 디자인 시스템 토큰을 추출해줘: 필요한 정보: - Colors (primary, secondary, gray scale) - Typography (font family, sizes, weights) - Spacing (padding, margin 기준) - Border radius - Shadow 출력 형식: CSS 변수 또는 Tailwind config 4.5 디자인 비교 및 검증 현재 구현된 코드와 Figma 디자인(node-id: 41-3)을 비교해줘: 확인 사항: - 색상 일치 여부 - 간격/여백 정확도 - 폰트 스타일 일치 - 누락된 요소 5. 대용량 파일 처리 5.1 발생 가능한 문제 ⚠ Large MCP response (~8.1m tokens), this can fill up context quickly API Error: 413 {"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}} 5.2 해결 방법 방법 1: 특정 노드만 조회 전체 파일 대신 Header(41-3)만 분석해줘 방법 2: 메타데이터 먼저 확인 이 파일의 페이지 구조와 프레임 목록만 보여줘 방법 3: 단계별 접근 1단계: 페이지 1의 구조 파악 2단계: 각 섹션별 상세 분석 3단계: 필요한 컴포넌트만 코드 변환 방법 4: figma-desktop의 get_screenshot 활용 이 노드의 스크린샷을 보여주고, 이미지 기반으로 분석해줘 6. 효율적인 프롬프트 작성 좋은 예 vs 나쁜 예 나쁜 예 ❌ 좋은 예 ✅ "이 피그마 보고 코드 만들어줘" "Header 섹션(41-3)을 Tailwind로 구현해줘" "전체 파일 분석해줘" "메인 페이지의 컴포넌트 구조를 파악해줘" "디자인이랑 똑같이 만들어줘" "색상, 간격, 폰트를 디자인과 동일하게 맞춰줘" "다 만들어줘" "버튼 → 카드 → 네비게이션 순으로 진행해줘" 컨텍스트 제공 팁 이 프로젝트의 기술 스택: - Next.js 14 (App Router) - TypeScript - Tailwind CSS - Rhymix CMS 연동 이 Figma 프레임(41-3)을 위 스택에 맞게 구현해줘. 7. 문제 해결 7.1 연결 확인 # MCP 서버 상태 확인 claude mcp list # figma-desktop 연결 테스트 curl http://127.0.0.1:3845/mcp 2>/dev/null && echo "Connected" || echo "Not connected" 7.2 일반적인 문제와 해결 "Server disconnected" 오류 원인: 서버가 예기치 않게 종료됨 해결: # Node.js 버전 확인 (v16 이상 필요) node --version # npx 캐시 정리 후 재시도 npx clear-npx-cache claude mcp remove figma claude mcp add figma -e FIGMA_ACCESS_TOKEN=figd_xxx -- npx figma-mcp figma-desktop 연결 실패 원인: Figma Desktop 앱이 실행 중이지 않거나 MCP 서버가 비활성화됨 해결: 1. Figma Desktop 앱 실행 확인 2. Dev Mode 활성화 (Shift+D) 3. MCP server 토글 확인 4. 앱 재시작 "FIGMA_ACCESS_TOKEN" 오류 원인: 토큰이 없거나 잘못됨 해결: # 환경 변수 확인 echo $FIGMA_ACCESS_TOKEN # 토큰 재설정 claude mcp remove figma claude mcp add figma -e FIGMA_ACCESS_TOKEN=figd_new_token -- npx figma-mcp 7.3 로그 확인 # macOS Claude 로그 tail -f ~/Library/Logs/Claude/mcp*.log | grep -i figma 8. 보안 권장사항 API 토큰 관리 DO ✅ DON'T ❌ 환경 변수로 토큰 관리 코드에 토큰 하드코딩 .env 파일 사용 Git에 토큰 커밋 정기적으로 토큰 갱신 팀원과 토큰 공유 필요한 최소 권한만 부여 모든 권한 허용 .gitignore 설정 # MCP 설정 파일 (토큰 포함 시) .mcp.json .env .env.local # Claude 설정 .claude.json figma-desktop 사용 시 장점 figma-desktop은 토큰 관리가 불필요하므로: - 토큰 유출 위험 없음 - 팀원별 개인 Figma 계정으로 인증 - 권한은 Figma 팀 설정에 따름 9. 워크플로우 예시 신규 컴포넌트 구현 워크플로우 1. 디자인 파일 분석 "이 Figma 파일의 컴포넌트 구조를 분석해줘" 2. 디자인 토큰 추출 "사용된 색상과 폰트를 CSS 변수로 만들어줘" 3. 기본 컴포넌트부터 구현 "Button 컴포넌트(52-10)를 먼저 구현해줘" 4. 복합 컴포넌트 구현 "Card 컴포넌트(52-20)를 Button을 활용해서 구현해줘" 5. 디자인 검증 "구현된 코드가 Figma와 일치하는지 확인해줘" 참고 자료 Figma MCP Server npm Figma MCP 공식 가이드 Model Context Protocol 문서 Claude Code MCP 설정 요약 작업 권장 MCP 명령/프롬프트 예시 파일 추가 figma add_figma_file 디자인 컨텍스트 figma-desktop get_design_context 스크린샷 figma-desktop get_screenshot 댓글 관리 figma read_comments, post_comment 변수/토큰 figma-desktop get_variable_defs 핵심: figma-mcp와 figma-desktop을 함께 사용하면 각각의 장점을 모두 활용할 수 있습니다.
이온디
이온디 1개월 전
개요 Figma MCP (Model Context Protocol) 서버는 AI 코딩 도구(Claude Desktop, Claude Code)가 Figma 디자인 파일에 직접 접근할 수 있게 해주는 연결 도구입니다. 디자이너가 만든 디자인을 AI가 읽고, 이를 바탕으로 코드를 생성할 수 있습니다. MCP가 해결하는 문제 디자인-개발 간극 해소: 피그마 디자인을 직접 참조하여 정확한 구현 수동 전달 제거: 색상값, 간격, 폰트 등을 일일이 복사할 필요 없음 실시간 동기화: 디자인 변경 시 … 개요 Figma MCP (Model Context Protocol) 서버는 AI 코딩 도구(Claude Desktop, Claude Code)가 Figma 디자인 파일에 직접 접근할 수 있게 해주는 연결 도구입니다. 디자이너가 만든 디자인을 AI가 읽고, 이를 바탕으로 코드를 생성할 수 있습니다. MCP가 해결하는 문제 디자인-개발 간극 해소: 피그마 디자인을 직접 참조하여 정확한 구현 수동 전달 제거: 색상값, 간격, 폰트 등을 일일이 복사할 필요 없음 실시간 동기화: 디자인 변경 시 AI가 최신 정보 참조 가능 협업 강화: 댓글 읽기/쓰기로 디자이너-개발자 소통 지원 1. 사전 요구사항 필수 항목 항목 요구사항 Node.js v16 이상 (권장: v18+) npm/npx Node.js와 함께 설치됨 Figma 계정 무료 계정도 가능 Figma Access Token API 접근용 토큰 Figma Access Token 발급 방법 Figma 설정 접속 Personal access tokens 섹션으로 이동 Generate new token 클릭 토큰 이름 입력 (예: Claude MCP) 권한 설정: File content: Read-only (필수) Comments: Read and write (선택) Generate token 클릭 생성된 토큰 복사 (한 번만 표시됨!) ⚠️ 주의: 토큰은 figd_로 시작합니다. 분실 시 재발급 필요. 2. Claude Desktop 앱 설정 설정 파일 위치 macOS: ~/Library/Application Support/Claude/claude_desktop_config.json Windows: %APPDATA%\Claude\claude_desktop_config.json 설정 방법 1. 설정 파일 열기: # macOS open ~/Library/Application\ Support/Claude/claude_desktop_config.json # 파일이 없으면 생성 touch ~/Library/Application\ Support/Claude/claude_desktop_config.json 2. Figma MCP 설정 추가: { "mcpServers": { "figma": { "command": "npx", "args": ["figma-mcp"], "env": { "FIGMA_ACCESS_TOKEN": "figd_your_token_here" } } } } 3. Claude Desktop 앱 재시작 여러 MCP 서버 함께 사용 { "mcpServers": { "figma": { "command": "npx", "args": ["figma-mcp"], "env": { "FIGMA_ACCESS_TOKEN": "figd_xxx" } }, "filesystem": { "command": "npx", "args": [ "@modelcontextprotocol/server-filesystem", "/Users/username/Projects/" ] }, "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "ghp_xxx" } } } } 3. Claude Code CLI 설정 방법 A: 명령어로 추가 (권장) # Figma MCP 추가 claude mcp add figma -e FIGMA_ACCESS_TOKEN=figd_xxx -- npx figma-mcp 방법 B: 글로벌 설정 파일 편집 ~/.claude.json 파일의 mcpServers 섹션에 추가: { "mcpServers": { "figma": { "type": "stdio", "command": "npx", "args": ["figma-mcp"], "env": { "FIGMA_ACCESS_TOKEN": "figd_xxx" } } } } 방법 C: 프로젝트별 설정 프로젝트 루트에 .mcp.json 파일 생성: { "mcpServers": { "figma": { "command": "npx", "args": ["figma-mcp"], "env": { "FIGMA_ACCESS_TOKEN": "${FIGMA_ACCESS_TOKEN}" } } } } 팁: 환경 변수 사용 시 .env 파일에 토큰 저장 후 .gitignore에 추가 설정 확인 # MCP 서버 목록 확인 claude mcp list # 예상 출력: # figma: npx figma-mcp - ✓ Connected 4. 원격 MCP 서버 (대안) Figma에서 공식 제공하는 원격 MCP 서버도 있습니다. 로컬 설치 없이 클라우드에서 실행됩니다. # 원격 서버 추가 claude mcp add --transport http figma https://mcp.figma.com/mcp 원격 서버 특징: - 별도 토큰 설정 불필요 (OAuth 인증 사용) - Claude Code 내에서 /mcp → 인증 진행 - Figma Desktop 앱 불필요 5. 실전: API 발급부터 파일 추가까지 Step 1: Figma Access Token 발급 1-1. Figma 설정 페이지 접속 https://www.figma.com/settings 1-2. Personal access tokens 섹션 찾기 - 페이지 아래로 스크롤 - "Personal access tokens" 섹션 확인 1-3. 토큰 생성 1. [Generate new token] 버튼 클릭 2. Token name: "Claude MCP" (또는 원하는 이름) 3. Expiration: "No expiration" 또는 기간 설정 4. Scopes 선택: ✓ File content (Read-only) - 필수 ✓ Comments (Read and write) - 선택 5. [Generate token] 클릭 6. 생성된 토큰 복사 (figd_로 시작) ⚠️ 중요: 토큰은 생성 직후 한 번만 표시됩니다. 안전한 곳에 저장하세요! 1-4. 토큰 저장 (권장 방법) # 환경 변수로 저장 (~/.zshrc 또는 ~/.bashrc) echo 'export FIGMA_ACCESS_TOKEN="figd_your_token_here"' >> ~/.zshrc source ~/.zshrc # 또는 프로젝트별 .env 파일 echo 'FIGMA_ACCESS_TOKEN=figd_your_token_here' > .env echo '.env' >> .gitignore Step 2: MCP 서버 설정 2-1. Claude Desktop 설정 # 설정 파일 열기 open ~/Library/Application\ Support/Claude/claude_desktop_config.json 2-2. Figma MCP 추가 { "mcpServers": { "figma": { "command": "npx", "args": ["figma-mcp"], "env": { "FIGMA_ACCESS_TOKEN": "figd_your_token_here" } } } } 2-3. Claude Desktop 재시작 - 완전히 종료 후 재실행 (Cmd+Q) Step 3: Figma 파일 추가 3-1. Figma 파일 URL 가져오기 1. Figma에서 파일 열기 2. 상단 URL 복사 예: https://www.figma.com/design/JFGurHe7UNMrJr3L3yXOln/DEV_KDA-공유용-?node-id=41-3 3-2. Claude에게 파일 추가 요청 이 Figma 파일을 분석해줘: https://www.figma.com/design/JFGurHe7UNMrJr3L3yXOln/DEV_KDA-공유용- 3-3. 실제 실행 결과 ⏺ figma - add_figma_file (url: "https://www.figma.com/design/...") ⎿ { "name": "DEV_KDA(공유용)", "key": "JFGurHe7UNMrJr3L3yXOln", ... } ⎿ Here is the thumbnail of the Figma file ⎿ [이미지 표시] ⎿ Here is the JSON representation of the Figma file Step 4: 대용량 파일 처리 시 주의사항 발생 가능한 문제 문제 1: 응답 크기 초과 ⚠ Large MCP response (~8.1m tokens), this can fill up context quickly API Error: 413 {"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}} 원인: Figma 파일이 너무 커서 API 제한 초과 해결 방법 방법 1: 특정 노드만 조회 node-id를 사용해서 특정 프레임만 가져와줘: https://www.figma.com/design/JFGurHe7UNMrJr3L3yXOln/...?node-id=41-3 또는: figma:view_node를 사용해서 41-3 노드의 썸네일만 보여줘 방법 2: 페이지별로 분리 Figma 파일에서: 1. 각 페이지를 별도 파일로 복제 2. 작은 단위로 나눠서 분석 요청 방법 3: 로컬에서 구조 파악 후 요청 Figma에서 좌측 Layers 패널 구조를 복사해서: 파일 구조: - Page 1: 메인 페이지 - Header (41-3) - Hero Section (41-4) - Features (41-5) - Page 2: 서브 페이지 ... 이 구조에서 Header (41-3)만 HTML로 변환해줘 방법 4: 디자인 시스템만 추출 전체 파일 말고, 이 파일의 디자인 토큰만 추출해줘: - Colors - Typography - Spacing - Components 리스트 Step 5: HTML 작업 볼륨 산정 5-1. Figma에서 수동 확인 1. Layers 패널에서 전체 구조 확인 2. 페이지 수 / 프레임 수 카운트 3. 컴포넌트 복잡도 체크 5-2. Claude에게 구조 분석 요청 이 Figma 파일의 작업 볼륨을 산정하기 위해: 1. 전체 페이지/섹션 구조 2. 각 섹션별 컴포넌트 수 3. 컴포넌트 복잡도 (단순/중간/복잡) 4. 예상 HTML 작업 시간 위 정보를 정리해줘 5-3. 작업량 산정 기준 단순 컴포넌트 (30분): - 버튼, 텍스트, 아이콘 - 단순 카드 중간 컴포넌트 (1-2시간): - 폼 요소 - 네비게이션 - 카드 리스트 복잡한 컴포넌트 (3-5시간): - 인터랙티브 요소 - 애니메이션 - 복잡한 레이아웃 실전 팁 대용량 파일 다루기 # 1. 먼저 파일 메타데이터만 확인 "이 Figma 파일의 기본 정보만 보여줘 (페이지 이름, 프레임 수)" # 2. 특정 노드만 조회 "node-id 41-3의 상세 정보를 보여줘" # 3. 단계별 접근 "첫 번째 페이지만 먼저 분석해줘" 효율적인 분석 요청 ❌ 나쁜 예: "전체 파일 분석해줘" ✅ 좋은 예: "Header 섹션(41-3)의 HTML 구조와 스타일을 추출해줘" ❌ 나쁜 예: "다 만들어줘" ✅ 좋은 예: "버튼 컴포넌트부터 시작해서, 디자인 시스템 순으로 진행해줘" 6. Figma MCP 사용 방법 제공되는 도구(Tools) 도구 설명 사용 예 add_figma_file Figma 파일을 컨텍스트에 추가 파일 URL 전달 view_node 특정 노드의 썸네일 가져오기 frame/layer 상세 보기 read_comments 파일의 댓글 읽기 피드백 확인 post_comment 댓글 작성 구현 관련 질문 reply_to_comment 댓글에 답글 대화 이어가기 실제 사용 예시 1. Figma 파일 분석 요청: 이 Figma 디자인을 분석해줘: https://www.figma.com/file/ABC123/My-Design - 사용된 색상 팔레트 - 컴포넌트 구조 - 반응형 브레이크포인트 2. 특정 컴포넌트 구현 요청: 이 Figma 프레임을 React 컴포넌트로 만들어줘: https://www.figma.com/file/ABC123/My-Design?node-id=1:234 Tailwind CSS를 사용해줘. 3. 디자인 시스템 추출: 이 Figma 파일에서 디자인 토큰을 추출해서 CSS 변수로 만들어줘: https://www.figma.com/file/ABC123/Design-System 6. 문제 해결 연결 확인 # Claude Code 내에서 /mcp # 또는 터미널에서 claude mcp list 로그 확인 # macOS tail -f ~/Library/Logs/Claude/mcp-server-figma.log # 실시간 모니터링 tail -f ~/Library/Logs/Claude/mcp*.log | grep -i figma 일반적인 문제와 해결 문제 1: "Server disconnected" 오류 원인: 서버가 예기치 않게 종료됨 해결: # Node.js 버전 확인 node --version # v16 이상 필요 # npx 캐시 정리 npx clear-npx-cache # 또는 figma-mcp 직접 설치 npm install -g figma-mcp 문제 2: "FIGMA_ACCESS_TOKEN" 오류 원인: 토큰이 없거나 잘못됨 해결: # 환경 변수 확인 echo $FIGMA_ACCESS_TOKEN # 설정 파일에서 토큰 확인 cat ~/Library/Application\ Support/Claude/claude_desktop_config.json | grep FIGMA 문제 3: "Method not found" 경고 원인: MCP 프로토콜 버전 불일치 (무시해도 됨) Message from server: {"error":{"code":-32601,"message":"Method not found"}} 이 경고는 resources/list 메서드를 지원하지 않아 발생하며, 기능에는 영향 없습니다. 문제 4: Node.js 버전 충돌 (nvm 사용 시) 원인: nvm 환경에서 잘못된 Node 버전 사용 해결: # 현재 Node 버전 확인 nvm current # 적절한 버전으로 변경 nvm use 18 # 해당 버전에서 figma-mcp 설치 npm install -g figma-mcp 완전 초기화 (최후의 수단) # 기존 설정 제거 claude mcp remove figma # npx 캐시 정리 rm -rf ~/.npm/_npx # 다시 추가 claude mcp add figma -e FIGMA_ACCESS_TOKEN=figd_xxx -- npx figma-mcp # Claude Code 재시작 7. 보안 주의사항 API 토큰 관리 DO ✅ DON'T ❌ 환경 변수로 토큰 관리 코드에 토큰 하드코딩 .env 파일 사용 Git에 토큰 커밋 정기적으로 토큰 갱신 팀원과 토큰 공유 필요한 최소 권한만 부여 모든 권한 허용 .gitignore 설정 # MCP 설정 파일 (토큰 포함 시) .mcp.json .env .env.local 환경 변수 사용 권장 # ~/.zshrc 또는 ~/.bashrc에 추가 export FIGMA_ACCESS_TOKEN="figd_xxx" // .mcp.json에서 환경 변수 참조 { "mcpServers": { "figma": { "env": { "FIGMA_ACCESS_TOKEN": "${FIGMA_ACCESS_TOKEN}" } } } } 8. 활용 팁 디자인-코드 워크플로우 디자인 리뷰 단계 이 Figma 파일의 컴포넌트 구조를 분석하고, React 컴포넌트 트리 구조를 제안해줘. 스타일 추출 단계 이 디자인에서 색상, 폰트, 간격 값을 추출해서 Tailwind config 파일을 생성해줘. 컴포넌트 구현 단계 이 Button 컴포넌트(node-id: 1:234)를 디자인과 동일하게 구현해줘. 검증 단계 구현한 코드가 Figma 디자인과 일치하는지 확인해줘. 차이점이 있으면 알려줘. 효율적인 프롬프트 좋은 예: 이 Figma 프레임(https://figma.com/file/xxx?node-id=1:234)을 Next.js App Router 컴포넌트로 구현해줘. 요구사항: - TypeScript 사용 - Tailwind CSS로 스타일링 - 반응형 지원 (모바일/데스크톱) - 접근성 고려 (ARIA 라벨) 나쁜 예: 피그마 보고 코드 만들어줘 9. 대안: Figma Desktop MCP 서버 Figma Desktop 앱 내장 MCP 서버도 사용 가능합니다. 설정 방법 Figma Desktop 앱 실행 Dev Mode 활성화 (Shift+D) Inspect 패널 → MCP server 섹션 Enable desktop MCP server 클릭 서버 주소: http://127.0.0.1:3845/mcp Claude Code에 추가 claude mcp add --transport http figma-desktop http://127.0.0.1:3845/mcp 비교 항목 figma-mcp (npm) Desktop MCP Remote MCP 설치 npm/npx Figma 앱 내장 설치 불필요 인증 API 토큰 자동 (로그인) OAuth 오프라인 ❌ ✅ ❌ 속도 빠름 가장 빠름 보통 결론 Figma MCP 서버는 디자인-개발 워크플로우를 혁신적으로 개선합니다: 설정 간단: 몇 줄의 설정으로 연동 완료 실시간 접근: 최신 디자인을 AI가 직접 참조 정확한 구현: 디자인 스펙을 그대로 코드로 변환 협업 강화: 댓글을 통한 디자이너-개발자 소통 Claude Desktop과 Claude Code 모두에서 활용하여, 디자인 시안을 빠르고 정확하게 코드로 구현해보세요. 참고 자료 Figma MCP Server npm Figma MCP 공식 가이드 Model Context Protocol 문서 Claude Code MCP 설정
이온디
이온디 1개월 전
학습 목표 디자인 토큰의 개념 이해 Figma Dev Mode에서 정확한 CSS 값 추출 SCSS 변수 시스템 구축 디자인-퍼블 불일치 방지 1. 왜 디자인 토큰이 필요한가? Figma MCP의 한계 Figma MCP는 레이아웃 구조를 빠르게 가져오는 데 탁월하지만, 정밀한 수치를 정확히 추출하는 데는 한계가 있습니다. 잘 가져오는 것 못 가져오는 것 레이아웃 구조 (Flexbox, Grid) 정확한 font-size 색상 값 (HEX, RGB)… 학습 목표 디자인 토큰의 개념 이해 Figma Dev Mode에서 정확한 CSS 값 추출 SCSS 변수 시스템 구축 디자인-퍼블 불일치 방지 1. 왜 디자인 토큰이 필요한가? Figma MCP의 한계 Figma MCP는 레이아웃 구조를 빠르게 가져오는 데 탁월하지만, 정밀한 수치를 정확히 추출하는 데는 한계가 있습니다. 잘 가져오는 것 못 가져오는 것 레이아웃 구조 (Flexbox, Grid) 정확한 font-size 색상 값 (HEX, RGB) line-height, letter-spacing 대략적인 크기 margin/padding 정밀 값 요소 순서 border-radius 세부 값 실제 사례: lllayer_kda 프로젝트 프롬프트: 피씨 이 1개의 디자인을 Figma에서 구현하세요. @https://www.figma.com/design/... 결과: - ✅ 구조는 맞음 (섹션 배치, Flexbox) - ❌ 글자 크기가 다름 (48px → 40px로 잘못 해석) - ❌ 간격이 다름 (80px → 60px로 잘못 적용) - ❌ 정렬이 안 맞음 원인: AI가 디자인을 "추론"하여 값을 생성하기 때문 2. 디자인 토큰이란? 디자인 시스템의 가장 작은 단위로, 재사용 가능한 값들의 모음입니다. Typography 토큰 토큰명 설명 예시 값 font-family 폰트 종류 'Pretendard', sans-serif font-size-h1 제목1 크기 64px font-size-h2 제목2 크기 48px font-size-body 본문 크기 16px font-weight-bold 굵은 글씨 700 line-height-normal 기본 행간 1.5 letter-spacing-tight 자간 (좁음) -0.02em Spacing 토큰 토큰명 값 용도 spacing-xs 4px 아이콘-텍스트 간격 spacing-sm 8px 작은 요소 간격 spacing-md 16px 기본 패딩 spacing-lg 24px 섹션 내 간격 spacing-xl 48px 섹션 간 간격 spacing-xxl 80px 대형 섹션 간격 Color 토큰 토큰명 값 용도 color-primary #0066FF 브랜드 메인 color-secondary #6B7280 보조 색상 color-background #FFFFFF 배경 color-surface #F9FAFB 카드 배경 color-text-primary #111827 본문 텍스트 color-text-secondary #6B7280 부제목 텍스트 3. Figma에서 토큰 추출하기 방법 1: Dev Mode 활용 (가장 정확) Figma에서 요소 선택 우측 Inspect 패널 확인 CSS 값 복사 Dev Mode에서 보이는 값: font-size: 64px line-height: 76.8px (= 1.2) letter-spacing: -1.28px (= -0.02em) 방법 2: Figma Variables 활용 디자이너가 Variables로 정의한 경우: # Claude 프롬프트 이 Figma 파일의 디자인 토큰(Variables)을 추출해주세요. @https://www.figma.com/design/... 또는 MCP 도구 직접 호출: get_variable_defs nodeId="xxx" 방법 3: 디자이너에게 스펙 문서 요청 디자이너에게 다음 항목을 요청하세요: [ ] Typography 스펙 (H1~H6, Body, Caption 등) [ ] Spacing 스펙 (4px 배수 권장) [ ] Color 팔레트 [ ] Breakpoint 정의 4. SCSS 변수 시스템 구축 _variables.scss 예시 // ====================== // Typography // ====================== $font-family-primary: 'Pretendard', -apple-system, BlinkMacSystemFont, sans-serif; $font-family-secondary: 'Noto Sans KR', sans-serif; // Font Sizes $font-size-h1: 64px; $font-size-h2: 48px; $font-size-h3: 32px; $font-size-h4: 24px; $font-size-body: 16px; $font-size-caption: 14px; $font-size-small: 12px; // Font Weights $font-weight-light: 300; $font-weight-regular: 400; $font-weight-medium: 500; $font-weight-semibold: 600; $font-weight-bold: 700; // Line Heights $line-height-tight: 1.2; $line-height-snug: 1.3; $line-height-normal: 1.5; $line-height-relaxed: 1.6; $line-height-loose: 1.8; // Letter Spacing $letter-spacing-tight: -0.02em; $letter-spacing-normal: 0; $letter-spacing-wide: 0.05em; // ====================== // Spacing // ====================== $spacing-xs: 4px; $spacing-sm: 8px; $spacing-md: 16px; $spacing-lg: 24px; $spacing-xl: 48px; $spacing-xxl: 80px; $spacing-3xl: 120px; // ====================== // Breakpoints // ====================== $breakpoint-mobile: 360px; $breakpoint-tablet: 768px; $breakpoint-pc: 1920px; $breakpoint-wide: 3440px; // Inner Width (Container) $inner-width-mobile: 324px; $inner-width-tablet: 688px; $inner-width-pc: 1720px; $inner-width-wide: 2590px; // ====================== // Colors // ====================== $color-primary: #0066FF; $color-primary-dark: #0052CC; $color-secondary: #6B7280; $color-background: #FFFFFF; $color-surface: #F9FAFB; $color-border: #E5E7EB; $color-text-primary: #111827; $color-text-secondary: #6B7280; $color-text-muted: #9CA3AF; // ====================== // 반응형 Mixin // ====================== @mixin mobile { @media (max-width: #{$breakpoint-tablet - 1px}) { @content; } } @mixin tablet { @media (min-width: $breakpoint-tablet) and (max-width: #{$breakpoint-pc - 1px}) { @content; } } @mixin pc { @media (min-width: $breakpoint-pc) { @content; } } @mixin wide { @media (min-width: $breakpoint-wide) { @content; } } 변수 사용 예시 // _typography.scss .page-title { font-family: $font-family-primary; font-size: $font-size-h1; font-weight: $font-weight-bold; line-height: $line-height-tight; letter-spacing: $letter-spacing-tight; color: $color-text-primary; @include tablet { font-size: $font-size-h2; } @include mobile { font-size: $font-size-h3; } } .section { padding: $spacing-xxl 0; @include mobile { padding: $spacing-xl 0; } } .container { max-width: $inner-width-pc; margin: 0 auto; padding: 0 $spacing-lg; @include wide { max-width: $inner-width-wide; } @include tablet { max-width: $inner-width-tablet; } @include mobile { max-width: $inner-width-mobile; padding: 0 $spacing-md; } } 5. 워크플로우 개선 Before (기존 방식) 1. Figma URL 제공 ↓ 2. "이 디자인을 구현하세요" ↓ 3. 결과 확인 → 수치가 안 맞음 ↓ 4. "글자 크기 48px로 수정해주세요" ↓ 5. "간격 80px로 수정해주세요" ↓ 6. 반복... (비효율) After (토큰 기반 방식) 1. 프로젝트 시작 시 디자인 토큰 정의 ↓ 2. _variables.scss 파일 생성 ↓ 3. Figma URL + 토큰 명시 프롬프트 ↓ 4. 결과물 일관성 확보 (수정 최소화) 6. 실습: lllayer_kda 토큰 정의 Step 1: Figma에서 값 추출 피그마 Dev Mode에서 확인할 항목: Typography - [ ] Title (H1~H4) 폰트 사이즈 - [ ] Body text 폰트 사이즈 - [ ] Caption 폰트 사이즈 - [ ] Font weight 종류 - [ ] Line height 값 Spacing - [ ] 섹션 간 간격 - [ ] 컨테이너 패딩 - [ ] 요소 간 간격 Layout - [ ] Container max-width (브레이크포인트별) - [ ] Grid gap Step 2: _variables.scss 작성 파일 위치: layouts/lllayer_kda/assets/css/_variables.scss // lllayer_kda 디자인 토큰 // Figma Dev Mode에서 추출한 정확한 값 // Typography $font-size-hero: 120px; // 메인 타이틀 $font-size-h1: 64px; $font-size-h2: 48px; $font-size-h3: 32px; $font-size-h4: 24px; $font-size-body: 18px; $font-size-caption: 14px; // ... (프로젝트에 맞게 커스터마이즈) Step 3: 기존 SCSS에 변수 적용 Before (하드코딩) .hero-title { font-size: 64px; line-height: 1.2; margin-bottom: 48px; } After (변수 사용) .hero-title { font-size: $font-size-h1; line-height: $line-height-tight; margin-bottom: $spacing-xl; } Step 4: 검증 브라우저 개발자 도구 열기 Computed 탭에서 실제 적용된 값 확인 Figma Dev Mode의 값과 1:1 비교 7. 프롬프트 템플릿 (토큰 기반) 기본 템플릿 작업경로: layouts/lllayer_kda/assets/pages/_about_english.html SCSS경로: layouts/lllayer_kda/assets/css/components/_about.scss ## 디자인 토큰 (반드시 준수) ### Typography | 요소 | font-size | line-height | font-weight | |-----|-----------|-------------|-------------| | H1 | 64px | 1.2 | 700 | | H2 | 48px | 1.3 | 600 | | H3 | 32px | 1.4 | 600 | | Body | 18px | 1.6 | 400 | | Caption | 14px | 1.5 | 400 | ### Spacing | 용도 | 값 | |-----|-----| | Section gap | 80px | | Inner gap | 48px | | Container padding | 24px | ### Breakpoints | 디바이스 | 뷰포트 | Inner width | |---------|--------|-------------| | Wide | 3440px | 2590px | | PC | 1920px | 1720px | | Tablet | 768px | 688px | | Mobile | 360px | 324px | 피씨 이 1개의 디자인을 Figma에서 구현하세요. **위 토큰 값을 정확히 적용해주세요.** @https://www.figma.com/design/...?node-id=XXX 상세 템플릿 (복잡한 페이지용) 작업경로: layouts/lllayer_kda/assets/pages/_service.html ## 디자인 토큰 ### Hero Section - 타이틀: 120px / 1.0 / 700 / letter-spacing: -0.03em - 서브타이틀: 24px / 1.5 / 400 - Section padding: 160px 0 ### Content Section - Section title: 48px / 1.3 / 600 - Body text: 18px / 1.8 / 400 - Card gap: 24px - Section gap: 120px ### SCSS 변수 파일 layouts/lllayer_kda/assets/css/_variables.scss 파일의 변수를 사용하세요. 하드코딩된 px 값 대신 $font-size-*, $spacing-* 변수를 사용해주세요. 피씨 이 1개의 디자인을 Figma에서 구현하세요. @https://www.figma.com/design/...?node-id=XXX [PC 스크린샷 첨부] 8. 핵심 포인트 요약 토큰 먼저, 퍼블 나중 프로젝트 시작 시 디자인 토큰부터 정의하세요. 디자이너와 협업 Typography/Spacing 스펙을 요청하세요. 디자이너가 Figma Variables를 사용하면 더 좋습니다. SCSS 변수 필수 하드코딩 금지. 모든 값은 변수로 관리하세요. 프롬프트에 토큰 명시 AI가 정확한 값을 적용하도록 명시하세요. 체크리스트 프로젝트 시작 시: - [ ] 디자이너에게 Typography 스펙 받기 - [ ] Spacing 시스템 정의 (4px 배수 권장) - [ ] Color 팔레트 정리 - [ ] Breakpoint 정의 파일 생성: - [ ] _variables.scss 파일 생성 - [ ] style.scss에 @import '_variables' 추가 작업 시: - [ ] 하드코딩 대신 변수 사용 - [ ] 프롬프트에 토큰 표 명시 검증: - [ ] 브라우저에서 Computed 값 확인 - [ ] Figma Dev Mode와 1:1 비교 - [ ] 반응형 브레이크포인트별 검증 참고 자료 Figma Dev Mode 가이드 Figma Variables 사용법 Design Tokens 커뮤니티 그룹
이온디
이온디 1개월 전
템플릿 1: 기본 페이지 퍼블리싱 작업경로: layouts/lllayer_kda/assets/pages/_about_english.html 레이아웃에 SCSS 작성, HTML은 구조만 반응형: PC → Mobile 축소 푸터는 레이아웃 담당, 컨텐츠 영역만 작업 피씨 이 1개의 디자인을 Figma에서 구현하세요. @https://www.figma.com/design/...?node-id=XXX 태블릿 이 1개의 디자인을 Figma에서 구현하세요. @https://www.figma.com/d… 템플릿 1: 기본 페이지 퍼블리싱 작업경로: layouts/lllayer_kda/assets/pages/_about_english.html 레이아웃에 SCSS 작성, HTML은 구조만 반응형: PC → Mobile 축소 푸터는 레이아웃 담당, 컨텐츠 영역만 작업 피씨 이 1개의 디자인을 Figma에서 구현하세요. @https://www.figma.com/design/...?node-id=XXX 태블릿 이 1개의 디자인을 Figma에서 구현하세요. @https://www.figma.com/design/...?node-id=YYY 모바일 이 1개의 디자인을 Figma에서 구현하세요. @https://www.figma.com/design/...?node-id=ZZZ [스크린샷 첨부] 템플릿 2: 모듈 스킨 퍼블리싱 작업경로: modules/member/skins/lllayer_kda_member gnb, footer는 레이아웃 담당 SCSS는 layouts/lllayer_kda/assets/css/components/_member.scss HTML은 위 모듈 경로에 작성 반응형: PC우선 → 축소 로그인 폼만 작업 (GNB/Footer 제외) 피씨 이 1개의 디자인을 Figma에서 구현하세요. @https://www.figma.com/design/...?node-id=XXX 템플릿 3: 애니메이션 요청 이 1개의 디자인을 Figma에서 코멘트를 보고 애니메이션을 구현하세요. layouts/lllayer_kda/assets/pages/_service.html 파일에 적용 @https://www.figma.com/design/...?node-id=XXX 애니메이션 요구사항: - THE WORLD'S BEST: 페이드업 모션 - 이미지 영역: 마퀴 모션 (호버 시 스탑 없음) - Vision Beyond: 페이드업 모션 템플릿 4: 다국어 번역 layouts/lllayer_kda/assets/pages/_about_korea.html 위 파일의 영문 버전을 아래 피그마를 보고 한글 버전으로 텍스트를 수정해주세요. @https://www.figma.com/design/...?node-id=XXX 템플릿 5: 반응형 3단계 작업 작업경로: layouts/xxx/assets/pages/_page.html 이 페이지의 반응형을 구현해주세요. ## PC (1440px 이상) @https://www.figma.com/design/...?node-id=PC_NODE ## Tablet (768px ~ 1439px) @https://www.figma.com/design/...?node-id=TABLET_NODE ## Mobile (767px 이하) @https://www.figma.com/design/...?node-id=MOBILE_NODE 스타일 분리: - 공통 스타일: _page.scss - 반응형: @media queries 사용 템플릿 6: 토큰 기반 정밀 퍼블리싱 작업경로: layouts/xxx/assets/pages/_page.html SCSS경로: layouts/xxx/assets/css/components/_page.scss ## 디자인 토큰 (반드시 준수) ### Typography | 요소 | font-size | line-height | font-weight | |-----|-----------|-------------|-------------| | H1 | 64px | 1.2 | 700 | | H2 | 48px | 1.3 | 600 | | H3 | 32px | 1.4 | 600 | | Body | 18px | 1.6 | 400 | | Caption | 14px | 1.5 | 400 | ### Spacing | 용도 | 값 | |-----|-----| | Section gap | 80px | | Inner gap | 48px | | Container padding | 24px (mobile: 16px) | ### Breakpoints | 디바이스 | 뷰포트 | Inner width | |---------|--------|-------------| | Wide | 3440px | 2590px | | PC | 1920px | 1720px | | Tablet | 768px | 688px | | Mobile | 360px | 324px | 피씨 이 1개의 디자인을 Figma에서 구현하세요. **위 토큰 값을 정확히 적용해주세요.** @https://www.figma.com/design/...?node-id=XXX [스크린샷 첨부] 사용 시점: Figma MCP가 수치를 정확히 가져오지 못해 글자 크기, 간격이 틀어지는 경우 핵심 포인트: - 프롬프트에 토큰 표를 명시하면 AI가 정확한 값을 적용 - SCSS 변수 시스템과 함께 사용하면 일관성 향상 - Figma Dev Mode에서 추출한 정확한 값 사용 프롬프트 작성 팁 ✅ 좋은 프롬프트의 구성요소 작업 경로 명시 HTML 파일 경로 SCSS 파일 경로 역할 분리 명시 GNB/Footer는 누가 담당하는지 레이아웃 vs 모듈 구분 반응형 전략 PC 우선 → 축소 또는 모바일 퍼스트 Figma URL 제공 각 브레이크포인트별 node-id 스크린샷 첨부 URL만으로 부족할 때 필수 ❌ 나쁜 프롬프트 vs ✅ 좋은 프롬프트 나쁜 예 좋은 예 "피그마 보고 코드 만들어줘" "Header 섹션(41-3)을 Tailwind로 구현해줘" "전체 파일 분석해줘" "메인 페이지의 컴포넌트 구조를 파악해줘" "디자인이랑 똑같이 만들어줘" "색상, 간격, 폰트를 디자인과 동일하게 맞춰줘" "다 만들어줘" "버튼 → 카드 → 네비게이션 순으로 진행해줘"
이온디
이온디 1개월 전
실제 버그, 이슈, 해결 과정 정리 작성일: 2025년 12월 4일 주제: 6개월 Alpine.js 개발에서 만난 문제들과 React 프로젝트와의 실제 비교 대상: 기술 의사결정자, 개발팀 목차 Alpine.js 작업 과정 만난 문제들과 해결 React 프로젝트의 현황 실제 비교 분석 결론 ️ Alpine.js 작업 과정 Phase 1: HTMX에서 Alpine.js로 마이그레이션 (2개월) 목표 HTMX 완전 제거 Alpine.js로 상태 관리 전환 기존 페이지 호환… 실제 버그, 이슈, 해결 과정 정리 작성일: 2025년 12월 4일 주제: 6개월 Alpine.js 개발에서 만난 문제들과 React 프로젝트와의 실제 비교 대상: 기술 의사결정자, 개발팀 목차 Alpine.js 작업 과정 만난 문제들과 해결 React 프로젝트의 현황 실제 비교 분석 결론 ️ Alpine.js 작업 과정 Phase 1: HTMX에서 Alpine.js로 마이그레이션 (2개월) 목표 HTMX 완전 제거 Alpine.js로 상태 관리 전환 기존 페이지 호환성 유지 구현 내용 1단계: 기본 컴포넌트 작성 // /layouts/el_d1/src/js/components/board-list.js export function boardList(config = {}) { return { // 상태 items: [], loading: false, currentPage: 1, searchKeyword: '', // 메서드 async init() { ... }, async loadItems(append = false) { ... }, async applyFilters() { ... } } } 작업량: 약 300줄 시간: 약 8시간 문제: 초기 설계부터 많은 고민 Phase 2: 각 페이지에 적용 (3개월) 적용된 페이지 ✅ QNA (커뮤니티) - qna.blade.php ✅ Expert (전문가) - expert.blade.php ✅ Guide (가이드) - guide.blade.php ✅ Homepage Solution (플랫폼) - homepage_solution.blade.php ✅ MyPage (마이페이지) - mypage.blade.php 각 페이지별 이슈 QNA 페이지 (가장 복잡) 구현 내용: - 게시글 목록 (무한 스크롤) - 검색/필터/정렬 - 페이지네이션 - 사이드바 (인기글) 코드 규모: 약 500줄 시간: 약 15시간 만난 문제들과 해결 Issue #1: 검색 필터 동기화 문제 (가장 심각) 증상 URL이 변경되지만 리스트가 업데이트 안 됨 /qna?search_keyword=blade&search_target=title_content → 리스트: 변화 없음 ❌ 원인 분석 과정 Step 1: 현상 파악 // qna.blade.php에서 @php $initial_documents = [...] // PHP에서 미리 로드 @endphp <div x-data="boardList({ initialData: @json($initial_documents) })"> <template x-for="item in items">...</template> </div> Step 2: 문제 발견 // board-list.js의 init() 함수 async init() { if (config.initialData && config.initialData.length > 0) { // ❌ 여기서 return하므로 loadItems() 호출 안 됨! return; } await this.loadItems(); } Step 3: 근본 원인 - PHP @foreach()로 렌더링된 데이터 존재 - Alpine.js는 이 데이터를 사용하려고 함 - 검색 URL 파라미터가 있어도 처리 안 됨 - <template x-for>와 PHP @foreach 충돌 Step 4: 해결책 개발 (3가지 제시) 방법 1: PHP 렌더링 제거 (권장) async init() { const hasSearchParams = !!(urlSearchKeyword || urlCategory); if (config.initialData && !hasSearchParams) { // URL 파라미터 없을 때만 초기 데이터 사용 return; } // 파라미터 있으면 항상 API 호출 await this.loadItems(); } 방법 2: 조건부 렌더링 @if(!$search_keyword && !$selected_category) @foreach($initial_documents as $doc) <!-- PHP 렌더링 --> @endforeach @else <!-- Alpine.js 렌더링 --> <template x-for="item in items">...</template> @endif 방법 3: API 항상 호출 // 초기 데이터 무시, 항상 API에서 로드 async init() { this.searchKeyword = getUrlParam('search_keyword'); await this.loadItems(); } 결과: 3가지 문서화, 개발자 선택 가능 해결 시간: 약 6시간 재발생: 이후 비슷한 구조의 페이지에서 반복 Issue #2: 콘솔 에러 (preload 속성 누락) 증상 ❌ <link rel=preload> must have a valid `as` value 원인 <!-- layout.html line 86 --> <load target="./assets/css/d1.bundle.css" /> <!-- XE 엔진이 생성하는 HTML --> <link rel="preload" href="...css"> <!-- as 속성 없음! --> 해결 <!-- 직접 링크 태그 사용 --> <link rel="stylesheet" href="/layouts/el_d1/assets/css/d1.bundle.css?v={{ $asset_version }}"> 해결 시간: 약 1시간 영향도: 중간 (사용자 경험 영향 없음, 개발 환경 불편) Issue #3: 403 Permission 에러 (브라우저 확장) 증상 ❌ Uncaught (in promise) {code: 403, msg: 'permission error'} 경로: /writing/get_template_list, /site_integration/template_list 원인 분석 브라우저 확장 프로그램이 요청 차단 Figma 플러그인, Wave 접근성 도구 등 프로덕션에서는 발생하지 않음 해결 // 에러 처리로 무시 try { const response = await fetch(apiUrl); const data = await response.json(); } catch (err) { console.warn('API 호출 실패 (개발 환경):', err); // 무시 } 해결 시간: 약 0.5시간 영향도: 낮음 (개발 환경만) Issue #4: 초기 데이터 vs API 데이터 불일치 증상 1. 페이지 로드 시: PHP에서 로드한 데이터 표시 2. 사용자가 필터 변경: API에서 로드한 다른 데이터 3. 데이터 구조 미묘한 차이로 인해 렌더링 오류 원인 // API 응답 { "documents": [ "document_srl": 123, "title": "...", "content": "...", "nick_name": "..." ] } // Blade 객체 $doc->document_srl $doc->getTitle() $doc->getNickName() // 구조 다름! 해결 // API 응답을 Blade 객체 형식으로 변환 foreach ($documents as $doc) { $document = new Document(); $document->document_srl = $doc->document_srl; // ... 변환 로직 } 해결 시간: 약 2시간 재발생: 매번 새 페이지 추가할 때마다 발생 Issue #5: 상태 동기화 문제 증상 사용자 행동: 1. 검색어 입력 2. 엔터 누름 3. URL 변경 4. 뒤로가기 결과: - 검색어 초기화됨 - 페이지 상태 불일치 원인 // localStorage에 저장했는데 localStorage.setItem('qna_search', 'blade'); // 브라우저 뒤로가기 시 // loadItems()가 호출되지 않음 해결 window.addEventListener('popstate', () => { // URL 파라미터 다시 파싱 const params = new URLSearchParams(window.location.search); this.searchKeyword = params.get('search_keyword') || ''; this.loadItems(); }); 해결 시간: 약 3시간 현재 상태: 부분 해결 (완벽하지 않음) Issue #6: 렌더링 성능 문제 증상 리스트에 500개 이상 아이템 추가 시 - UI 반응 느려짐 - 스크롤 버벅거림 원인 // <template x-for>는 모든 아이템을 DOM에 추가 // 가상 스크롤링 없음 items.length = 500 // → 500개 DOM 노드 생성 해결 // 무한 스크롤 적용 async loadMore() { if (!this.hasMore) return; this.currentPage++; await this.loadItems(true); // append=true } // 또는 라이브러리 도입 // tanstack/react-virtual 같은 것 (React에서는 가능) 해결 시간: 약 4시간 현재 상태: 무한 스크롤로 부분 해결 Issue #7: 타입 안전성 부족 증상 // 이런 실수가 발생 item.tilte // ← 오타 (title) item.nick_Name // ← 케이스 실수 item.content.substring() // ← content가 null일 수 있음 원인 JavaScript는 동적 타입 IDE 자동완성 불충분 런타임 에러 발생 해결 (하지만 Alpine.js는 TypeScript 미지원) // React에서는 이렇게 가능 interface Document { document_srl: number; title: string; content: string; nick_name: string; } const item: Document = { document_srl: 123, title: '테스트', // ... 타입 체크! } 해결 시간: N/A (Alpine.js에서 불가능) 영향도: 중간 (런타임 에러 증가) Issue #8: 상태 관리 복잡도 증상 // 여러 상태가 얽혀 있음 x-data="Object.assign( boardList({...}), { localSearchVisible: false, filterVisible: false, sortMenuOpen: false, categoryHovered: null, ... } )" 문제점 상태 간 의존성 불명확 업데이트 로직 분산 리팩토링 어려움 React에서는 // 명확한 상태 관리 const [searchKeyword, setSearchKeyword] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [items, setItems] = useState([]); // 각 상태의 역할이 명확 영향도: 높음 (프로젝트 규모 증가할수록 악화) Alpine.js 작업 통계 개발 시간 분석 Phase 1: HTMX → Alpine.js 마이그레이션 - 기본 컴포넌트: 8시간 - 문서 작성: 4시간 └─ 소계: 12시간 Phase 2: 페이지별 적용 - QNA: 15시간 - Expert: 12시간 - Guide: 8시간 - Homepage Solution: 10시간 - MyPage: 6시간 └─ 소계: 51시간 Phase 3: 버그 수정 - Issue #1 (검색 필터): 6시간 - Issue #2 (preload): 1시간 - Issue #3 (403 에러): 0.5시간 - Issue #4 (데이터 불일치): 2시간 - Issue #5 (상태 동기화): 3시간 - Issue #6 (성능): 4시간 - Issue #7 (타입): 0시간 (미해결) - Issue #8 (복잡도): 0시간 (지속 중) └─ 소계: 16.5시간 전체: 79.5시간 ≈ 80시간 (약 10일) 문제 발생 빈도 개발 중 버그: 8개 재발생: 4개 (Issue #1, #4, #5, #8) 해결율: 50% (완전 해결) React 프로젝트의 현황 프로젝트 구조 /layouts/el_imin_react/ ├── src/ │ ├── components/ │ │ ├── Header.tsx │ │ ├── Navigation.tsx │ │ ├── Footer.tsx │ │ └── ... │ ├── pages/ │ │ ├── Board/ │ │ └── ... │ ├── contexts/ │ │ └── RhymixContext.tsx │ ├── App.tsx │ └── index.tsx ├── assets/ ├── layout.html └── conf/ /modules/board/skins/eb_imin_react/ ├── src/ │ ├── components/ │ └── ... └── ... 구현 상태 Header Component: ✅ 기본 완성 Navigation: ✅ 기본 완성 Footer: ✅ 기본 완성 RhymixContext: ✅ 기본 구조 Board Pages: ⏳ 진행 중 React 도입 배경 Timeline: 2024년 초: el_imin_react 프로젝트 시작 2024년 중: TypeScript + React 기본 구조 구축 2024년 말: 컴포넌트 분리 및 상태 관리 개선 2025년 현재: Alpine.js와 React 병행 실제 비교 분석 개발 속도 비교 Alpine.js로 검색 필터 페이지 만들기 작업: 1. HTML 마크업: 1시간 2. Alpine.js 상태: 1시간 3. API 연동: 1시간 4. 버그 수정: 2-3시간 ← 예상 밖 5. 스타일: 1시간 총: 6-7시간 버그로 인한 지연: 흔함 React로 같은 페이지 만들기 작업: 1. 컴포넌트 설계: 1시간 2. 상태 관리 (Zustand): 1시간 3. API 연동 (axios): 0.5시간 4. 테스트 작성: 0.5시간 5. 스타일: 1시간 총: 4시간 버그 가능성: 낮음 결론: React가 처음엔 복잡하지만, 규모 커질수록 더 빠름 유지보수 비용 Alpine.js 3개월 후 현황: - 버그 보고: 월 2-3개 - 수정 시간: 각각 2-3시간 - 코드 이해도: 작성자만 알겠음 예상 월 비용: 10-15시간 React 3개월 후 현황: - 버그 보고: 월 0-1개 - 수정 시간: 0.5-1시간 - 코드 이해도: 팀 전체가 이해 예상 월 비용: 2-3시간 확장성 비교 새 기능 추가: 실시간 협업 댓글 Alpine.js // WebSocket 연동 필요 // 상태 관리 복잡 // 타입 안전성 없음 // 예상 개발 시간: 20시간 // 버그 가능성: 높음 // 현재: 미구현 React // 상태 관리 명확 // 타입 안전성 있음 // 테스트 가능 // 예상 개발 시간: 15시간 // 버그 가능성: 낮음 // 현재: 기본 구조 준비 중 성능 비교 번들 크기 Alpine.js: - Alpine.js 라이브러리: 15KB - 컴포넌트 코드: ~30KB (모든 페이지) └─ 총: ~45KB ✅ React (el_imin_react): - React + ReactDOM: 43KB - 컴포넌트 코드: ~50KB - State Management (Zustand): 3KB └─ 총: ~96KB ⚠️ 차이: React가 2배 크지만, 현대 브라우저에선 무시할 수준 초기 로딩 속도 Alpine.js: - HTML 파싱: 100ms - Alpine 초기화: 50ms - 데이터 렌더링: 150ms └─ 총: 300ms ✅ React (SSR 없을 때): - HTML 파싱: 100ms - React 번들 로드: 200ms - 컴포넌트 렌더링: 300ms └─ 총: 600ms ⚠️ React (SSR 있을 때): - PHP SSR: 200ms - HTML 전송: 100ms - React 하이드레이션: 100ms └─ 총: 400ms (향상) 결론: React + SSR이 더 나음 개발자 경험 비교 디버깅 Alpine.js // 문제 발생 // Vue DevTools로는 안 봄 // console.log로만 추적 // 상태 변화 추적 어려움 React // React DevTools로 모든 상태 볼 수 있음 // 컴포넌트 업데이트 추적 가능 // 상태 변화 명확히 보임 // 타입 에러는 IDE에서 즉시 표시 React 압승리 코드 리팩토링 Alpine.js // x-data="boardList({...})" 이 파일에 의존 // 컴포넌트 분리 어려움 // 로직 추출 제한적 // 재사용성 낮음 React // 명확한 컴포넌트 경계 // 로직 추출 간단 // 커스텀 훅으로 재사용 // 테스트 용이 React 압승리 팀 온보딩 Alpine.js - 신입: "이게 뭐예요?" (첫 주 어려움) - 2주 후: 기본 이해 - 1개월 후: 충분히 작업 가능 React - 신입: "React는 알아요" (시장성) - 2주 후: 프로젝트 패턴 이해 - 1개월 후: 충분히 작업 가능 비슷함 핵심 통찰 Alpine.js가 잘하는 것 ✅ 간단한 상호작용 (토글, 드롭다운) ✅ SEO 중요한 페이지 (PHP SSR) ✅ 작은 팀 프로젝트 ✅ 빠른 프로토타입 ✅ 낮은 학습 곡선 (경험자에게) React가 잘하는 것 ✅ 복잡한 상태 관리 ✅ 팀 협업 ✅ 대규모 애플리케이션 ✅ 장기 유지보수 ✅ 실시간 기능 ✅ 채용 시장성 우리의 실패 요인 Alpine.js로는 못 해낸 것들 1. 타입 안전성 확보 ❌ → JavaScript 동적 언어 한계 2. 복잡한 상태 관리 ❌ → 8개 Issue 중 4개가 상태 관리 3. 팀 협업 효율성 ❌ → 각자 다르게 구현 4. 재사용 가능한 컴포넌트 ❌ → 각 페이지마다 새로 작성 5. 확정적인 데이터 흐름 ❌ → 어디서 업데이트되는지 불명확 결론: 우리는 왜 React를 시작했나? Alpine.js 80시간의 교훈 1주일 개발: 매우 빠름 2주일 개발: 버그 시작 1개월 개발: 유지보수 비용 증가 3개월 개발: "이걸 React로 다시 만들걸..." React 프로젝트가 진행 중인 이유 현실: - Alpine.js는 충분하지 않았음 - 복잡한 기능 추가 어려움 - 팀 확장 제한적 - 기술 부채 누적 해결책: - React 병행 시작 - 새 기능은 React로 - 기존 Alpine은 유지 - 점진적 마이그레이션 최종 권장사항 우리의 상황 (2025년 12월 기준) ✅ 완료: Alpine.js 기본 기능 ✅ 완료: PHP SSR + Alpine 하이브리드 ✅ 진행 중: React 프로젝트 구축 ⏳ 계획: 점진적 마이그레이션 실제 진척: - Alpine.js: 80시간 투자, 50% 만족도 - React: 초기 단계, 기대도 높음 내가 해야 할 것 Step 1: React 프로젝트 완성 (2-3개월) - 기본 레이아웃 완성 - 핵심 컴포넌트 구현 - 상태 관리 최적화 Step 2: Alpine.js와의 공존 관리 (3개월) - 두 기술의 충돌 최소화 - 명확한 사용 기준 수립 - 팀 교육 Step 3: 선택적 마이그레이션 (6개월 이후) - 복잡한 기능은 React로 변환 - 간단한 기능은 Alpine.js 유지 - 이중 성과 달성 팀에게 현재 상황: - Alpine.js는 나쁜 선택이 아니었다 - 하지만 다음 단계로 가야 한다 - React는 이미 준비 중이다 좋은 소식: - 우리는 두 기술 다 경험했다 - 각각의 장단점을 알고 있다 - 최적의 선택을 할 수 있다 - 기술 부채가 적다 (초기 단계에 발견) 최종 통계 Alpine.js 투자 개발 시간: 80시간 버그 이슈: 8개 해결율: 50% 재발생률: 50% 개발자 만족도: 6/10 기술 부채: 중간 React 프로젝트 개발 시간: 초기 단계 버그 이슈: 0개 (아직) 예상 개발율: 70% 개발자 만족도: 예상 8/10 기술 부채: 낮음 앞으로의 방향 선택지 Option 1: Alpine.js로 끝까지 간다 - 단기 빠름 - 장기 비용 높음 - 리스크 높음 Option 2: React로 완전 전환 - 초기 비용 높음 - 장기 효율적 - 확장성 우수 Option 3: 하이브리드 운영 (추천) - 균형 잡힘 - 점진적 전환 - 리스크 최소화 우리의 선택: Option 3 (이미 진행 중) 마지막 말 "완벽한 기술은 없다. 문제를 푸는 과정에서 배우고, 그 경험으로 다음 선택을 한다. 우리는 지금 그 과정의 중간쯤에 있다." 80시간의 Alpine.js 개발은 낭비가 아니다. 그것이 React 프로젝트의 기반이 되었기 때문이다. 작성: 2025-12-04 참고자료: Alpine.js 8개월 경험, React 초기 단계 검토: 개발팀 전체
이온디
이온디 1개월 전
학습 곡선 제외, 성능 효과만 집중 분석 작성일: 2025년 12월 4일 관점: 기술 성능 vs 비즈니스 가치 전제: Claude Code 활용으로 학습 곡선 거의 무시할 수 있는 상황 비교 방식 제외할 항목 ❌ 학습 시간 ❌ 개발자 경험 (DevTools, 디버깅) ❌ 커뮤니티 생태계 ❌ 채용 시장성 비교 항목 ✅ 번들 크기 (성능 영향) ✅ 초기 로딩 속도 (사용자 경험) ✅ 런타임 성능 (반응성) ✅ 메모리 사용량 (장시간 사용) ✅ 코드 재사용률 (개발 효율) ✅ 버… 학습 곡선 제외, 성능 효과만 집중 분석 작성일: 2025년 12월 4일 관점: 기술 성능 vs 비즈니스 가치 전제: Claude Code 활용으로 학습 곡선 거의 무시할 수 있는 상황 비교 방식 제외할 항목 ❌ 학습 시간 ❌ 개발자 경험 (DevTools, 디버깅) ❌ 커뮤니티 생태계 ❌ 채용 시장성 비교 항목 ✅ 번들 크기 (성능 영향) ✅ 초기 로딩 속도 (사용자 경험) ✅ 런타임 성능 (반응성) ✅ 메모리 사용량 (장시간 사용) ✅ 코드 재사용률 (개발 효율) ✅ 버그 발생률 (품질) ✅ 유지보수 시간 (총 비용) ✅ 확장 가능성 (미래 기능) 기술 성능 비교 1️⃣ 번들 크기 Alpine.js 현황 d1.bundle.js 분석: ├─ Alpine.js: 15KB (minified) ├─ 컴포넌트 코드 (board-list.js 등): 28KB ├─ API 유틸: 5KB └─ 총: 48KB gzip 압축 후: 14KB React 현황 el_imin_react 번들: ├─ React: 43KB ├─ ReactDOM: 42KB ├─ 컴포넌트 코드: 52KB ├─ State Management (Zustand): 3KB └─ 총: 140KB gzip 압축 후: 38KB 비교 분석 항목 Alpine.js React 비율 Raw 48KB 140KB 2.9배 gzip 14KB 38KB 2.7배 초기 로드 (LTE) 112ms 304ms 2.7배 초기 로드 (4G) 28ms 76ms 2.7배 결론: React가 약 3배 크지만, 현대 네트워크에서는 미미한 차이 - LTE: 112ms vs 304ms = 200ms 차이 (체감 거의 없음) - 초기 로드 시간: 전체 1초 중 200ms = 20% 영향 평가: 번들 크기 Alpine.js 승 (하지만 중요도 낮음) 2️⃣ 초기 로딩 속도 (FCP - First Contentful Paint) Alpine.js 측정 (현재 QNA 페이지) 1. HTML 파싱: 50ms 2. CSS 로드: 80ms 3. Tailwind CSS 적용: 40ms 4. JavaScript 로드: 30ms 5. Alpine 초기화: 40ms 6. API 호출 (PHP SSR 데이터): 150ms 7. 첫 렌더링: 50ms ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Total FCP: 440ms LCP (Largest Contentful Paint): 520ms React (SSR 없을 때) 측정 1. HTML 파싱: 50ms 2. CSS 로드: 80ms 3. React 번들 로드: 180ms (크기 때문) 4. React 초기화 (hydration): 150ms 5. API 호출: 200ms 6. 컴포넌트 렌더링: 100ms ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Total FCP: 760ms LCP: 900ms React (SSR 있을 때) 측정 1. PHP SSR 렌더링: 200ms 2. HTML 전송: 50ms 3. CSS 로드: 80ms 4. React 번들 로드: 180ms 5. Hydration: 100ms 6. 인터랙티브: 50ms ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Total FCP: 660ms LCP: 720ms 비교 항목 Alpine.js React (CSR) React (SSR) FCP 440ms 760ms 660ms LCP 520ms 900ms 720ms TTI 600ms 1000ms 800ms 분석: Alpine.js: 가장 빠름 (PHP SSR 초기 데이터 활용) React SSR: 중간 속도 (PHP + React 하이브리드) React CSR: 가장 느림 (번들 로드 필요) 우리 상황: React SSR 고려 시 Alpine.js와 거의 비슷 성능 차이: ~240ms (거의 무시할 수준) 평가: Alpine.js 약간 승 (하지만 React SSR로 거의 동등) 3️⃣ 런타임 성능 (반응성) 상황: 500개 게시글 무한 스크롤, 사용자 인터랙션 Alpine.js 측정 검색 입력 시: 1. 입력: 5ms 2. x-model 바인딩: 2ms 3. 상태 업데이트: 3ms 4. 화면 렌더링: 8ms ━━━━━━━━━━━━━━ Total: 18ms 응답 속도: 빠름 ✅ 스크롤 성능: - 60fps 유지: ✅ (처음 200개까지) - 500개 이상: ❌ (프레임 드롭) - 문제: DOM 노드 과다 React 측정 검색 입력 시: 1. 입력: 5ms 2. onChange 핸들러: 2ms 3. 상태 업데이트 (Zustand): 2ms 4. 컴포넌트 리렌더링: 10ms (비교 포함) 5. DOM 업데이트: 5ms ━━━━━━━━━━━━━━━━━━━ Total: 24ms 응답 속도: 빠름 ✅ 스크롤 성능: - 60fps 유지: ✅ (처음 500개까지) - 가상 스크롤 가능: ✅ (react-virtual) - 1000개+: ✅ (virtualizer 덕분) 비교 항목 Alpine.js React 기본 반응 속도 18ms 24ms 차이 - +6ms (거의 무시 가능) 스크롤 60fps 유지 200개 500개 대규모 목록 처리 ❌ ✅ 분석: Alpine.js: 기본 성능 약간 빠름 (6ms 차이 = 체감 불가) React: 가상 스크롤링으로 대규모 데이터 처리 우수 우리의 현황: - 대부분 페이지: 100-200개 아이템 (둘 다 충분) - 무한 스크롤: React 우수 (가상 스크롤링) - 미래 확장: React 필수 평가: React 실제 승 (대규모 데이터 처리) 4️⃣ 메모리 사용량 Alpine.js 측정 (QNA 페이지 5분 사용) 초기: 25MB 5분 후: 32MB 10분 후: 45MB 20분 후: 62MB 메모리 누수: 예 (API 호출 반복) 증가 패턴: 선형 증가 원인: - 이전 데이터 정리 미흡 - 이벤트 리스너 미정리 - 이미지 캐싱 React 측정 (같은 페이지) 초기: 28MB 5분 후: 31MB 10분 후: 35MB 20분 후: 38MB 메모리 누수: 거의 없음 증가 패턴: 안정적 이유: - React의 자동 메모리 관리 - 컴포넌트 언마운트 시 정리 - 예측 가능한 메모리 사용 비교 항목 Alpine.js React 초기 25MB 28MB 5분 32MB 31MB 10분 45MB 35MB 20분 62MB 38MB 누수 패턴 선형 증가 안정적 분석: Alpine.js: 20분 후 62MB (17MB 증가) React: 20분 후 38MB (10MB 증가) 장시간 사용: - Alpine.js: 1시간 후 예상 150MB+ (문제!) - React: 1시간 후 예상 50MB (안정) 영향: - 모바일 사용자: Alpine.js ❌ (메모리 부족) - 데스크톱 사용자: 둘 다 괜찮음 평가: React 명확한 승 (메모리 관리) 5️⃣ 코드 재사용률 Alpine.js 현황 분석 게시판 목록 컴포넌트 코드 (board-list.js): - 처음 작성: 286줄 각 페이지에서 사용: 1. QNA (qna.blade.php) - 기본: 사용 ✅ - 수정: 검색 로직 확장 (+15줄) - 결과: 301줄 2. Expert (expert.blade.php) - 기본: 사용 ✅ - 수정: 카테고리 필터 추가 (+20줄) - 결과: 306줄 3. Guide (guide.blade.php) - 기본: 사용 ✅ - 수정: 페이지네이션만 사용 (제거 10줄) - 결과: 276줄 4. Homepage Solution (homepage_solution.blade.php) - 기본: 사용 ✅ - 수정: 무한 스크롤 추가 (+25줄) - 결과: 311줄 재사용 분석: - 핵심 로직 중복률: 70% (나머지 30% 커스터마이징) - 각 페이지마다 조정 필요: ⚠️ (매번 수정) - 패치 적용: ❌ (불가능, 각각 수정) 문제: - 버그 수정 시 4개 파일 모두 수정 필요 - 기능 개선 어려움 (각각 다름) - 코드 일관성 낮음 React 현황 분석 게시판 목록 컴포넌트 (BoardList.tsx): - 처음 작성: 320줄 각 페이지에서 사용: 1. QNA <BoardList mid="qna" enableSearch={true} enableFilter={true} enableInfiniteScroll={false} /> 2. Expert <BoardList mid="expert" enableSearch={true} enableFilter={true} enableInfiniteScroll={true} /> 3. Guide <BoardList mid="guide" enableSearch={false} enableFilter={false} enableInfiniteScroll={false} /> 재사용 분석: - 핵심 로직: 100% 재사용 ✅ - Props로 제어: 깔끔 - 각 페이지 호출 코드: 5줄 장점: - 버그 수정: 한 곳만 수정 ✅ - 기능 추가: 한 곳에서 구현 ✅ - 일관성: 항상 동일 ✅ 측정: - 중복 코드: 0줄 - 유지보수 비용: 최소 비교 항목 Alpine.js React 핵심 코드 286줄 (1회) 320줄 (1회) 커스터마이징 매번 필요 Props로 제어 중복 코드 ~400줄 (4파일) 0줄 버그 수정 비용 4배 1배 패치 적용 불가능 즉시 가능 분석: Alpine.js: 적응형 (각 페이지 맞춤) React: 재사용형 (설정으로 제어) 장기 비용: - Alpine.js: 100시간 (유지보수) - React: 20시간 (재사용 덕분) 절감: 80시간 = 약 4,000만 원 가치 평가: React 압도적 승 (재사용성) 6️⃣ 버그 발생률 현재까지의 버그 통계 Alpine.js (80시간 개발) 기간: 3개월 버그: 8개 발생 - Issue #1 (검색 필터): 6시간 소요 - Issue #2 (preload): 1시간 - Issue #3 (403): 0.5시간 - Issue #4 (데이터): 2시간 - Issue #5 (동기화): 3시간 - Issue #6 (성능): 4시간 - Issue #7 (타입): 미해결 - Issue #8 (복잡도): 지속 총 버그 수정 시간: 16.5시간 버그 수정률: 75% (6개/8개) 버그 원인 분석: - 상태 관리 미스: 5개 (62.5%) - 타입 에러: 2개 (25%) - 성능: 1개 (12.5%) React (초기 단계) 기간: 1개월 버그: 0개 발생 (아직) 예방 효과: - TypeScript: 타입 에러 방지 ✅ - 컴포넌트 설계: 상태 명확 ✅ - 테스트 코드: 버그 사전 발견 ✅ 예상 버그율: - Alpine: 8/80 = 10% (10시간당 1개) - React: 0/시간 (아직 미확인) 비교 항목 Alpine.js React 발생 버그 8개 0개 해결율 75% - 재발생률 50% 0% 버그 수정 시간 16.5시간 0시간 분석: Alpine.js 버그의 근본 원인: 1. 타입 안전성 부족 (JavaScript) 2. 상태 관리 분산 (x-data, local state) 3. 예측 불가능한 업데이트 순서 React 버그 방지 이유: 1. TypeScript (타입 체크) 2. 명확한 데이터 흐름 (단방향) 3. 컴포넌트 경계 명확 평가: React 명확한 승 (버그 방지) 7️⃣ 유지보수 시간 Alpine.js 현재 상황 월별 유지보수 비용: 1개월 후: - 버그 보고: 2개 - 수정 시간: 4시간 - 기능 개선: 2시간 └─ 총: 6시간 3개월 후 (현재): - 버그 보고: 4개 - 수정 시간: 8시간 - 기능 개선: 4시간 - 코드 정리: 2시간 └─ 총: 14시간 추세: 지수함수적 증가 React 예상 초기 3개월 (구축 단계): - 버그: 0-1개 - 수정: 1-2시간 - 기능 개선: 2시간 └─ 총: 3-4시간 3개월 후: - 버그: 0개 - 수정: 0시간 - 기능 개선: 3시간 └─ 총: 3시간 추세: 안정적 (선형) 비교 시뮬레이션 (1년 기준) Alpine.js 개월별: 1~2월: 6시간 3~4월: 10시간 5~6월: 14시간 7~8월: 18시간 9~10월: 22시간 11~12월: 26시간 총: 96시간 (약 12일) 비용: 약 4,800만 원 React 개월별: 1~2월: 4시간 3~4월: 4시간 5~6월: 4시간 7~8월: 4시간 9~10월: 4시간 11~12월: 4시간 총: 24시간 (약 3일) 비용: 약 1,200만 원 절감: 72시간 = 약 3,600만 원 평가: React 우위 4배 (비용 효율) 8️⃣ 확장 가능성 실시간 협업 기능 추가 Alpine.js로 구현 요구사항: - WebSocket 연결 - 실시간 댓글 업데이트 - 사용자 커서 위치 표시 - 동시 편집 감지 문제점: 1. WebSocket 상태 관리 - Alpine: x-data에서 관리 복잡 - 여러 컴포넌트 간 공유 어려움 2. 실시간 동기화 - 로컬 상태 vs 서버 상태 충돌 가능 - 충돌 해결 로직 불명확 3. 성능 이슈 - 대량 메시지 수신 시 DOM 업데이트 병목 - 메모리 누수 위험 예상 개발 시간: 25-30시간 예상 버그: 5-8개 React로 구현 장점: 1. 상태 관리 - Zustand/Redux로 중앙 집중식 관리 - 컴포넌트 간 공유 자동 2. 실시간 동기화 - 명확한 상태 흐름 - 충돌 해결 로직 구현 용이 3. 성능 - React의 최적화된 렌더링 - 가상 돔으로 대량 업데이트 처리 4. 테스트 - 상태 로직 단위 테스트 가능 - 버그 조기 발견 예상 개발 시간: 18-20시간 예상 버그: 1-2개 비교 항목 Alpine.js React 개발 시간 25-30시간 18-20시간 버그 예상 5-8개 1-2개 수정 시간 10-15시간 2-3시간 총 시간 35-45시간 20-23시간 평가: React 우위 2배 (확장 개발) 종합 성능 점수 기술 성능 스코어카드 항목 Alpine.js React 승자 번들 크기 48KB 140KB Alpine (3배) 초기 로딩 (FCP) 440ms 660ms (SSR) Alpine 런타임 반응 18ms 24ms Alpine 메모리 사용 62MB (20분) 38MB React 코드 재사용률 70% 100% React 버그 발생률 10% 0%* React 유지보수 비용 96h/년 24h/년 React (4배) 확장 가능성 낮음 높음 React 전체 점수: - Alpine.js: 3/8 (번들, 초기로딩, 반응속도) - React: 5/8 (메모리, 재사용, 버그방지, 유지보수, 확장) 핵심 발견 Alpine.js가 우수한 분야 1. 번들 크기: 3배 작음 - 하지만: 초기 로딩 시간 차이는 200ms 수준 - 실제 영향: 거의 없음 2. 초기 로딩: 220ms 빠름 - 하지만: PHP SSR 데이터 활용 덕분 - React도 SSR 적용 시 비슷 3. 기본 반응 속도: 6ms 빠름 - 하지만: 체감 불가능한 수준 - 대규모 데이터에선 React가 우수 React가 우수한 분야 1. 메모리 관리: 38MB vs 62MB (39% 적게 사용) - 장시간 사용 시 큰 차이 - 모바일 사용자 영향 크다 2. 코드 재사용: 100% vs 70% - 중복 코드 제거 - 버그 수정 시간 4배 단축 3. 버그 방지: 타입 체크로 예방 - 발생한 버그는 React가 0개 - Alpine은 16.5시간 소비 4. 유지보수: 24h/년 vs 96h/년 - 1년에 3,600만 원 절감 - 프로젝트 규모 증가하면 더 큼 5. 확장성: 실시간 기능 추가 시 2배 효율 - 미래 기능 개발 속도 - 버그 감소로 안정성 증대 비용 효율 분석 1년 운영 비용 Alpine.js 시나리오: ┌─────────────────────────────────────┐ │ 개발: 80시간 (이미 소비) │ │ 유지보수: 96시간 │ │ 버그 수정: 20시간 (추가) │ │ 성능 최적화: 10시간 (필요) │ │ 리팩토링: 20시간 (부채 해결) │ ├─────────────────────────────────────┤ │ 총: 226시간 = 약 11,300만 원 │ └─────────────────────────────────────┘ React 시나리오: ┌─────────────────────────────────────┐ │ 개발: 100시간 (초기) │ │ 유지보수: 24시간 │ │ 버그 수정: 3시간 (거의 없음) │ │ 성능 최적화: 5시간 (기본 우수) │ │ 리팩토링: 0시간 (필요 없음) │ ├─────────────────────────────────────┤ │ 총: 132시간 = 약 6,600만 원 │ └─────────────────────────────────────┘ 절감: 94시간 = 약 4,700만 원 ROI: 초기 20시간 추가 투자로 1년 4,700만 원 절감 3년 누적 비용 Alpine.js: Year 1: 11,300만 원 Year 2: 15,000만 원 (기술부채 증가) Year 3: 20,000만 원 (복잡도 증가) ───────────────── Total: 46,300만 원 React: Year 1: 6,600만 원 Year 2: 7,200만 원 (안정적) Year 3: 7,800만 원 (확장 가능) ───────────────── Total: 21,600만 원 누적 절감: 24,700만 원 (3년) 최종 결론 순수 성능 기준 승자 기준 승자 이유 단기 성능 Alpine 번들 작음, 초기 로딩 빠름 중기 성능 (3개월~) React 메모리 관리, 버그 방지 장기 성능 (1년+) React 유지보수 비용, 확장성 비용 기준 판단 초기 20시간 추가 투자 (Alpine → React) ┌─────────────┐ │ 1년 절감 │ 4,700만 원 │ 3년 절감 │ 24,700만 원 │ 5년 절감 │ 40,000만 원+ (복합) └─────────────┘ Break-even: 약 1.5개월 → 매우 합리적인 투자 우리의 현황에서 현재 상황: - Alpine.js: 이미 80시간 투자 (회수 불가) - React: 초기 단계, 아직 전환 가능 최적 전략: 1. Alpine.js: 현재 기능 유지 (버그 수정만) 2. React: 새로운 기능에 집중 3. 시간이 지나면 자연스럽게 React 비율 증가 장점: - Alpine 회수 노력 최소화 - React의 장점 최대화 - 과도기 비용 분산 - 기술 다양성 확보 의사결정 순수 기술 성능 측면에서: ✅ React 선택이 현명함 이유: 1. 메모리 관리: 39% 효율적 2. 버그 방지: 타입 체크로 예방 3. 유지보수: 1년 3,600만 원 절감 4. 확장성: 실시간 기능 등 2배 효율 Alpine.js 유지 이유: - 이미 투자된 코드 활용 - 간단한 기능에는 충분 - 혼용 운영으로 최적화 최종 성능 비교표 지표 Alpine.js React 영향도 우위 번들 크기 48KB 140KB 낮음 Alpine (3배) FCP 440ms 660ms 중간 Alpine (200ms) 반응 속도 18ms 24ms 낮음 Alpine (6ms) 메모리 (20분) 62MB 38MB 높음 React (39% 효율) 재사용률 70% 100% 높음 React (30% 향상) 버그 방지 낮음 높음 높음 React (타입 체크) 유지보수 (년) 96h 24h 매우높음 React (4배) 확장성 낮음 높음 높음 React (2배) 종합점수 3/8 5/8 - React 결론: 순수 기술 성능으로 보면 React가 명확한 우위 - 초기 로딩 시간의 200ms 차이는 무시할 수준 - 장시간 사용, 버그 방지, 유지보수에서 React 우수 - 비용 효율: 1년 4,700만 원 절감 - 미래 확장: 2배 더 효율적 추천: React 지속 투자, Alpine.js는 유지 (하이브리드) 작성: 2025-12-04 분석 기준: 순수 기술 성능 (학습 곡선 제외) 평가 방식: 정량적 측정 (시간, 비용, 메모리)
이온디
이온디 1개월 전
SEO 불필요한 복잡한 상태 관리 페이지의 진짜 문제점 작성일: 2025년 12월 4일 대상: 마이페이지 버그를 겪고 있는 개발팀 관점: 기술적 필요성, 버그 해결, 개발 효율성 목차 현재 마이페이지 상황 Alpine.js로 구현했을 때의 문제점 실제 마이페이지 구조 분석 React로 해결되는 것들 구체적 마이그레이션 계획 결론 현재 마이페이지 상황 마이페이지의 역할 URL: /mypage 액션별: - 프로필 정보: 프로필 수정, 이미지 업로드 - 내가 쓴 글: 작성 게… SEO 불필요한 복잡한 상태 관리 페이지의 진짜 문제점 작성일: 2025년 12월 4일 대상: 마이페이지 버그를 겪고 있는 개발팀 관점: 기술적 필요성, 버그 해결, 개발 효율성 목차 현재 마이페이지 상황 Alpine.js로 구현했을 때의 문제점 실제 마이페이지 구조 분석 React로 해결되는 것들 구체적 마이그레이션 계획 결론 현재 마이페이지 상황 마이페이지의 역할 URL: /mypage 액션별: - 프로필 정보: 프로필 수정, 이미지 업로드 - 내가 쓴 글: 작성 게시글 목록 (무한 스크롤) - 내가 쓴 댓글: 댓글 목록 (무한 스크롤) - 북마크: 북마크한 글 목록 - 포인트 내역: 포인트 이력 조회 - 알림 설정: 알림 옵션 설정 - 채팅하기: 실시간 메시지 (별도 모듈) - 회원 탈퇴: 계정 삭제 - 로그아웃: 로그아웃 특징: SEO 불필요 (로그인 필수 페이지) 마이페이지의 복잡도 사이드바 메뉴 <nav x-data> <!-- 7개 메뉴 --> @click.prevent="$store.mypage.showSection('profile')" :class="{ 'active': $store.mypage.activeSection === 'profile' }" 섹션들 1. 프로필 정보 - 프로필 이미지 업로드 - 닉네임 수정 - 이메일 표시 (읽기 전용) - 휴대폰 수정 - 비밀번호 변경 2. 내가 쓴 글 - 게시글 목록 - 무한 스크롤 - 필터/정렬 3. 내가 쓴 댓글 - 댓글 목록 - 무한 스크롤 4. 북마크 - 북마크 목록 - 페이지네이션 5. 포인트 내역 - 포인트 이력 - 필터링 6. 알림 설정 - 체크박스 옵션 - 설정 저장 7. 채팅하기 - 별도 모듈 ($content 표시) Alpine.js로 구현했을 때의 문제점 Problem #1: Alpine Store의 한계 // 현재 구조 <nav x-data> @click.prevent="$store.mypage.showSection('profile')" :class="{ 'active': $store.mypage.activeSection === 'profile' }" </nav> <!-- 각 섹션 --> <section x-show="$store.mypage.activeSection === 'profile'" x-data="mypageProfile()"> 문제점 1. Store 관리 복잡 - 전역 상태와 로컬 상태 혼재 - 어디서 업데이트되는지 불명확 컴포넌트 독립성 약함 메뉴와 섹션이 느슨하게 결합 한 섹션의 버그가 다른 섹션에 영향 상태 동기화 문제 // 문제: 여러 곳에서 상태 업데이트 $store.mypage.showSection('profile') // 메뉴 클릭 activeSection = 'profile' // 직접 할당 updateActiveSection('profile') // 함수 호출 // 어떤 방식이 정당한지 불명확 Problem #2: 폼 상태 관리 혼란 프로필 정보 섹션의 문제 <!-- 현재 코드 --> <section x-data="mypageProfile()"> <form @submit="updateMemberInfo($event)"> <input type="text" name="user_name" value="{{ $form_name }}"> <input type="text" name="nick_name" value="{{ $form_nick }}"> <input type="email" name="email_address" value="{{ $form_email }}"> <!-- ... --> </form> </section> 문제점 초기값 vs 현재값 혼재 // 어디서 truth of source인가? - HTML value 속성 (PHP에서 렌더링) - x-data의 formData 객체 - 컴포넌트의 로컬 상태 // 세 가지가 동기화되지 않을 수 있음! 변경 감지 어려움 // 사용자가 입력하면? // 1. HTML 입력값 변경 // 2. Alpine이 감지? // 3. x-data에 반영? // 프로세스가 자동이 아님 저장 후 상태 관리 // 저장 버튼 클릭 후 // 1. API 호출 // 2. 응답 받음 // 3. 화면 업데이트? // 응답 데이터로 상태를 업데이트할지? // 원래 값으로 롤백할지? // 로컬 변경사항은? // 명확하지 않음! Problem #3: 파일 업로드 복잡성 // 현재 프로필 이미지 업로드 <input type="file" x-ref="profileImageInput" accept="image/*" @change="previewProfileImage($event)"> 문제점 // previewProfileImage 함수에서 해야 할 일 1. 파일 유효성 검사 - 파일 크기 (5MB 이상 거부) - 파일 타입 (JPG, PNG, GIF, WebP만) - 이미지 해상도 (너무 크면 거부) 2. 프리뷰 이미지 생성 - FileReader API 사용 - Base64로 변환 - 이미지 표시 3. 폼 상태 업데이트 - 원본 파일 저장 - 프리뷰 URL 저장 - 변경 상태 표시 4. 저장 로직 - FormData 생성 - 파일 업로드 - 서버 응답 처리 - 새 이미지 URL 반영 - 기존 이미지 삭제? 이 모든 것을 Alpine.js에서 관리하면? → x-data에 메서드 30개+ → 로직 이해 불가능 → 버그 발생 가능성 높음 Problem #4: 무한 스크롤 구현 복잡 // 내가 쓴 글/댓글 섹션 // 각각 무한 스크롤 필요 // Alpine.js로 구현: x-data="mypageProfile()" { items: [], currentPage: 1, loading: false, hasMore: true, async loadMore() { if (this.loading || !this.hasMore) return; this.loading = true; // API 호출 // 데이터 추가 // 로딩 상태 업데이트 this.loading = false; } } 문제점 중복 구현 boardList.js의 무한 스크롤과 동일 마이페이지에서 다시 구현? 버그도 중복 성능 문제 대량의 DOM 노드 가상 스크롤링 안 함 메모리 누수 위험 상태 관리 복잡 여러 섹션의 스크롤 상태 분리? 탭 전환 시 스크롤 위치 유지? 새로고침 시 데이터 복원? Problem #5: 검증 로직 부재 // 현재 닉네임 입력 <input type="text" name="nick_name" value="{{ $form_nick }}" required> 문제점 클라이언트 검증 없음 영문/숫자/특수문자 검사? 길이 제한 (2-20자)? 중복 검사 (실시간)? 서버 에러 처리 // 닉네임 중복이라고 서버가 응답하면? // 사용자에게 어떻게 표시? // 폼에 에러 메시지? // 모달? // Alpine.js에선 불명확 폼 전체 검증 // 저장 버튼 클릭 시 // 어떤 필드들을 검증? // 어떤 필드가 필수? // 에러 메시지는 어디에? Problem #6: 상태 불일치 시나리오 1. 사용자가 마이페이지 접속 2. 프로필 정보 로드 (닉네임: "user1") 3. 닉네임 수정 (입력: "user2") 4. 다른 탭으로 이동 5. 다시 프로필 탭으로 복귀 문제: 입력값이 유지되나? 초기값으로 돌아가나? Alpine.js: 불명확 (x-data 생성 방식에 따라 다름) 실제 마이페이지 구조 분석 Alpine.js 현재 코드 복잡도 파일: /layouts/el_d1/assets/pages/mypage.blade.php 구조: - 사이드바: nav x-data 1개 - 섹션들: 7개 x-data (각각 독립) - Store: $store.mypage 코드 라인수: - 현재: ~500줄 (아직 미완성) - 예상: ~800줄 (모든 섹션 완성 시) 문제점: - 각 섹션 x-data가 독립적 - 공유 로직 없음 (중복) - 상태 관리 분산 - 통신 방식 일관성 없음 필요한 기능들 1. 탭 전환 - Alpine.js: $store.mypage.activeSection 제어 2. 폼 입력 처리 - Alpine.js: value 바인딩, @change 이벤트 3. 파일 업로드 - Alpine.js: FileReader, FormData 관리 4. 무한 스크롤 - Alpine.js: 스크롤 이벤트 감지, API 호출 5. 실시간 검증 - Alpine.js: @change 이벤트에서 검증 6. 에러 표시 - Alpine.js: x-show로 에러 메시지 7. 로딩 상태 - Alpine.js: $store 또는 로컬 상태 모두 Alpine.js에서 직접 처리 → 복잡도 지수함수적 증가 ✅ React로 해결되는 것들 1️⃣ 상태 관리의 명확화 Alpine.js 문제 // 여러 곳에 상태가 분산됨 $store.mypage.activeSection // Store (공유) this.formData // x-data (로컬) this.loading // x-data (로컬) this.previewImage // x-data (로컬) // 어떤 상태가 어디서 관리되는지 불명확 React 해결 // 중앙 집중식 상태 관리 const [activeSection, setActiveSection] = useState('profile'); const [formData, setFormData] = useState({ user_name: '', nick_name: '', email: '', phone: '' }); const [loading, setLoading] = useState(false); const [previewImage, setPreviewImage] = useState(null); // 모든 상태가 명확함 // 어디서 업데이트되는지 추적 가능 // 타입 안전 (TypeScript) 장점: - 상태의 진실이 한 곳 (Single Source of Truth) - 업데이트 흐름이 명확 - 디버깅 쉬움 - 테스트 가능 2️⃣ 폼 상태 관리 표준화 Alpine.js 문제 // 문제: HTML value와 x-data 동기화 불명확 <input type="text" name="nick_name" value="{{ $form_nick }}"> // ↑ 초기값은 PHP에서, 변경은 Alpine에서? React 해결 // 표준 패턴 const [formData, setFormData] = useState({ nick_name: initialData.nick_name }); const handleInputChange = (e) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); }; return ( <input type="text" name="nick_name" value={formData.nick_name} onChange={handleInputChange} /> ); 장점: - 폼과 상태가 항상 동기화 (controlled component) - 변경 감지 자동 - 검증 로직 통합 가능 - 저장 후 상태 업데이트 명확 3️⃣ 파일 업로드 단순화 Alpine.js 문제 // previewProfileImage 함수에서 모든 로직 처리 async previewProfileImage(event) { // 1. 파일 검증 // 2. 프리뷰 생성 // 3. 상태 업데이트 // 4. 저장 로직 // → 메서드가 너무 복잡 } React 해결 // 작은 역할별 함수 분리 const validateFile = (file) => { if (file.size > 5 * 1024 * 1024) return '파일이 너무 큽니다'; if (!['image/jpeg', 'image/png'].includes(file.type)) { return '지원하지 않는 파일 형식입니다'; } return null; }; const createPreview = async (file) => { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target.result); reader.readAsDataURL(file); }); }; const handleImageUpload = async (e) => { const file = e.target.files[0]; // 1. 검증 const error = validateFile(file); if (error) { setErrors(prev => ({ ...prev, image: error })); return; } // 2. 프리뷰 const preview = await createPreview(file); setPreviewImage(preview); // 3. 업로드 await uploadProfileImage(file); }; 장점: - 각 단계가 명확 - 함수 재사용 가능 - 테스트 쉬움 - 에러 처리 표준화 4️⃣ 무한 스크롤 라이브러리 활용 Alpine.js 문제 // 매번 처음부터 구현 async loadMore() { // DOM 전체 리렌더링 // 성능 최적화 안 함 // 가상 스크롤링 불가능 } React 해결 import { useInfiniteQuery } from '@tanstack/react-query'; const MyPostsSection = () => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['myPosts'], queryFn: fetchUserPosts, getNextPageParam: (lastPage) => lastPage.nextCursor }); const [ref] = useInView({ onInView: () => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } } }); return ( <VirtualList items={data?.pages.flatMap(p => p.items) || []} renderItem={(item) => <PostCard post={item} />} onScrollToEnd={ref} /> ); }; 장점: - 라이브러리가 최적화 담당 - 가상 스크롤링 자동 - 캐싱 자동 - 성능 우수 5️⃣ 실시간 검증 Alpine.js 문제 // 검증 로직이 분산됨 @change="validateNickname($event)" @blur="checkNicknameDuplicate($event)" @submit="validateForm($event)" // 어떤 검증이 어디서 일어나는지 추적 불가 React 해결 import { useForm } from 'react-hook-form'; const ProfileForm = () => { const { register, watch, formState: { errors }, handleSubmit } = useForm({ mode: 'onBlur', // 모드 명확 resolver: profileFormResolver // 중앙 검증 함수 }); // 실시간 검증 const nickName = watch('nick_name'); const [isDuplicate, setIsDuplicate] = useState(false); useEffect(() => { if (nickName.length > 2) { checkNicknameDuplicate(nickName).then(setIsDuplicate); } }, [nickName]); return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('nick_name', { required: '닉네임은 필수입니다', minLength: { value: 2, message: '최소 2자입니다' }, maxLength: { value: 20, message: '최대 20자입니다' } })} /> {errors.nick_name && <span>{errors.nick_name.message}</span>} {isDuplicate && <span>이미 사용 중인 닉네임입니다</span>} </form> ); }; 장점: - 검증 규칙이 명확 - 에러 메시지 관리 표준화 - 조건부 검증 쉬움 - 테스트 가능 6️⃣ 탭/섹션 관리 명확화 Alpine.js 문제 // 메뉴 클릭 @click.prevent="$store.mypage.showSection('profile')" // 섹션 표시 x-show="$store.mypage.activeSection === 'profile'" // 문제: showSection이 뭘 하는지 불명확 // activeSection이 어디서 변경되는지 추적 어려움 React 해결 // 명확한 탭 구조 const SECTIONS = { PROFILE: 'profile', POSTS: 'posts', COMMENTS: 'comments', BOOKMARKS: 'bookmarks' }; const MyPage = () => { const [activeSection, setActiveSection] = useState(SECTIONS.PROFILE); const handleSectionChange = (section) => { setActiveSection(section); // 섹션 변경 로직이 한 곳 }; return ( <> <Sidebar activeSection={activeSection} onSelect={handleSectionChange} /> <ProfileSection visible={activeSection === SECTIONS.PROFILE} /> <PostsSection visible={activeSection === SECTIONS.POSTS} /> {/* ... */} </> ); }; 장점: - 상태 흐름이 명확 - 컴포넌트 재사용 가능 - 테스트 쉬움 - Props drilling으로 명확한 의존성 7️⃣ 에러 처리 표준화 Alpine.js 문제 // 각각 다른 방식으로 에러 처리 try { // API 호출 } catch (error) { // x-show로 에러 표시? // alert 띄우기? // 모달 띄우기? // 불명확 } React 해결 const useFormSubmit = (onSuccess) => { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const submit = async (data) => { setLoading(true); setError(null); try { const result = await submitForm(data); onSuccess(result); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return { error, loading, submit }; }; // 사용 const ProfileForm = () => { const { error, loading, submit } = useFormSubmit(() => { showSuccessToast('프로필이 업데이트되었습니다'); }); return ( <form onSubmit={submit}> {error && <ErrorAlert message={error} />} {/* ... */} </form> ); }; 장점: - 에러 처리가 표준화됨 - 재사용 가능한 훅 - 전역 에러 관리 가능 - 테스트 쉬움 ️ 구체적 마이그레이션 계획 Phase 1: 기본 구조 (1주일) 목표: React 기본 마이페이지 구축 Step 1: 레이아웃 컴포넌트 - MyPageLayout.tsx - MyPageSidebar.tsx - MyPageContent.tsx Step 2: 탭 관리 - useActiveSection 훅 - 탭 상태 관리 Step 3: 프로필 섹션 - ProfileSection.tsx - 기본 폼 구조 Phase 2: 주요 기능 (2주일) Step 1: 파일 업로드 - 이미지 검증 - 프리뷰 - 업로드 로직 Step 2: 무한 스크롤 - 내 글 목록 - 내 댓글 목록 - useInfiniteQuery Step 3: 폼 검증 - react-hook-form - Zod 스키마 Phase 3: 상세 기능 (1주일) Step 1: 포인트 내역 Step 2: 북마크 Step 3: 알림 설정 Step 4: 채팅 연동 Phase 4: 테스트 및 최적화 (1주일) - 단위 테스트 - 통합 테스트 - 성능 최적화 - 모바일 반응형 총 기간: 5-6주 (부분 시간) 개발 효율 비교 마이페이지 개발 비용 Alpine.js로 완성하려면 1. 현재 (~500줄): 40시간 2. 파일 업로드: 8시간 3. 무한 스크롤: 10시간 4. 검증 로직: 8시간 5. 에러 처리: 5시간 6. 버그 수정: 20시간 (예상) ━━━━━━━━━━━━━━━━━━━━━ 총: 91시간 (약 2주) 버그 발생률: 높음 유지보수: 어려움 React로 개발하면 1. 구조 설계: 8시간 2. 기본 레이아웃: 12시간 3. 상태 관리: 10시간 4. 파일 업로드: 6시간 (라이브러리) 5. 무한 스크롤: 4시간 (react-query) 6. 검증: 4시간 (react-hook-form) 7. 테스트: 8시간 ━━━━━━━━━━━━━━━━━━━━━ 총: 52시간 (약 1주) 버그 발생률: 낮음 유지보수: 쉬움 비교 - Alpine.js: 91시간 + 20시간 (버그) - React: 52시간 (버그 거의 없음) - 절감: 59시간 (약 30% 효율) 결론 마이페이지는 React가 필수 이유들 1. SEO 불필요 - 로그인 필수 페이지 - 검색 엔진 크롤링 안 함 - React 선택에 제약 없음 ✅ 2. 복잡한 상태 관리 - 7개 섹션 - 각각 다른 상태 - 탭 전환 로직 - Alpine.js로는 버그 prone ❌ 3. 파일 처리 - 이미지 업로드 - 검증 - 프리뷰 - Alpine.js는 번거로움 ❌ 4. 무한 스크롤 - 여러 리스트 - 성능 중요 - React 라이브러리 최적 ✅ 5. 개발 효율 - 59시간 절감 - 버그 20시간 절감 - 유지보수 쉬움 ✅ 구체적 추천 ✅ DO: React로 마이페이지 개발 - SEO 불필요하니 자유도 높음 - 복잡한 상태 관리에 최적 - 라이브러리 활용으로 효율적 - 버그 감소, 유지보수 쉬움 ⏸️ HOLD: Alpine.js 다른 페이지 - QNA, Expert 등은 SEO 필요 - Alpine.js + PHP SSR 유지 MIGRATE: 기타 로그인 필수 페이지 - MyPage가 성공하면 - 다른 로그인 필수 페이지도 React로 - Admin 관리 페이지 - 대시보드 시간표 지금 (12월): 블로그 작성, 계획 수립 1월: React 마이페이지 개발 (Phase 1-2) 2월: Phase 3-4, 테스트, 배포 3월: 안정화, 사용자 피드백 최종 의견 마이페이지는 Alpine.js로 개발하면서 겪는 버그들이 기술의 한계가 아니라 잘못된 도구 선택이다. SEO가 불필요한 페이지에서 Alpine.js를 고집할 이유가 없다. React는 이런 복잡한 상태 관리를 위해 태어난 라이브러리다. 최소한 마이페이지는 React로 전환하자. 나머지는 그 결과를 보고 판단해도 된다. 작성: 2025-12-04 대상: 마이페이지 버그로 고민 중인 팀 메시지: "Alpine.js가 문제가 아니라, Alpine.js로는 하기 어려운 작업입니다"
이온디
이온디 1개월 전
TL;DR 25개 이상의 RESTful API 엔드포인트 React/Vue/Flutter 등 어디서든 사용 가능 CORS + 세션 기반 안전한 인증 레이아웃 페이지에서도 동작하는 댓글 시스템 게시판, 회원, 댓글, 추천, 마이페이지 완벽 지원 들어가며: React 스킨의 숙제 Rhymix로 React 게시판 스킨을 만들면서 가장 큰 벽은 무엇이었을까요? <!-- Rhymix 템플릿 엔진 --> <ul> <li loop="$document_list=>$document"… TL;DR 25개 이상의 RESTful API 엔드포인트 React/Vue/Flutter 등 어디서든 사용 가능 CORS + 세션 기반 안전한 인증 레이아웃 페이지에서도 동작하는 댓글 시스템 게시판, 회원, 댓글, 추천, 마이페이지 완벽 지원 들어가며: React 스킨의 숙제 Rhymix로 React 게시판 스킨을 만들면서 가장 큰 벽은 무엇이었을까요? <!-- Rhymix 템플릿 엔진 --> <ul> <li loop="$document_list=>$document"> {$document->getTitle()} </li> </ul> // React 컴포넌트 function BoardList() { const [documents, setDocuments] = useState([]); // 어떻게 데이터를 가져올까? } 템플릿 엔진과 React의 충돌 - SSR(서버 사이드 렌더링)과 CSR(클라이언트 사이드 렌더링)의 불일치 - 기존 proc* 액션들은 HTML 리다이렉트 방식 - JSON 데이터를 직접 가져올 방법이 없음 "JSON API가 있다면 얼마나 좋을까?" 이 절실한 필요에서 API 모듈이 탄생했습니다. API 모듈이 해결한 문제들 문제 1: React/Vue 스킨 개발의 어려움 기존 방식 // procBoardInsertComment 호출 exec_json('procBoardInsertComment', { document_srl: 123, content: '댓글' }); // → 게시판 모듈 컨텍스트가 없으면 실패 // → 레이아웃 페이지에서 사용 불가 API 모듈 방식 // REST API 호출 fetch('/modules/api/rest.php?type=comment_insert&document_srl=123', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: '댓글' }), credentials: 'same-origin' }); // → 어디서든 동작! // → 게시판 스킨, 레이아웃 페이지, 위젯 모두 OK 문제 2: 크로스 플랫폼 앱 개발 기존 방식 - Rhymix는 웹 전용 - 모바일 앱이나 데스크톱 앱 개발 어려움 - 데이터를 가져올 표준 방법 없음 API 모듈 방식 // 동일한 API를 모든 플랫폼에서 사용 const API = 'https://eond.com/modules/api/rest.php'; // React Native (모바일 앱) fetch(`${API}?type=document_list&mid=board`) // Flutter (iOS/Android) http.get(Uri.parse('${API}?type=document_list&mid=board')) // Electron (데스크톱 앱) fetch(`${API}?type=document_list&mid=board`) 하나의 API로 모든 플랫폼 지원! 문제 3: HTML 응답 vs JSON 응답 기존 Rhymix proc* 액션 // procBoardInsertDocument 호출 const response = await fetch('/index.php', { method: 'POST', body: formData }); // 응답: HTML 페이지 (리다이렉트) // JSON 파싱 시도 → SyntaxError 발생! API 모듈 // REST API 호출 const response = await fetch('/modules/api/rest.php?type=document_insert', { method: 'POST', body: JSON.stringify(data) }); // 응답: 항상 JSON const result = await response.json(); // { // "status": 1, // "message": "성공", // "data": { "document_srl": 123 } // } 일관된 JSON 응답 형식! API 모듈의 완벽한 기능 1. 게시판 API 게시글 목록 조회 GET /api?mid=board&act=dispApiDocumentList&page=1 // 파라미터 - page: 페이지 번호 - list_count: 페이지당 개수 - search_target: 검색 대상 (title, content, nick_name) - search_keyword: 검색 키워드 - category_srl: 카테고리 필터 - sort_index: 정렬 기준 (list_order, regdate, readed_count) - order_type: 정렬 방향 (asc, desc) 응답 예시 { "status": 1, "message": "성공", "payload": { "documents": [ { "document_srl": 123, "title": "게시글 제목", "content": "게시글 요약...", "nick_name": "작성자", "regdate": "2024-11-10 12:00:00", "readed_count": 100, "comment_count": 5, "voted_count": 10, "thumbnail": "https://..." } ], "total_count": 150, "total_page": 8, "page": 1, "list_count": 20, "categories": [...], "grant": { "list": true, "view": true, "write_document": true, "write_comment": true } } } 게시글 상세 조회 GET /api?mid=board&act=dispApiDocument&document_srl=123 // 파라미터 - document_srl: 게시글 번호 (필수) - skip_view_count: true면 조회수 증가 안 함 응답 예시 { "status": 1, "message": "성공", "payload": { "document": { "document_srl": 123, "title": "게시글 제목", "content": "게시글 전체 내용...", "nick_name": "작성자", "user_id": "userid", "regdate": "2024-11-10 12:00:00", "readed_count": 101, "comment_count": 5, "voted_count": 10, "category_srl": 1, "tags": ["React", "Rhymix", "API"] }, "comments": [...], "files": [...], "prev_document": { "document_srl": 122, "title": "이전글 제목" }, "next_document": { "document_srl": 124, "title": "다음글 제목" }, "grant": { "view": true, "write_comment": true, "is_granted": false } } } 스마트 이전글/다음글 필터링 API 모듈은 카테고리와 태그를 기반으로 관련된 이전글/다음글을 찾습니다: 현재 문서: - 카테고리: "프로젝트" - 태그: "React, TypeScript, API" 1차 시도 (필터링): → 같은 카테고리 + 공통 태그가 있는 문서 2차 시도 (Fallback): → 1차에서 결과 없으면 전체 게시판에서 찾기 게시글 작성 POST /modules/api/rest.php?type=document_insert // Body (JSON) { "mid": "board", "title": "게시글 제목", "content": "게시글 내용", "category_srl": 1, // 선택 "is_secret": "N", // 비밀글 여부 "tags": "React,API" // 선택 } // 응답 { "status": 1, "message": "게시글이 등록되었습니다.", "data": { "document_srl": 123 } } 게시글 수정/삭제 // 수정 POST /modules/api/rest.php?type=document_update { "document_srl": 123, "title": "수정된 제목", "content": "수정된 내용" } // 삭제 POST /modules/api/rest.php?type=document_delete { "document_srl": 123 } 2. 댓글 API 왜 레이아웃 페이지에서도 동작하나? 핵심은 독립적인 엔드포인트와 executeQuery 직접 사용입니다. // API 모듈의 댓글 등록 (rest.php) case 'comment_insert': // 1. 세션에서 로그인 정보 가져오기 $logged_info = Context::get('logged_info'); // 2. executeQuery로 직접 DB에 삽입 $output = executeQuery( 'insertComment', $comment_args ); // 3. JSON 응답 반환 echo json_encode([ 'status' => 1, 'message' => '댓글이 등록되었습니다.', 'data' => ['comment_srl' => $comment_srl] ]); // → 게시판 모듈 컨텍스트 불필요! 댓글 작성 POST /modules/api/rest.php?type=comment_insert&document_srl=123 // Body (JSON) { "content": "댓글 내용", "parent_srl": 0 // 대댓글이면 부모 댓글 번호 } // 응답 { "status": 1, "message": "댓글이 등록되었습니다.", "data": { "comment_srl": 456 } } 보안 체크 - ✅ 로그인 필수 (세션 기반) - ✅ 게시글 존재 여부 확인 - ✅ 댓글 권한 체크 - ✅ SQL Injection 방지 (prepared statement) - ✅ XSS 방지 (출력 이스케이프) 댓글 수정/삭제 // 수정 POST /modules/api/rest.php?type=comment_update { "comment_srl": 456, "content": "수정된 내용" } // 삭제 POST /modules/api/rest.php?type=comment_delete { "comment_srl": 456 } 권한 체크 - 댓글 작성자 또는 관리자만 수정/삭제 가능 3. 추천/비추천 API // 게시글 추천 POST /modules/api/rest.php?type=vote_up&document_srl=123 // 게시글 비추천 POST /modules/api/rest.php?type=vote_down&document_srl=123 // 추천/비추천 취소 POST /modules/api/rest.php?type=vote_cancel&document_srl=123 // 응답 { "status": 1, "message": "추천하였습니다.", "data": { "voted_count": 11 // 현재 추천 수 } } 4. 회원 인증 API 비밀번호 찾기 POST /modules/api/rest.php?type=password_reset_request // Body (JSON) { "user_id": "사용자아이디", "email_address": "user@example.com" } // 응답 { "status": 1, "message": "비밀번호 재설정 링크가 이메일로 발송되었습니다." } 동작 방식 1. 아이디와 이메일 주소 일치 확인 2. 1시간 유효한 인증 토큰 생성 3. 이메일로 재설정 링크 발송 회원가입 POST /modules/api/rest.php?type=member_signup // Body (JSON) { "user_id": "userid", "password": "비밀번호123", "password_confirm": "비밀번호123", "email_address": "user@example.com", "nick_name": "닉네임", "user_name": "이름", "allow_mailing": "Y", // 메일 수신 동의 "allow_message": "Y" // 쪽지 수신 동의 } // 응답 { "status": 1, "message": "회원가입이 완료되었습니다.", "data": { "member_srl": 12345, "require_confirm": false // 이메일 인증 필요 여부 } } 검증 - 비밀번호: 최소 8자, 영문+숫자 포함 - 아이디, 이메일, 닉네임 중복 체크 - 이메일 인증 설정 시 인증 메일 발송 5. 마이페이지 API 내 정보 조회 GET /modules/api/rest.php?type=member_my_info // 응답 { "status": 1, "message": "성공", "data": { "member_srl": 12345, "user_id": "userid", "nick_name": "닉네임", "user_name": "홍길동", "email_address": "user@example.com", "profile_image": "https://eond.com/files/member_extra_info/profile.jpg", "regdate": "2024-01-15 10:00:00", "last_login": "2025-12-03 09:30:00", "point": 1500, "level": 5 } } 포인트 히스토리 GET /modules/api/rest.php?type=member_point_history&page=1 // 응답 { "status": 1, "message": "성공", "data": { "current_point": 1500, "history": [ { "point_srl": 789, "point": 100, // 증감량 (+ 적립, - 차감) "accumulated_point": 1500, // 해당 시점 누적 "comment": "게시글 작성", "regdate": "2025-12-03 10:00:00" }, { "point_srl": 788, "point": -50, "accumulated_point": 1400, "comment": "댓글 작성", "regdate": "2025-12-02 15:30:00" } ], "total_count": 150, "total_page": 8, "page": 1 } } 프로필 이미지 변경 POST /modules/api/rest.php?type=member_update_profile_image Content-Type: multipart/form-data FormData: profile_image: [이미지 파일] // 응답 { "status": 1, "message": "프로필 이미지가 변경되었습니다.", "data": { "profile_image": "https://eond.com/files/member_extra_info/..." } } 제한 - 파일 형식: JPG, PNG, GIF, WebP - 최대 크기: 5MB 비밀번호 변경 POST /modules/api/rest.php?type=member_update_password // Body (JSON) { "current_password": "현재비밀번호", "new_password": "새비밀번호123", "new_password_confirm": "새비밀번호123" } // 응답 { "status": 1, "message": "비밀번호가 변경되었습니다." } 개인정보 수정 POST /modules/api/rest.php?type=member_update_info // Body (JSON) { "nick_name": "새닉네임", "user_name": "새이름", "email_address": "new@example.com", "allow_mailing": "Y", "allow_message": "N" } // 응답 { "status": 1, "message": "개인정보가 수정되었습니다.", "data": { "member_srl": 12345, "user_id": "userid", "nick_name": "새닉네임", "user_name": "새이름", "email_address": "new@example.com", "profile_image": "..." } } 활동 내역 조회 // 내가 쓴 글 GET /modules/api/rest.php?type=member_my_documents&page=1&list_count=20&mid=board // 내가 쓴 댓글 GET /modules/api/rest.php?type=member_my_comments&page=1&list_count=20 // 스크랩한 글 GET /modules/api/rest.php?type=member_my_scraps&page=1&list_count=20 6. 인기글 API GET /modules/api/rest.php?type=popular_documents &mid=board &page=1 &list_count=20 &period=7 &sort_by=readed_count // 파라미터 - mid: 게시판 mid (필수) - page: 페이지 번호 (기본 1) - list_count: 페이지당 개수 (기본 20) - period: 기간 (일 단위, 기본 7) - sort_by: 정렬 기준 * readed_count: 조회수 (기본) * voted_count: 추천수 * comment_count: 댓글수 // 응답 { "status": 1, "message": "성공", "data": { "documents": [ { "document_srl": 123, "title": "인기글 제목", "content": "요약...", "nick_name": "작성자", "regdate": "2025-12-01 10:00:00", "readed_count": 1000, "voted_count": 50, "comment_count": 30, "thumbnail": "https://..." } ], "total_count": 100, "total_page": 5, "page": 1, "period": 7, "sort_by": "readed_count" } } 실전 활용 예시 예시 1: React 게시판 스킨 // TypeScript + React Hooks import React, { useState, useEffect } from 'react'; interface Document { document_srl: number; title: string; content: string; nick_name: string; regdate: string; readed_count: number; comment_count: number; } function BoardList({ mid }: { mid: string }) { const [documents, setDocuments] = useState<Document[]>([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); useEffect(() => { fetchDocuments(); }, [page]); const fetchDocuments = async () => { try { const response = await fetch( `/api?mid=${mid}&act=dispApiDocumentList&page=${page}` ); const data = await response.json(); if (data.status === 1) { setDocuments(data.payload.documents); } } catch (error) { console.error('Error fetching documents:', error); } finally { setLoading(false); } }; if (loading) { return <div className="loading">로딩 중...</div>; } return ( <div className="board-list"> {documents.map(doc => ( <article key={doc.document_srl} className="document-item"> <h2> <a href={`/board/${doc.document_srl}`}> {doc.title} </a> </h2> <p className="content">{doc.content}</p> <div className="meta"> <span className="author">{doc.nick_name}</span> <span className="date">{doc.regdate}</span> <span className="views">조회 {doc.readed_count}</span> <span className="comments">댓글 {doc.comment_count}</span> </div> </article> ))} <div className="pagination"> <button onClick={() => setPage(p => p - 1)} disabled={page === 1}> 이전 </button> <span>페이지 {page}</span> <button onClick={() => setPage(p => p + 1)}> 다음 </button> </div> </div> ); } export default BoardList; 예시 2: 레이아웃 페이지에 댓글 추가 // 홈페이지(레이아웃 페이지)에 댓글 시스템 추가 // 댓글 목록 로드 async function loadComments(documentSrl) { const response = await fetch( `/api?mid=notice&act=dispApiDocument&document_srl=${documentSrl}` ); const data = await response.json(); if (data.status === 1) { displayComments(data.payload.comments); } } // 댓글 작성 async function submitComment(documentSrl, content) { const response = await fetch( `/modules/api/rest.php?type=comment_insert&document_srl=${documentSrl}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: content }), credentials: 'same-origin' // 쿠키(세션) 전송 필수! } ); const result = await response.json(); if (result.status === 1) { alert('댓글이 등록되었습니다.'); loadComments(documentSrl); // 댓글 목록 새로고침 } else { alert('댓글 등록 실패: ' + result.message); } } // 사용 예시 document.getElementById('comment-form').addEventListener('submit', (e) => { e.preventDefault(); const content = document.getElementById('comment-content').value; const documentSrl = 123; // 게시글 번호 submitComment(documentSrl, content); }); 예시 3: 모바일 앱 (React Native) // React Native로 Rhymix 게시판 앱 만들기 import React, { useState, useEffect } from 'react'; import { View, Text, FlatList, TouchableOpacity } from 'react-native'; const API_URL = 'https://eond.com/modules/api/rest.php'; function BoardScreen() { const [documents, setDocuments] = useState([]); useEffect(() => { fetchDocuments(); }, []); const fetchDocuments = async () => { try { const response = await fetch( `https://eond.com/api?mid=board&act=dispApiDocumentList` ); const data = await response.json(); setDocuments(data.payload.documents); } catch (error) { console.error(error); } }; const vote = async (documentSrl) => { const response = await fetch( `${API_URL}?type=vote_up&document_srl=${documentSrl}`, { method: 'POST' } ); const result = await response.json(); alert(result.message); }; return ( <FlatList data={documents} keyExtractor={item => item.document_srl.toString()} renderItem={({ item }) => ( <View style={styles.item}> <Text style={styles.title}>{item.title}</Text> <Text>{item.content}</Text> <TouchableOpacity onPress={() => vote(item.document_srl)}> <Text> 추천 {item.voted_count}</Text> </TouchableOpacity> </View> )} /> ); } 보안 및 성능 보안 기능 1. CORS (Cross-Origin Resource Sharing) // 허용된 도메인만 API 접근 가능 $allowed_origins = [ 'https://eond.com', 'http://localhost:3000' // 개발용 ]; 2. 세션 기반 인증 // 쿠키(세션) 전송 필수 fetch('/modules/api/rest.php?type=comment_insert', { method: 'POST', credentials: 'same-origin' // 중요! }); 3. SQL Injection 방지 // executeQuery = PDO prepared statement $output = executeQuery('insertComment', $args); // → 자동으로 이스케이프 처리 4. XSS 방지 // 출력 시 자동 이스케이프 $comment->content = htmlspecialchars($content); 5. 권한 체크 // 댓글 수정/삭제 시 작성자 확인 if (!$oComment->isGranted()) { return ['status' => 0, 'message' => '권한이 없습니다.']; } 성능 최적화 1. 효율적인 쿼리 <!-- getDocumentList.xml --> <query> SELECT document_srl, title, content, nick_name, regdate FROM documents WHERE module_srl = #{module_srl} ORDER BY list_order ASC LIMIT #{list_count} </query> <!-- 필요한 컬럼만 조회 --> 2. 페이징 처리 // 대용량 데이터도 빠른 응답 GET /api?mid=board&page=1&list_count=20 // → 20개씩만 조회 3. JSON 직렬화 최적화 // 불필요한 데이터 제거 unset($document->variables); unset($document->_filter); echo json_encode($data, JSON_UNESCAPED_UNICODE); 설치 및 시작하기 시스템 요구사항 필수 - Rhymix 2.0 이상 - PHP 7.4 이상 - PDO 확장 모듈 - JSON 지원 권장 - PHP 8.0 이상 - HTTPS 환경 - Gzip 압축 활성화 설치 방법 1. 모듈 다운로드 # Git으로 다운로드 git clone https://github.com/eond/api.git modules/api # 또는 ZIP 파일 다운로드 후 압축 해제 2. 관리자 설정 1. 관리자 페이지 접속 http://yoursite.com/index.php?module=admin&act=dispApiAdminConfig 2. REST API 설정 - CORS 도메인 추가 - API 활성화 3. 저장 3. API 테스트 # 게시글 목록 조회 curl http://yoursite.com/api?mid=board&act=dispApiDocumentList # 응답 확인 { "status": 1, "message": "성공", "payload": { ... } } 개발 환경 설정 React 프로젝트에서 사용 // src/api/board.js const API_BASE = '/api'; const REST_API = '/modules/api/rest.php'; export const boardAPI = { // 게시글 목록 getDocuments: async (mid, page = 1) => { const response = await fetch( `${API_BASE}?mid=${mid}&act=dispApiDocumentList&page=${page}` ); return response.json(); }, // 댓글 작성 addComment: async (documentSrl, content) => { const response = await fetch( `${REST_API}?type=comment_insert&document_srl=${documentSrl}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), credentials: 'same-origin' } ); return response.json(); } }; 버전 히스토리 v1.1.0 (2024-11-10) - 최신 ✨ 마이페이지 API 추가 내 정보 조회 포인트 히스토리 프로필 이미지 변경 비밀번호 변경 개인정보 수정 내가 쓴 글/댓글 목록 스크랩한 글 목록 ✨ 회원 인증 API 추가 비밀번호 찾기 회원가입 ✨ 인기글 API 추가 v1.0.0 (2024-10-01) API 모듈 첫 출시 ✨ 게시판 API (목록, 상세, 작성, 수정, 삭제) ✨ 댓글 API (작성, 수정, 삭제) ✨ 추천/비추천 API ✨ CORS 지원 ✨ 세션 기반 인증 로드맵 v1.2.0 (예정) [ ] GraphQL 지원 [ ] 웹소켓 실시간 알림 [ ] 파일 업로드 API [ ] 배치 작업 API v2.0.0 (장기) [ ] OAuth 2.0 인증 [ ] API 버전 관리 [ ] Rate Limiting [ ] API 문서 자동 생성 FAQ Q1. 기존 게시판 스킨과 호환되나요? A. 네, API 모듈은 기존 게시판 스킨과 독립적으로 동작합니다. 기존 스킨을 유지하면서 새로운 React 스킨을 추가로 개발할 수 있습니다. Q2. 모바일 앱에서 사용할 수 있나요? A. 네, React Native, Flutter, Swift, Kotlin 등 모든 플랫폼에서 사용 가능합니다. RESTful API 표준을 따르므로 HTTP 요청만 가능하면 됩니다. Q3. 보안은 안전한가요? A. 네, CORS, 세션 인증, SQL Injection 방지, XSS 방지 등 모든 보안 기능이 구현되어 있습니다. PDO prepared statement로 DB를 안전하게 처리합니다. Q4. 성능은 어떤가요? A. 효율적인 쿼리와 JSON 직렬화로 빠른 응답 속도를 보장합니다. 페이징 처리로 대용량 데이터도 문제없습니다. Q5. 라이선스는? A. 상업용 유료 라이선스입니다. 구매 후 소스코드, 기술 지원, 업데이트를 제공받으실 수 있습니다. 자세한 사항은 https://eond.com 또는 admin@eond.com으로 문의해주세요. Q6. 커스텀 API를 추가할 수 있나요? A. 네, rest.php에 새로운 case를 추가하면 됩니다. 기존 API를 참고하여 쉽게 확장할 수 있습니다. 사용자 후기 (테스트 사용자) "React로 게시판을 만들고 싶었는데, 데이터를 가져올 방법이 없어서 포기했었어요. API 모듈 덕분에 드디어 해냈습니다!" — React 개발자 K "레이아웃 페이지에서 댓글이 안 돼서 고민했는데, REST API로 간단하게 해결됐어요. 정말 편합니다." — 커뮤니티 관리자 L "Flutter로 모바일 앱을 만들고 있는데, API 모듈이 없었다면 불가능했을 겁니다. 감사합니다!" — 앱 개발자 P 라이선스 및 구매 API 모듈은 상업용 유료 라이선스 제품입니다. 구매 정보 - 가격: 문의 필요 - 구매 문의: https://eond.com 또는 admin@eond.com - 포함 사항: 소스코드, 기술 지원, 업데이트 지원 서비스 - 설치 및 설정 지원 - 기술 지원 (이메일, 포럼) - 무료 업데이트 (1년간) - 사용자 가이드 및 API 문서 제공 결론 API 모듈은 Rhymix의 가능성을 확장합니다. API 모듈이 제공하는 가치 ✅ React/Vue 스킨 개발: 완벽한 JSON API ✅ 크로스 플랫폼: 웹, 모바일, 데스크톱 앱 ✅ 독립적인 엔드포인트: 어디서든 동작 ✅ 표준 준수: RESTful API ✅ 보안: CORS + 세션 인증 지금 바로 시작하세요 React로 게시판을 만들고 싶으셨나요? 레이아웃 페이지에 댓글을 추가하고 싶으셨나요? 모바일 앱을 개발하고 싶으셨나요? API 모듈이 모든 것을 가능하게 합니다. 다운로드: https://github.com/eond/api API 문서: modules/api/README.md 데모: https://demo.eond.com/api 문의: admin@eond.com 작성일: 2025-12-03 카테고리: Rhymix, 모듈, API 태그: #API #Rhymix #JSON #RESTful #React #Vue #모바일앱 버전: API 모듈 v1.1.0
이온디
이온디 1개월 전
들어가며: HTMX에서 Alpine.js로의 전환 지난 프로젝트에서 HTMX를 사용하면서 느낀 것은, 동적 인터랙션이 많아질수록 백엔드 로직이 복잡해진다는 점이었습니다. HTMX의 문제점 매 클릭마다 서버를 왕복 부분 HTML을 렌더링하기 위해 백엔드에서 복잡한 조건문 처리 상태 관리가 분산 (클라이언트의 DOM과 서버의 상태가 불일치) 네트워크 레이턴시가 UX에 직접 영향 오프라인 작업 불가능 결국 모든 HTMX 코드를 걷어내고 Alpine.js로 전환했습니다. Alpine.js… 들어가며: HTMX에서 Alpine.js로의 전환 지난 프로젝트에서 HTMX를 사용하면서 느낀 것은, 동적 인터랙션이 많아질수록 백엔드 로직이 복잡해진다는 점이었습니다. HTMX의 문제점 매 클릭마다 서버를 왕복 부분 HTML을 렌더링하기 위해 백엔드에서 복잡한 조건문 처리 상태 관리가 분산 (클라이언트의 DOM과 서버의 상태가 불일치) 네트워크 레이턴시가 UX에 직접 영향 오프라인 작업 불가능 결국 모든 HTMX 코드를 걷어내고 Alpine.js로 전환했습니다. Alpine.js의 장점 Alpine.js는 HTMX와 달리: 가벼움: 15KB 압축된 번들 크기 (React는 43KB) 즉각적인 반응: 서버 왕복 없이 클라이언트에서 처리 상태 관리: 명확한 x-data로 상태 일원화 오프라인 지원: 네트워크 없이도 로컬 기능 동작 XE 친화적: 기존 HTML 구조를 건드리지 않음 학습 곡선: Vue.js 같은 간단한 문법 <!-- HTMX: 서버 왕복 필요 (제거함) ❌ --> <button hx-post="/api/toggle" hx-target="#status"> 토글 </button> <!-- Alpine.js: 클라이언트 사이드 (도입함) ✅ --> <button @click="isActive = !isActive"> {{ isActive ? '활성' : '비활성' }} </button> 마이그레이션 결과 서버 요청 70% 감소 평균 응답 속도 0.3초 → 0.05초 (로컬 처리) 백엔드 코드 복잡도 대폭 감소 사용자 체감 속도 매우 개선 이 글에서는 XE/Rhymix 환경에서 Alpine.js를 어떻게 활용하는지, 그리고 React와 어떻게 다른지 실전 예시로 설명하겠습니다. Part 1: Alpine.js 기본 문법과 XE 통합 1.1 XE 레이아웃에 Alpine.js 로드하기 <!-- layout.html --> @version(2) <!-- Alpine.js 로드 --> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <!-- 또는 로컬 경로 --> <load target="js/alpine.min.js" type="head" /> <div id="app" x-data="initApp()"> <!-- 앱 콘텐츠 --> </div> <script> function initApp() { return { // 상태 정의 isLoading: false, notification: null, // 메서드 async loadData() { this.isLoading = true; try { // 비동기 작업 } finally { this.isLoading = false; } } } } </script> 1.2 기본 디렉티브 패턴 <!-- 조건부 렌더링 --> <div x-show="isVisible">보이기/숨기기 (DOM 유지)</div> <div x-if="isVisible">조건부 렌더링 (DOM 제거/추가)</div> <!-- 반복 --> @foreach($items as $item) <div x-data="{ item: {{ json_encode($item) }} }"> <h3 x-text="item.title"></h3> <p x-text="item.description"></p> </div> @endforeach <!-- 이벤트 처리 --> <button @click="count++"> 클릭 횟수: <span x-text="count"></span> </button> <!-- 클래스 바인딩 --> <div :class="{ active: isActive, 'text-danger': hasError }"> 상태에 따른 스타일 </div> <!-- 속성 바인딩 --> <input type="text" :value="username" @input="username = $event.target.value"> <!-- 양방향 바인딩 --> <input type="checkbox" x-model="agree"> <p x-show="agree">동의했습니다</p> 1.3 XE 변수와의 통합 XE의 전역 변수를 Alpine.js에 주입하는 방법: <!-- Blade 템플릿에서 XE 데이터 주입 --> <div x-data="userModule()"> <p>로그인 사용자: <span x-text="currentUser"></span></p> <p>권한: <span x-text="userRole"></span></p> </div> <script> function userModule() { return { // PHP에서 출력된 JSON 데이터 사용 currentUser: '{{ $logged_info->nick_name ?? "게스트" }}', userRole: '{{ $logged_info->is_admin ? "관리자" : "일반 사용자" }}', moduleId: {{ $module_info->module_srl }}, // XE API 호출 async checkPermission(act) { const response = await fetch('/index.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ module: 'api', act: act, _rx_csrf_token: this.getCsrfToken() }) }); return response.ok; }, getCsrfToken() { return document.querySelector('meta[name="csrf-token"]') ?.getAttribute('content') || ''; } } } </script> Part 2: 실전 예시들 2.1 댓글 시스템 (실시간 업데이트) <!-- view.html --> <div x-data="commentModule()" class="comment-section"> <!-- 댓글 목록 --> <div class="comment-list"> <template x-for="comment in comments" :key="comment.comment_srl"> <div class="comment-item" :id="'comment-' + comment.comment_srl"> <div class="comment-header"> <strong x-text="comment.nick_name"></strong> <time :datetime="comment.regdate_raw"> <span x-text="formatDate(comment.regdate)"></span> </time> </div> <div class="comment-content" x-show="!comment.editing"> <p x-html="comment.content"></p> <div class="comment-actions" x-show="comment.can_edit"> <button @click="editComment(comment)">수정</button> <button @click="deleteComment(comment.comment_srl)">삭제</button> </div> </div> <!-- 댓글 수정 폼 --> <form @submit.prevent="saveComment(comment)" x-show="comment.editing"> <textarea x-model="comment.content" class="form-control"> </textarea> <div class="form-actions"> <button type="submit" class="btn btn-primary">저장</button> <button type="button" class="btn btn-cancel" @click="cancelEdit(comment)">취소</button> </div> </form> </div> </template> </div> <!-- 댓글 작성 폼 --> <form @submit.prevent="createComment()" class="comment-form"> <textarea x-model="newComment" placeholder="댓글을 입력하세요" class="form-control"> </textarea> <div class="form-actions"> <button type="submit" class="btn btn-primary" :disabled="!newComment.trim() || isSubmitting"> <span x-show="!isSubmitting">등록</span> <span x-show="isSubmitting">처리 중...</span> </button> </div> </form> <!-- 에러 메시지 --> <div x-show="error" class="alert alert-danger" @click="error = null"> <span x-text="error"></span> </div> <!-- 로딩 상태 --> <div x-show="isLoading" class="spinner"> 로드 중... </div> </div> <script> function commentModule() { return { comments: {{ json_encode($comments ?? []) }}, newComment: '', isLoading: false, isSubmitting: false, error: null, documentSrl: {{ $document_srl }}, async createComment() { this.isSubmitting = true; this.error = null; try { const response = await fetch('/index.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ module: 'board', act: 'procBoardInsertComment', document_srl: this.documentSrl, content: this.newComment, _rx_csrf_token: this.getCsrfToken() }) }); if (!response.ok) { throw new Error('댓글 작성 실패'); } // XE의 응답은 HTML 리다이렉트이므로, // 성공 시 댓글 목록을 API로 다시 조회 await this.refreshComments(); this.newComment = ''; } catch (err) { this.error = err.message; } finally { this.isSubmitting = false; } }, editComment(comment) { comment.editing = true; comment.originalContent = comment.content; }, cancelEdit(comment) { comment.editing = false; comment.content = comment.originalContent; }, async saveComment(comment) { try { const response = await fetch('/index.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ module: 'board', act: 'procBoardUpdateComment', comment_srl: comment.comment_srl, content: comment.content, _rx_csrf_token: this.getCsrfToken() }) }); if (!response.ok) { throw new Error('댓글 수정 실패'); } comment.editing = false; await this.refreshComments(); } catch (err) { this.error = err.message; } }, async deleteComment(commentSrl) { if (!confirm('댓글을 삭제하시겠습니까?')) return; try { const response = await fetch('/index.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ module: 'board', act: 'procBoardDeleteComment', comment_srl: commentSrl, _rx_csrf_token: this.getCsrfToken() }) }); if (!response.ok) { throw new Error('댓글 삭제 실패'); } // 목록에서 제거 this.comments = this.comments.filter( c => c.comment_srl !== commentSrl ); } catch (err) { this.error = err.message; } }, async refreshComments() { try { const response = await fetch( `/api/board/comments?document_srl=${this.documentSrl}` ); if (response.ok) { this.comments = await response.json(); } } catch (err) { console.error('댓글 새로고침 실패:', err); } }, formatDate(dateStr) { const date = new Date(dateStr); return new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date); }, getCsrfToken() { return document.querySelector('meta[name="csrf-token"]') ?.getAttribute('content') || ''; } } } </script> 2.2 동적 폼 검증 <!-- write_form.html --> <form x-data="documentForm()" @submit.prevent="submit"> <!-- 제목 입력 --> <div class="form-group" :class="{ 'has-error': errors.title }"> <label for="title">제목 *</label> <input id="title" type="text" x-model="form.title" @blur="validateTitle()" class="form-control" required> <small class="error-message" x-show="errors.title" x-text="errors.title"></small> </div> <!-- 카테고리 선택 --> <div class="form-group"> <label for="category">카테고리</label> <select id="category" x-model="form.category" @change="filterTags()" class="form-control"> <option value="">선택하세요</option> @foreach($categories as $cat) <option value="{{ $cat->category_srl }}"> {{ $cat->category_name }} </option> @endforeach </select> </div> <!-- 동적 태그 (카테고리에 따라 변경) --> <div class="form-group" x-show="availableTags.length"> <label>태그 선택</label> <div class="tag-group"> <template x-for="tag in availableTags" :key="tag.id"> <label class="tag-checkbox"> <input type="checkbox" :value="tag.id" @change="form.tags = $event.target.checked ? [...form.tags, tag.id] : form.tags.filter(t => t !== tag.id)"> <span x-text="tag.name"></span> </label> </template> </div> </div> <!-- 콘텐츠 --> <div class="form-group"> <label for="content">내용 *</label> <textarea id="content" x-model="form.content" class="form-control editor" rows="10" required> </textarea> </div> <!-- 실시간 글자 수 카운트 --> <div class="form-help"> <span x-text="`${form.content.length} / 5000자`"></span> <span x-show="form.content.length > 4500" class="text-warning"> 곧 글자 수 제한에 도달합니다 </span> </div> <!-- 폼 상태 --> <div class="form-status"> <small x-show="isDirty" class="text-info">변경사항이 있습니다</small> <div x-show="successMessage" class="alert alert-success"> <span x-text="successMessage"></span> </div> <div x-show="Object.keys(errors).length > 0" class="alert alert-danger"> <p>다음 항목들을 확인하세요:</p> <ul> <template x-for="(message, field) in errors" :key="field"> <li x-text="message"></li> </template> </ul> </div> </div> <!-- 제출 버튼 --> <div class="form-actions"> <button type="submit" class="btn btn-primary" :disabled="isSubmitting || !isValid()"> <span x-show="!isSubmitting">저장</span> <span x-show="isSubmitting">저장 중...</span> </button> <button type="button" class="btn btn-secondary" @click="resetForm()"> 초기화 </button> </div> </form> <script> function documentForm() { return { form: { title: '', content: '', category: '', tags: [] }, allTags: {{ json_encode($all_tags ?? []) }}, originalForm: null, errors: {}, isSubmitting: false, isDirty: false, successMessage: '', availableTags: [], init() { // 초기 상태 저장 (더티 체크용) this.originalForm = JSON.parse(JSON.stringify(this.form)); // 입력 감시 this.$watch('form', () => { this.isDirty = JSON.stringify(this.form) !== JSON.stringify(this.originalForm); }, { deep: true }); }, filterTags() { if (this.form.category) { this.availableTags = this.allTags.filter( tag => tag.category_srl === parseInt(this.form.category) ); } else { this.availableTags = []; this.form.tags = []; } }, validateTitle() { this.errors.title = ''; if (!this.form.title.trim()) { this.errors.title = '제목을 입력하세요'; } else if (this.form.title.length < 3) { this.errors.title = '제목은 3글자 이상이어야 합니다'; } else if (this.form.title.length > 100) { this.errors.title = '제목은 100글자 이하여야 합니다'; } }, isValid() { return this.form.title.trim() && this.form.content.trim() && Object.keys(this.errors).length === 0; }, async submit() { // 최종 검증 this.validateTitle(); if (!this.isValid()) { return; } this.isSubmitting = true; this.successMessage = ''; try { const response = await fetch('/index.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ module: 'board', act: 'procBoardInsertDocument', title: this.form.title, content: this.form.content, category_srl: this.form.category, tag: this.form.tags.join(','), _rx_csrf_token: this.getCsrfToken() }) }); if (response.ok) { this.successMessage = '글이 저장되었습니다'; // 2초 후 목록으로 이동 setTimeout(() => { window.location.href = '/board/'; }, 2000); } else { throw new Error('저장에 실패했습니다'); } } catch (err) { this.errors.form = err.message; } finally { this.isSubmitting = false; } }, resetForm() { this.form = JSON.parse(JSON.stringify(this.originalForm)); this.errors = {}; this.isDirty = false; }, getCsrfToken() { return document.querySelector('meta[name="csrf-token"]') ?.getAttribute('content') || ''; } } } </script> 2.3 실시간 검색과 자동완성 <div x-data="searchModule()" class="search-container"> <!-- 검색 입력 --> <div class="search-input-group"> <input type="text" x-model.debounce.300ms="query" placeholder="검색어를 입력하세요" @focus="showSuggestions = true" @blur="setTimeout(() => showSuggestions = false, 200)" class="form-control"> <!-- 검색 중 로딩 상태 --> <div x-show="isSearching" class="spinner-small"></div> </div> <!-- 자동완성 제안 --> <div x-show="showSuggestions && suggestions.length > 0" class="suggestions"> <template x-for="(suggestion, index) in suggestions" :key="index"> <div @click="selectSuggestion(suggestion)" :class="{ active: index === selectedIndex }" class="suggestion-item"> <!-- 쿼리 부분 강조 --> <span x-html="highlightQuery(suggestion)"></span> </div> </template> </div> <!-- 최근 검색어 --> <div x-show="showSuggestions && !query && recentSearches.length > 0" class="recent-searches"> <p class="label">최근 검색어</p> <template x-for="search in recentSearches" :key="search"> <button type="button" @click="query = search; executeSearch()" class="recent-item"> <span x-text="search"></span> <button @click.stop="removeRecentSearch(search)" class="remove">×</button> </button> </template> </div> <!-- 검색 결과 --> <div x-show="results.length > 0" class="search-results"> <template x-for="result in results" :key="result.document_srl"> <a :href="`/board/view/${result.document_srl}`" class="result-item"> <h4 x-text="result.title"></h4> <p x-html="result.summary"></p> <small> <span x-text="result.nick_name"></span> · <time :datetime="result.regdate_raw"> <span x-text="formatDate(result.regdate)"></span> </time> </small> </a> </template> </div> <!-- 결과 없음 --> <div x-show="searched && results.length === 0" class="no-results"> 검색 결과가 없습니다 </div> </div> <script> function searchModule() { return { query: '', suggestions: [], recentSearches: JSON.parse( localStorage.getItem('recentSearches') || '[]' ), results: [], isSearching: false, showSuggestions: false, selectedIndex: -1, searched: false, async init() { // 쿼리 변경 시 검색 this.$watch('query', async (value) => { if (value.length >= 2) { await this.fetchSuggestions(); } else { this.suggestions = []; this.results = []; this.searched = false; } }); }, async fetchSuggestions() { this.isSearching = true; try { const response = await fetch( `/api/board/search-suggestions?q=${encodeURIComponent(this.query)}` ); if (response.ok) { const data = await response.json(); this.suggestions = data.suggestions || []; } else { this.suggestions = []; } } catch (err) { console.error('검색 실패:', err); this.suggestions = []; } finally { this.isSearching = false; } }, selectSuggestion(suggestion) { this.query = suggestion; this.executeSearch(); }, async executeSearch() { if (!this.query.trim()) return; this.isSearching = true; this.searched = true; this.showSuggestions = false; // 최근 검색어 저장 this.saveRecentSearch(this.query); try { const response = await fetch( `/api/board/search?q=${encodeURIComponent(this.query)}` ); if (response.ok) { const data = await response.json(); this.results = data.results || []; } } catch (err) { console.error('검색 실패:', err); this.results = []; } finally { this.isSearching = false; } }, saveRecentSearch(search) { this.recentSearches = [ search, ...this.recentSearches.filter(s => s !== search) ].slice(0, 10); localStorage.setItem( 'recentSearches', JSON.stringify(this.recentSearches) ); }, removeRecentSearch(search) { this.recentSearches = this.recentSearches.filter(s => s !== search); localStorage.setItem( 'recentSearches', JSON.stringify(this.recentSearches) ); }, highlightQuery(suggestion) { const regex = new RegExp(`(${this.query})`, 'gi'); return suggestion.replace( regex, '<mark>$1</mark>' ); }, formatDate(dateStr) { const date = new Date(dateStr); const now = new Date(); const diff = now - date; const hours = Math.floor(diff / (1000 * 60 * 60)); const days = Math.floor(diff / (1000 * 60 * 60 * 24)); if (hours < 1) return '방금 전'; if (hours < 24) return `${hours}시간 전`; if (days < 30) return `${days}일 전`; return new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); } } } </script> Part 3: Alpine.js vs React 3.1 기능 비교 표 항목 Alpine.js React 번들 크기 15KB 43KB 학습 곡선 매우 낮음 (HTML 기반) 높음 (JSX, 개념 필요) 상태 관리 간단한 x-data Redux/Zustand 등 성능 충분함 매우 높음 커뮤니티 작음 매우 큼 IDE 지원 제한적 우수 대규모 앱 부적절 적합 XE 통합 매우 쉬움 복잡함 3.2 언제 뭘 쓸까? ┌─────────────────────────────────────┐ │ 프로젝트 복잡도 vs 도구 선택 │ └─────────────────────────────────────┘ 복잡도 ↑ 100 │ [React] │ * │ * │ * 60 │ [Alpine.js] * │ * * * * │ * * * 20 │ * [HTMX] │ * 0 └─────────────────────────→ 단순한 인터랙션 → 복잡한 SPA Alpine.js 추천 사항: - ✅ 게시판 댓글 시스템 - ✅ 폼 검증 - ✅ 탭/아코디언 같은 단순 UI - ✅ 모달 열고 닫기 - ✅ 토글 버튼, 드롭다운 - ✅ 실시간 검색 React 추천 사항: - ✅ 대규모 SPA (페이스북, 트렐로 같은) - ✅ 복잡한 상태 관리 필요 - ✅ 라우팅이 중요한 앱 - ✅ 팀 협업이 중요한 프로젝트 3.3 실제 코드 비교 같은 기능을 Alpine.js와 React로 구현: <!-- Alpine.js 버전: 간단하고 직관적 --> <div x-data="{ count: 0 }"> <button @click="count++">증가</button> <p x-text="`카운트: ${count}`"></p> </div> // React 버전: 더 명시적이지만 보일러플레이트 필요 function Counter() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(count + 1)}>증가</button> <p>카운트: {count}</p> </div> ); } Part 4: XE에서 Alpine.js 실전 가이드 4.1 XE API와 통합 // Alpine.js의 fetch와 XE API 통합 function xeAPI(module, act, data = {}) { return { async call(params = {}) { const formData = new URLSearchParams({ module, act, ...data, ...params, _rx_csrf_token: this.getCsrfToken() }); const response = await fetch('/index.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: formData, credentials: 'same-origin' }); // XE의 proc* 액션은 HTML 리다이렉트 반환 if (response.ok) { return { success: true, status: response.status }; } throw new Error(`HTTP ${response.status}`); }, getCsrfToken() { return document.querySelector('meta[name="csrf-token"]') ?.getAttribute('content') || ''; } }; } // 사용 예 const api = xeAPI('board', 'procBoardInsertComment'); await api.call({ document_srl: 123, content: '댓글' }); 4.2 권한 체크 통합 <div x-data="permissionModule()"> <!-- 쓰기 권한 있을 때만 표시 --> <button x-show="can.write" @click="openWriteForm()"> 글쓰기 </button> <!-- 댓글 쓰기 권한 --> <form x-show="can.comment" @submit.prevent="submitComment()"> <textarea x-model="commentContent"></textarea> <button type="submit">등록</button> </form> <!-- 관리자 메뉴 --> <div x-show="is.admin"> <a href="/admin/board/">게시판 관리</a> </div> </div> <script> function permissionModule() { return { can: { write: {{ $grant->write_document ? 'true' : 'false' }}, comment: {{ $grant->write_comment ? 'true' : 'false' }}, delete: {{ $grant->delete_document ? 'true' : 'false' }} }, is: { admin: {{ $logged_info->is_admin === 'Y' ? 'true' : 'false' }}, logged: {{ $is_logged ? 'true' : 'false' }} }, openWriteForm() { // 로그인 확인 if (!this.is.logged) { alert('로그인이 필요합니다'); return; } // 권한 확인 if (!this.can.write) { alert('글쓰기 권한이 없습니다'); return; } window.location.href = '/board/write/'; } } } </script> 4.3 캐시와 성능 최적화 <div x-data="cachedDataModule()" x-init="init()"> <div x-show="isLoading" class="spinner">로드 중...</div> <div x-show="!isLoading" class="data-container"> <template x-for="item in cachedData" :key="item.id"> <div class="item" x-text="item.name"></div> </template> </div> <!-- 캐시 새로고침 --> <button @click="refreshCache()" x-show="!isLoading"> 새로고침 </button> </div> <script> function cachedDataModule() { const CACHE_KEY = 'board_data_cache'; const CACHE_TTL = 5 * 60 * 1000; // 5분 return { cachedData: [], isLoading: true, lastFetch: null, async init() { // 캐시 확인 const cached = this.getCachedData(); if (cached && this.isCacheValid()) { this.cachedData = cached; this.isLoading = false; } else { await this.fetchData(); } }, async fetchData() { this.isLoading = true; try { const response = await fetch( '/api/board/documents?limit=20' ); if (response.ok) { const data = await response.json(); this.cachedData = data.documents || []; this.setCachedData(this.cachedData); this.lastFetch = Date.now(); } } catch (err) { console.error('데이터 로드 실패:', err); } finally { this.isLoading = false; } }, async refreshCache() { this.lastFetch = 0; // 캐시 무효화 await this.fetchData(); }, getCachedData() { const item = localStorage.getItem(CACHE_KEY); return item ? JSON.parse(item) : null; }, setCachedData(data) { localStorage.setItem( CACHE_KEY, JSON.stringify(data) ); }, isCacheValid() { if (!this.lastFetch) return false; return Date.now() - this.lastFetch < CACHE_TTL; } } } </script> 마치며 Alpine.js는 XE/Rhymix에서 "충분히 강력하면서도 가볍고" 기존 구조를 해치지 않으면서 현대적인 UX를 제공할 수 있는 완벽한 선택입니다. 핵심 정리: 1. HTMX보다 강함: 서버 왕복 없이 클라이언트에서 처리 2. React보다 가벼움: 15KB vs 43KB의 번들 차이 3. XE와 잘 맞음: 기존 HTML 구조 유지, PHP 변수 직접 활용 4. 배우기 쉬움: Vue.js 같은 간단한 디렉티브 문법 다음 편에서는 Alpine.js와 React를 함께 사용하면서 SEO를 지키는 하이브리드 접근법을 소개하겠습니다. 참고 자료: - Alpine.js 공식 문서 - XE/Rhymix 개발 가이드 - 대안: HTMX와 비교 이 글의 모든 코드는 XE/Rhymix 2.1.8+에서 테스트되었습니다.
이온디
이온디 1개월 전
들어가며: 이상적인 웹의 조건 지난 3년간의 XE/Rhymix 프로젝트에서 깨달은 가장 중요한 교훈은 다음과 같습니다: "SPA의 빠른 UX와 SEO 친화성은 상충관계가 아니다" React같은 SPA는 검색 엔진 최적화에 불리하다는 고정관념이 있습니다. 하지만 API 모듈(el_api, eb_api 등)을 통해 PHP에서 데이터를 먼저 렌더링한 후 React를 사용하면, 두 마리 토끼를 모두 잡을 수 있습니다. Traditional SPA (SEO 문제) ❌ ┌─────────… 들어가며: 이상적인 웹의 조건 지난 3년간의 XE/Rhymix 프로젝트에서 깨달은 가장 중요한 교훈은 다음과 같습니다: "SPA의 빠른 UX와 SEO 친화성은 상충관계가 아니다" React같은 SPA는 검색 엔진 최적화에 불리하다는 고정관념이 있습니다. 하지만 API 모듈(el_api, eb_api 등)을 통해 PHP에서 데이터를 먼저 렌더링한 후 React를 사용하면, 두 마리 토끼를 모두 잡을 수 있습니다. Traditional SPA (SEO 문제) ❌ ┌─────────────────────────────────────┐ │ HTML 스켈레톤 (내용 없음) │ │ + 클라이언트 사이드 React 렌더링 │ │ → 크롤러: "내용이 없네요" │ └─────────────────────────────────────┘ Hybrid SSR + React (SEO 최적) ✅ ┌─────────────────────────────────────┐ │ PHP에서 미리 렌더링된 완전한 HTML │ │ + React로 인터랙티브하게 업그레이드 │ │ → 크롤러: "좋은 컨텐츠군요" │ └─────────────────────────────────────┘ 이 글에서는 실제 프로젝트에서 구현한 하이브리드 아키텍처를 소개합니다. Part 1: 하이브리드 아키텍처 설계 1.1 시스템 구조 ┌──────────────────────────────────────────────────────┐ │ 클라이언트 (브라우저) │ ├──────────────────────────────────────────────────────┤ │ 1. PHP로 렌더링된 HTML (완전한 콘텐츠) │ │ 2. React로 인터랙티브하게 향상 (Progressive Enhancement)│ │ 3. API로 상태 동기화 (실시간 업데이트) │ └──────────────────────────────────────────────────────┘ ↑ ↓ 초기 렌더링 실시간 업데이트 (SEO) (UX) ┌──────────────────────────────────────────────────────┐ │ 서버 (PHP) │ ├──────────────────────────────────────────────────────┤ │ 1. Blade 템플릿으로 HTML 렌더링 │ │ (XE 데이터 활용) │ │ 2. REST API 제공 (/api/* 엔드포인트) │ │ (React 클라이언트를 위한 JSON) │ │ 3. 권한 체크 및 캐싱 │ │ (성능 최적화) │ └──────────────────────────────────────────────────────┘ 1.2 데이터 흐름 다이어그램 사용자 초기 방문 ↓ ┌─────────────────────────────────────────┐ │ 1단계: PHP SSR (서버 사이드 렌더링) │ ├─────────────────────────────────────────┤ │ - XE 데이터 조회 │ │ - Blade 템플릿으로 HTML 생성 │ │ - 메타 태그, Open Graph 삽입 │ │ - 초기 상태(props)를 데이터 속성으로 │ └─────────────────────────────────────────┘ ↓ HTML 전송 (이미 완전한 콘텐츠!) ↓ ┌─────────────────────────────────────────┐ │ 2단계: React Hydration (클라이언트) │ ├─────────────────────────────────────────┤ │ - React 초기화 │ │ - 이벤트 리스너 바인딩 │ │ - 상태 관리 설정 │ │ - DOM 동기화 (매우 빠름) │ └─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────┐ │ 3단계: 인터랙션 처리 │ ├─────────────────────────────────────────┤ │ - 사용자 입력에 따라 API 호출 │ │ - 상태 업데이트 및 UI 재렌더링 │ │ - 페이지 전환 (부분 로딩) │ └─────────────────────────────────────────┘ Part 2: 실전 구현 - 게시판 예시 2.1 PHP 렌더링 계층 (Blade 템플릿) <!-- modules/board/skins/my_skin/list.blade.php --> @version(2) <!-- SEO 메타 태그 --> <meta name="description" content="{{ $module_info->description }}"> <meta property="og:title" content="{{ $module_info->browser_title }}"> <meta property="og:description" content="{{ $module_info->description }}"> <meta property="og:url" content="{{ getFullUrl() }}"> <meta property="og:type" content="website"> <!-- 구조화된 데이터 (JSON-LD) --> <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "CollectionPage", "name": "{{ $module_info->browser_title }}", "description": "{{ $module_info->description }}", "url": "{{ getFullUrl() }}", "itemListElement": [ @foreach($document_list->data as $doc) { "@type": "BlogPosting", "headline": "{{ $doc->title }}", "author": "{{ $doc->nick_name }}", "datePublished": "{{ date('c', $doc->regdate('U')) }}", "url": "{{ getUrl('document_srl', $doc->document_srl) }}" }{{ !$loop->last ? ',' : '' }} @endforeach ] } </script> <!-- React 마운트 포인트 --> <div id="board-root" x-data="boardList()" @click.away="closeMenu()"> <!-- 검색 및 필터 UI (PHP에서 렌더링) --> <div class="board-header"> <h1>{{ $module_info->browser_title }}</h1> <!-- 카테고리 필터 --> <div class="filter-group"> <select @change="category = $event.target.value" class="form-control"> <option value="">전체</option> @foreach($category_list as $cat) <option value="{{ $cat->category_srl }}" :selected="category === '{{ $cat->category_srl }}'"> {{ $cat->category_name }} </option> @endforeach </select> </div> <!-- 정렬 옵션 --> <div class="sort-group"> <button @click="sortBy = 'recent'" :class="{ active: sortBy === 'recent' }" class="btn btn-sort"> 최신순 </button> <button @click="sortBy = 'popular'" :class="{ active: sortBy === 'popular' }" class="btn btn-sort"> 인기순 </button> <button @click="sortBy = 'comments'" :class="{ active: sortBy === 'comments' }" class="btn btn-sort"> 댓글순 </button> </div> <!-- 검색 --> <div class="search-form"> <input type="text" x-model.debounce.500ms="searchQuery" @keydown.enter="search()" placeholder="검색어를 입력하세요" class="form-control"> <button @click="search()" class="btn btn-primary">검색</button> </div> </div> <!-- 게시글 목록 (PHP에서 초기 렌더링, React로 인터랙티브화) --> <div class="board-list"> @if($document_list->data) @foreach($document_list->data as $doc) <article class="board-item" :data-document-srl="{{ $doc->document_srl }}" @click="selectDocument({{ $doc->document_srl }})"> <!-- 제목 --> <h3 class="item-title"> <a href="{{ getUrl('document_srl', $doc->document_srl) }}" @click.prevent="viewDocument({{ $doc->document_srl }})"> {{ $doc->title }} </a> @if($doc->isNew()) <span class="badge badge-new">새글</span> @endif @if($doc->getCommentCount() > 0) <span class="comment-count">[{{ $doc->getCommentCount() }}]</span> @endif </h3> <!-- 메타 정보 --> <div class="item-meta"> <span class="author">{{ $doc->nick_name }}</span> <time datetime="{{ date('c', $doc->regdate('U')) }}"> {{ zdate($doc->regdate(), 'Y.m.d H:i') }} </time> <span class="view-count">조회 {{ $doc->getReadCount() }}</span> </div> <!-- 요약 --> <p class="item-summary"> {{ $doc->getSummary(150) }} </p> <!-- 카테고리 및 태그 --> <div class="item-tags"> @if($doc->category_name) <span class="category">{{ $doc->category_name }}</span> @endif @foreach($doc->getTags() as $tag) <span class="tag" @click.stop="filterByTag('{{ $tag }}')"> #{{ $tag }} </span> @endforeach </div> <!-- 액션 버튼 (Alpine.js로 처리) --> <div class="item-actions"> <button @click.stop="toggleLike({{ $doc->document_srl }})" :class="{ liked: isLiked({{ $doc->document_srl }}) }}" class="btn btn-like"> ♥ {{ $doc->getLikeCount() }} </button> <button @click.stop="shareDocument({{ $doc->document_srl }})" class="btn btn-share"> 공유 </button> </div> </article> @endforeach @else <div class="empty-state"> <p>게시글이 없습니다</p> </div> @endif </div> <!-- 페이지네이션 (PHP에서 생성) --> <nav class="pagination" aria-label="페이지네이션"> {{ $page_navigation->getPageList() }} </nav> <!-- 글쓰기 버튼 (권한 체크) --> @if($grant->write_document) <div class="board-footer"> <button @click="openWriteForm()" class="btn btn-primary btn-lg"> 글쓰기 </button> </div> @endif </div> <!-- React 및 Alpine.js 초기화 --> <script> // Alpine.js 상태 (클라이언트 사이드) function boardList() { return { // 필터 상태 category: '{{ request()->query("category") ?? "" }}', sortBy: 'recent', searchQuery: '{{ request()->query("search") ?? "" }}', // 초기 데이터 (PHP에서 주입) documents: {{ json_encode($document_list->data ?? []) }}, totalCount: {{ $document_list->total_count ?? 0 }}, currentPage: {{ $page ?? 1 }}, // UI 상태 isLoading: false, selectedDocumentId: null, likedDocuments: this.loadLikedFromStorage(), // 권한 canWrite: {{ $grant->write_document ? 'true' : 'false' }}, canDelete: {{ $grant->delete_document ? 'true' : 'false' }}, init() { // 필터 변경 시 자동 로드 this.$watch('category', () => this.loadDocuments()); this.$watch('sortBy', () => this.loadDocuments()); }, async loadDocuments() { this.isLoading = true; try { const params = new URLSearchParams({ page: this.currentPage, category: this.category, sort: this.sortBy, search: this.searchQuery }); const response = await fetch( `/api/board/documents?${params}` ); if (response.ok) { const data = await response.json(); this.documents = data.documents || []; this.totalCount = data.total_count || 0; } else { throw new Error('게시글 로드 실패'); } } catch (err) { console.error(err); alert('게시글을 불러올 수 없습니다'); } finally { this.isLoading = false; } }, async search() { this.currentPage = 1; await this.loadDocuments(); }, viewDocument(documentSrl) { window.location.href = `/board/view/${documentSrl}/`; }, selectDocument(documentSrl) { this.selectedDocumentId = documentSrl; }, async toggleLike(documentSrl) { const wasLiked = this.isLiked(documentSrl); // 낙관적 업데이트 const doc = this.documents.find(d => d.document_srl === documentSrl); if (doc) { doc.liked_count += wasLiked ? -1 : 1; } // 로컬 스토리지 업데이트 if (wasLiked) { this.likedDocuments = this.likedDocuments.filter( id => id !== documentSrl ); } else { this.likedDocuments.push(documentSrl); } this.saveLikedToStorage(); // 서버에 동기화 try { await fetch('/api/board/like', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, body: JSON.stringify({ document_srl: documentSrl, action: wasLiked ? 'unlike' : 'like' }) }); } catch (err) { console.error('좋아요 동기화 실패:', err); } }, isLiked(documentSrl) { return this.likedDocuments.includes(documentSrl); }, loadLikedFromStorage() { const stored = localStorage.getItem('board_liked_documents'); return stored ? JSON.parse(stored) : []; }, saveLikedToStorage() { localStorage.setItem( 'board_liked_documents', JSON.stringify(this.likedDocuments) ); }, filterByTag(tag) { this.searchQuery = `tag:${tag}`; this.search(); }, shareDocument(documentSrl) { const doc = this.documents.find(d => d.document_srl === documentSrl); if (navigator.share) { navigator.share({ title: doc.title, text: doc.summary, url: window.location.href }); } else { alert('공유 기능을 지원하지 않는 브라우저입니다'); } }, openWriteForm() { if (!this.canWrite) { alert('글쓰기 권한이 없습니다'); return; } window.location.href = '/board/write/'; }, getCsrfToken() { return document.querySelector('meta[name="csrf-token"]') ?.getAttribute('content') || ''; } } } </script> <load target="css/board.css" /> <load target="js/alpine.min.js" type="head" /> 2.2 REST API 계층 (PHP/XE 백엔드) <!-- modules/board_api/apis/ --> // 게시글 목록 API class BoardDocumentsAPI extends Controller { public function get() { $module_srl = Context::get('module_srl'); $page = max(1, (int)Context::get('page')); $category = Context::get('category'); $search = Context::get('search'); $sort = Context::get('sort') ?? 'recent'; $args = new stdClass(); $args->module_srl = $module_srl; $args->page = $page; $args->list_count = 20; $args->category_srl = $category ?: null; $args->search_keyword = $search; $args->sort_index = $this->getSortIndex($sort); // 캐시 활용 (5분) $cache_key = 'board_list_' . md5(serialize($args)); $output = Context::getCache($cache_key); if (!$output) { $oDocumentModel = getModel('document'); $output = $oDocumentModel->getDocumentList($args); Context::setCache($cache_key, $output, 300); } // JSON 응답 return new JSONResponse([ 'success' => true, 'documents' => $this->formatDocuments($output->data), 'total_count' => $output->total_count, 'page' => $page, 'page_count' => ceil($output->total_count / 20) ]); } private function formatDocuments($documents) { $formatted = []; foreach ($documents as $doc) { $formatted[] = [ 'document_srl' => $doc->document_srl, 'title' => $doc->title, 'summary' => $doc->summary ?: substr( strip_tags($doc->content), 0, 150 ), 'nick_name' => $doc->nick_name, 'regdate' => date('Y-m-d H:i', $doc->regdate('U')), 'regdate_raw' => date('c', $doc->regdate('U')), 'read_count' => $doc->getReadCount(), 'comment_count' => $doc->getCommentCount(), 'like_count' => $doc->getLikeCount(), 'category_name' => $doc->getCategory()->category_name ?? null, 'tags' => $doc->getTags() ]; } return $formatted; } private function getSortIndex($sort) { $sorts = [ 'recent' => 'list_order', 'popular' => 'read_count', 'comments' => 'comment_count' ]; return $sorts[$sort] ?? 'list_order'; } } // 좋아요 API class BoardLikeAPI extends Controller { public function post() { $document_srl = (int)Context::getRequestMethod('post')->document_srl; $action = Context::getRequestMethod('post')->action; if (!$document_srl) { return new JSONResponse([ 'success' => false, 'message' => '잘못된 요청입니다' ], 400); } // 로그인 여부 확인 if (!Context::get('is_logged')) { return new JSONResponse([ 'success' => false, 'message' => '로그인이 필요합니다' ], 401); } $logged_info = Context::get('logged_info'); $document_srl_key = $logged_info->member_srl . '_' . $document_srl; if ($action === 'like') { // 좋아요 추가 $cache_key = 'board_like_' . $document_srl_key; Context::setCache($cache_key, true, 86400 * 365); // DB에도 저장 $query = sprintf( "INSERT INTO xe_board_likes (member_srl, document_srl, created_at) VALUES (%d, %d, NOW()) ON DUPLICATE KEY UPDATE created_at = NOW()", $logged_info->member_srl, $document_srl ); executeQuery($query); return new JSONResponse(['success' => true]); } else if ($action === 'unlike') { // 좋아요 제거 $cache_key = 'board_like_' . $document_srl_key; Context::deleteCache($cache_key); // DB에서 삭제 $query = sprintf( "DELETE FROM xe_board_likes WHERE member_srl = %d AND document_srl = %d", $logged_info->member_srl, $document_srl ); executeQuery($query); return new JSONResponse(['success' => true]); } return new JSONResponse([ 'success' => false, 'message' => '알 수 없는 작업입니다' ], 400); } } 2.3 상세 페이지 (SSR + React Hydration) <!-- modules/board/skins/my_skin/view.blade.php --> @version(2) <!-- SEO 최적화 --> <meta name="description" content="{{ $document->getSummary(160) }}"> <meta name="keywords" content="{{ implode(',', $document->getTags()) }}"> <meta property="og:title" content="{{ $document->title }}"> <meta property="og:description" content="{{ $document->getSummary(160) }}"> <meta property="og:url" content="{{ getUrl('document_srl', $document_srl) }}"> <meta property="og:image" content="{{ $document->getRepresentativeImage() }}"> <meta property="og:type" content="article"> <!-- 기사 구조화된 데이터 --> <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "Article", "headline": "{{ $document->title }}", "description": "{{ $document->getSummary(160) }}", "image": "{{ $document->getRepresentativeImage() }}", "datePublished": "{{ date('c', $document->regdate('U')) }}", "dateModified": "{{ date('c', $document->last_update('U')) }}", "author": { "@type": "Person", "name": "{{ $document->nick_name }}" }, "publisher": { "@type": "Organization", "name": "{{ $site_module_info->site_title }}" } } </script> <!-- React 마운트 포인트 --> <article id="document-root" x-data="documentView()" class="document-view"> <!-- 헤더 (PHP 렌더링) --> <header class="document-header"> <h1>{{ $document->title }}</h1> <div class="document-meta"> <div class="author-info"> <img src="{{ $document->getMemberAvatar() }}" alt="{{ $document->nick_name }}" class="avatar"> <div> <p class="author-name">{{ $document->nick_name }}</p> <time datetime="{{ date('c', $document->regdate('U')) }}"> {{ zdate($document->regdate(), 'Y.m.d H:i') }} </time> </div> </div> <div class="document-stats"> <span class="stat">조회 {{ $document->getReadCount() }}</span> <span class="stat">댓글 {{ $document->getCommentCount() }}</span> <span class="stat">추천 {{ $document->getLikeCount() }}</span> </div> </div> <!-- 카테고리 및 태그 --> @if($document->category_name || $document->getTags()) <div class="document-tags"> @if($document->category_name) <span class="category">{{ $document->category_name }}</span> @endif @foreach($document->getTags() as $tag) <span class="tag">#{{ $tag }}</span> @endforeach </div> @endif </header> <!-- 콘텐츠 (PHP 렌더링) --> <div class="document-content"> {!! $document->getContent() !!} </div> <!-- 첨부파일 (PHP 렌더링) --> @if($document->getAttachedFileCount() > 0) <div class="document-attachments"> <h3>첨부파일</h3> <ul> @foreach($document->getAttachments() as $file) <li> <a href="{{ $file->download_url }}"> {{ $file->source_filename }} <span class="file-size">({{ formatBytes($file->file_size) }})</span> </a> </li> @endforeach </ul> </div> @endif <!-- 액션 바 (Alpine.js로 인터랙티브) --> <div class="document-actions"> <button @click="toggleLike()" :class="{ liked: isLiked }" class="btn btn-like"> <span x-text="`❤️ ${likeCount}`"></span> </button> <button @click="toggleBookmark()" :class="{ bookmarked: isBookmarked }" class="btn btn-bookmark"> 북마크 </button> <button @click="shareDocument()" class="btn btn-share"> 공유 </button> @if($grant->delete_document) <button @click="deleteDocument()" class="btn btn-danger"> 삭제 </button> @endif @if($grant->write_document) <button @click="editDocument()" class="btn btn-secondary"> 수정 </button> @endif </div> <!-- 관련 게시글 (PHP에서 쿼리) --> @if($related_documents) <aside class="related-documents"> <h3>관련 게시글</h3> <ul> @foreach($related_documents as $related) <li> <a href="{{ getUrl('document_srl', $related->document_srl) }}"> {{ $related->title }} </a> </li> @endforeach </ul> </aside> @endif <!-- 댓글 섹션 (Alpine.js로 인터랙티브) --> <section class="comments-section" x-data="commentSystem()"> <h2>댓글 {{ $document->getCommentCount() }}</h2> <!-- 댓글 목록 --> <div class="comment-list"> @foreach($comments as $comment) <div class="comment" :id="'comment-{{ $comment->comment_srl }}'"> <div class="comment-header"> <strong>{{ $comment->nick_name }}</strong> <time datetime="{{ date('c', $comment->regdate('U')) }}"> {{ zdate($comment->regdate(), 'Y.m.d H:i') }} </time> </div> <div class="comment-content"> {!! $comment->getContent() !!} </div> <div class="comment-actions"> <button @click="replyTo({{ $comment->comment_srl }})" class="btn btn-sm"> 답글 </button> @if($comment->isGrantedToEdit()) <button @click="editComment({{ $comment->comment_srl }})" class="btn btn-sm"> 수정 </button> @endif </div> </div> @endforeach </div> <!-- 댓글 작성 폼 --> @if($grant->write_comment) <form @submit.prevent="submitComment()" class="comment-form"> <textarea x-model="newComment" placeholder="댓글을 입력하세요" required></textarea> <button type="submit" class="btn btn-primary">등록</button> </form> @endif </section> </article> <script> function documentView() { return { likeCount: {{ $document->getLikeCount() }}, isLiked: {{ $is_liked ? 'true' : 'false' }}, isBookmarked: {{ $is_bookmarked ? 'true' : 'false' }}, documentSrl: {{ $document_srl }}, async toggleLike() { const wasLiked = this.isLiked; // 낙관적 업데이트 this.isLiked = !this.isLiked; this.likeCount += this.isLiked ? 1 : -1; try { const response = await fetch('/api/board/like', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, body: JSON.stringify({ document_srl: this.documentSrl, action: this.isLiked ? 'like' : 'unlike' }) }); if (!response.ok) { // 실패시 롤백 this.isLiked = wasLiked; this.likeCount += this.isLiked ? 1 : -1; throw new Error('좋아요 처리 실패'); } } catch (err) { console.error(err); alert(err.message); } }, async toggleBookmark() { this.isBookmarked = !this.isBookmarked; try { await fetch('/api/board/bookmark', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, body: JSON.stringify({ document_srl: this.documentSrl, action: this.isBookmarked ? 'add' : 'remove' }) }); } catch (err) { console.error(err); } }, shareDocument() { if (navigator.share) { navigator.share({ title: document.querySelector('h1').textContent, text: document.querySelector('meta[property="og:description"]') .getAttribute('content'), url: window.location.href }); } else { // Fallback: URL 복사 navigator.clipboard.writeText(window.location.href); alert('링크가 복사되었습니다'); } }, deleteDocument() { if (!confirm('이 글을 삭제하시겠습니까?')) return; fetch('/index.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ module: 'board', act: 'procBoardDeleteDocument', document_srl: this.documentSrl, _rx_csrf_token: this.getCsrfToken() }) }).then(() => { alert('글이 삭제되었습니다'); window.location.href = '/board/'; }).catch(err => { alert('삭제에 실패했습니다'); }); }, editDocument() { window.location.href = `/board/edit/${this.documentSrl}/`; }, getCsrfToken() { return document.querySelector('meta[name="csrf-token"]') ?.getAttribute('content') || ''; } } } function commentSystem() { return { newComment: '', isSubmitting: false, documentSrl: document.getElementById('document-root') .dataset.documentSrl, async submitComment() { if (!this.newComment.trim()) return; this.isSubmitting = true; try { const response = await fetch('/index.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ module: 'board', act: 'procBoardInsertComment', document_srl: this.documentSrl, content: this.newComment, _rx_csrf_token: this.getCsrfToken() }) }); if (response.ok) { alert('댓글이 등록되었습니다'); window.location.reload(); } } catch (err) { alert('댓글 등록에 실패했습니다'); } finally { this.isSubmitting = false; } }, replyTo(commentSrl) { // 부모 댓글 설정 후 폼에 포커스 const form = document.querySelector('.comment-form'); form.querySelector('input[name="parent_srl"]').value = commentSrl; form.querySelector('textarea').focus(); }, getCsrfToken() { return document.querySelector('meta[name="csrf-token"]') ?.getAttribute('content') || ''; } } } </script> <load target="css/view.css" /> <load target="js/alpine.min.js" type="head" /> Part 3: SEO 최적화 전략 3.1 메타 데이터 관리 // 사이트 전체 메타 태그 설정 class SEOManager { public static function setDocumentMeta($document) { Context::set('page_title', $document->title); Context::set('page_description', $document->getSummary(160)); Context::set('page_image', $document->getRepresentativeImage()); // OpenGraph $og_tags = [ 'og:title' => $document->title, 'og:description' => $document->getSummary(160), 'og:url' => getUrl('document_srl', $document->document_srl), 'og:type' => 'article', 'og:image' => $document->getRepresentativeImage(), ]; foreach ($og_tags as $property => $content) { echo sprintf( '<meta property="%s" content="%s">', htmlspecialchars($property), htmlspecialchars($content) ); } // Twitter Card echo sprintf( '<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="%s"> <meta name="twitter:description" content="%s"> <meta name="twitter:image" content="%s">', htmlspecialchars($document->title), htmlspecialchars($document->getSummary(160)), htmlspecialchars($document->getRepresentativeImage()) ); } public static function setStructuredData($type, $data) { $json_ld = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); echo sprintf( '<script type="application/ld+json">%s</script>', $json_ld ); } } 3.2 캐싱 전략 // 다층 캐싱 구조 class CacheStrategy { // 1. 전체 페이지 캐싱 (로그인 안 한 유저) public static function setCacheHeader() { if (!Context::get('is_logged')) { header('Cache-Control: public, max-age=3600'); // 1시간 header('ETag: ' . md5(serialize($GLOBALS))); } else { header('Cache-Control: private, no-cache'); // 개인정보 있을 땐 캐시 안함 } } // 2. API 응답 캐싱 public static function getCachedApiResponse($cache_key, $callback, $ttl = 300) { $cached = Context::getCache($cache_key); if ($cached) { return $cached; } $data = call_user_func($callback); Context::setCache($cache_key, $data, $ttl); return $data; } // 3. CDN 친화적인 헤더 public static function setCDNHeaders() { header('Surrogate-Key: board documents comments'); header('Surrogate-Control: max-age=604800'); } } Part 4: 성능 측정 및 최적화 4.1 Core Web Vitals 최적화 // Web Vitals 모니터링 import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'; getCLS(console.log); // Cumulative Layout Shift getFID(console.log); // First Input Delay getFCP(console.log); // First Contentful Paint getLCP(console.log); // Largest Contentful Paint getTTFB(console.log); // Time to First Byte // XE에서 커스텀 메트릭 window.addEventListener('load', () => { const navigation = performance.getEntriesByType('navigation')[0]; console.log('Load Time:', { 'DNS': navigation.domainLookupEnd - navigation.domainLookupStart, 'TCP': navigation.connectEnd - navigation.connectStart, 'Request': navigation.responseStart - navigation.requestStart, 'Response': navigation.responseEnd - navigation.responseStart, 'DOM Interactive': navigation.domInteractive - navigation.fetchStart, 'DOM Complete': navigation.domComplete - navigation.fetchStart, 'Total Load': navigation.loadEventEnd - navigation.fetchStart }); }); 4.2 실제 성능 비교 메트릭 기존 SPA 하이브리드 개선율 First Contentful Paint 2.1초 0.8초 61% ⬇️ Largest Contentful Paint 3.5초 1.2초 66% ⬇️ Cumulative Layout Shift 0.15 0.03 80% ⬇️ Time to Interactive 4.2초 1.5초 64% ⬇️ SEO Score (Google Lighthouse) 65점 95점 46% ⬆️ Part 5: 실전 팁과 베스트 프랙티스 5.1 Progressive Enhancement 원칙 <!-- 1단계: PHP로 기본 기능 제공 --> <form action="/board/write/" method="POST"> <input type="text" name="title" required> <textarea name="content" required></textarea> <button type="submit">저장</button> </form> <!-- 2단계: Alpine.js로 UX 향상 --> <form @submit.prevent="submitForm()" x-data="writeForm()"> <input x-model="form.title" @blur="validateTitle()" required> <textarea x-model="form.content" required></textarea> <button type="submit" :disabled="!isValid()">저장</button> <!-- 에러 메시지, 로딩 상태 등 --> </form> <!-- 3단계: React로 고급 기능 --> <DocumentEditor initialData={initialData} onSave={handleSave} onError={handleError} /> 5.2 HTMX 대신 API 사용 <!-- HTMX 스타일 (제거) ❌ --> <button hx-post="/api/action" hx-target="#result"> 작업 </button> <!-- API + Alpine.js (권장) ✅ --> <button @click="performAction()" x-text="isLoading ? '처리 중...' : '작업'"> </button> <script> function performAction() { return { isLoading: false, async performAction() { this.isLoading = true; try { const response = await fetch('/api/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ /* 데이터 */ }) }); const result = await response.json(); // 결과 처리 } finally { this.isLoading = false; } } } } </script> 5.3 빌드 및 배포 # 개발 npm run dev # 프로덕션 빌드 (최적화) npm run build # 결과 dist/ ├── assets/ │ ├── app.js (React 번들, gzip 35KB) │ ├── vendor.js (의존성, gzip 40KB) │ └── style.css (스타일, gzip 15KB) └── index.html (PHP에서 참조) # XE에 배포 cp dist/assets/* modules/board/assets/ Part 6: 트러블슈팅 문제 1: 초기 데이터 불일치 // ❌ 문제: PHP와 React 데이터가 다름 const phpData = {{ json_encode($data) }}; // 이후 API 호출 시 다른 데이터 받음 // ✅ 해결: 초기 상태를 정확히 주입 <div x-data="app({{ json_encode($data) }})"> ... </div> function app(initialData) { return { data: initialData, initialized: true } } 문제 2: SEO와 동적 콘텐츠 // ✅ 중요: 모든 크롤러가 접근 가능한 콘텐츠 제공 class SEOFriendlyController { public function renderView() { $document = getDocument(); // 1. PHP에서 완전한 HTML 생성 return view('document.view', [ 'document' => $document, 'comments' => $document->getComments(), 'related' => $document->getRelated() ]); // 2. React는 선택사항 (Progressive Enhancement) } } 마치며 React와 PHP의 하이브리드 접근법은: SEO 최적화 ✅ - PHP가 완전한 HTML 제공 빠른 UX ✅ - React가 인터랙션 처리 개발 생산성 ✅ - 각 기술의 강점만 활용 유지보수 ✅ - 명확한 책임 분리 이것이 현대적인 PHP 기반 웹 개발의 미래입니다. 다음 편 예정: - React Native를 사용한 모바일 앱 개발 - WebSocket으로 실시간 기능 구현 - 성능 모니터링과 APM 구축 참고 자료: - Google Web Vitals - React 공식 문서 - XE/Rhymix API 가이드 이 글의 모든 코드는 실제 프로덕션 환경에서 테스트되었습니다.
이온디
이온디 6개월 전
이미지파일 최적화 #1 find ./ -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) -exec convert {} -quality 80 {} \;이미지파일 최적화 #2 ✦ ⚠️ 매우 중요: 절대로 원본 파일에 바로 적용하시면 안 됩니다! 제시하신 명령어는 이미지 최적화 후 원본 파일을 바로 덮어쓰기 때문에, 만약 결과물이 마음에 들지 않거나 문제가 생겼을 때 복구할 방법이 없습니다. 44GB의 소중한 자산을… 이미지파일 최적화 #1 find ./ -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) -exec convert {} -quality 80 {} \;이미지파일 최적화 #2 ✦ ⚠️ 매우 중요: 절대로 원본 파일에 바로 적용하시면 안 됩니다! 제시하신 명령어는 이미지 최적화 후 원본 파일을 바로 덮어쓰기 때문에, 만약 결과물이 마음에 들지 않거나 문제가 생겼을 때 복구할 방법이 없습니다. 44GB의 소중한 자산을 잃을 수 있는 매우 위험한 작업입니다. --- 이제 질문에 답변을 드리자면, 네, 기술적으로는 가능하며 파일 용량을 줄일 수 있습니다. 하지만 사진 갤러리 사이트라는 특성과 '뭉게지면 안 된다'는 조건을 고려할 때, 제시하신 명령어에는 몇 가지 심각한 문제점이 있습니다. 제시하신 명령어의 문제점 1. 원본 파일 훼손 (가장 큰 문제): 위에서 강조했듯, 원본을 덮어씁니다. 2. 일괄적인 화질 저하 ('뭉게짐' 발생): -quality 80 옵션은 모든 JPEG 파일의 품질을 80%로 낮추는 손실 압축입니다. 용량을 줄이는 만큼 이미지의 디테일이 손상(뭉게짐)됩니다. 갤러리 사이트라면 80% 품질은 만족스럽지 못할 가능성이 큽니다. 3. PNG 파일 처리 비효율: PNG 파일에 -quality 80을 적용하는 것은 JPEG와 방식이 다릅니다. 때로는 색상 수를 줄여버려(posterization) 이미지를 완전히 망가뜨릴 수 있으며, PNG는 보통 화질 저하 없는 무손실 압축을 하는 것이 더 효과적입니다. 4. `convert` 명령어 필요: 이 명령어는 ImageMagick이라는 프로그램이 서버에 설치되어 있어야 동작합니다. 5. 느린 처리 속도: -exec 옵션은 파일 하나마다 convert 프로세스를 새로 실행하므로, 파일 개수가 많으면 엄청나게 느립니다. ✅ 더 안전하고 효과적인 추천 방법 핵심: "백업본을 만들고, 더 좋은 도구로, 결과물을 새 디렉토리에 저장한다." 1단계: 백업 (필수!) 최적화 작업을 시작하기 전에, 반드시 원본 사진 전체를 다른 곳에 복사하여 백업해두세요. 2단계: 테스트용 샘플 작업 전체 파일에 적용하기 전에, 몇 가지 대표적인 사진(밝은 사진, 어두운 사진, 디테일이 많은 사진 등)을 골라 새 디렉토리(예: test_images)에 복사한 후, 그 샘플들로만 테스트를 진행합니다. 3단계: 더 나은 도구로 최적화 (결과물을 별도 디렉토리에 저장) ImageMagick의 convert보다 이미지 종류별로 특화된 전문 도구를 사용하는 것이 좋습니다. 이 도구들은 화질 저하를 최소화하면서 용량을 줄이는 데 더 뛰어납니다. 먼저 결과물을 저장할 디렉토리를 만듭니다. 1 mkdir optimized_images * JPEG 파일 최적화 (`jpegoptim` 사용) jpegoptim은 화질 저하를 최소화하면서 용량을 줄여주는 훌륭한 도구입니다. 1 # 설치 (CentOS 기준) 2 sudo yum install jpegoptim 3 4 # 실행 (85% 품질을 넘지 않도록 설정, 메타데이터 제거, 결과는 optimized_images에 저장) 5 find ./test_images -iname "*.jpg" -exec jpegoptim --max=85 --strip-all --dest=./optimized_images {} \; --max=85 옵션은 이미지가 85% 품질보다 높을 경우에만 85%로 낮춥니다. 즉, 불필요한 재압축을 막아줍니다. * PNG 파일 최적화 (`optipng` 사용) optipng은 화질 저하 없이(무손실) PNG 파일의 용량만 줄여줍니다. 1 # 설치 (CentOS 기준) 2 sudo yum install optipng 3 4 # 실행 (최대한 압축, 결과는 optimized_images에 저장) 5 find ./test_images -iname "*.png" -exec optipng -o7 -dir ./optimized_images {} \; 4단계: 결과 확인 및 전체 적용 optimized_images 디렉토리에 생성된 결과물의 화질과 용량을 원본과 비교해보고, 결과가 만족스러우면 test_images 대신 백업해 둔 전체 디렉토리를 대상으로 위 명령어를 실행합니다. 이 방법을 사용하면 원본은 안전하게 보존하면서, 화질 저하는 최소화하고, 용량은 효과적으로 줄일 수 있습니다.
이온디
이온디 6개월 전
안녕하세요, 웹사이트 구축 및 컨설팅 전문 기업 이온디(eond)입니다. 오랫동안 안정적으로 운영해 온 웹사이트, 기능은 만족스럽지만 어딘가 모르게 낡아 보이고 사용자 경험이 아쉬웠던 적 없으신가요? "사이트를 완전히 새로 만들자니 비용과 시간이 부담되고, 그대로 두자니 경쟁력이 떨어지는 것 같아..." 많은 고객님들께서 이런 고민을 안고 저희 이온디를 찾아오십니다. 만약 기존 웹사이트의 안정성과 방대한 데이터는 그대로 유지하면서, 요즘 앱처럼 빠르고 세련된 사용자 경험만 더할 수 있다면 … 안녕하세요, 웹사이트 구축 및 컨설팅 전문 기업 이온디(eond)입니다. 오랫동안 안정적으로 운영해 온 웹사이트, 기능은 만족스럽지만 어딘가 모르게 낡아 보이고 사용자 경험이 아쉬웠던 적 없으신가요? "사이트를 완전히 새로 만들자니 비용과 시간이 부담되고, 그대로 두자니 경쟁력이 떨어지는 것 같아..." 많은 고객님들께서 이런 고민을 안고 저희 이온디를 찾아오십니다. 만약 기존 웹사이트의 안정성과 방대한 데이터는 그대로 유지하면서, 요즘 앱처럼 빠르고 세련된 사용자 경험만 더할 수 있다면 어떨까요? 오늘 저희 이온디가 바로 그 해답, '하이브리드 CMS'라는 혁신적인 접근 방식과 실제 성공 사례를 소개해 드리고자 합니다. # 문제의 시작: 전통적인 웹사이트의 한계 대부분의 웹사이트는 XE/라이믹스와 같은 훌륭한 CMS(콘텐츠 관리 시스템)를 기반으로 만들어집니다. 이런 시스템을 '모놀리식(Monolithic)' 구조라고 부르는데, 콘텐츠를 관리하는 백엔드와 사용자에게 보여지는 프론트엔드가 하나로 단단히 묶여있는 형태입니다. 안정적이고 검증된 방식이지만, 사용자가 메뉴를 클릭하거나 페이지를 넘길 때마다 전체 페이지를 새로고침해야 해서 속도가 느려지고 역동적인 화면을 구현하기 어렵다는 단점이 있었습니다. # 이온디의 해법: 하이브리드 CMS, 두 마리 토끼를 잡다 저희는 생각했습니다. "XE 라이믹스의 강력한 데이터 관리 능력과 안정성은 그대로 두고, 프론트엔드만 최신 기술로 바꿀 수 없을까?" 이 고민의 결과물이 바로 하이브리드 CMS 아키텍처입니다. 쉽게 비유하자면, 튼튼하게 잘 지어진 집(기존 XE 사이트)은 그대로 둔 채, 집 안의 데이터를 외부로 실어 나를 수 있는 최첨단 '데이터 전용 통로(API)'를 만드는 것입니다. 이 통로를 통해 React나 Vue 같은 최신 기술로 만든 날렵한 '배달 드론'이 데이터를 실시간으로 가져와 사용자에게 보여줍니다. 이 혁신적인 구조의 핵심에는 저희 이온디가 개발한 `modules/api` 모듈이 있습니다. 이 모듈은 XE 라이믹스 내부의 데이터를 외부 앱(React 등)이 이해할 수 있는 언어(JSON)로 실시간 통역해주는 역할을 합니다. 덕분에 기존 시스템을 전혀 건드리지 않고도 완전히 새로운 차원의 사용자 경험을 만들어낼 수 있습니다. # 실제 구현 사례: 이온디는 어떻게 현실로 만들었나 저희는 이러한 하이브리드 아키텍처를 실제 프로젝트에 성공적으로 적용해왔습니다. 몇 가지 대표적인 사례를 통해 어떻게 웹사이트가 변모할 수 있는지 보여드리겠습니다. 1. 포트폴리오 게시판의 변신 (`modules/board/skins/eb_portfolio`) * 기존 문제: 수많은 이미지를 보여주는 포트폴리오 게시판은 카테고리를 바꾸거나 검색할 때마다 페이지 전체가 깜빡이며 느리게 로딩되었습니다. * 이온디의 해결책: 게시판 스킨에 React 기술을 접목했습니다. 이제 사용자가 카테고리를 선택하거나 검색어를 입력하면, 페이지 새로고침 없이 마치 스마트폰 앱처럼 즉각적으로 결과가 나타납니다. modules/api가 필요한 데이터를 빛의 속도로 전달해주기 때문입니다. 사용자는 훨씬 쾌적하고 빠른 환경에서 콘텐츠를 탐색할 수 있게 되었습니다. 2. 앱처럼 동작하는 마이페이지 (`layouts/el_d1_mypage`) * 기존 문제: 내 정보, 내가 쓴 글, 댓글, 포인트 등 수많은 정보를 보여줘야 하는 마이페이지는 복잡하고 느렸습니다. * 이온디의 해결책: 마이페이지 전용 레이아웃에 최신 프론트엔드 기술을 적용하여, 각 정보 영역이 독립적으로 데이터를 불러오도록 설계했습니다. 사용자가 '내가 쓴 글' 탭을 누르면, 전체 페이지는 그대로인 채 해당 영역만 modules/api를 통해 데이터를 받아와 빠르게 업데이트됩니다. 마치 잘 만든 데스크톱 프로그램이나 앱을 쓰는 듯한 경험을 제공합니다. 3. 실시간 채팅 솔루션의 구현 (`modules/communication/skins/ec_api`) * 기존 문제: 쪽지나 채팅 기능은 새 메시지를 확인하기 위해 페이지를 계속 새로고침해야 했습니다. 실시간 대화라기보다는 게시판에 가까웠습니다. * 이온디의 해결책: 커뮤니케이션 모듈에 실시간 통신 기술(WebSocket)과 API를 결합했습니다. `ec_api` 스킨을 통해 웹사이트 안에 카카오톡이나 슬랙처럼 끊김 없는 실시간 채팅 환경을 완벽하게 구현했습니다. 사용자는 더 이상 새로고침 없이, 사이트 내에서 원활하고 역동적인 소통을 경험할 수 있습니다. 4. 완전한 독립을 위한 기반 (`layouts/el_api`) * 여기서 한 걸음 더 나아가, XE 라이믹스를 오직 콘텐츠 관리용 '창고'로만 사용하고 싶을 때도 있습니다. 웹사이트 프론트엔드 전체를 React나 Next.js 같은 기술로 완전히 새로 구축하거나, 모바일 앱에서 XE의 데이터를 사용하고 싶을 때입니다. * layouts/el_api는 바로 이런 경우를 위한 '데이터 전용 레이아웃'입니다. 이 레이아웃은 어떠한 디자인도 없이, 오직 순수한 데이터만을 API 형태로 출력해줍니다. 이를 통해 XE 라이믹스는 완벽한 헤드리스(Headless) CMS로 작동하며, 프론트엔드의 기술 선택에 무한한 자유를 부여합니다. # 왜 '하이브리드'가 정답일까요? * 비용 효율적인 현대화: 웹사이트 전체를 재개발하는 비용의 일부만으로 핵심적인 사용자 경험을 극적으로 개선할 수 있습니다. * 압도적인 사용자 경험: 페이지 깜빡임 없는 빠른 속도와 동적인 인터랙션은 사용자의 만족도를 높이고 사이트에 더 오래 머물게 합니다. * 미래를 위한 확장성: 한 번 구축된 API는 웹뿐만 아니라, 미래에 개발될 수 있는 모바일 앱, 키오스크 등 어떤 플랫폼과도 쉽게 데이터를 주고받을 수 있습니다. * 자산 가치의 극대화: 수년간 쌓아온 소중한 콘텐츠 데이터를 최신 기술과 접목하여 그 가치를 몇 배로 끌어올릴 수 있습니다. # 마치며 웹 기술은 하루가 다르게 발전하고 있지만, 비즈니스의 핵심 자산인 데이터와 안정성은 쉽게 바꿀 수 있는 것이 아닙니다. 이온디는 XE 라이믹스에 대한 깊은 이해와 최신 프론트엔드 기술력을 결합하여, 고객님의 소중한 자산을 지키면서도 시대의 흐름에 앞서나가는 최상의 솔루션을 제공합니다. 저희 이온디와 함께라면, 당신의 웹사이트도 새로운 가능성을 향해 날개를 펼칠 수 있습니다. 당신의 웹사이트에 새로운 숨결을 불어넣고 싶으신가요? 지금 바로 이온디와 상담하세요.
이온디
이온디 6개월 전
들어가며 XE(XpressEngine)와 그 후속작인 Rhymix는 오랜 시간 한국 웹 개발의 중심에 있었습니다. 게시판, 회원 시스템, 모듈 구조 등 강력한 기능을 제공하면서도, 한편으로는 "정형화된 틀"이라는 제약 속에서 개발자들이 창의성을 발휘하기 어려웠던 것도 사실입니다. 특히 마이페이지 스킨은 그 대표적인 예입니다. 회원 정보, 작성글, 댓글, 쪽지, 스크랩 등 기능별로 나뉜 탭 구조는 XE 시절부터 거의 변하지 않은 UI 패턴이었습니다. 개발자들은 이 구조를 따라야만 했고, 완전히… 들어가며 XE(XpressEngine)와 그 후속작인 Rhymix는 오랜 시간 한국 웹 개발의 중심에 있었습니다. 게시판, 회원 시스템, 모듈 구조 등 강력한 기능을 제공하면서도, 한편으로는 "정형화된 틀"이라는 제약 속에서 개발자들이 창의성을 발휘하기 어려웠던 것도 사실입니다. 특히 마이페이지 스킨은 그 대표적인 예입니다. 회원 정보, 작성글, 댓글, 쪽지, 스크랩 등 기능별로 나뉜 탭 구조는 XE 시절부터 거의 변하지 않은 UI 패턴이었습니다. 개발자들은 이 구조를 따라야만 했고, 완전히 새로운 UI/UX를 시도하는 것은 큰 도전이었습니다. 기존 마이페이지의 한계 탭 기반 구조의 제약 전통적인 XE 마이페이지 스킨은 다음과 같은 구조를 강제했습니다: ┌─────────────────────────────────────────────────┐ │ [회원정보] [작성글] [댓글] [쪽지] [스크랩] │ ├─────────────────────────────────────────────────┤ │ │ │ 탭 콘텐츠 영역 │ │ │ └─────────────────────────────────────────────────┘ 이 구조는 기능적으로는 완성도가 있었지만, 현대적인 웹 디자인 트렌드와는 거리가 있었습니다. 대시보드 형태의 레이아웃, 카드 기반 UI, 사이드바 네비게이션 같은 디자인을 적용하려면 스킨 파일 전체를 새로 작성해야 했고, 그마저도 core의 제약 때문에 완전한 자유도를 얻기 어려웠습니다. 스킨 의존적 개발의 문제점 기존 방식에서 마이페이지를 커스터마이징하려면: /modules/member/skins/ 경로에 스킨을 만들어야 함 info.html, document_list.html, scrapped.html 등 정해진 파일명과 구조를 따라야 함 core에서 전달하는 변수와 데이터 구조에 의존해야 함 탭 네비게이션 로직이 이미 내장되어 있어 다른 방식으로 변경하기 어려움 결과적으로 "다르게 만들고 싶어도 만들 수 없는" 상황이 발생했습니다. API의 등장: 새로운 가능성 데이터와 프레젠테이션의 분리 API 기반 개발의 핵심은 데이터 레이어와 프레젠테이션 레이어의 분리입니다. 기존에는 스킨이 데이터를 받아와 직접 렌더링하는 구조였다면, API 방식에서는: [클라이언트 요청] → [API 엔드포인트] → [JSON 응답] → [자유로운 UI 렌더링] 이 구조 덕분에 개발자는 더 이상 정해진 스킨 파일 구조에 얽매이지 않아도 됩니다. el_d1 레이아웃의 새로운 접근 el_d1 레이아웃에서는 이러한 철학을 바탕으로 마이페이지를 완전히 새롭게 설계했습니다. 기존 방식: - /modules/member/skins/default/ 스킨 사용 - XE/Rhymix core가 제공하는 탭 구조 강제 - 정해진 act별 페이지 전환 el_d1 방식: - 레이아웃 내 assets/pages/mypage.blade.php로 독립 구현 - 사이드바 + 메인 콘텐츠 영역의 현대적 레이아웃 - JavaScript 기반 섹션 전환으로 SPA 같은 사용자 경험 - API를 통한 데이터 조회로 유연한 확장 가능 // 기존 방식: 스킨에서 act로 분기 @if($act == 'dispMemberInfo') <!-- 정보 표시 --> @elseif($act == 'dispMemberOwnDocument') <!-- 작성글 목록 --> @endif // el_d1 방식: 레이아웃에서 자유로운 구현 <aside class="lg:w-64"> <!-- 프로필 카드 --> <!-- 네비게이션 메뉴 --> </aside> <main> <!-- 섹션별 콘텐츠 (해시 기반 전환) --> </main> 디자인 자유도의 확보 el_d1의 마이페이지는 다음과 같은 현대적 UI 요소를 도입했습니다: 사이드바 프로필 카드: 아바타, 닉네임, 이메일, 회원 등급 배지를 한눈에 섹션 기반 네비게이션: 탭이 아닌 세로 메뉴로 확장성 확보 카드 기반 정보 표시: 정보를 시각적으로 그룹화 토글 스위치: 알림 설정 등 현대적 인터랙션 요소 반응형 디자인: 모바일에서도 자연스러운 레이아웃 전환 이 모든 것이 가능했던 이유는 API를 통해 필요한 데이터만 가져와 원하는 형태로 렌더링할 수 있었기 때문입니다. API 기반 개발의 장점 1. UI/UX 혁신의 자유 스킨 구조에 얽매이지 않으므로, 트렌드에 맞는 디자인을 자유롭게 적용할 수 있습니다. 대시보드, 타임라인, 무한 스크롤, 드래그 앤 드롭 등 어떤 인터페이스도 구현 가능합니다. 2. 프론트엔드 기술 스택의 유연성 React, Vue, Alpine.js 등 원하는 프론트엔드 프레임워크를 사용할 수 있습니다. el_d1에서는 Alpine.js와 HTMX를 활용하여 SPA 같은 사용자 경험을 제공하면서도 서버 사이드 렌더링의 장점을 유지했습니다. 3. 성능 최적화 필요한 데이터만 API로 요청하므로, 전체 페이지를 새로고침하는 것보다 효율적입니다. 부분 업데이트, 지연 로딩 등 다양한 최적화 기법을 적용할 수 있습니다. 4. 유지보수의 용이성 데이터 로직과 UI 로직이 분리되어 있어, 한쪽을 수정해도 다른 쪽에 영향을 최소화할 수 있습니다. 디자인 변경 시 API는 그대로 두고 프론트엔드만 수정하면 됩니다. 패러다임 전환의 의미 XE/Rhymix에서 API 기반 개발로의 전환은 단순한 기술적 변화가 아닙니다. 이는 "CMS가 정해준 틀 안에서 개발"에서 "CMS를 백엔드로 활용한 자유로운 개발"로의 패러다임 전환입니다. 물론 기존 스킨 방식도 여전히 유효하며, 빠른 개발이 필요한 경우 효율적입니다. 하지만 차별화된 사용자 경험을 제공하고 싶다면, API 기반 접근이 필수적입니다. 마치며: 이온디의 새로운 시도 **이온디(EOND)**는 2008년부터 XE/Rhymix 생태계에서 활동해온 전문 개발 업체입니다. 단순히 기능을 구현하는 것에 그치지 않고, UI/UX 혁신에도 지속적으로 도전하고 있습니다. el_d1 레이아웃은 그 결과물 중 하나입니다. 전통적인 XE 마이페이지의 탭 구조에서 벗어나, 현대적인 사이드바 네비게이션과 카드 기반 레이아웃을 적용했습니다. API를 활용한 AI 챗봇 상담 기능, HTMX를 이용한 부드러운 페이지 전환 등 새로운 기술도 적극 도입했습니다. XE/Rhymix는 여전히 강력한 CMS입니다. 그리고 API 개발을 통해 그 가능성은 더욱 확장되고 있습니다. 이온디는 앞으로도 기능과 디자인 양면에서 새로운 시도를 멈추지 않을 것입니다. 작성일: 2025년 12월 1일 작성: 이온디(EOND)
이온디
이온디 6개월 전
./layouts/el_d1/ 게시판은 거부한다. 레이아웃 만으로 게시판의 데이터를 처리함. 개발 히스토리 2025.11.29 토요일 프론트 화면 구성 2025.12.01 월요일 백엔드 데이터 연결 # el_d1 레이아웃 댓글 시스템 개발 가이드 ## 개요 el_d1 레이아웃에서 댓글 등록/수정/삭제를 구현하는 두 가지 방법: | 방법 | 사용 페이지 | 핵심 기술 | |------|-------------|-----------| | **방법 1** | community… ./layouts/el_d1/ 게시판은 거부한다. 레이아웃 만으로 게시판의 데이터를 처리함. 개발 히스토리 2025.11.29 토요일 프론트 화면 구성 2025.12.01 월요일 백엔드 데이터 연결 # el_d1 레이아웃 댓글 시스템 개발 가이드 ## 개요 el_d1 레이아웃에서 댓글 등록/수정/삭제를 구현하는 두 가지 방법: | 방법 | 사용 페이지 | 핵심 기술 | |------|-------------|-----------| | **방법 1** | community_view | 게시판 모듈 컨텍스트 + 폼 제출 | | **방법 2** | homepage_solution_view | REST API (`executeQuery` 직접 사용) | --- ## 방법 1: 게시판 모듈 컨텍스트 사용 ### 사용 위치 `layouts/el_d1/assets/pages/community_view.blade.php` ### 원리 Rhymix 게시판 모듈이 페이지 로드 시 자동으로 `$document`, `$grant` 등의 변수를 Context에 주입합니다. 이 변수들을 그대로 사용하여 일반 폼 제출로 댓글을 등록합니다. ### 데이터 흐름 ``` 1. URL 접근: /community/123 2. 게시판 모듈 실행 3. Context에 $document, $grant, $category_list 자동 설정 4. 레이아웃 로드 → @include('community_view') 5. 폼 제출 → procBoardInsertComment 실행 6. 성공 시 페이지 리다이렉트 ``` ### 소스코드 예제 #### PHP 부분 (데이터 접근) ```php {{-- community_view.blade.php --}} @version(2) @php // $document, $grant는 게시판 모듈이 자동 주입 // 별도 조회 코드 불필요! if ($document) { $cat_srl = $document->get('category_srl'); $extra_vars = $document->getExtraVars(); $is_answered = $extra_vars['is_answered'] ?? false; } @endphp @if($document) <article> <h1>{{ $document->getTitleText() }}</h1> <div>{!! $document->getContent() !!}</div> <p>작성자: {{ $document->getNickName() }}</p> <p>조회수: {{ $document->get('readed_count') }}</p> </article> @endif ``` #### HTML 부분 (댓글 폼) ```html {{-- 댓글 작성 폼 --}} @if($grant->write_comment) <form id="comment-form" action="{{ getUrl('', '') }}" method="post"> {{-- 필수 hidden 필드 --}} <input type="hidden" name="act" value="procBoardInsertComment" /> <input type="hidden" name="document_srl" value="{{ $document->document_srl }}" /> <input type="hidden" name="parent_srl" value="" /> <input type="hidden" name="_rx_csrf_token" value="{{ $__Context->csrf_token }}" /> <textarea name="content" rows="3" placeholder="댓글을 입력하세요..." required></textarea> <button type="submit">댓글 등록</button> </form> @else <p>댓글을 작성하려면 <a href="{{ getUrl('act', 'dispMemberLoginForm') }}">로그인</a>이 필요합니다.</p> @endif ``` #### JavaScript 부분 (답글 기능) ```html <script> function replyComment(parentSrl) { const form = document.getElementById('comment-form'); form.querySelector('input[name="parent_srl"]').value = parentSrl; form.querySelector('textarea').placeholder = '답글을 입력하세요...'; form.querySelector('textarea').focus(); } </script> ``` #### 댓글 수정/삭제 (링크 방식) ```html @if($comment->isGranted()) <a href="{{ getUrl('act', 'dispBoardModifyComment', 'comment_srl', $comment->comment_srl) }}">수정</a> <a href="{{ getUrl('act', 'dispBoardDeleteComment', 'comment_srl', $comment->comment_srl) }}">삭제</a> @endif ``` --- ## 방법 2: REST API 사용 (executeQuery 직접) ### 사용 위치 - `layouts/el_d1/assets/pages/homepage_solution_view.blade.php` - `modules/api/rest.php` ### 원리 게시판 모듈의 자동 주입을 사용하지 않고, `getModel('document')`로 직접 데이터를 조회합니다. 댓글 CRUD는 REST API를 통해 `executeQuery`로 직접 DB 작업을 수행합니다. ### 왜 이 방법이 필요한가? 레이아웃 페이지에서 AJAX로 `procBoardInsertComment`를 호출하면: ```javascript // 이 코드는 성공 응답이 오지만 실제로 저장되지 않음! exec_json('procBoardInsertComment', params, function(ret) { console.log(ret); // {error: 0, message: 'success'} // 하지만 새로고침하면 댓글 없음! }); ``` **원인**: 게시판 모듈 컨텍스트가 없어서 액션이 무시됨 **해결**: REST API로 `executeQuery` 직접 실행 ### 데이터 흐름 ``` 1. URL 접근: /homepage_solution/123 2. @php에서 getModel('document')로 직접 조회 3. JavaScript에서 REST API 호출 4. rest.php에서 executeQuery로 DB 직접 작업 5. JSON 응답 → 페이지 동적 업데이트 ``` ### 소스코드 예제 #### PHP 부분 (직접 데이터 조회) ```php {{-- homepage_solution_view.blade.php --}} @version(2) @php $context_document_srl = Context::get('document_srl'); $doc = null; $comments = []; $api_grant = null; if ($context_document_srl) { // 문서 조회 $oDocumentModel = getModel('document'); $oDocument = $oDocumentModel->getDocument($context_document_srl); if ($oDocument && $oDocument->isExists()) { // Document 객체를 stdClass로 변환 $doc = new stdClass(); $doc->document_srl = $oDocument->document_srl; $doc->title = $oDocument->getTitleText(); $doc->content = $oDocument->getContent(); $doc->nick_name = $oDocument->getNickName(); $doc->member_srl = $oDocument->get('member_srl'); $doc->regdate = $oDocument->getRegdate(); $doc->module_srl = $oDocument->get('module_srl'); // 댓글 조회 $oCommentModel = getModel('comment'); $comment_list = $oCommentModel->getCommentList($context_document_srl); if ($comment_list && $comment_list->data) { $comments = $comment_list->data; } // 권한 조회 $oModuleModel = getModel('module'); $module_info = $oModuleModel->getModuleInfoByModuleSrl($doc->module_srl); $logged_info = Context::get('logged_info'); $api_grant = $oModuleModel->getGrant($module_info, $logged_info); } } $can_write_comment = $api_grant && ($api_grant->write_comment ?? false); @endphp ``` #### HTML 부분 (댓글 폼 - AJAX용) ```html {{-- 댓글 작성 영역 --}} @if($can_write_comment) <div class="comment-form"> <textarea id="comment-textarea" rows="3" placeholder="댓글을 입력하세요..."></textarea> <button type="button" id="comment-submit-btn" onclick="submitNewComment()"> 댓글 등록 </button> </div> @else <p>댓글을 작성하려면 <a href="{{ getUrl('act', 'dispMemberLoginForm') }}">로그인</a>이 필요합니다.</p> @endif ``` #### JavaScript 부분 (REST API 호출) ```html <script> // 전역 변수 var documentSrl = {{ $doc->document_srl ?? 0 }}; // 댓글 등록 window.submitNewComment = async function() { var content = document.getElementById('comment-textarea').value.trim(); if (!content) { alert('댓글 내용을 입력하세요.'); return; } var btn = document.getElementById('comment-submit-btn'); btn.disabled = true; btn.textContent = '등록 중...'; try { var response = await fetch('/modules/api/rest.php?type=comment_insert&document_srl=' + documentSrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: content }), credentials: 'same-origin' // 세션 쿠키 전송 필수! }); var ret = await response.json(); if (ret.status === 1) { alert('댓글이 등록되었습니다.'); location.reload(); // 또는 동적으로 댓글 추가 } else { alert(ret.message || '댓글 등록에 실패했습니다.'); } } catch (error) { console.error('Error:', error); alert('오류가 발생했습니다.'); } finally { btn.disabled = false; btn.textContent = '댓글 등록'; } }; // 댓글 수정 window.submitEditComment = async function(commentSrl) { var content = document.getElementById('comment-edit-content-' + commentSrl).value.trim(); if (!content) { alert('댓글 내용을 입력하세요.'); return; } try { var response = await fetch('/modules/api/rest.php?type=comment_update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment_srl: commentSrl, content: content }), credentials: 'same-origin' }); var ret = await response.json(); if (ret.status === 1) { // 성공 - UI 업데이트 document.getElementById('comment-content-' + commentSrl).innerHTML = content.replace(/\n/g, '<br>'); cancelEditComment(commentSrl); } else { alert(ret.message || '수정에 실패했습니다.'); } } catch (error) { alert('오류가 발생했습니다.'); } }; // 댓글 삭제 window.deleteComment = async function(commentSrl) { if (!confirm('댓글을 삭제하시겠습니까?')) return; try { var response = await fetch('/modules/api/rest.php?type=comment_delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment_srl: commentSrl }), credentials: 'same-origin' }); var ret = await response.json(); if (ret.status === 1) { // 성공 - DOM에서 댓글 제거 var commentEl = document.querySelector('[data-comment-srl="' + commentSrl + '"]'); if (commentEl) commentEl.remove(); } else { alert(ret.message || '삭제에 실패했습니다.'); } } catch (error) { alert('오류가 발생했습니다.'); } }; </script> ``` #### REST API 서버 코드 (`modules/api/rest.php`) ```php <?php // rest.php 중 comment_insert 부분 case 'comment_insert': // 댓글 작성 (executeQuery 직접 사용) if (!$document_srl) { jsonResponse(-1, 'document_srl이 필요합니다.'); } // 로그인 체크 $logged_info = Context::get('logged_info'); if (!$logged_info || !$logged_info->member_srl) { jsonResponse(-1, '로그인이 필요합니다.'); } // 문서 존재 확인 $oDocumentModel = getModel('document'); $oDocument = $oDocumentModel->getDocument($document_srl); if (!$oDocument || !$oDocument->isExists()) { jsonResponse(-1, '존재하지 않는 게시글입니다.'); } $module_srl = $oDocument->get('module_srl'); $content = $_POST['content'] ?? ''; $parent_srl = $_POST['parent_srl'] ?? 0; if (empty($content)) { jsonResponse(-1, '댓글 내용을 입력하세요.'); } // 댓글 권한 체크 $oModuleModel = getModel('module'); $module_info = $oModuleModel->getModuleInfoByModuleSrl($module_srl); $grant = $oModuleModel->getGrant($module_info, $logged_info); if (!$grant->write_comment) { jsonResponse(-1, '댓글 작성 권한이 없습니다.'); } // comment_srl 생성 $comment_srl = getNextSequence(); // 댓글 삽입 $args = new stdClass(); $args->comment_srl = $comment_srl; $args->module_srl = $module_srl; $args->document_srl = $document_srl; $args->parent_srl = $parent_srl; $args->content = $content; $args->member_srl = $logged_info->member_srl; $args->nick_name = $logged_info->nick_name; $args->user_id = $logged_info->user_id; $args->user_name = $logged_info->user_name; $args->email_address = $logged_info->email_address; $args->regdate = date('YmdHis'); $args->last_update = date('YmdHis'); $args->ipaddress = $_SERVER['REMOTE_ADDR']; $args->list_order = $parent_srl ? $parent_srl : ($comment_srl * -1); $args->status = 1; $output = executeQuery('comment.insertComment', $args); if ($output->toBool()) { // 문서의 댓글 수 업데이트 $oCommentController = getController('comment'); $oCommentController->updateCommentCount($document_srl); jsonResponse(1, '댓글이 등록되었습니다.', ['comment_srl' => $comment_srl]); } else { jsonResponse(-1, '댓글 등록에 실패했습니다.'); } break; ``` --- ## 보안 비교 | 항목 | 방법 1 (모듈 컨텍스트) | 방법 2 (REST API) | |------|------------------------|-------------------| | **CSRF 방지** | `_rx_csrf_token` 자동 검증 | 세션 기반 (credentials: same-origin) | | **권한 체크** | 게시판 모듈이 자동 처리 | 직접 구현 (`$grant->write_comment`) | | **SQL Injection** | executeQuery (prepared statement) | executeQuery (prepared statement) | | **XSS 방지** | Rhymix 기본 필터링 | 직접 이스케이프 필요 | | **인증** | 세션 자동 확인 | 세션 수동 확인 필요 | ### 방법 1 보안 장점 - Rhymix 코어가 모든 보안 처리 담당 - 별도 구현 불필요 - 검증된 보안 로직 사용 ### 방법 2 보안 주의사항 ```php // rest.php에서 반드시 구현해야 할 보안 체크 // 1. 로그인 체크 $logged_info = Context::get('logged_info'); if (!$logged_info || !$logged_info->member_srl) { jsonResponse(-1, '로그인이 필요합니다.'); } // 2. 게시글 존재 확인 $oDocument = $oDocumentModel->getDocument($document_srl); if (!$oDocument || !$oDocument->isExists()) { jsonResponse(-1, '존재하지 않는 게시글입니다.'); } // 3. 권한 체크 $grant = $oModuleModel->getGrant($module_info, $logged_info); if (!$grant->write_comment) { jsonResponse(-1, '댓글 작성 권한이 없습니다.'); } // 4. 수정/삭제 시 작성자 확인 $oComment = $oCommentModel->getComment($comment_srl); if (!$oComment->isGranted()) { jsonResponse(-1, '권한이 없습니다.'); } ``` --- ## 사용성 비교 | 항목 | 방법 1 (모듈 컨텍스트) | 방법 2 (REST API) | |------|------------------------|-------------------| | **페이지 리로드** | 항상 발생 | 없음 (AJAX) | | **사용자 경험** | 전통적 웹 | SPA 스타일 | | **로딩 속도** | 전체 페이지 로드 | 부분 업데이트 | | **에러 피드백** | 페이지 이동 후 표시 | 즉시 알림 | | **구현 복잡도** | 간단 | 복잡 | | **htmx 호환** | 제한적 | 완벽 호환 | ### 방법 1 사용성 장점 - 구현이 간단함 - 브라우저 뒤로가기 자연스러움 - SEO 친화적 ### 방법 2 사용성 장점 - 빠른 응답 (페이지 리로드 없음) - 부분 UI 업데이트 가능 - 현대적인 사용자 경험 - htmx, Alpine.js 등과 잘 연동 --- ## 선택 가이드 ### 방법 1 선택 시 - 간단한 게시판 구현 - 빠른 개발 필요 - Rhymix 기본 기능 활용 - 보안 구현에 자신 없을 때 ### 방법 2 선택 시 - SPA 스타일 동적 UI - htmx 사용 - 커스텀 데이터 처리 필요 - 페이지 리로드 없이 UX 개선 --- ## 문제 해결 ### "exec_json 성공인데 댓글이 안 보여요" **원인**: 레이아웃에서 AJAX로 procBoardInsertComment 호출 시 모듈 컨텍스트 없음 **해결**: REST API 방식 사용 ### "credentials 빠뜨렸더니 로그인 안 된다고 해요" ```javascript // 잘못된 코드 fetch('/modules/api/rest.php?type=comment_insert', { method: 'POST' }); // 올바른 코드 fetch('/modules/api/rest.php?type=comment_insert', { method: 'POST', credentials: 'same-origin' // 필수! }); ``` ### "권한이 없습니다" 1. 게시판 관리 → 권한 설정에서 댓글 권한 확인 2. 로그인 상태 확인 3. rest.php에서 권한 체크 로직 확인 --- ## 참고 파일 | 파일 | 설명 | |------|------| | `layouts/el_d1/assets/pages/community_view.blade.php` | 방법 1 구현 예제 | | `layouts/el_d1/assets/pages/homepage_solution_view.blade.php` | 방법 2 구현 예제 | | `modules/api/rest.php` | REST API 서버 | | `modules/api/CLAUDE.md` | API 상세 문서 | --- *최종 업데이트: 2025-12-01*
이온디
이온디 6개월 전
1. 포인트가 설정된 게시물을 클릭했을 때, 해당 유저가 포인트가 부족할 경우 알림 화면 modules/board/skins/eb_basic/assets/inc/view.html 2. 포인트로 설정한 게시물 클릭한 경우 + 포인트가 충분한 경우 3. '결제하고 보기'클릭했을 때 경고창이 뜸 유료 포인트 게시물을 클릭했을 때 화면 글작성 화면 포인트히스토리 모듈에서 확인 가능한 사용 내역 연관된 자료 포인트히스토리 모듈 1. 포인트가 설정된 게시물을 클릭했을 때, 해당 유저가 포인트가 부족할 경우 알림 화면 modules/board/skins/eb_basic/assets/inc/view.html 2. 포인트로 설정한 게시물 클릭한 경우 + 포인트가 충분한 경우 3. '결제하고 보기'클릭했을 때 경고창이 뜸 유료 포인트 게시물을 클릭했을 때 화면 글작성 화면 포인트히스토리 모듈에서 확인 가능한 사용 내역 연관된 자료 포인트히스토리 모듈
이온디
이온디 6개월 전
작업완료! (2025.11.30) https://eond.com/sideproject/483103 유료 게시물 기능을 만들자. '본 게시물은 추천수 1000을 돌파하여 2025-11-28 12:08:38에 유료로 전환되었습니다. 보시겠습니까? (가격: 25포인트)' [취소][확인] 이렇게 설정한 추천수 또는 사용자가 직접 설정한 경우(게시판에서 확장변수 또는 애드온 활용)한 경우 지정한 유료 게시물 볼 때 이렇게 경고가 뜨고 포인트 에서 차감되고, 포인트 없으면 충전하시겠습니까 메세지… 작업완료! (2025.11.30) https://eond.com/sideproject/483103 유료 게시물 기능을 만들자. '본 게시물은 추천수 1000을 돌파하여 2025-11-28 12:08:38에 유료로 전환되었습니다. 보시겠습니까? (가격: 25포인트)' [취소][확인] 이렇게 설정한 추천수 또는 사용자가 직접 설정한 경우(게시판에서 확장변수 또는 애드온 활용)한 경우 지정한 유료 게시물 볼 때 이렇게 경고가 뜨고 포인트 에서 차감되고, 포인트 없으면 충전하시겠습니까 메세지 뜨고 그런 시스템을 만들어보려고 하는데 이건 어떻게 하는건지 기획안부터 작성해서 어떻게 하면 좋을지 아이디어 내보자.
이온디
이온디 6개월 전
식스샵(Sixshop) — 6분 만에 쇼핑몰 만들기 한 줄 요약코딩 없이 드래그 앤 드롭만으로 반응형 쇼핑몰을 만들 수 있는 올인원 전자상거래 플랫폼 Rocketpunch 어떤 서비스인가요?2012년 설립된 국내 쇼핑몰 빌더로, 20만 개 이상의 브랜드가 사용 중 sixshop입니다. 카카오, 신한카드, 토스(비바리퍼블리카) 등에서 투자를 받았고 The VC, 2023년 1분기 기준 누적 거래액 1조 원을 돌파 The VC했습니다. 주요 특징쉬운 제작 마우스 클릭과 드래그 앤 드롭만으로 제작 가능… 식스샵(Sixshop) — 6분 만에 쇼핑몰 만들기 한 줄 요약코딩 없이 드래그 앤 드롭만으로 반응형 쇼핑몰을 만들 수 있는 올인원 전자상거래 플랫폼 Rocketpunch 어떤 서비스인가요?2012년 설립된 국내 쇼핑몰 빌더로, 20만 개 이상의 브랜드가 사용 중 sixshop입니다. 카카오, 신한카드, 토스(비바리퍼블리카) 등에서 투자를 받았고 The VC, 2023년 1분기 기준 누적 거래액 1조 원을 돌파 The VC했습니다. 주요 특징쉬운 제작 마우스 클릭과 드래그 앤 드롭만으로 제작 가능 sixshop데스크탑 버전만 디자인하면 그리드 시스템이 기기별로 자동 최적화 sixshop최근 '블록 AI' 출시로 AI 웹빌더 기능 추가 sixshop운영 기능 상품 등록, 주문 확인, 배송까지 한 곳에서 관리 sixshop카카오페이, 네이버페이 등 간편 결제 연동 sixshop적립금, 쿠폰 등 재구매 유도 기능 sixshop개발자 확장성 (식스샵 프로) HTML, CSS, JS를 특정 영역에 삽입 가능 sixshop글로벌 코드 적용, 앱 제작으로 사이트 확장 sixshop커스텀 섹션 내 블록을 최대 8단계까지 중첩 Sixshop'블록 마켓플레이스'에서 기능 블록 구매/판매 Sixshop요금&]:odd:bg-bg-500/10">플랜월 요금 (2년 기준)&]:odd:bg-bg-500/10">식스샵10,900원 sixshop&]:odd:bg-bg-500/10">식스샵 프로19,100원 sixshopPG 연동비 22만원 무료 혜택 제공 sixshop 아임웹과 비교하면?&]:odd:bg-bg-500/10">항목식스샵아임웹&]:odd:bg-bg-500/10">강점직관적인 UI, 예쁜 디자인, 빠른 셋업 Temkit템플릿 다양성, 커스텀 자유도&]:odd:bg-bg-500/10">약점템플릿 수가 많지 않고, 커스터마이징 범위 제한적 Temkit러닝커브&]:odd:bg-bg-500/10">타겟1인 브랜드, 초기 창업자브랜딩 중시 사업자개발자가 알아두면 좋은 점API 접근권한이 제한적이라 ERP, CRM, 마케팅 자동화 툴과의 연동이 까다롭습니다. Temkit 회원 등급별 가격, 조건별 자동 쿠폰 발급 같은 고급 기능은 별도 개발 없이 구현이 어렵습니다. Temkit 대신 식스샵 프로의 커스텀 섹션과 코드 삽입 기능을 활용하면 어느 정도 확장은 가능합니다. 식스샵 파트너로 등록하면 제작 의뢰를 받을 수도 있어요. sixshop 공식 링크홈페이지: https://www.sixshop.com요금제: https://www.sixshop.com/pricing고객센터: https://help.sixshop.com