Ver3.0 ๐ ์ค์ํํธ๋ก ์๋ฒ ๋ง๋ค๊ธฐ! Vapor & Perfect ์์ ์ ๋ณต

๐ ์ค์ํํธ๋ก ์๋ฒ ๋ง๋ค๊ธฐ! Vapor & Perfect ์์ ์ ๋ณต
iOS ๊ฐ๋ฐ์๊ฐ ๋ฐฑ์๋๊น์ง? ์ด์ Swift๋ก ํ์คํ ๊ฐ๋ฐ์ ๋๋ ๋ฒ ๐ฏ
์ฒ์ ๋ค์ผ๋ฉด "๋ญ? Swift๋ก ์๋ฒ๋ฅผ?" ์ด๋ฐ ๋ฐ์ ๋์ฌ ์ ์์ด. ๋๋ ์ฒ์์ ๊ทธ๋ฌ๊ฑฐ๋ . ๊ทผ๋ฐ ์๊ฐํด๋ณด๋ฉด ์์ ํฉ๋ฆฌ์ ์ด์ผ. ํ๋ก ํธ์๋(iOS ์ฑ)๋ ๋ฐฑ์๋(์๋ฒ)๋ฅผ ๊ฐ์ ์ธ์ด๋ก ๊ฐ๋ฐํ๋ฉด ์ผ๋ง๋ ํธํด? ์ฝ๋ ์ฌ์ฌ์ฉ๋ ๋๊ณ , ํ ๋ด์์ ์ปค๋ฎค๋์ผ์ด์ ๋ ํจ์ฌ ์์ํ๊ณ !
2015๋ ์ ํ์ด Swift๋ฅผ ์คํ์์ค๋ก ๊ณต๊ฐํ๋ฉด์ ์๋ฒ ์ฌ์ด๋ Swift์ ๊ฐ๋ฅ์ฑ์ด ์ด๋ ธ์ด. ๊ทธ๋ฆฌ๊ณ ์ง๊ธ์ **Vapor**์ **Perfect** ๊ฐ์ ๊ฐ๋ ฅํ ํ๋ ์์ํฌ๋ค์ด ๋์์ ์ค์ ํ๋ก๋์ ํ๊ฒฝ์์๋ ์ถฉ๋ถํ ์ฌ์ฉํ ์ ์๋ ์์ค์ด ๋์ง. ๐ฅ
โข ์ฑ๋ฅ: C/C++ ์์ค์ ๋น ๋ฅธ ์๋๋ฅผ ์๋ํด
โข ๋ชจ๋ํ ๋ฌธ๋ฒ: ์ต์ ๋, ํด๋ก์ , ํ๋กํ ์ฝ ์งํฅ ํ๋ก๊ทธ๋๋ฐ ๋ฑ ๊ฐ๋ฐ์ ์นํ์
โข ์ฝ๋ ๊ณต์ : iOS/macOS ์ฑ๊ณผ ์๋ฒ ๊ฐ ๋ชจ๋ธ ์ฝ๋๋ฅผ ๊ทธ๋๋ก ๊ณต์ ๊ฐ๋ฅ
โข ๋ฉ๋ชจ๋ฆฌ ์์ ์ฑ: ARC(Automatic Reference Counting)๋ก ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ๊ฐ ์๋ํ๋ผ
### ๐ต Vapor - ํ๋์ ์ด๊ณ ํ๋ฐํ ์ปค๋ฎค๋ํฐ Vapor๋ 2016๋ ์ ์ฒ์ ๋ฑ์ฅํ๊ณ , ํ์ฌ ๊ฐ์ฅ ์ธ๊ธฐ ์๋ Swift ์๋ฒ ํ๋ ์์ํฌ์ผ. GitHub ์คํ๊ฐ 2๋ง ๊ฐ๊ฐ ๋์ ์ ๋๋ก ์ปค๋ฎค๋ํฐ๊ฐ ํ๋ฐํด.
Async/Await๋ฅผ ์๋ฒฝํ๊ฒ ์ง์ํ๊ณ , Swift Concurrency ๋ชจ๋ธ์ ์ ๊ทน ํ์ฉํด. ๋๋ถ์ ๋น๋๊ธฐ ์ฝ๋๋ฅผ ๋ง์น ๋๊ธฐ ์ฝ๋์ฒ๋ผ ๊น๋ํ๊ฒ ์์ฑํ ์ ์์ด.
2. ํ๋ถํ ์ํ๊ณ
ORM(Fluent), ํ ํ๋ฆฟ ์์ง(Leaf), ์ธ์ฆ ์์คํ , WebSocket ์ง์ ๋ฑ ํ์ํ ๊ฑฐ์ ๋ชจ๋ ๊ธฐ๋ฅ์ด ๊ณต์ ํจํค์ง๋ก ์ ๊ณต๋ผ. ๋ฐํด๋ฅผ ๋ค์ ๋ฐ๋ช ํ ํ์๊ฐ ์์ง!
3. ํ๋ฅญํ ๋ฌธ์ํ
๊ณต์ ๋ฌธ์๊ฐ ์ ๋ง ์ ๋์ด ์์ด. ํํ ๋ฆฌ์ผ๋ถํฐ ๊ณ ๊ธ ๊ธฐ๋ฅ๊น์ง ๋จ๊ณ๋ณ๋ก ์ค๋ช ๋์ด ์์ด์ ์ด๋ณด์๋ ์ฝ๊ฒ ์์ํ ์ ์์ด.
4. ํ๋ฐํ ์ ๋ฐ์ดํธ
Swift์ ์๋ก์ด ๊ธฐ๋ฅ์ด ๋์ค๋ฉด ๋น ๋ฅด๊ฒ ์ ์ฉ๋ผ. Swift 5.5์ async/await๋ ๋ฐ๋ก ์ง์ํ๊ณ , ์ต์ ํธ๋ ๋๋ฅผ ์ ๋ฐ๋ผ๊ฐ.
์ค๋ ๊ธฐ๊ฐ ๊ฒ์ฆ๋ ํ๋ ์์ํฌ๋ผ ํ๋ก๋์ ํ๊ฒฝ์์ ์์ ์ ์ผ๋ก ๋์ํด. ์์์น ๋ชปํ ๋ฒ๊ทธ๊ฐ ์ ์ด.
2. ๋ชจ๋์ ์ค๊ณ
ํ์ํ ๊ธฐ๋ฅ๋ง ๊ณจ๋ผ์ ์ฌ์ฉํ ์ ์์ด. ๊ฐ๋ฒผ์ด API ์๋ฒ๋ฅผ ๋ง๋ค๊ณ ์ถ๋ค๋ฉด ์ต์ํ์ ๋ชจ๋๋ง ํฌํจ์ํฌ ์ ์์ง.
3. C ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํตํฉ
๊ธฐ์กด C/C++ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์์ ํตํฉ์ด ์๋์ ์ผ๋ก ์ฌ์. ๋ ๊ฑฐ์ ์์คํ ๊ณผ ์ฐ๋ํด์ผ ํ๋ค๋ฉด ์ ๋ฆฌํ ์ ์์ด.
4. ๋ฎ์ ๋ฌ๋ ์ปค๋ธ
API๊ฐ ๋น๊ต์ ๋จ์ํ๊ณ ์ง๊ด์ ์ด์ผ. ๋ณต์กํ ์ถ์ํ๊ฐ ์ ์ด์ ๋ด๋ถ ๋์์ ์ดํดํ๊ธฐ ์ฌ์.
โข ์ต์ Swift ๊ธฐ๋ฅ์ ์ ๊ทน ํ์ฉํ๊ณ ์ถ์ ๋
โข ํ๋ถํ ์ปค๋ฎค๋ํฐ ์ง์์ด ํ์ํ ๋
โข ๋น ๋ฅด๊ฒ ํ๋กํ ํ์ ์ ๋ง๋ค๊ณ ์ถ์ ๋
โข ์ฅ๊ธฐ์ ์ธ ์ ์ง๋ณด์๋ฅผ ๊ณ ๋ คํ ๋
Perfect๋ฅผ ์ ํํด์ผ ํ ๋:
โข ์์ ์ฑ์ด ์ต์ฐ์ ์ผ ๋
โข ๊ฐ๋ฒผ์ด ์๋ฒ๊ฐ ํ์ํ ๋
โข C ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํตํฉ์ด ํ์ํ ๋
โข ๋จ์ํ API ๊ตฌ์กฐ๋ฅผ ์ ํธํ ๋
### ๐ฆ ์ค์น ๋ฐ ํ๊ฒฝ ์ค์ ๋จผ์ Vapor๋ฅผ ์ค์นํด์ผ ํด. macOS๋ฅผ ์ฌ์ฉํ๋ค๋ฉด Homebrew๋ก ๊ฐ๋จํ๊ฒ ์ค์นํ ์ ์์ด:
brew install vaporLinux๋ฅผ ์ฌ์ฉํ๋ค๋ฉด ๊ณต์ ๋ฌธ์์ ์ค์น ๊ฐ์ด๋๋ฅผ ๋ฐ๋ผ๊ฐ๋ฉด ๋ผ. Swift 5.7 ์ด์์ด ํ์ํ๋๊น Swift ๋ฒ์ ๋ ํ์ธํด์ค!
์ค์น๊ฐ ๋๋ฌ์ผ๋ฉด ์ ํ๋ก์ ํธ๋ฅผ ๋ง๋ค์ด๋ณด์:
vapor new MyFirstServer
cd MyFirstServer
vapor buildSources/App/ - ๋ฉ์ธ ์ ํ๋ฆฌ์ผ์ด์ ์ฝ๋
Controllers/ - ๋ผ์ฐํธ ํธ๋ค๋ฌ๋ค
Models/ - ๋ฐ์ดํฐ ๋ชจ๋ธ
Migrations/ - ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์
configure.swift - ์ฑ ์ค์
routes.swift - ๋ผ์ฐํธ ์ ์
์ด ๊ตฌ์กฐ๋ MVC ํจํด์ ๋ฐ๋ฅด๊ณ ์์ด์ ์ต์ํ ๊ฑฐ์ผ!
import Vapor
func routes(_ app: Application) throws {
// GET /hello ์๋ํฌ์ธํธ
app.get("hello") { req async -> String in
return "Hello, Vapor! ๐"
}
// GET /hello/:name ์๋ํฌ์ธํธ (ํ๋ผ๋ฏธํฐ ์ฌ์ฉ)
app.get("hello", ":name") { req async throws -> String in
guard let name = req.parameters.get("name") else {
throw Abort(.badRequest)
}
return "Hello, \(name)! ๐"
}
// POST /user ์๋ํฌ์ธํธ (JSON ๋ฐ๊ธฐ)
app.post("user") { req async throws -> User in
let user = try req.content.decode(User.self)
// ์ฌ๊ธฐ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅํ๋ ๋ก์ง์ ์ถ๊ฐํ ์ ์์ด
return user
}
}
// User ๋ชจ๋ธ ์ ์
struct User: Content {
var name: String
var email: String
var age: Int
}๋ณด๋ค์ํผ ์ฝ๋๊ฐ ์์ฒญ ์ง๊ด์ ์ด์ง? async/await๋ฅผ ์ฌ์ฉํด์ ๋น๋๊ธฐ ์ฝ๋๋ ๊น๋ํ๊ฒ ์์ฑํ ์ ์์ด.
์๋ฒ๋ฅผ ์คํํด๋ณด์:
vapor run์ด์ ๋ธ๋ผ์ฐ์ ๋ Postman์์ http://localhost:8080/hello์ ์ ์ํ๋ฉด "Hello, Vapor! ๐"๊ฐ ๋ณด์ผ ๊ฑฐ์ผ!
### ๐ง Fluent ์ค์ ํ๊ธฐ ๋จผ์ Package.swift์ Fluent ์์กด์ฑ์ ์ถ๊ฐํด์ผ ํด. PostgreSQL์ ์ฌ์ฉํ๋ค๊ณ ๊ฐ์ ํ๋ฉด:
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0")
]๊ทธ ๋ค์ configure.swift์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ค์ ํด:
import Fluent
import FluentPostgresDriver
import Vapor
public func configure(_ app: Application) throws {
// ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค์
app.databases.use(.postgres(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? 5432,
username: Environment.get("DATABASE_USERNAME") ?? "vapor",
password: Environment.get("DATABASE_PASSWORD") ?? "password",
database: Environment.get("DATABASE_NAME") ?? "vapor_db"
), as: .psql)
// ๋ง์ด๊ทธ๋ ์ด์
์ถ๊ฐ
app.migrations.add(CreateUser())
// ๋ผ์ฐํธ ๋ฑ๋ก
try routes(app)
}import Fluent
import Vapor
final class User: Model, Content {
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "email")
var email: String
@Field(key: "age")
var age: Int
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
init() { }
init(id: UUID? = nil, name: String, email: String, age: Int) {
self.id = id
self.name = name
self.email = email
self.age = age
}
}์ฌ๊ธฐ์ ์ฃผ๋ชฉํ ์ ๋ค:
โข @ID: ์๋์ผ๋ก ์์ฑ๋๋ UUID ๊ธฐ๋ณธ ํค
โข @Field: ์ผ๋ฐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ๋
โข @Timestamp: ์์ฑ/์์ ์๊ฐ ์๋ ๊ด๋ฆฌ
### ๐ ๋ง์ด๊ทธ๋ ์ด์ ๋ง๋ค๊ธฐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง๋ฅผ ์์ฑํ๋ ค๋ฉด ๋ง์ด๊ทธ๋ ์ด์ ์ ์ ์ํด์ผ ํด:
import Fluent
struct CreateUser: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("users")
.id()
.field("name", .string, .required)
.field("email", .string, .required)
.field("age", .int, .required)
.field("created_at", .datetime)
.field("updated_at", .datetime)
.unique(on: "email") // ์ด๋ฉ์ผ ์ค๋ณต ๋ฐฉ์ง
.create()
}
func revert(on database: Database) async throws {
try await database.schema("users").delete()
}
}๋ง์ด๊ทธ๋ ์ด์ ์ ์คํํ๋ ค๋ฉด:
vapor run migrate### ๐ฎ CRUD ์์ ๊ตฌํํ๊ธฐ ์ด์ ์ค์ ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ฌ์ฉํ๋ API๋ฅผ ๋ง๋ค์ด๋ณด์:
import Vapor
func routes(_ app: Application) throws {
let users = app.grouped("users")
// CREATE - ์ ์ฌ์ฉ์ ์์ฑ
users.post { req async throws -> User in
let user = try req.content.decode(User.self)
try await user.save(on: req.db)
return user
}
// READ - ๋ชจ๋ ์ฌ์ฉ์ ์กฐํ
users.get { req async throws -> [User] in
try await User.query(on: req.db).all()
}
// READ - ํน์ ์ฌ์ฉ์ ์กฐํ
users.get(":id") { req async throws -> User in
guard let user = try await User.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
return user
}
// UPDATE - ์ฌ์ฉ์ ์ ๋ณด ์์
users.put(":id") { req async throws -> User in
guard let user = try await User.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
let updatedUser = try req.content.decode(User.self)
user.name = updatedUser.name
user.email = updatedUser.email
user.age = updatedUser.age
try await user.save(on: req.db)
return user
}
// DELETE - ์ฌ์ฉ์ ์ญ์
users.delete(":id") { req async throws -> HTTPStatus in
guard let user = try await User.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
try await user.delete(on: req.db)
return .noContent
}
}์์ ๊น๋ํ์ง? ๐ Fluent์ ์ฟผ๋ฆฌ ๋น๋๋ฅผ ์ฌ์ฉํ๋ฉด ๋ณต์กํ SQL ์์ด๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์์ ์ ํ ์ ์์ด!
### ๐ซ JWT๋? JWT๋ ์ฌ์ฉ์ ์ธ์ฆ ์ ๋ณด๋ฅผ ์์ ํ๊ฒ ์ ๋ฌํ๋ ํ ํฐ ๋ฐฉ์์ด์ผ. ์๋ฒ๊ฐ ํ ํฐ์ ๋ฐ๊ธํ๋ฉด ํด๋ผ์ด์ธํธ๋ ์ด ํ ํฐ์ ํค๋์ ํฌํจ์์ผ์ ์์ฒญ์ ๋ณด๋ด๋ ๊ฑฐ์ง. ์ธ์ ๋ฐฉ์๋ณด๋ค ํ์ฅ์ฑ์ด ์ข์์ RESTful API์์ ๋ง์ด ์ฌ์ฉํด.
### ๐ฆ JWT ํจํค์ง ์ถ๊ฐํ๊ธฐ ๋จผ์ Package.swift์ JWT ํจํค์ง๋ฅผ ์ถ๊ฐํด:
dependencies: [
.package(url: "https://github.com/vapor/jwt.git", from: "4.0.0")
]### ๐ JWT ์ค์ ํ๊ธฐ configure.swift์์ JWT ์ํฌ๋ฆฟ ํค๋ฅผ ์ค์ ํด:
import JWT
public func configure(_ app: Application) throws {
// JWT ์ํฌ๋ฆฟ ํค ์ค์ (ํ๊ฒฝ ๋ณ์์์ ๊ฐ์ ธ์ค๊ธฐ)
app.jwt.signers.use(.hs256(key: Environment.get("JWT_SECRET") ?? "secret-key"))
// ... ๋๋จธ์ง ์ค์
}import Vapor
import JWT
// JWT ํ์ด๋ก๋ ์ ์
struct UserPayload: JWTPayload {
var userId: UUID
var email: String
var exp: ExpirationClaim // ๋ง๋ฃ ์๊ฐ
func verify(using signer: JWTSigner) throws {
try exp.verifyNotExpired()
}
}
// ๋ก๊ทธ์ธ ์์ฒญ ๊ตฌ์กฐ์ฒด
struct LoginRequest: Content {
var email: String
var password: String
}
// ๋ก๊ทธ์ธ ์๋ต ๊ตฌ์กฐ์ฒด
struct LoginResponse: Content {
var token: String
var user: User.Public
}
extension User {
struct Public: Content {
var id: UUID?
var name: String
var email: String
}
var publicView: Public {
Public(id: id, name: name, email: email)
}
}
func routes(_ app: Application) throws {
// ํ์๊ฐ์
app.post("register") { req async throws -> User.Public in
let user = try req.content.decode(User.self)
// ๋น๋ฐ๋ฒํธ ํด์ฑ (Bcrypt ์ฌ์ฉ)
user.passwordHash = try Bcrypt.hash(user.password)
try await user.save(on: req.db)
return user.publicView
}
// ๋ก๊ทธ์ธ
app.post("login") { req async throws -> LoginResponse in
let loginRequest = try req.content.decode(LoginRequest.self)
// ์ด๋ฉ์ผ๋ก ์ฌ์ฉ์ ์ฐพ๊ธฐ
guard let user = try await User.query(on: req.db)
.filter(\.$email == loginRequest.email)
.first() else {
throw Abort(.unauthorized, reason: "Invalid email or password")
}
// ๋น๋ฐ๋ฒํธ ๊ฒ์ฆ
guard try Bcrypt.verify(loginRequest.password, created: user.passwordHash) else {
throw Abort(.unauthorized, reason: "Invalid email or password")
}
// JWT ํ ํฐ ์์ฑ
let payload = UserPayload(
userId: try user.requireID(),
email: user.email,
exp: .init(value: Date().addingTimeInterval(86400)) // 24์๊ฐ ํ ๋ง๋ฃ
)
let token = try req.jwt.sign(payload)
return LoginResponse(token: token, user: user.publicView)
}
}### ๐ก๏ธ ์ธ์ฆ ๋ฏธ๋ค์จ์ด ๋ง๋ค๊ธฐ ์ด์ ๋ณดํธ๋ ๋ผ์ฐํธ๋ฅผ ๋ง๋ค์ด๋ณด์. ๋ฏธ๋ค์จ์ด๋ฅผ ์ฌ์ฉํ๋ฉด ํน์ ๋ผ์ฐํธ์๋ง ์ธ์ฆ์ ์ ์ฉํ ์ ์์ด:
import Vapor
import JWT
struct JWTAuthenticator: AsyncRequestAuthenticator {
func authenticate(request: Request) async throws {
// Authorization ํค๋์์ ํ ํฐ ์ถ์ถ
guard let token = request.headers.bearerAuthorization?.token else {
return
}
// ํ ํฐ ๊ฒ์ฆ ๋ฐ ํ์ด๋ก๋ ์ถ์ถ
let payload = try request.jwt.verify(token, as: UserPayload.self)
// ์ฌ์ฉ์ ์ ๋ณด ์กฐํ
guard let user = try await User.find(payload.userId, on: request.db) else {
return
}
// ์ธ์ฆ๋ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ request์ ์ ์ฅ
request.auth.login(user)
}
}
// ๋ณดํธ๋ ๋ผ์ฐํธ ๊ทธ๋ฃน ๋ง๋ค๊ธฐ
func routes(_ app: Application) throws {
let protected = app.grouped(JWTAuthenticator())
.grouped(User.guardMiddleware())
// ์ธ์ฆ์ด ํ์ํ ๋ผ์ฐํธ๋ค
protected.get("profile") { req async throws -> User.Public in
let user = try req.auth.require(User.self)
return user.publicView
}
protected.put("profile") { req async throws -> User.Public in
let user = try req.auth.require(User.self)
let updatedData = try req.content.decode(User.self)
user.name = updatedData.name
try await user.save(on: req.db)
return user.publicView
}
}์ด์ ํด๋ผ์ด์ธํธ๋ ๋ก๊ทธ์ธ ํ ๋ฐ์ ํ ํฐ์ ํค๋์ ํฌํจ์์ผ์ ์์ฒญ์ ๋ณด๋ด๋ฉด ๋ผ:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...์๋ฒฝํ์ง? ์ด์ ์์ ํ API๋ฅผ ๋ง๋ค ์ ์์ด! ๐
### ๐ Perfect ์์ํ๊ธฐ Perfect๋ Swift Package Manager๋ฅผ ์ฌ์ฉํด์ ํ๋ก์ ํธ๋ฅผ ๋ง๋ค์ด. ๋จผ์ ์ ๋๋ ํ ๋ฆฌ๋ฅผ ๋ง๋ค๊ณ ํจํค์ง๋ฅผ ์ด๊ธฐํํด:
mkdir MyPerfectServer
cd MyPerfectServer
swift package init --type executable๊ทธ ๋ค์ Package.swift๋ฅผ ์์ ํด์ Perfect ์์กด์ฑ์ ์ถ๊ฐํด:
// swift-tools-version:5.7
import PackageDescription
let package = Package(
name: "MyPerfectServer",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/PerfectlySoft/Perfect-HTTPServer.git", from: "3.0.0")
],
targets: [
.executableTarget(
name: "MyPerfectServer",
dependencies: [
.product(name: "PerfectHTTPServer", package: "Perfect-HTTPServer")
]
)
]
)### ๐ฏ ์ฒซ ๋ฒ์งธ Perfect ์๋ฒ ๋ง๋ค๊ธฐ main.swift ํ์ผ์ ๋ง๋ค๊ณ ๊ฐ๋จํ ์๋ฒ๋ฅผ ์์ฑํด๋ณด์:
import PerfectHTTP
import PerfectHTTPServer
// ๋ผ์ฐํธ ์ค์
var routes = Routes()
// GET /hello ์๋ํฌ์ธํธ
routes.add(method: .get, uri: "/hello") { request, response in
response.setHeader(.contentType, value: "application/json")
response.setBody(string: "{\"message\": \"Hello, Perfect! ๐\"}")
response.completed()
}
// GET /hello/:name ์๋ํฌ์ธํธ
routes.add(method: .get, uri: "/hello/{name}") { request, response in
let name = request.urlVariables["name"] ?? "Guest"
response.setHeader(.contentType, value: "application/json")
response.setBody(string: "{\"message\": \"Hello, \(name)! ๐\"}")
response.completed()
}
// POST /user ์๋ํฌ์ธํธ
routes.add(method: .post, uri: "/user") { request, response in
do {
guard let jsonData = request.postBodyString?.data(using: .utf8),
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let name = json["name"] as? String,
let email = json["email"] as? String else {
response.status = .badRequest
response.setBody(string: "{\"error\": \"Invalid request\"}")
response.completed()
return
}
let responseData: [String: Any] = [
"name": name,
"email": email,
"message": "User created successfully"
]
let responseJson = try JSONSerialization.data(withJSONObject: responseData)
response.setHeader(.contentType, value: "application/json")
response.setBody(bytes: Array(responseJson))
response.completed()
} catch {
response.status = .internalServerError
response.setBody(string: "{\"error\": \"Server error\"}")
response.completed()
}
}
// ์๋ฒ ์ค์
let server = HTTPServer()
server.serverPort = 8080
server.addRoutes(routes)
do {
try server.start()
print("๐ Server started on port 8080")
} catch {
print("โ Server failed to start: \(error)")
}์๋ฒ๋ฅผ ์คํํ๋ ค๋ฉด:
swift run๋ฐ๋ฉด Vapor๋ Content ํ๋กํ ์ฝ์ ์ฌ์ฉํด์ ์๋์ผ๋ก ์ธ์ฝ๋ฉ/๋์ฝ๋ฉ์ ์ฒ๋ฆฌํด์ค. ์ด๋ ๊ฒ ๋ ์ข๋ค๊ธฐ๋ณด๋ค๋ ์คํ์ผ์ ์ฐจ์ด์ผ. Perfect๋ ๋ ๋ง์ ์ ์ด๊ถ์ ์ฃผ๊ณ , Vapor๋ ๋ ๋ง์ ํธ์์ฑ์ ์ ๊ณตํ๋ ๊ฑฐ์ง!
๋จผ์ ํจํค์ง ์์กด์ฑ์ ์ถ๊ฐํด:
dependencies: [
.package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", from: "3.0.0")
]๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ ์ฝ๋:
import PerfectPostgreSQL
// ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ ์ค์
let connection = PGConnection()
let status = connection.connectdb(
"host=localhost port=5432 dbname=mydb user=postgres password=password"
)
guard status == .ok else {
print("โ Database connection failed")
exit(1)
}
// ์ฟผ๋ฆฌ ์คํ ํจ์
func executeQuery(_ query: String) -> PGResult? {
return connection.exec(statement: query)
}
// ์ฌ์ฉ์ ์์ฑ ๋ผ์ฐํธ
routes.add(method: .post, uri: "/user") { request, response in
guard let jsonData = request.postBodyString?.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let name = json["name"] as? String,
let email = json["email"] as? String else {
response.status = .badRequest
response.completed()
return
}
let query = """
INSERT INTO users (name, email)
VALUES ('\(name)', '\(email)')
RETURNING id, name, email
"""
guard let result = executeQuery(query),
result.status() == .tuplesOK else {
response.status = .internalServerError
response.completed()
return
}
// ๊ฒฐ๊ณผ ํ์ฑ
let id = result.getFieldString(tupleIndex: 0, fieldIndex: 0) ?? ""
let userName = result.getFieldString(tupleIndex: 0, fieldIndex: 1) ?? ""
let userEmail = result.getFieldString(tupleIndex: 0, fieldIndex: 2) ?? ""
let responseData: [String: Any] = [
"id": id,
"name": userName,
"email": userEmail
]
if let responseJson = try? JSONSerialization.data(withJSONObject: responseData) {
response.setHeader(.contentType, value: "application/json")
response.setBody(bytes: Array(responseJson))
}
response.completed()
}์ ์์ ๋ ๊ต์ก ๋ชฉ์ ์ด์ผ. ์ค์ ํ๋ก๋์ ์ฝ๋์์๋ ์ ๋ ๋ฌธ์์ด ๋ณด๊ฐ์ผ๋ก SQL ์ฟผ๋ฆฌ๋ฅผ ๋ง๋ค๋ฉด ์ ๋ผ! Prepared Statement๋ฅผ ์ฌ์ฉํด์ผ ํด:
let query = "INSERT INTO users (name, email) VALUES ($1, $2)"
let result = connection.exec(statement: query, params: [name, email])
### ๐ RESTful ์์น REST(Representational State Transfer)๋ ์น API ์ค๊ณ์ ํ์ค์ด์ผ. ๋ช ๊ฐ์ง ํต์ฌ ์์น์ ์ดํด๋ณด์:
URL์ ๋์ฌ๊ฐ ์๋๋ผ ๋ช ์ฌ๋ก ๊ตฌ์ฑํด. ๋ฆฌ์์ค๋ฅผ ๋ํ๋ด์ผ ํด.
โ GET /users
โ GET /getUsers
2. HTTP ๋ฉ์๋ ํ์ฉ
โข GET: ์กฐํ
โข POST: ์์ฑ
โข PUT/PATCH: ์์
โข DELETE: ์ญ์
3. ๊ณ์ธต์ ๊ตฌ์กฐ
๋ฆฌ์์ค ๊ฐ์ ๊ด๊ณ๋ฅผ URL๋ก ํํํด.
GET /users/123/posts - 123๋ฒ ์ฌ์ฉ์์ ๊ฒ์๊ธ๋ค
GET /users/123/posts/456 - 123๋ฒ ์ฌ์ฉ์์ 456๋ฒ ๊ฒ์๊ธ
4. ์ ์ ํ HTTP ์ํ ์ฝ๋
โข 200: ์ฑ๊ณต
โข 201: ์์ฑ ์ฑ๊ณต
โข 400: ์๋ชป๋ ์์ฒญ
โข 401: ์ธ์ฆ ํ์
โข 403: ๊ถํ ์์
โข 404: ๋ฆฌ์์ค ์์
โข 500: ์๋ฒ ์๋ฌ
5. ๋ฒ์ ๊ด๋ฆฌ
API ๋ฒ์ ์ URL์ ํฌํจ์์ผ.
/api/v1/users
/api/v2/users
import Vapor
import Fluent
// ๊ฒ์๊ธ ๋ชจ๋ธ
final class Post: Model, Content {
static let schema = "posts"
@ID(key: .id)
var id: UUID?
@Field(key: "title")
var title: String
@Field(key: "content")
var content: String
@Parent(key: "user_id")
var user: User
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Children(for: \.$post)
var comments: [Comment]
init() { }
}
// ๋๊ธ ๋ชจ๋ธ
final class Comment: Model, Content {
static let schema = "comments"
@ID(key: .id)
var id: UUID?
@Field(key: "content")
var content: String
@Parent(key: "post_id")
var post: Post
@Parent(key: "user_id")
var user: User
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() { }
}
// API ๋ผ์ฐํธ ์ค์
func routes(_ app: Application) throws {
let api = app.grouped("api", "v1")
// ๊ฒ์๊ธ ๊ด๋ จ ๋ผ์ฐํธ
let posts = api.grouped("posts")
// ๋ชจ๋ ๊ฒ์๊ธ ์กฐํ (ํ์ด์ง๋ค์ด์
ํฌํจ)
posts.get { req async throws -> Page<Post> in
try await Post.query(on: req.db)
.with(\.$user)
.paginate(for: req)
}
// ํน์ ๊ฒ์๊ธ ์กฐํ
posts.get(":id") { req async throws -> Post in
guard let post = try await Post.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
try await post.$user.load(on: req.db)
try await post.$comments.load(on: req.db)
return post
}
// ๊ฒ์๊ธ ์์ฑ (์ธ์ฆ ํ์)
let protected = posts.grouped(JWTAuthenticator()).grouped(User.guardMiddleware())
protected.post { req async throws -> Post in
let user = try req.auth.require(User.self)
let post = try req.content.decode(Post.self)
post.$user.id = try user.requireID()
try await post.save(on: req.db)
return post
}
// ๊ฒ์๊ธ ์์
protected.put(":id") { req async throws -> Post in
let user = try req.auth.require(User.self)
guard let post = try await Post.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
// ์์ฑ์๋ง ์์ ๊ฐ๋ฅ
guard post.$user.id == user.id else {
throw Abort(.forbidden)
}
let updatedPost = try req.content.decode(Post.self)
post.title = updatedPost.title
post.content = updatedPost.content
try await post.save(on: req.db)
return post
}
// ๊ฒ์๊ธ ์ญ์
protected.delete(":id") { req async throws -> HTTPStatus in
let user = try req.auth.require(User.self)
guard let post = try await Post.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
guard post.$user.id == user.id else {
throw Abort(.forbidden)
}
try await post.delete(on: req.db)
return .noContent
}
// ๋๊ธ ๊ด๋ จ ๋ผ์ฐํธ
let comments = posts.group(":postId", "comments")
// ํน์ ๊ฒ์๊ธ์ ๋๊ธ ์กฐํ
comments.get { req async throws -> [Comment] in
guard let postId = req.parameters.get("postId", as: UUID.self) else {
throw Abort(.badRequest)
}
return try await Comment.query(on: req.db)
.filter(\.$post.$id == postId)
.with(\.$user)
.all()
}
// ๋๊ธ ์์ฑ
let protectedComments = comments.grouped(JWTAuthenticator()).grouped(User.guardMiddleware())
protectedComments.post { req async throws -> Comment in
let user = try req.auth.require(User.self)
guard let postId = req.parameters.get("postId", as: UUID.self) else {
throw Abort(.badRequest)
}
let comment = try req.content.decode(Comment.self)
comment.$user.id = try user.requireID()
comment.$post.id = postId
try await comment.save(on: req.db)
return comment
}
}๋๋์ ๋ฐ์ดํฐ๋ฅผ ํ ๋ฒ์ ๋ฐํํ๋ฉด ์ฑ๋ฅ ๋ฌธ์ ๊ฐ ์๊ฒจ. Vapor์ paginate() ๋ฉ์๋๋ฅผ ์ฌ์ฉํ๋ฉด ์ฝ๊ฒ ํ์ด์ง๋ค์ด์ ์ ๊ตฌํํ ์ ์์ด:
// ์์ฒญ: GET /posts?page=2&per=20
// 2ํ์ด์ง, ํ์ด์ง๋น 20๊ฐ ํญ๋ชฉ์๋ต ํ์:
{
"items": [...],
"metadata": {
"page": 2,
"per": 20,
"total": 150
}
}
// ์ฑ๊ณต ์๋ต
struct APIResponse<T: Content>: Content {
var success: Bool
var data: T?
var message: String?
static func success(data: T, message: String? = nil) -> APIResponse {
APIResponse(success: true, data: data, message: message)
}
}
// ์๋ฌ ์๋ต
struct APIError: Content {
var success: Bool = false
var error: ErrorDetail
struct ErrorDetail: Content {
var code: String
var message: String
var details: [String: String]?
}
}
// ์ฌ์ฉ ์์
posts.get { req async throws -> APIResponse<[Post]> in
let posts = try await Post.query(on: req.db).all()
return .success(data: posts, message: "Posts retrieved successfully")
}
### ๐ฏ Vapor ํ ์คํธ ์์ฑํ๊ธฐ Vapor๋ XCTest๋ฅผ ์ฌ์ฉํ ํ ์คํธ๋ฅผ ์ง์ํด. Tests ๋๋ ํ ๋ฆฌ์ ํ ์คํธ ํ์ผ์ ๋ง๋ค์ด๋ณด์:
import XCTVapor
@testable import App
final class UserTests: XCTestCase {
var app: Application!
override func setUp() async throws {
app = Application(.testing)
try configure(app)
try await app.autoMigrate()
}
override func tearDown() async throws {
try await app.autoRevert()
app.shutdown()
}
// ์ฌ์ฉ์ ์์ฑ ํ
์คํธ
func testCreateUser() async throws {
let userData = User(name: "Test User", email: "test@example.com", age: 25)
try app.test(.POST, "users", beforeRequest: { req in
try req.content.encode(userData)
}, afterResponse: { res in
XCTAssertEqual(res.status, .ok)
let user = try res.content.decode(User.self)
XCTAssertEqual(user.name, "Test User")
XCTAssertEqual(user.email, "test@example.com")
XCTAssertNotNil(user.id)
})
}
// ์ฌ์ฉ์ ์กฐํ ํ
์คํธ
func testGetUser() async throws {
// ํ
์คํธ ๋ฐ์ดํฐ ์์ฑ
let user = User(name: "Test User", email: "test@example.com", age: 25)
try await user.save(on: app.db)
let userId = try user.requireID()
try app.test(.GET, "users/\(userId)", afterResponse: { res in
XCTAssertEqual(res.status, .ok)
let fetchedUser = try res.content.decode(User.self)
XCTAssertEqual(fetchedUser.id, user.id)
XCTAssertEqual(fetchedUser.name, user.name)
})
}
// ์ธ์ฆ ํ
์คํธ
func testAuthenticatedRoute() async throws {
// ์ฌ์ฉ์ ์์ฑ ๋ฐ ๋ก๊ทธ์ธ
let user = User(name: "Test User", email: "test@example.com", age: 25)
user.passwordHash = try Bcrypt.hash("password123")
try await user.save(on: app.db)
var token: String!
try app.test(.POST, "login", beforeRequest: { req in
try req.content.encode(LoginRequest(email: "test@example.com", password: "password123"))
}, afterResponse: { res in
let response = try res.content.decode(LoginResponse.self)
token = response.token
})
// ์ธ์ฆ์ด ํ์ํ ๋ผ์ฐํธ ํ
์คํธ
try app.test(.GET, "profile", beforeRequest: { req in
req.headers.bearerAuthorization = BearerAuthorization(token: token)
}, afterResponse: { res in
XCTAssertEqual(res.status, .ok)
})
}
// ์๋ฌ ์ฒ๋ฆฌ ํ
์คํธ
func testNotFound() async throws {
try app.test(.GET, "users/00000000-0000-0000-0000-000000000000", afterResponse: { res in
XCTAssertEqual(res.status, .notFound)
})
}
}ํ ์คํธ๋ฅผ ์คํํ๋ ค๋ฉด:
swift test- Given: ์ด๊ธฐ ์ํ ์ค์
- When: ํ ์คํธํ ๋์ ์คํ
- Then: ๊ฒฐ๊ณผ ๊ฒ์ฆ
โข ๋ ๋ฆฝ์ ์ธ ํ ์คํธ: ๊ฐ ํ ์คํธ๋ ์๋ก ์ํฅ์ ์ฃผ๋ฉด ์ ๋ผ
โข ์๋ฏธ ์๋ ์ด๋ฆ: ํ ์คํธ ์ด๋ฆ๋ง ๋ด๋ ๋ฌด์์ ํ ์คํธํ๋์ง ์ ์ ์์ด์ผ ํด
โข ์ฃ์ง ์ผ์ด์ค: ์ ์ ์ผ์ด์ค๋ฟ๋ง ์๋๋ผ ์๋ฌ ์ํฉ๋ ํ ์คํธํด
| ๊ตฌ๋ถ | ๋จ์ ํ ์คํธ | ํตํฉ ํ ์คํธ |
|---|---|---|
| ๋ฒ์ | ๊ฐ๋ณ ํจ์/๋ฉ์๋ | ์ฌ๋ฌ ์ปดํฌ๋ํธ์ ์ํธ์์ฉ |
| ์๋ | ๋น ๋ฆ | ๋๋ฆผ |
| ์์กด์ฑ | Mock/Stub ์ฌ์ฉ | ์ค์ ์์กด์ฑ ์ฌ์ฉ |
| ๋ชฉ์ | ๋ก์ง ๊ฒ์ฆ | ์์คํ ์ ์ฒด ๋์ ๊ฒ์ฆ |
| ์คํ ๋น๋ | ๋งค์ฐ ์์ฃผ | ์๋์ ์ผ๋ก ๋ ์์ฃผ |
์ด์์ ์ผ๋ก๋ ๋ ๊ฐ์ง๋ฅผ ๋ชจ๋ ์์ฑํ๋ ๊ฒ ์ข์. ๋จ์ ํ ์คํธ๋ก ๊ฐ๋ณ ๋ก์ง์ ๊ฒ์ฆํ๊ณ , ํตํฉ ํ ์คํธ๋ก ์ ์ฒด ์์คํ ์ด ์ ๋์ํ๋์ง ํ์ธํ๋ ๊ฑฐ์ง!
### ๐ณ Docker๋ก ์ปจํ ์ด๋ํํ๊ธฐ ์์ฆ์ Docker๋ฅผ ์ฌ์ฉํ ๋ฐฐํฌ๊ฐ ํ์ค์ด์ผ. Vapor ํ๋ก์ ํธ๋ฅผ Docker ์ด๋ฏธ์ง๋ก ๋ง๋ค์ด๋ณด์:
# Dockerfile
FROM swift:5.9-jammy as build
WORKDIR /build
# ์์กด์ฑ ๋ณต์ฌ ๋ฐ ๋น๋
COPY ./Package.* ./
RUN swift package resolve
# ์์ค ์ฝ๋ ๋ณต์ฌ
COPY . .
# ๋ฆด๋ฆฌ์ฆ ๋น๋
RUN swift build -c release --static-swift-stdlib
# ๋ฐํ์ ์ด๋ฏธ์ง
FROM ubuntu:jammy
# ํ์ํ ๋ฐํ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ค์น
RUN apt-get update && apt-get install -y \
libcurl4 \
libxml2 \
&& rm -r /var/lib/apt/lists/*
# ๋น๋๋ ๋ฐ์ด๋๋ฆฌ ๋ณต์ฌ
WORKDIR /app
COPY --from=build /build/.build/release/App /app
COPY --from=build /build/Public /app/Public
COPY --from=build /build/Resources /app/Resources
# ํฌํธ ๋
ธ์ถ
EXPOSE 8080
# ์คํ
ENTRYPOINT ["./App"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]Docker ์ด๋ฏธ์ง๋ฅผ ๋น๋ํ๊ณ ์คํํด๋ณด์:
# ์ด๋ฏธ์ง ๋น๋
docker build -t my-vapor-app .
# ์ปจํ
์ด๋ ์คํ
docker run -p 8080:8080 my-vapor-app์๋ฒ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ํจ๊ป ๊ด๋ฆฌํ๋ ค๋ฉด Docker Compose๋ฅผ ์ฌ์ฉํด:
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_USERNAME: vapor
DATABASE_PASSWORD: password
DATABASE_NAME: vapor_db
JWT_SECRET: your-secret-key
depends_on:
- db
restart: unless-stopped
db:
image: postgres:15
environment:
POSTGRES_USER: vapor
POSTGRES_PASSWORD: password
POSTGRES_DB: vapor_db
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:์คํ:
docker-compose up -d
# Procfile ์์ฑ
web: App serve --env production --hostname 0.0.0.0 --port $PORT
# ๋ฐฐํฌ
heroku create my-vapor-app
git push heroku mainEC2 ๋ฐฐํฌ ์์ :
# EC2 ์ธ์คํด์ค ์ ์
ssh -i your-key.pem ubuntu@your-ec2-ip
# Docker ์ค์น
sudo apt-get update
sudo apt-get install docker.io docker-compose
# ์ฝ๋ ํด๋ก ๋ฐ ์คํ
git clone your-repo
cd your-repo
docker-compose up -d- โ ํ๊ฒฝ ๋ณ์ ์ค์ (๋ฐ์ดํฐ๋ฒ ์ด์ค ๋น๋ฐ๋ฒํธ, JWT ์ํฌ๋ฆฟ ๋ฑ)
- โ HTTPS ์ค์ (Let's Encrypt ์ฌ์ฉ ์ถ์ฒ)
- โ ๋ก๊น ์ค์ (์๋ฌ ์ถ์ ์ ์ํด)
- โ ๋ชจ๋ํฐ๋ง ์ค์ (์๋ฒ ์ํ ํ์ธ)
- โ ๋ฐฑ์ ์ ๋ต ์๋ฆฝ
- โ CI/CD ํ์ดํ๋ผ์ธ ๊ตฌ์ถ
- โ ๋ก๋ ๋ฐธ๋ฐ์ฑ (ํธ๋ํฝ์ด ๋ง์ ๊ฒฝ์ฐ)
- โ ์บ์ฑ ์ ๋ต (Redis ๋ฑ)
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Swift
uses: swift-actions/setup-swift@v1
with:
swift-version: '5.9'
- name: Run tests
run: swift test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
push: true
tags: your-username/my-vapor-app:latest
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /app
docker-compose pull
docker-compose up -d์ด์ main ๋ธ๋์น์ ํธ์ํ๋ฉด ์๋์ผ๋ก ํ ์คํธํ๊ณ ๋ฐฐํฌ๋ผ! ์์ ํธํ์ง? ๐
### ๐ ์ฑ๋ฅ ์ธก์ ํ๊ธฐ ์ต์ ํํ๊ธฐ ์ ์ ๋จผ์ ์ธก์ ํด์ผ ํด. "์ธก์ ํ ์ ์์ผ๋ฉด ๊ฐ์ ํ ์ ์๋ค"๋ ๋ง์ด ์์์?
1. Apache Bench (ab)
๊ฐ๋จํ ๋ถํ ํ ์คํธ ๋๊ตฌ:
ab -n 1000 -c 10 http://localhost:8080/api/users
1000๊ฐ ์์ฒญ์ 10๊ฐ ๋์ ์ฐ๊ฒฐ๋ก ๋ณด๋ด๋ ๊ฑฐ์ผ.2. wrk
๋ ๊ฐ๋ ฅํ HTTP ๋ฒค์น๋งํน ๋๊ตฌ:
wrk -t12 -c400 -d30s http://localhost:8080/api/users
12๊ฐ ์ค๋ ๋, 400๊ฐ ์ฐ๊ฒฐ, 30์ด ๋์ ํ
์คํธ.3. Instruments (macOS)
Xcode์ Instruments๋ฅผ ์ฌ์ฉํ๋ฉด ๋ฉ๋ชจ๋ฆฌ ๋์, CPU ์ฌ์ฉ๋ ๋ฑ์ ์์ธํ ๋ถ์ํ ์ ์์ด.
// โ N+1 ์ฟผ๋ฆฌ ๋ฌธ์
let users = try await User.query(on: db).all()
for user in users {
// ๊ฐ ์ฌ์ฉ์๋ง๋ค ๋ณ๋ ์ฟผ๋ฆฌ ์คํ (๋นํจ์จ์ !)
let posts = try await user.$posts.get(on: db)
}
// โ
Eager Loading ์ฌ์ฉ
let users = try await User.query(on: db)
.with(\.$posts) // ํ ๋ฒ์ ์ฟผ๋ฆฌ๋ก ๋ชจ๋ ๊ฒ์๊ธ ๊ฐ์ ธ์ค๊ธฐ
.all()
// โ
ํ์ํ ํ๋๋ง ์ ํ
let users = try await User.query(on: db)
.field(\.$id)
.field(\.$name)
.field(\.$email)
.all()
// โ
์ธ๋ฑ์ค ํ์ฉ
struct CreateUser: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("users")
.id()
.field("email", .string, .required)
.field("name", .string, .required)
.unique(on: "email")
.create()
// ์์ฃผ ๊ฒ์ํ๋ ํ๋์ ์ธ๋ฑ์ค ์ถ๊ฐ
try await database.schema("users")
.createIndex(on: "email")
.run()
}
}### ๐๏ธ ์บ์ฑ ์ ๋ต ์์ฃผ ์กฐํ๋๋ ๋ฐ์ดํฐ๋ ์บ์์ ์ ์ฅํ๋ฉด ์ฑ๋ฅ์ด ํฌ๊ฒ ํฅ์๋ผ. Redis๋ฅผ ์ฌ์ฉํด๋ณด์:
import Redis
// Redis ์ค์
app.redis.configuration = try RedisConfiguration(
hostname: "localhost",
port: 6379
)
// ์บ์ ์ฌ์ฉ ์์
func getUser(id: UUID, req: Request) async throws -> User {
let cacheKey = "user:\(id)"
// ์บ์์์ ๋จผ์ ํ์ธ
if let cached = try await req.redis.get(cacheKey, as: User.self) {
return cached
}
// ์บ์์ ์์ผ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์กฐํ
guard let user = try await User.find(id, on: req.db) else {
throw Abort(.notFound)
}
// ์บ์์ ์ ์ฅ (1์๊ฐ TTL)
try await req.redis.setex(cacheKey, to: user, expirationInSeconds: 3600)
return user
}
// ์บ์ ๋ฌดํจํ
func updateUser(id: UUID, req: Request) async throws -> User {
let user = try await User.find(id, on: req.db)
// ... ์
๋ฐ์ดํธ ๋ก์ง
// ์บ์ ์ญ์
try await req.redis.delete("user:\(id)")
return user
}์ ์์ ์ฒ๋ผ ํ์ํ ๋๋ง ์บ์์ ์ ์ฅ. ๊ฐ์ฅ ์ผ๋ฐ์ ์ธ ๋ฐฉ์.
Write-Through
๋ฐ์ดํฐ๋ฅผ ์ธ ๋ ์บ์์ DB์ ๋์์ ์ ์ฅ. ์ผ๊ด์ฑ์ด ์ค์ํ ๋ ์ฌ์ฉ.
Write-Behind
์บ์์ ๋จผ์ ์ฐ๊ณ ๋์ค์ DB์ ๋น๋๊ธฐ๋ก ์ ์ฅ. ์ฐ๊ธฐ ์ฑ๋ฅ์ด ์ค์ํ ๋ ์ฌ์ฉ.
// โ ์์ฐจ ์ฒ๋ฆฌ (๋๋ฆผ)
let user = try await fetchUser(id: userId, on: db)
let posts = try await fetchPosts(userId: userId, on: db)
let comments = try await fetchComments(userId: userId, on: db)
// โ
๋ณ๋ ฌ ์ฒ๋ฆฌ (๋น ๋ฆ)
async let user = fetchUser(id: userId, on: db)
async let posts = fetchPosts(userId: userId, on: db)
async let comments = fetchComments(userId: userId, on: db)
let result = try await (user, posts, comments)
// โ
TaskGroup ์ฌ์ฉ (๋ ๋ง์ ์์
์ ๋ณ๋ ฌ๋ก)
let userIds = [UUID(), UUID(), UUID()]
let users = try await withThrowingTaskGroup(of: User.self) { group in
for id in userIds {
group.addTask {
try await fetchUser(id: id, on: db)
}
}
var results: [User] = []
for try await user in group {
results.append(user)
}
return results
}### ๐ฆ ์๋ต ์์ถ HTTP ์๋ต์ ์์ถํ๋ฉด ๋คํธ์ํฌ ์ ์ก๋์ด ์ค์ด๋ค์ด:
// Vapor ์ค์
app.http.server.configuration.responseCompression = .enabled
// ํน์ ๋ผ์ฐํธ์๋ง ์ ์ฉ
let compressed = app.grouped(FileMiddleware(publicDirectory: app.directory.publicDirectory))
compressed.get("large-data") { req async throws -> [String: Any] in
// ํฐ ๋ฐ์ดํฐ ๋ฐํ
}
### ๐ซ ์ผ๋ฐ์ ์ธ ๋ณด์ ์ํ๊ณผ ๋์
1. SQL Injection
Fluent ORM์ ์ฌ์ฉํ๋ฉด ์๋์ผ๋ก ๋ฐฉ์ด๋ผ. ํ์ง๋ง Raw SQL์ ์ธ ๋๋ ์ฃผ์ํด:
// โ ์ํ
let query = "SELECT * FROM users WHERE email = '\(email)'"
// โ
์์ (Parameterized Query)
let query = "SELECT * FROM users WHERE email = $1"
let result = try await db.raw(query).bind(email).all()2. XSS (Cross-Site Scripting)
์ฌ์ฉ์ ์ ๋ ฅ์ ๊ทธ๋๋ก ์ถ๋ ฅํ๋ฉด ์ ๋ผ:
// HTML ์ด์ค์ผ์ดํ ์ฒ๋ฆฌ
import Vapor
extension String {
var htmlEscaped: String {
self.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: "\"", with: """)
.replacingOccurrences(of: "'", with: "'")
}
}3. CSRF (Cross-Site Request Forgery)
CSRF ํ ํฐ์ ์ฌ์ฉํด์ ๋ฐฉ์ด:
// CSRF ๋ฏธ๋ค์จ์ด ๊ตฌํ
struct CSRFMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
if request.method == .POST || request.method == .PUT || request.method == .DELETE {
guard let token = request.headers.first(name: "X-CSRF-Token"),
try request.application.jwt.verify(token, as: CSRFPayload.self) != nil else {
throw Abort(.forbidden, reason: "Invalid CSRF token")
}
}
return try await next.respond(to: request)
}
}4. Rate Limiting
๋ฌด์ฐจ๋ณ ๋์ ๊ณต๊ฒฉ์ ๋ฐฉ์ดํ๊ธฐ ์ํ ์์ฒญ ์ ํ:
import Redis
struct RateLimitMiddleware: AsyncMiddleware {
let maxRequests: Int
let windowSeconds: Int
func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
let ip = request.remoteAddress?.ipAddress ?? "unknown"
let key = "rate_limit:\(ip)"
let count = try await request.redis.incr(key)
if count == 1 {
try await request.redis.expire(key, after: windowSeconds)
}
if count > maxRequests {
throw Abort(.tooManyRequests, reason: "Rate limit exceeded")
}
return try await next.respond(to: request)
}
}
// ์ฌ์ฉ
app.middleware.use(RateLimitMiddleware(maxRequests: 100, windowSeconds: 60))
# Nginx ๋ฆฌ๋ฒ์ค ํ๋ก์ ์ค์
server {
listen 80;
server_name yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}### ๐ ํ๊ฒฝ ๋ณ์ ๊ด๋ฆฌ ๋ฏผ๊ฐํ ์ ๋ณด๋ ์ ๋ ์ฝ๋์ ํ๋์ฝ๋ฉํ๋ฉด ์ ๋ผ:
// .env ํ์ผ (์ ๋ Git์ ์ปค๋ฐํ์ง ๋ง ๊ฒ!)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=your-super-secret-key-here
REDIS_URL=redis://localhost:6379
// .env.example ํ์ผ (Git์ ์ปค๋ฐ)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=change-this-in-production
REDIS_URL=redis://localhost:6379
// ์ฝ๋์์ ์ฌ์ฉ
let databaseURL = Environment.get("DATABASE_URL") ?? "default-value"- โ ๋ชจ๋ ๋น๋ฐ๋ฒํธ๋ ํด์ํ (Bcrypt ์ฌ์ฉ)
- โ JWT ํ ํฐ์ ๋ง๋ฃ ์๊ฐ ์ค์
- โ HTTPS ์ฌ์ฉ
- โ CORS ์ ์ฑ ์ค์
- โ Rate Limiting ๊ตฌํ
- โ ์ ๋ ฅ ๊ฒ์ฆ (Validation)
- โ ์๋ฌ ๋ฉ์์ง์ ๋ฏผ๊ฐํ ์ ๋ณด ๋ ธ์ถ ๊ธ์ง
- โ ์ ๊ธฐ์ ์ธ ์์กด์ฑ ์ ๋ฐ์ดํธ
- โ ๋ก๊ทธ์ ๋ฏผ๊ฐํ ์ ๋ณด ๊ธฐ๋ก ๊ธ์ง
- โ ๋ณด์ ํค๋ ์ค์ (X-Frame-Options, X-Content-Type-Options ๋ฑ)
### ๐ ํ๋ก์ ํธ ์๊ตฌ์ฌํญ ์ฐ๋ฆฌ๊ฐ ๋ง๋ค ๋ธ๋ก๊ทธ API์ ๊ธฐ๋ฅ๋ค:
โข ์ฌ์ฉ์ ํ์๊ฐ์ /๋ก๊ทธ์ธ
โข ๊ฒ์๊ธ CRUD
โข ๋๊ธ ๊ธฐ๋ฅ
โข ์ข์์ ๊ธฐ๋ฅ
โข ํ๊ทธ ์์คํ
โข ๊ฒ์ ๊ธฐ๋ฅ
### ๐๏ธ ๋ฐ์ดํฐ ๋ชจ๋ธ ์ค๊ณ ๋จผ์ ํ์ํ ๋ชจ๋ธ๋ค์ ์ ์ํ์:
import Fluent
import Vapor
// ์ฌ์ฉ์ ๋ชจ๋ธ
final class User: Model, Content {
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "email")
var email: String
@Field(key: "password_hash")
var passwordHash: String
@Field(key: "bio")
var bio: String?
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Children(for: \.$author)
var posts: [Post]
@Children(for: \.$user)
var comments: [Comment]
init() { }
}
// ๊ฒ์๊ธ ๋ชจ๋ธ
final class Post: Model, Content {
static let schema = "posts"
@ID(key: .id)
var id: UUID?
@Field(key: "title")
var title: String
@Field(key: "content")
var content: String
@Field(key: "slug")
var slug: String
@Parent(key: "author_id")
var author: User
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
@Children(for: \.$post)
var comments: [Comment]
@Siblings(through: PostTag.self, from: \.$post, to: \.$tag)
var tags: [Tag]
@Children(for: \.$post)
var likes: [Like]
init() { }
}
// ๋๊ธ ๋ชจ๋ธ
final class Comment: Model, Content {
static let schema = "comments"
@ID(key: .id)
var id: UUID?
@Field(key: "content")
var content: String
@Parent(key: "post_id")
var post: Post
@Parent(key: "user_id")
var user: User
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() { }
}
// ํ๊ทธ ๋ชจ๋ธ
final class Tag: Model, Content {
static let schema = "tags"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Siblings(through: PostTag.self, from: \.$tag, to: \.$post)
var posts: [Post]
init() { }
}
// ๊ฒ์๊ธ-ํ๊ทธ ์ค๊ฐ ํ
์ด๋ธ
final class PostTag: Model {
static let schema = "post_tags"
@ID(key: .id)
var id: UUID?
@Parent(key: "post_id")
var post: Post
@Parent(key: "tag_id")
var tag: Tag
init() { }
}
// ์ข์์ ๋ชจ๋ธ
final class Like: Model, Content {
static let schema = "likes"
@ID(key: .id)
var id: UUID?
@Parent(key: "post_id")
var post: Post
@Parent(key: "user_id")
var user: User
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() { }
}### ๐ฎ ์ปจํธ๋กค๋ฌ ๊ตฌํ ์ด์ ๋น์ฆ๋์ค ๋ก์ง์ ์ฒ๋ฆฌํ ์ปจํธ๋กค๋ฌ๋ฅผ ๋ง๋ค์:
import Vapor
import Fluent
struct PostController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let posts = routes.grouped("api", "v1", "posts")
// ๊ณต๊ฐ ๋ผ์ฐํธ
posts.get(use: index)
posts.get(":id", use: show)
posts.get("search", use: search)
posts.get("tag", ":tagName", use: byTag)
// ์ธ์ฆ ํ์ ๋ผ์ฐํธ
let protected = posts.grouped(JWTAuthenticator()).grouped(User.guardMiddleware())
protected.post(use: create)
protected.put(":id", use: update)
protected.delete(":id", use: delete)
protected.post(":id", "like", use: toggleLike)
protected.post(":id", "comments", use: addComment)
}
// ๊ฒ์๊ธ ๋ชฉ๋ก ์กฐํ (ํ์ด์ง๋ค์ด์
)
func index(req: Request) async throws -> Page<Post.Public> {
let posts = try await Post.query(on: req.db)
.with(\.$author)
.with(\.$tags)
.sort(\.$createdAt, .descending)
.paginate(for: req)
return posts.map { $0.asPublic() }
}
// ๊ฒ์๊ธ ์์ธ ์กฐํ
func show(req: Request) async throws -> Post.Detail {
guard let post = try await Post.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
try await post.$author.load(on: req.db)
try await post.$tags.load(on: req.db)
try await post.$comments.load(on: req.db)
try await post.$likes.load(on: req.db)
return post.asDetail()
}
// ๊ฒ์๊ธ ์์ฑ
func create(req: Request) async throws -> Post.Public {
let user = try req.auth.require(User.self)
let input = try req.content.decode(Post.Create.self)
let post = Post()
post.title = input.title
post.content = input.content
post.slug = input.title.slugified()
post.$author.id = try user.requireID()
try await post.save(on: req.db)
// ํ๊ทธ ์ถ๊ฐ
if let tagNames = input.tags {
for tagName in tagNames {
let tag = try await Tag.query(on: req.db)
.filter(\.$name == tagName)
.first() ?? Tag(name: tagName)
if tag.id == nil {
try await tag.save(on: req.db)
}
try await post.$tags.attach(tag, on: req.db)
}
}
try await post.$author.load(on: req.db)
try await post.$tags.load(on: req.db)
return post.asPublic()
}
// ๊ฒ์๊ธ ์์
func update(req: Request) async throws -> Post.Public {
let user = try req.auth.require(User.self)
guard let post = try await Post.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
// ์์ฑ์๋ง ์์ ๊ฐ๋ฅ
guard post.$author.id == user.id else {
throw Abort(.forbidden)
}
let input = try req.content.decode(Post.Update.self)
post.title = input.title ?? post.title
post.content = input.content ?? post.content
try await post.save(on: req.db)
try await post.$author.load(on: req.db)
try await post.$tags.load(on: req.db)
return post.asPublic()
}
// ๊ฒ์๊ธ ์ญ์
func delete(req: Request) async throws -> HTTPStatus {
let user = try req.auth.require(User.self)
guard let post = try await Post.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
guard post.$author.id == user.id else {
throw Abort(.forbidden)
}
try await post.delete(on: req.db)
return .noContent
}
// ์ข์์ ํ ๊ธ
func toggleLike(req: Request) async throws -> Post.LikeStatus {
let user = try req.auth.require(User.self)
guard let postId = req.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}
let existingLike = try await Like.query(on: req.db)
.filter(\.$post.$id == postId)
.filter(\.$user.$id == try user.requireID())
.first()
if let like = existingLike {
try await like.delete(on: req.db)
return Post.LikeStatus(liked: false, count: try await countLikes(postId: postId, db: req.db))
} else {
let like = Like()
like.$post.id = postId
like.$user.id = try user.requireID()
try await like.save(on: req.db)
return Post.LikeStatus(liked: true, count: try await countLikes(postId: postId, db: req.db))
}
}
// ๋๊ธ ์ถ๊ฐ
func addComment(req: Request) async throws -> Comment.Public {
let user = try req.auth.require(User.self)
guard let postId = req.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}
let input = try req.content.decode(Comment.Create.self)
let comment = Comment()
comment.content = input.content
comment.$post.id = postId
comment.$user.id = try user.requireID()
try await comment.save(on: req.db)
try await comment.$user.load(on: req.db)
return comment.asPublic()
}
// ๊ฒ์
func search(req: Request) async throws -> [Post.Public] {
guard let query = req.query[String.self, at: "q"] else {
throw Abort(.badRequest, reason: "Search query required")
}
let posts = try await Post.query(on: req.db)
.group(.or) { group in
group.filter(\.$title, .custom("ILIKE"), "%\(query)%")
group.filter(\.$content, .custom("ILIKE"), "%\(query)%")
}
.with(\.$author)
.with(\.$tags)
.all()
return posts.map { $0.asPublic() }
}
// ํ๊ทธ๋ณ ์กฐํ
func byTag(req: Request) async throws -> [Post.Public] {
guard let tagName = req.parameters.get("tagName") else {
throw Abort(.badRequest)
}
guard let tag = try await Tag.query(on: req.db)
.filter(\.$name == tagName)
.first() else {
return []
}
try await tag.$posts.load(on: req.db)
for post in tag.posts {
try await post.$author.load(on: req.db)
try await post.$tags.load(on: req.db)
}
return tag.posts.map { $0.asPublic() }
}
private func countLikes(postId: UUID, db: Database) async throws -> Int {
try await Like.query(on: db)
.filter(\.$post.$id == postId)
.count()
}
}
// DTO (Data Transfer Object) ์ ์
extension Post {
struct Create: Content {
var title: String
var content: String
var tags: [String]?
}
struct Update: Content {
var title: String?
var content: String?
}
struct Public: Content {
var id: UUID?
var title: String
var slug: String
var content: String
var author: User.Public
var tags: [String]
var createdAt: Date?
var updatedAt: Date?
}
struct Detail: Content {
var id: UUID?
var title: String
var slug: String
var content: String
var author: User.Public
var tags: [String]
var comments: [Comment.Public]
var likeCount: Int
var createdAt: Date?
var updatedAt: Date?
}
struct LikeStatus: Content {
var liked: Bool
var count: Int
}
func asPublic() -> Public {
Public(
id: id,
title: title,
slug: slug,
content: content,
author: author.publicView,
tags: tags.map { $0.name },
createdAt: createdAt,
updatedAt: updatedAt
)
}
func asDetail() -> Detail {
Detail(
id: id,
title: title,
slug: slug,
content: content,
author: author.publicView,
tags: tags.map { $0.name },
comments: comments.map { $0.asPublic() },
likeCount: likes.count,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
extension Comment {
struct Create: Content {
var content: String
}
struct Public: Content {
var id: UUID?
var content: String
var user: User.Public
var createdAt: Date?
}
func asPublic() -> Public {
Public(
id: id,
content: content,
user: user.publicView,
createdAt: createdAt
)
}
}
extension String {
func slugified() -> String {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-"))
return self.lowercased()
.components(separatedBy: allowed.inverted)
.filter { !$0.isEmpty }
.joined(separator: "-")
}
}์์ฑ์ด์ผ! ์ด์ ์์ ํ ๋์ํ๋ ๋ธ๋ก๊ทธ API๊ฐ ์๊ฒผ์ด. ๐
โข ์ด๋ฏธ์ง ์ ๋ก๋ (AWS S3 ์ฐ๋)
โข ์ด๋ฉ์ผ ์๋ฆผ (SendGrid ๋ฑ)
โข ์์ ๋ก๊ทธ์ธ (OAuth)
โข ์ค์๊ฐ ์๋ฆผ (WebSocket)
โข ๊ด๋ฆฌ์ ๋์๋ณด๋
โข ํต๊ณ ๋ฐ ๋ถ์
ํน์ Swift ์๋ฒ ๊ฐ๋ฐ์ ๋ํด ๋ ๋ฐฐ์ฐ๊ณ ์ถ๊ฑฐ๋, ํ๋ก์ ํธ์ ๋์์ด ํ์ํ๋ค๋ฉด ์ฌ๋ฅ๋ท์์ ์ ๋ฌธ๊ฐ๋ค์ ๋์์ ๋ฐ์ ์ ์์ด!
### ๐ ๊ณต์ ๋ฌธ์
https://docs.vapor.codes
๊ฐ์ฅ ์ต์ ์ ๋ณด์ ํํ ๋ฆฌ์ผ์ด ์์ด. ์์ด์ง๋ง ์์ ๊ฐ ๋ง์์ ๋ฐ๋ผํ๊ธฐ ์ฌ์.
Perfect ๊ณต์ ๋ฌธ์
https://www.perfect.org/docs/
Perfect์ ๋ชจ๋ ๊ธฐ๋ฅ๊ณผ API ๋ ํผ๋ฐ์ค๊ฐ ์ ๋ฆฌ๋์ด ์์ด.
Swift.org ์๋ฒ ์ํน ๊ทธ๋ฃน
https://www.swift.org/sswg/
Swift ์๋ฒ ์ํ๊ณ์ ๋ฐฉํฅ์ฑ๊ณผ ํ์ค์ ๋ ผ์ํ๋ ๊ณณ์ด์ผ.
์ค์๊ฐ์ผ๋ก ์ง๋ฌธํ๊ณ ๋ต๋ณ๋ฐ์ ์ ์๋ ๊ณณ. ๊ฐ๋ฐ์๋ค์ด ์ ๋ง ์น์ ํด!
Swift Forums
https://forums.swift.org
Swift ์ ๋ฐ์ ๋ํ ๊น์ด ์๋ ํ ๋ก ์ด ์ด๋ฃจ์ด์ง๋ ๊ณต์ ํฌ๋ผ.
Reddit r/swift
Swift ๊ด๋ จ ๋ด์ค์ ํ ๋ก ์ ๋ณผ ์ ์์ด.
Stack Overflow
vapor, perfect, swift-server ํ๊ทธ๋ก ๊ฒ์ํ๋ฉด ๋ง์ Q&A๋ฅผ ์ฐพ์ ์ ์์ด.
Server-Side Swift with Vapor ์๋ฆฌ์ฆ๊ฐ ์ ๋ง ์ข์. ์ ๋ฃ์ง๋ง ๊ทธ๋งํ ๊ฐ์น๊ฐ ์์ด.
YouTube
"Server-Side Swift", "Vapor Tutorial" ๊ฐ์ ํค์๋๋ก ๊ฒ์ํ๋ฉด ๋ฌด๋ฃ ๊ฐ์๋ค์ด ๋ง์.
GitHub
์ค์ ์คํ์์ค ํ๋ก์ ํธ๋ค์ ๋ณด๋ฉด์ ๋ฐฐ์ฐ๋ ๊ฒ ์ต๊ณ ์ผ. vapor/vapor ๋ ํฌ์งํ ๋ฆฌ์ ์ฝ๋๋ฅผ ์ฝ์ด๋ณด๋ ๊ฒ๋ ์ข์ ๊ณต๋ถ๊ฐ ๋ผ.
๊ทธ๋ฆฌ๊ณ ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ์ค๋ฌด ๊ฒฝํ์ด ์๋ ๊ฐ๋ฐ์๋ค์๊ฒ 1:1๋ก ๋ฐฐ์ธ ์๋ ์์ด. ๋งํ๋ ๋ถ๋ถ์ ๋ฐ๋ก๋ฐ๋ก ํด๊ฒฐํ ์ ์์ด์ ํ์ต ์๋๊ฐ ํจ์ฌ ๋นจ๋ผ์ง์ง!
โข "Server-Side Swift" by Paul Hudson
โข "Practical Server-Side Swift" by Tibor Bรถdecs
โข "Vapor 4 Complete Guide" (์จ๋ผ์ธ eBook)
Swift๋ก ์๋ฒ๋ฅผ ๊ฐ๋ฐํ๋ ๊ฑด ์์ง์ Node.js๋ Python๋งํผ ๋ฉ์ธ์คํธ๋ฆผ์ ์๋์ผ. ํ์ง๋ง ๋ช ๊ฐ์ง ํ์คํ ์ฅ์ ์ด ์์ด:
์ปดํ์ผ ํ์์ ๋ง์ ๋ฒ๊ทธ๋ฅผ ์ก์๋ผ ์ ์์ด. ๋ฐํ์ ์๋ฌ๊ฐ ํ ์ค์ด๋ค์ง.
2. ์ฑ๋ฅ
C/C++ ์์ค์ ์ฑ๋ฅ์ ์๋ํด. ํนํ CPU ์ง์ฝ์ ์ธ ์์ ์์ ๋น์ ๋ฐํด.
3. ์ฝ๋ ๊ณต์
iOS ์ฑ๊ณผ ์๋ฒ ๊ฐ์ ๋ชจ๋ธ ์ฝ๋๋ฅผ ๊ทธ๋๋ก ๊ณต์ ํ ์ ์์ด. ๊ฐ๋ฐ ํจ์จ์ด ์์ฒญ๋๊ฒ ์ฌ๋ผ๊ฐ.
4. ๋ชจ๋ํ ๋ฌธ๋ฒ
async/await, ์ต์ ๋, ํ๋กํ ์ฝ ์งํฅ ํ๋ก๊ทธ๋๋ฐ ๋ฑ ๊ฐ๋ฐ์ ์นํ์ ์ธ ๊ธฐ๋ฅ๋ค์ด ๋ง์.
5. ๋ฉ๋ชจ๋ฆฌ ์์ ์ฑ
ARC ๋๋ถ์ ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ๊ฐ ์๋ํ๋๋ฉด์๋ ์์ ํด.
โข **Swift Concurrency**: async/await๊ฐ ๋์ฑ ์ฑ์ํด์ง๊ณ ์์ด
โข **์ฑ๋ฅ ๊ฐ์ **: ๋งค ๋ฒ์ ๋ง๋ค ์ฑ๋ฅ์ด ํฅ์๋๊ณ ์์ด
โข **์ํ๊ณ ํ์ฅ**: ๋ ๋ง์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ๋๊ตฌ๋ค์ด ๋์ค๊ณ ์์ด
โข **๊ธฐ์ ๋์ **: ์ ์ ๋ ๋ง์ ๊ธฐ์ ๋ค์ด ๊ด์ฌ์ ๊ฐ์ง๊ณ ์์ด
ํนํ iOS ์ค์ฌ์ ์๋น์ค๋ฅผ ๋ง๋๋ ํ์ด๋ผ๋ฉด, Swift ์๋ฒ ์ฌ์ด๋๋ ์ ๋ง ๋งค๋ ฅ์ ์ธ ์ ํ์ง์ผ. ํ๋ก ํธ์๋์ ๋ฐฑ์๋๋ฅผ ๊ฐ์ ์ธ์ด๋ก ๊ฐ๋ฐํ ์ ์๋ค๋ ๊ฑด ์์ฒญ๋ ์ด์ ์ด๊ฑฐ๋ !
### ๐ ์์ํด๋ณด์! ์ด์ ๋๋ Swift๋ก ์๋ฒ๋ฅผ ๋ง๋ค ์ ์์ด! ์ฒ์์ ์ด๋ ค์ธ ์ ์์ง๋ง, ํ๋์ฉ ๋ฐ๋ผํ๋ค ๋ณด๋ฉด ๊ธ๋ฐฉ ์ต์ํด์ง ๊ฑฐ์ผ.
์์ ํ๋ก์ ํธ๋ถํฐ ์์ํด๋ด. ๊ฐ๋จํ TODO API๋ ๋ฉ๋ชจ ์ฑ ๋ฐฑ์๋ ๊ฐ์ ๊ฑฐ ๋ง์ด์ผ. ๊ทธ๋ฌ๋ค ๋ณด๋ฉด ์ ์ ๋ณต์กํ ์์คํ ๋ ๋ง๋ค ์ ์๊ฒ ๋ ๊ฑฐ์ผ.
๋งํ๋ ๋ถ๋ถ์ด ์์ผ๋ฉด ์ปค๋ฎค๋ํฐ์ ์ง๋ฌธํ๊ณ , ๊ณต์ ๋ฌธ์๋ฅผ ์ฐธ๊ณ ํ๊ณ , ๋ค๋ฅธ ์ฌ๋๋ค์ ์ฝ๋๋ฅผ ์ฝ์ด๋ด. ๊ทธ๋ฆฌ๊ณ ๊ณ์ ์ฝ๋๋ฅผ ์์ฑํ๋ ๊ฒ ๊ฐ์ฅ ์ค์ํด!
โข ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ๋ ์ต๊ด์ ๋ค์ฌ. ๋์ค์ ๋๋ฅผ ๊ตฌํด์ค ๊ฑฐ์ผ
โข ๋ณด์์ ์ ๋ ์ํํ ํ์ง ๋ง. ํ ๋ฒ์ ์ค์๊ฐ ํฐ ๋ฌธ์ ๋ฅผ ์ผ์ผํฌ ์ ์์ด
โข ์ปค๋ฎค๋ํฐ์ ๊ธฐ์ฌํด๋ด. ์คํ์์ค ํ๋ก์ ํธ์ PR์ ๋ณด๋ด๊ฑฐ๋ ๋ธ๋ก๊ทธ๋ฅผ ์์ฑํ๋ ๊ฒ๋ ์ข์
โข ์ฆ๊ธฐ๋ฉด์ ํด! ์ฝ๋ฉ์ ์ฌ๋ฏธ์์ด์ผ ํด ๐
ํน์ ๋ ๊น์ด ์๋ ํ์ต์ด๋ ์ค๋ฌด ํ๋ก์ ํธ ๊ฒฝํ์ด ํ์ํ๋ค๋ฉด, ์ฌ๋ฅ๋ท์์ ๊ฒฝํ ๋ง์ ๊ฐ๋ฐ์๋ค๊ณผ ์ฐ๊ฒฐ๋ ์ ์์ด. ๋๋ก๋ ๋ฉํ ์ ๋์์ ๋ฐ๋ ๊ฒ ํผ์ ํค๋งค๋ ๊ฒ๋ณด๋ค ํจ์ฌ ํจ์จ์ ์ด๊ฑฐ๋ !
Happy Coding! ๐
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.