Javascript VS GO — JavaScript(NestJS) 개발자가 바라본 Go
3년 전, Go를 백엔드 서비스에 쓴 적이 있었습니다. 그때는 사실 뭐가 뭔지 잘 모르기도 했고, Go만의 특장점을 딱히 느끼지도 못했었는데, NestJS와 함께 3년을 보내고 나서 다시 Go를 들여다보니 보이는 게 달라졌습니다.
Go에는 있었는데 JS에는 없는, 둘 다 있는데 다른, 그런 것들을 비교 하며 생긴 질문들이 있었습니다. “왜 Node.js는 싱글스레드일까?”, “Go에도 GC가 있는데 이 GC는 JS와 뭐가 다를까?”, “Go에서 데코레이터는 없었는데, 데코레이터는 실제로 어떤 일을 할까?” 이런 것들.
Go를 다시 들여다보면서, 그 질문들을 하나씩 풀어봤습니다.
- 1. Go의 가장 큰 특징, 동시성 — goroutine과 async/await
- 2. Go의 메모리 모델 — Go의 GC는 JS와 뭐가 다른가
- 3. 그렇다면 JS는 왜 싱글스레드인가
- 4. V8 JIT — JS가 싱글스레드인데 왜 빠른가
- 5. V8의 Cold start가 없을 수 있을까? 있음 — Bun
- 6. Nest에는 없는데 Go에는 있는 것 — context 패키지
- 7. Nest에는 있는데 Go에는 없는 것 — 데코레이터
- 8. Go는 추상화를 싫어하는가
- 9. NestJS의 장점은?
1. Go의 가장 큰 특징, 동시성 — goroutine과 async/await
Go의 특별한 점은 뭐가 있었나 생각해보면, goroutine이 있었습니다.
멀티스레드가 되어서, 병렬처리가 됐던 기억이 납니다.
물론 NestJS에서도 병렬 처리가 가능합니다. JS에겐 Promise 친구들이 있죠.
const [users, posts] = await Promise.all([
this.getUsers(),
this.getPosts(),
]);
Go에서는 이렇게 씁니다.
userCh := make(chan []User)
postCh := make(chan []Post)
go func() { userCh <- getUsers() }()
go func() { postCh <- getPosts() }()
users := <-userCh
posts := <-postCh
생긴 건 비슷해 보이지만 내부 동작이 완전히 다릅니다.
async/await은 싱글스레드 이벤트 루프 위에서 동작합니다. 여러 작업이 “동시에” 진행되는 것처럼 보이지만, CPU는 한 번에 하나씩 처리합니다. I/O 대기 중에 다른 작업을 끼워 넣는 방식이에요. Go의 goroutine은 다릅니다. OS 스레드 위에서 Go 런타임이 관리하는 경량 스레드인데, 실제로 여러 CPU 코어에서 병렬 실행됩니다.
goroutine이 메모리 효율적인 이유
멀티스레딩이라고 하니까 따르는 궁금한 점들이 있었습니다.
멀티스레딩이면 보통 메모리 효율성이 좋을까? 아니면 더 큰 메모리가 필요할까? 효율이 좋으면 어떤 원리로 좋은거지?
언뜻 생각해봐도, 싱글스레드 보다는 신경쓸 것들이 많으니까요. Lock 관리도 해야하지, Context Switching도 해야하지.
“멀티스레딩 = 메모리 많이 씀”이 일반적으로 맞습니다만, Go의 goroutine은 일반 OS 스레드와는 또 다릅니다.
OS Thread (Java, C++) → 기본 스택 1~8MB
Go goroutine → 시작 스택 2~8KB (필요시 동적 확장)
goroutine 하나가 OS 스레드의 약 1/1000 수준의 메모리를 씁니다. 그래서 수십만 개를 띄워도 문제가 없다고 합니다.
이게 가능한 이유는 M:N 스케줄러 때문입니다. Go 런타임이 적은 수의 OS 스레드 위에서 수많은 goroutine을 스케줄링합니다.
goroutine이 I/O 대기 상태가 되면, 런타임이 그 OS 스레드에 다른 goroutine을 배치합니다.
OS는 스레드 수만 보고, goroutine 수십만 개의 존재를 모르게 됩니다.
┌─────────────────────────────────┐
│ Go Runtime │
│ │
│ OS Thread OS Thread OS Thread│ ← M개 (CPU 코어 수)
│ │ │ │ │
│ ┌──┴──┐ ┌───┴──┐ ┌──┴──┐ │
│ │ G G │ │ G G │ │ G G │ │ ← N개 goroutine
│ │ G G │ │ G │ │ G G │ │ (수만~수십만 가능)
│ └─────┘ └──────┘ └─────┘ │
└─────────────────────────────────┘
컨텍스트 스위칭도 OS 커널이 아닌 Go 런타임 레벨에서 일어나서 오버헤드가 훨씬 작습니다.
동시 연결 1만 개 기준:
OS Thread → 약 10GB
goroutine → 약 80MB
2. Go의 메모리 모델 — Go의 GC는 JS와 뭐가 다른가
goroutine이 아닌 Go 자체의 메모리 모델을 보아도 JS와 다른 점을 확인할 수 있었습니다.
Java 또는 JavaScript 개발자 분들은 Stop The World를 겪어 보셨을텐데요.

물론 저두 ;;ㅎ
그래서 그런지, GC는 왠지 느리고 비효율적이고 내 세상을 망칠 것만 같습니다 (??)
사실 느린 건 GC가 아니라 Stop-the-World(STW) 때문에 느린 겁니다.
GC가 메모리를 정리하는 동안 프로그램 실행을 완전히 멈추는 것, 이 시간이 레이턴시 스파이크를 만들어냅니다ㅠㅠ Java 초기 버전이 긴 STW로 욕을 많이 먹었었읍니다..
Tri-color Mark & Sweep
Go GC의 목표는 STW를 1ms 미만으로 유지하는 것입니다. (!!)
이게 가능한 이유는 Concurrent Tri-color Mark & Sweep 방식을 사용하기 때문입니다.
해당 방식은 객체를 세 가지 색깔로 분류합니다.
⬜ White — 아직 검사 안 한 객체 (수거 대상 후보)
🩶 Gray — 검사 시작했지만 참조 확인 중
⬛ Black — 검사 완료, 살아있는 객체
GC가 프로그램과 동시에 실행되면서, Gray 객체의 참조를 따라가며 White를 Gray로, Gray를 Black으로 바꿉니다. 끝까지 White로 남은 것들이 아무도 참조하지 않는 객체, 즉 수거 대상입니다. 프로그램을 멈추는 건 아주 짧은 Root 스캔 구간뿐이고, 나머지는 앱과 병렬로 돌아갑니다.
[힙 메모리 상태 변화]
초기: W W W W W W W W
↓ Root 스캔
B G W W W W W W
↓ Concurrent Mark
B B B G W W W W
↓ 완료
B B B B W W W W
↑↑↑↑
수거됨
Escape Analysis — GC 부담 자체를 줄인다
Go는 GC 부담을 줄이는 방법도 씁니다. 컴파일 타임에 “이 변수가 함수 밖으로 나가냐?”를 분석합니다.
안 나가면 스택에, 나가면 힙에 할당합니다.
// 스택에 할당됨 — 함수 끝나면 자동 소멸, GC 불필요
func stackExample() {
x := 42
fmt.Println(x) // x가 밖으로 탈출 안 함
}
// 힙에 할당됨 — 포인터가 함수 밖으로 나감
func heapExample() *int {
x := 42
return &x // 탈출 → 힙 할당 → GC 대상
}
스택은 함수 종료 시 자동 해제라 GC가 신경 쓸 필요가 없습니다.
JS에서 모든 객체가 V8 힙에 올라가는 것과 다르게, Go는 컴파일러가 최대한 스택으로 보내버립니다.
빌드 시 직접 확인도 가능합니다.
go build -gcflags="-m" main.go
# ./main.go:6:2: x does not escape ← 스택
# ./main.go:12:2: x escapes to heap ← 힙
Node의 V8 GC와 비교
V8은 Generational GC를 씁니다. "대부분의 객체는 생성 직후 금방 죽는다"는 가설 기반입니다.
New Space (1~8MB) — 새로 생긴 객체들
Scavenger GC — 자주, 빠름
대부분 여기서 죽음
Old Space (수백MB~) — New Space에서 살아남은 객체
Major GC — 가끔, 무거움
Scavenger는 New Space를 From/To 두 영역으로 나눠서, 살아있는 객체만 To Space로 복사하는 방식입니다. 복사 과정에서 자동으로 메모리가 압축되어 단편화가 없습니다. 매우 빠르게 동작하지만, Old Space에 들어온 객체는 Major GC 대상이 되고 이 때 STW가 수십ms까지 발생할 수 있습니다.
Go GC V8 GC
STW 시간 < 1ms (목표) Old Space GC 시 수십ms 가능
방식 Concurrent Generational
메모리 구조 단일 힙 + 스택 New/Old Space 분리
장점 예측 가능한 레이턴시 단명 객체 처리 빠름
단점 메모리 오버헤드 있음 Old Space GC 시 스파이크
3. 그렇다면 JS는 왜 싱글스레드인가
Go의 동시성을 이해하고 나면 자연스럽게 이 질문이 생깁니다.
생각했던 것보단 다소,, 역사적 산물입니다. 1995년 Brendan Eich가 10일 만에 설계했고, 목적은 웹 페이지의 버튼 클릭, 폼 검증 같은 간단한 DOM 조작이었다고 합니다. 멀티스레드에서는 동시성 관리가 필수적인데, 작업 대상인 DOM은 공유자원이고, 이를 여기저기서 조작하려면 아무래도 꽤 복잡했겠죠?
Thread A: div.style.color = "red" ─┐
Thread B: div.remove() ├─ 동시에 실행되면?
Thread C: div.style.color = "blue" ─┘
→ 어떤 결과가 맞는 거지? 💥 race condition
간단한 조작을 하려고 했는데, 위와 같이 동시성을 관리하고 lock을 도입하기엔 오버 엔지니어링이다 싶었는지 브렌던은 아예 싱글스레드 + 이벤트 루프로 JS를 설계합니다.
Node.js가 이걸 그대로 이어받은 건 Ryan Dahl의 철학 때문이었습니다.
“I/O는 어차피 기다리는 시간이 대부분이다. 그 시간을 이벤트 루프로 활용하면 충분하다.”
실제로 웹 서버는 CPU 계산보다 I/O 대기가 압도적으로 많아서, 이 방식이 잘 작동합니다.
Node.js 이벤트 루프:
요청 A (DB 쿼리 대기 중) → 일단 넘기고
요청 B (파일 읽기 대기 중) → 일단 넘기고
요청 C (계산 실행) → 처리
↑
응답 오면 다시 처리 (콜백/Promise)
일반적인 API 서버에서는 Node.js의 이벤트 루프로 충분합니다.
Go가 진짜 빛나는 건 동시 연결이 수만 개를 넘어가거나, CPU 작업도 많은 경우입니다.
4. V8 JIT — JS가 싱글스레드인데 왜 빠른가
CPU 집약 작업이 아닌 이상, JS도 쓸만하다는 건데, 이 녀석은 싱글스레드 주제에 어떻게 빠른걸까요?
싱글스레드임에도 빠를 수 있는 이유 중 하나로, V8이 존재합니다.
V8은 JS 코드를 읽고 실행하는 프로그램입니다.
읽고 실행하는 과정에서, 바로 기계어로 컴파일하지 않고 세 단계를 거쳐 컴파일합니다.
Ignition — 인터프리터
Ignition이 JS 소스코드를 파싱해 AST*를 만들고, 이걸 바이트코드로 변환해 실행합니다.
이 과정에서 이 함수는 몇 번 불렸는지, 인자 타입이 뭔지를 계속해서 관찰합니다.
*Abstract Syntax Tree (추상 구문 트리), 코드를 트리구조로 표현한 것
JS 소스코드
↓
[Parser] → AST
↓
[Ignition] → 바이트코드 실행 + 관찰
↓
"add() 1만번 불림, 항상 number + number"
↓
TurboFan에게 넘김
TurboFan — JIT 컴파일러
TurboFan이 Ignition이 수집한 프로파일링 데이터를 받아 기계어로 컴파일합니다.
TurboFan:
"a, b가 항상 number라고 가정하고
타입 체크 코드 없이 바로 덧셈하는 기계어 생성"
이렇게 최적화된 기계어는 C 수준에 근접합니다.
JIT(Just-In-Time)라는 이름처럼, 실행 시점에 컴파일해서 정적 컴파일 언어에 준하는 성능을 냅니다.
Deoptimization — JIT의 아킬레스건
타입 일관성이 깨지면 TurboFan이 만든 기계어를 버리고 다시 처음부터 시작합니다.
add(1, 2) // number ✅
add(3, 4) // number ✅
// ... 9만번 더 ...
add("hello", 2) // 갑자기 string..!
// V8: "number 전용으로 최적화했는데 string이 들어왔잖아?"
// → 기계어 버림 → Ignition으로 롤백 → 다시 프로파일링
TypeScript를 쓰는 게 단순히 타입 안정성뿐 아니라 V8 최적화에도 직결되는 이유💡입니다.
Hidden Class — 동적 객체를 정적처럼
JS는 동적 언어라 객체 구조가 런타임에 바뀔 수 있습니다. 반면, C의 struct는 컴파일 타임에 구조가 확정되어 메모리 접근이 빠릅니다. V8은 Hidden Class로 이를 흉내냅니다.
const u1 = { id: 1, name: "유니" }
// → HiddenClass_A: { id: offset 0, name: offset 8 }
const u2 = { id: 2, name: "철수" }
// → HiddenClass_A 재사용! (같은 구조)
// → u1과 u2가 같은 Hidden Class를 공유
// → 메모리 접근이 C 구조체처럼 최적화됨
하지만 구조가 달라지는 순간 Hidden Class가 분기되어 최적화가 깨집니다.
// ❌ 프로퍼티 추가 순서가 다름 → 다른 Hidden Class
const u1 = {}
u1.id = 1 // HiddenClass_A { id }
u1.name = "유니" // HiddenClass_B { id, name }
const u2 = {}
u2.name = "철수" // HiddenClass_C { name } ← 순서 다름!
u2.id = 2 // HiddenClass_D { name, id } ← 새 HC 생성
// u1 → HC_B, u2 → HC_D → 다른 Hidden Class → 최적화 불가
// ❌ 조건부 프로퍼티 추가
const result: any = {}
if (isAdmin) result.role = 'admin' // 조건에 따라 구조가 달라짐
// ✅ 항상 같은 구조
class UserResponseDto {
id: number
role: string | null
constructor(user: User) {
this.id = user.id
this.role = user.isAdmin ? 'admin' : null
}
}
NestJS에서 DTO 같은 거 정의할 때 any대신 class로 정의하면 사람도 뭐가 뭔지 알기 쉽지만, 기계도 뭐가 뭔지 빨리 알 수 있는 구조였네요.
Vercel cold start와 JIT
Vercel에서의 cold start도 이와 같은 맥락입니다. 서버가 새로 뜨면 JIT 캐시가 없습니다. Ignition이 처음부터 다시 관찰하고 TurboFan이 다시 컴파일합니다. 그 워밍업 시간이 첫 요청의 레이턴시로 나타납니다.
서버 새로 뜸
↓
JIT 캐시 없음
↓
Ignition이 바이트코드로 처음부터 실행
↓
TurboFan이 핫 함수 다시 파악 → 기계어로 컴파일
↓
첫 요청 느림 (수백ms~수초)
↓
이후 요청 빠름
5. V8의 Cold start가 없을 수 있을까? 있음 — Bun
JS/TS 코드를 실행하는 환경으로는 Node.js뿐만 아니라 Deno나 Bun 같은 것도 존재합니다.
Node.js (2009) — Ryan Dahl, V8 + libuv
Deno (2020) — Ryan Dahl, V8 + Tokio(Rust) ← 같은 사람이 다시 만듦
Bun (2022) — Jarred Summer, JavaScriptCore + Zig
Deno는 Ryan Dahl이 Node.js를 만들고 나서 후회한 점(보안 모델, TS 기본 지원, node_modules 제거 등)들을 개선한 건데, Bun은 아예 다른 엔진을 사용합니다.
Bun이 빠른 이유 — JavaScriptCore
Bun은 V8 대신 JavaScriptCore(JSC)를 씁니다. Safari에서 쓰는 엔진이에요.
V8의 TurboFan은 최적화에 시간을 많이 투자합니다. 워밍업 후 최고 성능을 내지만 cold start 이슈가 있죠.
JSC는 3단계 JIT(LLInt → DFG → FTL)로 워밍업이 훨씬 빠릅니다.
서버리스 환경처럼 cold start가 잦은 곳에서 유리한 이유입니다.
그리고 Bun은 런타임, 패키지 매니저, 번들러, 테스트 러너가 전부 하나에 내장되어 있다고 합니다.
도구들 사이 오버헤드가 없으니 빠를 수밖에 없겠죠?
Node.js 생태계: Bun 하나로:
node + npm/yarn/pnpm 실행
+ jest/vitest + 패키지 설치
+ esbuild + 테스트
+ ts-node + 번들링
+ nodemon + TS 실행 + watch 모드
Deno는 의외로(?) Node.js와의 호환성이 낮은데, Bun은 높아서 기존 노드들이 대부분 그대로 실행된다고 합니다.
6. Nest에는 없는데 Go에는 있는 것 — context 패키지
다시 Go를 살펴보는 것으로 돌아와서, NestJS에는 없지만 Go에는 있는 것이 있다면 context가 떠오릅니다.
NestJS에서는 요청 정보를 서비스 깊은 곳까지 전달할 때 @Inject(REQUEST)나 클로저를 씁니다.
@Injectable({ scope: Scope.REQUEST })
export class UserService {
constructor(@Inject(REQUEST) private request: Request) {}
getUser() {
const userId = this.request.user.id
}
}
Go에서는 context를 함수 첫 번째 인자로 명시적으로 넘깁니다. 단순히 값 전달이 아닙니다. context는 세 가지 역할을 합니다.
취소 전파
유저가 요청 중간에 브라우저를 닫으면 어떻게 될까요? NestJS에서는 클라이언트가 떠나도 서버는 떠난 줄 모르고 요청을 끝까지 완수합니다. Go에서는 클라이언트가 떠났음을 인지할 수 있습니다.
func handleGetPosts(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // 브라우저 닫히면 자동 cancel
// ctx를 DB 쿼리에 전달
posts, err := db.QueryContext(ctx, "SELECT * FROM posts WHERE heavy_query = true")
if err != nil {
// context.Canceled — 클라이언트가 떠난 것
log.Println("클라이언트 떠남, 쿼리 중단:", err)
return
}
}
db.QueryContext(ctx, ...)처럼 ctx를 받는 버전을 쓰면, DB 드라이버가 ctx 취소 신호를 감지해서 커넥션 레벨에서 쿼리를 끊습니다. DB 부하가 큰 쿼리를 진행 중이었다면, 꽤 의미있는 차이를 만들어낼 수 있겠죠? 응답할 곳도 없는데 CPU와 DB 커넥션을 점유하는 상황을 막을 수 있으니까요.
타임아웃 자동 전파
context의 또 하나의 강점은 타임아웃이 호출 체인 전체에 자동으로 전파된다는 겁니다.
// 3초 안에 못 끝내면 자동 취소
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // 반드시 defer로 해제
user, err := fetchUser(ctx, userID) // 남은 시간: 3초
posts, err := fetchPosts(ctx, user.ID) // 남은 시간: 1.5초 (앞 작업 후 자동 적용)
NestJS에서 각 레이어마다 타임아웃을 따로 걸어야 하는 것과 달리, context 하나가 전체 호출 체인에 자동으로 흘러갑니다. 부모 context가 취소되면 자식 context도 전부 취소됩니다.
값 전달
미들웨어에서 JWT를 파싱해 userID를 context에 심으면, 서비스 어디서든 꺼낼 수 있습니다. NestJS의 @Req()와 비슷한 역할이에요.
type contextKey string
const userIDKey contextKey = "userID"
// 미들웨어에서 주입
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := extractUserID(r)
ctx := context.WithValue(r.Context(), userIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 깊은 곳에서 꺼내 씀
func (s *UserService) GetProfile(ctx context.Context) (*Profile, error) {
userID := ctx.Value(userIDKey).(string)
return s.repo.FindByID(ctx, userID)
}
7. Nest에는 있는데 Go에는 없는 것 — 데코레이터
Go의 context를 알게되면, 왜 이렇게 편리한게 NestJS는 없지? 하는 의문이 듭니다.
NestJS에도 Go에는 없는 편리한 것이 존재하는데, 그것이 바로 데코레이터입니다.
@Controller('/users')
@UseGuards(AuthGuard)
@UseInterceptors(Logger)
class UserController {
@Get('/:id')
getUser(@Param('id') id: string) {
// 실제 로직만
}
}
인증, 로깅, 직렬화가 전부 분리되고 핸들러는 로직에만 집중합니다.
이건 어떻게 동작하는 것이길래, Nest에만 존재할까요?
데코레이터의 정체 — 그냥 함수
// @UseGuards(AuthGuard)가 실제로 하는 일
function UseGuards(guard) {
return function(target) {
Reflect.defineMetadata('guards', guard, target) // 메타데이터로 저장
}
}
// 즉 이 두 코드는 동일합니다
@UseGuards(AuthGuard)
class UserController {}
// ↕ 완전히 같음
UseGuards(AuthGuard)(UserController) // 함수 호출이었던 것
데코레이터는 Reflect.defineMetadata로 정보를 태그처럼 붙여두고, NestJS 프레임워크가 요청 진입 시 Reflect.getMetadata로 꺼내 실행합니다. 코드에는 보이지 않지만 런타임에 동작이 추가되는 구조입니다.
Go에 데코레이터가 없는 이유
는 기술적 한계가 아니라, 철학적 선택이었다고 합니다.
Go는 “코드를 읽으면 동작이 다 보여야 한다”를 중요하게 여깁니다.
예를 들어, NestJS에서의 @UseGuards(AuthGuard) 한 줄이 실제로 뭘 하는지 알려면 AuthGuard 내부를 파고들어야 합니다.
Go 창시자들은 이런 “보이지 않는 동작”을 싫어했다고 합니다.
Go에서는 미들웨어 체이닝으로 같은 결과를 만들지만, 흐름이 코드에 명시됩니다.
router.GET("/users/:id",
authMiddleware( // @UseGuards
logMiddleware( // @UseInterceptors
getUser, // 실제 핸들러
),
),
)
authMiddleware 다음에, logMiddleware가 실행된다는 거죠.
@UseGuards(AuthGuard) // 1번째
@UseGuards(RoleGuard) // 2번째
@UseInterceptors(LogInterceptor) // 3번째
@UseInterceptors(CacheInterceptor) // 4번째
getUser() {}
반면 Nest에서는 위와 같은 순서로 실행됩니다.
같은 종류끼리는 위→아래, 다른 종류끼리는 Guard → Interceptor → Pipe → Handler 순으로 실행되는 것이 Nest에서의 약속입니다. cf
Go 코드를 처음 마주했을 땐 다소 깔끔하지 않은 느낌이었는데, 확실히 명시적인 것 같긴 합니다.
8. Go는 추상화를 싫어하는가
보이지 않는 동작을 싫어한다는 Go를 보다 보면 이런 생각이 듭니다. 그럼 Go는 추상화를 싫어하나?
interface 같은 건 쓰는 걸 보면, 추상화 절대 싫어! « 이런 건 아니었던 것 같습니다.
다만 무언가를 계속 타고 들어가야 하는 걸 경계했음은 느껴집니다.
Go에 없는 것들 이유
─────────────────────────────────────
클래스 상속 계층이 숨겨짐
데코레이터 보이지 않는 동작
try/catch 예외 흐름 추적이 어려움
연산자 오버로딩 a+b가 뭘 하는지 모름
Go에 있는 것들 이유
─────────────────────────────────────
interface 명시적 계약
미들웨어 체이닝 흐름이 코드에 보임
명시적 에러 반환 에러 흐름 추적 가능
컴포지션(embedding) 상속 대신 명시적 조합
이런 명시성을 느낄 수 있는 또다른 부분은 에러 핸들링이 있습니다.
// Go — 에러를 반환값으로 명시
user, err := getUser()
if err != nil { // getUser 실패 → 확실 }
posts, err := getPosts()
if err != nil { // getPosts 실패 → 확실 }
// try/catch — 어디서 터진 건지 모름
try {
await getUser() // 여기서?
await getPosts() // 여기서?
await getComments() // 여기서?
} catch (e) {
// 모르겠음
}
}
if err != nil 반복이 다소 답답하게 느껴질 수도 있을 것 같습니다. 그래도, 이런게 필수인 덕에 에러가 어디서 어떻게 흐르는지 코드만 읽어도 파악이 되는 장점이 있을 것 같네요. try/catch는 예외가 어디서 던져지고 어디서 잡히는지 전체 맥락을 봐야만 알 수 있긴 하니까요.
9. NestJS의 장점은?
보안 이슈가 있긴 하지만,, npm 패키지는 230만 개가 넘습니다. 결제, 소셜로그인, 이메일, SMS, 온갖 서드파티 연동이 다 있죠. TypeScript를 프론트와 백엔드가 함께 쓰면 타입을 한 번만 정의해도 된다는 장점도 있습니다. 데코레이터 한 줄로 끝나는 인증, 캐싱, 직렬화는 기능을 빠르게 찍어낼 때 진짜 강력합니다.
Go는 동시 연결이 수만 개를 넘거나, CPU 집약 작업이 많거나, 긴 쿼리가 많아 리소스 효율이 중요한 서비스에서 빛납니다. Kubernetes, Docker, Prometheus, Cilium이 전부 Go로 만들어진 건 우연이 아닙니다.
NestJS가 강한 곳
- 빠른 기능 개발 (스타트업, 초기)
- 풀스택 TypeScript (Next.js + NestJS)
- 외부 연동 많은 서비스
- 팀 온보딩 빠르게
Go가 강한 곳
- 동시 연결 수만 개 (채팅, 실시간)
- CPU 집약 작업 (이미지 처리, 암호화)
- 인프라 툴링
- 성능이 크리티컬한 마이크로서비스
3년 전에 쓰던 언어를 3년 만에 다시 들여다봤습니다.
그땐 비교할만한 다른 경험도 별로 없었던 것 같은데, 이제는 좀 더 장단점이 눈에 보이네요.
여기까지. 안녕!