[iOS] 당연하게 쓰던 메서드에게 뒤통수 맞아보기

2026. 1. 6. 03:44·iOS

👋 들어가며

안녕하세요, 사이드 프로젝트 DDD 동아리의 iOS 운영진 은표입니다.

이번에 동아리에서 새로운 기수를 맞아 운영진들끼리 여러 팀을 구성하게 되었습니다. 저는 콘텐츠 브랜딩 팀에 소속되어 블로그 글을 작성하게 되었는데요. 마침 제가 첫 글이네요. 부담이 살짝 되기도 합니다 ㅋㅋㅋ

 

글 주제 선정에 대해서 참 고민이 많았습니다. 요즘 많은 LLM이 시장에 나와있고 궁금한 건 LLM을 통해 웬만해서 다 해결되는 시대라, 점점 가면 갈수록 질문을 할 기회도 적어지고 제가 속해있던 커뮤니티들도 점점 잠잠해지더라구요.

 

또, 뻔한 주제는 작성하기는 너무 싫었습니다. 클린 아키텍처 같은거요. 헥사고날이면 모를까 개념만으로 존재하고, 각자 구현 방법이 조금씩은 다르니까요. 그렇다고 제가 작성하는 코드가 그렇게까지 클린한 것 같지도 않기도 하구요.

 

클린한 코드를 작성하는 방법은 다른 멋진 개발자 분들께서 잘 작성해주시니, 저는 이번 글에서는 우리가 평소에 너무나 당연하게 쓰면서 위험이 존재하는 메서드를 하나 소개하고 개선하는 방법을 소개해볼까 합니다.

 

hackingwithswift 라는 플랫폼을 아시나요? 저는 예전에는 가끔 들어가서 아티클들을 읽었었는데요, 서두에 말씀드렸듯이 LLM이 발전함에 따라 서서히 안보게 되던 플랫폼 중 하나입니다. 오랜만에 갑자기 눈에 들어서 아티클을 하나 읽었는데 이 내용을 조금 더 살을 붙여 공유드리려고 합니다.


🐹 본론을 위한 밑밥깔기

서론이 꽤나 길었죠? 제가 생각보다 말이 많네요. 오랜만에 글이란걸 써봐서 그래요 이해해주세요

이제 어떻게 보면 너무 뻔하고 지루한 글을 작성할겁니다. iOS 개발을 조금 하셨거나, 컴퓨터 공학을 전공했다면 너무나 당연한 이야기를 하려고 합니다.

(Objective-C 이야기도 조금 섞여 있으니 아조씨들도 나가지말고 읽어주세요.)

 

iOS 개발자라면 보통 Swift를 사용하실건데요.

Swift를 사용한다면 어떻게 해서든 자연스럽게 String을 사용하게 됩니다.

여러분은 String이 어떤 구조를 가지고 있는지 아시나요?

Apple 공식 문서

공식 문서를 보면 character들의 모음을 String이라고 부르네요! 근데 Unicode 라는 단어가 나옵니다.

 

유니코드(Unicode)가 무엇이고 왜 생겨났을까요?

전 세계의 모든 문자를 컴퓨터에서 일관되게 표현하고 다룰 수 있도록 설계된 국제 표준

나라나 시스템마다 다른 문자 인코딩 방식(예: ASCII, EUC-KR)을 사용하는데요! (ASCII는 많이들 들어보셨죠?)

다른 시스템끼리 문서 같은걸 주고 받으면 글자가 깨지는 현상을 겪어보신 분들도 계실거에요. 이 문제를 해결하기 위해 유니코드가 생겨났어요!

 

그래서 유니코드로 어떻게 해결했냐면, 생각보다 간단합니다. 이 세상의 모든 문자, 기호, 이모티콘 등에 고유한 번호를 매겨주는겁니다.

예를 들어, 'A'는 U+0041, '가'는 U+AC00 과 같은 고유한 번호를요!

 

유니코드를 통해 우리는 한글, 영어, 일본어, 한자, 아랍어 등등등... 대부분의 문자를 하나의 체계로 통합하여 다룰 수 있게 됩니다.

이 고유한 번호들을 바이트로 변환하는 작업을 인코딩이라고 부르며 UTF-8, UTF-16과 같은 방법들이 있습니다. 그 중 UTF-8은 가장 널리 사용되는 방식이죠.

 

근데 왜 갑자기 유니코드 이야기를 이렇게 길게 했냐구요?

바로 오늘 이야기할 주제가 유니코드로 인하여 발생되는 문제이기 때문입니다.


👀 본론 시작

다들 프로젝트 진행하면서나 코딩테스트 문제를 풀 때 replacingOccurrences(of:with:) 메서드, 한번쯤은 써보거나 본 적 있으시죠? (없어도 있다고 해주세요.)

이 메서드는 크게 설명드릴게 없습니다. 문자열에서 특정 문자열을 찾아, 원하는 문자열로 바꾸고 싶을 때 사용하죠.

 

그럼 이 메서드를 사용할 때 어떤 문제가 발생할 수 있는지 한번 볼까요?

let vacation: String = "🇨🇦🇺🇸" // 캐나다와 미국 국기

여기 두 개의 국기 이모티콘으로 이루어진 문자열이 있습니다.

 

만약 이 문자열에서 있지도 않은 호주 국기(🇦🇺)를 니카라과 국기(🇳🇮)로 바꾸려고 시도하면 어떻게 될까요?

print(vacation.replacingOccurrences(of: "🇦🇺", with: "🇳🇮"))

우리는 당연히 호주 국기는 없으니까 아무것도 안 바뀌고 "🇨🇦🇺🇸"가 그대로 나오겠지? 싶잖아요?

 

그런데 결과는 아래와 같이 표시됩니다.

"🇨🇳🇮🇸"

아니 캐나다, 미국 국기였는데 갑자기 중국과 아이슬란드 국기가 튀어나왔어요! 왤까요?

 


🧐 원인 분석

사실 replacingOccurrences(of:with:) 메서드는 Swift Native 메서드가 아니에요.

Objective-C 시절부터 있던 고인물 메서드입니다. 그리고 이 고여버린 메서드는 우리가 앞에서 이야기했던 유니코드를 제대로 이해하지 못하고 처리하지 못합니다.

 

국기 이모티콘은 사실 두 개의 특수한 유니코드 문자가 합쳐져서 만들어져요.

- 🇨🇦는 'C'와 'A'를 나타내는 특수 문자의 조합입니다.
- 🇺🇸는 'U'와 'S'를 나타내는 특수 문자의 조합이고요.

 

즉, "🇨🇦🇺🇸"라는 문자열은 내부적으로는 [C, A, U, S] 라는 4개의 특수 문자로 이루어져 있는 셈이죠.

 

여기서 문제가 발생합니다. 우리가 바꾸려던 호주 국기 🇦🇺는 [A, U] 문자 조합이거든요. Objective-C의 낡은 메서드는 이걸 글자가 아니라 그냥 바이트 덩어리로 보고, "CAUS" 중간에 있는 "AU"를 찾아내버리게 됩니다.

 

그리고 찾아낸 "AU"를 니카라과 국기 🇳🇮의 조합인 [N, I]로 바꿔치기합니다.

그 결과, 원래 [C, A, U, S] 였던 문자열은 [C, N, I, S] 로 바뀌게 되고,

 

Swift가 이 새로운 문자 조합을 다시 화면에 표시하려고 보니...

- [C, N]은 중국 국기 🇨🇳
- [I, S]는 아이슬란드 국기 🇮🇸

로 끔찍한 결과가 나와버린겁니다.

Swift 좋아.


😶 해결책

다행히 해결책은 정말 너무나 쉽습니다. Swift가 제공하는 최신 네이티브 메서드를 쓰면 됩니다.

사용법도 너무 쉽습니다.

print(vacation.replacing("🇦🇺", with: "🇳🇮"))

이 메서드는 설계 단계부터 유니코드를 완벽하게 이해하고 만들어졌기 때문에, 🇨🇦와 🇺🇸를 각각 하나의 의미 단위(글자)로 정확하게 인지합니다.

그래서 그 안에 호주 국기 🇦🇺가 통째로 들어있지 않다는 걸 정확히 알고, 아무것도 바꾸지 않은 채 원래 문자열 "🇨🇦🇺🇸"을 그대로 반환해 주죠.

 

이게 우리가 상상하고 원했던 결과잖아요?

근데 앞에서 말은 길게 써놓고 해결책은 간단해서 어이없죠?

 

문제가 하나 있어요. (저한테만 있을 수도)

✨ 최소 지원 버전 ✨

 

사실 사이드 프로젝트면 문제없는데요, 저희 회사는 iOS 9도 지원하거든요? 아 이거 절대 못쓰죠.

그럼 iOS 15 이하 환경에서는, 한땀한땀 가내수공업 말고 없을까요?

 

예. 그것이 iOS 개발이니까.


 

😭 iOS 15 이하 대응해주기

결과만 필요하신 분들을 위해 코드부터 드리고 시작하겠습니다.

코드에 대한 상세 설명은 아래에서 진행할게요!

extension String {
  func replacingCompat(
    _ target: String,
    with replacement: String,
    maxReplacements: Int = .max
  ) -> String {
    if #available(iOS 16.0, *) { // 선택 사항
      return replacing(target, with: replacement, maxReplacements: maxReplacements)
    }

    guard !target.isEmpty, maxReplacements > 0 else { return self }

    var result = ""
    result.reserveCapacity(utf8.count)

    var searchStart = startIndex
    var replaced = 0

    while replaced < maxReplacements,
          let r = range(of: target, range: searchStart..<endIndex) {

      result.append(contentsOf: self[searchStart..<r.lowerBound])
      result.append(contentsOf: replacement)

      replaced += 1
      searchStart = r.upperBound
    }

    result.append(contentsOf: self[searchStart..<endIndex])
    return result
  }
}

replacingCompat(_:with:maxReplacements:)이라는 메서드를 하나 만들었습니다. 이 메서드는 3개의 파라미터를 받습니다.

  • target: 찾아 바꿀 문자열
  • replacement: target 대신 들어갈 새로운 문자열
  • maxReplacements: 최대 몇 번까지 바꿀지 정하는 횟수(기본값은 .max로, 제한 없음을 의미합니다.)

방금 전에 보았던 iOS 16+의 String.replacing(_:with:maxReplacements:)와 동일한 파라미터를 받아 호환되도록 했습니다.

 

1. 예외 처리 (Early Exit)

본격적인 작업에 앞서, 굳이 실행할 필요가 없는 예외 케이스를 미리 처리합니다.

guard !target.isEmpty, maxReplacements > 0 else { return self }
  • !target.isEmpty: 찾아야 할 target이 비어있으면 작업을 수행할 수 없으므로 바로 종료합니다.
  • maxReplacements > 0: 바꿔야 할 횟수가 0번이면 아무것도 할 필요가 없으므로 바로 종료합니다.
  • return self: 위 조건 중 하나라도 해당되면, 아무것도 바꾸지 않고 원본 문자열(self)을 그대로 반환합니다.

2. 결과 변수 준비 및 최적화

교체 결과를 담을 새로운 문자열을 만들고, 성능을 위해 메모리를 미리 준비합니다.

var result = ""
result.reserveCapacity(utf8.count)
  • var result = "": 교체 작업의 결과를 차곡차곡 쌓아나갈 새로운 빈 문자열 result를 만듭니다.
  • result.reserveCapacity(utf8.count): (성능 최적화) result 문자열에 내용이 추가될 때마다 메모리를 새로 할당하는 비효율을 막기 위해, 미리 원본 문자열(self)의 크기만큼 메모리 공간을 예약합니다.

3. 반복문 상태 변수 초기화

반복문을 제어하기 위한 변수들을 설정합니다.

var searchStart = startIndex
var replaced = 0
  • var searchStart = startIndex: target을 찾기 시작할 위치를 저장하는 변수입니다. 처음에는 당연히 문자열의 맨 앞(startIndex)에서 시작합니다.
  • var replaced = 0: 지금까지 몇 번 교체했는지 횟수를 세는 변수입니다. maxReplacements와 비교하기 위해 사용됩니다.

4. 핵심 반복문 (target 검색)

target을 찾고 교체하는 핵심 작업을 반복 수행합니다.

while replaced < maxReplacements,
	let r = range(of: target, range: searchStart..<endIndex) {
  • replaced < maxReplacements: 교체 횟수가 maxReplacements보다 적을 때.
  • let r = range(of: target, range: searchStart..<endIndex): (핵심 로직) searchStart 위치부터 문자열 끝(endIndex)까지의 범위에서 target을 검색합니다.
    • 성공 시: target을 찾으면 그 위치 범위(Range)가 r에 저장되고 while문 안의 코드가 실행됩니다.
    • 실패 시: 더 이상 target을 찾지 못하면 nil이 반환되어 while문이 종료됩니다.

 

5. 찾은 내용 교체 및 상태 갱신 (반복문 내부)

target을 성공적으로 찾았을 때 실행되는 로직입니다.

result.append(contentsOf: self[searchStart..<r.lowerBound])
result.append(contentsOf: replacement)

replaced += 1
searchStart = r.upperBound
  • result.append(contentsOf: self[searchStart..<r.lowerBound]): result에 (이전 탐색 위치 ~ 방금 찾은 `target`의 시작 위치 바로 앞)` 까지의 문자열을 먼저 붙여넣습니다. (즉, 건드리지 않을 부분을 그대로 복사)
  • result.append(contentsOf: replacement): 그 다음, target이 있던 자리에 replacement 문자열을 이어서 붙입니다.
  • replaced += 1: 교체를 한 번 완료했으므로, 횟수를 1 증가시킵니다.
  • searchStart = r.upperBound: (가장 중요한 부분) 다음 검색은 방금 찾은 target의 끝나는 지점 바로 다음에서 시작하도록 searchStart 위치를 갱신합니다. 이것이 무한 루프와 비효율적인 중복 검색을 막는 핵심입니다.


6. 마무리 및 반환

반복문이 끝나고 남은 부분을 처리한 뒤, 최종 결과를 반환합니다.

result.append(contentsOf: self[searchStart..<endIndex])
return result
  • result.append(contentsOf: self[searchStart..<endIndex]): while 반복문이 모두 끝난 후, 마지막으로 찾았던 위치부터 문자열의 맨 끝까지 남아있던 부분을 result에 마저 붙여줍니다. (만약 target을 한 번도 못 찾았다면, searchStart는 여전히 startIndex이므로 이 라인에서 원본 문자열 전체가 복사됩니다.)
  • return result: 이렇게 완성된 새로운 문자열 result를 최종적으로 반환합니다.

 


 

🙇‍♂️ 마무리

오랜만에 써보지만 블로그 글 쓰는건 언제나 힘든 것 같습니다. 매번 꾸준히 글 쓰시는 분들 정말 존경합니다.

관련 지식이 크게 없는 분들도 이해시켜드리기 위해서 최대한 상세히 작성은 했지만, 읽으시기 쉬울지는 잘 모르겠습니다.

그래도 조금이나마 도움이 되었으면 좋겠습니다. 아니면 흥미를 느꼈거나 재미라도 있으셨다면 만족합니다.

긴 글 읽어주셔서 감사합니다!

 

 


참고

 

One Swift mistake everyone should stop making today

TL;DR: You should use replacing(_:with:) rather than replacingOccurrences(of:with:)

www.hackingwithswift.com

 

 

✍️ 작성자: 은표

  • 직군: iOS Developer
  • GitHub: https://github.com/honghoker

'iOS' 카테고리의 다른 글

Tuist Plugin 만들기 + Settings 설정까지 완전 정복!  (0) 2026.04.05
[iOS] Tuist를 쓰면서 Asset 코드 생성은 직접 만들기로 한 이유  (0) 2026.03.28
Tuist 모듈 자동화 CLI 자동화 만들기  (0) 2026.02.25
Tuist 모듈화에 빠져보기  (0) 2026.01.24
'iOS' 카테고리의 다른 글
  • Tuist Plugin 만들기 + Settings 설정까지 완전 정복!
  • [iOS] Tuist를 쓰면서 Asset 코드 생성은 직접 만들기로 한 이유
  • Tuist 모듈 자동화 CLI 자동화 만들기
  • Tuist 모듈화에 빠져보기
dynamic-ddd
dynamic-ddd
역할의 경계를 넘어 유저에게 도달하는 프로덕트를 완성해온 메이커들의 커뮤니티입니다.
  • 인기 글

  • dynamic-ddd
    dynamic-ddd 님의 블로그
    dynamic-ddd
  • 전체
    오늘
    어제
    • 분류 전체보기 (9)
      • iOS (5)
      • Android (2)
      • Design (2)
  • 공지사항

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
dynamic-ddd
[iOS] 당연하게 쓰던 메서드에게 뒤통수 맞아보기
상단으로

티스토리툴바