Logo

Prolip's Blog

HTTP - 브라우저의 HTTP 요청
  • #http

HTTP - 브라우저의 HTTP 요청

Prolip

2025-02-27

fetch를 이용한 요청 외에 브라우저가 직접 만들어내는 몇 가지 HTTP 요청에 대해 알아보기

브라우저의 HTTP 요청

우리가 브라우저를 사용할 때 네트워크 탭을 열어보면 많은 양의 HTTP 요청이 오가는 것을 볼 수 있다. 일반적으로 직접 어떤 서비스를 만들 때 fetch를 통해 만들어내는 HTTP 요청을 생각할 수 있지만, 브라우저 스스로 HTTP 요청을 만들기도 한다.

HTML

우리가 주소창에 특정 사이트에 들어가기 위한 주소를 입력한다고 생각해보자. 네이버라면, naver.com을 입력할 수 있을 것이다. 그럼 브라우저는 naver.com에 해당하는 IP 주소를 DNS 서버에게 요청하고.. 해당 IP 주소로 naver.com에 해당하는 HTML 문서를 받아오기 위한 HTTP 요청을 만들어낼 것이다.

이렇게 주소창에 입력한 경로에 해당하는 HTML 문서를 제공하기 위해 HTTP 요청을 만들어낼 수 있고, 서버는 해당 경로로 진입한 사용자에게 적절한 웹 문서를 제공하도록 구현해야 한다.

const http = require('http'); const path = require('path'); const static = require('미리 만들어둔 serve-static'); const handler = (req, res) => { static(path.join(__dirname, 'public')(req, res); } const server = http.createServer(handler); server.listen(3000, () => console.log('server is running ::3000');

이렇게 서버 인스턴스를 만들고 handler 함수는 미리 만들어둔 static 함수로 현재 현재 파일이 위치한 폴더의 절대 경로에 public을 합쳐 인자로 전달해 함수를 리턴 받는다.

그리고 리턴된 함수에 req, res 객체를 다시 전달해 역할을 위임한다.

const fs = require('fs'); const path = require('path'); const serveStatic = (root) => { return (req, res) => { const filePath = path.join(root, req.url === '/' ? 'index.html' : req.url); fs.readFile(filePath, (err, data) => { if(err) { if(err.code === 'ENOENT') { res.statusCode = 404; res.wirte('Not Found\n'); res.end(); return; } res.statusCode = 500; res.write('Internal Server Error\n'); res.end(); return; } const ext = path.extname(filePath).toLowerCase(); let contentType = 'text/html'; switch(ext) { case '.html': contentType = 'text/html'; break; case '.js': contentType = 'text/javascript'; break; case '.css': contentType = 'text/css'; break; case '.png': contentType = 'image/png'; break; case '.json': contentType = 'application/json'; break; case '.otf': contentType = 'font/oft'; break; default: contentType = 'application/octet-stream'; } res.setHeader('Content-Type', contentType); res.write(data); res.end(); }); } }
  • serveStatic: 위 handler 함수에서 사용하듯, 경로를 인자로 받아 함수를 리턴한다. handler 함수의 인자로 들어온 req, res 객체를 받아 처리하게 된다.
  • filePath:
    • root 경로는 파일이 위치한 경로(__dirname + ‘public’)
    • req 객체의 url 프로퍼티는 요청된 URL을 의미한다. http:/localhost:3000/ 이 전달된 경우 ‘/’로 index.html을 서빙한다.
    • 만약 요청된 url이 ‘/’이 아니라면 해당 url 자체를 사용한다. http:/localhost:3000/script.js ⇒ ‘/script.js’를 서빙.
  • fs.readFile: 첫 번째 인자로 생성한 filePath를 전달하고, 파일을 읽어 응답을 보낸다. 두 번째 인자로 콜백 함수를 전달해 처리한다. 파일을 읽는데 실패한 경우 err 객체를 받는다. 에러 코드 ENOENT는 해당 경로에 대한 파일을 찾지 못한 경우로 404 코드와 Not Found 값을 클라이언트 측으로 전달하고, 그 외 에러 객체가 들어온 경우 서버 내부적인 에러로 판단해 500 코드와 함께 요청을 종료한다.
  • extname: 해당 라인까지 도착한 경우 파일을 읽는데 성공한 경우로 판단해 extname을 통해 파일의 확장자를 추출한다.
    • Return the extension of the path, from the last '.' to end of string in the last portion of the path. If there is no '.' in the last portion of the path or the first character of it is '.', then it returns an empty string.
    • 위는 extname 함수의 jsdoc 설명으로 파일을 읽어 .을 기점으로 확장자를 추출한다고 한다.
  • contentType: 브라우저가 서버로부터 받은 데이터를 해석할 수 있게 마임 타입을 설정한다. index.html의 경우 text/html
  • 최종적으로 응답 헤더에 설정된 Content-Type 헤더를 설정한 뒤 읽은 데이터를 클라이언트 측으로 전달하며 요청을 종료한다.

이렇게 구현된 서버를 실행한 뒤 브라우저 주소창에 주소를 입력해보자.

image

루트 경로의 경우 index.html을 서빙하기에 Content-Type은 text/html로 설정되어 있음을 확인할 수 있다. 그럼 전달된 index.html은 제대로 도착했을까?

image

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> </head> <body> <h1>Home</h1> <a href="/ch01.html">Chapter 1</a> <a href="/ch02.html">Chapter 2</a> </body> </html>

미리 만들어둔 index.html이 제대로 전달되고 있다.

아래를 보면 favicon.ico에 대한 요청도 생성되어 있는데, 브라우저에 따라 파비콘을 얻기 위한 요청도 자동으로 발생한다.

또, 하이퍼링크가 index.html 문서에 작성되어 있는데 브라우저는 a 태그. 즉, 하이퍼링크를 클릭했을 때 href 속성에 명시된 주소로 GET 요청을 보낸다.

image

이렇게 Chapter 1이라는 하이퍼링크를 클릭했을 때 해당 링크 주소인 ch01.html을 받아오기 위해 서버에 GET 요청을 보내고, 서버는 해당 파일을 읽어 사용자에게 서빙하게 된다.

HTML 외 요청

HTML 파일은 css, 글꼴, js 등 텍스트 외의 다른 형태의 자원을 포함할 수 있다. 브라우저는 HTML 요청뿐 아니라 HTML을 파싱하면서도 자동으로 GET 요청을 만들 수 있다.

브라우저는 HTML 문서를 파싱하는 과정에서 css, 글꼴, 자바스크립트를 불러오는 태그를 만나게 되면, 렌더링을 멈추고 HTTP 요청을 만들게 된다. href, src에 적힌 링크로 GET 요청을 만들어 해당 자원을 불러온다.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto" /> <script src="script.js"></script> </head> <body> <h1>Home</h1> <a href="/ch01.html">Chapter 1</a> <a href="/ch02.html">Chapter 2</a> </body> </html>

이렇게 css, font, js 파일을 불러오는 태그를 html 문서에 작성했을 때, 브라우저가 HTTP 요청을 어떻게 만들어내는지 확인해보자.

image

이렇게 브라우저는 루트 경로에 해당하는 HTTP 요청을 만들어 index.html 문서를 받고, 이 문서를 렌더링하는 과정에서 마주친 여러 부가적인 요소들을 불러오기 위한 HTTP 요청을 만들어낸다.

css 파일의 경우 href 속성이 style.css로 설정되어있기에 http:/localhost:3000/style.css 와 같은 요청을 만들어낸다. 위와 같이 Content-Type은 구현한대로 text/css로 설정되어 서빙됨을 알 수 있다.

이미지

브라우저는 HTML 문서를 파싱하면서 img 태그를 만나면 해당 URL로 GET 요청을 보내 파일을 받아오려고 한다. 이를 이용해 사용자의 정보를 로그화시켜 분석할 수도 있을 것이다.

const insertTrackingPixel = () => { const img = document.createElement("img"); img.src = "/tracking-pixel.gif"; img.alt = "Tracking Pixel"; img.style.width = "1px"; img.style.height = "1px"; img.style.display = "none"; document.body.appendChild(img); }; document.addEventListener("DOMContentLoaded", () => { console.log("script.js"); insertTrackingPixel(); });

먼저 script.js 파일이 로딩될 때 body 태그에 1px * 1px에 화면에 보이지 않을 img 태그를 만들어 삽입한다.

그럼 스크립트가 로딩된 후 img 태그가 추가되며 브라우저는 /tracking-pixel.gif에 해당하는 GET 요청을 서버로 보내게 된다.

const logRequest = (req) => { const log = [ `${new Date().toISOString()}`, `IP: ${req.socket.remoteAddress || req.conection.remoteAddress}`, `User-Agent: ${req.headers["user-agent"]}`, `Referer: ${req.headers["referer"]}`, ].join("\n"); console.log(log); }; const handler = (req, res) => { const { pathname } = new URL(req.url, `http://${req.headers.host}`); if (pathname === "/tracking-pixel.gif") logRequest(req); static(path.join(__dirname, "public"))(req, res); };

그럼 서버는 받은 req.url에서 경로만을 떼어내 /tracking-pixel.gif에 해당하는 경로가 요청으로 들어오면 사용자의 정보를 로그로 찍을 수 있다.

image

위와 같이 script.js가 로딩될 때 insertTrackingPixel 함수가 실행되어 body 태그 내에 img 태그가 삽입되었고, 이후 해당 태그의 URL로 GET 요청을 만들어 아래 tracking-pixel.gif라는 요청이 자동으로 생성되었음을 확인할 수 있다.

이후 서버 콘솔에는 사용자 정보가 나타나게 된다.

image

Form

위에서 살펴본 HTTP 요청들은 브라우저가 자동으로 생성한 경우들이었다. 하지만 특정 시점에 HTTP 요청을 만들어 서버로 보내야 하는 경우가 있다.

로그인이 그 예다. 사용자가 아이디와 비밀번호를 입력한 뒤 로그인 버튼을 클릭한 시점에 해당 값으로 인증을 받을 수 있는 요청을 만들어야 한다. 당연히 서버가 아이디와 비밀번호를 가지고 있기 때문이다.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> </head> <body> <h1>Login</h1> <nav><a href="/">< Home</a></nav> <h2>GET LOGIN</h2> <form method="GET" action="/login"> <input type="text" name="email" placeholder="email.." /> <input type="password" name="password" placeholder="******" /> <button type="submit">Login</button> </form> </body> </html>

html 문서에 form 태그를 만들어 특정 시점에 입력한 값을 기반으로 HTTP 요청을 만들 수 있다.

image

크게 form 태그는 GET 요청과 POST 요청을 만들어낼 수 있다. method 속성으로 지정할 수 있으며, action 속성에 작성한 URL로 해당 요청을 만들어 보내게 된다.

사용자가 제출한 시점에 브라우저는 /login으로 GET 요청을 만들어낸다. 사용자가 입력한 값은 쿼리 문자열 형태로 URL에 포함되어 서버로 요청된다.

const getLogin = (req, res) => { const { searchParams } = new URL(req.url, `http://${req.headers.host}`); const email = searchParams.get("email"); const password = searchParams.get("password"); const authenticated = email === "myemail" && password === "mypassword"; if (!authenticated) { res.statusCode = 401; res.write("Unathorized\n"); res.end(); return; } res.write("Login Success!"); res.end(); }; const handler = (req, res) => { const { pathname } = new URL(req.url, `http://${req.headers.host}`); if (pathname === "/login") return getLogin(req, res); static(path.join(__dirname, "public"))(req, res); };

GET 요청은 입력한 값이 쿼리 문자열 형태로 전달되기 때문에 서버에서 URL 객체를 생성해 파싱해 사용할 수 있다.

하지만 몇 가지 제한 사항이 존재한다. 처리 가능한 URL의 최대 길이가 있어 실을 수 있는 데이터 양의 한계점이 존재한다. 또, 주소창이나 서버 로그에 브라우저가 전달한 데이터가 기록될 수 있어 민감한 데이터를 사용하는 데에는 적합하지 않다.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> </head> <body> <h2>POST LOGIN</h2> <form method="POST" action="/login"> <input type="text" name="email" placeholder="email.." /> <input type="password" name="password" placeholder="******" /> <button type="submit">Login</button> </form> </body> </html>

다음으로 method 속성에 POST를 입력해 POST 요청을 만들어낼 수 있다. POST 요청은 GET 요청과 달리 입력한 값을 요청 본문(body)에 담을 수 있다.

image

image

기본 요청의 경우 Content-Type이 application/x-www-form-urlencoded로 입력한 영역과 값을 key와 인코딩한 value를 =로 묶어 한 쌍으로 만든다. 만약 값이 여러 개일 경우 &로 묶어 전달한다.

const queryString = require('queryString'); const postLogin = (req, res) => { let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", () => { const { email, password } = queryString.parse(body); const authenticated = email === "myemail" && password === "mypassword"; if (!authenticated) { res.statusCode = 401; res.write("Unathorized\n"); res.end(); return; } res.write("Login Success!"); res.end(); }); }; const handler = (req, res) => { const { pathname } = new URL(req.url, `http://${req.headers.host}`); if (pathname === "/login") return postLogin(req, res); static(path.join(__dirname, "public"))(req, res); };

전달된 본문 데이터를 읽기 위해 body 변수를 선언한 뒤, 데이터가 전달될 때 req 객체에 data 이벤트가 발행된다. 콜백 함수의 인자로 전달된 데이터가 chunk 형태로 들어오기 때문에 string 형태로 바꿔 body에 차곡차곡 쌓아둔다.

이후 데이터 전송이 모두 끝난 뒤 req 객체에 end 이벤트가 발행된다. queryString 모듈을 사용해 쌓아둔 데이터를 파싱해 이후 같은 로직을 통해 사용자를 인증한다.

하지만 이 기본 값인 application/x-www-form-urlencoded는 데이터를 인코딩하기 때문에 데이터가 많아지면 전송에 비효율적이고, 파일을 전송할 수 없는 한계점이 존재한다.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> </head> <body> <h2>POST LOGIN 2</h2> <form method="POST" action="/login" enctype="multipart/form-data"> <input type="text" name="email" placeholder="email.." /> <input type="password" name="password" placeholder="******" /> <input type="file" name="identification-card" accept="image/png, image/jpeg" /> <button type="submit">Login</button> </form> </body> </html>

이 경우 mulitipart/form-data 방식을 사용할 수 있는데 요청 본문을 여러 부분으로 나누어 전송한다.

각 부분은 헤더와 바디로 구성되어 헤더 영역은 각 영역에 대한 메타 데이터를 표시하고 바디 영역에 실제 데이터가 담겨 보내진다.

image

image

HTTP - 쿠키

HTTP - 쿠키

프론트엔드에서의 에러와 예외

프론트엔드에서의 에러와 예외