이제 iOS도 맞춤법 검사됩니다! 😆🎉

2022년 9월! 드디어 네이버 블로그에 iOS 맞춤법 검사 기능이 출시되었습니다! 👏🏻👏🏻👏🏻
네이버 블로그는 PC 버전과 모바일 버전을 지원하는데요. PC와 Android에 맞춤법 검사 기능이 먼저 출시되고, 사용자들의 엄청난 반응이 있었습니다. 하지만 iOS에는 출시되지 않아 많은 사용자분들이 기다리고 계셨는데요.

이번 글에서는 사용자들의 기대 속에서 탄생한 맞춤법 검사 기능이 어떻게 개발되었는지 소개해 드릴게요.
맞춤법 검사 기능이 뭐냐고요?
설마 아직도 맞춤법 검사 기능을 모르시는 건 아니겠죠? 🥲

맞춤법 검사에서는 오류 문자를 추천 문자 또는 직접 작성한 문자로 수정할 수 있습니다. 수정 이후에는 다음 오류 문자로 스크롤이 이동합니다. 오류 문자 각각을 수정하는 기능뿐만 아니라 함께 수정, 모두 수정, 수정 취소, 제외 기능이 있습니다.
맞춤법 검사를 시작하면 일어나는 일
조금 더 자세하게 사용자가 맞춤법을 수정하는 경우 어떤 과정이 일어나는지 예시를 통해 말씀드릴게요.

1. 현재 선택된 오류 문자열을 추천 문자열로 대체합니다.

2. 수정된 문자열에 초록색 스타일을 적용합니다.

3. 다음 오류 문자열의 시작 위치를 계산합니다.
- 수정 전 “봬어요” 의 시작 위치) 17번째 문자
- 수정 후 “봬어요” 의 시작 위치) 18번째 문자

4. 다음 오류 문자열에 붉은색 스타일을 적용합니다.

맞춤법 수정은 이렇게 4단계를 반복하며 이뤄집니다. 맞춤법 검사 기능이 어떤 것인지 알아봤으니 이제 본격적으로 어떻게 개발했는지 소개해 드릴게요.
SmartEditor의 컴포넌트란?
SmartEditor에서 사용하는 컴포넌트는 정보를 담고 있는 덩어리를 의미합니다. 이미지를 담고 있는 이미지 컴포넌트, 글을 담고 있는 텍스트 컴포넌트 등을 예로 들 수 있습니다.

사용자는 여러 개의 컴포넌트를 구성하여 글을 작성하게 됩니다. 이러한 구조에 따라 맞춤법 검사 기능도 컴포넌트별로 맞춤법 오류 정보를 가지며, 전체 본문을 대상으로 맞춤법 수정 정보를 관리하게 됩니다.
맞춤법 검사 핵심 모델
맞춤법 기능을 구현하기 위해 가장 중요한 것은 모델 설계입니다. 사용자가 수정하기 원하는 문자열의 위치 정보를 잘 가지고 있지 않으면 엉뚱한 문자열을 수정해버리기 때문이죠. 또한, ‘함께 수정’ 기능과 같이 여러 개의 오류를 수정하기 위해서도 정보를 잘 가지고 있어야 합니다.
1) 컴포넌트의 맞춤법 오류 정보
Spell
: 맞춤법 수정 정보- 오류 문자열의 위치, 수정 문자열, 수정 여부 등을 저장합니다.
SpellCheckDataSource
: 맞춤법 오류 위치 정보- 컴포넌트 내에도 여러 개의 중복된 오류들이 존재합니다. 만약 여러 개의 오류 중 일부만 수정하고 싶다면 어떤 TextHolder(쉽게 생각하자면, ‘문장’을 의미)에 있는 문자열인지 알아야 합니다. 이 정보를 저장하기 위해 dictionary 자료 구조를 이용합니다.


2) 전체 본문에서 맞춤법 오류 정보
SpellCheckResult
: 맞춤법 수정 결과- 전체 본문에서 맞춤법 수정이 일어난 위치와 수정 정보를 저장합니다.
맞춤법 수정이 완료되면 이 객체를 이용하여 화면을 업데이트합니다.
- 전체 본문에서 맞춤법 수정이 일어난 위치와 수정 정보를 저장합니다.

모델 간의 흐름을 아래와 같은 다이어그램으로 정리해 볼 수 있습니다.

(1) 맞춤법 오류들을
(2) 본문에서 어떤 위치에 해당하는지 정리한 다음,
(3) 사용자 수정 액션에 따라 반환된 정보를 이용하여 화면에 적용합니다.
오류 문자열을 수정하는 과정
replace와 시작 위치 계산

1) 오류 문자 replace 하기
문자열의 수정은 문자의 시작 위치와 길이 정보를 기반으로 이루어집니다.
예를 들어, “감기 빨리낳으시고, 다음 주에 봬어요.” 에서 “빨리낳으시고” 를 수정하고 싶다면 시작 위치 3
번, 길이 6
의 정보가 필요합니다.
let 문자열의_시작위치 ← 3
let 문자열의_길이 ← 6
let range = (문자열의_시작위치, 문자열의_길이)
"빨리낳으시고" replace characters in range to "빨리 나으시고"
2) 시작 위치 변경하기
오류 문자열을 수정한 뒤에 꼭 해주어야 하는 작업이 있는데요. 바로 다음 오류 문자열의 시작 위치를 계산하는 것입니다.
처음에는 매번 오류 문자열을 찾아서 수정하면 된다고 생각했었습니다. 그런데 만약 하나의 문장에 중복된 오류 가 여러 개 있고, 이 중 일부만 수정하고 싶다면요? 특정 위치의 오류 문자열을 수정 하기 위해서는 시작 위치를 알아야 합니다.

그래서 앞서 구조에서 설명해 드린 Spell
객체는 시작 위치를 저장하고 있습니다. 맞춤법을 수정하기 전 “봬어요”의 시작 위치는 17
번인데요. 앞에서 “감기 빨리낳으시고”의 맞춤법을 수정하며 띄어쓰기를 추가하였으니 “봬어요” 의 시작 위치는 18
번이 됩니다. 하지만 Spell
객체는 여전히 17
번을 저장하고 있기 때문에, 다음 오류 문자열의 시작 위치를 수정해 주어야 하는 것이죠.


let gap ← original.length - modified.length // 원본 문자열과 수정 문자열의 길이 차이
for spell in spells do
if spell.시작위치 > modified.시작위치 then // 다음 오류 문자열들을 대상으로
spell.시작위치 += gap // 시작위치를 조정
오류 문자열에 스타일을 입히는 과정
빨간색, 초록색 스타일이 전부가 아니에요.
맛춤법검사기능개발
오류 문자열을 대체(replace)하고, 빨간색에서 초록색으로 색상을 변경한다고 맞춤법 수정이 끝난 걸까요?
텍스트에는 다양한 스타일 속성들이 존재합니다. 사용자가 기존에 적용해 둔 텍스트 스타일(예를 들어, 폰트, 폰트 크기, 색상, 첨자, 링크, 인라인 이미지, 목록 등)을 모두 유지한 채로 문자열을 대체해야 합니다.
불행하게도 swift에서 제공하는 replace
메서드는 정말 글.자.만. replace 하고 모든 텍스트 속성들을 날려버립니다.😩 텍스트 속성을 유지하려면 NSAttributedString
을 이용해서 이 모든 초기 스타일 정보를 Deep Copy 했다가 수정 시에 적용해 주어야 합니다.
1) 문자열 Deep Copy
let 원본_문자열 ← originalAttributedString.copy()
let 수정_문자열 ← originalAttributedString.copy()
수정_문자열 replace characters in range to 추천_문자열
원본의 스타일을 가지고 있는 원본_문자열
과 문구 수정을 적용할 수정_문자열
을 정의하고, 수정_문자열
에 추천_문자열
로 텍스트를 replace 합니다.
2) 텍스트 속성 적용
for style in 원본_문자열 where range do
수정_문자열 apply style
원본_문자열
이 가지고 있는 모든 텍스트 속성에 대해서 iteration을 돌며 수정_문자열
에 적용해 줍니다. 이때, 오류 문자열과 수정 문자열의 길이가 다른 경우에 대한 예외 처리도 필요합니다. 수정 이전 문자열 보다 수정 후 문자열이 더 긴 경우 마지막 속성을 유지합니다. (단순한 설명을 위해 예외 처리 코드는 생략하였습니다.)
성능 이슈
맞춤법 ‘모두 수정’ 시, 시간이 오래 걸리는 현상
맞춤법 수정이 일어나게 되면 여러 가지 일련의 과정이 필요합니다. 예를 들어 하나의 컴포넌트에 500개의 오류가 있고, 이를 한 번에 모두 수정하는 상황을 가정해 보겠습니다.
첫 번째 오류 문자열을 수정하고, 시작 위치를 조정하고, 스타일 적용하고, 화면에 반영하고,
두 번째 오류 문자열을 수정하고, 시작 위치를 조정하고, 스타일 적용하고, 화면에 반영하고,
세 번째 오류 문자열을 수정하고, 시작 위치를 조정하고, ……
결과적으로 수정된 화면을 보려면 2분 33초를 기다려야 했습니다. 각 단계에서 들어가는 비용을 최대한 줄이기 위하여 아래 두 가지 방법을 고안하였습니다.
1) 뒤에서부터 수정하기
시작 위치를 계산하지 않음
본문에 있는 맞춤법을 순서대로 수정하기 위해서 컴포넌트별로 오류 Spell
배열(SpellCheckDataSource
)을 두고 있는데요. 이 배열을 역순으로 정렬하여 맞춤법을 수정합니다. 역순으로 정렬했기 때문에 수정이 이뤄져도 다음 오류 문구의 시작 위치를 계산하지 않아도 됩니다.
2) UI 업데이트 한 번만 하기
화면 업데이트 비용 최소화

Xcode instruments의 Time Profiler를 통하여 확인해 보니 비용이 가장 많이 드는 연산은 화면을 업데이트하는 연산이었습니다. 사용자가 맞춤법을 하나하나 직접 수정하여 수정된 SpellCheckResult
객체가 반환되면 즉각적으로 화면을 업데이트합니다. 이 함수가 바로 SpellCheckable.updateProperty()
인데요. 보시다시피 전체 성능의 100%를 차지하고 있습니다.
화면의 업데이트는 컴포넌트 단위로 이뤄집니다. 한 컴포넌트 내부에 500개의 오류가 수정되었으므로, 화면을 500번 업데이트하는 현상이 발생한 것입니다. 이를 해결하기 위해 변경된 SpellCheckResult
객체들을 컴포넌트 단위로 Grouping 하여 화면을 한 번만 업데이트할 수 있도록 개선하였습니다.
let results <- spellchecked array // 맞춤법 수정이 완료된 객체 배열
let groups <- grouping results by component
for component in groups do
component.updateSpellProperty()

결과적으로 500개 오류를 기준으로 2분 33초 걸리던 ‘모두 수정‘ 기능을 0.295 초로 줄일 수 있게 되었습니다.
느낀 점
맞춤법 검사 기능은 제가 SmartEditor에 입사하고 처음으로 맡은 업무였습니다. 인기 많은 맞춤법 검사 기능을 담당하게 되어 굉장히 설렜었는데요. 하지만 한편으로 걱정이 되기도 하였습니다. 잘 짜여 있는 팀의 코드에 저의 코드를 추가하는 것이 겁이 나기도 했고, 사실 텍스트 개발은 폰트 크기 바꾸는 정도밖에 몰랐었거든요.
하지만, 제가 잘 해 낼 수 있을 거라고 믿어주시고 격려해 주신 팀원 덕분에 즐겁게 개발할 수 있었습니다. 맞춤법 검사 기능을 개발하고 나니, 어느새 저 혼자 텍스트에 배경 색상도 넣고, 취소선도 넣고, 인라인 이미지, 첨자 등등 여러 가지 스타일을 적용하고 있더라고요.☺️ 이 모든 과정을 도와주시고 함께해 주신 팀원들에게 정말 감사하다는 말씀을 드리고 싶습니다.
그리고 저는 개발자이지만 동시에 SmartEditor를 애용하는 사용자이기도 한데요. 맞춤법 검사 기능이 빨리 출시되기를 기다렸던 만큼, 배포되자마자 떨리는 마음으로 앱을 업데이트했습니다. 혹시라도 버그가 있을까 봐 며칠을 긴장하고 있었던 건 저만 알고 있겠습니다.😁
😊 열심히 만든 맞춤법 검사 기능 많이 많이 써주세요! 😊