Kim-Baek 개발자 이야기

Kotlin Extension Functions & String 처리 완벽 가이드 | 기존 클래스 확장하고 문자열 마스터하기 🎨 본문

개발/java basic

Kotlin Extension Functions & String 처리 완벽 가이드 | 기존 클래스 확장하고 문자열 마스터하기 🎨

김백개발자 2025. 12. 3. 12:41
반응형

이 글을 읽으면: Kotlin의 강력한 Extension Functions로 기존 클래스에 새로운 기능을 추가하는 방법과, String Templates, Multi-line String, 정규식 등 실무에서 바로 쓸 수 있는 문자열 처리 기법을 완벽하게 배울 수 있습니다.


📌 목차

  1. Extension Functions란? - 마법의 확장 기능
  2. Custom Extension Functions 만들기
  3. String Templates - 변수 삽입의 정석
  4. Multi-line String - 복잡한 텍스트 다루기
  5. 실전 String 처리 기법
  6. Extension Functions 고급 활용

1. Extension Functions란? - 마법의 확장 기능

😫 Java의 불편함 - 유틸리티 클래스 지옥

// Java - 유틸리티 클래스 필요
public class StringUtils {
    public static boolean isValidEmail(String email) {
        return email != null && email.contains("@");
    }
    
    public static String toTitleCase(String text) {
        return text.substring(0, 1).toUpperCase() + 
               text.substring(1).toLowerCase();
    }
}

// 사용
String email = "user@example.com";
boolean valid = StringUtils.isValidEmail(email);  // 불편함
String title = StringUtils.toTitleCase("hello");  // 가독성 낮음

✨ Kotlin의 해결책 - Extension Functions

// Kotlin - Extension Functions
fun String.isValidEmail(): Boolean {
    return this.contains("@")
}

fun String.toTitleCase(): String {
    return this.replaceFirstChar { it.uppercase() }
}

// 사용
val email = "user@example.com"
val valid = email.isValidEmail()  // 직관적!
val title = "hello".toTitleCase()  // 읽기 쉬움!

장점:

  • ✅ 마치 원래 있던 메서드처럼 사용
  • ✅ 가독성 향상 (메서드 체이닝 가능)
  • ✅ IDE 자동완성 지원
  • ✅ 기존 클래스 수정 불필요

2. Custom Extension Functions 만들기

2.1 기본 문법

// 기본 형태
fun 타입.함수명(파라미터): 반환타입 {
    // this는 확장 대상 객체
    return 결과
}

// 예제
fun Int.isEven(): Boolean {
    return this % 2 == 0
}

// 사용
println(4.isEven())   // true
println(7.isEven())   // false

2.2 실전 예제 1 - Collection 확장

// filterIf: 조건부 필터링
fun <T> Iterable<T>.filterIf(
    condition: Boolean, 
    predicate: (T) -> Boolean
): Iterable<T> {
    return if (condition) this.filter(predicate) else this
}

// 사용 예시
data class ServiceCode(val code: String, val value: String)

val serviceCodes = listOf(
    ServiceCode("planner", "기획자"),
    ServiceCode("dev", "개발자"),
    ServiceCode("designer", "디자이너")
)

val catalogType = CatalogType.TECH

// TECH 타입일 때만 planner 제외
val filtered = serviceCodes.filterIf(catalogType == CatalogType.TECH) { 
    !it.code.startsWith("planner") 
}

왜 유용한가?

  • 조건에 따라 필터링을 적용하거나 건너뛸 수 있음
  • if-else 분기 없이 체이닝 가능
  • 코드 가독성 향상

2.3 실전 예제 2 - Map 확장

// filterValueNotNull: Map의 null value 제거
fun <T, U> Map<T, U?>.filterValueNotNull(): Map<T, U> {
    return this.mapNotNull { (key, value) -> 
        value?.let { key to it } 
    }.toMap()
}

// 사용 예시
val serviceMap = mapOf(
    "SERVICE-001" to "Active",
    "SERVICE-002" to null,
    "SERVICE-003" to "Inactive",
    "SERVICE-004" to null
)

val cleanedMap = serviceMap.filterValueNotNull()
println(cleanedMap)
// {SERVICE-001=Active, SERVICE-003=Inactive}

2.4 Nullable 타입 확장

// orEmpty: Nullable String을 빈 문자열로
fun String?.orEmpty(): String {
    return this ?: ""
}

// isNullOrBlank 개선 버전
fun String?.isNotNullAndNotBlank(): Boolean {
    return !this.isNullOrBlank()
}

// 사용
val name: String? = null
println(name.orEmpty())  // ""

val text: String? = "  "
println(text.isNotNullAndNotBlank())  // false

2.5 실무 예제 - List 확장

// 빈 리스트를 null로 변환
fun <T> List<T>.nullIfEmpty(): List<T>? {
    return if (this.isEmpty()) null else this
}

// 중복 제거하면서 순서 유지
fun <T> List<T>.distinctPreserveOrder(): List<T> {
    return this.toSet().toList()
}

// 안전한 첫 번째 요소 가져오기
fun <T> List<T>.firstOrDefault(default: T): T {
    return this.firstOrNull() ?: default
}

// 사용
val users = emptyList<User>()
val result = users.nullIfEmpty()  // null

val numbers = listOf(1, 2, 2, 3, 3, 3, 4)
val distinct = numbers.distinctPreserveOrder()  // [1, 2, 3, 4]

val names = listOf<String>()
val first = names.firstOrDefault("Unknown")  // "Unknown"

3. String Templates - 변수 삽입의 정석

3.1 기본 사용법

// $변수명
val name = "김철수"
val message = "안녕하세요, $name님!"
println(message)  // "안녕하세요, 김철수님!"

// ${표현식}
val age = 30
val intro = "저는 ${age}세이고, 내년에는 ${age + 1}세가 됩니다."
println(intro)  // "저는 30세이고, 내년에는 31세가 됩니다."

3.2 Java vs Kotlin 비교

Java:

// Java - 문자열 연결 지옥
String name = "김철수";
int age = 30;
String message = "이름: " + name + ", 나이: " + age + "세";

// 또는 String.format
String message = String.format("이름: %s, 나이: %d세", name, age);

Kotlin:

// Kotlin - 간결하고 명확
val name = "김철수"
val age = 30
val message = "이름: $name, 나이: ${age}세"

3.3 실전 예제 - 에러 메시지

// 실무 코드: 동적 에러 메시지
data class ItgService(
    val itgCatalogId: String,
    val name: String,
    val status: String
)

fun validateService(service: ItgService) {
    if (service.status != "CREATED") {
        throw IllegalStateException(
            "서비스가 아직 생성되지 않은 상태에서 삭제 요청됨: ${service.itgCatalogId}"
        )
    }
}

// 사용자 친화적 메시지
fun getUserMessage(userName: String, count: Int): String {
    return "$userName님, 총 ${count}개의 알림이 있습니다."
}

3.4 복잡한 표현식

data class User(val name: String, val age: Int)

val user = User("김철수", 30)

// 메서드 호출
val message1 = "이름 길이: ${user.name.length}자"

// 조건식
val message2 = "${user.name}님은 ${if (user.age >= 18) "성인" else "미성년자"}입니다."

// 함수 호출
fun getGreeting(name: String) = "안녕하세요, $name님!"
val message3 = "${getGreeting(user.name)} 오늘 날씨가 좋네요."

println(message1)  // "이름 길이: 3자"
println(message2)  // "김철수님은 성인입니다."
println(message3)  // "안녕하세요, 김철수님! 오늘 날씨가 좋네요."

3.5 특수 문자 이스케이프

// $ 문자 자체를 출력하고 싶을 때
val price = 1000
val message = "가격: \$${price}원"  // "가격: $1000원"

// 백슬래시
val path = "C:\\Users\\Documents"

// 따옴표
val quote = "그는 \"안녕하세요\"라고 말했다."

4. Multi-line String - 복잡한 텍스트 다루기

4.1 Triple Quotes (""")

// 기본 사용
val text = """
    첫 번째 줄
    두 번째 줄
    세 번째 줄
"""
println(text)
// 결과:
//     첫 번째 줄
//     두 번째 줄
//     세 번째 줄

4.2 trimIndent() - 들여쓰기 제거

// 들여쓰기 자동 제거
val html = """
    <html>
        <body>
            <h1>제목</h1>
        </body>
    </html>
""".trimIndent()

println(html)
// 결과:
// <html>
//     <body>
//         <h1>제목</h1>
//     </body>
// </html>

4.3 실전 예제 - Slack 메시지

// 실무 코드: Slack 메시지 포맷팅
data class ItgServiceCreateRequest(
    val name: String,
    val admin: String,
    val member: List<String>,
    val description: String
)

fun createSlackMessage(request: ItgServiceCreateRequest): String {
    val memberMentions = request.member.joinToString(" ") { "@$it" }
    
    return """
        > *❏ 서비스 이름(필수)*
        > `특수문자 제외, 50자 이내`
        > : ${request.name}
        
        > *❏ 서비스 설명(필수)*
        > `100자 이내`
        > : ${request.description}
        
        > *❏ 담당자(필수)*
        > : @${request.admin}
        
        > *❏ 멤버*
        > : $memberMentions
    """.trimIndent()
}

// 사용
val request = ItgServiceCreateRequest(
    name = "새로운 서비스",
    admin = "admin01",
    member = listOf("user01", "user02", "user03"),
    description = "서비스 설명입니다"
)

println(createSlackMessage(request))

출력 결과:

> *❏ 서비스 이름(필수)*
> `특수문자 제외, 50자 이내`
> : 새로운 서비스

> *❏ 서비스 설명(필수)*
> `100자 이내`
> : 서비스 설명입니다

> *❏ 담당자(필수)*
> : @admin01

> *❏ 멤버*
> : @user01 @user02 @user03

4.4 trimMargin() - 커스텀 마진

// | 기준으로 들여쓰기 제거
val sql = """
    |SELECT *
    |FROM users
    |WHERE age >= 18
    |ORDER BY name
""".trimMargin()

println(sql)
// SELECT *
// FROM users
// WHERE age >= 18
// ORDER BY name

// 다른 마진 문자 사용
val text = """
    #첫 줄
    #두 번째 줄
    #세 번째 줄
""".trimMargin("#")

4.5 실전 예제 - SQL 쿼리

// 실무 코드: 동적 SQL 생성
fun buildUserQuery(
    minAge: Int?,
    city: String?,
    isActive: Boolean?
): String {
    val conditions = mutableListOf<String>()
    
    minAge?.let { conditions.add("age >= $it") }
    city?.let { conditions.add("city = '$it'") }
    isActive?.let { conditions.add("is_active = $it") }
    
    val whereClause = if (conditions.isNotEmpty()) {
        "WHERE " + conditions.joinToString(" AND ")
    } else {
        ""
    }
    
    return """
        SELECT id, name, email, age, city
        FROM users
        $whereClause
        ORDER BY created_at DESC
        LIMIT 100
    """.trimIndent()
}

// 사용
println(buildUserQuery(minAge = 18, city = "서울", isActive = true))
// SELECT id, name, email, age, city
// FROM users
// WHERE age >= 18 AND city = '서울' AND is_active = true
// ORDER BY created_at DESC
// LIMIT 100

5. 실전 String 처리 기법

5.1 문자열 분리 및 결합

// split - 문자열 분리
val csv = "apple,banana,cherry"
val fruits = csv.split(",")
println(fruits)  // [apple, banana, cherry]

// joinToString - 리스트를 문자열로
val numbers = listOf(1, 2, 3, 4, 5)
val result1 = numbers.joinToString()
println(result1)  // "1, 2, 3, 4, 5"

val result2 = numbers.joinToString(separator = " | ")
println(result2)  // "1 | 2 | 3 | 4 | 5"

val result3 = numbers.joinToString(
    separator = ", ",
    prefix = "[",
    postfix = "]"
)
println(result3)  // "[1, 2, 3, 4, 5]"

// transform과 함께
data class User(val name: String, val age: Int)
val users = listOf(
    User("김철수", 30),
    User("이영희", 25)
)

val userList = users.joinToString(", ") { "${it.name}(${it.age}세)" }
println(userList)  // "김철수(30세), 이영희(25세)"

5.2 실전 예제 - 멘션 생성

// 실무 코드: Slack/Agit 멘션 문자열 생성
fun createMentions(userIds: List<String>?, prefix: String = "@"): String {
    return userIds
        ?.takeIf { it.isNotEmpty() }
        ?.joinToString(" ") { "$prefix$it" }
        ?: ""
}

// 사용
val planners = listOf("planner01", "planner02", "planner03")
val plannerMentions = createMentions(planners)
println(plannerMentions)  // "@planner01 @planner02 @planner03"

val emptyList = emptyList<String>()
val emptyMentions = createMentions(emptyList)
println(emptyMentions)  // ""

val nullList: List<String>? = null
val nullMentions = createMentions(nullList)
println(nullMentions)  // ""

5.3 문자열 검색 및 치환

// contains - 포함 여부
val text = "Hello, World!"
println(text.contains("World"))  // true
println(text.contains("world"))  // false
println(text.contains("world", ignoreCase = true))  // true

// startsWith, endsWith
println(text.startsWith("Hello"))  // true
println(text.endsWith("!"))  // true

// replace - 치환
val original = "Hello, World!"
val replaced = original.replace("World", "Kotlin")
println(replaced)  // "Hello, Kotlin!"

// 정규식으로 치환
val phoneNumber = "010-1234-5678"
val cleaned = phoneNumber.replace(Regex("[^0-9]"), "")
println(cleaned)  // "01012345678"

5.4 실전 예제 - 입력값 정제

// 실무 코드: 사용자 입력 정제
fun sanitizeUserInput(input: String?): String? {
    return input
        ?.trim()  // 앞뒤 공백 제거
        ?.takeIf { it.isNotBlank() }  // 빈 문자열 제외
        ?.replace(Regex("\\s+"), " ")  // 연속 공백을 하나로
        ?.take(100)  // 최대 100자
}

// 사용
println(sanitizeUserInput("  Hello   World  "))  // "Hello World"
println(sanitizeUserInput("   "))  // null
println(sanitizeUserInput(null))  // null

5.5 문자열 포맷팅

// padStart, padEnd - 패딩
val number = "123"
println(number.padStart(5, '0'))  // "00123"
println(number.padEnd(5, '0'))  // "12300"

// take, takeLast - 부분 문자열
val text = "Hello, World!"
println(text.take(5))  // "Hello"
println(text.takeLast(6))  // "World!"

// drop, dropLast - 제거
println(text.drop(7))  // "World!"
println(text.dropLast(1))  // "Hello, World"

5.6 실전 예제 - 파일명 처리

// 실무 코드: 안전한 파일명 생성
fun createSafeFileName(input: String, maxLength: Int = 50): String {
    return input
        .replace(Regex("[^a-zA-Z0-9가-힣._-]"), "_")  // 특수문자 제거
        .take(maxLength)  // 길이 제한
        .trim('_')  // 앞뒤 언더스코어 제거
}

// 사용
println(createSafeFileName("My File@2024!.txt"))  // "My_File_2024_.txt"
println(createSafeFileName("프로젝트 문서 (최종).docx"))  // "프로젝트_문서__최종_.docx"

6. Extension Functions 고급 활용

6.1 Generic Extension Functions

// 제네릭 타입 확장
fun <T> T.print(): T {
    println(this)
    return this
}

// 사용 - 체이닝 가능
val result = listOf(1, 2, 3)
    .map { it * 2 }
    .print()  // [2, 4, 6] 출력
    .filter { it > 3 }
    .print()  // [4, 6] 출력

// Nullable 제네릭 확장
fun <T> T?.orThrow(message: String): T {
    return this ?: throw IllegalArgumentException(message)
}

// 사용
val user: User? = findUser(123)
val validUser = user.orThrow("사용자를 찾을 수 없습니다")

6.2 Receiver가 있는 Lambda

// buildString - StringBuilder를 쉽게
val html = buildString {
    append("<html>")
    append("<body>")
    append("<h1>제목</h1>")
    append("</body>")
    append("</html>")
}

// 커스텀 버전
fun buildCsv(builder: StringBuilder.() -> Unit): String {
    return StringBuilder().apply(builder).toString()
}

// 사용
val csv = buildCsv {
    append("Name,Age,City\n")
    append("김철수,30,서울\n")
    append("이영희,25,부산\n")
}
println(csv)

6.3 실전 예제 - DSL 스타일

// 실무 코드: HTML 빌더
class HtmlBuilder {
    private val content = StringBuilder()
    
    fun tag(name: String, body: HtmlBuilder.() -> Unit) {
        content.append("<$name>")
        this.body()
        content.append("</$name>")
    }
    
    fun text(value: String) {
        content.append(value)
    }
    
    override fun toString() = content.toString()
}

fun html(builder: HtmlBuilder.() -> Unit): String {
    return HtmlBuilder().apply(builder).toString()
}

// 사용
val page = html {
    tag("html") {
        tag("body") {
            tag("h1") {
                text("환영합니다")
            }
            tag("p") {
                text("Kotlin DSL 예제입니다")
            }
        }
    }
}
println(page)
// <html><body><h1>환영합니다</h1><p>Kotlin DSL 예제입니다</p></body></html>

6.4 Infix Functions

// infix - 중위 표기법
infix fun String.shouldBe(expected: String) {
    if (this != expected) {
        throw AssertionError("Expected: $expected, but was: $this")
    }
}

// 사용
"hello" shouldBe "hello"  // OK
// "hello" shouldBe "world"  // AssertionError

// 실전 예제 - 테스트 코드
infix fun <T> T.shouldEqual(expected: T) {
    if (this != expected) {
        throw AssertionError("Expected $expected but got $this")
    }
}

// 사용
val result = calculateSum(2, 3)
result shouldEqual 5

6.5 Extension Properties

// Extension Property
val String.lastChar: Char
    get() = this[this.length - 1]

val List<Int>.sumValue: Int
    get() = this.sum()

// 사용
println("Hello".lastChar)  // 'o'
println(listOf(1, 2, 3).sumValue)  // 6

// 실전 예제
val String.isValidEmail: Boolean
    get() = this.contains("@") && this.contains(".")

val String?.isNullOrEmpty: Boolean
    get() = this == null || this.isEmpty()

// 사용
println("user@example.com".isValidEmail)  // true
println("invalid".isValidEmail)  // false

7. 정규식 활용

7.1 기본 정규식

// Regex 객체 생성
val emailPattern = Regex("[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z]+")

// matches - 전체 매칭
println(emailPattern.matches("user@example.com"))  // true
println(emailPattern.matches("invalid"))  // false

// containsMatchIn - 부분 매칭
val text = "연락처: user@example.com으로 보내주세요"
println(emailPattern.containsMatchIn(text))  // true

// find - 첫 번째 매칭 찾기
val match = emailPattern.find(text)
println(match?.value)  // "user@example.com"

7.2 실전 예제 - 이메일 추출

// 실무 코드: 텍스트에서 모든 이메일 추출
fun extractEmails(text: String): List<String> {
    val emailPattern = Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")
    return emailPattern.findAll(text)
        .map { it.value }
        .toList()
}

// 사용
val message = """
    담당자: john@company.com
    참조: jane@company.com, bob@partner.com
""".trimIndent()

val emails = extractEmails(message)
println(emails)  // [john@company.com, jane@company.com, bob@partner.com]

7.3 정규식으로 치환

// 전화번호 포맷 변경
val phone = "010-1234-5678"
val formatted = phone.replace(Regex("(\\d{3})-(\\d{4})-(\\d{4})"), "($1) $2-$3")
println(formatted)  // "(010) 1234-5678"

// 민감 정보 마스킹
fun maskEmail(email: String): String {
    return email.replace(Regex("(?<=.{2}).(?=.*@)"), "*")
}

println(maskEmail("user@example.com"))  // "us**@example.com"

8. 자주 하는 실수와 해결법

❌ 실수 1: Extension Function 범위

// 나쁜 예 - 너무 광범위한 확장
fun Any.toJson(): String {
    // 모든 객체에 toJson 추가 (충돌 위험)
}

// 좋은 예 - 구체적인 타입에만 확장
fun User.toJson(): String {
    return """{"name":"${this.name}","age":${this.age}}"""
}

❌ 실수 2: String Template의 중괄호 생략

// 나쁜 예 - 모호한 경우
val name = "Kim"
val message = "$name님"  // OK

val user = User("Kim", 30)
// val message = "$user.name님"  // 컴파일 에러!

// 좋은 예 - 항상 중괄호 사용
val message = "${user.name}님"  // OK

❌ 실수 3: Multi-line String 들여쓰기

// 나쁜 예 - 들여쓰기가 결과에 포함됨
fun getHtml(): String {
    return """
        <html>
            <body>
            </body>
        </html>
    """
}
// 결과에 공백 포함됨!

// 좋은 예 - trimIndent() 사용
fun getHtml(): String {
    return """
        <html>
            <body>
            </body>
        </html>
    """.trimIndent()
}

💡 핵심 요약 (10초 복습)

// 1. Extension Functions
fun String.isEmail() = this.contains("@")
"user@example.com".isEmail()  // true

// 2. String Templates
val name = "Kim"
val message = "Hello, $name!"  // "Hello, Kim!"
val calc = "1 + 1 = ${1 + 1}"  // "1 + 1 = 2"

// 3. Multi-line String
val text = """
    Line 1
    Line 2
""".trimIndent()

// 4. Collection Extension
fun <T> List<T>.secondOrNull() = this.getOrNull(1)
listOf(1, 2, 3).secondOrNull()  // 2

📊 Extension Functions 사용 가이드

상황 사용 여부 이유

자주 사용하는 유틸리티 ✅ 권장 가독성 향상
도메인 특화 로직 ✅ 권장 명확한 의도
복잡한 비즈니스 로직 ❌ 비권장 Service 계층 사용
외부 라이브러리 확장 ✅ 권장 기존 클래스 수정 불가
너무 범용적인 확장 ❌ 비권장 충돌 위험

❓ FAQ (자주 묻는 질문)

Q1. Extension Functions는 성능에 영향을 주나요?

아니요. 컴파일 시 static 메서드로 변환되므로 성능 차이가 없습니다.

Q2. Extension Functions를 어디에 선언해야 하나요?

전역 함수로 선언하거나, object나 companion object 안에 선언하세요. 파일명은 보통 Extensions.kt를 사용합니다.

Q3. String Template에서 $를 출력하려면?

\$로 이스케이프하세요. 예: "가격: \$${price}"

Q4. Multi-line String에서 들여쓰기를 제거하려면?

trimIndent() 또는 trimMargin()을 사용하세요.

Q5. Extension Functions vs Utility Class?

Extension Functions가 더 직관적이고 IDE 지원이 좋습니다. Kotlin에서는 Extension Functions를 권장합니다.


🎯 실전 과제

과제 1: Email 유효성 검사

// TODO: Email Extension Function 작성
// - @와 .이 모두 포함되어야 함
// - @는 .보다 앞에 있어야 함
// - 최소 5자 이상

fun String.isValidEmail(): Boolean {
    // 여기에 코드 작성
}

// 테스트
println("user@example.com".isValidEmail())  // true
println("invalid".isValidEmail())  // false
println("@example.com".isValidEmail())  // false

과제 2: 문자열 포맷팅

// TODO: 전화번호 포맷팅 Extension Function
// "01012345678" → "010-1234-5678"

fun String.formatPhoneNumber(): String {
    // 여기에 코드 작성
}

// 테스트
println("01012345678".formatPhoneNumber())  // "010-1234-5678"

과제 3: Slack 메시지 빌더

// TODO: Multi-line String으로 Slack 메시지 생성
// 파라미터: title, description, mentions (List<String>)

fun buildSlackMessage(
    title: String,
    description: String,
    mentions: List<String>
): String {
    // 여기에 코드 작성
    // 힌트: """ """ 사용, trimIndent() 사용
}

정답과 해설은 댓글로! 😊


📢 마치며

Kotlin의 Extension Functions와 String 처리를 완벽하게 마스터하셨습니다!

이제 여러분은:

  • ✅ 기존 클래스에 새로운 기능을 추가할 수 있습니다
  • ✅ String Templates로 가독성 높은 코드를 작성할 수 있습니다
  • ✅ Multi-line String으로 복잡한 텍스트를 다룰 수 있습니다
  • ✅ 실무에서 바로 사용 가능한 Extension Functions를 만들 수 있습니다

다음 글 예고: 다음에는 Kotlin의 제어 구조(when, if expression)와 타입 시스템(Enum, Sealed Class, Companion Object)을 다룹니다. 더욱 강력하고 안전한 코드를 작성하는 방법을 배워보세요!

궁금한 점은 댓글로 남겨주세요! 💬

반응형
Comments