<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>J-한솔넷</title>
    <link>https://jhansol.tistory.com/</link>
    <description>안녕하세요.
먼저 저의 블로그에 방문해주셔서 대단히 감사합니다.
저의 블로그는 제가 일하면서 기록으로 남기고 싶은 내용을 관리하고 유익한 정보가 있다면 공유하고 참고하기 위한 목적으로 활용하고 있습니다. 비록 과거의 기록이라도 버리고 싶지 않은 것들입니다. 매우 오래된 것이 있더라도 널리 용서바랍니다.</description>
    <language>ko</language>
    <pubDate>Sun, 7 Jun 2026 05:47:08 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>jhansol</managingEditor>
    <image>
      <title>J-한솔넷</title>
      <url>https://tistory1.daumcdn.net/tistory/906974/attach/8d83e7dc62084d89964d48db0b4de85e</url>
      <link>https://jhansol.tistory.com</link>
    </image>
    <item>
      <title>AWS DMS (Database Migration Service)를 못믿어 데이터베이스 테이블 비교 코드를 만들어 보았습니다.</title>
      <link>https://jhansol.tistory.com/203</link>
      <description>&lt;p&gt;안녕하세요?&lt;br&gt;정말 오랜만에 블로그에 글을 올립니다. 그 동안 바쁘다는 핑계로 기억을 박제하지 못했네요. ㅠㅠ&lt;/p&gt;
&lt;p&gt;저는 요즘 개발 업무보다 회사의 운영 방침에 따라 AWS 인프라 관리, 마이그레이션관련 일에 몰두하고 있습니다. 개발 업무를 하고픈데, 업무가 이상하게 흘러가는 듯 합니다. 암튼 지금은 이 일을 하고 있습니다.&lt;/p&gt;
&lt;p&gt;마이그레이션 작업에서 가장 신경이 쓰이는 부분이 운영 중인 데이터베이스를 다른 서버 또는 다른 계정으로 마이그레이션 하는 부분이라고 생각합니다. 지금 이 작업을 하고 있는데, 몇일 전 하나의 프로젝트(서비스) 데이터를 이동하는 중 데이터가 모두 복재가 되지 않아 소스에서 덤프를 뜨서 대상 데이터베이스에 복원한 적이 있습니다. 이 후로 DMS로 마이그레이션하고, 복제하는 작업을 신뢰할 수 없게 되었습니다.&lt;/p&gt;
&lt;p&gt;그래서 만들어 봤습니다.&lt;/p&gt;
&lt;p&gt;아래의 코드는 간단하게 ChatGPT를 이용하여 코드를 작성하고, 약간 수정한 것입니다. 잘 동작합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const mariadb = require(&amp;#39;mariadb&amp;#39;);

const db1Config = {
  host: &amp;#39;127.0.0.1&amp;#39;,
  port: 63306,            // ✅ 포트 번호 추가
  user: &amp;#39;admin&amp;#39;,
  password: &amp;#39;xxxxxxxx!&amp;#39;,
  database: &amp;#39;xxxxxxxx&amp;#39;,
};

const db2Config = {
  host: &amp;#39;127.0.0.1&amp;#39;,
  port: 33306,            // ✅ 포트 번호 추가
  user: &amp;#39;admin&amp;#39;,
  password: &amp;#39;xxxxxxx!&amp;#39;,
  database: &amp;#39;xxxxxxx&amp;#39;,
};

async function getTableRowCounts(pool, database) {
  const conn = await pool.getConnection();
  try {
    const tables = await conn.query(`
      SELECT table_name
      FROM information_schema.tables
      WHERE table_schema = ?
    `, [database]);

    const counts = {};
    for (const row of tables) {
      const table = row.table_name;
      const result = await conn.query(`SELECT COUNT(*) as count FROM \`${table}\``);
      counts[table] = result[0].count;
    }
    return counts;
  } finally {
    conn.release();
  }
}

async function compareDatabases() {
  const pool1 = mariadb.createPool(db1Config);
  const pool2 = mariadb.createPool(db2Config);

  try {
    const [counts1, counts2] = await Promise.all([
      getTableRowCounts(pool1, db1Config.database),
      getTableRowCounts(pool2, db2Config.database),
    ]);

    const allTables = new Set([...Object.keys(counts1), ...Object.keys(counts2)]);
    let identical = true;

    for (const table of allTables) {
      const count1 = counts1[table];
      const count2 = counts2[table];

      if (count1 === undefined) {
        console.log(`❌ Table &amp;#39;${table}&amp;#39; missing in DB1`);
        identical = false;
      } else if (count2 === undefined) {
        console.log(`❌ Table &amp;#39;${table}&amp;#39; missing in DB2`);
        identical = false;
      } else if (count1 !== count2) {
        console.log(`❌ Table &amp;#39;${table}&amp;#39; row count differs: DB1=${count1}, DB2=${count2}`);
        identical = false;
      } else {
        console.log(`✅ Table &amp;#39;${table}&amp;#39; matches with ${count1} rows`);
      }
    }

    console.log(&amp;#39;\n✅ Databases are &amp;#39; + (identical ? &amp;#39;identical&amp;#39; : &amp;#39;different&amp;#39;) + &amp;#39; in structure and row count.&amp;#39;);
  } catch (err) {
    console.error(&amp;#39;Error:&amp;#39;, err);
  } finally {
    await pool1.end();
    await pool2.end();
  }
}

compareDatabases();&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그리고 package.json은 아래와 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;name&amp;quot;: &amp;quot;db_compare&amp;quot;,
  &amp;quot;version&amp;quot;: &amp;quot;1.0.0&amp;quot;,
  &amp;quot;description&amp;quot;: &amp;quot;Compare database&amp;quot;,
  &amp;quot;main&amp;quot;: &amp;quot;index.js&amp;quot;,
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;test&amp;quot;: &amp;quot;echo \&amp;quot;Error: no test specified\&amp;quot; &amp;amp;&amp;amp; exit 1&amp;quot;
  },
  &amp;quot;author&amp;quot;: &amp;quot;&amp;quot;,
  &amp;quot;license&amp;quot;: &amp;quot;ISC&amp;quot;,
  &amp;quot;dependencies&amp;quot;: {
    &amp;quot;mariadb&amp;quot;: &amp;quot;^3.4.4&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;잘 돌아갑니다. 그리고 테이블 이름과 테이블에 저장된 레코드 수가 모두 일치한다고 나요네요. 이로서 두 데이터베이스는 동일하다고 볼 수 있을 것 같습니다.&lt;/p&gt;
&lt;p&gt;그리고 두 데이터베이스는 AWS 프라이빗 서브넷에 있어 SSH 명령을 이용하여 터널링하여 접속해서 체크했습니다.&lt;br&gt;결과는 아래와 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;node index.js
✅ Table &amp;#39;dx_lecture_enrollments&amp;#39; matches with 0 rows
✅ Table &amp;#39;member_companies&amp;#39; matches with 1 rows
✅ Table &amp;#39;online_edu_lecture_study_logs&amp;#39; matches with 0 rows
✅ Table &amp;#39;password_resets&amp;#39; matches with 0 rows
✅ Table &amp;#39;performance_dx_job_transition_rates&amp;#39; matches with 0 rows
✅ Table &amp;#39;experience_images&amp;#39; matches with 0 rows
✅ Table &amp;#39;metabus_teaching_plans&amp;#39; matches with 0 rows
✅ Table &amp;#39;ncs_code&amp;#39; matches with 13351 rows
✅ Table &amp;#39;base_inspects&amp;#39; matches with 0 rows
✅ Table &amp;#39;migrations&amp;#39; matches with 97 rows
✅ Table &amp;#39;resume_activities&amp;#39; matches with 0 rows
✅ Table &amp;#39;personal_access_tokens&amp;#39; matches with 0 rows
✅ Table &amp;#39;online_edu_contents&amp;#39; matches with 0 rows
✅ Table &amp;#39;type_inspects&amp;#39; matches with 0 rows
✅ Table &amp;#39;failed_jobs&amp;#39; matches with 0 rows
✅ Table &amp;#39;competences&amp;#39; matches with 0 rows
✅ Table &amp;#39;metabus_classes&amp;#39; matches with 0 rows
✅ Table &amp;#39;dx_lecture_schedules&amp;#39; matches with 17 rows
✅ Table &amp;#39;univs&amp;#39; matches with 4 rows
✅ Table &amp;#39;community_files&amp;#39; matches with 4 rows
✅ Table &amp;#39;experience_folders&amp;#39; matches with 0 rows
✅ Table &amp;#39;expert_certifications&amp;#39; matches with 0 rows
✅ Table &amp;#39;users&amp;#39; matches with 13 rows
✅ Table &amp;#39;member_company_representative_history&amp;#39; matches with 0 rows
✅ Table &amp;#39;admins&amp;#39; matches with 4 rows
✅ Table &amp;#39;metabus_class_times&amp;#39; matches with 0 rows
✅ Table &amp;#39;report_logs&amp;#39; matches with 0 rows
✅ Table &amp;#39;digital_badges&amp;#39; matches with 4 rows
✅ Table &amp;#39;performance_adult_friendly_edu_indexes&amp;#39; matches with 0 rows
✅ Table &amp;#39;inspect_questions&amp;#39; matches with 256 rows
✅ Table &amp;#39;resume_languages&amp;#39; matches with 0 rows
✅ Table &amp;#39;digital_badge_issued_users&amp;#39; matches with 0 rows
✅ Table &amp;#39;univ_admins&amp;#39; matches with 6 rows
✅ Table &amp;#39;recruit_job_codes&amp;#39; matches with 1297 rows
✅ Table &amp;#39;communities&amp;#39; matches with 8 rows
✅ Table &amp;#39;metabus_class_logs&amp;#39; matches with 0 rows
✅ Table &amp;#39;performances&amp;#39; matches with 48 rows
✅ Table &amp;#39;resume_certificates&amp;#39; matches with 0 rows
✅ Table &amp;#39;metabus_matterports&amp;#39; matches with 4 rows
✅ Table &amp;#39;expert_histories&amp;#39; matches with 0 rows
✅ Table &amp;#39;resumes&amp;#39; matches with 0 rows
✅ Table &amp;#39;resume_careers&amp;#39; matches with 0 rows
✅ Table &amp;#39;cache&amp;#39; matches with 60 rows
✅ Table &amp;#39;resume_educations&amp;#39; matches with 0 rows
✅ Table &amp;#39;ncs_licenses&amp;#39; matches with 21299 rows
✅ Table &amp;#39;inspect_answers&amp;#39; matches with 0 rows
✅ Table &amp;#39;dx_lectures&amp;#39; matches with 4 rows
✅ Table &amp;#39;performance_dx_governance_indexes&amp;#39; matches with 0 rows
✅ Table &amp;#39;online_edu_content_media&amp;#39; matches with 0 rows
✅ Table &amp;#39;self_inspects&amp;#39; matches with 0 rows
✅ Table &amp;#39;performance_active_job_transition_indexes&amp;#39; matches with 0 rows
✅ Table &amp;#39;metabus_class_users&amp;#39; matches with 0 rows
✅ Table &amp;#39;cache_locks&amp;#39; matches with 0 rows
✅ Table &amp;#39;resume_technologies&amp;#39; matches with 0 rows
✅ Table &amp;#39;digital_badge_configs&amp;#39; matches with 3 rows
✅ Table &amp;#39;performance_dx_stakeholder_satisfactions&amp;#39; matches with 0 rows
✅ Table &amp;#39;dx_categories&amp;#39; matches with 21 rows
✅ Table &amp;#39;sessions&amp;#39; matches with 59 rows
✅ Table &amp;#39;member_company_certification_status&amp;#39; matches with 2 rows
✅ Table &amp;#39;recruit_infos&amp;#39; matches with 64099 rows
✅ Table &amp;#39;performance_dx_employed_startup_rates&amp;#39; matches with 0 rows
✅ Table &amp;#39;resume_self_introductions&amp;#39; matches with 0 rows
✅ Table &amp;#39;expert_experiences&amp;#39; matches with 0 rows
✅ Table &amp;#39;experiences&amp;#39; matches with 0 rows
✅ Table &amp;#39;resume_awards&amp;#39; matches with 0 rows
✅ Table &amp;#39;community_comments&amp;#39; matches with 1 rows
✅ Table &amp;#39;online_edu_lectures&amp;#39; matches with 0 rows
✅ Table &amp;#39;experts&amp;#39; matches with 0 rows
✅ Table &amp;#39;platform_histories&amp;#39; matches with 0 rows
✅ Table &amp;#39;performance_dx_attainment_degree_rates&amp;#39; matches with 0 rows
✅ Table &amp;#39;performance_dx_completes&amp;#39; matches with 0 rows
✅ Table &amp;#39;ncs_recommend_keywords&amp;#39; matches with 1083 rows
✅ Table &amp;#39;ncs_self_inspects&amp;#39; matches with 1114 rows

✅ Databases are identical in structure and row count.&lt;/code&gt;&lt;/pre&gt;</description>
      <category>AWS</category>
      <category>database migration service</category>
      <category>DMS</category>
      <category>JavaScript</category>
      <category>Server</category>
      <category>데이터베이스</category>
      <category>마이그레이션</category>
      <category>서버</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/203</guid>
      <comments>https://jhansol.tistory.com/203#entry203comment</comments>
      <pubDate>Wed, 9 Jul 2025 00:34:58 +0900</pubDate>
    </item>
    <item>
      <title>재취업 회사 개발환경 구성</title>
      <link>https://jhansol.tistory.com/202</link>
      <description>&lt;p&gt;6월 말로 최종 퇴사 처리가 되었지만, 업무는 4월말로 끝이 났었습니다. 약 6개월의 백수(?, 놀기만 한건 아닌데)생활을 끝내고 새로운 회사에 취업을 하게 되었습니다. 회사 분위기도 살펴야하고, 프로젝트 환경, 프로젝트 수행 패턴 등 아직 적응해야할 것이 많아 조금은 긴장된 상태로 지내고 있습니다.&lt;/p&gt;
&lt;p&gt;이 중에서 개발환경이 사실 적응이 않되어 제가 사용하던 도커 개발환경을 취업한 회사의 프로젝트에 맞추어 새로 만들었습니다. 본 글은 그 과정에 관련된 내용입니다.&lt;/p&gt;
&lt;h2&gt;도커 개발환경 구성 조건&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;PHP 7.4, 8.1, 8.2, 8.4 를 선택 가능하도록 한다.&lt;/li&gt;
&lt;li&gt;도매인 모드, 사이트 모드 두 가지 모드를 지원해야 한다.&lt;/li&gt;
&lt;li&gt;도매인 모드에서는 &amp;quot;프로젝트명&amp;quot;.&amp;quot;wd&amp;quot; 또는 &amp;quot;다큐먼트 루트&amp;quot;.&amp;quot;프로젝트명&amp;quot;.&amp;quot;wd&amp;quot; 형태의 도매인을 지원해야 한다.&lt;/li&gt;
&lt;li&gt;사이트 모드에서는 &amp;quot;다큐먼트 루트&amp;quot;.&amp;quot;프로젝트명&amp;quot;.&amp;quot;wd&amp;quot; 형태가 가능해야 하고, 요청한 주소를 기준으로 다큐먼트 루트로 지정되도록 한다.&lt;/li&gt;
&lt;li&gt;PHP용 사용자 지정 라이브러를 추가 가능하도록 한다.&lt;/li&gt;
&lt;li&gt;composer, NodeJs, git, vi 등을 사용할 수 있도록 한다.&lt;/li&gt;
&lt;li&gt;docker-compose.yml 에서 PHP 버젼, 모드, 커스텀 INI, 다큐먼트 루트 등을 지정할 수 있도록 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Dockerfile 구성&lt;/h2&gt;
&lt;p&gt;전체 구성은 아래와 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM ubuntu:latest
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Seoul
ENV LC_ALL=C.UTF-8

RUN apt update; \
    apt upgrade -y

WORKDIR /DevHome
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime; \
    echo $TZ &amp;gt; /etc/timezone

RUN apt update; \
    apt upgrade -y; \
    apt install -y gnupg curl ca-certificates zip unzip git libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano apache2; \
    apt install -y software-properties-common; \ 
    apt install -y sudo vim mysql-client; \
    echo tzdata tzdata/Areas select Asia | debconf-set-selections; \
    echo tzdata tzdata/Zones/Asia select Seoul | debconf-set-selections; \
    echo &amp;quot;Asia/Seoul&amp;quot; &amp;gt; /etc/timezone; \
    ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime; \
    add-apt-repository -y ppa:ondrej/php; \
    apt update; \
    apt install -y --fix-missing php7.4-amqp php7.4-apcu php7.4-apcu-bc php7.4-ast php7.4-bcmath php7.4-bz2 php7.4-cli php7.4-curl php7.4-dev php7.4-ds php7.4-enchant \
    php7.4-excimer php7.4-gd php7.4-gearman php7.4-geoip php7.4-gmp php7.4-gnupg php7.4-http php7.4-igbinary php7.4-imap php7.4-ldap php7.4-json php7.4-mailparse \
    php7.4-memcache php7.4-mongodb php7.4-mysql php7.4-opcache php7.4-pgsql php7.4-raphf php7.4-rdkafka php7.4-readline php7.4-soap php7.4-solr php7.4-sqlite3 \
    php7.4-uuid php7.4-xml php7.4-xmlrpc php7.4-xml php7.4-xmlrpc php7.4-yaml php7.4-intl php7.4-mbstring php7.4-zip php7.4-redis php7.4-imagick php7.4-xdebug libapache2-mod-php7.4; \
    apt install -y --fix-missing php8.1-amqp php8.1-apcu php8.1-bcmath php8.1-bz2 php8.1-cli php8.1-curl php8.1-dba php8.1-dev php8.1-ds php8.1-enchant php8.1-gd php8.1-gearman \
    php8.1-gnupg php8.1-gmp php8.1-http php8.1-igbinary php8.1-imagick php8.1-imap php8.1-intl php8.1-ldap php8.1-mailparse php8.1-mbstring php8.1-mcrypt \
    php8.1-mysql php8.1-memcache php8.1-mongodb php8.1-opcache php8.1-pgsql php8.1-readline php8.1-raphf php8.1-rdkafka php8.1-readline php8.1-redis \
    php8.1-soap php8.1-solr php8.1-sqlite3 php8.1-uuid php8.1-xml php8.1-xmlrpc php8.1-xsl php8.1-zip php8.1-xdebug php8.1-yaml libapache2-mod-php8.1; \
    apt install -y --fix-missing php8.2-amqp php8.2-apcu php8.2-bcmath php8.2-bz2 php8.2-cli php8.2-curl php8.2-dba php8.2-dev php8.2-ds php8.2-enchant php8.2-gd php8.2-gearman \
    php8.2-gnupg php8.2-gmp php8.2-http php8.2-igbinary php8.2-imagick php8.2-imap php8.2-intl php8.2-ldap php8.2-mailparse php8.2-mbstring php8.2-mcrypt \
    php8.2-mysql php8.2-memcache php8.2-mongodb php8.2-opcache php8.2-pgsql php8.2-readline php8.2-raphf php8.2-rdkafka php8.2-readline php8.2-redis \
    php8.2-soap php8.2-solr php8.2-sqlite3 php8.2-uuid php8.2-xml php8.2-xmlrpc php8.2-xsl php8.2-zip php8.2-xdebug php8.2-yaml libapache2-mod-php8.2; \
    apt install -y --fix-missing php8.4-amqp php8.4-apcu php8.4-bcmath php8.4-bz2 php8.4-cli php8.4-curl php8.4-dba php8.4-dev php8.4-ds php8.4-enchant php8.4-gd php8.4-gearman \
    php8.4-gmp php8.4-gnupg php8.4-http php8.4-igbinary php8.4-imagick php8.4-imap php8.4-intl php8.4-ldap php8.4-mailparse php8.4-mbstring php8.4-mcrypt php8.4-mcrypt php8.4-memcache \
    php8.4-mongodb php8.4-mysql php8.4-opcache php8.4-pgsql php8.4-raphf php8.4-rdkafka php8.4-readline php8.4-redis php8.4-soap php8.4-sqlite3 php8.4-uuid php8.4-xdebug php8.4-xml \
    php8.4-xmlrpc php8.4-xsl php8.4-yaml php8.4-zip libapache2-mod-php8.4; \
    a2enmod rewrite vhost_alias ssl; \
    curl https://deb.nodesource.com/setup_lts.x | bash -; \
    apt install -y nodejs; \
    php -r &amp;quot;copy(&amp;#39;https://getcomposer.org/installer&amp;#39;, &amp;#39;composer-setup.php&amp;#39;);&amp;quot;; \
    php composer-setup.php --install-dir=/bin --filename=composer; \
    rm -f composer-setup.php

RUN sed -i &amp;#39;s/upload_max_filesize = 2M/upload_max_filesize = 200M/g&amp;#39; /etc/php/7.4/apache2/php.ini; \
    sed -i &amp;#39;s/memory_limit = 128M/memory_limit = 512M/g&amp;#39; /etc/php/7.4/apache2/php.ini; \
    sed -i &amp;#39;s/post_max_size = 8M/post_max_size = 250M/g&amp;#39; /etc/php/7.4/apache2/php.ini; \
    sed -i &amp;#39;s/short_open_tag = Off/short_open_tag = On/g&amp;#39; /etc/php/7.4/apache2/php.ini; \
    echo &amp;quot;xdebug.mode = develop,debug&amp;quot; &amp;gt;&amp;gt; /etc/php/7.4/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_host=host.docker.internal&amp;quot; &amp;gt;&amp;gt; /etc/php/7.4/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_port = 9000&amp;quot; &amp;gt;&amp;gt; /etc/php/7.4/mods-available/xdebug.ini; \
    sed -i &amp;#39;s/upload_max_filesize = 2M/upload_max_filesize = 200M/g&amp;#39; /etc/php/8.1/apache2/php.ini; \
    sed -i &amp;#39;s/memory_limit = 128M/memory_limit = 512M/g&amp;#39; /etc/php/8.1/apache2/php.ini; \
    sed -i &amp;#39;s/post_max_size = 8M/post_max_size = 250M/g&amp;#39; /etc/php/8.1/apache2/php.ini; \
    sed -i &amp;#39;s/short_open_tag = Off/short_open_tag = On/g&amp;#39; /etc/php/8.1/apache2/php.ini; \
    echo &amp;quot;xdebug.mode = develop,debug&amp;quot; &amp;gt;&amp;gt; /etc/php/8.1/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_host=host.docker.internal&amp;quot; &amp;gt;&amp;gt; /etc/php/8.1/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_port = 9000&amp;quot; &amp;gt;&amp;gt; /etc/php/8.1/mods-available/xdebug.ini; \
    sed -i &amp;#39;s/upload_max_filesize = 2M/upload_max_filesize = 200M/g&amp;#39; /etc/php/8.2/apache2/php.ini; \
    sed -i &amp;#39;s/memory_limit = 128M/memory_limit = 512M/g&amp;#39; /etc/php/8.2/apache2/php.ini; \
    sed -i &amp;#39;s/post_max_size = 8M/post_max_size = 250M/g&amp;#39; /etc/php/8.2/apache2/php.ini; \
    sed -i &amp;#39;s/short_open_tag = Off/short_open_tag = On/g&amp;#39; /etc/php/8.2/apache2/php.ini; \
    echo &amp;quot;xdebug.mode = develop,debug&amp;quot; &amp;gt;&amp;gt; /etc/php/8.2/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_host=host.docker.internal&amp;quot; &amp;gt;&amp;gt; /etc/php/8.2/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_port = 9000&amp;quot; &amp;gt;&amp;gt; /etc/php/8.2/mods-available/xdebug.ini; \
    sed -i &amp;#39;s/upload_max_filesize = 2M/upload_max_filesize = 200M/g&amp;#39; /etc/php/8.4/apache2/php.ini; \
    sed -i &amp;#39;s/memory_limit = 128M/memory_limit = 512M/g&amp;#39; /etc/php/8.4/apache2/php.ini; \
    sed -i &amp;#39;s/post_max_size = 8M/post_max_size = 250M/g&amp;#39; /etc/php/8.4/apache2/php.ini; \
    sed -i &amp;#39;s/short_open_tag = Off/short_open_tag = On/g&amp;#39; /etc/php/8.4/apache2/php.ini; \
    echo &amp;quot;xdebug.mode = develop,debug&amp;quot; &amp;gt;&amp;gt; /etc/php/8.4/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_host=host.docker.internal&amp;quot; &amp;gt;&amp;gt; /etc/php/8.4/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_port = 9000&amp;quot; &amp;gt;&amp;gt; /etc/php/8.4/mods-available/xdebug.ini

RUN rm -f /etc/apache2/sites-available/* /etc/apache2/sites-enabled/*; \
    rm -f /etc/apache2/mods-enabled/php*; \
    mkdir -p /usr/lib/php/custom; \
    systemctl stop apache2; \
    systemctl disable apache2


COPY index.php /var/www/html/index.php
COPY svhost.sh /usr/bin/svhost
COPY sphp.sh /usr/bin/sphp
COPY ssl_key/* /etc/ssl/private
ADD phpMyAdmin /var/www/html/myadmin
RUN mkdir -p /var/www/html/myadmin/tmp; \
    chown www-data:www-data /var/www/html/myadmin/tmp; \
    echo &amp;quot;ubuntu ALL=(ALL:ALL) NOPASSWD: ALL&amp;quot; &amp;gt; /etc/sudoers.d/ubuntu
COPY docker_entrypoint.sh /usr/bin/docker_entrypoint.sh
COPY confs/* /etc/apache2/sites-available

RUN chmod 0755 /usr/bin/svhost; \
    chmod 0755 /usr/bin/sphp; \
    chmod 0755 /usr/bin/docker_entrypoint.sh

VOLUME [ &amp;quot;/DevHome&amp;quot;, &amp;#39;/usr/lib/php/custom&amp;#39; ]

EXPOSE 80 443 5173 8080

ENTRYPOINT [ &amp;quot;docker_entrypoint.sh&amp;quot; ]

CMD [&amp;quot;apache2ctl&amp;quot;, &amp;quot;-D&amp;quot;, &amp;quot;FOREGROUND&amp;quot;]&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;도매인모드, 사이트모드를 위한 Apache 설정파일&lt;/h2&gt;
&lt;p&gt;사이트 모드의 경우 이전에 구성했던 환경과 동일합니다. 그래서 사이트 모드 설정 파일&lt;code&gt;sites.conf&lt;/code&gt; 파일은 이전 글을 참고해주세요. 본 글에서는 도매인 모드에 대해서만 추가 설명을 하도록 하겠습니다. 이 설정 파일은 &lt;code&gt;conf&lt;/code&gt; 라는 폴더에 함께 작성해두었습니다.&lt;/p&gt;
&lt;h3&gt;domains.conf&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;VirtualHost *:80&amp;gt;
    ServerName localhost
    ServerAdmin webmaster@localhost
    DocumentRoot /var/www/html

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    &amp;lt;Directory /var/www/html&amp;gt;
        DirectoryIndex index.php index.html index.htm
        Options Indexes FollowSymLinks Multiviews
        AllowOverride All
        Order allow,deny
        Allow from All
        Require all granted
    &amp;lt;/Directory&amp;gt;
&amp;lt;/VirtualHost&amp;gt;

&amp;lt;VirtualHost *:80&amp;gt;
    ServerName server.wd
    ServerAlias *.*.wd

    ServerAdmin webmaster@localhost
    VirtualDocumentRoot /DevHome/domains/%2/docroot

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    &amp;lt;Directory /DevHome/domains/*/docroot&amp;gt;
        DirectoryIndex index.php index.html index.htm
        Options Indexes FollowSymLinks Multiviews
        AllowOverride All
        Order allow,deny
        Allow from All
        Require all granted
    &amp;lt;/Directory&amp;gt;
&amp;lt;/VirtualHost&amp;gt;

&amp;lt;VirtualHost *:443&amp;gt;
    ServerName server.wd
    ServerAlias *.*.wd

    ServerAdmin webmaster@localhost
    VirtualDocumentRoot /DevHome/domains/%2/docroot

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    &amp;lt;Directory /DevHome/domains/*/docroot&amp;gt;
        DirectoryIndex index.php index.html index.htm
        Options Indexes FollowSymLinks Multiviews
        AllowOverride All
        Order allow,deny
        Allow from All
        Require all granted
    &amp;lt;/Directory&amp;gt;

    SSLEngine on
    SSLCertificateKeyFile /etc/ssl/private/dev.key
    SSLCertificateFile /etc/ssl/private/dev.crt
&amp;lt;/VirtualHost&amp;gt;

&amp;lt;VirtualHost *:80&amp;gt;
    ServerName server.wd
    ServerAlias *.wd

    ServerAdmin webmaster@localhost
    VirtualDocumentRoot /DevHome/domains/%1/docroot

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    &amp;lt;Directory /DevHome/domains/*/docroot&amp;gt;
        DirectoryIndex index.php index.html index.htm
        Options Indexes FollowSymLinks Multiviews
        AllowOverride All
        Order allow,deny
        Allow from All
        Require all granted
    &amp;lt;/Directory&amp;gt;
&amp;lt;/VirtualHost&amp;gt;

&amp;lt;VirtualHost *:443&amp;gt;
    ServerName server.wd
    ServerAlias *.wd

    ServerAdmin webmaster@localhost
    VirtualDocumentRoot /DevHome/domains/%1/docroot

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    &amp;lt;Directory /DevHome/domains/*/docroot&amp;gt;
        DirectoryIndex index.php index.html index.htm
        Options Indexes FollowSymLinks Multiviews
        AllowOverride All
        Order allow,deny
        Allow from All
        Require all granted
    &amp;lt;/Directory&amp;gt;

    SSLEngine on
    SSLCertificateKeyFile /etc/ssl/private/dev.key
    SSLCertificateFile /etc/ssl/private/dev.crt
&amp;lt;/VirtualHost&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 파일은 총 5개의 &lt;code&gt;virtualHost&lt;/code&gt; 블록을 가지고 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;번째 블록은 &lt;code&gt;http://localhost&lt;/code&gt;로 접속했을 때 출력되는 데시보드와 데이터베이스 관리를 위한 &lt;code&gt;PhpMyAdmin&lt;/code&gt; 기능을 사용할 수 있습니다.&lt;/li&gt;
&lt;li&gt;두 번째 및 세 번째 블록은 &amp;quot;다큐먼트 루트&amp;quot;.&amp;quot;프로젝트명&amp;quot;.&amp;quot;wd&amp;quot; 형태의 도매인으로 접속하는 경우에 사용됩니다.&lt;/li&gt;
&lt;li&gt;네 번째 블록과 다섯 번째 블록은 &amp;quot;프로젝트명&amp;quot;.&amp;quot;wd&amp;quot; 형태의 도매인으로 접속하는 경우에 사용됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;첫 번째 블록을 제외한 나머지 블록에는 아래와 같은 &lt;code&gt;VirtualDocumentRoot&lt;/code&gt; 라는 항목이 있습니다. 이 부분이 접속하는 도매인 주소에 따라 동적으로 다뮤먼트 루트를 결정하는 핵심적인 부분입니다. 아래 내용 중 &lt;code&gt;%2&lt;/code&gt; 는 접속한 도매인 주소를 &amp;quot;.&amp;quot;으로 구분했을 때 두 번째 것을 뜻합니다. 이 부분은 주로 &amp;quot;프로젝트명&amp;quot;이 될 것입낟.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VirtualDocumentRoot /DevHome/domains/%2/docroot&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 내용 중 &lt;code&gt;docroot&lt;/code&gt;는 다큐먼트 루트 폴더를 지정한 것입니다만. 이 부분은 개발환경 시작 시점에 &lt;code&gt;svhost&lt;/code&gt; 명령에 의해 수정될 것입니다. &lt;code&gt;docker-compose.yml&lt;/code&gt;의 &lt;code&gt;DOCUMENT_ROOT&lt;/code&gt; 환경변수 전달받아 &lt;code&gt;svhost&lt;/code&gt;가 해당 부분을 대체하도록 되어 있습니다.&lt;/p&gt;
&lt;h3&gt;sites.conf&lt;/h3&gt;
&lt;p&gt;이 파일은 전체 내용만 올려두겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;VirtualHost *:80&amp;gt;
    ServerName admin.sites.wd
    ServerAdmin webmaster@localhost
    DocumentRoot /var/www/html

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    &amp;lt;Directory /var/www/html&amp;gt;
        DirectoryIndex index.php index.html index.htm
        Options Indexes FollowSymLinks Multiviews
        AllowOverride All
        Order allow,deny
        Allow from All
        Require all granted
    &amp;lt;/Directory&amp;gt;
&amp;lt;/VirtualHost&amp;gt;

&amp;lt;VirtualHost *:80&amp;gt;
    ServerName server.wd
    ServerAlias *.*.wd
    UseCanonicalName Off

    ServerAdmin webmaster@localhost
    VirtualDocumentRoot /DevHome/sites/%2/%1

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    &amp;lt;Directory /DevHome/sites/*/*&amp;gt;
        DirectoryIndex index.php index.html index.htm
        Options Indexes FollowSymLinks Multiviews
        AllowOverride All
        Order allow,deny
        Allow from All
        Require all granted
    &amp;lt;/Directory&amp;gt;
&amp;lt;/VirtualHost&amp;gt;

&amp;lt;VirtualHost *:443&amp;gt;
    ServerName server.wd
    ServerAlias *.*.wd
    UseCanonicalName Off

    ServerAdmin webmaster@localhost
    VirtualDocumentRoot /DevHome/sites/%2/%1

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    &amp;lt;Directory /DevHome/sites/*/*&amp;gt;
        DirectoryIndex index.php index.html index.htm
        Options Indexes FollowSymLinks Multiviews
        AllowOverride All
        Order allow,deny
        Allow from All
        Require all granted
    &amp;lt;/Directory&amp;gt;

    SSLEngine on
    SSLCertificateKeyFile /etc/ssl/private/dev.key
    SSLCertificateFile /etc/ssl/private/dev.crt
&amp;lt;/VirtualHost&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;HTTPS 프로토콜을 위한 인증서&lt;/h2&gt;
&lt;p&gt;인증서 파일은 제가 이번에 작업하면서 새로 자체 서명한 인증서 파일을 적용했습니다. 과거의 인증서 파일은 설정 파일에 명기된 &lt;code&gt;ServerName&lt;/code&gt;과 일치하지 않아 정상적으로 동작하지 않는 문제가 있었습니다. 이 파을들은 &lt;code&gt;ssl_key&lt;/code&gt; 라는 폴더에 넣어 두었습니다.&lt;/p&gt;
&lt;h2&gt;로컬 호스트 웹 루터 폴드 파일&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;http://localhost&lt;/code&gt;로 접속했을 때 처음에는 현재 가발환경의 사용자가 지정한 모드에서 실행되는 프로젝트 목록과, 시스템 정보를 출력하는 데시보드가 출력되고, 상단에 데이터베이스를 관리하는 &lt;code&gt;PhpMyAdmin&lt;/code&gt; 링크가 출력됩니다. &lt;code&gt;/var/www/html&lt;/code&gt; 폴더에 &lt;code&gt;index.php&lt;/code&gt;, &lt;code&gt;PhpMyAdmin 폴더&lt;/code&gt; 가 복사되어 들어갑니다.&lt;/p&gt;
&lt;h3&gt;index.php&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
    function checkSites() {
        if(file_exists(&amp;#39;/etc/apache2/sites-enabled/sites.conf&amp;#39;)) return 1;
        elseif(file_exists(&amp;#39;/etc/apache2/sites-enabled/domains.conf&amp;#39;)) return 0;
        else return -1;
    }

    function getDocRoot() {
        $conf = &amp;#39;/etc/apache2/sites-enabled/domains.conf&amp;#39;;
        if(file_exists($conf)) {
            $content = file_get_contents($conf, true);
            preg_match_all(&amp;#39;/\/DevHome\/domains\/%2\/(.*)/&amp;#39;, $content, $matches);
            if(count($matches) == 2) {
                return reset($matches[1]);
            }
        }
        return null;
    }

    $is_sites = checkSites();
    $part_name = null;
    if($is_sites == 1) $part_name = &amp;#39;일반&amp;#39;;
    elseif($is_sites == 0) $part_name = &amp;#39;도매인 사이트&amp;#39;;
    else $part_name = &amp;#39;미설정&amp;#39;;

    $domain_suffix = &amp;quot;wd&amp;quot;;
    $sites = array();
    $docroots = array(
        &amp;#39;docroot&amp;#39;, &amp;#39;html&amp;#39;, &amp;#39;public_html&amp;#39;, &amp;#39;public&amp;#39;, &amp;#39;web&amp;#39;, &amp;#39;webroot&amp;#39;, &amp;#39;www&amp;#39;, &amp;#39;wwwroot&amp;#39;
    );

    if($is_sites == 1) {
        $base = &amp;#39;/DevHome/sites&amp;#39;;
        $dh = opendir( $base );
        if( $dh ) {
            while( ($entry = readdir( $dh )) ) {
                foreach( $docroots as $docroot ) {
                    if( $entry == &amp;#39;..&amp;#39; || $entry == &amp;#39;.&amp;#39; ) continue;
                    if( filetype( $base . &amp;#39;/&amp;#39; . $entry . &amp;#39;/&amp;#39; . $docroot ) == &amp;#39;dir&amp;#39; ) {
                        $sites[] = array(
                            &amp;#39;https_url&amp;#39; =&amp;gt; &amp;quot;https://$docroot.$entry.$domain_suffix&amp;quot;,
                            &amp;#39;http_url&amp;#39; =&amp;gt; &amp;quot;http://$docroot.$entry.$domain_suffix&amp;quot;,
                            &amp;#39;sitename&amp;#39; =&amp;gt; $entry
                        );
                        break;
                    }
                }
            }
        }
    }
    elseif($is_sites == 0 &amp;amp;&amp;amp; ($domain_docroot = getDocRoot()) != null) {
        $base = &amp;#39;/DevHome/domains&amp;#39;;
        $dh = opendir( $base );
        if($dh) {
            while( ($entry = readdir( $dh )) ) {
                if( $entry == &amp;#39;..&amp;#39; || $entry == &amp;#39;.&amp;#39; ) continue;
                if( filetype( $base . &amp;#39;/&amp;#39; . $entry . &amp;#39;/&amp;#39; . $domain_docroot ) == &amp;#39;dir&amp;#39; ) {
                    $sites[] = array(
                        &amp;#39;https_url&amp;#39; =&amp;gt; &amp;quot;https://www.$entry.$domain_suffix&amp;quot;,
                        &amp;#39;http_url&amp;#39; =&amp;gt; &amp;quot;http://www.$entry.$domain_suffix&amp;quot;,
                        &amp;#39;sitename&amp;#39; =&amp;gt; $entry
                    );
                }
            }
        }
    }

?&amp;gt;

&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&amp;quot;ko&amp;quot;&amp;gt;
    &amp;lt;head&amp;gt;
        &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot;/&amp;gt;
        &amp;lt;title&amp;gt;Site Helper&amp;lt;/title&amp;gt;
        &amp;lt;script src=&amp;quot;https://code.jquery.com/jquery-3.2.1.slim.min.js&amp;quot; integrity=&amp;quot;sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN&amp;quot; crossorigin=&amp;quot;anonymous&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&amp;quot;https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js&amp;quot; integrity=&amp;quot;sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q&amp;quot; crossorigin=&amp;quot;anonymous&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&amp;quot;https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js&amp;quot; integrity=&amp;quot;sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl&amp;quot; crossorigin=&amp;quot;anonymous&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;link rel=&amp;quot;stylesheet&amp;quot; href=&amp;quot;https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css&amp;quot; integrity=&amp;quot;sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm&amp;quot; crossorigin=&amp;quot;anonymous&amp;quot;&amp;gt;
    &amp;lt;/head&amp;gt;
    &amp;lt;body&amp;gt;
        &amp;lt;nav class=&amp;quot;navbar navbar-expand-lg navbar-light bg-light&amp;quot;&amp;gt;
            &amp;lt;a class=&amp;quot;navbar-brand&amp;quot; href=&amp;quot;#&amp;quot;&amp;gt;Site Helper&amp;lt;/a&amp;gt;
            &amp;lt;button class=&amp;quot;navbar-toggler&amp;quot; type=&amp;quot;button&amp;quot; data-toggle=&amp;quot;collapse&amp;quot; data-target=&amp;quot;#navbarSupportedContent&amp;quot; aria-controls=&amp;quot;navbarSupportedContent&amp;quot; aria-expanded=&amp;quot;false&amp;quot; aria-label=&amp;quot;Toggle navigation&amp;quot;&amp;gt;
                &amp;lt;span class=&amp;quot;navbar-toggler-icon&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;
            &amp;lt;/button&amp;gt;

            &amp;lt;div class=&amp;quot;collapse navbar-collapse&amp;quot; id=&amp;quot;navbarSupportedContent&amp;quot;&amp;gt;
                &amp;lt;ul class=&amp;quot;navbar-nav mr-auto&amp;quot;&amp;gt;
                    &amp;lt;li class=&amp;quot;nav-item&amp;quot;&amp;gt;
                        &amp;lt;a href=&amp;quot;./myadmin&amp;quot; title=&amp;quot;phpMyAdmin&amp;quot;&amp;gt;데이터베이스 관리&amp;lt;/a&amp;gt;
                    &amp;lt;/li&amp;gt;
                &amp;lt;/ul&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/nav&amp;gt;

        &amp;lt;div class=&amp;quot;container mt-5&amp;quot;&amp;gt;
            &amp;lt;div class=&amp;quot;card&amp;quot;&amp;gt;
                &amp;lt;div class=&amp;quot;card-header&amp;quot;&amp;gt;사이트 목록 (&amp;lt;?=$part_name?&amp;gt;)&amp;lt;/div&amp;gt;
                &amp;lt;div class=&amp;quot;card-body&amp;quot;&amp;gt;
                    &amp;lt;?php foreach( $sites as $site ): ?&amp;gt;
                        &amp;lt;div class=&amp;quot;d-inline-block rounded m-2 bg-white&amp;quot; style=&amp;quot;box-shadow: 1px 1px 10px 1px #aaa&amp;quot;&amp;gt;
                            &amp;lt;div class=&amp;quot;d-inline-block px-4 py-2 bg-info text-white&amp;quot;&amp;gt;&amp;lt;?=$site[&amp;#39;sitename&amp;#39;]?&amp;gt;&amp;lt;/div&amp;gt;
                            &amp;lt;div class=&amp;quot;d-inline-block p-2&amp;quot;&amp;gt;
                                &amp;lt;a class=&amp;quot;m-1 p-1 text-success&amp;quot; href=&amp;quot;&amp;lt;?=$site[&amp;#39;https_url&amp;#39;]?&amp;gt;&amp;quot; title=&amp;quot;https&amp;quot;&amp;gt;https&amp;lt;/a&amp;gt;
                                &amp;lt;a class=&amp;quot;m-1 p-1 text-secondary&amp;quot; href=&amp;quot;&amp;lt;?=$site[&amp;#39;http_url&amp;#39;]?&amp;gt;&amp;quot; title=&amp;quot;http&amp;quot;&amp;gt;http&amp;lt;/a&amp;gt;
                            &amp;lt;/div&amp;gt;
                        &amp;lt;/div&amp;gt;
                    &amp;lt;?php endforeach; ?&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;div class=&amp;quot;container mt-5&amp;quot;&amp;gt;
            &amp;lt;div class=&amp;quot;card&amp;quot;&amp;gt;
                &amp;lt;div class=&amp;quot;card-header&amp;quot;&amp;gt;시스템 정보&amp;lt;/div&amp;gt;
                &amp;lt;div class=&amp;quot;card-body&amp;quot;&amp;gt;
                    &amp;lt;?php phpinfo(); ?&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;PhpMyAdmin 의 config.inc.php&lt;/h3&gt;
&lt;p&gt;본 개발환경은 도커를 기반으로 실행됩니다. 그래서 &lt;code&gt;config.inc.php&lt;/code&gt;를 &lt;code&gt;config.sample.inc.php&lt;/code&gt;를 복사한 후 접속 서버 정보를 아래와 같이 호스트 정보를 &lt;code&gt;host.docker.internal&lt;/code&gt;로 수정해주어야 합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
/**
 * phpMyAdmin sample configuration, you can use it as base for
 * manual configuration. For easier setup you can use setup/
 *
 * All directives are explained in documentation in the doc/ folder
 * or at &amp;lt;https://docs.phpmyadmin.net/&amp;gt;.
 */

declare(strict_types=1);

/**
 * This is needed for cookie based authentication to encrypt the cookie.
 * Needs to be a 32-bytes long string of random bytes. See FAQ 2.10.
 */
$cfg[&amp;#39;blowfish_secret&amp;#39;] = &amp;#39;&amp;#39;; /* YOU MUST FILL IN THIS FOR COOKIE AUTH! */

/**
 * Servers configuration
 */
$i = 0;

/**
 * First server
 */
$i++;
/* Authentication type */
$cfg[&amp;#39;Servers&amp;#39;][$i][&amp;#39;auth_type&amp;#39;] = &amp;#39;cookie&amp;#39;;
/* Server parameters */
$cfg[&amp;#39;Servers&amp;#39;][$i][&amp;#39;host&amp;#39;] = &amp;#39;host.docker.internal&amp;#39;;
$cfg[&amp;#39;Servers&amp;#39;][$i][&amp;#39;compress&amp;#39;] = false;
$cfg[&amp;#39;Servers&amp;#39;][$i][&amp;#39;AllowNoPassword&amp;#39;] = false;

/** 이하 생략 */&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;사용자 지정 PHP 라이브러리 추가를 위한 설정&lt;/h2&gt;
&lt;p&gt;새로 취업한 회사에서는 본인 인증을 위해 국내 서비스 기업의 PHP 전용 라이브러리를 사용하고 있었습니다. 이렇게 환경에 따라 라이브러리가 추가되어야 할 경우를 대비하여 &lt;code&gt;docker-compose.yml&lt;/code&gt; 이 있는 폴더 아래에 &lt;code&gt;lib&lt;/code&gt;라는 폴더를 생성하고, 폴더에 적용할 라이브러리 파일과 라이브러리를 로드하기 위한 INI 파일을 넣어 둡니다. 설정파일의 예는 아래와 같습니다.&lt;/p&gt;
&lt;h3&gt;custom-sample.ini&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;extension=/usr/lib/php/custom/okcert3_2.0.2_ext_linux64_glibc2.17__8.1.so&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;docker-compose.yml&lt;/h2&gt;
&lt;p&gt;도커 컴포즈를 이용하여 개발환경을 실행하기 위한 파일로 위 내용을 모두 적용하도록 한 파일을 예로 올려 둡니다. 이 파일을 직접 수정해서 사용하거나 뒤에 소개될 &lt;code&gt;dinit.sh&lt;/code&gt;를 이용하여 간단하게 만들어 줄 수도 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: PHP_DEV

volumes:
  sql_data:

services:
  db:
    image: mariadb
    command: --max_allowed_packet=536870912
    environment:
      MARIADB_ROOT_PASSWORD: mysql
    ports:
      - 3306:3306
    volumes:
      - sql_data:/var/lib/mysql

  web:
    image: pig482/devenv:ap78
    environment:
      PHP: 8.1
      CONF: domains
      CUSTOM_INI: custom.ini
      DOCUMENT_ROOT: public
    ports:
      - 80:80
      - 443:443
      - 5173:5173
      - 8080:8080
    volumes:
      - ./webroot:/DevHome
      - ./lib:/usr/lib/php/custom&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;docker_entrypoint.sh&lt;/h2&gt;
&lt;p&gt;도커 개발환경이 실행될 때, 이 파일은 &lt;code&gt;docker-compose.yml&lt;/code&gt;에 기록된 환경변수를 받아 실행하도록 합니다. 저의 경우 이 파일에는 &lt;code&gt;PHP&lt;/code&gt;, &lt;code&gt;CONF&lt;/code&gt;, &lt;code&gt;CUSTOM_INI&lt;/code&gt;, &lt;code&gt;DOCUMENT_ROOT&lt;/code&gt; 등의 환경변수를 받아 Apache 웹서버가 동작하도록 합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash

/usr/bin/sphp &amp;quot;$PHP&amp;quot;
/usr/bin/svhost &amp;quot;$CONF&amp;quot; &amp;quot;$DOCUMENT_ROOT&amp;quot;
if [ -f &amp;quot;/usr/lib/php/custom/${CUSTOM_INI}&amp;quot; ]; then
    ln -s &amp;quot;/usr/lib/php/custom/${CUSTOM_INI}&amp;quot; &amp;quot;/etc/php/${PHP}/apache2/conf.d/35-${CUSTOM_INI}&amp;quot;
    ln -S &amp;quot;/usr/lib/php/custom/${CUSTOM_INI}&amp;quot; &amp;quot;/etc/php/${PHP}/apache2/conf.d/35-${CUSTOM_INI}&amp;quot;
    ln -s &amp;quot;/usr/lib/php/custom/${CUSTOM_INI}&amp;quot; &amp;quot;/etc/php/${PHP}/cli/conf.d/35-${CUSTOM_INI}&amp;quot;
    ln -S &amp;quot;/usr/lib/php/custom/${CUSTOM_INI}&amp;quot; &amp;quot;/etc/php/${PHP}/cli/conf.d/35-${CUSTOM_INI}&amp;quot;
fi
exec &amp;quot;$@&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;sphp.sh&lt;/h2&gt;
&lt;p&gt;이 스크립트 파일은 개발환경 내에서 PHP 버전을 변경하는 기능을 가지고 있습니다. 내용은 아래와 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash

if [ &amp;quot;$1&amp;quot; == &amp;quot;7.4&amp;quot; ]; then
    a2enmod php7.4
    update-alternatives --set php /usr/bin/php7.4
elif [ &amp;quot;$1&amp;quot; == &amp;quot;8.1&amp;quot; ]; then
    a2enmod php8.1
    update-alternatives --set php /usr/bin/php8.1
elif [ &amp;quot;$1&amp;quot; == &amp;quot;8.2&amp;quot; ]; then
    a2enmod php8.2
    update-alternatives --set php /usr/bin/php8.2
elif [ &amp;quot;$1&amp;quot; == &amp;quot;8.4&amp;quot; ]; then
    a2enmod php8.4
    update-alternatives --set php /usr/bin/php8.4
fi&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;svhost.sh&lt;/h2&gt;
&lt;p&gt;이 스크립트 파일은 Apache 웹 서버가 도매인모드, 사이트 모드 중 하나로 동작하도록 합니다. 내용은 아래와 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash

# 도메인 기반 사이트 환경설정 활성화
function domain_link()
{
    file=&amp;quot;/etc/apache2/sites-available/domains.conf&amp;quot;
    if [ -e $file ]; then
        sed -i &amp;quot;s/\/DevHome\/domains\/%2\/.*$/\/DevHome\/domains\/%2\/$1/g&amp;quot; $file
        sed -i &amp;quot;s/\/DevHome\/domains\/%1\/.*$/\/DevHome\/domains\/%1\/$1/g&amp;quot; $file
        sed -i &amp;quot;s/\/DevHome\/domains\/\*\/[^&amp;gt;]*/\/DevHome\/domains\/\*\/$1/g&amp;quot; $file
        if [ ! -e &amp;quot;/etc/apache2/sites-enabled/domains.conf&amp;quot; ]; then
            ln -s $file &amp;quot;/etc/apache2/sites-enabled/domains.conf&amp;quot;
        fi
        echo 1
    else
        echo 0;
    fi
}

# 개별 사이트 환경설정 활성화
function sites_link()
{
    file=&amp;quot;/etc/apache2/sites-available/sites.conf&amp;quot;
    if [ -e $file ]; then
        if [ ! -e &amp;quot;/etc/apache2/sites-enabled/sites.conf&amp;quot; ]; then
            ln -s $file &amp;quot;/etc/apache2/sites-enabled/sites.conf&amp;quot;
        fi
        echo 1
    else
        echo 0
    fi
}

case $1 in
    sites)
        sites_link
        ;;
    domains)
         domain_link $2
        ;;
esac&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;dinit.sh&lt;/h2&gt;
&lt;p&gt;이 파일은 없어도 되는 파일입니다. &lt;code&gt;docker-compose.yml&lt;/code&gt;을 직접 편집하기 힘든 사용자를 위한 스크립트 파일입니다. 내용은 아래와 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash

PHP=&amp;quot;&amp;quot;
CONF=&amp;quot;&amp;quot;
CUSTOM_INI=&amp;quot;&amp;quot;
DOCUMENT_ROOT=&amp;quot;&amp;quot;

EXIT=0
while [ $EXIT -eq 0 ]
do
    echo &amp;quot; 1. Init Directory (webroot, webroot/sites, webroot/domains, lib) &amp;quot;
    echo &amp;quot;&amp;quot;
    echo &amp;quot; Domains Evironment &amp;quot;
    echo &amp;quot; 2. PHP 7.4 &amp;quot;
    echo &amp;quot; 3. PHP 8.1 &amp;quot;
    echo &amp;quot; 4. PHP 8.2 &amp;quot;
    echo &amp;quot; 5. PHP 8.4 &amp;quot;
    echo &amp;quot;&amp;quot;
    echo &amp;quot; Sites Environment &amp;quot;
    echo &amp;quot; 6. PHP 7.4 &amp;quot;
    echo &amp;quot; 7. PHP 8.1 &amp;quot;
    echo &amp;quot; 8. PHP 8.2 &amp;quot;
    echo &amp;quot; 9. PHP 8.4 &amp;quot;
    echo &amp;quot; 0. Exit &amp;quot;

    read -p &amp;quot;Select Number : &amp;quot; NO
    case &amp;quot;$NO&amp;quot; in
        1)
            mkdir webroot 2&amp;gt; /dev/null
            mkdir -p webroot/sites 2&amp;gt; /dev/null
            mkdir -p webroot/domains 2&amp;gt; /dev/null
            mkdir -p lib 2&amp;gt; /dev/null
            ;;
        2)
            PHP=&amp;quot;7.4&amp;quot;
            CONF=&amp;quot;domains&amp;quot;
            EXIT=1
            ;;
        3)
            PHP=&amp;quot;8.1&amp;quot;
            CONF=&amp;quot;domains&amp;quot;
            EXIT=1
            ;;
        4)
            PHP=&amp;quot;8.2&amp;quot;
            CONF=&amp;quot;domains&amp;quot;
            EXIT=1
            ;;
        5)
            PHP=&amp;quot;8.4&amp;quot;
            CONF=&amp;quot;domains&amp;quot;
            EXIT=1
            ;;
        6)
            PHP=&amp;quot;7.4&amp;quot;
            CONF=&amp;quot;sites&amp;quot;
            EXIT=1
            ;;
        7)
            PHP=&amp;quot;8.1&amp;quot;
            CONF=&amp;quot;sites&amp;quot;
            EXIT=1
            ;;
        8)
            PHP=&amp;quot;8.2&amp;quot;
            CONF=&amp;quot;sites&amp;quot;
            EXIT=1
            ;;
        9)
            PHP=&amp;quot;8.4&amp;quot;
            CONF=&amp;quot;sites&amp;quot;
            EXIT=1
            ;;
        0)
            exit 0
            ;;
        *)
            echo &amp;quot; Wrong number.&amp;quot;
            ;;
    esac
done

if [ $EXIT -eq 1 ]; then
    read -p &amp;quot;PHP Custom extension ini file name : &amp;quot; INI
    if [ -f &amp;quot;./lib/${INI}&amp;quot; ]; then
        CUSTOM_INI=$INI
    fi

    read -p &amp;quot;Type Document root directory name (Domains only) : &amp;quot; DIR
    DOCUMENT_ROOT=$DIR

    if [ -f &amp;quot;docker-compose.yml&amp;quot; ]; then
        rm -f docker-compose.yml
    fi

    echo &amp;quot;name: PHP_DEV&amp;quot; &amp;gt; docker-compose.yml
    echo &amp;quot;&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;volumes:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;  sql_data:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;services:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;  db:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    image: mariadb&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    command: --max_allowed_packet=536870912&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    environment:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      MARIADB_ROOT_PASSWORD: mysql&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    ports:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      - 3306:3306&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    volumes:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      - sql_data:/var/lib/mysql&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;  web:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    image: pig482/devenv:ap78&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    environment:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      PHP: ${PHP}&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      CONF: ${CONF}&amp;quot; &amp;gt;&amp;gt; docker-compose.yml

    if [ -n &amp;quot;./lib/${CUSTOM_INI}&amp;quot; ]; then
        echo &amp;quot;      CUSTOM_INI: ${CUSTOM_INI}&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    fi

    echo &amp;quot;      DOCUMENT_ROOT: ${DOCUMENT_ROOT}&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    ports:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      - 80:80&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      - 443:443&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      - 5173:5173&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      - 8080:8080&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;    volumes:&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    echo &amp;quot;      - ./webroot:/DevHome&amp;quot; &amp;gt;&amp;gt; docker-compose.yml

    if [ -n &amp;quot;./lib/${CUSTOM_INI}&amp;quot; ]; then
        echo &amp;quot;      - ./lib:/usr/lib/php/custom&amp;quot; &amp;gt;&amp;gt; docker-compose.yml
    fi

    echo &amp;quot;Init docker-compose.yml file&amp;quot;
fi&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;파일 준비&lt;/h2&gt;
&lt;p&gt;위 내용의 모든 파일은 저의 Github 저장소에 등록되어 있습니다. 저장소의 내용을 내려 받아 활용하는 것이 편할 것입니다. 폴더 구조를 간단하게나마 보자면 아래와 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.
├─confs
│ ├─domains.conf
│ └─sites.conf
├─phpMyAdmin
│ └─config.inc.php
├─custom-sample.ini
├─dinit.sh
├─Dockerfile
├─index.php
├─sphp.sh
├─svhost.sh
└─ssl_key
  ├─dev.crt
  └─dev.key&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;도커 이미지 빌드&lt;/h2&gt;
&lt;p&gt;저의 경우 Docker Hub에 제가 작성한 이미지를 등록해두고 화룡하고 있습니다. 아래와 같이 빌드를 하지 않고 제가 올려둔 이미지를 이용할 수도 있습니다. 저는 아래와 같이 빌드하여 도커 호브에 푸시했습니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/j-hansol/Dev-Environment&quot;&gt;j-hansol / Dev-Environment&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker build -t pig482/devenv:ap78 .
docker push pig482/devenv:ap78&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;docker-compose 파일 생성&lt;/h2&gt;
&lt;p&gt;위에 언급한 &lt;code&gt;docker-compose.yml&lt;/code&gt;을 아래와 같이 &lt;code&gt;dinit.sh&lt;/code&gt;를 이용하여 생성합니다. 참고로 저는 wsl 환경에서 실행했습니다. 아래와 같이 모드와 PHP 버전을 번호를 입력하여 선택하고 라이브러리 설장 파일의 이름을 지정하고, 마지막으로 다큐먼트 루트 폴더명을 입력하면 &lt;code&gt;docker-compose.yml&lt;/code&gt; 이 완성됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ sh dinit.sh
 1. Init Directory (webroot, webroot/sites, webroot/domains, lib)

 Domains Evironment
 2. PHP 7.4
 3. PHP 8.1
 4. PHP 8.2
 5. PHP 8.4

 Sites Environment
 6. PHP 7.4
 7. PHP 8.1
 8. PHP 8.2
 9. PHP 8.4
 0. Exit
Select Number : 3
PHP Custom extension ini file name : custom.ini
Type Document root directory name (Domains only) : public
Init docker-compose.yml file
$&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;개발환경 실행&lt;/h2&gt;
&lt;p&gt;개발관경 실행과 종료는 아래의 명령을 이용하면 됩니다.&lt;/p&gt;
&lt;h3&gt;개발환경 실행&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker-compose up -d&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;개발환경 종료&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker-compose down&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;개발환경 접속&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker-compose exec web bash&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;또는&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose exec -u ubuntu web bash&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;웹 브로우즈 접속&lt;/h3&gt;
&lt;p&gt;데시보드와 데이터베이스 관리를 위해서는 아래와 같이 접속합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://localhost&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;각 프로젝트 사이트 접속은 아래와 같이 가능합니다. 예를 들자면...&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://public.project.wd
https://public.project.wd
http://project.wd
https://project.wd&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;자세하게 기록하자면 너무 많아 간단하게 적는다고 적었는데 그래도 내용이 많은 듯 하네요. 나름데로 회사에서 진행하는 프로젝트나, 저의 개인적으로 진행하는 사이드 프로젝트 등에 잘 활용할 듯 합니다. 무엇보다 본인 인증과 같은 기능을 추가하는 경우 위부 라이브러리를 사용할 수 있도록 만들었다는 점에서 매우 흡족합니다.&lt;/p&gt;
&lt;p&gt;그리고 회사에서는 &lt;code&gt;XAMPP&lt;/code&gt;를 이용하고 있는데, 이 환경에서는 외부 라이브러리를 적용할 수 없다는 점에서 &amp;quot;역시 개발은 linux, Mac, Docker 환경이 좋구나&amp;quot; 하는 생각을 했습니다.&lt;/p&gt;</description>
      <category>프로그래밍/PHP</category>
      <category>php</category>
      <category>개발환경</category>
      <category>서버</category>
      <category>인프라</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/202</guid>
      <comments>https://jhansol.tistory.com/202#entry202comment</comments>
      <pubDate>Sun, 15 Dec 2024 17:39:08 +0900</pubDate>
    </item>
    <item>
      <title>그누보드 6 설치하기, 삽질기</title>
      <link>https://jhansol.tistory.com/201</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;참! 요즘 다양하게 시도해봅니다. 오늘은 저의 주종목이 아닌 Python과 관련된 내용입니다. 오늘 저의 지인분이 마무리하지 못한 프로젝트를 받아 마무리하는 것으로 협의가 되었습니다. 그누보드로 되어 있다고 해서 덥석 받았더니만, URL 형식이 그누보드 5의 형식이 아닌 것을 확인하고 사이트에 들어 가보니 그누보드 6이 올라와 있네요. 그런데, 이건 Python으로 되어 있네요. ㅠㅠ. 아직 소스는 받아보지 못했는데 밤이 늦어 내일 확인하고 받아봐야 할 것 같습니다. 그누보드 6여야 하는데....&lt;br /&gt;그러면 받지 않았을 텐데&lt;/p&gt;
&lt;p&gt;&lt;del&gt;~ 아&lt;/del&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;받았으니 마무리를 지어야 하겠죠?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 일단 설치를해서 사용해보려고 합니다.&lt;/p&gt;
&lt;h1&gt;그누보드 설치&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그누보드 6는 아직 안정화 버전이 아니고, 공부하는 사람에게 추천한다고 되어 있습니다. 그래서인지 Github 저장소에서 받아 설치하도록 되어 있습니다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;git clone https://github.com/gnuboard/g6.git&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내려받은 폴더로 이동한 후 가상환경에서 실행하도록 하고 있습니다. 그런데 Ubuntu에서는 가상환경을 생성할 수 없다고 하네요. 그래서 가상환경을 생성하기 전에 필요한 패키지를 먼저 설채해줘야 합니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;cd g6
apt install python3.12-venv
python3 -m venv venv&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상환경을 생성했으면 아래의 명령으로 가상환경을 활성화해줍니다. 그리고 필요한 패키지를 설치합니다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;source venv/bin/activate
pip3 install -r requirements.txt&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;데이터베이스 준비&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노트북에는 MySQL 8.0이 설치되어 있습니다. 그누보드용 데이터베이스를 준비해두겠습니다. 현재 개발용으로 &lt;code&gt;dbadmin&lt;/code&gt;이라는 계정이 있으므로 데이터베이스만 생성했습니다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;create database test_gnu;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;그누보드 실행&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;uvicorn&lt;/code&gt;을 이용하여 그누보드 어플리케이션을 실행합니다. 저의 Ubuntu 노트북에는 이미 nginx가 80번 포트로 실행되고 있기 때문에 &lt;code&gt;uvicorn&lt;/code&gt; 의 기본 포트인 8000 포트로 실행을 해줍니다. 이 경우 포트부분은 생략해도 됩니다. 하지만 그누보드 문서에는 이렇게 되어 있어 그냥 적어뒀습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;uvicorn main:app --reload --host 0.0.0.0 --port 8000&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;브라우즈로 접속하여 설치 마무리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 그림과 같이 브라우즈로 접속하면 &lt;code&gt;env 파일이 없습니다. 설치를 진행주세요&lt;/code&gt; 라고 메시지가 출력됩니다. &lt;code&gt;확인&lt;/code&gt;클릭하여 다음 화면으로 진행합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;912&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ep0J7d/btsH60XzssH/GN0vAMYUZc0bujdbpBV13k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ep0J7d/btsH60XzssH/GN0vAMYUZc0bujdbpBV13k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ep0J7d/btsH60XzssH/GN0vAMYUZc0bujdbpBV13k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fep0J7d%2FbtsH60XzssH%2FGN0vAMYUZc0bujdbpBV13k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1301&quot; height=&quot;912&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;912&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 아래 그림과 같이 그누보드 6 안내 페이지가 표시됩니다. 아래의 &lt;code&gt;설치하기&lt;/code&gt; 버튼을 클릭하여 설치를 진행합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1063&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vNWeT/btsH6RzLqkA/3TaDjVIvZZ1PObRau69Vi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vNWeT/btsH6RzLqkA/3TaDjVIvZZ1PObRau69Vi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vNWeT/btsH6RzLqkA/3TaDjVIvZZ1PObRau69Vi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvNWeT%2FbtsH6RzLqkA%2F3TaDjVIvZZ1PObRau69Vi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1301&quot; height=&quot;1063&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1063&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이센스 안내 페이지에서 &lt;code&gt;동의합니다&lt;/code&gt;에 체크한 후 &lt;code&gt;다음&lt;/code&gt; 버튼을 클릭합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;856&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnMcIU/btsH6ymYiY2/BTWo5McQlUjbKcQddJnY9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnMcIU/btsH6ymYiY2/BTWo5McQlUjbKcQddJnY9K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnMcIU/btsH6ymYiY2/BTWo5McQlUjbKcQddJnY9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnMcIU%2FbtsH6ymYiY2%2FBTWo5McQlUjbKcQddJnY9K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1301&quot; height=&quot;856&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;856&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치의 마지막 단계로 데이터베이스 설정을 해야합니다. 저의 경우 MySQL, 계정은 dbadmin, 비밀번호는 xxxx, 데이터베이스 이름은 test_gnu, 그리고 사이트 관리자 계정의 비밀번호를 지정하고 &lt;code&gt;다음&lt;/code&gt; 버튼을 클릭했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKi8Qp/btsH5ViAPKH/2Enc1HExFy1mzHUEqS23Nk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKi8Qp/btsH5ViAPKH/2Enc1HExFy1mzHUEqS23Nk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKi8Qp/btsH5ViAPKH/2Enc1HExFy1mzHUEqS23Nk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKi8Qp%2FbtsH5ViAPKH%2F2Enc1HExFy1mzHUEqS23Nk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1301&quot; height=&quot;1032&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 설정을 마무리하면 설치를 마무리하는 화면이 출력되고, 이어 &lt;code&gt;매인으로 이동&lt;/code&gt; 버튼이 표시됩니다. 설치가 완료되었으니 버튼을 클릭하여 매인 화면으로 이동합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/br8TKY/btsH6Ll1dws/cohJnugic5PA7Qx8tYo2c0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/br8TKY/btsH6Ll1dws/cohJnugic5PA7Qx8tYo2c0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/br8TKY/btsH6Ll1dws/cohJnugic5PA7Qx8tYo2c0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbr8TKY%2FbtsH6Ll1dws%2FcohJnugic5PA7Qx8tYo2c0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1301&quot; height=&quot;1032&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 아래 그림과 같이 매인화면이 출력됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pO4Ta/btsH71IbG5w/Kwzp0mBMscX4V5duAjBkP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pO4Ta/btsH71IbG5w/Kwzp0mBMscX4V5duAjBkP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pO4Ta/btsH71IbG5w/Kwzp0mBMscX4V5duAjBkP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpO4Ta%2FbtsH71IbG5w%2FKwzp0mBMscX4V5duAjBkP1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1301&quot; height=&quot;1032&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자 계정으로 로그인한 후 우측 상단의 사용자 아이콘을 클릭하면 &lt;code&gt;관리자&lt;/code&gt; 메뉴가 보입니다. 이 메뉴를 클릭하면 관리자용 페이지로 접근할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ch0JkQ/btsH5LN25Dq/DR5Nuabkb7Fk1JZKRqfMIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ch0JkQ/btsH5LN25Dq/DR5Nuabkb7Fk1JZKRqfMIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ch0JkQ/btsH5LN25Dq/DR5Nuabkb7Fk1JZKRqfMIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fch0JkQ%2FbtsH5LN25Dq%2FDR5Nuabkb7Fk1JZKRqfMIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1301&quot; height=&quot;1032&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자 화면은 기존 그누보드 5와는 차이가 많아 보이고, 아직 개발중이라는 느낌이 듭니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rp2ln/btsH6ymYsPg/w6AddeDOrSV3l3xX3zCd01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rp2ln/btsH6ymYsPg/w6AddeDOrSV3l3xX3zCd01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rp2ln/btsH6ymYsPg/w6AddeDOrSV3l3xX3zCd01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Frp2ln%2FbtsH6ymYsPg%2Fw6AddeDOrSV3l3xX3zCd01%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1301&quot; height=&quot;1032&quot; data-origin-width=&quot;1301&quot; data-origin-height=&quot;1032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;마치며...&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아들과 파이썬을 공부하면서 익숙하지 않은 언어로 PHP 만큼 개발할 수 있을까하는 막연함으로 입문정도만 하고 말았었는데, 설치 과정에서 공부의 목적으로 해보는 것으로눈 좋을 것 같습니다. 오늘의 일을 계기로 파이선을 활용하는 시발점이 대기를 기대해봅니다. 그리고 파이선 웹 서비스 서버 실행환경 설정을 맛볼 수 있는 좋은 계기였습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그리고 한 가지 더!!!&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상환경을 생성하고 활성화는 했는데, 비활성화하는 명령(deactive)이 없다고 나오네요. 구글링, ChatGPT를 이용해 확인해보니 해당 명령으로 비활성화한다고 되어 있으나 해당 명령이 없다고 나옵니다. 혹 이 부분 아시는 분 있을까요?&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자라보고 놀란 가슴 솥뚜껑보고 놀란다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아놔~&lt;br /&gt;포스팅하고 그누보드 5의 클린 URL(그누보드 환경설정에는 &lt;code&gt;짧은주소&lt;/code&gt;라고 표현됨) 설정에 관련된 내용을 구글링하니 내용이 있군요. 그누보드를 많이 사용해보지 않은 티가 팍팍 납니다. 우리 속담에 &lt;code&gt;자라보고 놀란 가슴 솥뚜껑보고 날란다.&lt;/code&gt; 이 있는데, 제가 딱 그짝이네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 링크를 클릭하면 해당 설정에 대한 메뉴얼을 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://sir.kr/manual/g5/286&quot;&gt;짤은주소&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저의 노트북에는 Nginx가 설치되어 있어 &lt;code&gt;숫자&lt;/code&gt;에 체크한 후 &lt;code&gt;Nginx 설정 코드 보기&lt;/code&gt;를 클릭하여 내용을 복사한 후 서버 설정에 적용한 후 Nginx를 재실행했습니다. 설정 전체 내용은 아래와 같습니다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;server {
        listen 80 default_server;
        listen [::]:80 default_server;

        root /var/www/html;

        index index.php index.html index.htm index.nginx-debian.html;

        server_name localhost admin.sites.wd;

        location / {
                try_files $uri $uri/ /index.php?$query_string;
        }

        location = /favicon.ico { access_log off; log_not_found off; }
        location = /robots.txt { access_log off; log_not_found off; }

        location ~ \.php$ {
                fastcgi_pass unix:/run/php/php8.2-fpm.sock;
                fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
                include fastcgi_params;
                fastcgi_hide_header X-Powered-By;
        }

        location ~ /\.(?!well-known).* {
                deny all;
        }
}

server {
        listen 80;
        listen [::]:80;

        listen 443;
        listen [::]:443;

        ssl_certificate /etc/ssl/private/dev.crt;
        ssl_certificate_key /etc/ssl/private/dev.key;

        index index.php index.html index.htm index.nginx-debian.html;

        server_name ~^(?&amp;lt;host_name&amp;gt;[\w\-]+)\.(?&amp;lt;organization&amp;gt;[\w\-]+)\.wd$;

        set $site /home/jhansol/sites/$organization/$host_name;
        root $site;

        location / {
                try_files $uri $uri/ /index.php?$query_string;
        }

        location = /favicon.ico { access_log off; log_not_found off; }
        location = /robots.txt { access_log off; log_not_found off; }

        location ~ \.php$ {
                fastcgi_pass unix:/run/php/php8.2-fpm.sock;
                fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
                include fastcgi_params;
                fastcgi_hide_header X-Powered-By;
        }

        if (!-e $request_filename) {
                rewrite ^/shop/list-([0-9a-z]+)$ /shop/list.php?ca_id=$1&amp;amp;rewrite=1 break;
                rewrite ^/shop/type-([0-9a-z]+)$ /shop/listtype.php?type=$1&amp;amp;rewrite=1 break;
                rewrite ^/shop/([0-9a-zA-Z_\-]+)$ /shop/item.php?it_id=$1&amp;amp;rewrite=1 break;
                rewrite ^/shop/([^/]+)/$ /shop/item.php?it_seo_title=$1&amp;amp;rewrite=1 break;
                rewrite ^/content/([0-9a-zA-Z_]+)$ /bbs/content.php?co_id=$1&amp;amp;rewrite=1 break;
                rewrite ^/content/([^/]+)/$ /bbs/content.php?co_seo_title=$1&amp;amp;rewrite=1 break;
                rewrite ^/rss/([0-9a-zA-Z_]+)$ /bbs/rss.php?bo_table=$1 break;
                rewrite ^/([0-9a-zA-Z_]+)$ /bbs/board.php?bo_table=$1&amp;amp;rewrite=1 break;
                rewrite ^/([0-9a-zA-Z_]+)/write$ /bbs/write.php?bo_table=$1&amp;amp;rewrite=1 break;
                rewrite ^/([0-9a-zA-Z_]+)/([^/]+)/$ /bbs/board.php?bo_table=$1&amp;amp;wr_seo_title=$2&amp;amp;rewrite=1 break;
                rewrite ^/([0-9a-zA-Z_]+)/([0-9]+)$ /bbs/board.php?bo_table=$1&amp;amp;wr_id=$2&amp;amp;rewrite=1 break;
        }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>프로그래밍/Python</category>
      <category>python</category>
      <category>서버</category>
      <category>인프라</category>
      <category>파이썬</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/201</guid>
      <comments>https://jhansol.tistory.com/201#entry201comment</comments>
      <pubDate>Fri, 21 Jun 2024 02:21:07 +0900</pubDate>
    </item>
    <item>
      <title>Nginx 기반 Docker 개발환경 만들기, 삽질기</title>
      <link>https://jhansol.tistory.com/200</link>
      <description>&lt;p&gt;이번에는 기존에 자가 사용하고 있는 도커 개발환경(Apache 기반)을 두고, 새롭게 Nginx 기반으로 개발환경을 구성해봤습니다. 새롭게 환경을 구성하게된 동기, 구성 과정, 구성후 느낌 정도를 포스팅하고자 합니다.&lt;/p&gt;
&lt;h1&gt;구성 동기&lt;/h1&gt;
&lt;p&gt;최근(아니 최근 1년)에는 Lravel Blade 기반의 프론트엔드부분을 개발할 일이 없어 젼혀 모르고 있다가, 사이드 프로젝트를 진행하려고 준비를 하는 과정에 CSS와 Blade 파일을 수정했는데 핫 리로드 기능이 동작하지 않는 것을 확인했습니다. 이 기능이 없었을 때는 어래 그려려니 했지만 막상 사용하다 않되니 너무 불편했습니다.&lt;br&gt;원래는 아래와 같이 CSS나 Blade 파일을 수정하면 수정을 감지하고 페이지를 리로드해줘야 합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;7:56:26 PM [vite] page reload resources/views/test.blade.php&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그리고 Docker 개발환경에서 웹사이트 응답속도가 느려도 너무나 느린 것입니다. 이참에 Apache 기반이 아닌 Nginx 기반 환경으로 바꿔보고 싶은 것도 하나의 동기입니다.&lt;/p&gt;
&lt;h1&gt;구성 과정&lt;/h1&gt;
&lt;h2&gt;AI의 도움을 받다.&lt;/h2&gt;
&lt;p&gt;서버 구성을 자주 하는 것도 아니고, 기억이 안나는 문제도 있었지만 Nginx의 경우 익숙하지 않은 것이라 ChatGPT에게 도움을 요청했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;nginx의 가상 호스트 설정에서 도매인 주소의 host name을 기빈으로 디렉토리를 지정하는 방법이 없을까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;ChatGPT는 맵을 이용한 멀티 도매인 제안을 해주었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http {
    map $host $root_path {
        hostnames;
        example.com   /var/www/example.com/public_html;
        example.org   /var/www/example.org/public_html;
        default       /var/www/default/public_html;
    }

    server {
        listen 80;
        server_name example.com www.example.com example.org www.example.org;

        root $root_path;

        location / {
            try_files $uri $uri/ =404;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;하지만 저의 경우에는 &amp;quot;&lt;a href=&quot;http://public.web.wd%22%EC%99%80&quot;&gt;http://public.web.wd&amp;quot;와&lt;/a&gt; 같이 주소가 주어졌을 때 &amp;quot;/DevHome/web/public&amp;quot; 형태로 도큐먼트 루타가 결정되는 형태로 되어야 하는데, 저와는 맞지 않았습니다. 그래서...&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;map을 이용하지 않고 일정한 규칙에 따라 디렉토리가 결정되면 좋을 것 같은데, 예를 들어 a.sites.wd의 경우 /dev/a/public_html, b.sites.wd의 경우 /dev/b/public_html 로 연결되었으면 해.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code&gt;server {
    listen 80;
    server_name ~^(?&amp;lt;subdomain&amp;gt;\w+)\.sites\.wd$;

    location / {
        root /dev;
        alias /dev/$subdomain/public_html;
        try_files $uri $uri/ =404;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 내용을 적용해보니 Nginx가 오류를 표시하고는 실행이 안되길래 다시 물었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;alias&amp;quot; directive is duplicate, &amp;quot;root&amp;quot; directive was specified earlier in /etc/nginx/sites-enabled/default:17&lt;br&gt;이 메시지에 대해 설명해줘&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code&gt;nginx 설정 파일에서 root 지시어와 alias 지시어는 서로 다르게 동작하며, 
같은 location 블록 내에서 동시에 사용할 수 없습니다. 이 오류 메시지는 
root와 alias를 같은 location 블록에 지정하려고 했기 때문에 발생합니다.

server {
    listen 80;
    server_name ~^(?&amp;lt;subdomain&amp;gt;\w+)\.sites\.wd$;

    location / {
        set $subdir /dev/$subdomain/public_html;
        root $subdir;
        try_files $uri $uri/ =404;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Docker 기반 환경 구성시 가장 불편한 점&lt;/h2&gt;
&lt;p&gt;제가 Docker 기반 환경을 구성하기 위해 수많은 삽질을 했습니다. 삽질 과정에서 가장 불편한 점이 위와 같은 구성 파일의 오류를 알수 없다는 것입니다. Docker용 linux 이미지의 경우 각종 서비스가 systemd를 기반으로 등작하지 않습니다. 결국 &lt;strong&gt;journalctl과 같은 기능을 사용할 수 없어 어떤 오류가 발생하여 서비스가 실행되지 않는지 알수 없게됩니다.&lt;/strong&gt;&lt;br&gt;하는 수 없이 VirtualBox에 Ubuntu를 설치하고, 개발환경 전반을 테스트하고 Docker 이미지에 적용하는 과정을 거쳐야 했습니다.&lt;/p&gt;
&lt;h2&gt;시행착오 끝에 만든 Nginx 환경설정 파일&lt;/h2&gt;
&lt;p&gt;위 과정을 거쳐 Nginx 설정 파일을 아래와 같이 만들었습니다. 이 글을 쓰기 전에 최종 테스트도 통과를 했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 80 default_server;
    listen [::]:80 default_server;

    index index.php index.html index.htm index.nginx-debian.html;

    server_name localhost admin.sites.wd;

    root /var/www/html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        include fastcgi.conf;
        fastcgi_hide_header X-Powered-By;
    }
}

server {
    listen 80;
    listen [::]:80;

    listen 443 ssl;
    listen [::]:443 ssl;

    ssl_certificate /etc/ssl/private/dev.crt;
    ssl_certificate_key /etc/ssl/private/dev.key;

    index index.php index.html index.htm index.nginx-debian.html;

    server_name ~^(?&amp;lt;host_name&amp;gt;[\w\-]+)\.(?&amp;lt;organization&amp;gt;[\w\-]+)\.wd$;

    set $site /DevHome/$organization/$host_name;
    root $site;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        include fastcgi.conf;
        fastcgi_hide_header X-Powered-By;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;DockerFile 군살빼기&lt;/h2&gt;
&lt;p&gt;기존에 구성했던 DockerFile은 레이어도 너무 많고, 불필요한 PHP 모듈도 많아 대폭 정리를 했습니다. DockerFile 내용은 아래와 같습니다. 그리고 저의 깃허브 레포지토리에도 해당 내용이 올라가 있습니다. 전체 파일이나 내용을 보시려면 레포지토리를 방문해서 확인해보세요.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM ubuntu:24.04
ENV DEBIAN_FRONTEND noninteractive
ENV TZ Asia/Seoul
ENV LC_ALL C.UTF-8

WORKDIR /DevHome

RUN mkdir -p /DevHome/sites/admin; ln -snf /usr/share/zoneinfo/$TZ /etc/localtime; echo $TZ &amp;gt; /etc/timezone

RUN apt-get update; \
    apt-get -y upgrade; \
    mkdir -p /etc/apt/keyrings; \
    apt-get install -y gnupg curl ca-certificates zip unzip git libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano nginx mysql-client; \
    curl -sS &amp;#39;https://keyserver.ubuntu.com/pks/lookup?op=get&amp;amp;search=0x14aa40ec0831756756d7f66c4f4ea0aae5267a6c&amp;#39; | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg &amp;gt; /dev/null; \
    echo &amp;quot;deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main&amp;quot; &amp;gt; /etc/apt/sources.list.d/ppa_ondrej_php.list; \
    apt-get update; \
    apt-get install -y php8.2-bcmath php8.2-cli php8.2-fpm php8.2-curl php8.2-dev php8.2-gd php8.2-intl php8.2-ldap php8.2-mbstring php8.2-mysql php8.2-opcache php8.2-readline \
    php8.2-soap php8.2-xml php8.2-xsl php8.2-zip php8.2-xdebug; \
    curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer; \
    curl https://deb.nodesource.com/setup_lts.x | bash -; \
    apt-get update; \
    apt-get install -y nodejs; \
    npm install -g pnpm; \
    npm install -g bun; \
    curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg &amp;gt;/dev/null; \
    echo &amp;quot;deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main&amp;quot; &amp;gt; /etc/apt/sources.list.d/yarn.list; \
    apt-get update; \
    apt-get install -y yarn; \
    apt-get -y autoremove; \
    apt-get clean; \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN sed -i &amp;#39;s/upload_max_filesize = 2M/upload_max_filesize = 200M/g&amp;#39; /etc/php/8.2/fpm/php.ini; \
    sed -i &amp;#39;s/memory_limit = 128M/memory_limit = 512M/g&amp;#39; /etc/php/8.2/fpm/php.ini; \
    sed -i &amp;#39;s/post_max_size = 8M/post_max_size = 250M/g&amp;#39; /etc/php/8.2/fpm/php.ini; \
    sed -i &amp;#39;s/short_open_tag = Off/short_open_tag = On/g&amp;#39; /etc/php/8.2/fpm/php.ini; \
    echo &amp;quot;xdebug.mode = develop,debug&amp;quot; &amp;gt;&amp;gt; /etc/php/8.2/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_host=host.docker.internal&amp;quot; &amp;gt;&amp;gt; /etc/php/8.2/mods-available/xdebug.ini; \
    echo &amp;quot;xdebug.client_port = 9000&amp;quot; &amp;gt;&amp;gt; /etc/php/8.2/mods-available/xdebug.ini

COPY index.php /var/www/html
COPY ssl_key/* /etc/ssl/private
COPY default /etc/nginx/sites-available
ADD myadmin /var/www/html/myadmin
COPY docker_entrypoint.sh /usr/bin/docker_entrypoint.sh

RUN chmod 0755 /usr/bin/docker_entrypoint.sh; \
    rm -r /var/lib/apt/lists /var/cache/apt/archives

VOLUME [ &amp;quot;/DevHome&amp;quot; ]

EXPOSE 80 443 5173 8080

ENTRYPOINT [ &amp;quot;docker_entrypoint.sh&amp;quot; ]

CMD [&amp;quot;tail&amp;quot;,&amp;quot;-f&amp;quot;,&amp;quot;/var/log/nginx/error.log&amp;quot;]&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;docker_entrypoint.sh&lt;/h2&gt;
&lt;p&gt;PHP fpm과 Nginx 기반으로 변경되다 보니 이 파일도 아래와 같이 변경했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash

service php8.2-fpm start
service nginx start

exec &amp;quot;$@&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;h1&gt;구성 후 느낀 점&lt;/h1&gt;
&lt;p&gt;개발환경을 구성하고 다시 테스트를 해봤는데, 응답속도는 기존과 동일한 것 같습니다. 속도의 문제는 Apache나 Nginx나 별로 차이가 없어 보입니다. 구글링을 해보니 WSL이 Windows의 파일 시스템에 접근할 때 속도가 느려진다는 이야기가 있습니다. 찾고 보니 Vite의 공식 문서에서도 언급이 되어 있네요.&lt;/p&gt;
&lt;h2&gt;Host bind volume과 docker volume의 차이&lt;/h2&gt;
&lt;p&gt;저의 개발환경에서 프로젝트 폴더는 Bind Volume으로 연결하고, 데이터베이스(Docker Volume)와 PHPMyAdmin과 관리용 PHP프로그램은 이미지 내부에 포함되어 있습니다. 지금에 와서 생각해보니 PHPMyAdmin은 응답속도가 그렇게 느리다는 생각을 못했습니다. 하지만 프로젝트 폴더의 웹사이트는 느려도 너무 느려 도저히 못 쓰겠다는 지경까지 왔습니다. 지금 생각해보면 이 두 방식의 차이로 생기는 속도차이로 보입니다. WSL 기반의 Docker 환경에서는 Bind Volume은 피하는 것이 좋겠습니다. 안된다면 WSL을 쓰지 않는 것도 고려를 해봐야 할 것 같습니다.&lt;/p&gt;
&lt;h2&gt;Vite Hot Reload가 동작하지 않는 문제&lt;/h2&gt;
&lt;p&gt;이 글을 쓰기 전에 막 Ubuntu를 노트북에 설치하고, 개발환경 구성을 마쳤습니다. 노트북의 개발환경에서는 Hot Reload 기능이 너무나 잘 동작합니다. 물론 속도도 빠르구요. 이건 운영체제의 문제나 Docker의 문제로 어렴풋이 생각은 하고 있었습니다만 구체적으로 검증을 하지 목한 터라 결론을 못내리고 있었습니다. 마지막으로 구글링을 해보니 WSL의 파일시스템 문제로 파일의 변경 감지가 않된다는 말도 있고, Vite 공식 문저에도 아래와 같은 내용이 있었네요. 이걸 알았으면 이번 삽질도 하지 않았을텐데 ㅠㅠ&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Using Vite on Windows Subsystem for Linux (WSL) 2

When running Vite on WSL2, file system watching does not work when a file is edited by Windows applications (non-WSL2 process). This is due to a WSL2 limitation. This also applies to running on Docker with a WSL2 backend.

To fix it, you could either:

Recommended: Use WSL2 applications to edit your files.  
It is also recommended to move the project folder outside of a Windows filesystem. Accessing Windows filesystem from WSL2 is slow.  
Removing that overhead will improve performance.  
Set { usePolling: true }.  
Note that usePolling leads to high CPU utilization.&lt;/code&gt;&lt;/pre&gt;&lt;h1&gt;마치며&lt;/h1&gt;
&lt;p&gt;Docker 개발환경에서 WSL을 사용하지 않는것을 심각하게 고민하고 있습니다.&lt;/p&gt;</description>
      <category>프로그래밍/PHP</category>
      <category>docker</category>
      <category>Laravel</category>
      <category>php</category>
      <category>Server</category>
      <category>개발환경</category>
      <category>서버</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/200</guid>
      <comments>https://jhansol.tistory.com/200#entry200comment</comments>
      <pubDate>Tue, 18 Jun 2024 21:07:56 +0900</pubDate>
    </item>
    <item>
      <title>Laravel 용 패키지 만들기, 삽질기</title>
      <link>https://jhansol.tistory.com/199</link>
      <description>&lt;p&gt;정말 오랜만에 포스팅을 하는것 같습니다. 이번 포스트는 제가 구상중인 사이트 프로젝트의 일부인 다국어 지원 서비스를 구축하기 위해 언어 검출 및 서비스의 인터페이스와 콘텐츠 언어 출력을 설정하는 역할을 하는 설치 가능한 패키지 개발과정에 관련된 것입니다.&lt;br&gt;이 포스트의 코드는 Github에 공개할 예정입니다.&lt;/p&gt;
&lt;p&gt;지금까지 10여년을 PHP로 개발을 해왔지만 Composer를 이용한 설치용 패키지를 만들어보기는 처음입니다. 몇번에 걸쳐 시행착오도 격고, 구글링, Copilot, ChatGPT에 질문하고 해서 겨우 제가 원하는 단계까지 왔습니다. 이 과정의 기억을 박제하고자 합니다.&lt;/p&gt;
&lt;h1&gt;composeer.json 생성&lt;/h1&gt;
&lt;p&gt;우선 아래와 같이 &lt;code&gt;laravel-language-detect&lt;/code&gt; 폴더를 만들고, 폴더로 이동하여 &amp;#39;composer init&amp;#39; 명령을 실행했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir laravel-language-detect
cd laravel-language-detect
composer init&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그러면 아래와 같이 패키지와 관련된 정보를 입력하는 프롬프트가 출력됩니다. 아래와 같이 입력했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Package name (&amp;lt;vendor&amp;gt;/&amp;lt;name&amp;gt;) [root/laravel-language-detect]: j-hansol/laravel-language-detect
Author [n to skip]: SungHyun Jang
Minimum Stability []: dev
Package Type (e.g. library, project, metapackage, composer-plugin) []: library
License []: MIT

Define your dependencies.

Would you like to define your dependencies (require) interactively [yes]? no
Would you like to define your dev dependencies (require-dev) interactively [yes]? no
Add PSR-4 autoload mapping? Maps namespace &amp;quot;JHansol\LaravelLanguageDetect&amp;quot; to the entered relative path. [src/, n to skip]:

{
    &amp;quot;name&amp;quot;: &amp;quot;j-hansol/laravel-language-detect&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;This is a package for language detection and configuration for Laravel.&amp;quot;,
    &amp;quot;type&amp;quot;: &amp;quot;library&amp;quot;,
    &amp;quot;license&amp;quot;: &amp;quot;MIT&amp;quot;,
    &amp;quot;autoload&amp;quot;: {
        &amp;quot;psr-4&amp;quot;: {
            &amp;quot;JHansol\\LaravelLanguageDetect\\&amp;quot;: &amp;quot;src/&amp;quot;
        }
    },
    &amp;quot;authors&amp;quot;: [
        {
            &amp;quot;name&amp;quot;: &amp;quot;SungHyun Jang&amp;quot;
        }
    ],
    &amp;quot;minimum-stability&amp;quot;: &amp;quot;dev&amp;quot;,
    &amp;quot;require&amp;quot;: {}
}

Do you confirm generation [yes]? yes&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위와 같이 &lt;code&gt;composer.json&lt;/code&gt; 파일에 몇가지를 더 추가하거나 수정했습니다.&lt;/p&gt;
&lt;h2&gt;테스터 케이스 네임스페이스 설정&lt;/h2&gt;
&lt;p&gt;지금은 사용하고 있지 않지만 다음에 테스트까지 고려하기 위해 미리 추가해두었습니다. 이 내용은 &lt;code&gt;autoload&lt;/code&gt; 아래에 추가했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;quot;autoload-dev&amp;quot;: {
        &amp;quot;psr-4&amp;quot;: {
            &amp;quot;JHansol\\LaravelLanguageDetect\\Tests\\&amp;quot;: &amp;quot;tests/&amp;quot;
        }
    },&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;authors 수정&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;authors&lt;/code&gt; 부분에 이름만 들어 있는데, 여기에 역할과, 이메일 주소를 추가했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;quot;authors&amp;quot;: [
        {
            &amp;quot;role&amp;quot;: &amp;quot;Developer&amp;quot;,
            &amp;quot;name&amp;quot;: &amp;quot;SungHyun Jang&amp;quot;,
            &amp;quot;email&amp;quot;: &amp;quot;p....2@gmail.com&amp;quot;
        }
    ],&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Composer 관련 설정 추가&lt;/h2&gt;
&lt;p&gt;아래와 같이 컴포저가 패키지관리를 위한 설정을 추가했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;quot;minimum-stability&amp;quot;: &amp;quot;dev&amp;quot;,
    &amp;quot;prefer-stable&amp;quot;: true,
    &amp;quot;config&amp;quot;: {
        &amp;quot;sort-packages&amp;quot;: true,
        &amp;quot;preferred-install&amp;quot;: &amp;quot;dist&amp;quot;,
        &amp;quot;optimize-autoloader&amp;quot;: true,
        &amp;quot;allow-plugins&amp;quot;: {
            &amp;quot;kylekatarnls/update-helper&amp;quot;: true
        }
    },&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;required 항목 수정&lt;/h2&gt;
&lt;p&gt;이 내용은 생성과정에서 패키지를 검색하는 부분이 있으나 제가 아직 사용법을 잘 몰라 아래와 같이 수정했습니다. 이본적으로 이 패키지는 &lt;code&gt;PHP 8.1&lt;/code&gt;과 라라벨 프레임워크인 &lt;code&gt;laravel/framework&lt;/code&gt; 11.0... 버전에 의존하는 것을 추가해주었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;quot;require&amp;quot;: {
        &amp;quot;php&amp;quot;: &amp;quot;^8.2&amp;quot;,
        &amp;quot;laravel/framework&amp;quot;: &amp;quot;^11.0&amp;quot;
    },&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;라라벨용 패키지 정보 추가&lt;/h2&gt;
&lt;p&gt;라라벨은 최처 설치 후, 패키지 설치 후, Autoload 파일 갱신 후에 라라벨용 패키지를 검출하는 스크립트가 실행됩니다. 아래 내용은 이 때 필요한 것으로 서비스 프로바이더와 파사드 등의 클래스명을 등록해두면 서비스 실행 시에 자동으로 로드될 수 있도록 합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;quot;extra&amp;quot;: {
        &amp;quot;laravel&amp;quot;: {
            &amp;quot;providers&amp;quot;: [
                &amp;quot;JHansol\\LaravelLanguageDetect\\LanguageDetectServiceProvider&amp;quot;
            ]
        }
    }&lt;/code&gt;&lt;/pre&gt;&lt;h1&gt;패키지 코딩&lt;/h1&gt;
&lt;p&gt;```src``` 폴더에 언어를 검출하고 설정하는 클래스와 클래스를 컨테이너에 등록하는 서비스 프로바이더를 만들어 두었습니다.&lt;/p&gt;
&lt;h2&gt;LanguageDetect.php&lt;/h2&gt;
&lt;p&gt;이 코드는 추후 수정될 것입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php

namespace JHansol\LaravelLanguageDetect;

use Illuminate\Foundation\Application;
use Illuminate\Http\Request;

class LanguageDetect {
    private Application $application;
    private Request $request;

    function __construct(Application $application) {
        $this-&amp;gt;application = $application;
    }

    public function setRightLocale() : void  {
        $this-&amp;gt;request = $this-&amp;gt;application-&amp;gt;make(&amp;#39;request&amp;#39;);

        $able_locales = config(&amp;#39;language-detect.locales&amp;#39;);
        $default_locale = config(&amp;#39;language-detect.default_locale&amp;#39;);
        $locale_segment = config(&amp;#39;language-detect.locale_segment&amp;#39;);

        $temp_locale = $this-&amp;gt;request-&amp;gt;segment($locale_segment);
        $target_locale = null;

        if($temp_locale &amp;amp;&amp;amp; in_array($temp_locale, $able_locales)) $target_locale = $temp_locale;
        if(!$target_locale) $target_locale = $default_locale;

        $this-&amp;gt;application-&amp;gt;setLocale($target_locale);
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;LanguageDetectServiceProvider.php&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php
namespace JHansol\LaravelLanguageDetect;

use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;

class LanguageDetectServiceProvider extends ServiceProvider {
    public function register(): void {
        $this-&amp;gt;app-&amp;gt;singleton(&amp;#39;language_detect&amp;#39;, function(Application $application) {
            return new LanguageDetect($application);
        });

        $config = __DIR__.&amp;#39;/config/language_detect.php&amp;#39;;
        $this-&amp;gt;mergeConfigFrom(
            $config, &amp;#39;language-detect&amp;#39;
        );
    }

    public function boot(): void {
        $config = __DIR__.&amp;#39;/config/language_detect.php&amp;#39;;
        $this-&amp;gt;publishes(
            [$config =&amp;gt; config_path(&amp;#39;language_detect.php&amp;#39;)],
            [&amp;#39;language-detect&amp;#39;, &amp;#39;language-detect:config&amp;#39;]
        );

        try {
            $language_detect = $this-&amp;gt;app-&amp;gt;make(&amp;#39;language_detect&amp;#39;);
            $language_detect-&amp;gt;setRightLocale();
        } catch (BindingResolutionException $e) {}
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;기타 환경과 관련 파일도 있으나 여기서는 생략하도록 하겠습니다. 이렇게 하여 필요한 구성이 완료되었습니다.&lt;br&gt;위 코드를 Github 저장소에 푸시했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;삽질 부분에서 또 언급하겠지만 composer에 의해 패키지가 원할하게 관리되려면 버전을 표시하는 태그를 추가해야 합니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h1&gt;라라벨 프로젝트에 적용 삽질&lt;/h1&gt;
&lt;p&gt;패키지는 무리없이 만들었는데, 여기서 애를 먹었습니다. 패키지가 설치되기는 했어나 라라벨 패키지로 인식하는 부분에서 많은 시행착오가 있었습니다. 여기서는 그 과정을 모두 적을 수는 없고, 주된 원인만 적고 넘어가겠습니다.&lt;/p&gt;
&lt;h2&gt;Custom 패키지 저장소 설정 삽질&lt;/h2&gt;
&lt;p&gt;공식 패키지 저장소에 등록된 것이 아니라면 반드시 &amp;#39;repositories&amp;#39; 항목에 해당 정보가 지정되어야 합니다. 저장소 등록을 구글링하여 아래와 같이 등록을 했었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;quot;repositories&amp;quot;: [
        {
            &amp;quot;type&amp;quot;:&amp;quot;package&amp;quot;,
            &amp;quot;package&amp;quot;: {
                &amp;quot;name&amp;quot;: &amp;quot;j-hansol/laravel-language-detect&amp;quot;,
                &amp;quot;version&amp;quot;:&amp;quot;1.0.0&amp;quot;,
                &amp;quot;source&amp;quot;: {
                    &amp;quot;url&amp;quot;: &amp;quot;https://github.com/j-hansol/language-detect.git&amp;quot;,
                    &amp;quot;type&amp;quot;: &amp;quot;git&amp;quot;,
                    &amp;quot;reference&amp;quot;:&amp;quot;main&amp;quot;
                }
            }
        }
    ],&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위와 같이 하고 아래와 같이 composer를 이용하여 설치를 했습니다. 성공은 했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;composer require j-hansol/laravel-language-detect&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;하지만 라라벨 패키지로 인식이 않었습니다. 결국 라라벨의 &lt;code&gt;php artisan package:discover --ansi&lt;/code&gt; 콘솔 커멘드를 디버깅을 통해 저의 패키지에 어떤 문제가 있는지 체크를 해봤습니다. 이 명령은 아래 코드에 의해 실행됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php

namespace Illuminate\Foundation\Console;

use Illuminate\Console\Command;
use Illuminate\Foundation\PackageManifest;
use Symfony\Component\Console\Attribute\AsCommand;

#[AsCommand(name: &amp;#39;package:discover&amp;#39;)]
class PackageDiscoverCommand extends Command
{
    /**
     * The console command signature.
     *
     * @var string
     */
    protected $signature = &amp;#39;package:discover&amp;#39;;

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = &amp;#39;Rebuild the cached package manifest&amp;#39;;

    /**
     * Execute the console command.
     *
     * @param  \Illuminate\Foundation\PackageManifest  $manifest
     * @return void
     */
    public function handle(PackageManifest $manifest)
    {
        $this-&amp;gt;components-&amp;gt;info(&amp;#39;Discovering packages&amp;#39;);

        $manifest-&amp;gt;build();

        collect($manifest-&amp;gt;manifest)
            -&amp;gt;keys()
            -&amp;gt;each(fn ($description) =&amp;gt; $this-&amp;gt;components-&amp;gt;task($description))
            -&amp;gt;whenNotEmpty(fn () =&amp;gt; $this-&amp;gt;newLine());
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그 중에서도 패키지를 검출하는 것은 이 코드입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$manifest-&amp;gt;build();&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;메소드는&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    public function build()
    {
        $packages = [];

        if ($this-&amp;gt;files-&amp;gt;exists($path = $this-&amp;gt;vendorPath.&amp;#39;/composer/installed.json&amp;#39;)) {
            $installed = json_decode($this-&amp;gt;files-&amp;gt;get($path), true);

            $packages = $installed[&amp;#39;packages&amp;#39;] ?? $installed;
        }

        $ignoreAll = in_array(&amp;#39;*&amp;#39;, $ignore = $this-&amp;gt;packagesToIgnore());

        $this-&amp;gt;write(collect($packages)-&amp;gt;mapWithKeys(function ($package) {
            return [$this-&amp;gt;format($package[&amp;#39;name&amp;#39;]) =&amp;gt; $package[&amp;#39;extra&amp;#39;][&amp;#39;laravel&amp;#39;] ?? []];
        })-&amp;gt;each(function ($configuration) use (&amp;amp;$ignore) {
            $ignore = array_merge($ignore, $configuration[&amp;#39;dont-discover&amp;#39;] ?? []);
        })-&amp;gt;reject(function ($configuration, $package) use ($ignore, $ignoreAll) {
            return $ignoreAll || in_array($package, $ignore);
        })-&amp;gt;filter()-&amp;gt;all());
    }&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;외 코드에서 &lt;code&gt;write()&lt;/code&gt; 메소드 안의 코드가 패키지를 검출(필터링)하는 코드인데, 저의 패키지가 이 과정에서 재외되는 것을 확인했습니다. 그리고 패키지 설치와 관련된 정보를 &lt;code&gt;composer/installed.json&amp;#39;&lt;/code&gt; 파일의 내용을 바탕으로 하는 것을 알고, 해당 파일을 열어보니 라라벨 패키지와 관련된 내용은 없고, reposotories 항목에 기록된 내용이 저장되어 있었습니다. 전혀 엉뚱한 내용이 저장되어 있으니 인식이 안된 것이었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        {
            &amp;quot;name&amp;quot;: &amp;quot;j-hansol/laravel-language-detect&amp;quot;,
            &amp;quot;version&amp;quot;: &amp;quot;1.0.0&amp;quot;,
            &amp;quot;version_normalized&amp;quot;: &amp;quot;1.0.0.0&amp;quot;,
            &amp;quot;source&amp;quot;: {
                &amp;quot;type&amp;quot;: &amp;quot;git&amp;quot;,
                &amp;quot;url&amp;quot;: &amp;quot;https://github.com/j-hansol/language-detect.git&amp;quot;,
                &amp;quot;reference&amp;quot;: &amp;quot;main&amp;quot;
            },
            &amp;quot;type&amp;quot;: &amp;quot;library&amp;quot;,
            &amp;quot;installation-source&amp;quot;: &amp;quot;source&amp;quot;,
            &amp;quot;install-path&amp;quot;: &amp;quot;../j-hansol/laravel-language-detect&amp;quot;
        },&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;삽질 끝에 패키지 저장소 수정&lt;/h2&gt;
&lt;p&gt;결국 저장소정보 설정이 잘 못되었다는 것인데, 구글링, Copilot, ChatGPT에게 질문했습니다. 앞에 것들은 전혀 도움이 안되었고, 마지막 ChatGPT가 매우 큰 도움이 되었습니다. ChatGPT의 조력에 따라&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;quot;repositories&amp;quot;: [
        {
            &amp;quot;url&amp;quot;: &amp;quot;https://github.com/j-hansol/language-detect.git&amp;quot;,
            &amp;quot;type&amp;quot;: &amp;quot;vcs&amp;quot;,
            &amp;quot;reference&amp;quot;:&amp;quot;main&amp;quot;
        }
    ],&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;패키지 저장소 버전 태그 추가&lt;/h2&gt;
&lt;p&gt;패키기 저장소에 버전을 나타내는 태그가 없으면 위와 같이 수정한 수 설치를 시도하면 아래와 같은 오류가 출력됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Could not find a version of package j-hansol/laravel-language-detect matching your minimum-stability (stable). Require it with an explicit version constraint allowing its desired stability. &lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그래서 아래와 같이 태그를 추가해서 푸시했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git tag 1.0.0
git push origin 1.0.0&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;설치 성공, 패키지 인식&lt;/h2&gt;
&lt;p&gt;드디어 아래의 명령으로 설치되고, 패키지도 정상적으로 인식되었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;composer require j-hansol/laravel-language-detect

..... 이전 생략 .....

&amp;gt; @php artisan package:discover --ansi

   INFO  Discovering packages.  

  j-hansol/laravel-language-detect ......................................................................................... DONE
  laravel/breeze ........................................................................................................... DONE
  laravel/sail ............................................................................................................. DONE
  laravel/tinker ........................................................................................................... DONE
  livewire/livewire ........................................................................................................ DONE
  livewire/volt ............................................................................................................ DONE
  nesbot/carbon ............................................................................................................ DONE
  nunomaduro/collision ..................................................................................................... DONE
  nunomaduro/termwind ...................................................................................................... DONE
  spatie/laravel-ignition .................................................................................................. DONE&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;패키지와 관련된 파일을 퍼블리싱하는 명령에서 시비스 프로바이더가 검색됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;php artisan vendor:publish

 ┌ Which provider or tag&amp;#39;s files would you like to publish? ────────┐
 │ jH                                                                        │
 ├────────────────────────────────────┤
 │   Provider: JHansol\LaravelLanguageDetect\LanguageDetectServiceProvider   │
  ─────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;태그도 잘 검색됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;php artisan vendor:publish

┌ Which provider or tag&amp;#39;s files would you like to publish? ────────┐  
│ language │  
├ ────────────────────────────────────┤  
│ Provider: JHansol\\LaravelLanguageDetect\\LanguageDetectServiceProvider │  
│ Tag: language-detect │  
│ Tag: language-detect:config │  
└───────────────── ───────────────────┘  &lt;/code&gt;&lt;/pre&gt;</description>
      <category>프로그래밍/PHP</category>
      <category>Laravel</category>
      <category>Package</category>
      <category>php</category>
      <category>라라벨</category>
      <category>패키지</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/199</guid>
      <comments>https://jhansol.tistory.com/199#entry199comment</comments>
      <pubDate>Sun, 2 Jun 2024 04:11:37 +0900</pubDate>
    </item>
    <item>
      <title>삽질의 연속</title>
      <link>https://jhansol.tistory.com/198</link>
      <description>&lt;p&gt;안녕하세요. 오늘도 유지보수관련하여 글을 남길까 합니다.&lt;br&gt;몇일 전 고객사로부터 콘텐츠는 존재하는데 검색이 않된다고 확인을 요청하는 메일이 대표님을 통해 들어왔습니다. 저도 약간의 문제가 있다는 것은 알고 있었으나 여건상 해결상 시간을 투자할 수 없었습니다.&lt;/p&gt;
&lt;h2&gt;기본 사양&lt;/h2&gt;
&lt;p&gt;유지보수 중인 사이트는 PHP 5.6 기반에 Drupal 7을 이용하여 개발된 것입니다. 그리고 이 사이트는 약 120여개의 기여모듈과, 자체 제작 모듈 약 20여개로 구성되어 있습니다. 이 문제와 연관된 모듈은 Entity Translation, Search Api, Search Api Entity Translation 등 3개 입니다.&lt;/p&gt;
&lt;h2&gt;문제의 현상&lt;/h2&gt;
&lt;p&gt;관리자가 특정 언어로 컨텐츠를 등록하면 색인 대상 정보에 해당 콘텐츠 정보가 추가되지만 콘텐츠의 언어를 변경하거나 다른 언어로 번역할 경우 검색엔진에 색인을 요청하지만 색인 대상 정보에 반영되지 않는 현상이 발생합니다. 이로 인해 기존 색인을 삭제하고 콘텐츠 전체를 다시 색인하는 경우 잘 못된 정보로 색인을 하거나 누락되는 경우가 발생했습니다.&lt;br&gt;그리고 콘텐츠가 삭제되면 색인 대상 정보에서 해당 콘텐츠 정보가 삭제되어야 하는데 삭제되지 않고 남아 있는 경우가 있었습니다.&lt;br&gt;위 두 문제로 정상적으로 색인이 되지 않아 콘텐츠 검색이 않되는 경우가 발생합니다.&lt;/p&gt;
&lt;h2&gt;Drupal Hook&lt;/h2&gt;
&lt;p&gt;Drupal은 Hook 함수를 이용하여 동작한다고 해도 과언이 아닙니다. 검색을 위한 색인 역시 Hook 함수를 통해 실행됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/**
 * Implements hook_entity_translation_insert().
 */
function search_api_et_entity_translation_insert($entity_type, $entity, $translation, $values = array()) {
  list($entity_id) = entity_extract_ids($entity_type, $entity);
  $item_id = SearchApiEtHelper::buildItemId($entity_id, $translation[&amp;#39;language&amp;#39;]);

  search_api_track_item_insert(SearchApiEtHelper::getItemType($entity_type), array($item_id));
}

/**
 * Implements hook_entity_translation_update().
 */
function search_api_et_entity_translation_update($entity_type, $entity, $translation, $values = array()) {
  list($entity_id) = entity_extract_ids($entity_type, $entity);
  $item_id = SearchApiEtHelper::buildItemId($entity_id, $translation[&amp;#39;language&amp;#39;]);
  search_api_track_item_change(SearchApiEtHelper::getItemType($entity_type), array($item_id));
}

/**
 * Implements hook_entity_translation_delete().
 */
function search_api_et_entity_translation_delete($entity_type, $entity, $langcode) {
  list($entity_id) = entity_extract_ids($entity_type, $entity);
  $item_id = SearchApiEtHelper::buildItemId($entity_id, $langcode);
  search_api_track_item_delete(SearchApiEtHelper::getItemType($entity_type), array($item_id));
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 3개의 함수는 콘텐츠가 추가, 수정, 삭제 시 호출되는 Hook 함수입니다. 문제의 현상이 발생하지 않으려면 위 3개의 함수가 호출되어 색인 대상 정보에 해당 콘텐츠 정보가 반영이되어야 하지만 반영되지 않는 경우가 다수 발생합니다.&lt;/p&gt;
&lt;h2&gt;해결을 위한 고민&lt;/h2&gt;
&lt;p&gt;기본 사양에서 언급한 3개의 모듈은 크고 복잡한 모듈입니다. 시간이 허락할 때마다 조금씩 코드를 살펴보고, 디버깅도 하고 코드를 분석해보고는 있지만 분석해야 하는 코드의 양도 만만치 않고, 각종 Hook 함수에 의해 얽혀 있는 탓에 완전 해결이 매우 힘듭니다. 모듈 개발자분의 생각을 이해한다면 문제의 원인을 원천 파악하고 수정하겠지만 현재로서는 부분적인 수정으로 땜질식 처방만 할 수 있습니다.&lt;/p&gt;
&lt;h2&gt;부분적 수정의 두려움&lt;/h2&gt;
&lt;p&gt;현재 Drupal 7의 코어를 비롯하여 모듈의 공식 유지보수 기간이 지난 관계로 더 이상의 오류 패치는 기대할 수 없어 어쩔 수 없이 수정은 하고 있지만 이 수정으로 사이트 전체 데이터의 무결성 훼손이나 성능에 문제를 이르키지는 않을까 하는 두려움을 가지고 있습니다. 그래서 이번 문제는 기존 모듈을 그대로 두고 자체 재작한 모듈에 기능을 추가하여 해결하기로 했습니다.&lt;/p&gt;
&lt;h2&gt;문제의 현상 해결 방법&lt;/h2&gt;
&lt;p&gt;문제를 해결하기 위해 아래와 같은 작업을 수행하는 콘솔 명령을 작했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;검색 대상 정보에서 삭제된 콘텐츠 정보를 제거하고 검색엔진에 해당 콘텐츠를 삭제하도록 요청한다.&lt;/li&gt;
&lt;li&gt;색인 대상 콘텐츠의 원본과 번역본을 수집하고 검색 대상 정보의 내용과 비교하여 삭제해야 하는 색인, 새롭게 색인해야 하는 콘텐츠를 구분하여 색인 삭제, 색인 대상 정보에서 항목 삭제, 새롭게 색인해야 하는 항목을 추가한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;아래 코드는 위 작업을 수행하는 코드입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function xxxxx_util_get_sapi_refresh() {
  $page_per_item = 100;
  $page = 0;
  $delete_target_count = 0;
  $add_target_count = 0;
  $no_exists_content_count = 0;
  $sapi_base_url = _get_sapi_server_base_path();
  $proc_count = 0;

  print &amp;quot;검색엔진 URL : {$sapi_base_url}\n&amp;quot;;

  do{
    $result = db_select(&amp;#39;search_api_et_item&amp;#39;, &amp;#39;t&amp;#39;)
      -&amp;gt;fields(&amp;#39;t&amp;#39;)
      -&amp;gt;orderBy(&amp;#39;item_id&amp;#39;, &amp;#39;asc&amp;#39;)
      -&amp;gt;condition(&amp;#39;index_id&amp;#39;, 6)
      -&amp;gt;range($page * $page_per_item, $page_per_item)
      -&amp;gt;execute()-&amp;gt;fetchAllAssoc(&amp;#39;item_id&amp;#39;);
    $item_count = count($result);
    foreach ($result as $item) {
      list($id, $language) = explode(&amp;#39;/&amp;#39;, $item-&amp;gt;item_id);
      if(!_is_exists_node($id)) {
        $no_exists_content_count++;
        _delete_sapi_document($sapi_base_url, array($item-&amp;gt;item_id));
        _delete_sapi_et_item(array($item-&amp;gt;item_id));
      }
      $proc_count++;
      print &amp;quot;콘텐츠 점검 : {$proc_count}\r&amp;quot;;
    }
    $page++;
  } while ($item_count == $page_per_item);
  print PHP_EOL;

  $page = 0;
  $proc_count = 0;
  do{
    $result = db_select(&amp;#39;node&amp;#39;, &amp;#39;t&amp;#39;)
      -&amp;gt;fields(&amp;#39;t&amp;#39;)
      -&amp;gt;orderBy(&amp;#39;nid&amp;#39;, &amp;#39;asc&amp;#39;)
      -&amp;gt;condition(&amp;#39;type&amp;#39;, &amp;#39;resources&amp;#39;)
      -&amp;gt;range($page * $page_per_item, $page_per_item)
      -&amp;gt;execute()-&amp;gt;fetchAllAssoc(&amp;#39;nid&amp;#39;);
    $item_count = count($result);
    $nids = _get_nids($result);
    $nodes = node_load_multiple($nids);
    foreach($nodes as $node) {
      if(isset($node-&amp;gt;translations-&amp;gt;data)) {
        $languages = array_keys($node-&amp;gt;translations-&amp;gt;data);
        $target_item_ids = _get_item_ids($node, $languages);
        $current_item_ids = _get_sapi_et_items($node-&amp;gt;nid);
        $delete_targets = array_diff($current_item_ids, $target_item_ids);
        $add_targets = array_diff($target_item_ids, $current_item_ids);
        if(!empty($delete_targets)) {
          $solr_result = _delete_sapi_document($sapi_base_url, $delete_targets);
          _delete_sapi_et_item($delete_targets);;
          $delete_target_count += count($delete_targets);
        }
        if(!empty($add_targets)) {
          _add_sapi_et_item($add_targets);
          $add_target_count += count($add_targets);
        }
      }
      $proc_count++;
      print &amp;quot;색인 대상 자료 새로 고침 : {$proc_count}\r&amp;quot;;
    }
    $page++;
  } while ($item_count == $page_per_item);

  print &amp;quot;\n존재하지 않는 콘텐츠 : {$no_exists_content_count}&amp;quot;;
  print &amp;quot;\n삭제 대상 항목 : {$delete_target_count},  추가대상 항목 : {$add_target_count}\n&amp;quot;;
}

function _get_nids($nodes) {
  return array_map(function($node) {
    return $node-&amp;gt;nid;
  }, $nodes);
}

function _get_item_ids($node, $languages) {
  $target_item_id = array();
  foreach($languages as $language) $target_item_id[] = $node-&amp;gt;nid . &amp;#39;/&amp;#39; . $language;
  return $target_item_id;
}

function _get_sapi_et_items($nid) {
  $result = db_select(&amp;#39;search_api_et_item&amp;#39;, &amp;#39;t&amp;#39;)
    -&amp;gt;fields(&amp;#39;t&amp;#39;)
    -&amp;gt;condition(&amp;#39;index_id&amp;#39;, 6)
    -&amp;gt;condition(&amp;#39;item_id&amp;#39;, &amp;quot;${nid}/%&amp;quot;, &amp;#39;like&amp;#39;)
    -&amp;gt;execute()-&amp;gt;fetchAllAssoc(&amp;#39;item_id&amp;#39;);
  return array_map(function($item) {
    return $item-&amp;gt;item_id;
  }, $result);
}

function _get_sapi_server_base_path() {
  $server = db_select(&amp;#39;search_api_server&amp;#39;, &amp;#39;t&amp;#39;)
    -&amp;gt;fields(&amp;#39;t&amp;#39;)
    -&amp;gt;condition(&amp;#39;machine_name&amp;#39;, &amp;#39;inner_solr&amp;#39;)
    -&amp;gt;execute()-&amp;gt;fetchAssoc();
  if($server) {
    $options = unserialize($server[&amp;#39;options&amp;#39;]);
    $schema = $options[&amp;#39;scheme&amp;#39;];
    $host = $options[&amp;#39;host&amp;#39;];
    $port = $options[&amp;#39;port&amp;#39;];
    $path = $options[&amp;#39;path&amp;#39;];
    return &amp;quot;{$schema}://{$host}:{$port}{$path}&amp;quot;;
  }
  else return null;
}

function _add_sapi_et_item($item_ids) {
  $fields = array(&amp;#39;item_id&amp;#39;, &amp;#39;index_id&amp;#39;, &amp;#39;changed&amp;#39;);
    $now = time();

  $query = db_insert(&amp;#39;search_api_et_item&amp;#39;)
    -&amp;gt;fields($fields);
  foreach ($item_ids as $id) $query-&amp;gt;values(array(&amp;#39;item_id&amp;#39; =&amp;gt; $id, &amp;#39;index_id&amp;#39; =&amp;gt; 6, &amp;#39;changed&amp;#39; =&amp;gt; $now));
  $query-&amp;gt;execute();
}

function _delete_sapi_et_item($item_ids) {
  db_delete(&amp;#39;search_api_et_item&amp;#39;)
    -&amp;gt;condition(&amp;#39;index_id&amp;#39;, 6)
    -&amp;gt;condition(&amp;#39;item_id&amp;#39;, $item_ids, &amp;#39;in&amp;#39;)
    -&amp;gt;execute();
}

function _delete_sapi_document($base_url, $item_ids, $display_response = false) {
  $id_string = &amp;#39;&amp;#39;;
  foreach($item_ids as $id) {
    $id_string .= &amp;quot;&amp;lt;query&amp;gt;item_id:{$id}&amp;lt;/query&amp;gt;&amp;quot;;
  }

  $options = array(
    &amp;#39;method&amp;#39; =&amp;gt; &amp;#39;POST&amp;#39;,
    &amp;#39;data&amp;#39; =&amp;gt; &amp;quot;&amp;lt;add commitWithin=\&amp;quot;1000\&amp;quot; overwrite=\&amp;quot;true\&amp;quot;&amp;gt;&amp;lt;delete&amp;gt;{$id_string}&amp;lt;/delete&amp;gt;&amp;lt;/add&amp;gt;&amp;quot;,
    &amp;#39;headers&amp;#39; =&amp;gt; array(
      &amp;#39;Content-Type&amp;#39; =&amp;gt; &amp;#39;text/xml&amp;#39;
    )
  );
  $response = drupal_http_request(&amp;quot;{$base_url}/update?wt=json&amp;quot;, $options);
  return $response-&amp;gt;code == 200;
}

function _is_exists_node($id) {
  $result = db_select(&amp;#39;node&amp;#39;, &amp;#39;t&amp;#39;)
    -&amp;gt;fields(&amp;#39;t&amp;#39;)
    -&amp;gt;condition(&amp;#39;nid&amp;#39;, $id)
    -&amp;gt;execute()-&amp;gt;fetchAllAssoc(&amp;#39;nid&amp;#39;);
  return count($result) &amp;gt; 0;
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 코드를 기존 모듈에 추가하고 해당 모듈의 Drush 콘솔 명령어 정보 부분에 아래의 코드를 추가합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  $items[&amp;#39;sapi_refresh&amp;#39;] = array(
    &amp;#39;description&amp;#39; =&amp;gt; t(&amp;#39;검색 색인 자료 정보를 새로고침한다.&amp;#39;),
    &amp;#39;callback&amp;#39; =&amp;gt; &amp;#39;xxxx_util_get_sapi_refresh&amp;#39;,
    &amp;#39;aliases&amp;#39; =&amp;gt; array(&amp;#39;sr&amp;#39;)
  );&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이재 콘솔 명령을 실행하기 위해 아래와 같이 콘솔명령 관련 캐시를 지워줍니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;drush cc drush&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그리고 아래 명령으로 콘솔 명령을 실행해주면 콘텐츠 색인 대상 정보를 새로고침합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;drush sapi-refresh
# 또는 
drush sr&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;주기적으로 실행하도록 스크립트에 추가&lt;/h2&gt;
&lt;p&gt;이 사이트는 이미 몇개의 Cron Job이 실행중입니다. 그 중에서도 1시간 주기로 일괄색인을 진행하는 스크립트가 있습니다. 이 스크립트에 위 명령을 추가했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/sh

path=`dirname $0`

cd $path
echo &amp;quot;Cron started....&amp;quot;
/usr/local/bin/drush sr
/usr/local/bin/drush sapi-i
echo &amp;quot;Done...&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 스크립트를 실행하는 Cron 설정은 아래와 같이 crontab에 지정해두었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0 * * * * /home/gced/xxxx/docroot/run_index.sh &amp;gt; /home/xxxx/xxxx/docroot/search_index.log&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;약 4일동안 주말도 반납해가며 고민하고, 코드를 작성해서 몇 번의 테스트를 거쳐 운영서버에 적용을 했습니다. 재발 더 이상 이 문제로 메일이나 전화가 않오기를 기대해봅니다.&lt;/p&gt;</description>
      <category>프로그래밍/PHP</category>
      <category>Apache Solr</category>
      <category>index</category>
      <category>php</category>
      <category>색인</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/198</guid>
      <comments>https://jhansol.tistory.com/198#entry198comment</comments>
      <pubDate>Sat, 20 Apr 2024 16:11:13 +0900</pubDate>
    </item>
    <item>
      <title>서버는 죽었다 살았다, 나는 현세와 지옥을 오간다.</title>
      <link>https://jhansol.tistory.com/197</link>
      <description>&lt;p&gt;제가 관리(? 회사가) 유지보수 중인 서버가 있습니다. 제목에서도 알 수 있듯이 이 서버의 사이트가 불안정하여 사이트가 죽었다 살았다를 반복하고, 식은땀을 흘리게 만들었습니다. 최근에는 서버가 안정되어 한숨을 돌리고 있습니다만 또 언재 문제가 터질지 걱정되기도 합니다. 이 글에서는 이 과정에 관련된 기록을 남기기 위함입니다.&lt;/p&gt;
&lt;h1&gt;서버 사양&lt;/h1&gt;
&lt;h2&gt;웹사이트 운영 버&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;서버 유형 : VPS&lt;/li&gt;
&lt;li&gt;VPS 제공 : Vultr&lt;/li&gt;
&lt;li&gt;VCPU 수 : 6&lt;/li&gt;
&lt;li&gt;메모리 : 16GB&lt;/li&gt;
&lt;li&gt;저장메체 용량 : 320GB SSD&lt;/li&gt;
&lt;li&gt;운영체제 : Ubuntu 18.04.1 LTS&lt;/li&gt;
&lt;li&gt;기반 소프트웨어 : pache 2.4.29, PHP 5.6.38, MariaDB 10.1.34-&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;검색엔진 서버&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;서버 유형 : VPS&lt;/li&gt;
&lt;li&gt;VPS 제공 : Vultr&lt;/li&gt;
&lt;li&gt;VCPU 수 : 1&lt;/li&gt;
&lt;li&gt;메모리 : 2B&lt;/li&gt;
&lt;li&gt;저장메체 용량 : 40 SSD&lt;/li&gt;
&lt;li&gt;운영체제 : Ubuntu 18.04.1 LTS&lt;/li&gt;
&lt;li&gt;기반 소프트웨어 : Apache Solr 5.5.5&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Apache Solr 장애&lt;/h1&gt;
&lt;h2&gt;Apache Solr 다운으로 사이트 검색 기능 다운&lt;/h2&gt;
&lt;p&gt;아래의 사진은 최근 사진입니다. 하지만 장애가 났던 당시에는 아래와 같은 사진의 내용을 표시할 수 없었습니다. Apache Solr 서비스 데몬이 죽었고, 재실항할 수 없었기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1442&quot; data-origin-height=&quot;454&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qnmAB/btsGCnl5K4V/cwX6PP8AWoNORuHhmkqamK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qnmAB/btsGCnl5K4V/cwX6PP8AWoNORuHhmkqamK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qnmAB/btsGCnl5K4V/cwX6PP8AWoNORuHhmkqamK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqnmAB%2FbtsGCnl5K4V%2FcwX6PP8AWoNORuHhmkqamK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1442&quot; height=&quot;454&quot; data-origin-width=&quot;1442&quot; data-origin-height=&quot;454&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;데몬이 재실행되지 않은 이유는 메모리 부족이 가장 큰 문제였습니다. 메모리가 부족하여 색인 데이터를 모두 로드하지 못했기 때문입니다. 현재는 2GB의 메모리를 가지고 있지만 이 당시 1GB 메모리로 되어 있었습니다. 하지만 메모리를 업그레이드한 이후에도 데몬이 죽는 등 문제가 지속적으로 발생했습니다.&lt;/p&gt;
&lt;h2&gt;조치 사항&lt;/h2&gt;
&lt;h3&gt;jVM 메모리 제한 업그레이드&lt;/h3&gt;
&lt;p&gt;장애 발생 당시에는 JVM 최대 힙 사이즈가 256MB 였습니다. 메모리 업그레이드 후 최대 힙 사이즈를 아래와 같이 1GB로 설정하여 실행하도록 했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /opt/solr/bin/solr restart -m 1024m -Djava.library.path=/usr/local/lib&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;주기적인 색인 데이터 최적화&lt;/h3&gt;
&lt;p&gt;위와 같이 메모리를 널려줘도 다운되는 현상이 생겼습니다. 이문제를 해결하기 위해 색인 데이터를 최적화하여 크기를 줄여줘야 했습니다. 최적화의 가장 간단한 방법은 Apache Solr의 관리용 데시보드의 &amp;quot;Optimize now&amp;quot; 버튼을 클릭하여 실행할 수 있습니다. 이 기능을 주기적으로 실행할 필요가 있어, 임시방편으로 아래와 같이 하루에 두 번 주기적으로 최적화하도록 Crontab에 설정을 해두었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0 0,12 * * * /usr/bin/curl http://localhost:8983/solr/drupal/update?optimize=true&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;더 나은 방법&lt;/h3&gt;
&lt;p&gt;더 나은 방법으로는 자동으로 최적화되도록 하는 것인데, 현재 운영중인 서버에도 기본 설정은 되어 있습니다. 하지만 이 설정이 현재 서버 사양과 맞는지 여부는 검토해볼 필요가 있습니다. 현재로서는 여기에 시간 투자를 할 여유가 없어 기록만 남겨 둡니다. 아래 내용은 &amp;#39;solrconfig.xml&amp;#39; 파일의 일부입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  &amp;lt;indexConfig&amp;gt;
    &amp;lt;!-- maxFieldLength was removed in 4.0. To get similar behavior, include a
         LimitTokenCountFilterFactory in your fieldType definition. E.g.
     &amp;lt;filter class=&amp;quot;solr.LimitTokenCountFilterFactory&amp;quot; maxTokenCount=&amp;quot;10000&amp;quot;/&amp;gt;
    --&amp;gt;
    &amp;lt;!-- &amp;lt;writeLockTimeout&amp;gt;1000&amp;lt;/writeLockTimeout&amp;gt;  --&amp;gt;
    &amp;lt;!-- &amp;lt;maxIndexingThreads&amp;gt;8&amp;lt;/maxIndexingThreads&amp;gt;  --&amp;gt;
    &amp;lt;!-- &amp;lt;useCompoundFile&amp;gt;false&amp;lt;/useCompoundFile&amp;gt; --&amp;gt;
    &amp;lt;ramBufferSizeMB&amp;gt;32&amp;lt;/ramBufferSizeMB&amp;gt;
    &amp;lt;!-- &amp;lt;maxBufferedDocs&amp;gt;1000&amp;lt;/maxBufferedDocs&amp;gt; --&amp;gt;
    &amp;lt;mergePolicy class=&amp;quot;org.apache.lucene.index.LogByteSizeMergePolicy&amp;quot;/&amp;gt;
    &amp;lt;!--
       &amp;lt;mergeScheduler class=&amp;quot;org.apache.lucene.index.ConcurrentMergeScheduler&amp;quot;/&amp;gt;
       --&amp;gt;
    &amp;lt;mergeFactor&amp;gt;4&amp;lt;/mergeFactor&amp;gt;
    &amp;lt;lockType&amp;gt;${solr.lock.type:native}&amp;lt;/lockType&amp;gt;
    &amp;lt;!-- &amp;lt;termIndexInterval&amp;gt;256&amp;lt;/termIndexInterval&amp;gt; --&amp;gt;
    &amp;lt;unlockOnStartup&amp;gt;false&amp;lt;/unlockOnStartup&amp;gt;
    &amp;lt;reopenReaders&amp;gt;true&amp;lt;/reopenReaders&amp;gt;
    &amp;lt;deletionPolicy class=&amp;quot;solr.SolrDeletionPolicy&amp;quot;&amp;gt;
      &amp;lt;str name=&amp;quot;maxCommitsToKeep&amp;quot;&amp;gt;1&amp;lt;/str&amp;gt;
      &amp;lt;str name=&amp;quot;maxOptimizedCommitsToKeep&amp;quot;&amp;gt;0&amp;lt;/str&amp;gt;
      &amp;lt;!--
         &amp;lt;str name=&amp;quot;maxCommitAge&amp;quot;&amp;gt;30MINUTES&amp;lt;/str&amp;gt;
         &amp;lt;str name=&amp;quot;maxCommitAge&amp;quot;&amp;gt;1DAY&amp;lt;/str&amp;gt;
      --&amp;gt;
    &amp;lt;/deletionPolicy&amp;gt;
    &amp;lt;infoStream&amp;gt;true&amp;lt;/infoStream&amp;gt;
  &amp;lt;/indexConfig&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;h1&gt;사이트 운영 서버 장애&lt;/h1&gt;
&lt;h2&gt;급격히 상승하는 서버 부하&lt;/h2&gt;
&lt;p&gt;아래 그림과 같이 순식간에 서버 부하가 상승하고 메모리 사용도 급격하게 늘어 사용 가능 메모리 공간이 없어 서버가 매우 느려지거나 응답하지 않는 상태가 자주 발생합니다. 심한 경우 mariaDB 서비스 데몬이 다운되고, 데이터 손상으로 실행이 아에 되지 않는 사태도 발생했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1221&quot; data-origin-height=&quot;916&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yeI07/btsGAA1Frs6/ey4M5qexusKBs6Pwr3UWz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yeI07/btsGAA1Frs6/ey4M5qexusKBs6Pwr3UWz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yeI07/btsGAA1Frs6/ey4M5qexusKBs6Pwr3UWz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyeI07%2FbtsGAA1Frs6%2Fey4M5qexusKBs6Pwr3UWz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1221&quot; height=&quot;916&quot; data-origin-width=&quot;1221&quot; data-origin-height=&quot;916&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;AmariaDB 서버&lt;/h3&gt;
&lt;p&gt;아래의 내용은 DB 서버 데몬이 죽었을 때의 로그입니다. 로그를 보면 메모리가 부족하고 요구 용량이 표시되고 있고, 현제 Buffer Pool Size의 크기가 표시되어 있습니다. 그리고 마지막에는 이로 인해 InnoDB 엔진을 초기화 할수 없어 재시작도 못하고 다운된 것을 확인했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2024-03-04  1:49:02 140636705234048 [ERROR] mysqld: Out of memory (Needed 128663552 bytes)
2024-03-04  1:49:02 140636705234048 [ERROR] mysqld: Out of memory (Needed 96485376 bytes)

......

InnoDB: mmap(140574720 bytes) failed; errno 12
2024-03-14 23:41:28 140476940496000 [ERROR] InnoDB: Cannot allocate memory for the buffer pool
2024-03-14 23:41:28 140476940496000 [ERROR] Plugin &amp;#39;InnoDB&amp;#39; init function returned error.
2024-03-14 23:41:28 140476940496000 [ERROR] Plugin &amp;#39;InnoDB&amp;#39; registration as a STORAGE ENGINE failed.
2024-03-14 23:41:28 140476940496000 [Note] Plugin &amp;#39;FEEDBACK&amp;#39; is disabled.
2024-03-14 23:41:28 140476940496000 [ERROR] Unknown/unsupported storage engine: InnoDB
2024-03-14 23:41:28 140476940496000 [ERROR] Aborting&lt;/code&gt;&lt;/pre&gt;&lt;h1&gt;장애 원인 추정&lt;/h1&gt;
&lt;h2&gt;해킹 시도&lt;/h2&gt;
&lt;p&gt;Apache Access Log를 확인해보니 아래와 같은 로그가 다수 보입니다. 로그에서 아래와 같이 &lt;code&gt;/ru/events/2023-01-16?language=en&lt;/code&gt;, &lt;code&gt;calendar-node-field-event-date/month/2021-03&lt;/code&gt; 등이 보입니다. 이들 경로는 사이트에서 재공하지 않는 경로입니다. 아래의 경로로 직접 접속해보니 응답속도도 느리고, 마지막에는 오류가 출력됩니다. DB의 Slow Query 로그를 확인해보니 이때 Slow Query가 발생합니다. 사이트의 취약점을 활용한 해킹시도로 보입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;85.208.96.197 - - [24/Nov/2023:17:51:14 +0900] &amp;quot;GET /ru/events/2023-01-16?language=en HTTP/1.1&amp;quot; 200 14033 &amp;quot;-&amp;quot; &amp;quot;Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html)&amp;quot;

...

114.119.135.182 - - [24/Nov/2023:19:47:05 +0900] &amp;quot;GET /calendar-node-field-event-date/day/2021-03-06?field_events_title_value&amp;amp;page=52&amp;amp;language=en HTTP/1.1&amp;quot; 200 13886 &amp;quot;https://g.........org/calendar-node-field-event-date/month/2021-03?field_events_title_value&amp;amp;page=52&amp;amp;language=en&amp;quot; &amp;quot;Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; PetalBot;+https://webmaster.petalsearch.com/site/petalbot)&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;사이트에서 발생한 Slow Query&lt;/h2&gt;
&lt;p&gt;아래의 이미지는 장애가 발생했을 때 확인한 Slow Query 내용입니다. 아래의 쿼리는 제가 개발한 모듈에서 발생한 것이 아니라 Drupal의 공식 사이트에서 설치한 기여 모듈입니다. 저의 짧은 생각에는 이런 모듈들이 대량의 데이터를 예상하지 않고 만들어진 것이 아닌가 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;20240304_100535.png&quot; data-origin-width=&quot;1851&quot; data-origin-height=&quot;1392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blk4zb/btsGBxJXviQ/JTSbxyzsGhEXp7uPNfKu91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blk4zb/btsGBxJXviQ/JTSbxyzsGhEXp7uPNfKu91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blk4zb/btsGBxJXviQ/JTSbxyzsGhEXp7uPNfKu91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fblk4zb%2FbtsGBxJXviQ%2FJTSbxyzsGhEXp7uPNfKu91%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1851&quot; height=&quot;1392&quot; data-filename=&quot;20240304_100535.png&quot; data-origin-width=&quot;1851&quot; data-origin-height=&quot;1392&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2&gt;작은 용량의 Buffer Pool Size&lt;/h2&gt;
&lt;p&gt;최초 Buffer Pool Size는 128MB로 8개의 인스턴스가 나누어 사용하는 것으로 되어 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2024-03-04  1:49:02 140636705234048 [Note] InnoDB: Initializing buffer pool, size = 128.0M
InnoDB: mmap(140574720 bytes) failed; errno 12&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;+-------------------------------------+----------------+
| Variable_name                       | Value          |
+-------------------------------------+----------------+
| innodb_buffer_pool_dump_at_shutdown | OFF            |
| innodb_buffer_pool_dump_now         | OFF            |
| innodb_buffer_pool_dump_pct         | 100            |
| innodb_buffer_pool_filename         | ib_buffer_pool |
| innodb_buffer_pool_instances        | 8              |
| innodb_buffer_pool_load_abort       | OFF            |
| innodb_buffer_pool_load_at_startup  | OFF            |
| innodb_buffer_pool_load_now         | OFF            |
| innodb_buffer_pool_populate         | OFF            |
| innodb_buffer_pool_size             | 131072         |
+-------------------------------------+----------------+&lt;/code&gt;&lt;/pre&gt;&lt;h1&gt;조치 사항&lt;/h1&gt;
&lt;h2&gt;InnoDB 손성 복구&lt;/h2&gt;
&lt;p&gt;MariaDB는 /etc/mysql/mariadb.conf.d/50-server.cnf 파일 내용 중에 아래와 주석을 제거하고 저장한 후 서비스를 복구모드로 실행하도록 했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[mariadb]
# innodb_force_recovery = 1
innodb_force_recovery = 1&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;# service mysql restart&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;해킹 시도 조치사항&lt;/h2&gt;
&lt;p&gt;일단 사용하지 않는 모듈을 전수 검사하여 비활성화 했습니다. 그리고 아래 내용과 같이 사이트 DocumentRoot 폴드에 &lt;code&gt;.htaccess&lt;/code&gt; 파일에 사이트 경로를 rewrite하는 코드 위에 rewrite 조건을 지정해두었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  # 해킹으로 의심되는 요청 차단
  RewriteCond %{REQUEST_URI} /events/\d{4}-\d{2}-\d{2}$
  RewriteRule ^ 400.html [L]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;아래 내용과 같이 해킹 시도로 의심되는 IP를 C클래스의 크기 단위로 차단하도록 &lt;code&gt;ufw&lt;/code&gt;를 이용하여 설정해두었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;To                         Action      From
--                         ------      ----
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere
Anywhere                   DENY        106.11.167.0/24
Anywhere                   DENY        106.11.229.0/24
Anywhere                   DENY        106.11.230.0/24
Anywhere                   DENY        106.11.228.0/24
Anywhere                   DENY        106.11.231.0/24
50022                      ALLOW       .......
Anywhere                   DENY        114.234.157.0/24
Anywhere                   DENY        119.39.100.0/24
Anywhere                   DENY        119.39.102.0/24
Anywhere                   DENY        119.39.16.0/24
Anywhere                   DENY        119.39.17.0/24
Anywhere                   DENY        122.247.154.0/24
Anywhere                   DENY        122.247.157.0/24
Anywhere                   DENY        125.115.105.0/24
Anywhere                   DENY        125.115.189.0/24
Anywhere                   DENY        125.115.191.0/24
Anywhere                   DENY        180.115.187.0/24
Anywhere                   DENY        180.115.190.0/24
Anywhere                   DENY        180.116.178.0/24
Anywhere                   DENY        180.116.181.0/24
Anywhere                   DENY        180.116.182.0/24
Anywhere                   DENY        183.27.48.0/24
Anywhere                   DENY        183.27.49.0/24
Anywhere                   DENY        183.27.50.0/24
Anywhere                   DENY        183.27.51.0/24
Anywhere                   DENY        218.68.102.0/24
Anywhere                   DENY        218.68.105.0/24
Anywhere                   DENY        218.68.107.0/24
Anywhere                   DENY        218.68.108.0/24
Anywhere                   DENY        221.197.56.0/24
Anywhere                   DENY        221.197.66.0/24
Anywhere                   DENY        49.81.173.0/24
Anywhere                   DENY        60.24.14.0/24
Anywhere                   DENY        117.45.252.0/24
Anywhere                   DENY        117.45.253.0/24
....                       ALLOW       .......
..../tcp                   ALLOW       .......
..../tcp                   ALLOW       .......
..../tcp                   ALLOW       .......
..../tcp                   ALLOW       .......
..../tcp                   ALLOW       .......
..../tcp                   ALLOW       .......
..../tcp                   ALLOW       .......
..../tcp                   ALLOW       .......
..../tcp                   DENY        Anywhere
80/tcp (v6)                ALLOW       Anywhere (v6)
root@apceiu:~#
..../tcp  (v6)             DENY        Anywhere (v6)&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Slow Query 예방을 위한 조치&lt;/h2&gt;
&lt;p&gt;사실 이 부분은 다른 기여 모듈에서 발생하는 것이라 수정하기에는 어려움이 많습니다. 사실 어느 모듈에서 이런 ㅋ쿼리를 넘기는지 알수가 없습니다. 사용하지 않는 모듈을 찾아 비활성화하는 것으로 마무리했습니다.&lt;/p&gt;
&lt;h2&gt;Buffer Pool Size 조정&lt;/h2&gt;
&lt;p&gt;사이트를 서비스하고 있는 사이트의 서버는 16GB의 메모리를 보유하고 있습니다. 이 장애가 발생하고 나서 인터넷을 검색해보니 물리적인 메모리의 70%~80%로 설정할 것을 권장하고 있습니다. 하지만 DBMS만 실행되고 있는 것이 아니라 적용하기에는 어려워 점진적으로 조정해왔습니다. 현재는 4GB로 설정되어 있습니다. 아래의 내용을 /etc/mysql/mariadb.conf.d/50-server.cnf 추가한 후 서비스를 다시 실행했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[mysqld]
innodb_buffer_pool_size = 4G&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이렇게 조치한 후 InnoDB 엔진의 상태를 아래의 명령으로 확인해봤습니다. 확인 결과 8개의 인스턴스가 동일하게 512MG 씩 나우어 사용하고 있었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;show engine innodb status\G;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;---BUFFER POOL 0
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30413
Old database pages      11206
Modified db pages       7

---BUFFER POOL 1
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30427
Old database pages      11211
Modified db pages       2

---BUFFER POOL 2
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30414
Old database pages      11207
Modified db pages       0

---BUFFER POOL 2
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30414
Old database pages      11207
Modified db pages       0

---BUFFER POOL 3
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30414
Old database pages      11207
Modified db pages       2

---BUFFER POOL 4
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30420
Old database pages      11209
Modified db pages       2

---BUFFER POOL 5
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30418
Old database pages      11208
Modified db pages       0

---BUFFER POOL 6
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30401
Old database pages      11202
Modified db pages       3

---BUFFER POOL 7
Buffer pool size        32767
Buffer pool size, bytes 536854528
Free buffers            1024
Database pages          30413
Old database pages      11206
Modified db pages       1&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;아래의 명령으로 버퍼의 상태 등을 보다 간략하게 확인할 수도 있습니다. 여기서 관심있게 본 부분은 &lt;code&gt;Innodb_buffer_pool_wait_free&lt;/code&gt; 부분인데, 2GB로 지정했을 때는 이 값이 &lt;code&gt;126&lt;/code&gt; 정도였습니다. 아래 명령은 이 글을 작성하고 있는 시점에서 실행한 것인데, 4GB로 저종한 이후 46번 정도의 락으로 인한 대기가 있었다는 것으로 현상태로 충분하겠다 싶어 더 이상 Buffer Pool Size를 조정하지 않고 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MariaDB [(none)]&amp;gt; show status like &amp;#39;%innodb_buffer_pool%&amp;#39;;
+-----------------------------------------+----------------------------------------+
| Variable_name                           | Value                                  |
+-----------------------------------------+----------------------------------------+
| Innodb_buffer_pool_bytes_data           | 3985571840                             |
| Innodb_buffer_pool_bytes_dirty          | 6750208                                |
| Innodb_buffer_pool_dump_status          | Dumping buffer pool(s) not yet started |
| Innodb_buffer_pool_load_status          | Loading buffer pool(s) not yet started |
| Innodb_buffer_pool_pages_data           | 243260                                 |
| Innodb_buffer_pool_pages_dirty          | 412                                    |
| Innodb_buffer_pool_pages_flushed        | 87821456                               |
| Innodb_buffer_pool_pages_free           | 8192                                   |
| Innodb_buffer_pool_pages_lru_flushed    | 0                                      |
| Innodb_buffer_pool_pages_made_not_young | 491905155                              |
| Innodb_buffer_pool_pages_made_young     | 1605166                                |
| Innodb_buffer_pool_pages_misc           | 10684                                  |
| Innodb_buffer_pool_pages_old            | 89633                                  |
| Innodb_buffer_pool_pages_total          | 262136                                 |
| Innodb_buffer_pool_read_ahead           | 8956261                                |
| Innodb_buffer_pool_read_ahead_evicted   | 0                                      |
| Innodb_buffer_pool_read_ahead_rnd       | 0                                      |
| Innodb_buffer_pool_read_requests        | 500759227970                           |
| Innodb_buffer_pool_reads                | 1546483                                |
| Innodb_buffer_pool_wait_free            | 46                                     |
| Innodb_buffer_pool_write_requests       | 848190430                              |
+-----------------------------------------+----------------------------------------+&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;DB Table 최적화&lt;/h2&gt;
&lt;p&gt;최적화에 대한 이야기는 장애가 발생하기 전에 익히 들어왔던 것이었으나, 최적화 과정에 문제가 발생할 가능성을 우려해 손도 못되고 있다가 쟁애가 발생한 시점에 고객사쪽에도 미리 이야기를 해서 밤 12시에 약 2시간을 작업 시간으로 설정하고 최적화를 작업을 진행을 했습니다.&lt;/p&gt;
&lt;p&gt;작업을 진행하기 전에 &lt;code&gt;.htaccess&lt;/code&gt; 파일에 아래의 내용을 추가하여 사이트 각종 서비스에 접근할 수 없도록 한 후 작업을 했습니다. 이렇게 한 이유는 예전에 서비스 중인 데이터 필드와 자료를 통패합할 때가 있었는데, 작업을 다 하고 검토를 하는데, 통패합 전 데이터로 입력된 것이 다수 있어서 &amp;quot;내가 작업을 잘 못했나?&amp;quot;하는 생각에 엄청 당황했었습니다. 나중에 알고보니 서비스 자체의 점검모드를 켜 두어도 관리자는 예외로 설정되어 있고, 세션 유지시간도 약 2주(? 미친)로 설정되어 있다보니 별도의 로그인 없이 들어와서 작업을 한 것이었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  # 본 규칙은 서버의 중요한 유지보수 모드를 위한 것입니다.
  # 규칙을 적용하기 위해 아래의 IP 주소를 유지보수 작업 IP를 기록하고
  # maintenance.enable 파일을 생성해주세요.
  RewriteCond %{REMOTE_ADDR} !121.144.74.110
  RewriteCond %{DOCUMENT_ROOT}/maintenance.html -f
  RewriteCond %{DOCUMENT_ROOT}/maintenance.enable -f
  RewriteRule ^ maintenance.html [L]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 내용 IP주소는 제가 작업 중인 PC의 IP주소입니다. 접속한 IP가 명기된 IP가 아니고, &lt;code&gt;maintenance.html&lt;/code&gt;, &lt;code&gt;maintenance.enable&lt;/code&gt; 파일이 존재하면 &lt;code&gt;maintenance.htm&lt;/code&gt; 파일의 내용이 출력되도록 해둔 것입니다.&lt;/p&gt;
&lt;p&gt;MySQL이나 MariaDB 모두 최적화하는 방식의 차이는 있으나 테이블 단위로 최적화하는 명령을 재공합니다. 하지만 전 &lt;code&gt;mysql&lt;/code&gt;과 &lt;code&gt;mysqldump&lt;/code&gt;를 이용하여 백업하고 복구하는 형태로 진행을 했습니다.&lt;/p&gt;
&lt;p&gt;아래 내용은 최적화 전 물리적인 파일의 용량입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 최적화 전
root@axxxxx:/var/lib/mysql# du -m
64154   ./g...............    // 62.5GB
12      ./mysql
1       ./performance_schema
64785&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그리고 아래의 내용은 최적화 후의 용량입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 최적화 후
root@axxxxx:/var/lib/mysql# du -m
12330   ./g...............     // 12.0GB
12      ./mysql
1       ./performance_schema
12961&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;약 5배의 공간 절약이 있었습니다.&lt;/p&gt;
&lt;h2&gt;swap 메모리(가상메모리) 추가&lt;/h2&gt;
&lt;p&gt;메모리가 부족하여 서비스가 다운되는 것을 막기 위해 성능은 떨어지지만 저장소(SSD)에 Swap 파일을 생성하여 들록했습니다. 용량은 메모리와 동일한 16GB로 지정했습니다. 아래의 내용은 Swap 메몰리를 추가하는 과정입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 핸재 메모리 사용 현황 확인
free -m

# 디스크 가용 용량 확인
df

# Swap 메모리 상태 확인
swapon

# Swap 메모리용 파일 생성
fallocate -l 4G /swapfile

# 권한 설정
chmod 600 /swapfile 

# swapfile을 Swap 메모리용으로 설정
mkswap /swapfile

# swapfile을 Swap 메모리로 활성화
swapon /swapfile

# fstab에 마운터 정보 설정
echo &amp;quot;/swapfile swap swap defaults 0 0&amp;quot;    &amp;gt;&amp;gt; /etc/fstab&lt;/code&gt;&lt;/pre&gt;</description>
      <category>웹 개발관련/서버</category>
      <category>apache</category>
      <category>Drupal</category>
      <category>error</category>
      <category>mariadb</category>
      <category>php</category>
      <category>Server</category>
      <category>서버</category>
      <category>장애</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/197</guid>
      <comments>https://jhansol.tistory.com/197#entry197comment</comments>
      <pubDate>Mon, 15 Apr 2024 00:25:44 +0900</pubDate>
    </item>
    <item>
      <title>기록 보관 : NFT 발행을 위한 이미지 제작</title>
      <link>https://jhansol.tistory.com/196</link>
      <description>&lt;p&gt;저의 개인 홈페이지에 있던 내용을 옮겨 기록하고자 합니다. 제가 게으른 탓에 활용하기 힘들고 유지를 하기에 금전적으로도 낭비다 생각되어 님길 것은 남기고, 버릴 것은 버리고 사이트를 없에기 위함입니다. 기존 내용 그대로 아래와 같이 옮겼습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;스마트 컨트렉트가 어느 정도 준비가 되었다. 이재 NFT 이미지를 제작해야 한다. 여기에는 이미지 제작을 위해 내가 시행착오를 겪었던 것과 NFT 이미지에 대한 내 개인적인 생각을 기록하려고 한다.&lt;/p&gt;
&lt;h1&gt;제작전 탐색&lt;/h1&gt;
&lt;p&gt;이 부분도 조코딩님의 유투브 체널을 참조했다. 여기선 소개된 HashLips / hashlips_art_engine 을 이용하여 이미지를 제작하려고 했다. 하지만 버전문제와 의도치 않은 구문오류가 발생한다. 그래서 대안은 없는지 인터넷을 검색해보니 이미지를 제작해주는 온라인 사이트가 여럿 있다. 그러나 이들 사이트의 경우 이미지 숫자, 기타 제한이 있었다. 그 외 응용프로그램(Windows, Mac, Linux)도 다수 존재했으나 앞에 소개한 NodeJs 기반으로 운영체제 독립적인 도구가 나오는 상황에서 사용하기 꺼려졌다.&lt;/p&gt;
&lt;p&gt;검색하다 마지막으로 찾은 것이 nft-art-maker 패키지이다. 이 패키지는 NodeJs 전역에서 사용 가능한 패키지로 HashLips / hashlips_art_engine 를 기반으로 제작되었다고 한다. 약간의 시행착오를 거쳤지만 매우 만족할 만큼 이미지 제작이 되었다.&lt;/p&gt;
&lt;h1&gt;제작 환경&lt;/h1&gt;
&lt;p&gt;nft-art-maker 패키지는 버전의 영향을 많이 받는다. 공식 홈페이지를 보면 최소 NodeJs 14.14.0 이상에서 동작하지만 버전 18에서는 아직 동작하지 않는다고 되어 있다. 요즘 NodeJs 버전 때문에 스트레스가 이만저만이 아니었다. 그래서 아에 NVM 체제로 변경하였다. 이 프로젝트도 NVM 환경에서 버전을 선택한 후 작업하였다.&lt;/p&gt;
&lt;p&gt;우선 NVM을 설치해야 한다.&lt;/p&gt;
&lt;h2&gt;Mac 환경에서 NVM 설치&lt;/h2&gt;
&lt;p&gt;우선 Homebrew가 설치되어 있어야 한다. 설치되어 있지 않다면 설치 후 아래 과정을 진행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brew uninstall node
brew install nvm&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;혹시 문제가 발생할까봐 현재의 시스템에 설치되어 있는 NodeJs를 삭제하고 설치했다.&lt;/p&gt;
&lt;h2&gt;Windows 환경에 NVM 설치&lt;/h2&gt;
&lt;p&gt;Windows 에서는 &lt;a href=&quot;https://github.com/coreybutler/nvm-windows&quot;&gt;coreybutler / nvm-windows&lt;/a&gt; 에서 설치버전을 다운받아 설치한다. 설치 마법사 형태로 설치가 되므로 매우 간단하게 설치할 수 있다. 사용방법은 같다.&lt;/p&gt;
&lt;h2&gt;NVM에서 14.14.0 설치 및 사용&lt;/h2&gt;
&lt;p&gt;특정 벚전의 설치와 사용은 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nvm install 14.14.0
nvm use 14.14.0&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이재 NodeJs 버전을 확인하면 &amp;quot;v14.14.0&amp;quot; 이라고 표시될 것이다. 한 가지 주의할 점은 버전 사용은 위 명령을 실행한 터미널 세션 안에서만 유효하다. 다른 터미널을 실행한 후 버전을 확인하면 결과가 다르게 나올 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;% node -v
v19.1.0

% nvm use 14.14.0
Now using node v14.14.0 (npm v6.14.8)

% node -v
v14.14.0&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;nft-art-maker 패키지 설치&lt;/h2&gt;
&lt;p&gt;이 패키지는 전역으로 설치하여 이용한다. 아래와 같이 설치한다. 설치하기 전에 위와 같이 NodeJs 버전을 14.14.0 으로 변경한 다음 설치해야한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install nft-art-maker@latest -g&lt;/code&gt;&lt;/pre&gt;&lt;h1&gt;NFT 이미지 소스 준비 및 환경설정&lt;/h1&gt;
&lt;h2&gt;이미지 소스 준비&lt;/h2&gt;
&lt;p&gt;이미지는 조합 대상 분류에 따라 폴더 단위로 구성한다. 각 폴드 안에는 조합 대상의 이미지(특히 PNG)를 준비한다. 참고로 이미지 이름에 &amp;quot;#확률&amp;quot; 형태로 확률을 백분율로 지정하여 조합 확률을 지정할 수 있다. 여기서는 확률을 지정하지 않았다.&lt;/p&gt;
&lt;p&gt;이미지 폴더는 아래와 같이 준비하였다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;layers
├── arrow
├── background
├── character
├── check
├── fx
└── home&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;각 폴더에는 이미지가 10개씩 준비되어 있다. 이들을 조합하면 최대 1,000,000개의 이미지를 생성할 수 있다&lt;/p&gt;
&lt;h2&gt;이미지 조합 환경 설정&lt;/h2&gt;
&lt;p&gt;이재 제작을 위한 마지막 일만 남았다. 이미지 제작에 대한 설정을 해주어야 하는데, layers 폴더가 보이는 곳에 &amp;quot;.nftartmakerrc&amp;quot; 파일을 생성하여 아래와 같은 설정 내용을 저장한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;description&amp;quot;: &amp;quot;SOHOCODE Test NFT&amp;quot;,
  &amp;quot;layerConfigurations&amp;quot;: [
    {
      &amp;quot;growEditionSizeTo&amp;quot;: 1000,
      &amp;quot;layersOrder&amp;quot;: [
        {&amp;quot;name&amp;quot;: &amp;quot;background&amp;quot;},
        {&amp;quot;name&amp;quot;: &amp;quot;arrow&amp;quot;},
        {&amp;quot;name&amp;quot;: &amp;quot;fx&amp;quot;},
        {&amp;quot;name&amp;quot;: &amp;quot;check&amp;quot;},
        {&amp;quot;name&amp;quot;: &amp;quot;home&amp;quot;},
        {&amp;quot;name&amp;quot;: &amp;quot;character&amp;quot;}
      ]
    }
  ],
  &amp;quot;format&amp;quot;: {
    &amp;quot;width&amp;quot;: 500,
    &amp;quot;height&amp;quot;: 173
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 설정 내용은 아래와 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;본 NFT 이미지 설명은 &amp;quot;SOHOCODE Test NFT&amp;quot;으로 추 후 콜렉션 제목으로 이용된다.&lt;/li&gt;
&lt;li&gt;이미지 제작 개수는 1000개로한다.&lt;/li&gt;
&lt;li&gt;이미지 레어 조합 순서는 &amp;quot;background&amp;quot;를 제일 아래에 배치하고, 순서데로 &amp;quot;arrow&amp;quot;, &amp;quot;fx&amp;quot;, &amp;quot;check&amp;quot;, &amp;quot;home&amp;quot;, &amp;quot;character&amp;quot; 등의 폴더를 쌓아 올려 조합한다.&lt;/li&gt;
&lt;li&gt;이미지의 크기는 가래 500픽셀, 세로 173픽셀로 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;이미지 제작&lt;/h1&gt;
&lt;p&gt;모든 준비가 다 되었다. 아래의 명령으로 위에서 설정한 데로 이미지를 제작하도록 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nft-art-maker generate&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;환경설정 파일에 문제가 없다면 아래와 같이 output 폴더가 생성되고 이미지와 메타데이터가 생성된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.
├── layers
│   ├── arrow
│   ├── background
│   ├── character
│   ├── check
│   ├── fx
│   └── home
└── output
    ├── images
    ├── json
    └── metadata.json&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;아래 그림처럼 이미지가 조합되어 100개가 만들어 졌다. 이재 이것을 ipfs나 자체 서버에 보관하고 NFT 발행에 활용하면 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;977&quot; data-origin-height=&quot;539&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dEE7iJ/btsDJkN8d19/zjPYqqRhEk7dCk0A7N0q01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dEE7iJ/btsDJkN8d19/zjPYqqRhEk7dCk0A7N0q01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dEE7iJ/btsDJkN8d19/zjPYqqRhEk7dCk0A7N0q01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdEE7iJ%2FbtsDJkN8d19%2FzjPYqqRhEk7dCk0A7N0q01%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;977&quot; height=&quot;539&quot; data-origin-width=&quot;977&quot; data-origin-height=&quot;539&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;실행 후 검토 사항&lt;/h1&gt;
&lt;p&gt;이 글을 적기 전에 내가 예상한 이미지 수(1,000,000개)를 생성을 시도했다. 이미지 저장 용량이 상당히 크다. 앞으로 서버시의 활용에 따라 그 이상의 NFT가 발행될 것이다. 그렇게 되면 저장 욜량으로 인한 문제가 크게 대두될 것이다. 위와 같이 이미지를 제작하는 것은 소량으로 발행하고, 유지를 위한 지속적인 수익이 나올 때 적합하다.&lt;br&gt;NFT에서 사용되는 이미지, 비디오 등은 미디어 소유 자체를 의미하지 않는다면 굳이 각 NFT 별로 유일한 이미지를 가질 필요는 없다. 그리고 메타데이터 역시 마찬가지이다. 오픈씨나 NFT 마켓을 통해 유통하지 않는다면 메타데이터 역시 필요하지 않다. 필요하더라도 별도의 Json 파일을 만들지 않고 요청 시 서버에서 동적으로 생성하여 전송하는 것도 가능하다.&lt;br&gt;이를 바탕으로 초 대량으로 NFT를 발행하고 각족 서비스를 제공하는 경우 각 NFT 발행 시 하나의 독특한 이미지를 사용하고 메타데이터는 배경시스틈(Backend System)에서 동적으로 생성하여 전송하는 것이 좋겠다는 생각이 든다.&lt;/p&gt;
&lt;h1&gt;참고자료&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/HashLips/hashlips_art_engine&quot;&gt;HashLips / hashlips_art_engine&lt;/a&gt;&lt;br&gt;&lt;a href=&quot;https://www.youtube.com/c/%EC%A1%B0%EC%BD%94%EB%94%A9JoCoding&quot;&gt;조코딩 JoCoding&lt;/a&gt;&lt;br&gt;&lt;a href=&quot;https://github.com/juliancwirko/nft-art-maker&quot;&gt;coreybutler / nvm-windows&lt;/a&gt;&lt;br&gt;&lt;a href=&quot;https://github.com/coreybutler/nvm-windows&quot;&gt;coreybutler / nvm-windows&lt;/a&gt;&lt;/p&gt;</description>
      <category>프로그래밍/Node.js</category>
      <category>nft</category>
      <category>NFT Image Maker</category>
      <category>NFT 이미지 메이커</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/196</guid>
      <comments>https://jhansol.tistory.com/196#entry196comment</comments>
      <pubDate>Mon, 22 Jan 2024 23:40:37 +0900</pubDate>
    </item>
    <item>
      <title>기록 보관 : NFT 발행을 위한 스마트 큰트렉트</title>
      <link>https://jhansol.tistory.com/195</link>
      <description>&lt;p&gt;저의 개인 홈페이지에 있던 내용을 옮겨 기록하고자 합니다. 제가 게으른 탓에 활용하기 힘들고 유지를 하기에 금전적으로도 낭비다 생각되어 님길 것은 남기고, 버릴 것은 버리고 사이트를 없에기 위함입니다. 기존 내용 그대로 아래와 같이 옮겼습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;이 글은 유투브 체널 조코딩 JoCoding 의 영상에서 소개한 스마트 컨트렉트 코드를 나의 학습을 위해 주속을 제거하여 올려둔다. 스마트 컨트렉트를 이용한 NFT 발행 실무코드를 보고자한다. 아래 코드는 Github 소스를 내 나름데로 수정하고 정리한 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-solitity&quot;&gt;pragma solidity ^0.5.0;

// ---------------------------------------------------------------------------
// 인터이스 모음
// ---------------------------------------------------------------------------
interface IKIP13 {
    function supportsInterface(bytes4 interfaceId) external view returns (bool);
}

interface IKIP17 is IKIP13 {
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
    function balanceOf(address owner) public view returns (uint256 balance);
    function ownerOf(uint256 tokenId) public view returns (address owner);
    function safeTransferFrom(address from, address to, uint256 tokenId) public;
    function transferFrom(address from, address to, uint256 tokenId) public;
    function approve(address to, uint256 tokenId) public;
    function getApproved(uint256 tokenId) public view returns (address operator);
    function setApprovalForAll(address operator, bool _approved) public;
    function isApprovedForAll(address owner, address operator) public view returns (bool);
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public;
}

interface IERC721Receiver {
    function onERC721Received(address operator, address from, uint256 tokenId, bytes memory data) public returns (bytes4);
}

interface IKIP17Receiver {
    function onKIP17Received(address operator, address from, uint256 tokenId, bytes memory data) public returns (bytes4);
}

interface IKIP17Enumerable is IKIP17 {
    function totalSupply() public view returns (uint256);
    function tokenOfOwnerByIndex(address owner, uint256 index) public view returns (uint256 tokenId);

    function tokenByIndex(uint256 index) public view returns (uint256);
}

interface IKIP17Metadata is IKIP17 {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
}


// ---------------------------------------------------------------------------
// 라이브러리 모음
// ---------------------------------------------------------------------------
library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c &amp;gt;= a, &amp;quot;SafeMath: addition overflow&amp;quot;);

        return c;
    }

    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        return sub(a, b, &amp;quot;SafeMath: subtraction overflow&amp;quot;);
    }

    function sub(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b &amp;lt;= a, errorMessage);
        uint256 c = a - b;

        return c;
    }

    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        if (a == 0) {
            return 0;
        }

        uint256 c = a * b;
        require(c / a == b, &amp;quot;SafeMath: multiplication overflow&amp;quot;);

        return c;
    }

    function div(uint256 a, uint256 b) internal pure returns (uint256) {
        return div(a, b, &amp;quot;SafeMath: division by zero&amp;quot;);
    }

    function div(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b &amp;gt; 0, errorMessage);
        uint256 c = a / b;

        return c;
    }

    function mod(uint256 a, uint256 b) internal pure returns (uint256) {
        return mod(a, b, &amp;quot;SafeMath: modulo by zero&amp;quot;);
    }

    function mod(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b != 0, errorMessage);
        return a % b;
    }
}

library Address {
    function isContract(address account) internal view returns (bool) {
        uint256 size;
        // solhint-disable-next-line no-inline-assembly
        assembly { size := extcodesize(account) }
        return size &amp;gt; 0;
    }
}

library Counters {
    using SafeMath for uint256;

    struct Counter {
        uint256 _value; // default: 0
    }

    function current(Counter storage counter) internal view returns (uint256) {
        return counter._value;
    }

    function increment(Counter storage counter) internal {
        counter._value += 1;
    }

    function decrement(Counter storage counter) internal {
        counter._value = counter._value.sub(1);
    }
}

library Roles {
    struct Role {
        mapping (address =&amp;gt; bool) bearer;
    }

    function add(Role storage role, address account) internal {
        require(!has(role, account), &amp;quot;Roles: account already has role&amp;quot;);
        role.bearer[account] = true;
    }

    function remove(Role storage role, address account) internal {
        require(has(role, account), &amp;quot;Roles: account does not have role&amp;quot;);
        role.bearer[account] = false;
    }

    function has(Role storage role, address account) internal view returns (bool) {
        require(account != address(0), &amp;quot;Roles: account is the zero address&amp;quot;);
        return role.bearer[account];
    }
}

library String {
  function uint2str(uint _i) internal pure returns (string memory _uintAsString) {
    if (_i == 0) {
      return &amp;quot;0&amp;quot;;
    }
    uint j = _i;
    uint len;
    while (j != 0) {
      len++;
      j /= 10;
    }
    bytes memory bstr = new bytes(len);
    uint k = len - 1;
    while (_i != 0) {
      bstr[k--] = byte(uint8(48 + _i % 10));
      _i /= 10;
    }
    return string(bstr);
  }
}

library MerkleProof {
    function verify(
        bytes32[] memory proof,
        bytes32 root,
        bytes32 leaf
    ) internal pure returns (bool) {
        return processProof(proof, leaf) == root;
    }

    function processProof(bytes32[] memory proof, bytes32 leaf) internal pure returns (bytes32) {
        bytes32 computedHash = leaf;
        for (uint256 i = 0; i &amp;lt; proof.length; i++) {
            bytes32 proofElement = proof[i];
            if (computedHash &amp;lt;= proofElement) {
                // Hash(current computed hash + current element of the proof)
                computedHash = _efficientHash(computedHash, proofElement);
            } else {
                // Hash(current element of the proof + current computed hash)
                computedHash = _efficientHash(proofElement, computedHash);
            }
        }
        return computedHash;
    }

    function _efficientHash(bytes32 a, bytes32 b) private pure returns (bytes32 value) {
        assembly {
            mstore(0x00, a)
            mstore(0x20, b)
            value := keccak256(0x00, 0x40)
        }
    }
}

// ---------------------------------------------------------------------------
// 컨트렉트 모음
// ---------------------------------------------------------------------------
contract KIP13 is IKIP13 {
    bytes4 private constant _INTERFACE_ID_KIP13 = 0x01ffc9a7;
    mapping(bytes4 =&amp;gt; bool) private _supportedInterfaces;

    constructor () internal {
        _registerInterface(_INTERFACE_ID_KIP13);
    }

    function supportsInterface(bytes4 interfaceId) external view returns (bool) {
        return _supportedInterfaces[interfaceId];
    }

    function _registerInterface(bytes4 interfaceId) internal {
        require(interfaceId != 0xffffffff, &amp;quot;KIP13: invalid interface id&amp;quot;);
        _supportedInterfaces[interfaceId] = true;
    }
}


contract KIP17 is KIP13, IKIP17 {
    using SafeMath for uint256;
    using Address for address;
    using Counters for Counters.Counter;

    bytes4 private constant _KIP17_RECEIVED = 0x6745782b;
    bytes4 private constant _ERC721_RECEIVED = 0x150b7a02;
    bytes4 private constant _INTERFACE_ID_KIP17 = 0x80ac58cd;
    mapping (uint256 =&amp;gt; address) private _tokenOwner;
    mapping (uint256 =&amp;gt; address) private _tokenApprovals;
    mapping (address =&amp;gt; Counters.Counter) private _ownedTokensCount;
    mapping (address =&amp;gt; mapping (address =&amp;gt; bool)) private _operatorApprovals;

    constructor () public {
        _registerInterface(_INTERFACE_ID_KIP17);
    }

    function balanceOf(address owner) public view returns (uint256) {
        require(owner != address(0), &amp;quot;KIP17: balance query for the zero address&amp;quot;);

        return _ownedTokensCount[owner].current();
    }

    function ownerOf(uint256 tokenId) public view returns (address) {
        address owner = _tokenOwner[tokenId];
        require(owner != address(0), &amp;quot;KIP17: owner query for nonexistent token&amp;quot;);

        return owner;
    }

    function approve(address to, uint256 tokenId) public {
        address owner = ownerOf(tokenId);
        require(to != owner, &amp;quot;KIP17: approval to current owner&amp;quot;);

        require(msg.sender == owner || isApprovedForAll(owner, msg.sender),
            &amp;quot;KIP17: approve caller is not owner nor approved for all&amp;quot;
        );

        _tokenApprovals[tokenId] = to;
        emit Approval(owner, to, tokenId);
    }

    function getApproved(uint256 tokenId) public view returns (address) {
        require(_exists(tokenId), &amp;quot;KIP17: approved query for nonexistent token&amp;quot;);

        return _tokenApprovals[tokenId];
    }

    function setApprovalForAll(address to, bool approved) public {
        require(to != msg.sender, &amp;quot;KIP17: approve to caller&amp;quot;);

        _operatorApprovals[msg.sender][to] = approved;
        emit ApprovalForAll(msg.sender, to, approved);
    }

    function isApprovedForAll(address owner, address operator) public view returns (bool) {
        return _operatorApprovals[owner][operator];
    }

    function transferFrom(address from, address to, uint256 tokenId) public {
        //solhint-disable-next-line max-line-length
        require(_isApprovedOrOwner(msg.sender, tokenId), &amp;quot;KIP17: transfer caller is not owner nor approved&amp;quot;);

        _transferFrom(from, to, tokenId);
    }

    function safeTransferFrom(address from, address to, uint256 tokenId) public {
        safeTransferFrom(from, to, tokenId, &amp;quot;&amp;quot;);
    }

    function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public {
        transferFrom(from, to, tokenId);
        require(_checkOnKIP17Received(from, to, tokenId, _data), &amp;quot;KIP17: transfer to non KIP17Receiver implementer&amp;quot;);
    }

    function _exists(uint256 tokenId) internal view returns (bool) {
        address owner = _tokenOwner[tokenId];
        return owner != address(0);
    }

    function _isApprovedOrOwner(address spender, uint256 tokenId) internal view returns (bool) {
        require(_exists(tokenId), &amp;quot;KIP17: operator query for nonexistent token&amp;quot;);
        address owner = ownerOf(tokenId);
        return (spender == owner || getApproved(tokenId) == spender || isApprovedForAll(owner, spender));
    }

    function _mint(address to, uint256 tokenId) internal {
        require(to != address(0), &amp;quot;KIP17: mint to the zero address&amp;quot;);
        require(!_exists(tokenId), &amp;quot;KIP17: token already minted&amp;quot;);

        _tokenOwner[tokenId] = to;
        _ownedTokensCount[to].increment();

        emit Transfer(address(0), to, tokenId);
    }

    function _burn(address owner, uint256 tokenId) internal {
        require(ownerOf(tokenId) == owner, &amp;quot;KIP17: burn of token that is not own&amp;quot;);

        _clearApproval(tokenId);

        _ownedTokensCount[owner].decrement();
        _tokenOwner[tokenId] = address(0);

        emit Transfer(owner, address(0), tokenId);
    }

    function _burn(uint256 tokenId) internal {
        _burn(ownerOf(tokenId), tokenId);
    }

    function _transferFrom(address from, address to, uint256 tokenId) internal {
        require(ownerOf(tokenId) == from, &amp;quot;KIP17: transfer of token that is not own&amp;quot;);
        require(to != address(0), &amp;quot;KIP17: transfer to the zero address&amp;quot;);

        _clearApproval(tokenId);

        _ownedTokensCount[from].decrement();
        _ownedTokensCount[to].increment();

        _tokenOwner[tokenId] = to;

        emit Transfer(from, to, tokenId);
    }

    function _checkOnKIP17Received(address from, address to, uint256 tokenId, bytes memory _data)
        internal returns (bool)
    {
        bool success; 
        bytes memory returndata;

        if (!to.isContract()) {
            return true;
        }

        // Logic for compatibility with ERC721.
        (success, returndata) = to.call(
            abi.encodeWithSelector(_ERC721_RECEIVED, msg.sender, from, tokenId, _data)
        );
        if (returndata.length != 0 &amp;amp;&amp;amp; abi.decode(returndata, (bytes4)) == _ERC721_RECEIVED) {
            return true;
        }

        (success, returndata) = to.call(
            abi.encodeWithSelector(_KIP17_RECEIVED, msg.sender, from, tokenId, _data)
        );
        if (returndata.length != 0 &amp;amp;&amp;amp; abi.decode(returndata, (bytes4)) == _KIP17_RECEIVED) {
            return true;
        }

        return false;
    }

    function _clearApproval(uint256 tokenId) private {
        if (_tokenApprovals[tokenId] != address(0)) {
            _tokenApprovals[tokenId] = address(0);
        }
    }
}

contract KIP17Enumerable is KIP13, KIP17, IKIP17Enumerable {
    mapping(address =&amp;gt; uint256[]) private _ownedTokens;
    mapping(uint256 =&amp;gt; uint256) private _ownedTokensIndex;
    uint256[] private _allTokens;
    mapping(uint256 =&amp;gt; uint256) private _allTokensIndex;

    bytes4 private constant _INTERFACE_ID_KIP17_ENUMERABLE = 0x780e9d63;

    constructor () public {
        _registerInterface(_INTERFACE_ID_KIP17_ENUMERABLE);
    }

    function tokenOfOwnerByIndex(address owner, uint256 index) public view returns (uint256) {
        require(index &amp;lt; balanceOf(owner), &amp;quot;KIP17Enumerable: owner index out of bounds&amp;quot;);
        return _ownedTokens[owner][index];
    }

    function totalSupply() public view returns (uint256) {
        return _allTokens.length;
    }

    function tokenByIndex(uint256 index) public view returns (uint256) {
        require(index &amp;lt; totalSupply(), &amp;quot;KIP17Enumerable: global index out of bounds&amp;quot;);
        return _allTokens[index];
    }

    function _transferFrom(address from, address to, uint256 tokenId) internal {
        super._transferFrom(from, to, tokenId);

        _removeTokenFromOwnerEnumeration(from, tokenId);

        _addTokenToOwnerEnumeration(to, tokenId);
    }

    function _mint(address to, uint256 tokenId) internal {
        super._mint(to, tokenId);

        _addTokenToOwnerEnumeration(to, tokenId);

        _addTokenToAllTokensEnumeration(tokenId);
    }

    function _burn(address owner, uint256 tokenId) internal {
        super._burn(owner, tokenId);

        _removeTokenFromOwnerEnumeration(owner, tokenId);
        _ownedTokensIndex[tokenId] = 0;

        _removeTokenFromAllTokensEnumeration(tokenId);
    }

    function _tokensOfOwner(address owner) internal view returns (uint256[] storage) {
        return _ownedTokens[owner];
    }

    function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private {
        _ownedTokensIndex[tokenId] = _ownedTokens[to].length;
        _ownedTokens[to].push(tokenId);
    }

    function _addTokenToAllTokensEnumeration(uint256 tokenId) private {
        _allTokensIndex[tokenId] = _allTokens.length;
        _allTokens.push(tokenId);
    }

    function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private {
        uint256 lastTokenIndex = _ownedTokens[from].length.sub(1);
        uint256 tokenIndex = _ownedTokensIndex[tokenId];

        if (tokenIndex != lastTokenIndex) {
            uint256 lastTokenId = _ownedTokens[from][lastTokenIndex];

            _ownedTokens[from][tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token
            _ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token&amp;#39;s index
        }

        _ownedTokens[from].length--;
    }

    function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private {
        uint256 lastTokenIndex = _allTokens.length.sub(1);
        uint256 tokenIndex = _allTokensIndex[tokenId];
        uint256 lastTokenId = _allTokens[lastTokenIndex];

        _allTokens[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token
        _allTokensIndex[lastTokenId] = tokenIndex; // Update the moved token&amp;#39;s index
        _allTokens.length--;
        _allTokensIndex[tokenId] = 0;
    }
}

contract KIP17Metadata is KIP13, KIP17, IKIP17Metadata {
    string private _name;
    string private _symbol;

    mapping(uint256 =&amp;gt; string) private _tokenURIs;
    bytes4 private constant _INTERFACE_ID_KIP17_METADATA = 0x5b5e139f;

    constructor (string memory name, string memory symbol) public {
        _name = name;
        _symbol = symbol;

        _registerInterface(_INTERFACE_ID_KIP17_METADATA);
    }

    function name() external view returns (string memory) {
        return _name;
    }

    function symbol() external view returns (string memory) {
        return _symbol;
    }

    function tokenURI(uint256 tokenId) external view returns (string memory) {
        require(_exists(tokenId), &amp;quot;KIP17Metadata: URI query for nonexistent token&amp;quot;);
        return _tokenURIs[tokenId];
    }

    function _setTokenURI(uint256 tokenId, string memory uri) internal {
        require(_exists(tokenId), &amp;quot;KIP17Metadata: URI set of nonexistent token&amp;quot;);
        _tokenURIs[tokenId] = uri;
    }

    function _burn(address owner, uint256 tokenId) internal {
        super._burn(owner, tokenId);

        if (bytes(_tokenURIs[tokenId]).length != 0) {
            delete _tokenURIs[tokenId];
        }
    }
}

contract Ownable {
    address payable private _owner;

    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    constructor () internal {
        _owner = msg.sender;
        emit OwnershipTransferred(address(0), _owner);
    }

    function owner() public view returns (address payable) {
        return _owner;
    }

    modifier onlyOwner() {
        require(isOwner(), &amp;quot;Ownable: caller is not the owner&amp;quot;);
        _;
    }

    function isOwner() public view returns (bool) {
        return msg.sender == _owner;
    }

    function renounceOwnership() public onlyOwner {
        emit OwnershipTransferred(_owner, address(0));
        _owner = address(0);
    }

    function transferOwnership(address payable newOwner) public onlyOwner {
        _transferOwnership(newOwner);
    }

    function _transferOwnership(address payable newOwner) internal {
        require(newOwner != address(0), &amp;quot;Ownable: new owner is the zero address&amp;quot;);
        emit OwnershipTransferred(_owner, newOwner);
        _owner = newOwner;
    }
}

contract MinterRole {
    using Roles for Roles.Role;

    event MinterAdded(address indexed account);
    event MinterRemoved(address indexed account);

    Roles.Role private _minters;

    constructor () internal {
        _addMinter(msg.sender);
    }

    modifier onlyMinter() {
        require(isMinter(msg.sender), &amp;quot;MinterRole: caller does not have the Minter role&amp;quot;);
        _;
    }

    function isMinter(address account) public view returns (bool) {
        return _minters.has(account);
    }

    function addMinter(address account) public onlyMinter {
        _addMinter(account);
    }

    function renounceMinter() public {
        _removeMinter(msg.sender);
    }

    function _addMinter(address account) internal {
        _minters.add(account);
        emit MinterAdded(account);
    }

    function _removeMinter(address account) internal {
        _minters.remove(account);
        emit MinterRemoved(account);
    }
}

contract KIP17Kbirdz is KIP17, KIP17Enumerable, KIP17Metadata, MinterRole {
    mapping (address =&amp;gt; uint256) private _lastCallBlockNumber;
    uint256 private _antibotInterval;

    uint256 private _mintIndexForSale;
    uint256 private _mintLimitPerBlock;           // Maximum purchase nft per person per block
    uint256 private _mintLimitPerSale;            // Maximum purchase nft per person per sale

    string  private _tokenBaseURI;
    uint256 private _mintStartBlockNumber;        // In blockchain, blocknumber is the standard of time.
    uint256 private _maxSaleAmount;               // Maximum purchase volume of normal sale.
    uint256 private _mintPrice;                   // 1 KLAY = 1000000000000000000

    string baseURI;
    string notRevealedUri;
    bool public revealed = false;
    bool public publicMintEnabled = false;

    function _baseURI() internal view returns (string memory) {
      return baseURI;
    }

    function _notRevealedURI() internal view returns (string memory) {
      return notRevealedUri;
    }

    function setBaseURI(string memory _newBaseURI) public onlyMinter {
      baseURI = _newBaseURI;
    }

    function setNotRevealedURI(string memory _newNotRevealedURI) public onlyMinter {
      notRevealedUri = _newNotRevealedURI;
    }

    function reveal(bool _state) public onlyMinter {
      revealed = _state;
    }

    function tokenURI(uint256 tokenId)
      public
      view
      returns (string memory)
    {
      require(
        _exists(tokenId),
        &amp;quot;KIP17Metadata: URI query for nonexistent token&amp;quot;
      );

      if(revealed == false) {
        string memory currentNotRevealedUri = _notRevealedURI();
        return bytes(currentNotRevealedUri).length &amp;gt; 0
            ? string(abi.encodePacked(currentNotRevealedUri, String.uint2str(tokenId), &amp;quot;.json&amp;quot;))
            : &amp;quot;&amp;quot;;
      }
      string memory currentBaseURI = _baseURI();
      return bytes(currentBaseURI).length &amp;gt; 0
          ? string(abi.encodePacked(currentBaseURI, String.uint2str(tokenId), &amp;quot;.json&amp;quot;))
          : &amp;quot;&amp;quot;;
    }

    constructor () public {
      _mintIndexForSale = 1;
    }

    function withdraw() external onlyMinter{
      // This code transfers 5% of the withdraw to JoCoding as a donation.
      // =============================================================================
      0x3e944Ca8B08a0a0D3245B05ABF01586B9142f52C.transfer(address(this).balance * 5 / 100);
      // =============================================================================
      // This will transfer the remaining contract balance to the owner.
      // Do not remove this otherwise you will not be able to withdraw the funds.
      // =============================================================================
      msg.sender.transfer(address(this).balance);
      // =============================================================================
    }

    function mintingInformation() external view returns (uint256[7] memory){
      uint256[7] memory info =
        [_antibotInterval, _mintIndexForSale, _mintLimitPerBlock, _mintLimitPerSale, 
          _mintStartBlockNumber, _maxSaleAmount, _mintPrice];
      return info;
    }

    function setPublicMintEnabled(bool _state) public onlyMinter {
      publicMintEnabled = _state;
    }

    function setupSale(uint256 newAntibotInterval, 
                       uint256 newMintLimitPerBlock,
                       uint256 newMintLimitPerSale,
                       uint256 newMintStartBlockNumber,
                       uint256 newMintIndexForSale,
                       uint256 newMaxSaleAmount,
                       uint256 newMintPrice) external onlyMinter{
      _antibotInterval = newAntibotInterval;
      _mintLimitPerBlock = newMintLimitPerBlock;
      _mintLimitPerSale = newMintLimitPerSale;
      _mintStartBlockNumber = newMintStartBlockNumber;
      _mintIndexForSale = newMintIndexForSale;
      _maxSaleAmount = newMaxSaleAmount;
      _mintPrice = newMintPrice;
    }

    //Public Mint
    function publicMint(uint256 requestedCount) external payable {
      require(publicMintEnabled, &amp;quot;The public sale is not enabled!&amp;quot;);
      require(_lastCallBlockNumber[msg.sender].add(_antibotInterval) &amp;lt; block.number, &amp;quot;Bot is not allowed&amp;quot;);
      require(block.number &amp;gt;= _mintStartBlockNumber, &amp;quot;Not yet started&amp;quot;);
      require(requestedCount &amp;gt; 0 &amp;amp;&amp;amp; requestedCount &amp;lt;= _mintLimitPerBlock, &amp;quot;Too many requests or zero request&amp;quot;);
      require(msg.value == _mintPrice.mul(requestedCount), &amp;quot;Not enough Klay&amp;quot;);
      require(_mintIndexForSale.add(requestedCount) &amp;lt;= _maxSaleAmount + 1, &amp;quot;Exceed max amount&amp;quot;);
      require(balanceOf(msg.sender) + requestedCount &amp;lt;= _mintLimitPerSale, &amp;quot;Exceed max amount per person&amp;quot;);

      for(uint256 i = 0; i &amp;lt; requestedCount; i++) {
        _mint(msg.sender, _mintIndexForSale);
        _mintIndexForSale = _mintIndexForSale.add(1);
      }
      _lastCallBlockNumber[msg.sender] = block.number;
    }

    //Whitelist Mint
    bytes32 public merkleRoot;
    mapping(address =&amp;gt; bool) public whitelistClaimed;
    bool public whitelistMintEnabled = false;

    function setMerkleRoot(bytes32 _merkleRoot) public onlyMinter {
      merkleRoot = _merkleRoot;
    }

    function setWhitelistMintEnabled(bool _state) public onlyMinter {
      whitelistMintEnabled = _state;
    }

    function whitelistMint(uint256 requestedCount, bytes32[] calldata _merkleProof) external payable {
      require(whitelistMintEnabled, &amp;quot;The whitelist sale is not enabled!&amp;quot;);
      require(msg.value == _mintPrice.mul(requestedCount), &amp;quot;Not enough Klay&amp;quot;);
      require(!whitelistClaimed[msg.sender], &amp;#39;Address already claimed!&amp;#39;);
      require(requestedCount &amp;gt; 0 &amp;amp;&amp;amp; requestedCount &amp;lt;= _mintLimitPerBlock, &amp;quot;Too many requests or zero request&amp;quot;);
      bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
      require(MerkleProof.verify(_merkleProof, merkleRoot, leaf), &amp;#39;Invalid proof!&amp;#39;);

      for(uint256 i = 0; i &amp;lt; requestedCount; i++) {
        _mint(msg.sender, _mintIndexForSale);
        _mintIndexForSale = _mintIndexForSale.add(1);
      }

      whitelistClaimed[msg.sender] = true;
    }

    //Airdrop Mint
    function airDropMint(address user, uint256 requestedCount) external onlyMinter {
      require(requestedCount &amp;gt; 0, &amp;quot;zero request&amp;quot;);
      for(uint256 i = 0; i &amp;lt; requestedCount; i++) {
        _mint(user, _mintIndexForSale);
        _mintIndexForSale = _mintIndexForSale.add(1);
      }
    }
}


contract KIP17Full is KIP17, KIP17Enumerable, KIP17Metadata, Ownable, KIP17Kbirdz {
    constructor (string memory name, string memory symbol) public KIP17Metadata(name, symbol) {
        // solhint-disable-previous-line no-empty-blocks
    }
}


contract KIP17MetadataMintable is KIP13, KIP17, KIP17Metadata, MinterRole {
    bytes4 private constant _INTERFACE_ID_KIP17_METADATA_MINTABLE = 0xfac27f46;

    constructor () public {
        _registerInterface(_INTERFACE_ID_KIP17_METADATA_MINTABLE);
    }

    function mintWithTokenURI(address to, uint256 tokenId, string memory tokenURI) public onlyMinter returns (bool) {
        _mint(to, tokenId);
        _setTokenURI(tokenId, tokenURI);
        return true;
    }
}


contract KIP17Mintable is KIP17, MinterRole {
    bytes4 private constant _INTERFACE_ID_KIP17_MINTABLE = 0xeab83e20;

    constructor () public {
        _registerInterface(_INTERFACE_ID_KIP17_MINTABLE);
    }

    function mint(address to, uint256 tokenId) public onlyMinter returns (bool) {
        _mint(to, tokenId);
        return true;
    }
}

contract KIP17Burnable is KIP13, KIP17 {
    bytes4 private constant _INTERFACE_ID_KIP17_BURNABLE = 0x42966c68;

    constructor () public {
        _registerInterface(_INTERFACE_ID_KIP17_BURNABLE);
    }

    function burn(uint256 tokenId) public {
        //solhint-disable-next-line max-line-length
        require(_isApprovedOrOwner(msg.sender, tokenId), &amp;quot;KIP17Burnable: caller is not owner nor approved&amp;quot;);
        _burn(tokenId);
    }
}

contract PauserRole {
    using Roles for Roles.Role;

    event PauserAdded(address indexed account);
    event PauserRemoved(address indexed account);

    Roles.Role private _pausers;

    constructor () internal {
        _addPauser(msg.sender);
    }

    modifier onlyPauser() {
        require(isPauser(msg.sender), &amp;quot;PauserRole: caller does not have the Pauser role&amp;quot;);
        _;
    }

    function isPauser(address account) public view returns (bool) {
        return _pausers.has(account);
    }

    function addPauser(address account) public onlyPauser {
        _addPauser(account);
    }

    function renouncePauser() public {
        _removePauser(msg.sender);
    }

    function _addPauser(address account) internal {
        _pausers.add(account);
        emit PauserAdded(account);
    }

    function _removePauser(address account) internal {
        _pausers.remove(account);
        emit PauserRemoved(account);
    }
}

contract Pausable is PauserRole {
    event Paused(address account);
    event Unpaused(address account);

    bool private _paused;

    constructor () internal {
        _paused = false;
    }

    function paused() public view returns (bool) {
        return _paused;
    }

    modifier whenNotPaused() {
        require(!_paused, &amp;quot;Pausable: paused&amp;quot;);
        _;
    }

    modifier whenPaused() {
        require(_paused, &amp;quot;Pausable: not paused&amp;quot;);
        _;
    }

    function pause() public onlyPauser whenNotPaused {
        _paused = true;
        emit Paused(msg.sender);
    }

    function unpause() public onlyPauser whenPaused {
        _paused = false;
        emit Unpaused(msg.sender);
    }
}

contract KIP17Pausable is KIP13, KIP17, Pausable {
    bytes4 private constant _INTERFACE_ID_KIP17_PAUSABLE = 0x4d5507ff;

    constructor () public {
        _registerInterface(_INTERFACE_ID_KIP17_PAUSABLE);
    }

    function approve(address to, uint256 tokenId) public whenNotPaused {
        super.approve(to, tokenId);
    }

    function setApprovalForAll(address to, bool approved) public whenNotPaused {
        super.setApprovalForAll(to, approved);
    }

    function transferFrom(address from, address to, uint256 tokenId) public whenNotPaused {
        super.transferFrom(from, to, tokenId);
    }
}

contract KIP17KbirdzToken is KIP17Full, KIP17Mintable, KIP17MetadataMintable, KIP17Burnable, KIP17Pausable {
    constructor (string memory name, string memory symbol) public KIP17Full(name, symbol) {
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>프로그래밍/Solitidy</category>
      <category>Blockchain</category>
      <category>Klaythn</category>
      <category>nft</category>
      <category>Solitity</category>
      <category>블록체인</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/195</guid>
      <comments>https://jhansol.tistory.com/195#entry195comment</comments>
      <pubDate>Mon, 22 Jan 2024 23:26:34 +0900</pubDate>
    </item>
    <item>
      <title>기록 보관 : Drupal 7 Search Api 일괄처리 오류로 인한 성능의 급격한 저하</title>
      <link>https://jhansol.tistory.com/194</link>
      <description>&lt;p&gt;저의 개인 홈페이지에 있던 내용을 옮겨 기록하고자 합니다. 제가 게으른 탓에 활용하기 힘들고 유지를 하기에 금전적으로도 낭비다 생각되어 님길 것은 남기고, 버릴 것은 버리고 사이트를 없에기 위함입니다. 기존 내용 그대로 아래와 같이 옮겼습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;오늘(2022년 9월 24일) 나는 어재 오후부터 지옥을 해마다 나온 듯 하다.&lt;/p&gt;
&lt;p&gt;회사에서 유지보수 및 서버 운영을 하고 있는 GCEDClearinghouse 홈페이지가 매우 심학하게 느려지고,  운영사에서도 항의성 전화가 오고 원인을 찾느라고 지금까지 해매고 겨우 원인을 찾아 임시방편이지만 해결했다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;htop 명령으로 서버 상태 확인&lt;/li&gt;
&lt;li&gt;MySQL Slow Query 확인&lt;/li&gt;
&lt;li&gt;의심 원인 확인&lt;/li&gt;
&lt;li&gt;인터넷 검색&lt;/li&gt;
&lt;li&gt;해결&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;htop 명령으로 서버 상태 확인&lt;/h1&gt;
&lt;p&gt;와래와 같이 htop 명령을 실행하여 확인해보니 서버 가용 자원이 거의 남아 있지 않다. 서버의 평균 부하율을 보면 거의 300%에 도달해 있고, 메모리도 1GB 정도 밖에 남아 있지 않다. 그 중에서도 mysqld가 최 상위에 있다. 이건 mysql 쿼리 실행으로 시스템에 부하를 주고 있다는 반증이라 생각된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;htop&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmLnIF/btsDJUn8IFq/8bpMCHxm64UUlQRocOsS4K/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmLnIF/btsDJUn8IFq/8bpMCHxm64UUlQRocOsS4K/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmLnIF/btsDJUn8IFq/8bpMCHxm64UUlQRocOsS4K/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmLnIF%2FbtsDJUn8IFq%2F8bpMCHxm64UUlQRocOsS4K%2Fimg.jpg&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;MySQL Slow Query 실행 여부&lt;/h1&gt;
&lt;p&gt;현재 이 서버는 MariaDB 10.1.34를 이용하고 있다. MariaDB는 MySQL 호황되고 심지어 my.conf 파일의 설정이 매우 유사하다. Slow Query 설정은 두 DB 모두 동일하다. 그래서 급하게나마 아래와 같이 설정하고 데이터베이스를 재실행했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;slow_query_log          = 1
slow_query_log_file     = /var/log/mysql/mariadb-slow.log
long_query_time = 5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 아래와 같은 Slow Query 로그를 확인할 수 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# User@Host: gced[gced] @ localhost []
# Thread_id: 2348  Schema: gcedclearinghouse_org  QC_hit: No
# Query_time: 32.410811  Lock_time: 0.000109  Rows_sent: 9267660  Rows_examined: 9267660
# Rows_affected: 0
SET timestamp=1663959260;
SELECT t.*
FROM
`search_api_task` t
WHERE  (t.type IN  (&amp;#39;addIndex&amp;#39;, &amp;#39;fieldsUpdated&amp;#39;, &amp;#39;removeIndex&amp;#39;, &amp;#39;deleteItems&amp;#39;)) AND (t.server_id = &amp;#39;inner_solr&amp;#39;)
ORDER BY t.id ASC;&lt;/code&gt;&lt;/pre&gt;&lt;h1&gt;의심 원인 확인&lt;/h1&gt;
&lt;p&gt;위 내용을 보면 쿼리 실행에 걸린 시간이 32초, 평가 대상 레코드 9,267,660건, 전송 레코드 역시 동일한 수로 표시된다. 확인해보니 아래와 같이 search_api_task 테이블에 비정상 데이터가 이렇게나 많이 쌓여 있는 것이다.  아래의 내용은 운영서버의 것은 아니지만 이런 식으로 쌓여 있었다. 운영서버는 이것 보다 엄청 심하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mysql&amp;gt; select * from search_api_task limit 10;
+--------+-----------+-------------+--------------------------+------------------------+
| id     | server_id | type        | index_id                 | data                   |
+--------+-----------+-------------+--------------------------+------------------------+
| 532555 | solr      | deleteItems | files_for_download_index | a:1:{i:0;s:5:&amp;quot;11705&amp;quot;;} |
| 532556 | solr      | deleteItems | products_index           | a:1:{i:0;s:5:&amp;quot;11673&amp;quot;;} |
| 532557 | solr      | deleteItems | files_for_download_index | a:1:{i:0;s:5:&amp;quot;11673&amp;quot;;} |
| 532558 | solr      | deleteItems | products_index           | a:1:{i:0;s:5:&amp;quot;11706&amp;quot;;} |
| 532559 | solr      | deleteItems | files_for_download_index | a:1:{i:0;s:5:&amp;quot;11706&amp;quot;;} |
| 532560 | solr      | deleteItems | products_index           | a:1:{i:0;s:5:&amp;quot;11707&amp;quot;;} |
| 532561 | solr      | deleteItems | files_for_download_index | a:1:{i:0;s:5:&amp;quot;11707&amp;quot;;} |
| 532562 | solr      | deleteItems | products_index           | a:1:{i:0;s:5:&amp;quot;11708&amp;quot;;} |
| 532563 | solr      | deleteItems | files_for_download_index | a:1:{i:0;s:5:&amp;quot;11708&amp;quot;;} |
| 532564 | solr      | deleteItems | products_index           | a:1:{i:0;s:5:&amp;quot;11709&amp;quot;;} |
+--------+-----------+-------------+--------------------------+------------------------+&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 테이블은 Drupal 기여 모듈인 Search Api 모듈의 검색엔진을 위한 색인작업(일괄처리) 정보를 저장하고 있는 것으로 작업이 끝난 경우 해당 작업정보가 삭제되어야 마땅하지만 그대로 남아 쌓이고 있었던 것이다.  이 모듈의 오류로 생각하고 인터넷을 검색해보니 역시 그렇다.&lt;/p&gt;
&lt;h1&gt;인터넷 검색&lt;/h1&gt;
&lt;p&gt;인터넷을 검색해보니 아래의 페이지에 나와 동일한 사례가 나왔다.  이 페이지의 내용을 보면 Search Api 모듈의 오류로 확신하며, 색인 작업 실페로 인해 작업은 중단되고 끝나지 않은 작업정보는 위와 같이 남게 된 것이다. 이렇게 쌓아고 쌓여 시스템에 엄청난 성능 저하를 주고 있었는데, 지금까지 모르고 있었다. Drupal은 원래 좀 무거워서 그런 것이겠거니 생각했다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.drupal.org/project/search_api/issues/2408727&quot;&gt;https://www.drupal.org/project/search_api/issues/2408727&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;해결&lt;/h1&gt;
&lt;p&gt;위에서는 임시 방편으로 해결을 했다고 했는데, 사실 이 방법 밖에 없는 것은 아닌가 하는 생각을 위 사이트 내용을 보고 하게 되었다. 작업이 끝나면 더이상 데이터베이스에 저장된 작업정보는 필요가 없다. 그러므로 문제가 되는 비정상 작업정보를 삭제해도 데이터 무결성에는 영향을 주지 않을 것이다. 모듈의 버그 수정이 가장 최선이겠지만 운영서버를 가지고 수정과 테스트 작업을 하기에는 무리가 있다. 그래서 주기적으로 해당 정보를 지우는 Cron 작업을 하도록 해야겠다.&lt;/p&gt;
&lt;p&gt;오늘은 아래와 같이 테이블 데이터 초기화하는 것으로 해결하고 마무리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MariaDB [(none)]&amp;gt; truncate table search_api_task;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>웹 개발관련/서버</category>
      <category>Drupal</category>
      <category>Drupal 7.x</category>
      <category>mariadb</category>
      <category>MYSQL</category>
      <category>php</category>
      <category>Search Api</category>
      <category>데이터베이스</category>
      <author>jhansol</author>
      <guid isPermaLink="true">https://jhansol.tistory.com/194</guid>
      <comments>https://jhansol.tistory.com/194#entry194comment</comments>
      <pubDate>Mon, 22 Jan 2024 23:04:27 +0900</pubDate>
    </item>
  </channel>
</rss>