EP.1 링크 단축 서비스 (3) — 서버측 코드 작성

잇(IT)! 가이드
EP.1 링크 단축 서비스 (3) — 서버측 코드 작성
잇(IT)! 가이드는 IT 프로젝트를 따라해보실 수 있도록 하는 가이드입니다. 여러 프로젝트를 단계별로 설명드리며, 누구나 쉽게 따라할 수 있도록 설명해 드립니다.

 

본 포스팅은 이전 게시물과 이어지는 내용입니다. 아래 게시글을 먼저 확인해 보시기를 추천드립니다.

2024.05.16 - [잇(IT)! 가이드] - EP.1 링크 단축 서비스 (2) — Vercel 연결 및 데이터베이스 구축

 

EP.1 링크 단축 서비스 (2) — Vercel 연결 및 데이터베이스 구축

잇(IT)! 가이드EP.1 링크 단축 서비스 (2) — Vercel 연결 및 데이터베이스 구축잇(IT)! 가이드는 IT 프로젝트를 따라해보실 수 있도록 하는 가이드입니다. 여러 프로젝트를 단계별로 설명드리며, 누구

scian.xyz

 

서버 측 코드 작성 준비

이전 포스팅에서 우리는 Vercel을 통해 무료 Postgres (Postgresql) 데이터베이스를 구축하였다.

이제, 이 데이터베이스를 실제 동작이 가능한 링크 처리 서버와 연결하여 링크와 로그 정보를 데이터베이스에 저장하고, 불러오며 통신이 가능하도록 할 것이다.

 

NPM 패키지 설치

서버에 필요한 패키지를 설치해 준다.

아래 명령어를 작업 폴더의 터미널에서 실행해 주면 된다.

npm i express cors express-useragent request-ip serve-favicon dotenv pg
npm i @vercel/postgres

 

여기서 각 라이브러리의 기능은 다음과 같다.

  • express: API 서버 구축 (정적 호스팅)
  • cors: CORS 관련 문제를 쉽게 해결하기 위해 특정 조건을 허용하기 위해 사용
  • express-useragent: 접속 클라이언트 정보를 로깅하고, 클라이언트별 리디렉션을 위해 사용
  • request-ip: 접속 클라이언트의 IP주소를 로깅하기 위해 사용
  • serve-favicon: favicon 이미지를 안정적으로 로드하기 위해 사용
  • dotenv: 비밀 정보(내부 API 키, 관리자 비번 등)를 환경변수로 안전하게 관리하기 위해 사용
  • pg, @vercel/postgres: Postgres 데이터베이스 연결을 위해 사용

 

app.js 작성

app.js 파일에 아래 node.js 코드를 입력해 준다.

최종 코드는 아래에 있으며, 우선 각 부분별로 간단한 설명을 함께 제공하겠다.

패키지 import 및 정의

36번줄까지는 패키지와 서버에 필요한 항목을 정의하며, authenticate 함수에서 관리자 인증 기능을 하나의 함수로 통합해 구현하였다.

또한, async를 통해 데이터베이스에 연결을 시도하고, 연결 여부를 확인하도록 하였다.

const express = require('express');
const { Client } = require('pg');
const cors = require('cors');
const useragent = require('express-useragent');
const requestIp = require('request-ip');
const favicon = require('serve-favicon');
const { sql, db } = require('@vercel/postgres'); // Import the Vercel Postgres SDK
const env = require('dotenv').config();

const app = express();
const port = process.env.PORT || 3000; // Use environment variable for port

// Get database URL from environment variable
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
    console.error("DATABASE_URL environment variable is missing. Please set it.");
    process.exit(1);
}

// Create a Postgres client
const client = new Client({
    connectionString: databaseUrl,
    ssl: {
        rejectUnauthorized: false
    }
});

const adminList = [
    { 'login': 'admin', 'password': process.env.ADMIN1_PASSWORD },
    { 'login': 'admin2', 'password': process.env.ADMIN2_PASSWORD }
];

app.use(cors());
app.use(express.json());
app.use(useragent.express());
app.use(favicon(__dirname + '/favicon.ico'));

// Basic Authentication Middleware
const authenticate = async (req, res, next) => {
    const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
    const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
    if (!login || !password || !adminList.some(admin => admin.login === login && admin.password === password)) {
        res.set('WWW-Authenticate', 'Basic realm="401"');
        res.status(401).send('관리자 권한이 필요합니다.');
        return;
    }
    next(); // Proceed if authenticated
};

// Connect to the database
(async () => {
    try {
        await client.connect();
        console.log('Connected to PostgreSQL!');
    } catch (err) {
        console.error('Connection error', err.stack);
    }
})();

 

index, 테스트 페이지 구현

메인 페이지는 관리를 위해 사용할 것이므로 인증이 필요하도록 하였다.

또한, /ping 엔드포인트로 동작 여부를 확인하도록 하였다.

// Route for the main page 
app.get('/', authenticate, (req, res) => {
    res.sendFile(__dirname + '/index.html');
});

// Route for ping
app.get('/ping', (req, res) => {
    res.send('pong!');
});

 

 

링크 접속 처리 구현

사용자가 링크를 타고 들어왔을 때, 링크 접속을 처리하는 엔드포인트를 구현하였다.

사실 엔드포인트라기보다는 와일드카드로, 세부적으로 정의되지 않은 모든 URL로부터 오는 요청을 모니터링하고, 여기서 데이터베이스에 해당 링크가 없다면 404.html을, 링크가 있다면 해당 링크로 연결되도록 한다.

특히, 사용자 에이전트를 분석하여 iOS 기기인 경우와 Android 기기인 경우에 별도 링크로 연결되는 딥링크 기능을 탑재하였으며, 사용자 인증 또는 쿼리 기반 패스워드 인증 기능을 포함하였다.

 

주의점

중요한 점은, 실제로 이 코드는 전체 코드의 가장 마지막 부분에 위치해야 한다는 것이다.

Node.js 특성상 위쪽에 있는 엔드포인트를 먼저 실행하는데, 만약 이 코드가 위쪽에 있다면 추후 설명할 다른 엔드포인트(링크 생성, 수정, 로그 보기 등)들이 작동하지 않게 될 것이다!

// Catch-all route for handling link redirection
app.get('*', async (req, res) => {
    try {
        const ua = req.useragent;
        const destination = req.originalUrl.slice(1).split('?')[0];
        // Parameterized query to prevent SQL injection
        const result = await client.query('SELECT * FROM links WHERE alias = $1', [destination]);

        if (result.rows.length === 0) {
            res.sendFile(__dirname + '/404.html');
        } else {
            const status = result.rows[0].status;
            if (status === 1) {
                // Do nothing for public links
            } else if (status === 2) {
                const real_password = result.rows[0].password;
                const user_password = req.query.pw;
                if (real_password !== user_password) {
                    res.status(403).send('Forbidden');
                    return;
                }
            } else if (status === 3) {
                const auth = { login: 'admin', password: 'merona06*' };
                const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
                const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
                if (!login || !password || login !== auth.login || password !== auth.password) {
                    res.set('WWW-Authenticate', 'Basic realm="401"');
                    res.status(401).send('관리자 권한이 필요합니다.');
                    return;
                }
            }

            const isIOS = ua.isiPhone || ua.isiPad || ua.isiPod;
            const isAndroid = ua.isAndroid;
            const ip = requestIp.getClientIp(req);

            // Parameterized query to prevent SQL injection
            await client.query('INSERT INTO logs (alias, ip, useragent) VALUES ($1, $2, $3)', [destination, ip, req.headers['user-agent']]);

            if (isIOS) {
                res.redirect(result.rows[0].ios_url || result.rows[0].url);
            } else if (isAndroid) {
                res.redirect(result.rows[0].android_url || result.rows[0].url);
            } else {
                res.redirect(result.rows[0].url);
            }
        }
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

 

로그 통합 페이지 및 개별 상세 페이지 구현

각 링크별로 접속 기록을 각각 살펴볼 수 있는 상세 페이지와, 전체 로그를 한눈에 볼 수 있는 페이지를 구현하였다.

이는 SQL 데이터베이스의 logs 테이블과 연결되어 있으며, 사용자 IP, useragent 등을 포함하였다.

또한, 파라미터를 통해 링크별 상세 접속 기능을 확인할 수 있도록 하였다.

// Route for logs (with authentication)
app.get('/logs', authenticate, async (req, res) => {
    try {
        const result = await client.query('SELECT * FROM logs'); // Use client.query for Postgres
        res.send(`
            <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
            <style>
            table {
                width: 100%;
                border-collapse: collapse;
            }
            th, td {
                border: 1px solid black;
                padding: 8px;
                text-align: center;
            }
            th {
                background-color: #f2f2f2;
            }

            a {
                text-decoration: none;
                color: #3322bb;
            }
            </style>
            <h1>Logs</h1>
            <a href="/" style="padding-bottom: 1rem;"><i class="bi bi-chevron-left" style="margin-right: 0.2rem;"></i>홈으로</a>
            <table>
                <thead>
                    <tr>
                        <th>ID</th>
                        <th>Alias</th>
                        <th>IP Address</th>
                        <th>User Agent</th>
                        <th>Visit Timestamp</th>
                    </tr>
                </thead>
                <tbody>
                    ${result.rows.map(log => `
                        <tr>
                            <td>${log.id}</td>
                            <td><a href="/logs/${log.alias}">${log.alias}</a></td>
                            <td>${log.ip}</td>
                            <td>${log.useragent}</td>
                            <td>${log.timestamp}</td>
                        </tr>
                    `).join('')}
                </tbody>
            </table>
        `);
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

// Route for logs with alias (with authentication)
app.get('/logs/:alias', authenticate, async (req, res) => {
    try {
        const { alias } = req.params;
        // Parameterized query to prevent SQL injection
        const result = await client.query('SELECT * FROM logs WHERE alias = $1', [alias]);
        res.send(`
            <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
            <style>
            table {
                width: 100%;
                border-collapse: collapse;
            }
            th, td {
                border: 1px solid black;
                padding: 8px;
                text-align: center;
            }
            th {
                background-color: #f2f2f2;
            }

            a {
                text-decoration: none;
                color: #3322bb;
            }
            </style>
            <h1>Logs</h1>
            <a href="/logs" style="padding-bottom: 1rem;"><i class="bi bi-chevron-left" style="margin-right: 0.2rem;"></i>로그 목록</a>
            <table>
                <thead>
                    <tr>
                        <th>ID</th>
                        <th>Alias</th>
                        <th>IP Address</th>
                        <th>User Agent</th>
                        <th>Visit Timestamp</th>
                    </tr>
                </thead>
                <tbody>
                    ${result.rows.map(log => `
                        <tr>
                            <td>${log.id}</td>
                            <td><a href="/${log.alias}">${log.alias}</a></td>
                            <td>${log.ip}</td>
                            <td>${log.useragent}</td>
                            <td>${log.timestamp}</td>
                        </tr>
                    `).join('')}
                </tbody>
            </table>
        `);
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

 

 

전체 링크 목록 확인 구현하기

전체 링크 목록을 한 눈에 확인하고, 삭제 및 수정 등의 기능을 사용할 수 있는 페이지를 구현하였다.

관리자 인증을 통해 접속이 가능하며, bootstrap icon을 이용해서 수정/삭제, 상태 표시 아이콘을 구현하였다.

// Route for the link list (with authentication)
app.get('/list', authenticate, async (req, res) => {
    try {
        const result = await client.query('SELECT * FROM links'); // Use client.query for Postgres
        res.send(`
            <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
            <style>
            table {
                width: 100%;
                border-collapse: collapse;
            }
            th, td {
                border: 1px solid black;
                padding: 8px;
                text-align: center;
            }
            th {
                background-color: #f2f2f2;
            }

            a {
                text-decoration: none;
                color: #3322bb;
            }
            </style>
            <h1>Links</h1>
            <a href="/" style="padding-bottom: 1rem;"><i class="bi bi-chevron-left" style="margin-right: 0.2rem;"></i>홈으로</a>
            <table>
                <thead>
                    <tr>
                        <th>ID</th>
                        <th>Alias</th>
                        <th style="max-width: 200px;">URL</th>
                        <th>iOS URL</th>
                        <th>Android URL</th>
                        <th>Status</th>
                        <th>Password</th>
                        <th>Actions</th>
                    </tr>
                </thead>
                <tbody>
                    ${result.rows.map(link => `
                        <tr>
                            <td>${link.id}</td>
                            <td><a href="/${link.alias}">${link.alias}</a></td>
                            <td style="max-width: 400px; word-wrap: break-word;">${link.url}</td>
                            <td>${link.ios_url}</td>
                            <td>${link.android_url}</td>
                            <td>
                                ${link.status === 1 ? '<i class="bi bi-globe" title="Public"></i>' : ''}
                                ${link.status === 2 ? '<i class="bi bi-eye-slash" title="Protected"></i>' : ''}
                                ${link.status === 3 ? '<i class="bi bi-lock" title="Private"></i>' : ''}
                            </td>
                            <td>${link.password}</td>
                            <td>
                                <a href="/edit/${link.alias}"><i class="bi bi-pencil" title="Edit" style="margin-right: 0.5rem; color: #3399bb;"></i></a>
                                <a href="/delete/${link.id}"><i class="bi bi-trash" title="Delete" style="color: #bb3333;"></i></a>
                            </td>
                        </tr>
                    `).join('')}
                </tbody>
            </table>
        `);
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

 

 

링크 생성 기능 구현

링크를 생성하기 위해 접속할 페이지와, POST method의 링크 생성 처리 엔드포인트를 구현하였다.

또한, 해당 링크가 이미 존재하는지 확인하는 checkalias라는 GET method의 엔드포인트도 구현하였다.

// Route for creating a new link (with authentication)
app.get('/create', authenticate, (req, res) => {
    res.sendFile(__dirname + '/create.html');
});

// Route to check if alias is available (with authentication)
app.get('/checkalias/:alias', authenticate, async (req, res) => {
    try {
        const { alias } = req.params;
        // Parameterized query to prevent SQL injection
        const result = await client.query('SELECT * FROM links WHERE alias = $1', [alias]);
        if (result.rows.length === 0) {
            res.status(200).send({ success: true, message: 'Alias 사용 가능' });
        } else {
            res.status(409).send({ success: false, message: '이미 사용중인 링크입니다.' });
        }
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

// Route to handle link creation (with authentication)
app.post('/create', authenticate, async (req, res) => {
    try {
        const password = process.env.ADMIN_KEY;
        const authKey = req.body.authKey;
        if (!authKey || authKey !== password) {
            res.status(401).send('관리자 권한이 필요합니다.');
            return;
        }
        const { name, alias, url, ios_url, android_url, status, password: set_pass } = req.body;

        // Check if alias already exists using parameterized query
        const checkQuery = 'SELECT * FROM links WHERE alias = $1';
        const checkResult = await db.query(checkQuery, [alias]);

        if (checkResult.rows.length > 0) {
            res.status(409).send({ success: false, message: '이미 사용중인 링크입니다.' });
            return;
        }

        // Insert new link using parameterized query to prevent SQL injection
        const insertQuery = `
            INSERT INTO links (alias, url, name, ios_url, android_url, status, password) 
            VALUES ($1, $2, $3, $4, $5, $6, $7)
        `;
        await client.query(insertQuery, [alias, url, name, ios_url, android_url, status, set_pass]);
        res.status(200).send({ success: true, message: 'Link 생성 성공' });
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

 

링크 수정 기능 구현

링크 생성과 유사하게, 링크 수정을 하는 페이지와 POST method의 링크 수정 백엔드 엔드포인트를 구현하였다.

SQL 데이터베이스에 쿼리를 실행해 업데이트를 해 주는 방식이다.

조금 더 코드가 길고 복잡한 이유는 디자인을 위해 html 페이지를 렌더링하기 때문이다.

// Route for editing a link (with authentication)
app.get('/edit/:alias', authenticate, async (req, res) => {
    try {
        const { alias } = req.params;
        const ADMIN_KEY = process.env.ADMIN_KEY;
        // Parameterized query to prevent SQL injection
        const result = await client.query('SELECT * FROM links WHERE alias = $1', [alias]);
        res.send(`
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>${process.env.SERVICE_NAME} Link 편집 | ${process.env.SERVICE_NAME} Link Service</title>
                <link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-dynamic-subset.min.css" />
                <style>
                body {
                    font-family: 'Pretendard';
                    display: flex;
                    flex-direction: column;
                    align-items: center;
                    justify-content: center;
                    height: 100vh;
                    font-family: Arial, sans-serif;
                    background-color: #f5f5f5;
                }
        
                h1 {
                    font-family: 'Pretendard';
                    font-size: 4rem;
                    margin-bottom: 1rem;
                    color: #333;
                }
        
                p {
                    font-family: 'Pretendard';
                    font-size: 1.5rem;
                    color: #666;
                }
        
                form {
                    font-family: 'Pretendard';
                    display: flex;
                    flex-direction: column;
                    align-items: center;
                    justify-content: center;
                    margin-top: 2rem;
                }
        
                input {
                    font-family: 'Pretendard';
                    padding: 0.5rem;
                    margin-bottom: 1rem;
                    border: 1px solid #ccc;
                    border-radius: 4px;
                    width: 100%;
                    max-width: 500px;
                }
        
                button {
                    font-family: 'Pretendard';
                    padding: 0.5rem 1rem;
                    border: none;
                    border-radius: 4px;
                    background-color: #007bff;
                    color: #fff;
                    cursor: pointer;
                }
        
                button:hover {
                    font-family: 'Pretendard';
                    background-color: #0056b3;
                }
        
                select {
                    font-family: 'Pretendard';
                    padding: 0.5rem;
                    margin-bottom: 1rem;
                    border: 1px solid #ccc;
                    border-radius: 4px;
                    width: 100%;
                    max-width: 500px;
                }
                </style>
            </head>
            <body>
                <style>
                @media (max-width: 768px) {
                    h1 {
                        font-size: 3rem;
                    }
                }
                </style>
                <h1>Link 편집</h1>
                <img src="https://i.imgur.com/U77qqOC.png" alt="Link Services" width="120" height="120" style="margin-bottom:1.5rem;">
                <form>
                    ${result.rows.map(link => `
                    <input type="text" name="name" placeholder="링크 제목" value="${link.name}">
                    <input type="text" name="alias" placeholder="연결될 alias" value="${link.alias}" readonly>
                    <input type="text" name="url" placeholder="URL" value="${link.url}">
                    <select name="status" title="Status" value="${link.status}">
                        <option value="1">Public</option>
                        <option value="2">Protected</option>
                        <option value="3">Private</option>
                    </select>
                    <input type="text" name="password" placeholder="Password" hidden value="${link.password}">
                    <input type="text" name="ios_url" placeholder="iOS URL" value="${link.ios_url}">
                    <input type="text" name="android_url" placeholder="Android URL" value="${link.android_url}">
                    <button type="submit">링크 편집</button>
                    `).join('')}
                </form>
                <script>
                const form = document.querySelector('form');
                const inputs = form.querySelectorAll('input');
                const select = form.querySelector('select');
                const button = form.querySelector('button');
        
                select.addEventListener('change', () => {
                    const selected = select.value;
                    const password = form.querySelector('input[name="password"]');
                    if (selected === '2') {
                        password.removeAttribute('hidden');
                    } else {
                        password.setAttribute('hidden', true);
                    }
                });
        
                form.addEventListener('submit', async event => {
                    event.preventDefault();
                    const data = {
                        name: inputs[0].value,
                        alias: inputs[1].value,
                        url: inputs[2].value,
                        status: select.value,
                        password: inputs[3].value,
                        ios_url: inputs[4].value,
                        android_url: inputs[5].value,
                        authKey: '${ADMIN_KEY}'
                    }
                    console.log(data);
                    const response = await fetch('/edit', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify(data)
                    });
                    const result = await response.json();
                    alert(result.message);
                    form.reset();
                    history.back();
                });
                </script>
            </body>
            </html>
        `);
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

// Route to handle link editing (with authentication)
app.post('/edit', authenticate, async (req, res) => {
    try {
        const password = process.env.ADMIN_KEY; // Replace with your actual admin key
        const authKey = req.body.authKey;
        if (!authKey || authKey !== password) {
            res.status(401).send('관리자 권한이 필요합니다.');
            return;
        }
        const { name, alias, url, ios_url, android_url, status, password: set_pass } = req.body;
        // Parameterized query to prevent SQL injection
        await client.query(
            'UPDATE links SET name = $1, url = $2, ios_url = $3, android_url = $4, status = $5, password = $6 WHERE alias = $7',
            [name, url, ios_url, android_url, status, set_pass, alias]
        );
        res.status(200).send({ success: true, message: 'Link 수정 성공' });
    } catch (err) {
        console.error(err);
        res.status(500).send({ success: false, message: 'Internal Server Error' });
    }
});

링크 삭제 기능 구현

링크를 삭제하기 위한 API 엔드포인트(DELETE method)와, 관리자 접속이 가능한 삭제 페이지(GET method)를 구현하였다.

파라미터를 통해 어떤 링크를 삭제할지 확인하도록 하였으며, 최종 승인 후 삭제되도록 하였다.

// Route for deleting a link (with authentication)
app.get('/delete/:id', authenticate, async (req, res) => {
    try {
        const { id } = req.params;
        const ADMIN_KEY = process.env.ADMIN_KEY;
        // Parameterized query to prevent SQL injection
        const result = await client.query('SELECT * FROM links WHERE id = $1', [id]);
        res.send(`
            <style>
            button {
                padding: 0.5rem 1rem;
                border: none;
                border-radius: 4px;
                background-color: #dc3545;
                color: #fff;
                cursor: pointer;
            }
            button:hover {
                background-color: #c82333;
            }
            </style>
            <h1>Link를 삭제하시겠습니까?</h1>
            <p>Alias: ${result.rows[0].alias}</p>
            <p>URL: ${result.rows[0].url}</p>
            <button id="deletebutton">
                <span>삭제</span>
            </button>
            <script>
                const deleteButton = document.getElementById('deletebutton');
                deleteButton.addEventListener('click', async () => {
                    const response = await fetch('/delete/${id}', {
                        method: 'DELETE',
                        headers: {
                            'Authorization': '${ADMIN_KEY}'
                        }
                    });
                    const result = await response.json();
                    alert(result.message);
                    window.location.href = '/list';
                });
            </script>
        `);
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

// Route to handle link deletion (with authentication)
app.delete('/delete/:id', authenticate, async (req, res) => {
    try {
        const { id } = req.params;
        const password = process.env.ADMIN_KEY;
        const authKey = req.headers.authorization;
        if (!authKey || authKey !== password) {
            res.status(401).send('관리자 권한이 필요합니다.');
            return;
        }
        // Parameterized query to prevent SQL injection
        await client.query('DELETE FROM links WHERE id = $1', [id]);
        res.status(200).send({ success: true, message: 'Link 삭제 성공' });
    } catch (err) {
        console.error(err);
        res.status(500).send({ success: false, message: 'Internal Server Error' });
    }
});

 

 

서버 실행 코드 작성

마지막으로, 서버가 실제로 동작되도록 하는 module export와 포트 정의, app 실행 부분을 작성하였다.

// Start the server
app.listen(port, '0.0.0.0', () => {
    console.log(`Server is running on ${port}`);
});

module.exports = app;

 

전체 코드 완성

이로써 app.js의 모든 코드는 완성되었다.

다만, 실제로 동작하기 위해서는 404.html, index.html과 같은 정적 웹페이지 소스가 필요하다.

이는 관리자의 편리한 관리를 위함이며, 예제 파일은 아래에서 다운로드받을 수 있다.

 

link_sources.zip
0.05MB

 

app.js를 포함한 전체 코드

위 소스 파일은 연습을 위해 app.js는 제외하였다.

만약 전체 코드가 필요하다면, 아래 '더보기' 버튼을 눌러 GitHub 링크를 활용하도록 하자.

다만, 연습을 위하여 위의 코드를 이용하여 직접 만들어 보기를 권장한다.

 

환경 변수 설정

gitignore 설정

위에서 언급된 것처럼, 우리는 보안을 위해서 중요 정보를 환경 변수로 관리한다.

이를 위해, .env라는 파일을 만들어 주어야 한다. 이는 중요 보안 정보이기에 GitHub에 올라가면 안되므로, 만약 .gitignore에 .env 파일이 등록되어 있지 않다면, 아무 곳에나 .env를 넣어 주자. (만약 /node_modules/가 포함되어 있지 않다면, 이 또한 넣어주자.)

gitignore 추가

 

.env 파일 설정

gitignore에 추가했다면, 실제 환경변수를 설정할 차례이다.

.env 파일을 넣어 아래 내용을 추가해 준다.

POSTGRES로 시작하는 내용은 vercel 데이터베이스 관리 페이지에서 메모한 정보를 넣어준다.

DATABASE_URL은 vercel 데이터베이스 관리 페이지의 psql 부분을 참고한다.

 

여기서 ADMIN_KEY는 복잡한 문자열로 아무 문자열을 입력하면 된다.

또한, SERVICE_NAME은 간단한 서비스명 (ex. SCIAN)을 입력해 주고, ADMIN1_PASSWORD, ADMIN2_PASSWORD에는 자신이 원하는 패스워드를 입력해 준다. 이때 작성한 패스워드는 메모해 두도록 하자.

 

호스팅하기

GitHub 데스크탑에서 이렇게 작성한 코드를 Commit&Push해 준다.

이후, Vercel 사이트에 접속하여 우리가 만든 프로젝트 페이지의 Settings>Environment Variables에 들어가 준다.

그런 다음 아까 만들어 두었던 .env의 내용을 복사하여 이곳에 붙여넣어 준다.

Vercel 환경변수 설정

Save 버튼을 눌러주면 아래와 같이 나타날 것이다.

Vercel 환경변수 설정 완료

 

이렇게 되면, 모든 준비는 끝났다. 처음에 메모해 둔 이 서버의 URL로 들어가 ID:admin, PW: [메모해 둔 비밀번호]로 로그인하여 링크를 생성해 보자!

 

전체 서비스의 사용 방법은 다음 글을 통해 소개하겠다.

 

다음 편 바로보기

2024.05.16 - [잇(IT)! 가이드] - EP.1 링크 단축 서비스 (4) — 사용 방법

 

EP.1 링크 단축 서비스 (4) — 사용 방법

잇(IT)! 가이드EP.1 링크 단축 서비스 (4) — 사용 방법잇(IT)! 가이드는 IT 프로젝트를 따라해보실 수 있도록 하는 가이드입니다. 여러 프로젝트를 단계별로 설명드리며, 누구나 쉽게 따라할 수 있도

scian.xyz