같은 Todo 앱을 JavaScript, Java, Go, Rust로 써 보며 본 차이
목차
처음 만든 Todo 앱은 HTML, CSS, JavaScript 뿐이었다. 할 일을 localStorage 에 저장하고 대기 목록과 완료 목록 사이를 체크박스로 옮기며 진행률을 보여 준다. 이걸 다른 언어로 다시 써 보면 같은 기능을 어디에 두게 되는지 궁금해서 Java, Go, Rust 로 옮겼다. 네 구현은 각각 한두 시간짜리 연습이라 완성도는 다르다. 원본 JavaScript 판은 플레이그라운드에서 돌려 볼 수 있다.
JavaScript: 브라우저가 전부다
상태는 배열 두 개와 localStorage 다. DOM 을 직접 만들고, 저장하고, 진행률을 계산하는 함수가 한 파일에 있다.
const PENDING = 'pending'
const FINISHED = 'finished'
let pendingList = []
let finishedList = []
const saveToDo = (listName, newList) => {
localStorage.setItem(listName, JSON.stringify(newList))
}
const updateToDo = (type, listName, id, text) => {
const prevToDos = listName === PENDING ? pendingList : finishedList
let newToDos = ''
switch (type) {
case 'ADD':
newToDos = [...prevToDos, { id, text }]
break
case 'DEL':
newToDos = prevToDos.filter((toDo) => toDo.id !== id)
break
}
saveToDo(listName, newToDos)
listName === PENDING ? (pendingList = newToDos) : (finishedList = newToDos)
calcRate()
}
체크박스를 누르면 한 목록에서 지우고 다른 목록에 다시 그린다. 완료 하나를 옮기는 데 DOM 삭제, 저장소 갱신, DOM 추가, 저장소 갱신이 차례로 일어난다. 데이터와 화면이 한 덩어리라 작게 시작하기는 쉽지만, 다른 기기에서는 목록이 보이지 않는다.
Java: 콘솔에서 메뉴를 고르는 CRUD
화면이 없으니 Scanner 로 번호를 받는 메뉴 루프가 UI 다. 할 일은 TodoItem 클래스로, 목록은 List<TodoItem> 으로 둔다.
public class TodoItem {
private int id;
private String task;
private boolean completed;
public TodoItem(int id, String task) {
this.id = id;
this.task = task;
this.completed = false;
}
public int getId() { return id; }
public void setTask(String task) { this.task = task; }
public void setCompleted(boolean completed) { this.completed = completed; }
@Override
public String toString() {
return "TodoItem{id=" + id + ", task='" + task + "', completed=" + completed + '}';
}
}
public void updateTodoItem(int id, String newTask) {
for (TodoItem item : todoList) {
if (item.getId() == id) {
item.setTask(newTask);
System.out.println("Updated: " + item);
return;
}
}
System.out.println("ToDo item not found with id: " + id);
}
JavaScript 판에서는 목록 둘 사이를 오가는 것으로 표현했던 "완료"가 여기서는 completed 필드 하나가 된다. 데이터 모양을 먼저 정하고 나니 그쪽이 더 자연스러웠다. 대신 getter 와 setter, 찾기 루프가 메서드마다 반복된다. 이 반복이 나중에 Kotlin 으로 옮겨 보는 계기가 됐다.
Go: 모델, 핸들러, 라우트로 나눈 REST 서버
Go 판은 처음부터 HTTP 서버다. gorilla/mux 로 라우트를 묶고 모델, 핸들러, 라우트를 패키지로 나눴다.
// models/todo.go
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
var Todos []Todo
var NextID = 1
func CreateTodo(title string) Todo {
todo := Todo{ID: NextID, Title: title, Done: false}
Todos = append(Todos, todo)
NextID++
return todo
}
func DeleteTodoByID(id int) bool {
for i, t := range Todos {
if t.ID == id {
Todos = append(Todos[:i], Todos[i+1:]...)
return true
}
}
return false
}
// routes/routes.go
func SetupRoutes() *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/todos", handlers.GetTodosHandler).Methods("GET")
router.HandleFunc("/todos", handlers.CreateTodoHandler).Methods("POST")
router.HandleFunc("/todos/{id:[0-9]+}", handlers.GetTodoHandler).Methods("GET")
router.HandleFunc("/todos/{id:[0-9]+}", handlers.UpdateTodoHandler).Methods("PUT")
router.HandleFunc("/todos/{id:[0-9]+}", handlers.DeleteTodoHandler).Methods("DELETE")
return router
}
// handlers/todo_handler.go
func GetTodoHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
todo, found := models.GetTodoByID(id)
if !found {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todo)
}
찾는 함수가 (Todo, bool) 두 값을 돌려주고 핸들러가 found 를 보고 404 를 내는 흐름이 Go 답다. 슬라이스에서 하나를 빼는 append(Todos[:i], Todos[i+1:]...) 도 이때 처음 썼다. main.go 의 import "routes/routes.go" 는 todoapp/routes 여야 하는데 그대로 둔 채 커밋되어 있었다. 모듈 경로를 import 에 쓴다는 것을 아직 몰랐던 흔적이다.
Rust: Rocket 에 상태를 Mutex 로 들고 있기
Rust 판은 Rocket 으로 서버를 띄우고 할 일 목록을 State 에 넣었다. 여러 요청이 같은 벡터를 만지므로 Mutex 로 감싼다.
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Todo {
id: String,
text: String,
status: String, // pending or finished
}
struct TodoState {
pending: Mutex<Vec<Todo>>,
finished: Mutex<Vec<Todo>>,
}
#[post("/add", format = "application/json", data = "<todo>")]
fn add(todo: Json<Todo>, state: &rocket::State<TodoState>) {
let mut pending = state.pending.lock().unwrap();
pending.push(todo.into_inner());
}
#[delete("/delete/<id>")]
fn delete(id: String, state: &rocket::State<TodoState>) {
let mut pending = state.pending.lock().unwrap();
pending.retain(|t| t.id != id);
}
#[launch]
fn rocket() -> _ {
rocket::build()
.mount("/", routes![index, add, delete])
.mount("/static", StaticFiles::from("./static"))
.manage(TodoState {
pending: Mutex::new(vec![]),
finished: Mutex::new(vec![]),
})
}
pending 과 finished 를 벡터 두 개로 둔 것은 JavaScript 판의 목록 두 개를 그대로 옮긴 것이다. 정적 파일을 같이 내보내 프런트까지 붙이려 했지만 static/app.js 는 비어 있고 rocket_contrib 0.4 는 Rocket 0.5 와 맞지 않아 그대로는 빌드되지 않는다. 가장 덜 끝난 판이다.
그래도 서버가 데이터를 들고 있게 되면서 달라지는 것은 분명했다. 브라우저 저장소가 아니라 서버 메모리에 목록이 있으니 다른 기기에서도 같은 목록을 보고, 인증이나 DB 연결 같은 기능을 붙일 자리가 생기고, 데이터 검증을 서버에서 할 수 있다.
네 판을 놓고 보면
| 상태가 있는 곳 | "완료"의 표현 | 인터페이스 | |
|---|---|---|---|
| JavaScript | localStorage | 목록 두 개 사이 이동 | DOM |
| Java | List<TodoItem> | completed 필드 | 콘솔 메뉴 |
| Go | 패키지 전역 슬라이스 | Done 필드 | REST |
| Rust | State<Mutex<Vec>> | 벡터 두 개 | REST (미완) |
같은 기능인데 언어가 바뀌면 먼저 손이 가는 곳이 달랐다. JavaScript 에서는 화면부터, Java 에서는 클래스부터, Go 에서는 라우트부터 썼다. 다음에 또 포팅한다면 Rust 판을 끝내고, 네 판이 같은 요청과 응답을 주고받는지 맞춰 보고 싶다.