함수를 다루는 기술: 매개변수, 반환값, 선택적 인자, 콜백
좋은 TypeScript 코드는 멋진 트릭에서 시작하지 않습니다. 의외로 출발점은 아주 소박합니다. 함수를 잘 만드는 것입니다.
함수는 프로그램의 작은 기계입니다. 무언가를 넣으면, 규칙에 따라 처리하고, 결과를 내놓습니다. TypeScript는 이 작은 기계에 설명서를 붙여 줍니다. 무엇을 넣어야 하는지, 무엇이 나오는지, 어떤 값은 생략해도 되는지, 심지어 다른 함수를 전달받는지도 미리 알려 줍니다.
자바스크립트에서는 함수가 꽤 자유롭습니다. 자유는 멋지지만, 가끔은 너무 자유로워서 개발자를 놀라게 합니다. TypeScript는 그 자유를 완전히 없애지 않으면서, 적어도 함수 앞에는 안내문을 세워 둡니다. 그래서 실수로 엉뚱한 값을 넣거나, 반환값을 오해하는 일을 줄여 줍니다.
함수 시그니처는 계약서다
함수를 볼 때 가장 먼저 봐야 하는 것은 함수 이름이 아니라 함수 시그니처입니다. 시그니처는 이 함수가 어떤 입력을 받고 어떤 출력을 내는지 보여 주는 계약서입니다.1
가장 기본적인 예시는 이렇습니다.
function add(a: number, b: number): number {
return a + b;
}이 함수는 이렇게 읽으면 됩니다.
a와 b라는 두 개의 숫자를 받아서, 숫자를 하나 돌려주는 함수.
아주 단순하지만, 여기에는 중요한 약속이 다 들어 있습니다.
매개변수
a는 number매개변수
b는 number반환값은 number
여기서 잠깐, 초보자가 자주 헷갈리는 단어를 정리하고 가겠습니다.
매개변수(parameter)는 함수 정의에 적는 변수이고, 인자(argument)는 함수를 호출할 때 실제로 넘기는 값입니다.
function greet(name: string) {
return `안녕하세요, ${name}님!`;
}
greet('지민');위 코드에서 name은 매개변수이고, '지민'은 인자입니다.
TypeScript에서는 함수 타입 자체를 따로 적을 수도 있습니다. 예를 들어 이렇게요.1
type GreetFunction = (name: string) => string;이 코드는 선언문처럼 보이진 않지만, 뜻은 아주 분명합니다.
name이라는 문자열을 받아서 문자열을 돌려주는 함수 타입.
화살표 기준으로 생각하면 쉽습니다.
왼쪽: 들어오는 것
오른쪽: 나가는 것
함수 시그니처를 읽는 습관이 생기면, 코드를 보기 전에 이미 함수의 성격을 절반쯤 이해하게 됩니다.
매개변수에 타입 붙이기
TypeScript에서 함수 매개변수에 타입을 붙이는 건 어렵지 않습니다. 변수에 타입을 붙이는 것과 거의 같은 느낌입니다.
function introduce(name: string, age: number): string {
return `${name}님은 ${age}살입니다.`;
}이제 이 함수는 문자열과 숫자를 정확히 받아야 합니다.
introduce('민수', 20); // 정상
introduce('민수', '20'); // 오류두 번째 호출이 막히는 이유는 단순합니다. 함수가 숫자를 원한다고 계약서에 적혀 있는데 문자열을 들고 왔기 때문입니다.
화살표 함수도 똑같이 타입을 붙일 수 있습니다.
const multiply = (a: number, b: number): number => {
return a * b;
};또는 함수 변수 자체에 함수 타입을 먼저 적을 수도 있습니다.
const divide: (a: number, b: number) => number = (a, b) => {
return a / b;
};이 방식이 좋은 이유는 함수의 모양을 먼저 못 박을 수 있기 때문입니다. 마치 '이 자리에 들어올 직원은 이런 역할을 해야 합니다'라고 채용 공고를 먼저 써 두는 것과 비슷합니다.
초보자에게는 첫 번째 방식이 더 읽기 쉽고, 규모가 커지면 두 번째 방식이 더 재사용하기 좋습니다.
반환값은 함수의 결말이다
함수의 매개변수가 입력이라면, 반환값은 결말입니다. 영화를 보는데 결말이 없으면 허무하듯, 함수도 무엇을 돌려주는지 분명해야 이해하기 쉽습니다.
function square(num: number): number {
return num * num;
}: number는 이 함수가 반드시 숫자를 반환해야 한다는 뜻입니다.2
TypeScript는 때로 반환 타입을 스스로 추론할 수 있습니다.
function square(num: number) {
return num * num;
}이 코드도 문제는 없습니다. TypeScript는 num * num이 숫자라는 걸 알기 때문에 반환 타입을 number로 이해합니다.2
그런데 초보자에게는 이렇게 생각하는 습관이 꽤 유용합니다.
짧고 명백한 함수는 추론에 맡겨도 되지만, 중요한 함수는 반환 타입을 직접 적자.
왜냐하면 반환 타입을 적는 순간, 함수가 해야 할 역할이 더 또렷해지기 때문입니다. 나중에 실수로 문자열을 반환하게 바뀌어도 바로 잡아낼 수 있습니다.
반환값이 없는 함수도 있습니다.
function logMessage(message: string): void {
console.log(message);
}void는 값을 돌려주지 않는다는 뜻입니다.2
이런 함수는 결과를 만드는 대신, 화면에 출력하거나 파일에 저장하거나 상태를 바꾸는 식으로 행동을 합니다. 즉, 계산기보다는 스위치에 가깝습니다.
선택적 인자: 있어도 되고 없어도 되는 값
모든 함수 인자가 항상 필요한 것은 아닙니다. 어떤 함수는 기본 정보만 있어도 동작하고, 추가 정보는 있으면 더 잘 동작합니다. 이럴 때 쓰는 것이 선택적 매개변수입니다.
function greet(name: string, title?: string): string {
if (title !== undefined) {
return `${title} ${name}님, 안녕하세요!`;
}
return `${name}님, 안녕하세요!`;
}title?: string은 문자열이 올 수도 있고, 아예 안 올 수도 있다는 뜻입니다.3
호출은 이렇게 됩니다.
greet('다은');
greet('다은', '개발자');중요한 규칙이 하나 있습니다. 선택적 매개변수는 필수 매개변수 뒤에 와야 합니다.3
// 잘못된 예시
function badExample(title?: string, name: string) {
return `${title} ${name}`;
}왜 안 될까요? 호출하는 쪽에서 인자 하나만 넣었을 때, 그 값이 title인지 name인지 헷갈리기 때문입니다.
선택적 매개변수를 사용할 때 또 하나 조심해야 할 것이 있습니다. 바로 if (value) 같은 검사입니다. 이 방식은 편해 보이지만, 0, '', false 같은 값도 없다고 착각할 수 있습니다.3
function retry(taskName: string, delayMs?: number) {
if (delayMs) {
console.log(`${delayMs}ms 뒤에 ${taskName} 재시도`);
} else {
console.log(`${taskName} 즉시 재시도`);
}
}
retry('업로드', 0);이 코드는 0을 전달했는데도 마치 값을 안 준 것처럼 처리할 수 있습니다. 그래서 선택적 값은 가능하면 이렇게 검사하는 편이 안전합니다.
function retry(taskName: string, delayMs?: number) {
if (delayMs !== undefined) {
console.log(`${delayMs}ms 뒤에 ${taskName} 재시도`);
} else {
console.log(`${taskName} 즉시 재시도`);
}
}이제 0은 진짜 값으로 인정받습니다. 숫자 0도 소중한 시민입니다.
기본값이 있는 매개변수는 더 친절하다
선택적 매개변수와 비슷하지만 느낌이 다른 것이 기본값 매개변수입니다.
function power(value: number, exponent: number = 2): number {
return value ** exponent;
}이 함수는 exponent를 생략하면 자동으로 2를 사용합니다.2
power(3); // 9
power(3, 3); // 27선택적 매개변수와 기본값 매개변수의 차이는 생각보다 큽니다.
선택적 매개변수는 함수 안에서 undefined일 수 있습니다. 그래서 확인이 필요합니다. 반면 기본값 매개변수는 값이 빠져도 자동으로 채워지기 때문에 함수 안에서는 더 다루기 편합니다.3
function greet(name: string, message: string = '안녕하세요'): string {
return `${message}, ${name}님!`;
}이 함수 안에서 message는 언제나 문자열입니다. 그래서 별도 검사 없이 바로 사용할 수 있습니다.
실무 감각으로 정리하면 이렇습니다.
빠졌을 때 쓸 만한 기본값이 있다면 기본값 매개변수
빠졌는지 자체가 중요한 정보라면 선택적 매개변수
선택 가능한 값이 너무 많아지면 함수 설계를 다시 생각하기
매개변수 세 개, 네 개, 다섯 개가 모두 선택적이라면 함수가 '유연한 것'이 아니라 '헷갈리는 것'일 수도 있습니다.
콜백: 함수를 인자로 받는 함수
이제 함수의 진짜 매력을 볼 차례입니다. 자바스크립트와 TypeScript에서 함수는 값처럼 다룰 수 있습니다. 즉, 숫자나 문자열처럼 다른 함수에 전달할 수 있습니다.1
이때 전달되는 함수를 콜백(callback)이라고 부릅니다. 쉽게 말해, '일이 끝나면 이 함수를 불러 주세요'라는 예약 버튼입니다.
function calculate(a: number, b: number, callback: (result: number) => void): void {
const result = a + b;
callback(result);
}이 시그니처는 이렇게 읽습니다.
숫자 두 개를 받고, 숫자 결과를 전달받는 함수를 하나 더 받아서, 최종적으로는 아무 값도 반환하지 않는 함수.
사용은 이렇게 합니다.
calculate(3, 4, (result) => {
console.log(`계산 결과는 ${result}입니다.`);
});여기서 세 번째 인자는 함수입니다. callback: (result: number) => void라는 타입 덕분에, 콜백이 숫자를 받아야 한다는 사실을 TypeScript가 알고 있습니다. 그래서 콜백 안의 result도 자연스럽게 숫자로 이해합니다.
콜백 타입이 길어지면 이름을 붙여 주는 편이 읽기 좋습니다.
type ResultHandler = (result: number) => void;
function calculate(a: number, b: number, handler: ResultHandler): void {
handler(a + b);
}이제 함수 시그니처가 훨씬 덜 복잡해 보입니다. 긴 설명을 별명으로 바꿔 둔 셈입니다.
재미있는 예로, 라면 완성 알림 함수를 만들어 볼 수 있습니다.
type Done = (message: string) => void;
function cookRamen(waterMl: number, done: Done): void {
const message = `물 ${waterMl}ml로 라면이 완성되었습니다!`;
done(message);
}
cookRamen(550, (message) => {
console.log(message);
});물론 실제 라면은 함수보다 냄비가 더 중요하지만, 콜백을 이해하는 데는 꽤 훌륭한 예시입니다.
콜백의 선택적 매개변수는 신중하게
콜백에도 선택적 매개변수를 붙일 수 있습니다.
type FetchCallback = (data: string, success?: boolean) => void;문법상 가능하고, 실제로도 쓸 수 있습니다.4
하지만 여기에는 함정이 있습니다. 콜백 매개변수를 선택적으로 표시하면, 그 콜백을 받는 쪽은 그 값이 없을 수도 있다고 항상 생각해야 합니다. 즉, 편해 보이지만 오히려 처리 코드가 복잡해질 수 있습니다.
const handleFetch: FetchCallback = (data, success) => {
if (success === true) {
console.log(`성공: ${data}`);
} else {
console.log('실패 또는 상태 정보 없음');
}
};그래서 설계할 때는 이렇게 판단하면 좋습니다.
정말 어떤 호출에서는 그 값을 생략할 계획인가?
생략되었을 때의 의미가 분명한가?
차라리 기본값이나 다른 구조가 더 읽기 쉽지 않은가?
콜백의 선택적 매개변수는 '유연함'도 주지만 '애매함'도 함께 데려옵니다. 초보자일수록 남발하지 않는 편이 좋습니다.
함수 시그니처 읽기 연습
이제 함수 시그니처를 실제로 읽어 봅시다.
function savePost(title: string, content: string, published?: boolean): void이 함수는 이렇게 읽습니다.
문자열 제목과 문자열 본문을 받고, 공개 여부를 뜻하는 불리언 값은 선택적으로 받으며, 반환값은 없다.
다음은 콜백이 들어간 경우입니다.
function fetchUserName(id: number, onSuccess: (name: string) => void): void이 함수는 이렇게 읽습니다.
숫자 아이디를 받고, 사용자 이름 문자열을 전달받는 콜백 함수를 하나 받으며, 함수 자체는 아무 값도 반환하지 않는다.
조금 더 복잡한 것도 읽어 볼 수 있습니다.
type Formatter = (value: string, upper?: boolean) => string;이 타입은 이렇게 읽으면 됩니다.
문자열 하나를 받고, 대문자 변환 여부는 선택적으로 받으며, 최종적으로 문자열을 반환하는 함수 타입.
함수 시그니처를 읽을 때는 순서를 정해 두면 쉽습니다.
무엇을 받는가?
무엇이 선택 사항인가?
무엇을 돌려주는가?
함수 말고 다른 함수를 받는가?
이 네 가지만 보이면 대부분의 함수는 겁먹지 않고 읽을 수 있습니다.
좋은 함수 설계는 좋은 질문에서 나온다
TypeScript 문법을 아는 것과 함수를 잘 설계하는 것은 조금 다릅니다. 문법은 도구이고, 설계는 판단입니다.
함수를 만들기 전에 스스로에게 이런 질문을 해 보세요.
이 함수가 꼭 받아야 하는 값은 무엇인가?
빠져도 되는 값은 무엇인가?
결과를 반환하는 편이 좋은가, 아니면 콜백으로 넘기는 편이 좋은가?
반환 타입을 보면 이 함수의 목적이 바로 드러나는가?
예를 들어, 이런 함수는 조금 아쉽습니다.
function doStuff(a: string, b?: number, c?: boolean): any {
// ...
}이 코드는 동작할 수는 있어도, 읽는 사람 입장에서는 안개가 낀 것처럼 흐릿합니다. 이름도 모호하고, 반환값도 any이고, 선택적 값도 많습니다.
반면 이런 함수는 의도가 더 잘 드러납니다.
function createNickname(name: string, maxLength: number = 8): string {
return name.slice(0, maxLength);
}이 함수는 이름만 봐도 목적이 보이고, 매개변수도 이해되고, 반환값도 분명합니다.
좋은 TypeScript 코드는 대단한 고급 타입보다 먼저, 이런 선명한 함수에서 출발합니다.
작은 정리: 오늘 꼭 챙겨 갈 것
이 장의 핵심은 문법 암기가 아닙니다. 함수의 모양을 읽고, 설명할 수 있는 감각입니다.
함수 시그니처는 입력과 출력을 보여 주는 계약서다.
매개변수 타입은 함수가 무엇을 받아야 하는지 명확하게 만든다.
반환 타입은 함수가 무엇을 약속하는지 보여 준다.
선택적 매개변수와 기본값 매개변수는 비슷해 보여도 역할이 다르다.
콜백은 함수를 값처럼 전달하는 방식이며, 타입을 붙이면 훨씬 안전해진다.
함수를 잘 만든다는 말은 결국 이런 뜻입니다. 이 함수가 어떤 일을 하고, 어떻게 써야 하는지 한눈에 보이게 만드는 것.
TypeScript는 그 일을 도와주는 아주 성실한 안내원입니다. 다만 안내판을 세우는 일은 여전히 개발자의 몫입니다.
참고
1TypeScript Handbook - More on Functions
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.