Devin.KR

취업·면접 · 실무

개발자 실무 면접 - 질문의 의도와 모범 답안

언어별 단골 질문 - Java·Python·JavaScript

Java(JVM 메모리·가비지 컬렉션·equals 와 hashCode·불변 객체), Python(가변/불변·얕은/깊은 복사·GIL·제너레이터), JavaScript(호이스팅·클로저·this·동등 비교), 답하는 틀은 '정의→예→실수 사례', 꼬리 질문, 완성 코드는 Python 으로 가변 기본값 함정·얕은/깊은 복사·제너레이터 지연 평가를 실행해 보여 준다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 협업 경험을 설명했다면, 이번에는 그 경험을 구현한 언어의 동작을 설명할 차례다. 면접관은 문법 이름을 많이 기억하는지보다 코드의 결과를 예측하고 그 이유를 설명하는지 확인한다. 중고 거래 서비스에서 관심 상품 목록이 다른 사용자에게 섞이거나, 상품을 복사했는데 원본까지 바뀌는 현상은 언어의 동작 원리와 연결된다.

세 언어를 같은 깊이로 암기할 필요는 없다. 주로 사용하는 언어에서는 작은 코드의 결과까지 설명하고, 나머지 언어에서는 개념의 경계와 대표적인 실수를 구분하는 것이 목표다. 답변은 ‘정의→예→실수 사례’ 순서로 준비하되, 실제로 말할 때는 결론을 먼저 제시하고 근거, 경험이나 예시, 한계와 대안으로 확장한다.

  • Java의 메모리 관리와 객체의 동등성 규약을 연결해 설명한다.
  • Python의 가변성, 복사, GIL, 제너레이터를 실제 실행 결과로 구분한다.
  • JavaScript의 호이스팅, 클로저, this, 동등 비교에서 조건에 따라 달라지는 동작을 짚는다.
  • 같은 질문에 대한 나쁜 답과 좋은 답의 차이를 확인하고 꼬리 질문에 대응한다.
  • Python 한 파일로 객체 공유와 지연 평가를 재현한다.

문제 상황

가상의 중고 거래 서비스 팀에 지원한 지원자는 과제에서 상품 검색과 관심 상품 기능을 구현했다. 기술 면접관은 기능 설명을 들은 뒤 “새 관심 목록을 만들었는데 이전 호출의 상품이 남는다면 무엇부터 보겠습니까”라고 묻는다. 지원자는 데이터베이스 저장 문제를 떠올리지만, 면접관이 보여 준 코드는 저장소를 전혀 사용하지 않는다. 함수의 기본 인수가 리스트일 뿐이다.

다음 질문은 상품 편집 화면이다. 미리보기용 상품을 복사해 태그를 수정했더니 원본 태그까지 바뀌었다. 지원자가 “복사했으니 독립적입니다”라고 답하면 면접관은 어느 깊이까지 새 객체가 만들어졌는지 다시 묻는다. 용어를 아는 것과 실제 참조 관계를 설명하는 것 사이의 차이가 드러나는 순간이다.

이어 Java로 구현된 상품 키와 JavaScript로 작성된 화면 이벤트가 등장한다. 모든 언어로 서비스를 작성하라는 요구는 아니다. 자신이 이해한 범위를 밝히면서 언어의 규칙으로 현상을 설명하는지 확인하는 질문이다. 직접 겪지 않은 사례라면 프로젝트 경험으로 꾸미지 않고 “다음과 같은 예로 설명하겠습니다”라고 말한다.

언어별 대표 질문과 면접관이 확인하는 판단
질문의도답변 핵심꼬리 질문
Java의 JVM 메모리를 설명해 달라.실행 공간과 객체 수명의 구분스레드별 스택, 공유 힙과 메서드 영역을 구분한다.지역 참조 변수가 사라지면 객체도 바로 없어지는가?
Java의 가비지 컬렉션은 언제 객체를 회수하는가?도달 가능성과 회수 시점의 이해도달 불가능해진 객체가 회수 대상이며 즉시 회수를 보장하지 않는다.메모리 누수도 생기는가?
equals를 바꾸면 hashCode도 바꿔야 하는가?해시 기반 컬렉션의 규약 이해동등한 객체는 같은 해시 코드를 가져야 한다.해시 코드가 같으면 동등한가?
Java에서 불변 객체를 어떻게 만드는가?외부 변경 경로의 통제상태 변경을 막고 가변 필드의 별칭을 관리한다.final 리스트는 불변인가?
Python에서 가변 객체와 불변 객체는 무엇인가?객체 변경과 이름 재바인딩의 구분리스트의 내부 변경과 정수 이름의 재할당을 구분한다.튜플 안의 리스트도 불변인가?
얕은 복사와 깊은 복사는 어떻게 다른가?중첩 객체의 공유 여부 이해얕은 복사는 내부 참조를 공유하고 깊은 복사는 재귀적으로 복사한다.깊은 복사가 항상 적절한가?
GIL이 있으면 스레드는 쓸모없는가?구현과 작업 성격의 구분일반적인 CPython의 CPU 작업과 입출력 대기를 나눠 설명한다.공유 상태의 잠금은 필요한가?
제너레이터는 언제 코드를 실행하는가?지연 평가와 소비 시점 이해호출로 객체를 만들고 순회가 진행될 때 본문을 실행한다.한 번 소비한 값을 다시 순회할 수 있는가?
JavaScript의 호이스팅은 무엇인가?선언별 초기화 차이 이해var, let·const, 함수 선언의 접근 가능 시점을 구분한다.let 선언 전 접근은 무엇을 일으키는가?
클로저는 무엇을 기억하는가?렉시컬 환경과 변수 접근 이해생성 위치의 바깥 변수에 계속 접근한다.변수 값의 복사본을 저장하는가?
this는 어떻게 결정되는가?호출 방식의 구분일반 함수는 호출 방식, 화살표 함수는 바깥 this를 따른다.메서드를 변수로 꺼내 호출하면?
==와 ===는 어떻게 다른가?변환 규칙과 객체 비교 이해==는 형 변환이 개입할 수 있고 ===는 형 변환 없이 비교한다.내용이 같은 두 객체는 ===로 같은가?

Java: 객체의 수명과 동등성을 설명한다

메모리와 회수는 도달 가능성으로 연결한다

자바 가상 머신(JVM)의 런타임 데이터 영역을 설명할 때는 스레드별 영역과 공유 영역부터 나눈다. 각 스레드의 스택에는 메서드 호출에 따른 프레임이 생기고, 프레임에는 지역 변수와 연산에 필요한 정보가 들어간다. 힙은 객체와 배열을 위한 공유 영역이다. 메서드 영역은 클래스 관련 정보를 다루는 공유 영역이며, 구체적인 배치와 관리 방식은 구현에 따라 달라진다.

“지역 변수는 스택, 객체는 힙”은 출발점으로 쓸 수 있지만 모든 실제 메모리 배치를 단정하는 문장은 아니다. 지역 변수에 객체를 가리키는 참조가 들어갈 수 있고, 실행기는 관찰 가능한 동작을 유지하면서 할당을 최적화할 수 있다. 메서드가 끝나 참조 하나가 사라져도 다른 곳에서 객체에 도달할 수 있다면 객체는 살아 있다.

가비지 컬렉션(GC)은 더 이상 필요한 객체인지 업무 의미를 판단하는 기능이 아니다. 살아 있는 스레드나 정적 필드 등에서 시작하는 도달 가능성을 바탕으로 회수 대상을 판단한다. 두 객체가 서로 참조해도 외부에서 둘에 도달할 수 없다면 회수 대상이 될 수 있다. 반대로 사용이 끝난 상품을 정적 컬렉션에 계속 담아 두면 도달 가능성이 남아 메모리를 차지한다.

면접관: “GC가 있으니 자원을 닫지 않아도 됩니까?”

지원자: “아니다. 메모리 회수와 파일 같은 외부 자원의 사용 종료는 별개다. 회수 시점을 기다리지 않고 자원의 사용 범위를 정해 닫겠다. Java에서는 해당 자원이 지원한다면 try-with-resources로 종료 경로를 명시하겠다.”

깊이 보기: equals와 hashCode는 한 쌍의 규약이다

동등성 질문에 대한 답변의 차이
수준답변면접관이 확인하는 점
나쁜 답“둘 다 객체 주소를 비교하는 기능이다.”기본 동작과 재정의한 동작을 구분하지 못한다.
보통 답“equals를 재정의하면 hashCode도 재정의해야 한다.”규칙은 알지만 필요한 이유가 빠져 있다.
좋은 답“동등한 객체가 같은 해시 코드를 갖도록 두 메서드를 함께 정의한다. 해시 기반 컬렉션이 후보를 찾고 동등성을 판별하는 과정이 일관돼야 하기 때문이다.”규약과 실제 오동작을 연결한다.

모범 답안: “논리적으로 같은 객체를 equals로 같다고 정의했다면 hashCode도 같은 값을 반환하도록 맞춰야 한다. 해시 기반 컬렉션은 해시 값을 이용해 탐색 범위를 좁히므로 두 규칙이 어긋나면 같은 상품 키를 넣고도 찾지 못할 수 있다. 예를 들어 상품 번호로 동등성을 판단하는 키라면 두 메서드 모두 그 번호를 기준으로 만들겠다. 다만 같은 해시 코드가 같은 객체를 뜻하지는 않는다. 충돌한 후보 사이에서는 동등성 판단이 더 필요하다.”

정의는 ‘논리적 동등성의 규약’, 예는 ‘같은 상품 번호의 키’, 실수 사례는 ‘equals만 재정의해 조회가 어긋나는 상황’이다. 지원자가 규약을 나열한 뒤 멈추지 않고 실패 상황을 제시하면 답변의 근거가 선명해진다. equals 자체도 반사성, 대칭성, 추이성, 일관성을 지켜야 하며, null과 비교하면 false여야 한다.

면접관: “키를 넣은 다음 상품 번호를 바꾸면 어떻습니까?”

지원자: “동등성과 해시 코드에 쓰이는 상태가 바뀌면 저장할 때의 위치와 조회할 때의 계산이 어긋날 수 있다. 키의 식별 상태를 불변으로 만들거나, 변경이 필요하면 기존 키를 제거한 뒤 새 키를 넣겠다.”

불변 객체는 생성 후 외부에서 관찰되는 상태가 바뀌지 않는 객체다. 필드를 private final로 두는 것만으로 충분하지 않을 수 있다. final은 필드가 다른 객체를 가리키도록 다시 대입하지 못하게 하지만, 필드가 가리키는 리스트의 내부 변경까지 막지는 않는다. 생성자 입력과 반환값을 통해 가변 객체가 공유되지 않도록 방어적 복사나 불변 표현을 선택해야 한다. 읽기 전용 뷰도 원본이 따로 변경된다면 관찰 결과가 바뀔 수 있다는 점을 함께 확인한다.

Python: 공유된 객체와 실행 시점을 구분한다

깊이 보기: 가변 기본값과 복사는 참조 관계의 문제다

Python에서 이름은 객체에 바인딩된다. 가변 객체는 동일한 객체의 내부 상태를 바꿀 수 있으며 리스트와 딕셔너리가 대표적이다. 정수와 문자열 같은 불변 객체는 그 객체 자체를 변경하지 못한다. 정수 변수에 값을 더한 뒤 다시 대입하는 것은 이름이 가리키는 객체를 바꾸는 동작이다.

불변 컨테이너가 내부 객체의 불변성까지 보장하지는 않는다. 튜플의 원소 자리를 다른 객체로 교체할 수는 없지만, 원소가 리스트라면 그 리스트의 내용은 바뀔 수 있다. 따라서 “튜플이니 내부 데이터도 변하지 않는다”는 답은 객체의 경계를 빠뜨린다.

가변 기본값 질문에 대한 답변의 차이
수준답변빠진 근거 또는 장점
나쁜 답“Python이 이전 반환값을 자동으로 저장한다.”반환값과 기본 인수 객체를 혼동한다.
보통 답“기본값으로 리스트를 쓰면 안 되므로 None을 쓴다.”언제 생성되고 공유되는지 설명하지 않는다.
좋은 답“기본 인수는 함수 정의 시 평가된다. 인수를 생략한 호출들이 같은 리스트를 사용하므로 호출마다 새 목록이 필요하면 함수 안에서 만든다.”생성 시점과 수정 위치를 연결한다.

모범 답안: “호출마다 독립적인 관심 목록이 필요하므로 기본값에 가변 리스트를 직접 두지 않겠다. 기본 인수는 함수 정의 시 한 번 평가되어 이후 생략된 호출에 공유되기 때문이다. 예를 들어 첫 호출에서 의자를 추가한 뒤 두 번째 호출에서 책상을 추가하면 같은 목록에 둘 다 남을 수 있다. None을 기본값으로 받고 함수 안에서 리스트를 만들겠다. 다만 None 자체를 유효한 입력과 구분해야 하는 인터페이스라면 별도의 표식 객체를 쓰겠다.”

의도적인 캐시처럼 공유가 필요한 설계도 있다. 그러므로 가변 기본값을 문법 오류라고 설명해서는 안 된다. 핵심은 상태의 수명과 공유 범위가 요구사항에 맞는가다. 면접에서는 “항상 금지”보다 “이 함수는 호출마다 독립적인 결과가 필요하다”는 근거가 설득력 있다.

대입은 복사가 아니다. 얕은 복사(shallow copy)는 바깥 컨테이너를 새로 만들지만 내부 객체를 가리키는 참조는 재사용한다. 깊은 복사(deep copy)는 복사 대상 내부를 재귀적으로 복사한다. 기본적인 중첩 리스트와 딕셔너리에서는 이 차이를 쉽게 관찰할 수 있다.

얕은 복사는 내부 태그 목록을 공유하고 깊은 복사는 별도의 태그 목록을 만든다

면접관: “그렇다면 전부 깊은 복사를 쓰면 됩니까?”

지원자: “복사 비용과 객체의 의미를 함께 봐야 한다. 공유해야 하는 객체까지 복사하는 것이 적절하지 않을 수 있고, 사용자 정의 객체는 복사 동작을 별도로 정의할 수 있다. 상품 미리보기에서 태그만 독립적으로 편집한다면 바깥 딕셔너리와 태그 목록만 새로 만드는 방법도 검토하겠다.”

깊은 복사는 원래 객체 구조 안의 공유 관계까지 무조건 없애는 기능도 아니다. 같은 내부 객체를 여러 곳에서 참조했다면 복사본 내부에서도 그 관계가 유지될 수 있다. 순환 참조를 처리하는 장치도 있다. “모든 참조를 끊는다”보다 “복사 규칙에 따라 객체 그래프를 복사한다”는 설명이 정확하다.

깊이 보기: GIL과 제너레이터는 서로 다른 경계를 다룬다

전역 인터프리터 잠금(GIL)은 일반적인 CPython 빌드에서 한 인터프리터 안의 여러 스레드가 Python 코드를 동시에 실행하는 데 제약을 주는 장치다. Python 언어 자체가 모든 구현에 요구하는 규칙으로 설명해서는 안 된다. CPython 3.13부터는 GIL을 비활성화할 수 있는 선택적 자유 스레드 빌드도 있으므로 실행 구현과 빌드를 먼저 확인해야 한다.

GIL 질문에 대한 답변의 차이
수준답변면접관의 다음 확인
나쁜 답“Python은 스레드를 실행하지 못한다.”동시 진행과 병렬 실행을 혼동하는가?
보통 답“CPU 작업에는 프로세스, 입출력에는 스레드를 쓴다.”구현과 비용 조건을 설명할 수 있는가?
좋은 답“일반적인 CPython에서 Python 코드 중심의 CPU 작업은 스레드 병렬화의 이점이 제한된다. 입출력 대기는 겹칠 수 있으며, 확장 모듈이나 빌드 조건도 확인한다.”조건을 나누고 측정할 계획이 있는가?

모범 답안: “일반적인 CPython에서는 Python 코드 중심의 계산을 스레드 수만큼 병렬 처리하기 어렵다. GIL이 같은 인터프리터에서의 실행을 제약하기 때문이다. 예를 들어 상품 설명을 순수 Python으로 대량 변환한다면 프로세스 분할을 후보로 두겠다. 반면 여러 파일이나 원격 응답을 기다리는 작업에서는 스레드가 도움이 될 수 있다. 다만 프로세스의 직렬화 비용과 작업 크기, 확장 모듈의 GIL 해제 여부까지 확인하고 측정하겠다.”

면접관: “GIL이 있으면 공유 목록을 수정할 때 잠금은 필요 없습니까?”

지원자: “GIL은 업무상 여러 단계를 하나의 원자적 작업으로 만들지 않는다. 상품이 있는지 확인한 다음 추가하는 과정에서 경쟁이 생길 수 있다. 공유 상태의 불변식을 지켜야 하는 구간에 잠금을 두거나 공유 자체를 줄이겠다.”

제너레이터(generator)는 값의 생성과 소비 시점을 나누는 도구다. yield가 있는 일반적인 제너레이터 함수를 호출하면 제너레이터 객체가 만들어지고, 본문은 다음 값을 요청할 때 진행된다. yield에서 값을 내보내고 멈춘 뒤 다음 요청에서 이어서 실행한다. 모든 값을 미리 담을 필요가 없다는 장점이 있지만, 원본을 함수 안에서 리스트로 만들면 그 메모리 비용은 남는다.

지연 평가는 오류와 부수 효과가 나타나는 시점도 늦춘다. 상품 한 건을 읽는 과정에서 오류가 난다면 객체 생성 시점이 아니라 순회 도중에 드러날 수 있다. 또한 소비한 제너레이터는 처음부터 자동으로 다시 시작하지 않는다. 재순회가 필요하면 새 제너레이터를 만들거나, 크기를 감당할 수 있을 때 결과를 저장한다.

제너레이터 본문은 객체 생성 때가 아니라 다음 값 요청 때 진행된다

JavaScript: 선언 위치와 호출 방식을 나눠 답한다

호이스팅(hoisting)을 “코드를 위로 옮긴다”는 설명만으로 끝내면 선언별 차이를 놓치기 쉽다. 실행 전에 바인딩을 준비하는 과정으로 이해하고, 언제 초기화되는지 구분한다. var는 선언문 실행 전에도 undefined로 접근할 수 있지만, let과 const는 해당 선언의 초기화 전까지 접근하면 ReferenceError가 발생하는 일시적 사각지대(TDZ)에 있다. 함수 선언은 보통 선언문 앞에서도 호출할 수 있지만 함수 표현식을 변수에 대입하는 경우에는 변수 선언 방식과 대입 시점의 영향을 받는다.

클로저(closure)는 함수가 만들어진 렉시컬 환경의 변수에 접근할 수 있는 성질이다. 관심 상품 개수를 유지하는 함수를 만들면 바깥 함수 호출이 끝난 뒤에도 내부 함수가 그 변수에 접근할 수 있다. 이는 생성 당시의 숫자 복사본을 고정한다는 뜻이 아니다. 같은 바인딩을 함께 참조하는 함수들은 변경된 값을 관찰할 수 있다. 오래 유지되는 콜백이 큰 객체를 참조하면 필요 이상으로 메모리에 남길 수도 있다.

깊이 보기: this는 함수가 놓인 위치만으로 정해지지 않는다

this 질문에 대한 답변의 차이
수준답변판단
나쁜 답“this는 언제나 함수를 가진 객체다.”함수를 꺼내 호출하는 상황을 설명하지 못한다.
보통 답“this는 호출 방법에 따라 달라진다.”일반 함수와 화살표 함수의 차이가 필요하다.
좋은 답“일반 함수의 this는 호출 방식에 영향을 받는다. 화살표 함수는 자신의 this를 만들지 않고 바깥 this를 사용한다.”규칙과 예외 조건을 구분한다.

모범 답안: “일반 함수의 this는 정의된 객체만 보고 판단할 수 없고 호출식을 봐야 한다. 상품 객체의 메서드를 객체를 통해 호출하면 그 객체가 this가 되지만, 메서드를 변수로 꺼내 일반 호출하면 연결이 유지되지 않는다. 엄격 모드에서는 그런 호출의 this가 undefined다. 콜백에 넘겨도 수신 객체가 유지돼야 한다면 bind로 고정하거나 호출을 감싼 함수를 사용하겠다. 다만 화살표 함수는 바깥 this를 사용하므로 객체 메서드를 무조건 화살표 함수로 바꾸지는 않겠다.”

면접관: “화살표 함수에 call을 쓰면 this를 바꿀 수 있습니까?”

지원자: “바꿀 수 없다. 화살표 함수에는 자체 this 바인딩이 없으므로 call로 전달한 this 인수가 그 동작을 바꾸지 않는다. 일반 함수와 화살표 함수 중 어떤 규칙이 필요한지 먼저 선택하겠다.”

동등 비교에서는 ==와 ===를 구분한다. ==는 피연산자 조합에 따라 형 변환이 개입할 수 있다. ===는 형 변환 없이 비교하지만 객체의 속성을 재귀적으로 비교하지는 않는다. 내용이 같은 두 상품 객체를 각각 만들면 서로 다른 객체이므로 === 결과는 false다. 상품의 업무상 동일성을 판단하려면 상품 번호 같은 기준을 명시한다.

“===는 값과 타입이 같으면 참”이라는 요약에도 보충이 필요하다. NaN은 자신과 ===로 비교해도 false이고, 양의 0과 음의 0은 ===로 같다고 판단한다. Object.is는 이 두 경우를 다르게 처리하지만 객체 내용을 비교하는 도구는 아니다. 경계 입력을 설명할 수 있되, 면접 답변의 중심은 예측 가능한 비교 기준을 선택하는 데 둔다.

완성 코드

다음 내용을 main.py로 저장한다. 첫 실험은 의도적으로 공유 기본값을 만들고, 두 번째 실험은 중첩 객체의 복사를 비교한다. 마지막 실험은 생산 로그와 소비 로그를 번갈아 출력해 지연 평가를 확인한다. 외부 패키지와 시간 측정 없이 Python 3.12 이상에서 실행한다.

from copy import deepcopy


def remember_bad(item, items=[]):
    items.append(item)
    return items


def remember_good(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items


def discounted_prices(products):
    for product in products:
        print(f"생산: {product['name']}")
        yield product["price"] * 9 // 10


def main():
    print("[1] 가변 기본값")
    first = remember_bad("의자")
    print(f"첫 호출 직후: {first}")
    second = remember_bad("책상")
    print(f"두 번째 호출: {second}")
    print(f"첫 결과 재확인: {first}")
    print(f"같은 목록: {first is second}")

    safe_first = remember_good("의자")
    safe_second = remember_good("책상")
    print(f"수정 후 첫 결과: {safe_first}")
    print(f"수정 후 두 번째 결과: {safe_second}")
    print(f"같은 목록: {safe_first is safe_second}")

    print("\n[2] 얕은 복사와 깊은 복사")
    original = {"name": "스탠드", "tags": ["조명"]}
    shallow = original.copy()
    deep = deepcopy(original)
    shallow["name"] = "미리보기"
    shallow["tags"].append("예약")
    print(f"원본 이름: {original['name']}")
    print(f"얕은 복사 이름: {shallow['name']}")
    print(f"원본 태그: {original['tags']}")
    print(f"얕은 복사 태그: {shallow['tags']}")
    print(f"깊은 복사 태그: {deep['tags']}")
    print(f"얕은 복사 태그 공유: {original['tags'] is shallow['tags']}")
    print(f"깊은 복사 태그 공유: {original['tags'] is deep['tags']}")

    print("\n[3] 제너레이터 지연 평가")
    products = [
        {"name": "의자", "price": 10000},
        {"name": "책상", "price": 20000},
        {"name": "스탠드", "price": 30000},
    ]
    prices = discounted_prices(products)
    print("제너레이터 생성 완료")
    first_price = next(prices)
    print(f"첫 소비: {first_price}")
    print("남은 값 소비 시작")
    rest = list(prices)
    print(f"남은 값: {rest}")
    print(f"소진 후 다시 소비: {list(prices)}")


if __name__ == "__main__":
    main()

줄별 해설

빈 줄을 포함해 위 코드의 첫 줄을 1행으로 센다. 1행은 깊은 복사 함수를 가져온다. 4~6행의 기본 리스트는 함수 정의가 실행될 때 만들어진다. append는 그 리스트 자체를 변경하며, 반환문은 복사본이 아니라 같은 객체를 반환한다.

9~13행은 인수가 생략되었을 때 함수 본문에서 새 리스트를 만든다. 10행의 is None은 기본 표식을 판별한다. if not items로 바꾸면 호출자가 명시적으로 넘긴 빈 리스트까지 새 리스트로 교체하므로 입력 객체를 유지하려는 계약과 어긋날 수 있다.

16~19행은 제너레이터 함수다. 18행의 출력은 다음 값을 생산하기 직전에 실행된다. 19행은 정수 금액에 90%를 적용한다. 부동소수점이나 출력 반올림을 사용하지 않아 결과가 일정하며, 이 예에서는 모두 나누어떨어지는 금액을 사용한다.

24~29행은 첫 결과를 저장한 뒤 같은 기본 리스트를 다시 수정한다. 28행에서 첫 결과에도 책상이 나타난다. 29행의 is는 두 이름이 같은 객체를 가리키는지 확인한다. 31~35행은 수정한 함수의 두 반환값이 서로 다른 리스트임을 보여 준다.

38~42행은 원본, 얕은 복사본, 깊은 복사본을 준비한 뒤 두 종류의 수정을 수행한다. 41행은 복사본 딕셔너리의 name 자리를 새 문자열에 연결한다. 42행은 공유 중인 태그 리스트를 직접 변경한다. 그래서 원본 이름은 유지되지만 원본 태그에는 예약이 추가된다. 47행의 깊은 복사본 태그에는 복사 이후의 변경이 반영되지 않는다.

57행은 제너레이터 객체를 만들지만 생산 로그는 아직 없다. 59행의 next가 첫 상품만 처리한다. 62행의 list는 남은 상품을 끝까지 소비한다. 64행에서는 이미 소진된 객체를 다시 소비하므로 빈 리스트가 나온다. 67~68행은 파일을 직접 실행했을 때 main을 호출한다.

실행 결과

python3 main.py
[1] 가변 기본값
첫 호출 직후: ['의자']
두 번째 호출: ['의자', '책상']
첫 결과 재확인: ['의자', '책상']
같은 목록: True
수정 후 첫 결과: ['의자']
수정 후 두 번째 결과: ['책상']
같은 목록: False

[2] 얕은 복사와 깊은 복사
원본 이름: 스탠드
얕은 복사 이름: 미리보기
원본 태그: ['조명', '예약']
얕은 복사 태그: ['조명', '예약']
깊은 복사 태그: ['조명']
얕은 복사 태그 공유: True
깊은 복사 태그 공유: False

[3] 제너레이터 지연 평가
제너레이터 생성 완료
생산: 의자
첫 소비: 9000
남은 값 소비 시작
생산: 책상
생산: 스탠드
남은 값: [18000, 27000]
소진 후 다시 소비: []

첫 실험에서 중요한 것은 두 번째 반환값의 길이만이 아니다. 첫 반환값도 나중 호출의 영향을 받는다는 점이다. 두 번째 실험은 바깥 객체가 다르다는 사실만으로 내부 상태의 독립성을 주장할 수 없음을 보여 준다. 마지막 실험에서는 생성 완료 메시지 뒤에 생산 메시지가 나타난다. 이 순서가 본문 실행 시점의 근거다.

이 실험은 가변 기본값, 복사, 지연 평가를 확인한다. 스레드를 실행하지 않으므로 GIL의 성능 효과를 증명하는 실험은 아니다. 실행 결과가 보여 주는 범위와 개념 설명의 범위를 구분하는 태도도 면접에서 평가할 수 있는 부분이다.

실무에서 자주 틀리는 것

기본 인수를 호출마다 새로 만든다고 생각한다

다음 함수는 인수를 생략한 호출들이 한 목록을 공유한다. 테스트가 한 번만 호출하면 문제가 보이지 않기 때문에 서로 독립적이어야 하는 호출을 연속해서 확인해야 한다.

def add_interest(product, interests=[]):
    interests.append(product)
    return interests

호출마다 새 목록을 원한다면 생성 위치를 함수 본문으로 옮긴다. 전달받은 목록을 직접 수정하는 동작은 여전히 남으므로 그 부분도 함수의 계약으로 명시한다.

def add_interest(product, interests=None):
    if interests is None:
        interests = []
    interests.append(product)
    return interests

바깥 컨테이너의 복사를 전체 독립성으로 해석한다

다음 코드는 미리보기 태그를 바꾸면서 원본 태그도 변경한다.

product = {"name": "가방", "tags": ["생활"]}
preview = product.copy()
preview["tags"].append("검토")

태그 목록만 독립적으로 편집한다면 필요한 경계만 복사할 수 있다. 이 수정은 태그 원소가 문자열이라는 현재 구조를 전제로 한다. 원소가 가변 딕셔너리로 바뀌면 복사 깊이를 다시 판단한다.

product = {"name": "가방", "tags": ["생활"]}
preview = product.copy()
preview["tags"] = product["tags"].copy()
preview["tags"].append("검토")

제너레이터를 재사용 가능한 목록으로 다룬다

다음 코드는 첫 list 호출에서 값을 모두 소비하므로 두 번째 결과가 비어 있다.

prices = (amount * 9 // 10 for amount in [10000, 20000])
first_pass = list(prices)
second_pass = list(prices)

각 순회에서 새로 계산하려면 새 제너레이터를 만드는 함수를 둔다. 계산 비용이 크고 결과가 작다면 한 번 목록으로 저장한 뒤 그 목록을 재사용하는 대안도 있다.

def make_prices():
    return (amount * 9 // 10 for amount in [10000, 20000])


first_pass = list(make_prices())
second_pass = list(make_prices())

객체의 동일성과 값의 동등성을 섞는다

다음 비교는 목록의 내용이 같은지 묻는 요구에 맞지 않는다. 두 목록은 별도로 만들어진 객체다.

left = ["의자"]
right = ["의자"]
same_contents = left is right

내용 비교에는 동등 비교를 사용한다. Python의 is는 객체 동일성을 확인할 때, ==는 해당 타입이 정의한 동등성을 확인할 때 쓴다. Java의 equals와 JavaScript의 ===까지 모두 같은 규칙이라고 일반화하지 않는다.

left = ["의자"]
right = ["의자"]
same_contents = left == right

한눈에 보기

정의에서 실수 사례까지 이어지는 답변 지도
주제정의와 예실수 사례대응 기준
Java 메모리·GC참조의 도달 가능성으로 객체 수명을 설명한다.메서드 종료와 즉시 회수를 동일시한다.남은 참조와 외부 자원 수명을 확인한다.
Java 동등성같은 상품 키는 같은 해시 코드를 갖는다.equals만 바꾸거나 저장 중 키를 변경한다.규약을 함께 지키고 식별 상태를 고정한다.
Java 불변성생성 후 관찰되는 상태를 유지한다.final 리스트의 내용을 외부에 노출한다.가변 필드의 입력과 출력 경로를 통제한다.
Python 가변성·복사내부 변경과 이름 재바인딩을 구분한다.중첩 객체를 얕게 복사하고 독립적이라고 생각한다.공유해야 할 범위를 먼저 정한다.
Python GIL구현과 빌드에 따른 실행 제약이다.공유 상태의 동기화도 해결된다고 생각한다.작업 성격과 불변식을 나눠 검토한다.
Python 제너레이터요청할 때 생산하고 중간 상태를 유지한다.소진 후 자동으로 재시작한다고 생각한다.재생성 또는 결과 저장을 선택한다.
JavaScript 선언·클로저초기화 시점과 바깥 변수 접근을 설명한다.let을 선언 전 읽거나 값 복사로 이해한다.접근 시점과 공유 바인딩을 확인한다.
JavaScript this·비교호출 규칙과 비교 연산자의 의미를 구분한다.꺼낸 메서드의 this나 객체 내용 비교를 잘못 예상한다.호출식과 업무상 비교 기준을 명시한다.

면접에서는 답을 길게 말하는 것보다 적용 조건을 빠뜨리지 않는 편이 낫다. 먼저 한 문장으로 정의하고, 상품이나 관심 목록처럼 작은 예를 든 다음, 그 규칙을 오해했을 때 나타나는 실수를 제시한다. 마지막으로 “이 예에서는 이 방법을 선택하되, 이런 조건에서는 다른 방법을 검토한다”라고 경계를 닫는다.

사실 확인이 더 필요하면 JVM 실행 영역 명세, Java 객체 동등성 규약, Python 복사 동작 설명, CPython 자유 스레드 실행 안내, JavaScript 언어 명세를 참고한다. 문서에서는 결론을 외우기보다 자신이 말한 조건이 실제 규칙과 맞는지 확인한다.

연습 문제

  1. 완성 코드에서 remember_bad의 기본 리스트를 유지한 채 본문의 첫 동작으로 items = items.copy()를 추가한다고 가정한다. 인수를 생략한 두 호출의 결과가 서로 섞이는지 예측하고, 이 수정이 None 방식과 다른 점을 설명하라.
  2. 원본 상품의 태그를 리스트 대신 문자열만 담긴 튜플로 바꾼다. 얕은 복사본에서 태그에 새 튜플을 연결하면 원본이 바뀌는지 설명하라. 튜플 원소가 리스트인 경우와도 비교하라.
  3. 가격 제너레이터의 두 번째 상품 처리 부분에서 예외를 발생시키도록 바꾼다고 가정한다. 객체 생성, 첫 next, 남은 값을 list로 변환하는 단계 중 어디에서 예외가 드러나는지 설명하라. 그 제너레이터를 다시 소비하면 두 번째 상품부터 재시도되는지도 답하라.
  4. 면접관이 “상품 키를 불변으로 만들면 무엇이 좋아집니까”라고 물었다. Java의 해시 기반 컬렉션을 예로 들어 정의, 근거, 예시, 한계와 대안을 포함한 답변을 작성하라.

정답과 해설

  1. 매번 수정 전에 새 리스트를 만들므로 주어진 함수에서는 생략 호출의 결과가 서로 섞이지 않는다. 기본 리스트 자체는 계속 공유되지만 본문이 그 객체를 변경하지 않기 때문이다. 다만 인수로 넘긴 리스트도 복사하므로 호출자 목록을 직접 수정하던 계약이 달라진다. None 방식의 완성 코드는 인수를 생략했을 때만 새 리스트를 만들며, 명시적으로 받은 리스트에는 직접 추가한다. 두 방식은 결과뿐 아니라 입력 객체를 변경하는지까지 비교해야 한다.
  2. 문자열만 담긴 태그 튜플을 공유하더라도 튜플 자체와 원소는 변경할 수 없다. 얕은 복사본의 태그 자리에 새 튜플을 대입하면 바깥 딕셔너리의 연결만 바뀌므로 원본은 유지된다. 반면 튜플 안에 리스트가 있다면 그 리스트를 변경할 수 있고, 공유 중인 원본에서도 변경을 관찰한다. 바깥 컨테이너의 불변성과 내부 객체의 불변성은 따로 판단한다.
  3. 제너레이터 객체 생성 시에는 본문이 실행되지 않는다. 첫 next는 첫 상품의 가격을 반환하고 멈춘다. 남은 값을 list로 변환하는 과정에서 두 번째 상품에 도달해 예외가 드러난다. 예외가 제너레이터 밖으로 빠져나왔다면 그 제너레이터는 종료되므로 다음 소비가 재시도가 되지는 않는다. 재시도가 필요하면 새 객체를 만들고 진행 위치나 중복 처리 정책을 별도로 설계해야 한다.
  4. “상품 키의 동등성과 해시 코드에 쓰이는 상태를 불변으로 만들면 저장 후 조회 기준이 달라지는 문제를 줄일 수 있다. 해시 기반 컬렉션은 넣을 때와 찾을 때 일관된 키 상태를 전제로 하기 때문이다. 상품 번호로 키를 만든다면 그 번호를 생성 후 변경하지 못하게 하겠다. 다만 불변 객체도 동등성 규약을 잘못 구현하면 문제가 생긴다. 상품 번호 교체가 업무상 필요하다면 기존 키를 제거하고 새 키로 등록하는 절차를 두겠다.”라고 답할 수 있다.

이번 장의 실험은 짧지만 답변을 검증하는 방식은 공통적이다. 어떤 객체가 공유되는지, 어떤 시점에 실행되는지, 어떤 조건에서 규칙이 달라지는지 말로 설명한 뒤 작은 예로 확인한다. 다음 장에서는 이렇게 확인한 이해를 바탕으로 팀과 업무에 관해 무엇을 물을지 준비한다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.