Delivery icons created by dreamicons - Flaticon
# 프로젝트를 만들고자 하는 폴더로 이동
npx [email protected] init FoodDeliveryApp
# 다음 말이 뜨면 y 입력
Need to install the following packages:
[email protected]
Ok to proceed? (y)
# [ios전용]Do you want to install CocoaPods now? 뜨면 y 입력
# [ios전용]실수로 y 안 눌러서 CocoaPods 수동설치하려면
sudo gem install cocoapods
# [ios전용]cocoapods 설치시 ruby 오류가 나면 터미널에 나오는 명령어 입력 후 cocoapods 재설치(아래 버전은 달라질 수 있으니 주의)
sudo gem install activesupport -v 6.1.7.6
# [ios전용]마지막으로 pod 설치
cd ./FoodDeliveryApp/ios && pod install
잠깐!! 이 명령어를 입력하면 항상 최신 버전의 react를 받아오므로 강좌의 버전(0.66)과 일치하지 않게 됨. 현재 최신 버전은 0.75이라서 상당히 차이가 남. 강좌랑 동일한 버전으로 하지 않으면 많은 스트레스를 받을 수 있음.
보통은 강의용으로 자동생성 안 좋아하는데 RN은 자동생성하지 않으면 네이티브단까지 처리하기 어려움
프로젝트 폴더에서 다음 명령어로 앱 실행 가능
npm start # rn72 버전에서는 npm start 후 뜨는 화면에서 i나 a를 눌러 아이폰/안드로이드 실행
# rn72까지는
npm run android # 안드로이드 실행 명령어
npm run ios # 아이폰 실행 명령어
터미널이 뜨면서 그 안에서 서버가 하나 뜰 것임. Metro 서버. 여기서 서버가 안 뜨고 No device 등의 에러 메시지가 뜬다면 안드로이드 에뮬레이터 실행한 채로 다시 명령어 입력할 것. Metro 서버에서 소스 코드를 컴파일하고 앱으로 전송해줌. 기본 8081포트.
다음 에러가 뜨면서 시뮬레이터에 앱이 켜지지 않는다면 터미널 종료 후 새로 하나 띄워 npm run android를 입력하고 다시 npm start를 입력한다
* What went wrong:
Error resolving plugin [id: 'com.facebook.react.settings']
> java.io.UncheckedIOException: Could not move temporary workspace
메트로 서버가 꺼져있다면 터미널을 하나 더 열어
npm start
개발은 iOS 기준으로 하는 게 좋다(개인 경험). 그러나 강좌는 어쩔 수 없이 Windows로 한다(Windows 사용자가 많기 때문).
[email protected] 버전, 한 달에 0.1씩 올라가는데 요즘 개발 속도가 느려져서 규칙이 깨짐. 버전 업그레이드 함부로 하지 말 것!
[맥 전용]npx pod-install도 미리 한 번, iOS 라이브러리(pod) 받는 용도
Flipper 페이스북이 만든 모바일앱 디버거도 좋음(다만 연결 시 에러나는 사람 다수 발견)
npm i react-native-flipper redux-flipper rn-flipper-async-storage-advanced @react-native-async-storage/async-storage
npx pod-install # 아이폰 전용
\android\app\src\main\res\values\strings.xml
app.json의 displayName
\ios\FoodDeliveryApp\Info.plist의 CFBundleDisplayName
단! 0.68버전부터는 app.json, strings.xml, CFBundleDisplayName을 한글로하면 튕기는 문제 발생. 그럴때는 전부 영어로 되돌리고 ios에서는 링크 따라서 다국어 설정으로 한국어 설정할 것. 또한 안드로이드에서는 \android\app\src\main\res\values\strings.xml은 영어로 두고 \android\app\src\main\res\values-ko\strings.xml 을 새로 만들어 여기서 한글로 변경할 것
android/gradle.properties
FLIPPER_VERSION=0.239.0
플리퍼 버전 추가
react-router-native도 대안임(웹에서 넘어온 개발자들에게 친숙, 웹처럼 주소 기반)
npm i @react-navigation/native
npm i @react-navigation/native-stack
npm i [email protected] react-native-safe-area-context
npx pod-install # 맥 전용
android/app/src/main/java/FoodDeliveryApp/MainActivity.java
import android.os.Bundle;
...
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);
}
android/build.gradle
buildscript {
ext {
...
kotlin_version = '1.6.10'
}
...
dependencies {
...
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
...
}
App.tsx 교체
import * as React from 'react';
import {NavigationContainer, ParamListBase} from '@react-navigation/native';
import {
createNativeStackNavigator,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import {Text, TouchableHighlight, View} from 'react-native';
import {useCallback} from 'react';
type RootStackParamList = {
Home: undefined;
Details: undefined;
};
type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
type DetailsScreenProps = NativeStackScreenProps<ParamListBase, 'Details'>;
function HomeScreen({navigation}: HomeScreenProps) {
const onClick = useCallback(() => {
navigation.navigate('Details');
}, [navigation]);
return (
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
<TouchableHighlight onPress={onClick}>
<Text>Home Screen</Text>
</TouchableHighlight>
</View>
);
}
function DetailsScreen({navigation}: DetailsScreenProps) {
const onClick = useCallback(() => {
navigation.navigate('Home');
}, [navigation]);
return (
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
<TouchableHighlight onPress={onClick}>
<Text>Details Screen</Text>
</TouchableHighlight>
</View>
);
}
const Stack = createNativeStackNavigator<RootStackParamList>();
function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen
name="Home"
component={HomeScreen}
options={{title: 'Overview'}}
/>
<Stack.Screen name="Details">
{props => <DetailsScreen {...props} />}
</Stack.Screen>
</Stack.Navigator>
</NavigationContainer>
);
}
export default App;
npm install @react-navigation/bottom-tabs
App.tsx
src/components/DismissKeyBoardView.tsx
import React from 'react';
import {
TouchableWithoutFeedback,
Keyboard,
StyleProp,
ViewStyle,
KeyboardAvoidingView,
Platform,
} from 'react-native';
const DismissKeyboardView: React.FC<{ style: StyleProp<ViewStyle> }> = ({children, ...props}) => (
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<KeyboardAvoidingView
{...props}
style={props.style}
behavior={Platform.OS === 'android' ? undefined : 'padding'}>
{children}
</KeyboardAvoidingView>
</TouchableWithoutFeedback>
);
export default DismissKeyboardView;
인풋 바깥 클릭 시 키보드를 가리기 위함
npm i react-native-keyboard-aware-scrollview
types/react-native-keyboard-aware-scroll-view
src/components/DismissKeyBoardView.tsx
back 서버 실행 필요, DB 없이도 되게끔 만들어둠. 서버 재시작 시 데이터는 날아가니 주의
# 터미널 하나 더 켜서
cd back
npm start
리덕스 설정
npm i @reduxjs/toolkit react-redux redux-flipper
src/store/index.ts와 src/store/reducer.ts, src/slices/user.ts 작성
AppInner.tsx 생성 및 isLoggedIn을 redux로 교체(AppInner 분리 이유는 App.tsx에서 useSelector를 못 씀)
App.tsx
import * as React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import {Provider} from 'react-redux';
import store from './src/store';
import AppInner from './AppInner';
function App() {
return (
<Provider store={store}>
<NavigationContainer>
<AppInner />
</NavigationContainer>
</Provider>
);
}
export default App;
액세스토큰/리프레시토큰을 받아서 다음 라이브러리로 저장
npm install react-native-encrypted-storage
npx pod-install # ios 전용
서버 요청은 axios 사용(요즘 ky나 got으로 넘어가는 추세이나 react-native와 호환 여부 불투명)
npm i axios
환경변수, 키 값을 저장할 config 패키지
npm i react-native-config
import Config from 'react-native-config';
-Android에서 Config가 적용이 안 되면 다음 추가해야함
android/app/proguard-rules.pro
-keep class com.fooddeliveryapp.BuildConfig { *; }
android/app/build.gradle
apply plugin: "com.android.application"
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
...
defaultConfig {
...
resValue "string", "build_config_package", "com.fooddeliveryapp"
}
API_URL=http://10.0.2.2:3105
암호화해서 저장할 데이터는 다음 패키지에
import EncryptedStorage from 'react-native-encrypted-storage';
await EncryptedStorage.setItem('키', '값');
await EncryptedStorage.removeItem('키');
const 값 = await EncryptedStorage.getItem('키');
src/pages/SignUp.tsx, src/pages/SignIn.tsx
android에서 http 요청이 안 보내지면
ActivityIndicator로 로딩창 꾸미기
웹소켓 기반 라이브러리
npm i socket.io-client
src/hooks/useSocket.ts
import {useCallback} from 'react';
import {io, Socket} from 'socket.io-client';
import Config from 'react-native-config';
let socket: Socket | undefined;
const useSocket = (): [Socket | undefined, () => void] => {
const disconnect = useCallback(() => {
if (socket) {
socket.disconnect();
socket = undefined;
}
}, []);
if (!socket) {
socket = io(`${Config.API_URL}`, {
transports: ['websocket'],
});
}
return [socket, disconnect];
};
export default useSocket;
AppInner.tsx
const [socket, disconnect] = useSocket();
useEffect(() => {
const helloCallback = (data: any) => {
console.log(data);
};
if (socket && isLoggedIn) {
console.log(socket);
socket.emit('login', 'hello');
socket.on('hello', helloCallback);
}
return () => {
if (socket) {
socket.off('hello', helloCallback);
}
};
}, [isLoggedIn, socket]);
useEffect(() => {
if (!isLoggedIn) {
console.log('!isLoggedIn', !isLoggedIn);
disconnect();
}
}, [isLoggedIn, disconnect]);
src/pages/Settings.tsx
socket.io에서 주문 내역 받아서 store에 넣기
AppInner.tsx
useEffect(() => {
const callback = (data: any) => {
console.log(data);
dispatch(orderSlice.actions.addOrder(data));
};
if (socket && isLoggedIn) {
socket.emit('acceptOrder', 'hello');
socket.on('order', callback);
}
return () => {
if (socket) {
socket.off('order', callback);
}
};
}, [isLoggedIn, socket]);
encrypted-storage에서 토큰 불러오기
AppInner.tsx
// 앱 실행 시 토큰 있으면 로그인하는 코드
useEffect(() => {
const getTokenAndRefresh = async () => {
try {
const token = await EncryptedStorage.getItem('refreshToken');
if (!token) {
return;
}
const response = await axios.post(
`${Config.API_URL}/refreshToken`,
{},
{
headers: {
authorization: `Bearer ${token}`,
},
},
);
dispatch(
userSlice.actions.setUser({
name: response.data.data.name,
email: response.data.data.email,
accessToken: response.data.data.accessToken,
}),
);
} catch (error) {
console.error(error);
if ((error as AxiosError).response?.data.code === 'expired') {
Alert.alert('알림', '다시 로그인 해주세요.');
}
}
};
getTokenAndRefresh();
}, [dispatch]);
src/slices/order.ts
src/pages/Settings.tsx
src/pages/Orders.tsx
src/components/EachOrder.tsx
axios.interceptor 설정하기
useEffect(() => {
axios.interceptors.response.use(
response => {
return response;
},
async error => {
const {
config,
response: {status},
} = error;
if (status === 419) {
if (error.response.data.code === 'expired') {
const originalRequest = config;
const refreshToken = await EncryptedStorage.getItem('refreshToken');
// token refresh 요청
const {data} = await axios.post(
`${Config.API_URL}/refreshToken`, // token refresh api
{},
{headers: {authorization: `Bearer ${refreshToken}`}},
);
// 새로운 토큰 저장
dispatch(userSlice.actions.setAccessToken(data.data.accessToken));
originalRequest.headers.authorization = `Bearer ${data.data.accessToken}`;
// 419로 요청 실패했던 요청 새로운 토큰으로 재요청
return axios(originalRequest);
}
}
return Promise.reject(error);
},
);
}, [dispatch]);
npm i https://github.com/ZeroCho/react-native-naver-map
npm i react-native-nmap을 하면 유지보수가 안 되는 패키지가 설치되므로 강의를 위해 제작된 패키지를 대신 설치
...
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
...
pod 'NMapsMap'
...
target 'FoodDeliveryAppTests' do
...
npx pod-install # ios 전용
android/build.gradle
buildscript {
...
}
allprojects {
repositories {
google()
jcenter()
// 네이버 지도 저장소
maven {
url 'https://repository.map.naver.com/archive/maven'
}
}
}
src/components/EachOrder.tsx
<View
style={{
width: Dimensions.get('window').width - 30,
height: 200,
marginTop: 10,
}}>
<NaverMapView
style={{width: '100%', height: '100%'}}
zoomControl={false}
center={{
zoom: 10,
tilt: 50,
latitude: (start.latitude + end.latitude) / 2,
longitude: (start.longitude + end.longitude) / 2,
}}>
<Marker
coordinate={{
latitude: start.latitude,
longitude: start.longitude,
}}
pinColor="blue"
/>
<Path
coordinates={[
{
latitude: start.latitude,
longitude: start.longitude,
},
{latitude: end.latitude, longitude: end.longitude},
]}
/>
<Marker
coordinate={{latitude: end.latitude, longitude: end.longitude}}
/>
</NaverMapView>
</View>
권한 얻기(위치정보, 카메라, 갤러리)
npm i react-native-permissions
ios/Podfile
pod 'NMapsMap', '3.16.0'
permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
pod 'Permission-LocationAccuracy', :path => "#{permissions_path}/LocationAccuracy"
pod 'Permission-LocationAlways', :path => "#{permissions_path}/LocationAlways"
pod 'Permission-LocationWhenInUse', :path => "#{permissions_path}/LocationWhenInUse"
pod 'Permission-Notifications', :path => "#{permissions_path}/Notifications"
pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary"
ios/FoodDeliveryApp/Info.plist
<key>NSCameraUsageDescription</key>
<string>배송완료 사진 촬영을 위해 카메라 권한이 필요합니다.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>배송중 위치 확인을 위해서 위치 권한이 필요합니다.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>배송중 위치 확인을 위해서 위치 권한이 필요합니다.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>배송중 위치 확인을 위해서 위치 권한이 필요합니다.</string>
<key>NSMotionUsageDescription</key>
<string>배송중 위치 확인을 위해서 위치 권한이 필요합니다.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>배송완료 사진 선택을 위해 라이브러리 접근 권한이 필요합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>배송완료 사진 선택을 위해 라이브러리 접근 권한이 필요합니다.</string>
android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
npx pod-install
src/hooks/usePermissions.ts
import {useEffect} from 'react';
import {Alert, Linking, Platform} from 'react-native';
import {check, PERMISSIONS, request, RESULTS} from 'react-native-permissions';
function usePermissions() {
// 권한 관련
useEffect(() => {
if (Platform.OS === 'android') {
check(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION)
.then(result => {
console.log('check location', result);
if (result === RESULTS.BLOCKED || result === RESULTS.DENIED) {
Alert.alert(
'이 앱은 위치 권한 허용이 필요합니다.',
'앱 설정 화면을 열어서 항상 허용으로 바꿔주세요.',
[
{
text: '네',
onPress: () => Linking.openSettings(),
},
{
text: '아니오',
onPress: () => console.log('No Pressed'),
style: 'cancel',
},
],
);
}
})
.catch(console.error);
} else if (Platform.OS === 'ios') {
check(PERMISSIONS.IOS.LOCATION_ALWAYS)
.then(result => {
if (result === RESULTS.BLOCKED || result === RESULTS.DENIED) {
Alert.alert(
'이 앱은 백그라운드 위치 권한 허용이 필요합니다.',
'앱 설정 화면을 열어서 항상 허용으로 바꿔주세요.',
[
{
text: '네',
onPress: () => Linking.openSettings(),
},
{
text: '아니오',
onPress: () => console.log('No Pressed'),
style: 'cancel',
},
],
);
}
})
.catch(console.error);
}
if (Platform.OS === 'android') {
check(PERMISSIONS.ANDROID.CAMERA)
.then(result => {
if (result === RESULTS.DENIED || result === RESULTS.GRANTED) {
return request(PERMISSIONS.ANDROID.CAMERA);
} else {
console.log(result);
throw new Error('카메라 지원 안 함');
}
})
.catch(console.error);
} else {
check(PERMISSIONS.IOS.CAMERA)
.then(result => {
if (
result === RESULTS.DENIED ||
result === RESULTS.LIMITED ||
result === RESULTS.GRANTED
) {
return request(PERMISSIONS.IOS.CAMERA);
} else {
console.log(result);
throw new Error('카메라 지원 안 함');
}
})
.catch(console.error);
}
}, []);
}
export default usePermissions;
npm i @react-native-community/geolocation
src/pages/Ing.tsx
src/pages/Complete.tsx
이미지 선택 후 리사이징
npm i react-native-image-crop-picker
npm i react-native-image-resizer
npx pod-install # ios 전용
Native Module Patching
npm i patch-package
package.json
"scripts": {
"postinstall": "patch-package",
"android": "react-native run-android",
npx patch-package react-native-image-crop-picker
android/app/src/main/AndroidManifest.xml
...
<queries>
<package android:name="com.skt.tmap.ku" />
</queries>
</manifest>
src/pages/Ing.tsx
TMap.openNavi(
'도착지',
end.longitude.toString(),
end.latitude.toString(),
'MOTORCYCLE',
).then(data => {
console.log('TMap callback', data);
if (!data) {
Alert.alert('알림', '티맵을 설치하세요.');
}
});
npm i react-native-splash-screen
...
const token = await EncryptedStorage.getItem('refreshToken');
if (!token) {
SplashScreen.hide();
return;
}
...
} finally {
SplashScreen.hide();
}
};
getTokenAndRefresh();
}, [dispatch]);
npm i react-native-vector-icons
npm i -D @types/react-native-vector-icons
npm i react-native-fast-image
링크 src/slices/order.ts
interface InitialState {
...
completes: Order[];
}
const initialState: InitialState = {
...
completes: [],
};
...
setCompletes(state, action) {
state.completes = action.payload;
},
src/pages/Settings.tsx
푸쉬알림 보내기
npm i @react-native-firebase/analytics @react-native-firebase/app @react-native-firebase/messaging
npm i react-native-push-notification @react-native-community/push-notification-ios
npm i -D @types/react-native-push-notification
npx pod-install
[ios] pod install 시 에러 발생 시 참고
[ios] 따라할 것
App.tsx
adb devices
adb -s <기기이름> reverse tcp:8081 tcp:8081
여러 문제 발견 가능
android/app/build.gradle
def enableSeparateBuildPerCPUArchitecture = true
/**
* Run Proguard to shrink the Java bytecode in release builds.
*/
def enableProguardInReleaseBuilds = true
package.json
"scripts": {
...
"build:android": "npm ci && cd android && ./gradlew bundleRelease && cd .. && open android/app/build/outputs/bundle/release",
"apk:android": "npm ci && cd android && ./gradlew assembleRelease && cd .. && open android/app/build/outputs/apk/release",
iOS 개발자 멤버쉽 가입 필요
버저닝, 배포 자동화 가능
npm i react-native-code-push
npm install appcenter appcenter-analytics appcenter-crashes
npm i -g appcenter-cli (맥에서는 sudo 필요)
appcenter login
appcenter codepush deployment list -a zerohch0/food-delivery-app-android -k
App.tsx
import codePush from "react-native-code-push";
const codePushOptions: CodePushOptions = {
checkFrequency: CodePush.CheckFrequency.MANUAL,
// 언제 업데이트를 체크하고 반영할지를 정한다.
// ON_APP_RESUME은 Background에서 Foreground로 오는 것을 의미
// ON_APP_START은 앱이 실행되는(켜지는) 순간을 의미
installMode: CodePush.InstallMode.IMMEDIATE,
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
// 업데이트를 어떻게 설치할 것인지 (IMMEDIATE는 강제설치를 의미)
};
function App() {
}
export default codePush(codePushOptions)(App);
"codepush:android": "appcenter codepush release-react -a 아이디/앱이름 -d 배포이름 --sourcemap-output --output-dir ./build -m -t 타겟버전",
"codepush:ios": "appcenter codepush release-react -a 아이디/앱이름 -d 배포이름 --sourcemap-output --output-dir ./build -m -t 타겟버전",
"bundle:android": "react-native bundle --assets-dest build/CodePush --bundle-output build/CodePush/index.android.bundle --dev false --entry-file index.js --platform android --sourcemap-output build/CodePush/index.android.bundle.map",
"bundle:ios": "react-native bundle --assets-dest build/CodePush --bundle-output build/CodePush/main.jsbundle --dev false --entry-file index.js --platform ios --sourcemap-output build/CodePush/main.jsbundle.map",
[맥 전용]ios 폴더 안에서 pod 명령어 수행 가능, but npx pod-install은 프로젝트 폴더 어디서나 가능
시작 성능 빨라지고, 메모리 사용량 적고, 앱 사이즈 작아짐
이미 메트로 서버가 다른 데서 켜져 있는 것임. 메트로 서버를 실행하고 있는 터미널 종료하기
메트로 서버 꺼볼 것
[email protected] 설치([email protected]에 문제 있음) 링크
cd android
./gradlew clean
cd ..
npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle
android/gradle.properties에 다음 줄 주석 해제
org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
또는
android/app/src/main/AndroidManifest.xml 에서 태그에 android:largeHeap="true" 추가
안드로이드 스튜디오의 SDK Tools에서 33.0.0 제거한 후 다시 설치. show package details 눌러보면 33.0.0 보임
0.71 미만 버전들에서 발생 여기에 나오는 최신버전으로 업데이트 업그레이드 헬퍼사용하면 편리
npx react-native start --reset-cache
cd android && ./gradlew clean
cd ..
npx react-native run-android
윈도에서 발생하는 에러인데 choco로 openssl 다시 설치하기
chmod 755 android/gradlew
node.js 16버전으로 할 것, node 17버전부터 해당 에러 발생함.
보통 App.tsx 부분이 여러번 실행되어서 발생함. Metro 서버를 껐다가 켜고, 에뮬레이터에서 앱을 지웠다가 다시 설치하면 해결 됨
android:exported
when the corresponding component has an intent filter defined서버 실행 시 JS단에서 에러가 발생해서 발생함. JS단 에러부터 해결할 것. 애러가 없다면 index.js의 appName이 일치하는지 확인할 것
파이어베이스 앱 만들어둘 것
rn73부터는 앱 실행을 위해 npm start 실행 후 i나 a를 눌러 아이폰/안드로이드 실행하면 됩니다.