PHpullh
개발자 면접 준비/언어 기초 인터뷰

언어 기초 인터뷰

메모리·값·참조: 언어 기초 면접 준비

스택과 힙, 값과 참조, 가비지 컬렉션, 소유권을 언어별 차이와 함께 설명하는 기초 인터뷰 가이드입니다.

메모리 질문은 언어 자랑 대회가 아닙니다

메모리와 참조를 묻는 면접에서 가장 자주 벌어지는 일은, 질문은 “이 코드에서 값이 왜 바뀌었나요”인데 답은 “스택과 힙이 있고요”로 시작하는 것입니다. 스택과 힙은 구현 세부사항이고 언어와 런타임에 따라 다릅니다. 면접관이 실제로 확인하려는 것은 훨씬 실용적입니다. 어떤 이름이 어떤 값을 가리키는지, 그 값을 다른 곳에서도 볼 수 있는지, 볼 수 있다면 한쪽의 변경이 다른 쪽에 보이는지입니다.

이 세 가지를 바인딩, 별칭, 변경 가능성이라고 부르겠습니다. 언어가 무엇이든 이 세 축으로 설명하면 답이 흔들리지 않습니다. 가비지 컬렉터가 있든 없든, 소유권 검사가 있든 없든 마찬가지입니다.

연습 문제: 이 함수는 왜 호출자의 값을 바꿨습니까

PYTHON · 별칭과 재바인딩

def add_tag(tags, tag):
    tags.append(tag)          # 가리키는 객체를 변경 → 호출자에게 보인다
    return tags

def reset_tags(tags):
    tags = []                 # 지역 이름만 다시 묶음 → 호출자에게 안 보인다
    return tags

base = ['a']
add_tag(base, 'b')
print(base)                   # ['a', 'b']
reset_tags(base)
print(base)                   # ['a', 'b']  — 그대로


# 같은 원리가 만드는 대표적인 사고
def collect(item, bucket=[]):     # 기본값은 함수 정의 시 한 번만 만들어진다
    bucket.append(item)
    return bucket

print(collect(1))   # [1]
print(collect(2))   # [1, 2]  — 호출마다 초기화되지 않는다

def collect_fixed(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket


# 얕은 복사는 바깥 상자만 새로 만든다
grid = [[0] * 3 for _ in range(2)]
copy = list(grid)             # 바깥 리스트만 새것, 안쪽은 같은 객체
copy[0][0] = 9
print(grid[0][0])             # 9 — 원본도 바뀐다

약한 답

“파이썬은 리스트를 참조로 넘기니까 append는 원본이 바뀌고, 재할당은 지역 변수라 안 바뀝니다. 값 타입은 복사되고 참조 타입은 참조가 넘어갑니다.”

강한 답

“두 함수의 차이는 참조인지 값인지가 아니라, 객체를 변경했는가와 이름을 다시 묶었는가의 차이입니다.

add_tag에서 tags와 호출자의 base는 같은 리스트 객체를 가리키는 두 이름, 즉 별칭입니다. append는 그 객체 자체를 변경하므로 두 이름 어느 쪽으로 봐도 변경이 보입니다.

reset_tags의 tags = []는 새 객체를 만들어 지역 이름을 거기에 다시 묶는 동작입니다. 원래 객체는 건드리지 않았으므로 호출자 쪽 이름은 여전히 옛 객체를 가리킵니다. 그래서 아무 변화가 없습니다.

기본 인자 사례도 같은 규칙입니다. 기본값 리스트는 함수 객체가 만들어질 때 한 번 생성되어 함수에 붙어 있고, 호출마다 새로 만들어지지 않습니다. 그래서 첫 호출에서 변경한 그 객체가 다음 호출에 그대로 다시 쓰입니다. 이름을 다시 묶는 순간을 None 검사로 호출 시점으로 옮기면 해결됩니다.

얕은 복사도 마찬가지입니다. 바깥 리스트는 새 객체가 되지만 그 안의 원소들은 여전히 같은 객체를 가리키므로, 안쪽을 변경하면 원본에도 보입니다. 깊은 복사가 필요한지 아닌지는 안쪽 원소가 변경 가능한 객체인가로 판단합니다. 안쪽이 전부 불변이면 얕은 복사로 충분하고, 그 판단을 매번 하지 않으려면 애초에 불변 자료구조를 쓰는 방향이 낫습니다.”

무엇이 강한 답을 만들었는가

약한 답은 결론이 맞습니다. 그런데 “참조로 넘긴다”라는 표현에 기대고 있어서, 조금만 조건을 바꾸면 설명이 어긋납니다. 예를 들어 정수나 문자열에 같은 논리를 적용하면 왜 안 바뀌는지 설명할 수 없고, 기본 인자 문제나 얕은 복사 문제는 아예 별개의 암기 항목이 됩니다. 강한 답은 세 사례를 전부 하나의 규칙으로 설명했습니다. 이름을 다시 묶는가, 객체를 변경하는가. 하나의 규칙으로 여러 현상을 설명하면 면접관은 “변형을 물어도 답하겠구나”라고 판단합니다. 그것이 이 질문에서 벌 수 있는 점수의 전부입니다.

TIP

“값으로 전달인가요 참조로 전달인가요”라는 질문을 받으면 용어 논쟁으로 들어가지 말고, 그 언어에서 x = 새값이 호출자에게 보이는지, x.무언가변경()이 보이는지를 각각 답하십시오. 이 두 문장이면 어느 언어에서든 정확합니다.

언어별로 이 규칙이 어떻게 나타나는가

참조 기반 언어에서는 위 파이썬 설명이 거의 그대로 적용됩니다. 객체를 담은 변수는 사실상 핸들이고, 대입은 핸들을 복사하며, 메서드 호출은 같은 객체를 만집니다. 그래서 함수에 컬렉션을 넘길 때 “이 함수가 내 컬렉션을 바꾸는가”를 문서로 보장하거나 방어적 복사를 하거나 불변 뷰를 넘겨야 합니다.

값 타입이 있는 언어에서는 구조체를 대입하면 내용이 복사되므로 별칭이 생기지 않습니다. 대신 큰 구조체를 함수에 넘길 때마다 복사 비용이 붙고, 안에 참조 필드가 있으면 얕은 복사가 되어 다시 별칭 문제가 돌아옵니다. “값 타입이니까 안전하다”가 항상 참이 아닌 이유입니다.

소유권 모델을 가진 언어에서는 이 문제를 문서나 관습이 아니라 컴파일러가 검사합니다. 값을 함수에 넘기면 소유권이 이동해 원래 이름을 쓸 수 없고, 빌려주려면 명시적으로 대여해야 하며, 가변 대여는 동시에 하나만 허용됩니다. 이 규칙이 막아 주는 것이 정확히 위에서 본 “나도 모르는 사이에 남이 내 데이터를 바꾸는” 상황이고, 해제 후 사용과 데이터 경쟁도 같은 규칙으로 함께 막힙니다. 대신 컴파일러를 설득하는 비용을 지불합니다. 이 트레이드오프를 말할 수 있으면 충분합니다. 러스트 기초 가이드와 언어별 변수 비교가 감을 잡는 데 도움이 됩니다.

가비지 컬렉터가 해결하지 못하는 것

“GC가 있으니 메모리 걱정은 안 합니다”는 절반만 맞습니다. GC가 회수하는 것은 도달 불가능한 객체뿐입니다. 어딘가에서 여전히 참조하고 있으면 논리적으로 쓸모없어도 회수되지 않습니다. 실무에서 보는 누수는 거의 전부 이 형태입니다.

대표적인 패턴은 넷입니다. 첫째, 상한 없는 캐시입니다. 키를 계속 넣기만 하면 그 캐시가 참조 사슬의 뿌리가 되어 전부 살려 둡니다. 둘째, 해제하지 않은 리스너나 콜백 등록입니다. 등록된 객체가 등록처에 매달려 수명이 무한정 늘어납니다. 셋째, 정적 컬렉션입니다. 프로세스 수명과 같아지므로 여기에 들어간 것은 사실상 영구입니다. 넷째, 짧게 쓰려고 만든 객체가 오래 사는 객체에 붙는 경우입니다.

그리고 메모리와 별개로 파일 핸들, 소켓, 커넥션 같은 자원은 GC가 제때 닫아 주지 않습니다. GC 시점은 예측할 수 없기 때문에, 자원 해제는 언어가 제공하는 명시적 범위 구문으로 처리해야 합니다. 면접에서 “GC는 메모리만 다루고 그 외 자원은 명시적으로 닫습니다”라고 구분해 주면 좋습니다. JVM 쪽 세부 사항은 JVM 성능 가이드에서 이어 볼 수 있습니다.

WARNING

메모리 문제를 추측으로 고치려 들면 거의 실패합니다. 사용량이 계속 오르는 것을 봤다면 다음 질문은 “무엇이 늘었나”가 아니라 “그것을 아직 누가 붙잡고 있나”입니다. 힙 스냅샷에서 보존 경로를 확인하기 전에는 코드를 고치지 마십시오.

이어지는 질문들

왜 정수나 문자열은 함수 안에서 바꿔도 밖에 영향이 없나요?

그 타입들이 불변이라 변경하는 연산 자체가 없고, 따라서 할 수 있는 것은 이름을 다시 묶는 것뿐이기 때문입니다. “불변 객체에는 별칭이 있어도 문제가 되지 않는다”가 핵심 문장입니다. 이 성질이 불변 자료구조를 동시성에서 선호하는 이유이기도 합니다.

깊은 복사는 언제 하나요?

안쪽에 변경 가능한 객체가 있고 그 변경이 서로에게 보이면 안 될 때만 합니다. 비용이 크고 순환 참조가 있으면 까다로우므로 기본 선택지가 되면 곤란합니다. 더 좋은 방향은 애초에 안쪽을 불변으로 만들어 복사가 필요 없게 하는 것입니다.

객체를 재사용하는 풀을 만들면 성능이 좋아지나요?

때에 따라 다르고, 대개는 먼저 할 일이 아닙니다. 현대 런타임에서 짧게 사는 작은 객체의 할당은 매우 저렴하고, 풀은 반납 누락과 상태 오염이라는 새 버그 종류를 만듭니다. 할당률을 측정해서 실제로 그것이 병목이라는 근거가 나온 뒤에 꺼낼 카드입니다.

동시성과 이 주제가 어떻게 연결되나요?

별칭이 곧 공유입니다. 두 스레드가 같은 변경 가능 객체를 별칭으로 들고 있으면 그것이 데이터 경쟁의 정의입니다. 그래서 해법도 셋으로 정리됩니다. 공유하지 않거나, 공유하되 불변으로 만들거나, 공유하고 변경하되 접근을 직렬화하는 것입니다.

참조와 포인터는 같은 것인가요?

목적은 비슷하지만 보장이 다릅니다. 포인터는 산술 연산과 임의 주소 접근이 가능하고 유효성 보장이 프로그래머 책임인 반면, 참조 계열은 대개 유효한 대상을 가리키도록 언어가 제약합니다. 어느 쪽이든 “가리키는 대상의 수명이 이 이름보다 긴가”라는 질문은 똑같이 필요합니다.

이 영역에서 자주 나오는 실수

첫째, 언어 구현 용어로 도망가는 것입니다. 질문은 동작인데 답이 “힙에 올라갑니다”면 관측 가능한 결과를 설명한 것이 아닙니다. 스택·힙 이야기는 필요할 때만 근거로 붙이고, 먼저 프로그램에서 무엇이 보이는지를 말해야 합니다.

둘째, 언어를 뒤섞는 것입니다. 한 언어의 규칙을 다른 언어에 그대로 적용하면 틀린 설명이 나옵니다. “제가 말하는 것은 이 언어 기준이고, 값 타입이 있는 언어에서는 이 부분이 다릅니다”라고 경계를 그으면 됩니다.

셋째, 방어적 복사를 남발하는 것입니다. 안전하지만 비용이 붙고, 무엇보다 문제의 원인을 가립니다. 왜 이 객체가 외부에 노출되어 있는지를 먼저 보는 편이 낫습니다.

넷째, 성능 관련 주장을 측정 없이 하는 것입니다. “이게 더 빠릅니다”라고 단정하면 반드시 근거를 요구받습니다. “그럴 것으로 예상하지만 할당률과 지연 시간을 측정해 확인하겠습니다”가 더 안전하고 실제로도 더 정확합니다.

설명하기 전 확인할 것

  • 이름 재바인딩과 객체 변경을 구분해서 말했는가
  • 지금 설명이 어느 언어 기준인지 밝혔는가
  • 별칭이 존재하는지, 존재한다면 변경이 보이는지 답했는가
  • 얕은 복사와 깊은 복사가 필요한 조건을 말할 수 있는가
  • 불변 객체에는 별칭이 문제되지 않는 이유를 아는가
  • GC가 회수하는 것과 회수하지 않는 것을 구분했는가
  • 메모리 외 자원의 해제를 별도로 언급했는가
  • 성능 주장에 측정 계획을 붙였는가

연습 방법

가장 좋은 훈련은 짧은 코드를 보고 출력을 먼저 예측하는 것입니다. 함수에 컬렉션을 넘겨 변경하는 코드, 기본 인자에 변경 가능한 값을 쓴 코드, 중첩 리스트를 얕게 복사한 코드, 같은 객체를 두 컬렉션에 넣고 한쪽을 수정하는 코드를 각각 만들어 예측하고 실행해 비교하십시오. 틀린 지점이 곧 자기 모델의 구멍입니다.

그다음에는 같은 코드를 다른 언어로 옮겨 보십시오. 어디서 동작이 갈라지는지가 그 언어의 메모리 모델을 이해하는 가장 빠른 길입니다. 러스트 학습 라이브러리와 자바 학습 라이브러리를 나란히 보면 두 모델의 차이가 선명해집니다. 관련 면접 주제는 비동기와 동시성으로 이어집니다.

관련 언어 학습으로 복습하기