Featured Post
작성자:
Iros
- 공유 링크 만들기
- X
- 이메일
- 기타 앱

요즘 개발하다 보면, 진짜 옆자리에 AI 코딩 도우미 하나 앉혀두고 일하는 느낌이 들 때가 많아요. 저도 개발자로 20년 넘게 밥벌이를 해왔는데, GitHub Copilot이나 ChatGPT가 코드 흐름을 슥 읽고 다음 코드를 제안해주는 걸 보면 아직도 가끔은 신기합니다. 예전 같으면 한참 검색하고, 샘플 코드 뒤지고, 팀 위키까지 뒤적였을 일을 이제는 몇 초 만에 초안으로 뽑아주니까요.
그런데 말이죠. 편해진 만큼 은근히 찜찜한 부분도 생기더라고요. 특히 TypeScript 타입 안전 쪽이 그래요. AI가 코드는 꽤 그럴듯하게 만들어주는데, 자세히 들여다보면 any가 슬쩍 들어가 있거나, API 응답 타입을 너무 낙관적으로 가정하거나, 상태값을 그냥 string으로 퉁쳐버리는 경우가 꽤 있습니다. 겉으로는 잘 돌아가요. 그래서 더 무섭습니다. 나중에 운영에서 터지거든요.
그래서 오늘은 제가 실제로 일하면서 AI 바이브 코딩을 어떻게 쓰고 있는지, 그리고 그 과정에서 TypeScript 타입 안전을 어떻게 챙기는지 편하게 한번 풀어보려고 해요. 뭐랄까, 대단한 이론 강의라기보다는 “나 이렇게 하니까 덜 불안하더라”에 가까운 이야기입니다.
AI가 코드를 잘 짜주는 시대에도 타입은 결국 사람이 챙겨야 하더라
솔직히 고백하자면, 저도 예전에는 any를 그렇게 미워하진 않았어요. 급할 때 있잖아요. 일정은 밀리고, 기획은 바뀌고, 테스트 서버는 터지고. 그럴 때 any 한 번 넣으면 당장은 일이 앞으로 갑니다. 당장은요.
그런데 AI 코딩 도구를 본격적으로 쓰기 시작하면서 생각이 좀 바뀌었어요. AI는 생각보다 문맥을 잘 잡아줍니다. 함수 이름도 그럴듯하게 만들고, 반복 코드도 잘 줄여주고, 테스트 코드 초안도 꽤 괜찮게 뽑아줘요. 그런데 우리 서비스 안에 있는 도메인 규칙, 예를 들면 “이 상태에서는 이 필드가 반드시 있어야 한다”거나 “이 API는 실패해도 200을 내려주고 내부 코드로 판단해야 한다” 같은 묘한 사내 룰까지 완벽하게 이해하진 못합니다.
최근에 사내 백오피스 쪽 사용자 관리 모듈을 손보다가 이런 일이 있었어요. AI에게 REST API 클라이언트 함수를 만들어달라고 했더니, 아주 빠르게 코드를 뽑아주더군요. 그런데 반환 타입이 Promise<any>였습니다.
처음엔 별생각 없었어요. “일단 돌아가면 됐지” 싶었죠. 그런데 그 함수를 다른 화면에서 가져다 쓰는 순간부터 슬슬 불편해졌습니다. 자동완성도 애매하고, user.name이 진짜 문자열인지, createdAt이 문자열인지 Date인지도 불분명하고요. 나중에는 팀원이 그 데이터를 믿고 썼다가 런타임에서 에러가 났습니다. 로그를 보는 순간 약간 허탈했어요.
TypeError: Cannot read properties of undefined (reading 'name')
at UserDetailPage.tsx:42:18
at renderWithHooks
at updateFunctionComponent
이런 에러, 다들 한 번쯤 봤을 거예요. 별것 아닌 것처럼 보이는데 막상 운영 중에 만나면 속이 서늘합니다. 그래서 그때부터 저는 AI가 만들어준 코드를 바로 믿지 않고, 꼭 한 번은 타입 관점에서 다시 봅니다. 약간 습관처럼요.
제가 세운 기준은 단순합니다. AI는 초안을 빠르게 만들고, 타입 안전은 내가 책임진다. 이 정도 선을 잡으니까 오히려 마음이 편하더라고요.
실제로 AI가 만들어준 REST 함수, 이렇게 고쳤습니다
말로만 하면 좀 싱겁잖아요. 실제로 제가 자주 마주치는 형태의 코드를 한번 볼게요. 사용자 정보를 가져오는 API 함수입니다. AI에게 “사용자 ID로 사용자 정보를 가져오는 fetch 함수를 만들어줘”라고 하면 대충 이런 코드가 나옵니다.
// AI가 처음 만들어준 코드에 가까운 형태
async function fetchUser(id: string): Promise<any> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
짧고 깔끔하죠. 처음 보면 괜찮아 보입니다. 그런데 이 코드에는 제 기준으로 아쉬운 점이 몇 가지 있어요.
Promise<any>라서 호출하는 쪽에서 타입 도움을 거의 못 받습니다.response.ok확인이 없어서 404나 500이어도 그대로 JSON을 파싱하려고 합니다.- API 응답이 정말 사용자 형태인지 런타임 검증이 없습니다.
createdAt같은 날짜 필드가 문자열인지Date인지 불명확합니다.
그래서 저는 보통 이렇게 손봅니다.
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`사용자 조회 실패: ${response.status} ${response.statusText}`);
}
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('사용자 API 응답 형식이 올바르지 않습니다.');
}
return data;
}
function isUser(data: unknown): data is User {
if (typeof data !== 'object' || data === null) {
return false;
}
const obj = data as Record<string, unknown>;
return (
typeof obj.id === 'string' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string' &&
typeof obj.createdAt === 'string'
);
}
여기서 제가 제일 신경 쓰는 부분은 any를 unknown으로 바꾸는 거예요. 사실 any는 TypeScript에게 “너 이제 쉬어도 돼”라고 말하는 것과 비슷합니다. 반면 unknown은 “아직 모르니까 확인하고 쓰자”에 가깝고요. 저는 외부 API 응답, JSON 파싱 결과, localStorage에서 꺼낸 값에는 웬만하면 unknown을 먼저 둡니다.
물론 이렇게 하면 코드가 조금 길어집니다. 귀찮기도 해요. 그런데 운영에서 이상한 데이터가 들어왔을 때, 어디서 잘못됐는지 훨씬 빨리 잡힙니다. 40대 개발자가 되면요, 코드 몇 줄 아끼는 것보다 새벽 장애 알림 한 번 덜 받는 게 더 소중합니다. 이건 정말입니다.
| 구분 | AI가 처음 만든 코드 | 제가 다듬은 코드 |
|---|---|---|
| 반환 타입 | Promise<any> |
Promise<User> |
| API 실패 처리 | 거의 없음 | response.ok 확인 후 명확한 에러 발생 |
| 응답 검증 | 바로 response.json() 반환 |
unknown으로 받은 뒤 타입 가드 적용 |
| 호출부 자동완성 | 약함 | User 타입 기반으로 안정적 |
| 운영 중 디버깅 | 에러 원인 추적이 늦어짐 | 응답 형식 문제를 초기에 발견 가능 |
이 정도만 해도 체감이 꽤 큽니다. 특히 팀 프로젝트에서는 더 그래요. 내가 만든 함수를 다른 사람이 가져다 쓸 때, 타입이 제대로 잡혀 있으면 사용법 자체가 코드에 녹아들거든요. 따로 문서 한 장 덜 써도 됩니다. 자동완성이 어느 정도 문서 역할을 해주니까요.
AI에게 프롬프트를 줄 때 타입 정보를 같이 던져야 결과가 좋아집니다
제가 처음 AI 코딩 도구를 쓸 때는 그냥 이렇게 물어봤어요.
사용자 목록을 가져오는 함수를 만들어줘.
그러면 AI도 나름 열심히 만들어줍니다. 그런데 결과물은 매번 조금씩 달라요. 어떤 날은 any, 어떤 날은 임의로 만든 User 타입, 또 어떤 날은 에러 처리가 없는 코드. 뭐랄까, 운에 맡기는 느낌이 있었습니다.
요즘은 조금 더 구체적으로 요청합니다.
아래 User 인터페이스를 사용해서 사용자 목록을 가져오는 TypeScript 함수를 만들어줘.
반환 타입은 Promise<User[]>로 명시해줘.
fetch 실패 시 Error를 throw하고, response.json() 결과는 unknown으로 받은 뒤 타입 가드로 검증해줘.
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
이렇게 적으면 결과가 확실히 달라집니다. AI가 처음부터 User 타입을 의식하고 코드를 짜기 때문에, 제가 나중에 고칠 부분이 줄어요. 물론 완벽하진 않습니다. 그래도 출발점이 좋아지면 리뷰 시간이 확 줄어듭니다.
제가 자주 쓰는 프롬프트 문장도 몇 개 있어요. 별거 아닌데 은근히 효과가 좋습니다.
- 반환 타입을 명시적으로 작성해줘.
any는 사용하지 말고, 필요한 경우unknown을 사용해줘.- 외부 API 응답은 타입 가드로 검증해줘.
strict: true환경에서 컴파일 에러가 없도록 작성해줘.- 상태값은
string대신 유니온 타입으로 정의해줘. - 에러 케이스를 호출부에서 구분할 수 있게 설계해줘.
AI에게 일을 맡길 때도 결국 지시가 중요하더라고요. 사람 후배에게 “대충 만들어줘”라고 하면 대충 나오는 것처럼, AI도 마찬가지입니다. 요구사항을 구체적으로 줄수록 결과물이 좋아집니다. 조금 냉정하게 말하면, AI가 못하는 게 아니라 제가 대충 시킨 경우도 많았어요.
상태값을 string으로 두면 나중에 꼭 삐끗합니다
제가 AI 생성 코드에서 자주 보는 패턴 중 하나가 상태값을 그냥 string으로 두는 겁니다. 예를 들어 주문 상태를 다룬다고 해볼게요.
// AI가 자주 만드는 형태
interface Order {
id: string;
status: string;
amount: number;
}
처음엔 문제가 없어 보입니다. 그런데 실제 서비스에서는 status에 아무 문자열이나 들어가면 곤란하죠. pending, confirmed, shipped, cancelled 정도만 허용해야 하는데, done이나 complete 같은 값이 슬쩍 들어와도 TypeScript는 아무 말도 안 합니다. 왜냐하면 그냥 string이니까요.
그래서 저는 이런 식으로 바꿉니다.
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'cancelled';
interface Order {
id: string;
status: OrderStatus;
amount: number;
}
이렇게 해두면 상태값을 잘못 넣는 순간 바로 잡힙니다.
const order: Order = {
id: 'order-001',
status: 'complete',
amount: 32000,
};
// TypeScript Error:
// Type '"complete"' is not assignable to type 'OrderStatus'.
이런 에러는 고마운 에러예요. 개발 중에 빨리 맞는 게 낫습니다. 운영에서 고객 주문 상태가 이상하게 보이는 것보다 훨씬 싸게 먹히거든요.
저는 AI에게도 아예 이렇게 요청합니다.
주문 상태는 string으로 두지 말고
'pending' | 'confirmed' | 'shipped' | 'cancelled'
유니온 타입으로 정의해서 사용해줘.
이 한 문장만 넣어도 코드 품질이 꽤 달라져요. 특히 프론트엔드에서 버튼 노출 여부나 라벨 색상을 상태값 기준으로 분기할 때 안정감이 생깁니다.
| 상태 타입 방식 | 장점 | 아쉬운 점 | 제가 쓰는 기준 |
|---|---|---|---|
string |
작성은 빠름 | 잘못된 값도 통과 | 거의 사용하지 않음 |
| 유니온 타입 | 가볍고 자동완성이 좋음 | 값이 많아지면 길어짐 | 프론트엔드 상태값에 자주 사용 |
enum |
명시적이고 구조화하기 좋음 | 취향에 따라 호불호 있음 | 백엔드 계약과 맞출 때 사용 |
제네릭을 조금만 섞어도 AI 코드가 훨씬 오래 갑니다
AI가 만들어주는 코드는 보통 당장의 요구사항에 딱 맞춰져 있습니다. 이게 장점이기도 한데, 프로젝트가 커지면 단점이 되기도 해요. 예를 들어 사용자 목록 페이지네이션을 만들어달라고 하면 이런 식으로 나올 때가 많습니다.
interface UserPageResponse {
items: User[];
totalCount: number;
page: number;
pageSize: number;
}
이 코드 자체는 괜찮습니다. 그런데 비슷한 구조가 주문 목록, 상품 목록, 게시글 목록에도 필요해지면 어떻게 될까요? OrderPageResponse, ProductPageResponse, PostPageResponse가 계속 생깁니다. 이름만 다르고 구조는 비슷한 타입이 쌓여요. 저는 이런 걸 보면 약간 간지럽습니다.
그래서 보통 이렇게 바꿉니다.
interface PageResponse<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}
type UserPageResponse = PageResponse<User>;
type OrderPageResponse = PageResponse<Order>;
이렇게 제네릭으로 빼두면 재사용하기가 좋습니다. AI가 만들어준 코드가 일회용 초안에서 프로젝트 공용 코드로 올라가는 느낌이랄까요.
API 클라이언트도 마찬가지입니다.
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`API 요청 실패: ${response.status}`);
}
return response.json() as Promise<T>;
}
다만 이 코드는 편하지만 조심해야 합니다. as Promise<T>는 런타임 검증이 아니에요. TypeScript에게 “이거 T라고 믿어줘”라고 말하는 것뿐입니다. 내부 관리자 도구처럼 API 계약이 꽤 안정적인 곳에서는 쓸 수 있지만, 외부 API나 결제, 주문처럼 민감한 영역에서는 타입 가드를 붙이는 쪽이 낫습니다.
그래서 제 기준은 이렇습니다.
- 사내 API이고 스키마가 안정적이면 제네릭 기반
fetchJson<T>를 사용합니다. - 외부 API이거나 데이터 품질이 들쭉날쭉하면
unknown+ 타입 가드를 사용합니다. - 결제, 주문, 권한처럼 사고 나면 큰 영역은 조금 귀찮아도 런타임 검증을 넣습니다.
개발에는 늘 균형이 필요하잖아요. 모든 곳에 완벽한 검증을 넣으면 코드가 무거워지고, 아무 데도 안 넣으면 불안합니다. 저는 위험도가 높은 곳부터 단단하게 막는 편입니다.
strict 모드는 귀찮지만, 켜두면 결국 나를 살려줍니다
제 tsconfig.json에는 거의 항상 strict: true가 들어갑니다. 처음 TypeScript를 도입하는 팀에서는 이 설정 때문에 불평이 좀 나옵니다. 저도 이해해요. 갑자기 빨간 줄이 우르르 뜨면 기분이 좋을 리가 없죠.
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
}
그런데 AI 바이브 코딩을 할 때는 이 설정이 특히 더 중요하다고 봅니다. AI가 만든 코드가 겉보기엔 멀쩡해도, strict 모드에서 돌려보면 숨어 있던 문제가 꽤 잘 드러나요.
- 파라미터 타입을 빼먹은 함수
- null 가능성을 무시한 코드
- 배열 인덱스로 접근했는데 값이 없을 수 있는 코드
- API 응답 필드를 무조건 있다고 가정한 코드
이런 것들이 개발 중에 잡히면 다행입니다. 조금 귀찮아도 그 자리에서 고치면 되니까요. 반대로 이걸 다 통과시켜버리면, 나중에 QA나 운영에서 더 비싼 비용으로 돌아옵니다. 경험상 그렇더라고요.
저는 AI가 코드를 만들어주면 보통 이런 순서로 봅니다.
any가 있는지 먼저 검색합니다.- API 응답 타입이 명시되어 있는지 확인합니다.
- null이나 undefined 가능성을 무시한 곳이 있는지 봅니다.
- 상태값이
string으로 뭉개져 있지 않은지 확인합니다. strict모드에서 컴파일 에러가 없는지 확인합니다.
체크리스트라고 거창하게 말했지만, 사실 몇 번 하다 보면 손에 익습니다. 코드 리뷰할 때도 이 기준으로 보면 훨씬 덜 흔들려요. “느낌상 이상한데?”가 아니라 “여기 타입이 열려 있어서 위험해요”라고 말할 수 있으니까요.
제가 실제로 쓰는 AI 바이브 코딩 타입 안전 체크리스트
일하면서 자주 확인하는 것들을 조금 더 정리해보면 이렇습니다. 저는 이걸 따로 메모 앱에 적어두고, 새 프로젝트 시작할 때마다 한 번씩 봅니다. 나이가 들수록 기억력보다는 체크리스트가 믿음직하더라고요.
| 확인 항목 | 왜 보는지 | 제가 선호하는 방식 |
|---|---|---|
any 사용 여부 |
타입 체커가 무력화되기 쉬움 | unknown으로 바꾸고 필요한 검증 추가 |
| API 응답 타입 | 런타임 데이터와 타입 선언이 다를 수 있음 | 도메인 타입 + 타입 가드 조합 |
| 상태값 타입 | 잘못된 문자열이 들어오기 쉬움 | 유니온 타입 또는 enum 사용 |
| null 처리 | 화면 렌더링 중 자주 터짐 | strictNullChecks 켜고 분기 처리 |
| 재사용 가능성 | 비슷한 타입이 계속 복사될 수 있음 | 제네릭으로 공통 구조 분리 |
| 에러 메시지 | 운영 디버깅 시간을 줄여줌 | 상태 코드와 도메인 맥락을 함께 남김 |
이 중에서 하나만 고르라면 저는 any를 unknown으로 바꾸는 습관을 고를 것 같아요. 효과 대비 난이도가 낮습니다. 코드가 갑자기 엄청 우아해지는 건 아닌데, 위험한 지점을 눈에 보이게 만들어줘요. 그게 중요합니다.
AI 코딩 도구를 쓰다 보면 속도가 빨라집니다. 그런데 속도가 빨라지면 실수도 빨리 쌓일 수 있어요. 그래서 저는 AI가 만들어준 코드를 “완성품”으로 보지 않고 “괜찮은 초안”으로 봅니다. 초안을 빠르게 받고, 타입으로 단단하게 다듬는 식이죠.
AI 바이브 코딩은 편하지만, 품질의 책임자는 여전히 개발자입니다
사실 AI 바이브 코딩이라는 말이 주는 느낌이 좀 있잖아요. 커피 한 잔 옆에 두고, AI에게 말로 시키면 코드가 술술 나오고, 나는 옆에서 방향만 잡는 그런 그림. 저도 그 느낌 좋아합니다. 실제로 생산성도 많이 올라갔고요.
그런데 일을 하다 보면 결국 현실로 돌아옵니다. 장애가 나면 AI가 슬랙에 사과문을 올려주진 않거든요. 고객 데이터가 꼬이면, 타입을 대충 둔 책임은 우리 팀이 져야 합니다. 그래서 저는 AI를 아주 좋은 동료처럼 쓰되, 최종 리뷰어 자리는 사람에게 남겨둬야 한다고 생각해요.
특히 TypeScript를 쓰는 팀이라면 AI가 만들어준 코드에 타입 안전을 입히는 과정이 꼭 필요합니다. 거창한 아키텍처까지 안 가도 됩니다. any 줄이고, unknown으로 받고, 타입 가드 조금 쓰고, 상태값을 유니온 타입으로 묶는 것. 이 정도만 해도 코드가 훨씬 차분해집니다.
이 글은 TypeScript를 쓰면서 GitHub Copilot이나 ChatGPT 같은 도구로 코딩 속도를 높이고 싶은 분들, 그런데 한편으로는 “이렇게 막 만들어도 괜찮나?” 하고 살짝 불안했던 분들이 읽으면 좋을 것 같아요. 이미 AI 코딩을 쓰고 있다면 오늘 이야기한 체크리스트 중 하나만 적용해봐도 꽤 차이를 느낄 겁니다.
저는 요즘도 AI 도움을 많이 받습니다. 대신 그대로 붙여넣지는 않아요. 빠르게 초안을 받고, 제 기준으로 타입을 조이고, 위험한 부분을 한 번 더 확인합니다. 이 정도 거리감이 딱 좋더라고요. 너무 믿지도 않고, 괜히 멀리하지도 않고요. 개발 도구와 오래 잘 지내는 방법도 결국 사람 관계랑 비슷한 구석이 있는 것 같습니다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기