1. PL
TL;DR
이 글은 2025년도 봄학기 KAIST 전산학부 대학원 면접을 준비하며, 면접 참고자료(PL)에 대한 대답을 정리한 글입니다.
문제에 대한 정답이 아니며, 제가 공부하며 생각한 바들을 정리한 것입니다.
후에 같은 목표로 면접을 준비하는 학우분들께 도움이 되기를 바랍니다.
이 풀이노트는 질문만 본 상태에서 답변한 내용에 대한 채점, 그리고 이 면접을 준비하며 이전에 한번 LLM과 함께 작성해본 답안이 이어져 있습니다.
문제
Preliminaries
Q: 자바에서 public, protected, private 키워드가 있는데 아무것도 안 쓸 경우 default로 적용되는범위는? 그렇게 디자인된 이유는?
자바의 class method에 아무런 스코프도 명시하지 않을 경우 default 스코프는 public과 protected 사이의 범위입니다. -> 오답, private과 protected
public은 패키지 외부에서도 자유롭게 메소드를 호출할 수 있고, protected는 동일 패키지 내의 상속받은 자식 클래스만 해당 메소드를 호출할 수 있습니다. -> 오답, protected 동일 패키지 내 아무곳이나, 패키지 외부 자식클래스에서 사용 가능
default는 패키지 외부의 자식 클래스에서도 메소드를 호출할 수 있습니다. -> 오답, 패키지 내부 아무데서나 호출 가능
이러한 디자인은 객체지향의 특징 중 하나인 캡슐화와 상속을 구현하기 위함입니다. -> 오답, 캡슐화를 구현하기 위함
패키지 외부에선 자식 클래스만 해당 메소드를 호출할 수 있는것이 가장 자연스럽습니다.
default는 public과 protected 사이의 스코프를 가집니다.
같은 패키지에 속한 클래스에서만 접근이 가능합니다.
스코프의 크기는 public > protected > default > private 순으로,
pulbic은 모든 패키지에서 접근 가능,
protected는 같은 패키지 또는 자식 클래스에서 접근 가능,
private은 선언한 클래스 내부에서만 접근 가능합니다.
클래스는 public과 default 키워드만 존재하며 패키지 밖에서의 접근 가능 여부를 결정합니다.
이러한 디자인은 패키지 밖에서 의도하지 않은 접근이 발생하지 않도록 객체지향 프로그래밍의 캡슐화를 강하게 하기 위함입니다.
C++에서 서로 다른 타입의 오브젝트를 가리키는 포인터를 사용할 수 있나?
네 가능합니다. 포인터의 타입을 강제로 캐스팅하는것도 가능하고, 일반적으로 상속 관계의 포인터의 타입을 변환할 때 이용하곤 합니다. 이를 업캐스팅, 다운캐스팅이라고 부릅니다.
네 가능합니다.
C++에선 특정 타입의 오브젝트 포인터를 다른 타입의 오브젝트 포인터로 캐스팅할 수 있습니다.
예를 들어 같은 부모 클래스 Animal을 상속하는 Dog, Cat 클래스 포인터를 Animal 클래스 포인터로 캐스팅하여 그 메소드를 이용할 수 있습니다.
이를 업 캐스팅이라고 부르고, 반대 경우를 다운 캐스팅이라고 부릅니다.
전역 변수 사용의 장단점은?
전역 변수는 프로세스의 Data 섹션에 저장되어 임의의 함수에서 접근하기 쉽습니다. 하지만 함수의 스코프에 따라 로컬 변수와 혼동하기 쉽고, 멀티스레드 프로그램에선 크리티컬 섹션으로 작용하기 쉽습니다.
전역 변수는 일반적으로 data 섹션에 저장되어 프로그램 종료시까지 유지됩니다.
따라서 프로그램 전역에서 활용해야 하는 객체를 관리할 때 용이합니다.
하지만, 전역에서 접근하기 때문에 현제 상태를 파악하기 어렵고, 동시에 접근할 가능성도 발생합니다.
또한 전역에서 동일한 심볼을 사용하게 되어 지역변수와 헷갈릴 수 있습니다.
Function
Higher-order function이 무엇인지 설명하고, 현대 프로그래밍에서 이 개념을 활용하여 쓰이는 대표적인 함수를 제시하시오.
고차 함수란 first-class function을 인수로 받거나, 리턴값이 일급 함수인 함수를 의미합니다. 가장 대표적인 예시로 반복자에 적용되는 map, foreach 등의 함수가 있습니다. 일급함수란 프로그래밍 언어에서 변수와 동일하게 취급되는 함수를 의미합니다.
고차 함수는 일급 함수를 인수로 받거나 리턴값이 함수인 함수를 의미합니다.
대표적인 예시로 map, foreach 등 반복자에서 유용하게 활용합니다.
Lazy Evaluation
파라미터 패싱 방식에는 eager evaluation 방식과 lazy evaluation 방식이 있다. 두 방식 의 차이점을 비교 설명하세요. call-by-value와 call-by-name 파라미터 패싱 방식은 각 각 어느 방식에 속하는지 구분하세요.
eager evaluation은 파라미터를 넘기는 타이밍에 expression을 계산하고, lazy evaluation은 실제로 함수가 해당 값을 이용하는 타이밍에 expression을 계산합니다. call-by-value가 eager evaluation, call-by-name이 lazy evaluation에 속합니다.
eager evaluation은 함수에 인수를 전달하기 전에 expression을 계산합니다.
call-by-value가 이에 속합니다.
lazy evaluation은 실제로 그 표현의 값이 필요한 시점에 계산을 수행합니다.
call-by-name이 이에 속합니다.
Memory, Garbage Collection
Rust와 Java는 C/C++를 대체하여 시스템 프로그래밍을 쉽게 하기 위해 대두된 대표적인 언어들이다. 메모리 관리 측면에서 기존 C/C++의 한계를 제시하고, 두 언어가 그 한계를 각각 어떻게 극복했는지 설명하고, 어떤 장단점이 있는지 설명하시오
C언어는 메모리를 선언적으로 할당, 해제해야합니다. 이로인해 다양한 메모리 버그들이 발생하는데, 대표적으로 use-after-free, double free 등이 있습니다. C는 이러한 한계를 극복하기 위해 다양한 소프트웨어 분석 도구를 이용합니다. 컴파일 타임에 메모리 새니타이징을 검사하기 위한 옵션을 넣을수도 있고, valgrind 같은 툴을 이용할수도 있습니다. C++는 RAII라는 프로그래밍 패턴을 도입했습니다. Resource Allocation is Initailization의 약자로, 객체를 생성하는 타이밍에 초기화가 이루어져야 하고, 이를 사용하지 않는 타이밍에 바로 destructor를 호출하여 메모리를 정리합니다. C의 원시적인 메모리 관리는 프로그램 최적화에 용이합니다. 메모리 할당이 어느정도 예정되어 있을 때, 이를 한번에 처리하고 정리할 수 있습니다. C++의 다양한 스마트 포인터는 이보다 자주 할당이 발생하지만, 객체를 더이상 사용하지 않을 때 바로 메모리 할당을 해제하여 안정성을 강화합니다.
아 Rust와 Java의 장단점이군요, 긴장해서 엉뚱한 대답을 했네요 ㅎㅎ Rust는 말씀드린 RAII 패턴을 소유권이라는 독자적인 개념과 결합하여 언어단에서 강제한 경우입니다. 이는 언어 수준에서 메모리 안정성을 보장하고, 직접적인 포인터 접근 없이 고수준의 언어로 시스템 프로그래밍을 가능하도록 만들었습니다. Java는 가비지 컬렉터를 통해 memory leak을 해결했습니다. 이는 프로그래머가 신경쓰지 않더라도 자동으로 객체를 정리하여 프로그래머가 메모리를 신경쓰지 않고 로직에만 집중하도록 돕습니다. 하지만 GC의 구현 방식에 따라 오버헤드가 발생합니다. 예를 들어 mark&swip 방식은 stop the world가 필연적이여서 운영체제에는 적합하지 않습니다.
c는 메모리를 직접 동적 할당하고 해제해야 합니다. 이 과정에서 use after free, double free, dangling pointer 등이 메모리 문제가 발생하고 이는 예상치 못한 동작으로 이어집니다. Java는 가비지 컬렉션과 정적인 타입 시스템, 객체지향 프로그래밍을 통해 안정적인 시스템 구현을 제공합니다. 또한 JVM이라는 인터프리터 위에서 동작하는 자바 바이트코드를 컴파일하고 JVM은 런타임에 널 포인터 검사 등 프로그램 안정성을 위한 고립을 제공합니다. 가비지 컬렉션에 의한 런타임 오버헤드가 존재하며 stop_world가 필수적이라는 한계점이 있습니다. Rust는 소유권이라는 개념을 도입하여 RAII를 시스템에 내장했습니다. 객체 생성 시점에 이를 초기화하고 스코프에서 벗어나면 자동으로 해제하여 메모리 안전을 보장합니다. 또한 소유권 개념을 동시성 프로그래밍에서 객체의 소유권에 따라 안전하게 이를 다룰 수 있도록 돕습니다. 하지만 이러한 개념들이 아려워 높은 러닝 커브가 단점으로 꼽히고, 객체의 라이프타임을 비동기 프로그래밍에서 다루기 어려운것이 현업에서의 가장 큰 문제입니다.
RAII(Resource acquisition is initialization)가 무엇이고, 어느 상황에서 사용하면 좋을지 설명하시오.
설명은 앞 문제 참조, RAII를 강제하는 대표적인 예로 Rust가 있습니다. C 수준의 고성능 프로그래밍을 요구하지만 memory safety도 챙기고 싶은 경우에 좋은 패턴이라고 생각합니다.
RAII는 “Resource Acquisition Is Initialization”의 약자로, 생성자로 자원을 획득하고, 스코프에서 벗어가는 시점에 자동으로 소멸자를 호출하는 패턴을 의미합니다. 이는 메모리 취약점 방지 등의 장점이 있습니다. 이는 메모리 관리, 파일(소켓) 핸들 관리, 뮤텍스 관리 등에서 용이합니다.
가비지 컬렉션을 위해 Mark and Sweep 기법이 크게 어떻게 이루어지는지 간단히 설명하고, 이 기법의 장단점을 설명하시오
mark and sweep은 두 단계로 나뉩니다. 먼저 mark 단계에서 GC root에서 도달 가능한 모든 객체를 mark합니다. sweep 단계에서 mark되지 않은 모든 객체를 정리합니다.
이 기법은 다른 GC 기법보다 완벽한 정리를 제공합니다. 예를 들어 순환 참조 등도 완벽하게 정리할 수 있습니다. 하지만 이 과정동안 새로운 객체를 할당할 수 없어 stop the world가 보통 발생합니다.
Mark and swip은 GC root (힙 바깥의 객체)에서부터 참조되는 모든 객체를 마킹하고, 이를 일괄적으로 swip하여 해제하는 기법을 의미합니다. 이는 순환 참조를 초함한 대부분의 경우를 메모리 누수 없이 정리할 수 있는 장점이 있습니다. 하지만 Stop-the-World와 메모리 단편와 같은 문제가 있습니다.
Type System
Type system이 sound하다는 것, 그리고 complete하다는 것의 의미를 각각 설명하시오.
타입 시스템이은 타입 규칙에 따라 프로그램을 통과시키거나, 타입 에러를 발생시킵니다. 타입시스템이 sound하다면 이 타입시스템을 통과한 프로그램을 타입 규칙을 만족시킵니다. 즉 타입규칙을 만족하지 않는 프로그램이 타입시스템을 통과하는 경우는 없습니다. 타입시스템이 complete하다면 타입규칙을 만족하는 프로그램은 타입시스템을 통과합니다. 즉 타입 에러가 발생하는 프로그램은 항상 타입 규칙을 만족하지 않습니다.
현대 타입 시스템은 대부분 sound합니다.
https://hjaem.info/articles/kr_19_5
프로그램 실행 전 컴파일 시에 타입을 정적으로 검사하는 프로그래밍 언어가 그렇지 않은 프로그래밍 언어에 비해 갖는 장점을 설명하시오. 또한, 그럼에도 불구하고 그렇지 않은 언어가 사용되는 이유를 설명하시오.
정적인 타입 시스템을 가진 언어는 다른 언어에 비해 정상적으로 동작할 것이라고 기대됩니다. 즉, 타입 검사기를 통과한 프로그램은 오류 없이 실행될 가능성이 높습니다. 하지만 타입 시스템을 만족하기 위해 불필요하게 긴 코드를 작성해야 하는 경우가 많습니다. 동적 타입 언어들은 이러한 타입 검사를 런타임에 실행하고 컴파일 타임에 타입 검사를 건너뛰어 개발자가 편리하게 코드를 짜고 빠르게 실행하는 것이 초점이 맞추어져 있습니다.
정적 타입 언어와 동적 타입 언어는 각자의 장단점을 가지고 있다. 정적 타입 언어의 장점부터 보자. 정적 타입 언어를 사용하면 실행 전에 타입 오류를 찾을 수 있다. 프로그램이 복잡하거나 긴 시간 동안 유지하고 보수해야 한다면 정적 타입 검사는 매력적이다. 프로그램의 신뢰도가 중요한 경우에도 정적 타입 언어를 사용하는 것이 바람직하다. 또, 정적 검사를 통해 얻은 타입 정보를 통해 다양한 최적화를 수행할 수 있다. 정적 타입 검사를 통해 타입 오류가 없음을 보장하면 동적 타입 검사의 필요성이 감소하기도 한다. 이러한 이유로 정적 타입 언어는 실행 시 성능에서도 우월하다. 정적 타입 언어는 타입 검사를 위해 프로그래머에게 코드에 타입을 표시할 것을 요구하기도 한다. 타입 표시(type annotation)는 컴퓨터에 의해 항상 자동으로 검증되는, 정확한 정보를 제공하는 주석의 역할을 한다. 이는 프로그램의 유지와 보수에 도움이 된다. 정적 타입 언어의 단점은 타입 검사기의 안전성을 대가로 잃어버린 완전성이다. 타입 오류를 일으키지 않는 프로그램도 타입 검사기가 거절할 수 있다. 이를 극복하기 위해 대부분의 정적 타입 언어는 안전하지 않은 기능을 제공한다. 명시적 타입 변환(explicit type casting)이 대표적인 예시다. 프로그래머는 안전하지 않은 기능을 통해 타입 검사기의 불완전함을 피해갈 수 있다. 그러나 안전하지 않은 기능을 사용하면 타입 검사기의 안전성이 사라진다. 이는 정적 타입 언어를 사용하는 목적 자체를 흐리는 것이므로 안전하지 않은 기능은 되도록 사용하지 않아야 한다. 타입을 표시해야 한다는 점 역시 단점이 된다. 타입 표시는 주석의 역할을 하여 프로그램 관리에 도움을 주지만, 동시에 코드를 불필요하게 장황하게 만든다. 많은 정적 타입 언어는 타입 추론(type inference)을 제공하여 타입 표시를 생략할 수 있게 한다. 프로그래머는 적절히 타입 표시를 생략함으로써 장황함을 해소할 수 있다. 출처: https://hjaem.info/articles/kr_19_5
Static typing과 dynamic typing의 차이점은? C++/JAVA은 어떤 typing을 사용하는가?
정적 타이핑은 프로그램 실행 전, 보통 컴파일 타임에 타입 검사를 수행합니다. 동적 타이핑은 프로그램 실행중에 실시간으로 타입 검사를 수행합니다. C/java는 정적 타입 언어입니다.
정적 타이핑은 프로그램 컴파일/실행 전에 타입 검사를 수행하고 동적 타이핑은 런타임에 타입 검사를 실행한다. cpp/java는 정적 타입 언어이다
Option type이 무엇이고, 어느 상황에서 사용하면 좋을지 설명하시오.
Option 타입은 객체가 존재하거나 존재하지 않는 상황을 표현하는 타입입니다. 주로 Container로부터 값을 가져오는 상황에 사용하면 편리합니다. 예를 들어, c++의 벡터는 그 크기보다 큰 범위를 참조하면 UB가 발생하지만, Rust의 벡터는 None을 리턴하여 에러 핸들링에 용이합니다.
특정 값이 존재하거나 하지 않는 경우 표현할 때 유용하다. 과거엔 null을 이용하여 표현했는데, 이는 타입 시스템에 모호함을 제공할 수 있다.
함수형 언어에서는 예외 상황을 안전하고 불변성을 유지하는 방식으로 처리할 수 있도록 옵션 타입을 제공한다.
Scala 등 많은 언어에서 Option이라고 부르며 일부 언어에서는 Maybe라고 부르는 경우도 있다.
그 이름에서 유추할 수 있듯이 값이 존재하는 경우와 존재하지 않는 경우를 선택적으로 나타낼 수 있는 타입이다.
https://hjaem.info/articles/kr_4_8
Compiler
중간 표현(Intermediate Representation, IR)은 컴파일러 설계에서 매우 중요한 요소이다. IR이 사용되는 이유를 간단히 설명하라.
현대 컴파일러는 프로그래밍 언어를 IR로 번역하는 프론트엔드와, IR을 최적화하고 어셈블리도 변환하는 백엔드로 구성되어 있습니다. 이러한 다양한 언어를 중간 언어로 통일하여 최적화하는 이점을 제공합니다. 또한 프로그래밍 언어보다 기계어에 가까운 형태에서 최적화를 수행하여 다양한 최적화가 가능합니다. (SSA ~~)
IR은 다양한 언어의 AST로부터 생성하는 통합된 중간 언어이다. 가상 레지스터를 통해 SSA(Static single assignment)를 구현하며 최적화에 용이한 형태로 이용한다. 다양한 소스 언어로부터 IR을 생성하는 프론트엔드와 IR을 최적화하고 이로부터 어셈블리를 생성하는 백엔드를 분리할 수 있다.
기타
아래 두 Javascript 코드(code1.js 및 code2.js)의 실행 결과가 다른 원인을 설명하고, 그 원인 각각의
장단점을 설명하시오.
// code1.js
let l1 = [1, 2];
let l2 = [100, 200];
let s = 0;
for (var x of l1) {
for (var x of l2) {
}
s += x;
}
console.log(s)
// Result: 400
// code2.js
let l1 = [1, 2];
let l2 = [100, 200];
let s = 0;
for (let x of l1) {
for (let x of l2) {
}
s += x;
}
console.log(s)
// Result: 3
앞 코드의 var는 function scope이기 때문에 바깥 스코프의 x를 내부 스코프의 x가 변경할 수 있고, 값에 200이 들어갑니다. 뒤 코드의 let은 curly brace scope를 따라가기 때문에 내부 for문을 나오면 바깥 for문의 값을 이용합니다.
var (함수 스코프):var 키워드로 선언된 변수는 **함수 스코프(Function Scope)**를 가집니다. 즉, 변수가 선언된 함수 내에서 어디서든 접근할 수 있으며, 블록({ }) 스코프는 무시됩니다. 동일한 이름으로 여러 번 선언해도 오류가 발생하지 않고, 재선언됩니다. let 키워드로 선언된 변수는 **블록 스코프(Block Scope)**를 가집니다. 즉, 변수가 선언된 { } 블록 내에서만 유효합니다. 블록을 벗어나면 해당 변수는 접근할 수 없습니다. 또한, 동일한 스코프 내에서 동일한 이름으로 변수를 재선언할 수 없습니다.
간단한 표현식 1 + 2 * 3 을 중심으로, Operational semantics와 Denotational semantics의 차이를 설명하시오.
Operational semantics은 추론 법칙 등을 이용해 표현식의 답을 찾아가는 과정을 제공합니다. 주어진 식에선 2 * 3 -> 6, 1 + 6 -> 7 이런식으로 small step 을 수행할 수 있습니다. Denotaional semantic이란 프로그램 수행 절차보다 이들의 수학적 표현에 집중하여 semantic을 해석합니다. 1 + 2 * 3이 7이 된다는 점이 중요하지, 어떤 순서로 진행되는지는 중요하지 않습니다.
아래의 코드에서 sum 변수에 배열 array의 모든 요소를 더하는 작업을 수행한다. 컴파일러가 이 루프를 최적화할 수 있는 방법을 제안하고, 이 방법이 성능 향상에 어떻게 기여하는지 설명하라.
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += array[i];
}
루프 언롤링이 가능합니다. 이는 연속적인 데이터 접근을 분기 없이 명령어셋으로 이어 작성하여 오버헤드를 줄입니다. mem2reg도 가능할 것 같습니다. 동일한 심볼 i를 반복적으로 접근하고 ir로 표현해 보면 load store가 엄청 일어날 텐데, 이를 줄일 수 있습니다.
다음은 프로시저 read(a, b)의 의사코드이다. 이 프로그램은 특정 조건을 만족하는 a값과 b값이 들어오면 오류를 일으킨다. 다음 중 프로그램의 오류를 막을 수 있는 가장 약한 조건은?
c := 3.0 * a + b;
if c = 0 { a := 1; } else { a := 1.0/c + 1.0/b; }
// 1) b > 0
// 2) a>0 and b>0
// 3) a ≠ -b/3
// 4) b ≠ 0
// 5) 3.0*a ≠ 0 and b ≠ 0
4번