대상: web1(192.168.56.41), web(192.168.56.42)
이번엔 실제 서비스가 동작하는 웹 서버 계층을 구성해본다. Apache httpd + PHP로 REST API 서버를 구성하고, NFS에서 코드를 공유하여 web1/web2가 동일한 애플리케이션을 제공하도록 한다.
DB 연결은 직접 db1 IP가 아닌 내부 LB(192.168.56.30)를 통해 이뤄져 Failover가 적용된다.
이번에는 구성할 항목이 많아 미리 표를 통해 알아본다. (제법 긴 호흡이 될 거다.)
| 항목 | 내용 |
| web1 IP | 192.168.56.41 |
| web2 IP | 192.168.56.42 |
| 웹 서버 | Apache httpd |
| 백엔드 언어 | PHP 8.1 (AppStream 기본) |
| NFS 소스 | storage.sesac.cloud.com:/srv/nfs/webroot (DNS 전환 후) |
| NFS 마운트 대상 | /var/www/html |
| DB 엔드포인트 | lb-internal.sesac.cloud.com:3306 (DNS 전환 후) |
| DB 이름 | saa_db |
| DB 계정 | saauser / qwer1234 |
| 인증 방식 | JWT (HS256, firebase/php-jwt) |
나도 안써본 지난 번에 한번 써본 PHP로 백엔드를 구성해봤다.
(아마 AWS로 Migration 할 때는 Python을 사용하는 프레임워크를 써볼 거 같다)
AI의 도움을 받아 이 부분은 구현을 했다.
VM 메모리 증설
사실 초기 Vagrantfile에서 web1, web2 구성을 할 때 기본 메모리로 1024MB를 부여했다.
하지만, dnf install 중 OOM(Out of Memory) Killer가 프로세스를 강제 종료하는 것을 확인해 2048MB로 증설 후 재시작했다.
- dnf install 중 Killed 메시지 출력 후 프로세스 종료.
- 원인: /var/log/messages에서 Out of memory: Killed process (dnf) 확인
VM 부팅
vagrant up web1 web2
이후 설정은 web1, web2 모두 동일하게 수행한다. 애플리케이션 배포(코드 업로드, Composer 설치) 섹션만 web1에서 1회 실행하면 NFS를 통해 web2에도 공유된다.
| 섹션 | web1 | web2 |
| 패키지 설치 | ✓ | ✓ |
| NFS 마운트 | ✓ | ✓ |
| SELinux 설정 | ✓ | ✓ |
| 방화벽 설정 | ✓ | ✓ |
| VirtualHost 설정 | ✓ | ✓ |
| 애플리케이션 배포 | ✓ | NFS 자동 공유 |
패키지 설치
1] Apache httpd + NFS 클라이언트 + MySQL 클라이언트
sudo dnf install -y httpd nfs-utils mysql
2] PHP 8.1 (AppStream)
sudo dnf module reset php -y
sudo dnf module enable php:8.1 -y
sudo dnf install -y php php-mysqlnd php-pdo php-json php-mbstring php-cli
# PHP 버전 확인
php -v
| 확장 | 용도 |
| php-mysqlnd | PDO용 Native MySQL 드라이버 |
| php-pdo | DB 추상화 레이어. DB 종류에 관계없이 동일한 코드로 쿼리 실행 가능 |
| php-json | JSON 인코딩/디코딩 (API 응답) |
| php-mbstring | 멀티바이트 문자열 (문제 텍스트 한글) |
| php-cli | Composer 실행에 필요 |
Composer?
- PHP의 패키지 매니저로, Node.js의 npm, Python의 pip와 같은 역할
- composer.json에 필요한 라이브러리 목록을 적어 다운 + 버전 관리까지 가능함.
- 이 프로젝트에서는 JWT 라이브러리를 설치하려고 사용한다.
- sudo composer로 실행 시 command not found 오류 발생
- sudo 환경의 PATH에 /usr/local/bin이 포함되지 않아, composer 실행 시 sudo /user/local/bin/composer 절대 경로를 사용한다.
curl -sS https://getcomposer.org/installer -o /tmp/composer-setup.php
sudo php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer
composer --version
# Composer version 2.9.5
NFS 마운트
1] IP로 마운트 (DNS 전환 전)
# 수동 마운트 테스트
sudo mount -t nfs 192.168.56.10:/srv/nfs/webroot /var/www/html
mount | grep nfs
df -h /var/www/html
# fstab 영구 설정 — IP로 등록
echo "192.168.56.10:/srv/nfs/webroot /var/www/html nfs defaults,_netdev,rw,soft,timeo=30 0 0" \
| sudo tee -a /etc/fstab
2] 도메인으로 변경(DNS 전환 후)
DNS 설정을 가장 마지막에 할 텐데, DNS 설정이 완료되면 fstab의 IP를 도메인명으로 교체한다. 도메인을 사용하면 storage 서버의 IP가 변해도 fstab을 수정할 필요가 없다.
sudo sed -i 's|192.168.56.10:/srv/nfs/webroot|storage.sesac.cloud.com:/srv/nfs/webroot|' /etc/fstab
# 변경 확인
grep nfs /etc/fstab
# storage.sesac.cloud.com:/srv/nfs/webroot /var/www/html nfs defaults,_netdev,rw,soft,timeo=30 0 0
# 재마운트로 적용
sudo umount /var/www/html
sudo mount -a
각 옵션의 의미는 아래와 같다.
- _netdev는 Database 구성 편에서 설명했으므로 자세한 설명은 생략한다.
| 옵션 | 의미 |
| _netdev | 네트워크 활성화 후 마운트 (부팅 순서 보장) |
| rw | 읽기/쓰기 |
| soft | NFS 서버 비응답 시 에러 반환 (무한 대기 방지) |
| timeo=30 | 재시도 전 3초 대기 (1/10초 단위) |
SELinux 설정
sudo setsebool -P httpd_can_network_connect 1
sudo setsebool -P httpd_use_nfs 1
# 확인
getsebool httpd_can_network_connect
getsebool httpd_use_nfs
# httpd_can_network_connect --> on
# httpd_use_nfs --> on
- httpd_can_network_connect
- 앞에서도 등장했는데(LB Nginx), httpd가 외부 서버에 TCP 연결하는 것을 허용하기 위함이다.
- 내부 LB로의 DB 연결에 필요하다.
- httpd_use_nfs
- httpd가 NFS 마운트 디렉터리를 읽을 수 있도록 허용한다.
- 미설정 시 /var/www/html이 NFS로 마운트 되어 있어도 Apache가 파일을 읽지 못해 403 Status code가 반환된다.
방화벽 설정
http, https로의 접근을 허용한다.
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
Apache httpd VirtualHost 설정
[세부 코드 구현의 경우 AI의 도움을 많이 받았다. 내용을 우선으로 확인해보자]
Apache는 하나의 서버에서 여러 웹사이트를 운영할 수 있다. 예를 들어 IP는 같아도 a.com으로 오면 A 사이트, b.com으로 오면 B 사이트를 보여주는 것과 같다. 이 기능을 가능하게 하는 것이 VirtualHost 기능이다.
즉 VirtualHost가 여러 개일 때는 ServerName을 보고 어느 블록이 요청을 처리할지 판단한다.
<VirtualHost *:80>
ServerName sesac.cloud.com ← sesac.cloud.com으로 오면 여기
</VirtualHost>
<VirtualHost *:80>
ServerName other.cloud.com ← other.cloud.com으로 오면 여기
</VirtualHost>
이 프로젝트는 사이트가 하나뿐이다. Apache 기본 설정만으로도 동작은 하지만, .htaccess로 mod_rewrite를 사용하려면 AllowOverride All이 필요하다. 기본 설정 파일을 직접 수정하는 것보다 VirtualHost 파일을 별도로 만드는 게 관리가 편하므로 saa.conf로 분리했다.
- mod_rewrite?
- REST API는 URL이 /api/questions, /api/login 처럼 생겼지만 실제 파일은 questions.php, auth.php 다.
- 브라우저가 /api/questions를 요청하면 해당 파일이 없으므로 Apache는 기본적으로 404를 반환한다.
- mod_rewrite는 중간에서 URL을 가로채 실제 파일 경로로 연결해준다.
- .htaccess에서 규칙을 정의한다.
1] welcome.conf 비활성화
sudo mv /etc/httpd/conf.d/welcome.conf /etc/httpd/conf.d/welcome.conf.bak
Rocky Linux의 Apache는 기본으로 welcome.conf가 활성화되어 있다. 이게 있으면 모든 요청을 기본 환영 페이지로 가로챈다. 직접 만든 VirtualHost가 동작하려면 비활성화해야 한다. 삭제하지 않고 .bak으로 이름을 바꾸는 건 나중에 복구할 수 있게 하기 위해서다.
2] 설정 파일 생성
sudo tee /etc/httpd/conf.d/saa.conf << 'EOF'
<VirtualHost *:80>
ServerName sesac.cloud.com
DocumentRoot /var/www/html
<Directory /var/www/html>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
DirectoryIndex index.php index.html
ErrorLog /var/log/httpd/saa_error.log
CustomLog /var/log/httpd/saa_access.log combined
</VirtualHost>
EOF
각 코드에 대한 설명은 아래와 같다.
| <VirtualHost *:80> | 모든 IP의 80번 포트로 오는 요청을 이 블록에서 처리한다. |
| ServerName | 이 VirtualHost가 담당하는 도메인. VirtualHost가 여러 개일 때 요청을 어느 블록으로 넘길지 판단하는 기준이 된다. |
| DocumentRoot | 웹 루트 디렉터리. 브라우저가 /에 접속하면 이 경로 안에서 파일을 찾는다. NFS로 마운트된 경로가 여기다. |
| Options -Indexes | 디렉터리 목록 노출 비활성화. index.php가 없을 때 파일 목록이 외부에 보이는 것을 방지한다. |
| Options +FollowSymLinks | 심볼릭 링크 허용. mod_rewrite 동작에 필요하다. |
| AllowOverride All | .htaccess 파일로 Apache 설정 재정의를 허용한다. 이게 없으면 .htaccess 자체가 무시된다. |
| Require all granted | 모든 클라이언트의 접근 허용 |
| DirectoryIndex | 루트 URL 접속 시 찾을 파일 순서. index.php를 먼저 찾고 없으면 index.html을 찾는다. |
| ErrorLog | 서버 오류 로그 경로. 500 에러 등 발생 시 여기서 확인한다. |
| CustomLog | 모든 HTTP 요청 기록 경로. 트러블슈팅 시 먼저 확인한다. |
.htaccess에는 아래와 같은 규칙들이 들어간다.
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f # 실제 파일이 아닐 때
RewriteCond %{REQUEST_FILENAME} !-d # 실제 디렉터리가 아닐 때
RewriteRule ^api/(.*)$ api/$1.php [L] # /api/questions → api/questions.php로 연결
이 규칙이 동작하려면 Apache가 .htaccess를 읽어야 하는데, AllowOverride All이 없으면 .htaccess 자체를 무시한다.
요청 처리 흐름을 정리하면 아래와 같다.
브라우저: GET /api/questions
↓
Apache: /var/www/html/api/questions 파일 없음
↓
AllowOverride All → .htaccess 읽음
↓
mod_rewrite: /api/questions → api/questions.php로 연결
↓
questions.php 실행 → JSON 응답 반환
근데 위 설정 파일에서 조금 이상한 점이 있다. 아직 나는 DNS를 설정한 적이 없는데?
DNS 전환 전 ServerName 동작
현재 DNS가 없는 상태다. ServerName sesac.cloud.com이 설정되어 있지만 IP로 접근해도 정상 동작한다.
VirtualHost가 하나뿐이면 Apache는 ServerName을 무시하고 유일한 VirtualHost 블록이 모든 요청을 처리한다.
[독박을 쓴다]
비교할 대상이 없으므로 요청이 IP로 왔든 도메인으로 왔뜬 그냥 받는다.
Servername을 도메인으로 미리 써둔 건 DNS 전환 후에도 설정을 수정할 필요가 없도록 하기 위함이다.
sudo apachectl configtest
# AH00558: Could not reliably determine ... (무시 가능)
# Syntax OK
sudo systemctl enable --now httpd
ss -tlnp | grep ':80'
- Could not reliably determine the server's fully qualified domain name 경고는 Apache가 ServerName을 DNS에서 확인할 수 없을 때 나타난다. 동작에는 영향 없으며 DNS 설정 완료 후 사라진다.
DB 스키마 구성
1] questions/choices 테이블 생성
이전 saa_db 데이터베이스만 생성했으므로 테이블은 별도로 생성해야 한다. db1에 직접 접속하여 생성한다. db1에서 생성하면 Replication으로 db2에 반영된다.
sudo mysql -u root -p'비밀번호' saa_db
CREATE TABLE questions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
question TEXT NOT NULL,
explanation TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE choices (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
question_id INT UNSIGNED NOT NULL,
label CHAR(1) NOT NULL,
content TEXT NOT NULL,
is_answer TINYINT(1) NOT NULL DEFAULT 0,
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2] 기출문제 데이터 입력
insert_questions.sql을 호스트 머신에서 db1으로 전송 후 실행한다.
# 호스트 머신에서
vagrant upload insert_questions.sql /tmp/insert_questions.sql db1
# db1에서
sudo mysql -u root -p'qwer1234' saa_db < /tmp/insert_questions.sql
3] 입력 결과 확인
sudo mysql -u root -p'qwer1234' saa_db \
-e "SELECT COUNT(*) FROM questions; SELECT COUNT(*) FROM choices;"
# questions: 724개
# choices: 2986개
4] 사용자 테이블 생성
db1에 직접 연결하여 생성하면 Replication으로 db2에 반영된다. Failover시 db2가 동일한 스키마를 가지고 있어야 서비스가 동작하므로 db2를 확인해본다.
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_answers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
question_id INT UNSIGNED NOT NULL,
selected_label CHAR(1) NOT NULL,
is_correct TINYINT(1) NOT NULL DEFAULT 0,
answered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_wrong (user_id, is_correct),
INDEX idx_user_question (user_id, question_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_flags (
user_id INT UNSIGNED NOT NULL,
question_id INT UNSIGNED NOT NULL,
flagged_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, question_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_tips (
user_id INT UNSIGNED NOT NULL,
question_id INT UNSIGNED NOT NULL,
tip_text TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, question_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
5] db2 Replication 확인
우리가 생성한 테이블들이 나오면 된다.
mysql -h 192.168.56.22 -u root -p'비밀번호' saa_db -e "SHOW TABLES;"
애플리케이션 배포
1] 파일 전송
호스트 머신에서 varant upload로 각 파일을 NFS 마운트 경로에 직접 업로드한다. web1에서 1회만 실행하면 NFS에 저장되므로 web2에도 자동으로 반영이 된다.
- 각 파일의 경우 개요 부분에 있다.(2026-02-26 업데이트 예정)
# 호스트 머신에서
vagrant upload webroot/index.php /var/www/html/index.php web1
vagrant upload webroot/.htaccess /var/www/html/.htaccess web1
vagrant upload webroot/config/db.php /var/www/html/config/db.php web1
vagrant upload webroot/config/jwt.php /var/www/html/config/jwt.php web1
vagrant upload webroot/middleware/auth_check.php /var/www/html/middleware/auth_check.php web1
vagrant upload webroot/api/auth.php /var/www/html/api/auth.php web1
vagrant upload webroot/api/questions.php /var/www/html/api/questions.php web1
vagrant upload webroot/api/submit.php /var/www/html/api/submit.php web1
vagrant upload webroot/api/wrong.php /var/www/html/api/wrong.php web1
vagrant upload webroot/api/flags.php /var/www/html/api/flags.php web1
vagrant upload webroot/api/tips.php /var/www/html/api/tips.php web1
2] DB 비밀번호 생성
db.php 파일에 처음에는 DB_PASS -> password로 정의되어 있던 부분을 나에 맞게 수정하면 된다.
- 단, 실제 배포 환경에서는 비밀번호와 같은 정보를 코드에 쓰지 않고 환경 변수로 분리한다.
- (Vagrant 로컬 환경)실습 환경이므로 편의상 코드에 직접 작성했지만, 실무에서는 환경 변수나 .env로 분리해야 한다.
# web1에서
sudo sed -i "s/define('DB_PASS', 'password')/define('DB_PASS', '비밀번호')/" \
/var/www/html/config/db.php
grep DB_PASS /var/www/html/config/db.php
# define('DB_PASS', 'qwer1234');
3] JWT 라이브러리 설정
# web1에서 (NFS에 설치 → web2 자동 공유)
cd /var/www/html
sudo /usr/local/bin/composer require firebase/php-jwt
ls /var/www/html/vendor/firebase/php-jwt
4] 파일 권한 설정
NFS와 마찬가지로 vagrant upload시 파일 소유자가 vagrant가 되므로 apache 계정으로 바꿔준다.
sudo chown -R apache:apache /var/www/html
연결 / 동작 테스트
1] PHP -> DB 연결 테스트
php -r "
\$pdo = new PDO('mysql:host=192.168.56.30;port=3306;dbname=saa_db', 'saauser', 'qwer1234');
echo \$pdo->query('SELECT COUNT(*) FROM questions')->fetchColumn() . ' questions' . PHP_EOL;
"
# 724 questions
DNS 전환 후에는 db.php의 호스트를 도메인으로 교체한다.
sudo sed -i "s/192.168.56.30/lb-internal.sesac.cloud.com/" /var/www/html/config/db.php
# 변경 확인
grep DB_HOST /var/www/html/config/db.php
# define('DB_HOST', 'lb-internal.sesac.cloud.com');
2] API 테스트
# 회원가입
curl -s -X POST http://192.168.56.41/api/register \
-H 'Content-Type: application/json' \
-d '{"username":"testuser","password":"Test1234!","email":"test@test.com"}'
# {"message":"회원가입이 완료되었습니다"}
# 로그인 + 토큰 저장
TOKEN=$(curl -s -X POST http://192.168.56.41/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"testuser","password":"Test1234!"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 문제 목록 (총 724개, 20개씩 페이지네이션)
curl -s http://192.168.56.41/api/questions -H "Authorization: Bearer $TOKEN"
# 단일 문제 + 보기 + 사용자 상태
curl -s http://192.168.56.41/api/questions/1 -H "Authorization: Bearer $TOKEN"
# 답안 제출 및 채점
curl -s -X POST http://192.168.56.41/api/submit \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"question_id":1,"selected_label":"A"}'
# {"is_correct":true,...}
3] 무상태 JWT 검증
JWT는 토큰 자체에 사용자 정보와 서명이 포함되어 있어 서버가 세션 저장소를 조회하지 않고 토큰만 검증한다.
- 서명 키(jwt.php)가 NFS로 공유되므로 web1과 web2가 동일한 키로 검증할 수 있다.
- 따라서 어느 서버로 요청이 가도 동일하게 동작한다.
web1에서 발급한 토큰을 web2에서 그대로 사용할 수 있어야 한다.
# web1에서 — web2 IP로 요청
curl -s http://192.168.56.42/api/questions/1 -H "Authorization: Bearer $TOKEN"
# web1과 동일한 응답 반환 → 무상태 JWT 정상 동작 확인
프론트엔드 배포
index.html은 내부에서 API를 도메인명(sesac.cloud.com)으로 호출한다. DNS 전환 전에 배포하면 도메인이 해석되지 않아 API 연결이 실패한다. DNS 전환 완료 후 배포한다.
→ DNS 설정 단계 완료 후 이어서 진행한다.
최종 상태 요약
| 항목 | 상태 | 비고 |
| httpd 설치 | 완료 | web1, web2 |
| PHP 8.1 (AppStream) 설치 | 완료 | php-mysqlnd, pdo, json, mbstring |
| NFS /var/www/html 마운트 | 완료 | fstab _netdev, IP로 설정 |
| SELinux httpd_can_network_connect | on | DB → 내부 LB 연결 |
| SELinux httpd_use_nfs | on | NFS 마운트 webroot 읽기 |
| 방화벽 HTTP/HTTPS 포트 | 열림 | 80/tcp, 443/tcp |
| VirtualHost saa.conf | 완료 | AllowOverride All |
| questions/choices 테이블 생성 | 완료 | db1 직접 생성 |
| 기출문제 데이터 입력 | 완료 | 724문제, 2986보기 |
| 사용자 테이블 생성 | 완료 | db2 복제 전파 확인 |
| Composer + firebase/jwt | 완료 | NFS 공유, web2 자동 사용 |
| 애플리케이션 코드 배포 | 완료 | web1 배포 → web2 자동 공유 |
| index.html (프론트엔드) 배포 | 대기 | DNS 전환 후 진행 |
| API 엔드포인트 동작 | 확인 | 회원가입, 로그인, 문제조회, 채점 |
| 무상태 JWT 동작 | 확인 | web1 토큰 → web2 정상 처리 |
다음 단계에서는 Load Balancer(External, 192.168.56.50)에 Nginx L7 로드밸런서를 구성하고, web1/web2로 트래픽을 분산한다.
'Infra > Project' 카테고리의 다른 글
| [세미 프로젝트] AWS SAA 기출문제 풀이 플랫폼 - DNS 서버 (0) | 2026.02.26 |
|---|---|
| [세미 프로젝트] AWS SAA 기출문제 풀이 플랫폼 - LoadBalancer(External) 구성 (0) | 2026.02.25 |
| [세미 프로젝트] AWS SAA 기출문제 풀이 플랫폼 - LoadBalancer(internal) 구성 (0) | 2026.02.25 |
| [세미 프로젝트] AWS SAA 기출문제 풀이 플랫폼 - DB 서버 (0) | 2026.02.25 |
| [세미 프로젝트] AWS SAA 기출문제 풀이 플랫폼 - Storage 서버 (0) | 2026.02.25 |