-
자바스크립트 객체 때문에 에러를 겪는 개발자들을 위한 실무 안내서개발/자바스크립트로의 깊은 잠수 2026. 4. 15. 21:22

거창하게 안내서라고 적어뒀지만 사실은 실무 팁같은 느낌의 글이에요.
자바스크립트의 객체는 잘못 다루기 쉽습니다.
다루는 난이도가 높은 것에 비해 쓰기는 쉽죠.
우리 개발자들은 실무에서 React, Vue 같은 라이브러리, NestJS 같은 프레임워크가 제공하는 높은 수준으로 추상화된 객체를 빈번하게 다뤄야 합니다.
이 과정에서 흔히 발생하는 에러와 원인을 살펴보고, 에러를 방지하기 위해서는 어떤 마음가짐으로 객체를 다뤄야 하는지 정리해 보겠습니다.
JavaScript 객체의 특징
본격적으로 에러 케이스를 알아보기 전에 JavaScript 객체의 본질적인 특징부터 알아볼까요?
- mutable (변경 가능)
객체는 한 번 생성된 후에도 내부의 값을 자유롭게 바꿀 수 있어요.
const로 선언해도 변수에 담긴 참조가 바뀌지 않을 뿐, 객체 내부의 프로퍼티는 얼마든지 수정할 수 있습니다.- pass by reference (참조에 의한 전달)
객체를 함수 인자로 넘기거나 다른 변수에 할당할 때, 값이 복사되는 게 아니라 같은 객체를 가리키는 참조가 전달됩니다.
그래서 한쪽에서 객체 내부를 수정하면, 그 객체를 참조하고 있던 다른 모든 곳에서도 바뀐 값이 보이게 돼요.- shallow copy (얕은 복사)
spread 연산자나 Object.assign으로 객체를 복사하면, 가장 바깥쪽 한 겹만 새 객체로 만들어집니다.
내부에 중첩된 객체들은 여전히 원본과 같은 참조를 공유하므로, 깊은 곳을 수정하면 원본도 함께 영향을 받습니다.[React]
분명 값을 바꾸고 setState를 호출했는데, 화면이 바뀌지 않아도 당황하지 마세요.
React는 객체 내부 변이를 감지하지 못할 뿐입니다.const [user, setUser] = useState({ name: '철수', address: { city: '서울' } }); // 아래 코드를 아무리 실행해도 화면에는 '철수', '서울'이 나옵니다... const handleChange = () => { user.address.city = '부산'; user.name = '영희'; setUser(user); }; return ( <div> <span>이름: {user.name}</span> <span>지역: {user.address.city}</span> <button onClick={handleChange}>바꾸기</button> </div> );`setUser()`를 분명 호출했는데 화면에 렌더링된 결과가 바뀌지 않아서 많이 당황하셨죠?
React는 state 변경을 참조간의 비교(===)로 감지하기 때문입니다.
이는 사실 React의 특성이라기보다는, JavaScript의 특성인데요.
일치 연산자 `===`는 두 객체 안의 내용물과 관계 없이, 참조하는 주소가 같은지를 비교합니다. (동등 연산자 `==`도 마찬가지)
즉 객체 내부의 내용을 바꿔도 React는 그 사실을 모릅니다.
위의 예시에서 `user`라는 변수에 할당된 객체는 바뀐 적이 없습니다.
그래서 `user`가 가리키는 메모리 주소는 그대로이기 때문에, React는 이전 state와 새 state를 같다고 판단합니다.
내부의 `city`, `name` 값이 바뀌었든 말든 말이에요.
의도대로 화면을 리렌더링 하려면, 아래와 같이 객체의 참조 주소가 바뀌도록 새 객체를 만들어주어야 합니다.
const handleChange = () => { const newUser = { ...user, address: { ...user.address, city: '부산' }, }; setUser(newUser); };이것은 배열도 마찬가지입니다. `push(), sort(), reverse(), splice()`는 모두 원본 배열을 직접 변이하는 메서드이므로, 같은 문제를 일으킵니다.
const [items, setItems] = useState(['a', 'b', 'c']); // 이렇게 하면 리렌더링이 안됩니다 items.push('d'); setItems(items); // 같은 참조 // 이렇게 하면 리렌더링 됩니다 setItems([...items, 'd']); // 새 배열중첩이 깊어질수록 spread가 장황해지는 것이 고민이라면, Immer의 produce를 활용해 보세요.
직접 변이하는 것처럼 작성할 수 있는데, 내부적으로는 새 객체를 만들어 줍니다.
state가 예상치 못한 타이밍에 불규칙하게 바뀐다면, 자식 컴포넌트가 props 객체를 수정하고 있지는 않은지 확인해보세요.
위에서 살펴 봤듯, React는 객체 내부의 변화를 감지하지 못합니다.
따라서 자식 컴포넌트에서 props로 받은 객체 내부를 수정할 경우 당장은 리렌더링이 안 됩니다.
보이는 것에 변화가 없으니까, 조용한 버그를 일으키기도 쉽겠죠?
function Parent() { const [config, setConfig] = useState({ theme: 'light', fontSize: 14 }); return <Child config={config} />; } function Child({ config }) { const handleClick = () => { config.theme = 'dark'; // 부모가 모르는 사이에 부모의 state가 바뀜 }; // ... }자식이 config.theme = 'dark'를 실행하면, 부모의 config state 원본이 직접 바뀝니다.
함수의 인자로 객체를 전달할 때, 원시값은 복사된 값이 전달되지만 객체는 원본이 전달되기 때문입니다.
(엄밀히 말하자면, 얕은 복사본이 전달되므로 같은 원본 객체를 참조한다고 볼 수 있습니다)
이렇게 되면 setConfig가 호출되기 전까지 부모는 리렌더링되지 않고 있다가, 이후 부모가 다른 이유로 리렌더링되면 그제서야 'dark'가 반영되어, 타이밍에 따라 됐다 안됐다 하는 유령 같은 버그가 발생합니다.
아래와 같이, 상태를 변경하는 함수를 따로 만들어서 함께 인자로 내리고 자식이 해당 함수를 호출하도록 해보세요.
부모 컴포넌트가 상태변경 로직을 관리하기 때문에 더 예측 가능한 변경이 일어납니다.
function Parent() { const [config, setConfig] = useState({ theme: 'light', fontSize: 14 }); // 변경 함수를 만들어서, 이 함수를 props로 내려주는 방식 const updateTheme = (newTheme) => { setConfig(prev => ({ ...prev, theme: newTheme })); }; return <Child config={config} onChangeTheme={updateTheme} />; } function Child({ config, onChangeTheme }) { const handleClick = () => { onChangeTheme('dark'); // 부모에게 변경을 요청 }; // ... }"state를 소유한 쪽이 변경도 책임진다"라고 생각하면 쉽습니다.
useEffect에서 무한루프가 발생한다면, 컴포넌트에서 선언한 객체를 의존성 배열에 넣은 건 아닌지 확인하세요.
React 컴포넌트 함수는 리렌더될 때 항상 다시 실행됩니다.
만약 아래와 같은 코드가 있다고 하면..
function SearchResults({ query }) { const [results, setResults] = useState([]); const options = { page: 1, limit: 10 }; // 문제의 객체 useEffect(() => { fetchResults(query, options).then(setResults); }, [query, options]); // <- 컴포넌트 내부에서 새로 만들어지는 객체인 options가 의존성 배열에 들어있다 }options에 새 객체가 만들어지고,
최초 렌더링에서 useEffect가 실행되고,
useEffect는 setResults를 실행하고,
results가 바뀌면서 SearchResults의 리렌더링을 촉발하고,
options에 다시 새 객체가 만들어지고,
useEffect가 options 변화를 감지하여 다시 실행되고,
useEffect는 setResults를 실행하고,
results가 바뀌면서 SearchResults의 리렌더링을 촉발하고,
options에 다시 새 객체가 만들어지고,
...
매 렌더마다 options에 새로운 객체 리터럴을 할당하게 되고,
useEffect는 새로 렌더할 때마다 이전의 options와 새로운 options를 비교합니다.
두 객체는 항상 다르기 때문에(새로 만들기 때문에), 무한루프가 발생하는 것이죠.
이를 방지하기 위해서는 useMemo를 사용해서 리렌더링에서도 객체가 변하지 않게 하거나,
그냥 의존성 배열에 원시값을 사용하는 방법이 있습니다.
// 방법 1: useMemo로 참조를 안정화 const options = useMemo(() => ({ page: 1, limit: 10 }), []); // 방법 2: 객체 대신 원시값을 의존성으로 사용 const page = 1; const limit = 10; useEffect(() => { fetchResults(query, { page, limit }).then(setResults); }, [query, page, limit]); // 원시값은 참조비교가 아닌 값비교를 사용하므로 안전[NestJS]
request.body가 뭔가 이상하다면, Interceptor에서 request 객체를 수정하고 있지 않은지 확인하세요.
문자열 trim, 빈 값 정리처럼 모든 요청에 공통으로 적용하고 싶은 처리가 있으면, 글로벌 인터셉터에서 request.body를 직접 손대고 싶어집니다.
// request.body의 string값들의 앞뒤 공백을 트리밍하는 글로벌 인터셉터 @Injectable() export class TrimInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler) { const request = context.switchToHttp().getRequest(); for (const key of Object.keys(request.body)) { if (typeof request.body[key] === 'string') { request.body[key] = request.body[key].trim(); } } return next.handle(); } }이 코드는 잘 동작합니다.
문제는 이 인터셉터의 존재를 모르는 다른 개발자가 코드를 작성할 때 생깁니다.
제목: 이런 제목을 쓰고 싶을수도 있어요 @Controller('posts') export class PostsController { @Post() async createPost(@Body() dto: CreatePostDto) { // 공백이 잘린 채 들어오지만 어디가 원인인지 찾기 어렵다 // dto.title -> '이런 제목을 쓰고 싶을수도 있어요' return this.postService.create(dto); } }Interceptor, Guard, Pipe, Controller는 모두 같은 request.body를 공유하기 때문에
위와 같이 예상치 못한 값을 보게 되는 어안이 벙벙한 상황이 일어날 수 있습니다.
동료가 내가 짠 코드 때문에 1시간 동안 머리를 쥐어뜯으며 디버깅하는 모습을 보고 싶다면, 뭐 나쁘지 않을지도요...
TypeORM, Prisma 등으로 조회한 DB entity 객체를 여기저기 들고 다니지 마세요.
아, 사실 들고 다니는 건 괜찮을지도 모릅니다.
다만 의도치 않은 수정이 발생하지 않도록 조심하세요.
DB에서 주문을 조회하고, 계산서를 발행한 후 발행시점을 기록하는 로직이 있다고 가정해봅시다.
async issueInvoice(orderId: number): Invoice { const order = await this.orderRepo.findOne({ where: { id: orderId }, relations: ['items', 'customer'], }); await this.applyDiscount(order); // order.price -= discountAmount 같은 짓을 함 await this.applyTax(order); // order.price += tax const invoice = await this.generateInvoice(order); order.invoiceIssueDate = new Date(); await this.orderRepo.save(order); // 의도하지 않은 값이 DB에 저장됨 return invoice; }위의 코드가 실행된다면, DB는 할인금액과 세금이 붙은 price를 저장하게 될 것입니다.
만약 코드가 여러 번 실행된다면, 실행되는 횟수만큼 price는 아득한 값으로 변형되겠죠.
아래와 같이 entity는 읽기 전용으로 사용하고, 의도한 변이만 저장되도록 하는 것이 단단한 코드를 만드는 데 도움이 됩니다.
async issueInvoice(orderId: number): Invoice { const order = await this.orderRepo.findOne({ where: { id: orderId }, relations: ['items', 'customer'], }); const discountAmount = await this.getDiscountAmount(order); // order를 변형하지 않음 const taxAmount = await this.getTaxAmount(order); // order를 변형하지 않음 const invoice = await this.generateInvoice({ ...order, price: price - discountAmount + taxAmount, }); order.invoiceIssueDate = new Date(); await this.orderRepo.save(order); // 의도된 변이인 invoiceIssueDate만 저장됨! return invoice; }자꾸 환경변수로 넣은 Global Config가 바뀐다면, 분명 어딘가에서 수정하고 있는 겁니다.
Global Config 객체는 전역에서 Singleton으로 유지되는 객체입니다.
따라서 어딘가에서 수정된다면, 그 수정된 값이 계속 유지됩니다.
아래와 같이 DB 타임아웃을 임시로 바꾸고 싶은 상황이 있다고 해 볼게요.
@Injectable() export class ReportService { constructor(private configService: ConfigService) {} // 월간 리포트를 조회하는 쿼리는 무거워서 5분 정도 걸린다고 가정해 봅시다. async generateMonthlyReport(month: string) { const dbConfig = this.configService.get('database'); // "월간 리포트는 무거우니까 이번 건만" 타임아웃을 늘리고 싶었던 의도 dbConfig.queryTimeout = 1000 * 60 * 10; dbConfig.statementTimeout = 1000 * 60 * 10; return this.dbClient.query(dbConfig, monthlyReportSQL(month)); } }DB에 일시적인 문제가 있을 때 5초만에 빠르게 실패하도록 하기 위해서, 평상시 DB 쿼리 타임아웃을 5초로 했다고 칠게요.
월간 리포트 같은 무거운 집계 쿼리는 5초 안에 끝나기 어려우니, 이 메서드 안에서만 타임아웃을 10분으로 늘리고 싶었던 거죠.
문제는 configService.get('database')가 반환하는 객체가 앱 전체에서 공유되는 설정의 참조라는 점입니다.
이걸 수정하면 앱 전체의 DB 타임아웃이 10분이 됩니다.
평소 같으면 5초 만에 빠르게 실패해서 클라이언트에 에러를 돌려주고, 커넥션 풀에서 빠져나갔을 느린 쿼리들이 이제는 10분 동안 커넥션을 점유하게 됩니다.
커넥션 풀이 빠르게 고갈되고, 장애가 장기화될 수 있겠죠...아래처럼 복사본을 만들어 쓰는 것이 안전합니다.
async generateMonthlyReport(month: string) { const dbConfig = this.configService.get('database'); const reportDbConfig = { ...dbConfig, queryTimeout: 1000 * 60 * 10, statementTimeout: 1000 * 60 * 10, }; return this.dbClient.query(reportDbConfig, monthlyReportSQL(month)); // 원본 dbConfig는 그대로 보존됨 }더 근본적으로는 Config 객체에 Object.freeze()를 걸어두거나, structuredClone()으로 처음부터 복사본을 반환하도록 설계하면 실수를 원천차단할 수 있습니다.
공통 팁
지금까지 살펴본 모든 문제는 결국 객체를 함수에 넘기면, 함수 안에서 그 객체를 직접 변이할 수 있다는 사실에서 비롯됩니다.
참 편리하지만 위험하죠.함수에서 객체를 안전하게 인자로 넘기고 받는 방법은 무엇일까요?
formatUser라는, user 객체의 속성을 포맷하는 함수의 예시를 보겠습니다.
function formatUser(user) { user.name = user.name.trim().toLowerCase(); // 원본 변이..! user.createdAt = new Date(user.createdAt).toISOString(); return user; } const user = { name: ' Alice ', createdAt: 1700000000000 }; const formatted = formatUser(user); // 호출자는 원본이 바뀔 거라고 예상하지 못함 console.log(user.name); // 'alice' — 원본이 바뀌어 있음 console.log(user === formatted); // true - 같은 객체formatUser라는 이름만 봐서는 원본을 건드릴 거라고 예상하기 어렵습니다.
호출자는 formatted만 변환된 결과일 거라고 믿고, 원본 user는 그대로일 거라 기대했을 겁니다.
이런 함수는 호출하는 쪽에서 원본의 무결성을 신경 써야 하는, 다루기 까다로운 함수가 됩니다.아래와 같이 받은 객체를 수정하지 않고, 새 객체를 만들어서 반환하면 더 예측하기 쉽겠죠?
function formatUser(user) { // 새로운 객체를 반환 return { ...user, name: user.name.trim().toLowerCase(), createdAt: new Date(user.createdAt).toISOString(), }; } const user = { name: ' Alice ', createdAt: 1700000000000 }; const formatted = formatUser(user); console.log(user.name); // ' Alice ' — 원본 보존 console.log(user === formatted); // false — 서로 다른 객체만약 함수가 의도적으로 원본을 변이해야 하는 경우라면, 함수 이름에 그 의도가 드러나는 것이 좋아요.
updateUserInPlace(user), mutateConfig(config) 같은 네이밍을 하면 호출자에게 "이 함수는 원본을 바꾼다"는 경고를 할 수 있습니다.'개발 > 자바스크립트로의 깊은 잠수' 카테고리의 다른 글
재귀 함수 왜 써요? 함수의 return과 매개변수, 스코프 200% 활용하기 (0) 2026.05.12 자바스크립트의 함수 알아보기 - 1 (0) 2026.04.29 자바스크립트같은 약타입 언어에 강한 컨벤션을 강제하는 건 쓸데없는 짓일까? 그럴거면 다른거 쓰지 (1) 2026.04.08 자바스크립트의 빈 배열([])은 빈 문자열(""), 0이지만 boolean일 땐 true로 변환되는 이유 (0) 2026.04.01 어차피 다 똑같은 반복인데 for문만 사용하면 안되는걸까 (1) 2026.03.25