SY키보드는 가볍고, 사용하기 간편한 한글, 영어 키보드입니다.
- 한글 키보드: 나랏글, 천지인, 두벌식
- 영어 키보드: QWERTY
개발 기간: 2024.07.30 ~ 2025.01.15
리팩토링 기간: 2025.07.09 ~ 2025.12.07
- 나랏글 키보드/천지인 키보드를 계속 사용해 왔던 사람
- 나랏글 키보드/천지인 키보드에 입문하는 사람
- 편의 기능이 있는 두벌식 키보드를 사용해 보고 싶은 사람
- 필수적인 기능들을 포함하되, 가벼운 키보드 앱을 찾는 사람
| 범위 | 기술 이름 |
|---|---|
| 의존성 관리 도구 | SPM |
| 형상 관리 도구 | Git, GitHub |
| 디자인 패턴 | Delegate, Singleton |
| 인터페이스 | UIKit, SwiftUI |
| 활용 API | Firebase Analytics, Firebase Crashlytics, Google AdMob |
| 내부 저장소 | UserDefaults |
| 테스트 | Swift Testing |
첫 iOS 프로젝트인 SY키보드를 SwiftUI로 개발하여 1월에 출시하였다.
하지만 Swift 언어를 다루는 데에 미숙했던 것과 UIKit에 비해 부족한 터치 이벤트 및 상태 관리로 인해 키보드 버튼 코드가 매우 길어졌다.
이후 4개월간의 UIKit 부트캠프를 수강하며 어느 정도 iOS 앱 개발에 익숙해지면서, 복잡한 상태 관리에는 SwiftUI보다 UIKit이 적합함을 알게 되었다.
미숙했던 개발 실력과 SwiftUI의 특징이 맞물려 유지보수하기 어려웠던 SY키보드의 UIKit 리팩토링을 생각하게 되었고, 부트캠프 수료 이후 진행하였다.
SY키보드의 메인 앱은 키보드 설정 위주의 단순한 구조이므로 기존 SwiftUI를 유지하면서 개선에 목적을 두었다.
Keyboard Extension 부분은 SwiftUI에서 UIKit으로 리팩토링하는 작업을 진행했다.
리팩토링을 거치며 영어 키보드를 추가하였고, 천지인 키보드도 다음 업데이트를 위해 기본적인 UI를 만들어 두었다.
또한, 다른 프로젝트에서 가져와 수정해서 사용했던 한글 오토마타 코드도 처음부터 다시 만들기로 결정했다.
SwiftUI에서는 Button의 action이 touchUpInside 기준으로 고정되어 있어서, Gesture를 사용하여 우회적으로 다른 이벤트들을 구현해야 했다.
하지만 UIKit에서는 addTarget 혹은 addAction의 UIControlEvents를 통해 touchDown, touchUpInside, touchDownRepeat로 세밀하게 제어할 수 있었다.
또한 버튼이 눌렸을 때(highlighted, selected)에 대한 상태 변경도 더 직관적이었다.
기존 SwiftUI
// KeyboardButton 구조체의 일부
Button(action: {}) {
// Image 버튼들
if systemName != nil {
if systemName == "return.left" { // 리턴 버튼
if state.returnButtonType == .default {
Image(systemName: "return.left")
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.font(.system(size: imageSize))
.foregroundStyle(Color(uiColor: UIColor.label))
.background(checkPressed() ? Color("PrimaryKeyboardButton") : Color("SecondaryKeyboardButton"))
// ...(중략)...
.highPriorityGesture(
LongPressGesture(minimumDuration: 0)
.onEnded({ _ in
// 버튼 눌렀을 때
os_log("LongPressGesture() onEnded: pressed", log: log, type: .debug)
gesturePressed()
})
)
.simultaneousGesture(
LongPressGesture(minimumDuration: state.longPressDuration, maximumDistance: cursorActiveDistance)
// 버튼 길게 눌렀을 때
.onEnded({ _ in
os_log("simultaneous_LongPressGesture() onEnded: longPressed", log: log, type: .debug)
gestureLongPressed()
})
.sequenced(before: DragGesture(minimumDistance: 10, coordinateSpace: .global))
// 버튼 길게 누르고 드래그시 호출
.onChanged({ value in
switch value {
case .first(_):
break
case .second(_, let dragValue):
if let value = dragValue {
os_log("LongPressGesture()->DragGesture() onChanged: longPressedDrag", log: log, type: .debug)
gestureLongPressedDrag(dragGestureValue: value)
}
}
})
.exclusively(before: DragGesture(minimumDistance: cursorActiveDistance, coordinateSpace: .global)
// 버튼 드래그 할 때
.onChanged({ value in
os_log("exclusively_DragGesture() onChanged: drag", log: log, type: .debug)
gestureDrag(dragGestureValue: value)
})
)
)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
// 버튼 뗐을 때
.onEnded({ _ in
os_log("DragGesture() onEnded: released", log: log, type: .debug)
gestureReleased()
})
)UIKit 리팩토링 이후
// BaseKeyboardViewController 클래스의 일부
func addInputActionToTextInterableButton(_ button: TextInteractable) {
let inputAction = UIAction { [weak self] action in
guard let self, let currentButton = action.sender as? TextInteractable else { return }
if currentButton.isProgrammaticCall {
// 코드(sendActions)로 호출된 경우 -> 무조건 입력 수행
performTextInteraction(for: currentButton)
} else {
// 사용자가 touchUpInside한 경우 -> currentPressedButton 확인
if let currentPressedButton = buttonStateController.currentPressedButton,
currentPressedButton == currentButton {
performTextInteraction(for: currentButton)
}
}
}
if button is DeleteButton {
button.addAction(inputAction, for: .touchDown)
} else if let spaceButton = button as? SpaceButton {
button.addAction(inputAction, for: .touchUpInside)
addPeriodShortcutActionToSpaceButton(spaceButton)
} else {
button.addAction(inputAction, for: .touchUpInside)
}
}
// ButtonStateController 클래스의 일부
func setFeedbackActionToButtons(_ buttonList: [BaseKeyboardButton]) {
buttonList.forEach { button in
let playFeedbackAndSetPressed: UIAction
if button is ShiftButton {
playFeedbackAndSetPressed = UIAction { [weak self] action in
guard let senderButton = action.sender as? BaseKeyboardButton else { return }
if let previousButton = self?.currentPressedButton, previousButton != senderButton {
previousButton.sendActions(for: .touchUpInside)
}
self?.isShiftButtonPressed = true
senderButton.playFeedback()
}
} else {
playFeedbackAndSetPressed = UIAction { [weak self] action in
guard let senderButton = action.sender as? BaseKeyboardButton else { return }
if let previousButton = self?.currentPressedButton, previousButton != senderButton {
previousButton.sendActions(for: .touchUpInside)
}
self?.currentPressedButton = senderButton
senderButton.playFeedback()
}
}
button.addAction(playFeedbackAndSetPressed, for: .touchDown)
}
}
// PrimaryButton 클래스의 일부
func setStyles() {
self.configurationUpdateHandler = { [weak self] button in
guard let self else { return }
switch button.state {
case .normal:
backgroundView.backgroundColor = isGesturing ? .primaryButtonPressed : .primaryButton
case .highlighted:
backgroundView.backgroundColor = isPressed || isGesturing ? .primaryButtonPressed : .primaryButton
default:
break
}
}
}| 설명 | 스크린샷 |
|---|---|
| 리팩토링 이전 | ![]() |
| 리팩토링 이후 | ![]() |
실기기에 리팩토링 이전과 이후 버전을 Release 빌드로 설치 후 "동해물과 백두산이 마르고 닳도록"을 입력, 키보드의 입력 처리 시간을 측정한 결과이다.
명령형 프레임워크인 UIKit으로 리팩토링하면서 복잡한 버튼, 제스처 로직을 효율적으로 처리할 수 있었지만, 선언형 프레임워크인 SwiftUI보다 UI 구현 코드는 더 길어지게 되었다.
하지만 리팩토링 이후 키보드 입력 처리 시간이 이전보다 1/3 정도로 단축되어 성능이 높아지는 이점을 얻을 수 있었다.
또한, 현재로선 UIKit이 SwiftUI보다 세밀한 커스텀이 가능한 장점이 있어 SY키보드에는 UIKit이 좀더 적합하다고 생각된다.
최근 WWDC에서 SwiftUI 위주의 업데이트가 계속 발표되고 있으니, 나중에 커스텀하기 더 편하게 SwiftUI가 업데이트된다면 그때 다시 SwiftUI로 리팩토링을 해봐야겠다.
| 설명 | 스크린샷 |
|---|---|
| Crashlytics | ![]() |
| Instruments Allocations |
![]() |
| Instruments Generations |
![]() |
Crashlytics에 didReceiveMemoryWarning 로그와 크래시가 발생하는 것을 보고, Profile을 실행하여 다음 사항을 확인했다.
- Allocations 그래프를 통해 키보드 dismiss 이후에도 인스턴스가 메모리에서 해제되지 않음
- 키보드 dismiss 이후 Generation에 키보드 관련 인스턴스 존재
- 키보드를 표시할때마다 새로운 인스턴스가 쌓이다가 임계점을 넘어가면 크래시가 발생
KeyboardView가deinit되지 않음
- iOS 버그로 인해 코드 베이스 레이아웃 작성 시
BaseKeyboardViewController(UIInputViewController)의view(UIInputView)가 메모리에서 해제되지 않는다. view의subview인KeyboardView또한 해제되지 않게 된다.
- 버튼이 순환 참조로 인해
deinit되지 않음
BaseKeyboardViewController와ButtonStateController에서 버튼에 액션을 할당할 때, 클로저 내부에서 인자로 들어온 버튼을 강하게 참조- 버튼 ->
UIAction-> 클로저 -> 버튼 순환 참조 UIAction클로저 내부[weak self]는 해당 인스턴스와의 연결만 약한 참조로 변경
// BaseKeyboardViewController (UIInputViewController)
func addInputActionToTextInterableButton(_ button: TextInteractable) {
let inputAction = UIAction { [weak self] action in
guard let self, let currentButton = action.sender as? TextInteractable else { return }
if currentButton.isProgrammaticCall {
// 메서드 인자인 'button'을 클로저가 강하게 참조(캡처)
performTextInteraction(for: button)
// ...KeyboardView를 Storyboard(xib)로 초기화하고,loadView시점에 할당하도록 수정UIAction클로저의 매개변수를 활용하여 버튼 객체에 대한 강한 참조 제거
// BaseKeyboardViewController (UIInputViewController)
deinit {
logger.debug("\(String(describing: type(of: self))) deinit")
keyboardView.removeFromSuperview()
}
func addInputActionToTextInterableButton(_ button: TextInteractable) {
let inputAction = UIAction { [weak self] action in
guard let self, let currentButton = action.sender as? TextInteractable else { return }
if currentButton.isProgrammaticCall {
// UIAction 클로저의 매개변수를 활용, 버튼에 대한 강한 참조(캡처) 제거
performTextInteraction(for: currentButton)
// ...| 설명 | 스크린샷 |
|---|---|
| Instruments Allocations |
![]() |
| Instruments Generations |
![]() |
Instruments의 Allocations 그래프와 Generations 표를 통해 키보드 dismiss 이후에 인스턴스가 메모리에서 해제되는 것을 확인하였다.
출처: Apple Developer Forums - UIInputView is not deallocated from memory
| 설명 | 스크린샷 |
|---|---|
| 애니메이션 글리칭 |
![]() |
애플 공식 문서(레거시, 최신) 기반으로 키보드 높이 조절 코드를 구현했을 때, 위 GIF처럼 키보드가 잠깐동안 높이 튀어오르는 현상이 발생하였다.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setKeyboardHeight()
FeedbackManager.shared.prepareHaptic()
}
func setKeyboardHeight() {
let heightConstraint = self.view.heightAnchor.constraint(equalToConstant: UserDefaultsManager.shared.keyboardHeight)
heightConstraint.priority = .init(999)
heightConstraint.isActive = true
}- view를 위한 애니메이션이 구성되기 직전인
viewWillAppear메서드에 높이 제약조건 코드 구현
문제 해결을 위해 찾아보던 중 Stack Overflow의 한 질문글의 답변에서 힌트를 얻을 수 있었다.
private var constraintsHaveBeenAdded = false
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
initKeyboardConstraints()
}
private func initKeyboardConstraints() {
if constraintsHaveBeenAdded { return }
guard let superview = view.superview else { return }
view.translatesAutoresizingMaskIntoConstraints = false
view.leftAnchor.constraint(equalTo: superview.leftAnchor).isActive = true
view.bottomAnchor.constraint(equalTo: superview.bottomAnchor).isActive = true
view.rightAnchor.constraint(equalTo: superview.rightAnchor).isActive = true
view.heightAnchor.constraint(equalToConstant: 250.0).isActive = true
constraintsHaveBeenAdded = true
}
- 제약조건이 설정되었는지를 판단하는 플래그 변수
constraintsHaveBeenAdded설정- 제약조건이 이미 설정되었거나, 상위 view가 설정되지 않은 경우 실행 X (방어 코드)
- view의 모든 edge에 대해 상위 view와 같도록 제약조건 설정
constraintsHaveBeenAdded를 true로 설정
- 이전 코드에서는 view의 모든 edge에 대해 상위 view와 같도록 제약조건을 설정하는 코드(
$0.edges.equalToSuperview())와translatesAutoresizingMaskIntoConstraints를false로 설정하는 코드가 없었음 - 이로 인해 Autoresizing Mask로 view의 크기와 위치를 정하려 하는 과정에서 Auto Layout의 높이 제약조건이 충돌을 일으켜 애니메이션에 글리칭이 발생한 것으로 추측
translatesAutoresizingMaskIntoConstraints만false로 설정하는 경우 아래 사진처럼 UI가 치우치는 현상이 발생함
| 설명 | 스크린샷 |
|---|---|
| UI 치우침 | ![]() |
위 답변을 토대로 높이 제약조건 코드를 수정하고 방어코드를 추가하였다.
- 키보드 가로모드 대응 코드도 추가된 상태
func setKeyboardHeight() {
let keyboardHeight: CGFloat
if let orientation = self.view.window?.windowScene?.effectiveGeometry.interfaceOrientation {
keyboardHeight = orientation == .portrait ? UserDefaultsManager.shared.keyboardHeight : KeyboardLayoutFigure.landscapeKeyboardHeight
} else {
if !isPreview {
assertionFailure("View가 window 계층에 없습니다.")
}
keyboardHeight = UserDefaultsManager.shared.keyboardHeight
}
if let keyboardHeightConstraint {
keyboardHeightConstraint.constant = keyboardHeight
} else {
let constraint = self.view.heightAnchor.constraint(equalToConstant: keyboardHeight)
constraint.priority = .init(999)
constraint.isActive = true
keyboardHeightConstraint = constraint
}
}| 설명 | 스크린샷 |
|---|---|
| 해결 이후 | ![]() |
%%{
init: {
"theme": "default",
"fontFamily": "monospace",
"elk": {
"mergeEdges": false,
"nodePlacementStrategy": "BRANDES_KOEPF",
"forceNodeModelOrder": false,
"considerModelOrder": "NODES_AND_EDGES"
},
"class": {
"hideEmptyMembersBox": true
}
}
}%%
classDiagram
direction LR
%% Keyboard Type
namespace KeyboardGestureController {
class TextInteractionGestureController
class SwitchGestureController
}
namespace KeyboardGestureProtocol {
class SwitchGestureHandling
}
namespace KeyboardTypeLayoutProtocol {
class HangeulKeyboardLayoutProvider
class EnglishKeyboardLayoutProvider
class SymbolKeyboardLayoutProvider
class NumericKeyboardLayoutProvider
class TenkeyKeyboardLayoutProvider
}
namespace ParentKeyboardViewController {
class BaseKeyboardViewController
}
namespace FinalKeyboardViewController {
class HangeulKeyboardViewController
class EnglishKeyboardViewController
}
class NormalKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class PrimaryKeyboardRepresentable:::SYKeyboard_primary { <<protocol>> }
class HangeulKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class EnglishKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class SymbolKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class NumericKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class TenkeyKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class SwitchGestureHandling:::SYKeyboard_primary { <<protocol>> }
BaseKeyboardViewController --> PrimaryKeyboardRepresentable: Association
BaseKeyboardViewController *-- SymbolKeyboardLayoutProvider: Composition
BaseKeyboardViewController *-- NumericKeyboardLayoutProvider: Composition
BaseKeyboardViewController *-- TenkeyKeyboardLayoutProvider: Composition
NormalKeyboardLayoutProvider <|-- PrimaryKeyboardRepresentable: Inheritance
NormalKeyboardLayoutProvider <|-- SymbolKeyboardLayoutProvider: Inheritance
NormalKeyboardLayoutProvider <|-- NumericKeyboardLayoutProvider: Inheritance
BaseKeyboardViewController *-- TextInteractionGestureController: Composition
BaseKeyboardViewController *-- SwitchGestureController: Composition
SwitchGestureHandling <|-- NormalKeyboardLayoutProvider: Inheritance
SwitchGestureController --> SwitchGestureHandling: Association
BaseKeyboardViewController <|-- HangeulKeyboardViewController: Inheritance
BaseKeyboardViewController <|-- EnglishKeyboardViewController: Inheritance
HangeulKeyboardViewController *-- HangeulKeyboardLayoutProvider: Composition
PrimaryKeyboardRepresentable <|-- HangeulKeyboardLayoutProvider: Inheritance
EnglishKeyboardViewController *-- EnglishKeyboardLayoutProvider: Composition
PrimaryKeyboardRepresentable <|-- EnglishKeyboardLayoutProvider: Inheritance
classDef SYKeyboard_primary fill:#ffa6ed
%%{
init: {
"theme": "default",
"fontFamily": "monospace",
"elk": {
"mergeEdges": false,
"nodePlacementStrategy": "BRANDES_KOEPF",
"forceNodeModelOrder": false,
"considerModelOrder": "NODES_AND_EDGES"
},
"class": {
"hideEmptyMembersBox": true
}
}
}%%
classDiagram
direction LR
%% Keyboard Layout
namespace KeyboardGestureProtocol {
class SwitchGestureHandling
}
namespace KeyboardLayoutProtocol {
class HangeulKeyboardLayoutProvider
class EnglishKeyboardLayoutProvider
class SymbolKeyboardLayoutProvider
class NumericKeyboardLayoutProvider
class TenkeyKeyboardLayoutProvider
}
namespace ParentKeyboardView {
class FourByFourKeyboardView
class FourByFourPlusKeyboardView
class StandardKeyboardView
}
namespace FinalKeyboardView {
class NaratgeulKeyboardView
class CheonjiinKeyboardView
class DubeolsikKeyboardView
class EnglishKeyboardView
class SymbolKeyboardView
class NumericKeyboardView
class TenkeyKeyboardView
}
class BaseKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class NormalKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class SwitchGestureHandling:::SYKeyboard_primary { <<protocol>> }
class PrimaryKeyboardRepresentable:::SYKeyboard_primary { <<protocol>> }
class HangeulKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class EnglishKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class SymbolKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class NumericKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
class TenkeyKeyboardLayoutProvider:::SYKeyboard_primary { <<protocol>> }
BaseKeyboardLayoutProvider <|-- NormalKeyboardLayoutProvider: Inheritance
SwitchGestureHandling <|-- NormalKeyboardLayoutProvider: Inheritance
BaseKeyboardLayoutProvider <|-- TenkeyKeyboardLayoutProvider: Inheritance
NormalKeyboardLayoutProvider <|-- PrimaryKeyboardRepresentable: Inheritance
TenkeyKeyboardLayoutProvider ..|> TenkeyKeyboardView: Implementation
PrimaryKeyboardRepresentable <|-- HangeulKeyboardLayoutProvider: Inheritance
FourByFourKeyboardView <|-- NaratgeulKeyboardView: Inheritance
HangeulKeyboardLayoutProvider <|.. NaratgeulKeyboardView: Implementation
FourByFourPlusKeyboardView <|-- CheonjiinKeyboardView: Inheritance
HangeulKeyboardLayoutProvider <|.. CheonjiinKeyboardView: Implementation
StandardKeyboardView <|-- DubeolsikKeyboardView: Inheritance
HangeulKeyboardLayoutProvider <|.. DubeolsikKeyboardView: Implementation
PrimaryKeyboardRepresentable <|-- EnglishKeyboardLayoutProvider: Inheritance
EnglishKeyboardLayoutProvider ..|> EnglishKeyboardView: Implementation
StandardKeyboardView <|-- EnglishKeyboardView: Inheritance
NormalKeyboardLayoutProvider <|-- SymbolKeyboardLayoutProvider: Inheritance
SymbolKeyboardLayoutProvider ..|> SymbolKeyboardView: Implementation
NormalKeyboardLayoutProvider <|-- NumericKeyboardLayoutProvider: Inheritance
NumericKeyboardLayoutProvider ..|> NumericKeyboardView: Implementation
classDef SYKeyboard_primary fill:#ffa6ed
%%{
init: {
"theme": "default",
"fontFamily": "monospace",
"elk": {
"mergeEdges": false,
"nodePlacementStrategy": "BRANDES_KOEPF",
"forceNodeModelOrder": false,
"considerModelOrder": "NODES_AND_EDGES"
},
"class": {
"hideEmptyMembersBox": true
}
}
}%%
classDiagram
direction LR
%% Keyboard Button
namespace TextInteractionProtocol {
class TextInteractable
}
namespace ParentKeyboardButton {
class BaseKeyboardButton
class PrimaryButton
class SecondaryButton
}
namespace FinalKeyboardButton {
class PrimaryKeyButton
class SpaceButton
class SecondaryKeyButton
class ShiftButton
class DeleteButton
class SwitchButton
class NextKeyboardButton
class ReturnButton
}
class TextInteractable:::SYKeyboard_primary { <<protocol>> }
BaseKeyboardButton <|-- PrimaryButton: Inheritance
BaseKeyboardButton <|-- SecondaryButton: Inheritance
BaseKeyboardButton <|-- TextInteractable: Constraint
PrimaryButton <|-- PrimaryKeyButton: Inheritance
PrimaryButton <|-- SpaceButton: Inheritance
PrimaryKeyButton ..|> TextInteractable: Implementation
SecondaryButton <|-- DeleteButton: Inheritance
SecondaryButton <|-- NextKeyboardButton: Inheritance
SecondaryButton <|-- ReturnButton: Inheritance
SecondaryButton <|-- SecondaryKeyButton: Inheritance
SecondaryButton <|-- ShiftButton: Inheritance
SecondaryButton <|-- SwitchButton: Inheritance
SecondaryKeyButton ..|> TextInteractable: Implementation
DeleteButton ..|> TextInteractable: Implementation
ReturnButton ..|> TextInteractable: Implementation
SpaceButton ..|> TextInteractable: Implementation
classDef SYKeyboard_primary fill:#ffa6ed
- 나랏글 키보드
기본에 충실한 나랏글(EZ한글) 키보드입니다.
- 천지인 키보드
입력이 편리한 천지인 키보드입니다.
- 두벌식 키보드
대중적인 두벌식(한글 쿼티) 키보드입니다.
- 영어 키보드
대중적인 영어(QWERTY) 키보드입니다.
- 숫자 키패드 탑재
숫자를 입력할 때 큰 버튼으로 편하게 입력할 수 있는 숫자 입력 전용 키패드를 탑재했습니다.
- 한 손 키보드 모드
한 손으로 폰을 들고 있는 상태에서도 입력하기 수월하도록 한 손 키보드 모드를 제공합니다.
- 다양하고 디테일한 키보드 설정
길게 누르기 동작, 커서 이동, 키보드 높이 및 한 손 키보드 너비 조절 등 사용자의 편의에 맞게 키보드 설정이 가능합니다.











