백엔드 프레임워크와 템플릿 엔진 분리하기
Emily Parker
Product Engineer · Leapcell

소개
현대 웹 개발에서 관심사의 명확한 분리는 유지 관리 가능하고, 확장 가능하며, 테스트 가능한 애플리케이션을 육성하는 기본 원칙입니다. 비즈니스 로직과 데이터 지속성을 담당하는 백엔드 프레임워크가 프레젠테이션 계층을 처리하는 템플릿 엔진과 긴밀하게 결합될 때 일반적인 문제가 발생합니다. 이러한 얽힘은 코드 재사용, 독립적인 테스트, 심지어 아키텍처 유연성까지 어렵게 만듭니다. 예를 들어, 서버에서 데이터를 처리하는 방식의 변경은 렌더링 로직 자체가 변경되지 않았더라도 해당 데이터를 렌더링하는 방식에 대한 조정을 필요로 할 수 있습니다. 이 글은 Jinja2, EJS, Go Templates와 같은 템플릿 엔진으로부터 백엔드 프레임워크를 분리하는 데 필요한 노력과, 결국 더 깔끔한 아키텍처 설계의 실질적인 가치를 강조하면서, 깨지기 쉬운 종속성을 만들지 않고 데이터 컨텍스트를 효과적으로 전달하기 위한 효과적인 전략들을 탐구합니다.
핵심 개념
분리의 메커니즘에 대해 자세히 알아보기 전에, 관련된 주요 구성 요소에 대한 명확한 이해를 확립해 봅시다.
- 백엔드 프레임워크: 애플리케이션의 서버 측 로직을 구축하기 위한 구조를 제공하는 소프트웨어 프레임워크입니다. 예로는 Flask (Python), Express.js (Node.js), Gin (Go)이 있습니다. 이러한 프레임워크는 라우팅을 관리하고, HTTP 요청을 처리하며, 데이터베이스와 상호 작용하고, 애플리케이션의 비즈니스 규칙을 조정합니다.
- 템플릿 엔진: 고정된 템플릿 파일과 동적 데이터를 결합하여 동적 HTML, XML 또는 기타 텍스트 형식을 생성할 수 있게 해주는 도구입니다. 예로는 Jinja2 (Python), EJS (Node.js), Go Templates (Go)가 있습니다. 템플릿 파일 내에서 제어 흐름 구조(루프, 조건문)와 변수 치환 기능을 제공합니다.
- 컨텍스트 (또는 데이터 컨텍스트): 렌더링을 위해 템플릿 엔진에 사용할 수 있게 되는 데이터(변수, 객체, 목록) 세트입니다. 이 데이터는 일반적으로 요청을 처리하는 백엔드 프레임워크에서 비롯됩니다.
- 분리: 시스템의 서로 다른 모듈 또는 구성 요소 간의 상호 의존성을 줄이는 프로세스입니다. 이 맥락에서는 백엔드 프레임워크와 템플릿 엔진을 필요한 데이터 교환을 넘어서 가능한 한 서로 독립적으로 만드는 것을 의미합니다.
분리와 컨텍스트 전달의 원칙
분리의 기본 원칙은 템플릿 엔진을 "모델/컨트롤러 계층"(백엔드 프레임워크)에서 제공하는 데이터를 소비하는 별도의 "보기 계층"으로 취급하는 것입니다. 백엔드는 데이터가 어떻게 표시되는지에 대한 것이 아니라, 어떤 데이터가 표시되어야 하는지에 대한 것입니다.
작동 방식
일반적인 흐름은 다음과 같습니다:
- 요청 수신: 백엔드 프레임워크가 HTTP 요청을 수신합니다.
- 로직 실행: 프레임워크는 요청을 처리하고, 비즈니스 로직을 수행하며, 데이터베이스에서 데이터를 가져옵니다.
- 컨텍스트 준비: 프레임워크는 필요한 모든 데이터를 구조화된 형식(예: 사전, 객체, 구조체)으로 집계합니다. 이 구조화된 데이터가 컨텍스트입니다.
- 템플릿 렌더링 호출: 프레임워크는 컨텍스트와 렌더링할 템플릿 파일의 이름을 전달하여 템플릿 엔진을 호출합니다.
- HTML 생성: 템플릿 엔진은 템플릿 파일을 가져와, 제공된 컨텍스트로 변수를 치환하고, 제어 흐름을 적용하며, 최종 HTML 출력을 생성합니다.
- 응답 전송: 백엔드 프레임워크는 이 생성된 HTML을 클라이언트에 다시 보냅니다.
구현 예시
Python (Flask와 Jinja2), Node.js (Express와 EJS), Go (net/http와 Go Templates)를 사용하여 실제 예시로 설명해 보겠습니다.
Python (Flask와 Jinja2)
Flask는 Jinja2를 활용하여 자연스럽게 좋은 분리를 장려합니다.
# app.py from flask import Flask, render_template app = Flask(__name__) @app.route('/') def home(): # 1. 백엔드 로직이 데이터를 처리합니다 user_name = "Alice" products = [ {"id": 1, "name": "Laptop", "price": 1200}, {"id": 2, "name": "Mouse", "price": 25}, {"id": 3, "name": "Keyboard", "price": 75}, ] is_admin = True # 2. 컨텍스트를 사전으로 준비합니다 context = { "title": "Welcome to Our Store", "user": {"name": user_name, "is_admin": is_admin}, "products_list": products } # 3. 컨텍스트를 키워드 인수로 펼쳐서 render_template을 호출합니다 return render_template('index.html', **context) # **context는 사전을 키워드 인수로 펼칩니다 if __name__ == '__main__': app.run(debug=True)
<!-- templates/index.html (Jinja2) --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{{ title }}</title> </head> <body> <h1>Hello, {{ user.name }}!</h1> {% if user.is_admin %} <p>You have administrative privileges.</p> {% endif %} <h2>Our Products:</h2> <ul> {% for product in products_list %} <li>{{ product.name }} - ${{ product.price }}</li> {% else %} <li>No products available.</li> {% endfor %} </ul> </body> </html>
이 Flask 예시에서 home
함수(백엔드 로직)는 user_name
, products
, is_admin
을 준비하는 것만을 담당합니다. 그런 다음 이들을 context
사전에 묶습니다. 그런 다음 render_template
함수(내부적으로 Jinja2 사용)가 이 컨텍스트와 함께 호출됩니다. index.html
템플릿은 이 데이터를 단순히 소비합니다. user.name
또는 products_list
가 어떻게 가져오거나 계산되었는지 전혀 알지 못합니다.
Node.js (Express와 EJS)
Express.js는 미니멀리스트 Node.js 웹 프레임워크로, EJS와 완벽하게 통합됩니다.
// app.js const express = require('express'); const path = require('path'); const app = express(); const port = 3000; // EJS를 뷰 엔진으로 설정 app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); app.get('/', (req, res) => { // 1. 백엔드 로직이 데이터를 처리합니다 const userName = "Bob"; const items = [ { id: 101, name: "Book", quantity: 2 }, { id: 102, name: "Pen", quantity: 5 } ]; const loggedIn = true; // 2. 컨텍스트를 객체로 준비합니다 const context = { pageTitle: "My Shopping List", currentUser: { name: userName, isLoggedIn: loggedIn }, shoppingItems: items }; // 3. 컨텍스트를 전달하여 res.render를 호출합니다 res.render('home', context); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
<!-- views/home.ejs --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><%= pageTitle %></title> </head> <body> <h1>Hello, <%= currentUser.name %>!</h1> <% if (currentUser.isLoggedIn) { %> <p>Welcome back to your shopping list.</p> <% } %> <h2>Your Items:</h2> <ul> <% shoppingItems.forEach(function(item) { %> <li><%= item.name %> (Quantity: <%= item.quantity %>)</li> <% }); %> </ul> </body> </html>
여기서 Express 라우트 핸들러는 userName
, items
, loggedIn
을 준비합니다. 그런 다음 context
객체를 생성하고 res.render('home', context)
로 전달합니다. home.ejs
템플릿은 컨텍스트 객체에서 제공하는 범위에서 이러한 속성에 직접 액세스합니다.
Go (net/http와 Go Templates)
Go의 표준 라이브러리는 html/template
패키지를 사용하여 강력한 템플릿 기능을 제공합니다.
// main.go package main import ( "html/template" "log" "net/http" ) // 컨텍스트를 보유할 데이터 구조체 type PageData struct { Title string Heading string Users []User } type User struct { ID int Name string Email string } func main() { http.HandleFunc("/", homeHandler) log.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } func homeHandler(w http.ResponseWriter, r *http.Request) { // 1. 백엔드 로직이 데이터를 처리합니다 users := []User{ {ID: 1, Name: "Charlie", Email: "charlie@example.com"}, {ID: 2, Name: "Diana", Email: "diana@example.com"}, } // 2. 컨텍스트를 Go 구조체로 준비합니다 data := PageData{ Title: "User Management", Heading: "Registered Users", Users: users, } // 3. 템플릿을 파싱하고 실행하며 컨텍스트를 전달합니다 tmpl, err := template.ParseFiles("templates/user_list.html") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } err = tmpl.Execute(w, data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } }
<!-- templates/user_list.html (Go Template) --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{{.Title}}</title> </head> <body> <h1>{{.Heading}}</h1> <ul> {{range .Users}} <li>{{.Name}} ({{.Email}}) - ID: {{.ID}}</li> {{else}} <li>No users found.</li> {{end}} </ul> </body> </html>
Go 예시에서 homeHandler
는 전달하려는 데이터를 명시적으로 모델링하기 위해 PageData
구조체를 정의합니다. 이 구조체의 인스턴스를 채우고 tmpl.Execute(w, data)
를 호출합니다. Go 템플릿은 .
표기법(예: {{.Title}}
, {{.Name}}
)을 사용하여 PageData
구조체의 필드에 직접 액세스합니다.
적용 시나리오 및 이점
이러한 명시적인 컨텍스트 전달 및 분리 접근 방식은 수많은 이점을 제공합니다.
- 유지 관리성 향상: 프레젠테이션 로직의 변경은 백엔드 데이터베이스 쿼리나 비즈니스 규칙의 변경을 거의 요구하지 않으며, 그 반대의 경우도 마찬가지입니다.
- 테스트 용이성 향상: 백엔드 로직은 템플릿을 렌더링할 필요 없이 독립적으로 테스트할 수 있습니다. 마찬가지로 템플릿은 모의 데이터로 테스트할 수 있습니다.
- 더 큰 유연성: 템플릿 엔진을 전환하거나 심지어 동일한 백엔드 로직으로 완전히 다른 출력 형식(예: API용 JSON)을 렌더링할 수 있으므로 변경이 최소화됩니다.
- 명확한 역할 분담: 개발자는 전문화될 수 있습니다. 백엔드 개발자는 데이터와 로직에 집중하고, 프런트엔드 개발자(또는 디자이너)는 프레젠테이션에 집중합니다.
- 문제 해결 간소화: 문제가 발생하면 백엔드 데이터 문제인지 프런트엔드 렌더링 문제인지 쉽게 파악할 수 있습니다.
추가 분리: 뷰 모델 / DTO
더 큰 규모의 애플리케이션, 특히 대규모 애플리케이션에서는 "뷰 모델" 또는 "데이터 전송 객체(DTO)"를 도입하는 것이 일반적입니다. 템플릿에 원시 데이터베이스 모델 또는 내부 비즈니스 객체를 직접 전달하는 대신, 백엔드는 템플릿의 요구 사항에 전적으로 맞춰진 전용 뷰 모델 구조체/클래스로 매핑합니다. 이렇게 하면 템플릿이 애플리케이션 도메인 모델의 내부 구조를 전혀 알지 못하게 되어 추가적인 추상화 및 격리 계층이 제공됩니다.
결론
백엔드 프레임워크를 템플릿 엔진으로부터 분리하는 것은 단순한 학문적 연습이 아니라, 더 견고하고, 유지 관리 가능하며, 확장 가능한 웹 애플리케이션으로 이어지는 실용적인 접근 방식입니다. 명확한 데이터 컨텍스트를 명시적으로 준비하고 전달함으로써 백엔드 프레임워크는 핵심 책임을 중심으로 기능할 수 있으며, 프레젠테이션 문제는 전적으로 템플릿 엔진에 맡길 수 있습니다. 이러한 관심사의 명확한 분리는 적응성 있고 탄력적인 소프트웨어 아키텍처를 생산하는 기반을 이룹니다.