CodeGym /행동 /Kotlin SELF /함수에서 2–3개의 값을 반환하기

함수에서 2–3개의 값을 반환하기

Kotlin SELF
레벨 10, 레슨 3
사용 가능

1. Pair/Triple과 결과 컨트랙트

솔직히 말하면, 현실에서 유용한 함수 대부분은 약간 “욕심쟁이”입니다. 하나가 아니라 여러 값을 한 번에 반환하고 싶어 하는 경우가 많죠. 예를 들어 문자열을 파싱해서 결과와 문자열의 “나머지”를 함께 반환하고 싶을 수 있습니다. 또는 최소값과 최대값을 함께 계산했을 수도 있고, 좌표를 찾은 뒤 그 좌표로부터 뭔가를 추가로 계산했을 수도 있습니다. 그런데 여기서 충돌이 생깁니다. Kotlin에서 함수는 항상 하나의 값만 반환합니다.

하지만 이미 눈치채셨겠지만, 이 충돌은 비극이 아닙니다. 반환되는 “하나의 값”은 여러 조각을 담은 컨테이너일 수 있습니다. Pair와 Triple은 바로 이런 용도로 존재합니다. 2–3개의 값을 “한 묶음으로 묶어” 한 번에 반환하게 해 주는 것이죠.

“숫자 하나”가 아니라 컨테이너를 반환한다

이전에는 반환값이 대개 Int, String, Boolean 같은 것이라고 익숙해졌을 겁니다. 하지만 Kotlin은 “프리미티브” 같은 것에 여러분을 가두지 않습니다. 반환값은 Pair일 수도 있고, Triple일 수도 있습니다.

예를 들어, 두 수의 최소값과 최대값을 계산하는 함수는 Pair<Int, Int>를 반환할 수 있습니다. 그리고 컨트랙트로 “첫 번째는 최소값, 두 번째는 최대값”이라고 약속하는 겁니다:

fun minMax(a: Int, b: Int): Pair<Int, Int> {
    return if (a < b) a to b else b to a
}

fun main() {
    val result = minMax(10, 3)
    println(result.first)   // 3
    println(result.second)  // 10
}

여기서 a to b는 인픽스(infix) 형태로 Pair(a, b)를 생성하는 표현(즉 “값 사이에” 쓰는 형태)이고, 표준 라이브러리에 있는 to 함수를 그대로 호출하는 것입니다.

컴파일러 관점에서는 완전히 정직합니다. 함수는 하나의 값, 즉 Pair 객체를 반환했습니다. 하지만 우리 사람 입장에서는 “하나의 패키지에 들어 있는 두 개의 연결된 값”입니다.

결과 컨트랙트: 가장 중요한 약속

Pair나 Triple을 반환할 때, 호출하는 코드에게 이렇게 말하는 셈입니다. “상자를 하나 줄게. 칸이 2개/3개 있고, 어떤 칸에 무엇이 들어 있는지 너는 알고 있어야 해.” 이것이 바로 결과 컨트랙트입니다. 컴포넌트의 의미와 순서에 대한 합의죠.

여기서 가장 흔한 함정은 이겁니다: first/second/third는 기술적인 이름일 뿐입니다. 도메인 의미를 담고 있지 않아요. 의미는 오직 컨텍스트에서만 생깁니다. 변수를 어떻게 이름 붙였는지, 결과를 어디서 어떻게 풀어냈는지, 함수를 어떻게 문서화했는지에 따라 의미가 결정됩니다.

대비를 보세요:

fun minMax(a: Int, b: Int): Pair<Int, Int> = if (a < b) a to b else b to a

fun main() {
    val x = minMax(10, 3)
    println(x.first)   // 3
    println(x.second)  // 10
}

동작은 하지만 읽기는 애매합니다: “x.first가 뭐지? 최소값? 범위의 시작? x좌표?” 같은 생각이 들죠.

이제 똑같은데, 이름을 제대로 붙여 봅시다:

fun minMax(a: Int, b: Int): Pair<Int, Int> = if (a < b) a to b else b to a

fun main() {
    val (min, max) = minMax(10, 3)
    println("min=$min max=$max")  // min=3 max=10
}

기술적으로는 완전히 같습니다. 하지만 이제 결과 컨트랙트가 “바깥으로 끌려 나와” 명확해졌습니다. min과 max가 사용 지점에 바로 적혀 있죠. 거의 주석 같지만, 이건 컴파일되는 주석입니다.

머릿속에 유지하기 쉽게 하려면, Pair/Triple 컨트랙트를 작은 표처럼 머릿속(또는 함수 위 주석)에 떠올리면 좋습니다:

함수가 반환하는 값 first의 의미 second의 의미 third의 의미
Pair<Int, Int>
(범위)
from
to
—
Pair<Int, Int>
(min/max)
min
max
—
Triple<Int, Int, Int>
(통계)
min
max
sum

그리고 네, 순서도 컨트랙트의 일부입니다. 한 번 “first는 min”이라고 결정했다면, 그다음부터도 계속 그렇게 써야 합니다. “오늘은 min/max, 내일은 max/min (기분이 바뀌어서)” 같은 건 안 됩니다.

디스트럭처링은 호출 지점의 문서화

많은 사람이 디스트럭처링을 “더 짧게 쓰기 위해서”라고 생각합니다. 하지만 학습용 코드나 팀 코드에서는 종종 다른 의미가 큽니다: 더 명확하게 만들기 위해서입니다.

다음처럼 쓸 때:

val (min, max) = minMax(10, 3)

여러분은 동시에 두 가지를 합니다. 첫째, Pair를 풀어냅니다. 둘째, 컨트랙트를 문서화합니다: first에는 최소값, second에는 최대값이 들어 있다고요.

여기서 유용한 습관이 하나 있습니다: 함수에서 Pair/Triple

2. 실습: 문자열을 파싱해서 2–3개의 값을 반환하기

학습용 콘솔 “프로필 미니 파서”를 계속 만들어 봅시다. 아이디어는 간단합니다. 사용자가 다음 형태의 문자열을 입력합니다:

"Ada Lovelace, 36" 그리고 우리는 이름, 성, 나이를 각각 따로 얻고 싶습니다.

단계 1: 쉼표로 문자열을 나누고 두 부분을 반환하기

먼저 아주 작은 유용한 함수를 하나 뽑아 봅시다: “첫 번째 쉼표를 기준으로 문자열을 나눠라.”

컨트랙트: first는 왼쪽 부분, second는 오른쪽 부분(둘 다 이미 trim() 적용됨).

fun splitByComma(text: String): Pair<String, String> {
    val commaIndex = text.indexOf(',')
    if (commaIndex == -1) return text.trim() to ""

    val left = text.substring(0, commaIndex).trim()
    val right = text.substring(commaIndex + 1).trim()
    return left to right
}

fun main() {
    val (a, b) = splitByComma("  Ada Lovelace, 36 ")
    println("a='$a' b='$b'") // a='Ada Lovelace' b='36'
}

주의할 점: 일을 복잡하게 만들지 않습니다. 쉼표가 없으면 오른쪽 부분을 빈 문자열로 반환합니다. 이것도 컨트랙트입니다. 단지 “나쁜 입력에 대한 컨트랙트”일 뿐이죠.

단계 2: 나이를 파싱하고 “값 + 메시지”를 반환하기

가끔은 데이터뿐 아니라 “무슨 일이 일어났는지”에 대한 설명도 함께 반환하는 것이 유용합니다. 예: 나이 파싱 성공 — 좋습니다. 실패 — null과 함께 “나이가 숫자가 아님” 같은 텍스트를 반환합니다.

컨트랙트: first는 Int? (숫자 또는 null), second는 사용자/로그용 메시지.

fun parseAge(text: String): Pair<Int?, String> {
    val trimmed = text.trim()
    if (trimmed.isEmpty()) return null to "나이가 입력되지 않았습니다"

    val age = trimmed.toIntOrNull()
    if (age == null) return null to "나이는 숫자여야 합니다"

    if (age < 0) return null to "나이는 음수일 수 없습니다"
    return age to "OK"
}

fun main() {
    val (age, message) = parseAge(" 36 ")
    println("age=$age message='$message'") // age=36 message='OK'
}

여기서 컨트랙트에 대한 중요한 포인트: 우리는 "OK"가 성공을 의미한다고 합의했습니다. 문자열 대신 Boolean을 반환할 수도 있었겠죠(예: Pair<Boolean, Int>). 하지만 학습 예제에서는 문자열이 더 편합니다. “왜 실패했는지”를 바로 볼 수 있으니까요.

단계 3: 모두 합치고 Triple을 반환하기

이제 세 개의 결과를 반환하는 함수를 만들어 봅시다: 이름, 성, 나이(나이는 파싱 실패 시 null일 수 있음).

컨트랙트: first는 firstName, second는 lastName, third는 ageOrNull.

복잡한 문자열 연산으로 빠지지 않기 위해, 성은 공백으로 찾겠습니다(첫 번째 공백이 이름과 성을 분리). 공백이 없으면 성은 빈 문자열이 됩니다.

fun parseNameAge(line: String): Triple<String, String, Int?> {
    val (fullName, ageText) = splitByComma(line)

    val spaceIndex = fullName.indexOf(' ')
    val firstName = if (spaceIndex == -1) fullName.trim() else fullName.substring(0, spaceIndex).trim()
    val lastName = if (spaceIndex == -1) "" else fullName.substring(spaceIndex + 1).trim()

    val (age, _) = parseAge(ageText)
    return Triple(firstName, lastName, age)
}

fun main() {
    val (first, last, age) = parseNameAge("Ada Lovelace, 36")
    println("first='$first' last='$last' age=$age") // first='Ada' last='Lovelace' age=36
}

네, 여기서는 Triple이 적절해 보입니다: “프로필”이라는 하나의 의미를 이루는 세 부분이니까요. 하지만 Triple 자체가 컨트랙트를 자동으로 이해하기 쉽게 만들어 주는 것은 아닙니다. 우리가 이해하기 쉽게 만든 겁니다: 결과를 a/b/c가 아니라 first/last/age로 풀어냈기 때문입니다.

미니 흐름도: 입력에서 사용까지

가끔은 전체 흐름을 머릿속에 고정하는 게 도움이 됩니다. 대략 이런 모습입니다:

flowchart TD
    A["문자열 입력 (readln)"] --> B["parseNameAge(line) -> Triple"]
    B --> C["val (first, last, age) = ..."]
    C --> D["로직에서 first/last/age 사용"]

바로 여기서 Pair/Triple이 멋지게 동작합니다: 함수는 객체 하나를 반환하지만, 호출 지점에서 즉시 “서랍별로 나눠 담는” 것이죠.

3. Pair/Triple이 가독성을 떨어뜨리기 시작할 때

처음에는 Pair가 선물처럼 느껴집니다: 코드가 줄고, 모든 게 컴팩트해 보이죠. 하지만 어느 순간부터 이 컴팩트함이 오히려 발목을 잡습니다.

흔한 증상: result.first 같은 걸 쓰고 있는데, 거기에 뭐가 들어 있는지 이미 기억이 안 납니다. 또는 변수 이름이 p, x, t, res처럼 늘어나면서, 코드를 고고학자처럼 읽게 됩니다: “이 벽에 새겨진 글의 저자는 대체 무슨 말을 하려 했지?”

더 분명한 빨간 신호도 있습니다: Pair/Triple을 반환해 놓고, 긴 주석으로 “first는 …, second는 …, third는 …”를 강제로 적어야만 남들이 이해합니다. 그건 이미 코드가 “이름 있는 결과”(필드가 의미 있게 이름 붙은 별도의 타입)를 원한다는 뜻입니다.

우리는 아직 자체 데이터 타입을 만들지는 않을 겁니다(코스의 다음 큰 블록 주제입니다). 하지만 이 아이디어는 기억해 두세요: Pair/Triple은 빠른 컨테이너로는 좋지만, 공식적인 데이터 포맷으로 남발하기에는 부적절합니다.

좋은 스타일 규칙

Pair/Triple로 2–3개의 값을 반환할 때는 “컨테이너에 다 우겨 넣기”가 아니라 “컨트랙트를 명확하게 만들기”를 목표로 사고해 보세요.

첫째, 컴포넌트의 순서를 컨트랙트의 일부로 고정하고 기분에 따라 바꾸지 마세요. minMax가 (min, max)를 반환하기로 했다면, 항상 그래야 합니다.

둘째, 거의 항상 호출 지점에서 결과를 디스트럭처링하고, 컴포넌트에 의미 있는 이름을 붙이세요. val (min, max) = ...는 val p = ... 다음에 p.first를 쓰는 것보다 훨씬 읽기 쉽습니다.

셋째, Triple을 “모든 걸 담는 자루”로 만들지 마세요. 세 값은 하나의 의미를 이루는 구성 요소여야 합니다. Triple(userName, taxRate, planetName)을 반환한다면, 그건 “컨트랙트”가 아니라 나쁜 농담의 시작입니다.

4. 흔한 실수

오류 1: 컴포넌트 순서가 들쭉날쭉함.
어느 날은 우연히 (max, min)을 반환해 놓고, 다른 곳에서는 (min, max)를 기대하기가 너무 쉽습니다. 겉보기엔 둘 다 똑같이 Pair<Int, Int>로 보입니다. 컴파일러는 구해주지 않습니다. 구해주는 건 오직 규율뿐입니다: 컨트랙트를 한 번 고정하고, 어디서나 지키세요.

오류 2: 디스트럭처링에서 의미 없는 이름 사용.
형식적으로 val (a, b) = minMax(10, 3)는 동작합니다. 하지만 하루만 지나도 여러분 스스로 a와 b를 “첫 번째 뭔가”, “두 번째 뭔가”로 보게 될 겁니다. 디스트럭처링의 강점은 컴포넌트에 의미 있는 이름을 붙일 수 있다는 점입니다: val (min, max) = ....

오류 3: Pair/Triple을 서로 관련 없는 데이터에 사용.
Pair는 “아무거나 다 싣는 2인용 카트”가 아니라, 서로 연관된 결과를 담는 컨테이너입니다. 값들이 하나의 의미의 구성 요소가 아니라면, 코드를 읽는 일이 추측 게임이 되고, 수정은 컴포넌트를 뒤바꿀 위험이 됩니다.

오류 4: 사실은 한 부분만 필요한데 Triple을 반환함.
초보자들이 “모든 상황을 위한 만능 결과”를 만들어 두고, 실제로는 80%의 위치에서 first만 쓰는 경우가 있습니다. 이건 결과 설계가 실제 사용과 맞지 않는다는 신호입니다. 필요한 것만 정확히 반환하거나, 컨트랙트를 다시 설계하세요.

오류 5: first/second/third 안에 의미를 “숨기려는” 시도.
가끔은 first가 “중요한 것”, second가 “덜 중요한 것”이라고 생각하고 싶어집니다. 하지만 Kotlin에서 그건 단지 “순서상 첫 번째”, “순서상 두 번째”일 뿐입니다. 컨테이너에 마법을 부여하지 마세요. 마법은 변수 이름과, 이해 가능한 함수 컨트랙트에 있어야 합니다.

1
과제
Kotlin SELF, 레벨 10, 레슨 3
잠금
간단 요약
간단 요약
1
과제
Kotlin SELF, 레벨 10, 레슨 3
잠금
올바른 범위
올바른 범위
1
과제
Kotlin SELF, 레벨 10, 레슨 3
잠금
세 가지 지표
세 가지 지표
1
과제
Kotlin SELF, 레벨 10, 레슨 3
잠금
상태와 함께 검색
상태와 함께 검색
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION