목차
1) Springboot 프로젝트 생성하고 RestController 작성 후 실행하기(Gradle)
2) AWS RDS로 MariaDB 생성해서 워크벤치에 연결하기
3) JPA(Hibernate) + HikariCP로 스프링부트 프로젝트와 RDS MariaDB 연동 후 CRUD 메소드 구현
4) Springboot 프로젝트 AWS EC2 인스턴스에 배포
5) 안드로이드 앱에서 Retrofit을 사용해서 REST API와 통신하기(CRUD 구현)
참고사항
1) 부가적인 설명은 최대한 배제하는 대신 모든 과정을 여과 없이 스크린샷으로 남겼습니다. 그래서 대부분의 스크린샷이 창 전체를 포함합니다.
2) 이론적인 이해가 필요한 부분은 해시 태그(#)를 통해 키워드만 남겨놓도록 하겠습니다.
3) 모든 과정을 정확하게 따르기 위해서는 IntelliJ, Gradle, Putty, AWS 계정, Mysql Workbench, Postman이 필요합니다.(테스트 과정을 패스한다면 Mysql Workbench, Postman은 필요 없습니다.)
5. 안드로이드 앱에서 Retrofit을 사용해서 REST API와 통신하기(CRUD 구현)
1) 안드로이드 프로젝트 생성하기
- 이번 장에서는 1~4장에서 만들고 배포한 REST API와 통신해서 간단한 CRUD를 구현할 앱을 작성합니다.
- 안드로이드 스튜디오에서 New Project를 클릭해서 새로운 프로젝트를 생성합니다.
- Empty Acitivity를 선택하고 Next를 클릭합니다.
- 프로젝트 이름과 패키지는 자유롭게 선택합니다.
- 언어는 java를 선택합니다.
- 프로젝트가 생성되었습니다.
2) 어플리케이션 프리뷰
- 앞으로 만들게 될 앱은 이런 형태입니다.
- REST API에서 통신 가능한 Member 객체를 그대로 사용합니다.
- 이름 나이 주소를 적어서 삽입하고 삽입된 결과가 출력됩니다.
- 출력된 아이템은 수정과 삭제가 가능합니다.
- 수정 작업은 아래와 같은 형태로 이루어집니다.
- 수정 및 삭제시 Toast를 통해 알림이 출력됩니다.
#Toast
3) 레이아웃 작성
- 메인 레이아웃은 ConstraintLayout 내에 뷰들이 위치를 잡고 있습니다.
- 하단에 RecyclerView를 통해 리스트로 출력됩니다.
- RecyclerView가 익숙하지 않은 분은 아래 포스팅을 참고하세요
https://wickies.tistory.com/94
#ConstraintLayout #RecyclerView
- 코드
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText
android:id="@+id/et_name"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:hint="NAME"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/et_age"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:hint="AGE"
app:layout_constraintStart_toEndOf="@+id/et_name"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/et_address"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:hint="ADDRESS"
app:layout_constraintStart_toEndOf="@+id/et_age"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:layout_width="69dp"
android:layout_height="46dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:text="Add"
android:id="@+id/btn_add"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="68dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:text="MemberList"
android:textColor="#000"
android:textSize="25sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<android.support.v7.widget.RecyclerView
android:id="@+id/member_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" />
</android.support.constraint.ConstraintLayout>
- RecyclerView에서 사용할 리스트의 레이아웃입니다.
- 출력용 레이아웃과 수정용 레이아웃이 함께 있으며 visibility 속성을 통해 전환됩니다.
- 아래와 같이 한개의 레이아웃만 보여집니다.
- 아이템의 테두리 속성을 위한 border_shadow.xml 파일입니다.
- 코드
member_info.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="3dp"
android:background="@drawable/border_shadow"
android:orientation="vertical">
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:id="@+id/info_layout"
>
<TextView
android:id="@+id/info_id"
android:layout_width="50dp"
android:layout_height="40dp"
android:text="ID"
android:gravity="center"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/info_name"
android:layout_width="60dp"
android:layout_height="40dp"
android:text="NAME"
android:gravity="center"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/info_id"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/info_age"
android:layout_width="60dp"
android:layout_height="40dp"
android:text="AGE"
android:gravity="center"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/info_name"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/info_address"
android:layout_width="75dp"
android:layout_height="40dp"
android:text="ADDRESS"
android:gravity="center"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/info_age"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/info_created"
android:layout_width="80dp"
android:layout_height="40dp"
android:text="CREATED"
android:gravity="center"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/info_address"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/info_update"
android:layout_width="31dp"
android:layout_height="18dp"
android:layout_marginTop="5dp"
android:layout_marginEnd="4dp"
android:layout_marginRight="4dp"
android:background="#f0f0f0"
android:text="수정"
android:textSize="8sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/info_delete"
android:layout_width="31dp"
android:layout_height="18dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:layout_marginRight="4dp"
android:background="#f0f0f0"
android:text="삭제"
android:textSize="8sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/info_update" />
</android.support.constraint.ConstraintLayout>
<android.support.constraint.ConstraintLayout
android:id="@+id/update_layout"
android:layout_width="match_parent"
android:layout_height="50dp"
android:visibility="gone"
>
<TextView
android:id="@+id/update_id"
android:layout_width="50dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="7dp"
android:gravity="center"
android:text="ID"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/update_name"
android:layout_width="60dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="7dp"
android:gravity="center"
android:text="NAME"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/update_id"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/update_age"
android:layout_width="60dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="7dp"
android:gravity="center"
android:text="AGE"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/update_name"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/update_address"
android:layout_width="75dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="7dp"
android:gravity="center"
android:text="ADDRESS"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/update_age"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/update_created"
android:layout_width="80dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="7dp"
android:gravity="center"
android:text="CREATED"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/update_address"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.416" />
<Button
android:id="@+id/update_btn"
android:layout_width="31dp"
android:layout_height="35dp"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="4dp"
android:layout_marginRight="4dp"
android:layout_marginBottom="8dp"
android:background="#f0f0f0"
android:text="수정"
android:textSize="8sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/update_created"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
</android.support.constraint.ConstraintLayout>
</LinearLayout>
border_shadow.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#CABBBBBB"/>
<corners android:radius="2dp" />
</shape>
</item>
<item
android:left="1dp"
android:right="1dp"
android:top="1dp"
android:bottom="2dp">
<shape android:shape="rectangle">
<solid android:color="@android:color/white"/>
<corners android:radius="2dp" />
</shape>
</item>
</layer-list>
4) 라이브러리 의존성 추가
- 주석이 없는 부분은 프로젝트 생성시 자동으로 추가된 라이브러리들입니다.
- 하단의 6개 라이브러리 의존성만 추가해주시면 됩니다.
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
// Lombok
compileOnly 'org.projectlombok:lombok:1.18.8'
annotationProcessor 'org.projectlombok:lombok:1.18.8'
// RecyclerView
implementation 'com.android.support:recyclerview-v7:28.0.0'
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
// Gson
implementation 'com.google.code.gson:gson:2.8.5'
}
5) 객체 모델 정의
- REST API에서 사용하던 Member 모델을 그대로 가져옵니다.
- 코드
Member.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Member {
private long id;
private String name;
private int age;
private String address;
private Date createdAt;
}
6) 데이터 서비스 클래스와 클라이언트 인터페이스 정의
- 아래 코드는 Retrofit 클라이언트를 생성하는 클래스입니다.
- 한개의 파일에 인터페이스까지 전부 작성되어 연결되어 있습니다.
- TODO 옆의 BASE_URL의 경로를 이전 장에서 생성한 EC2의 퍼블릭 IP로 변경해주세요.
- 어노테이션을 통해 헤더를 설정할 수 있습니다. 항상 새로운 정보를 받아야 하는 경우 캐쉬 설정을 반드시 no-cache로 설정해야 합니다.
ex) @Headers("Content-Type: 타입") / @Headers("Cache-Control: no-cache")
- 코드
#Retrofit #OkHttpClient #Content-Type #Header
DataService.java
public class DataService {
private String BASE_URL = "http://52.78.166.158/member/"; // TODO REST API 퍼블릭 IP로 변경
Retrofit retrofitClient =
new Retrofit.Builder()
.baseUrl(BASE_URL)
.client(new OkHttpClient.Builder().build())
.addConverterFactory(GsonConverterFactory.create())
.build();
SelectAPI select = retrofitClient.create(SelectAPI.class);
InsertAPI insert = retrofitClient.create(InsertAPI.class);
UpdateAPI update = retrofitClient.create(UpdateAPI.class);
DeleteAPI delete = retrofitClient.create(DeleteAPI.class);
}
interface SelectAPI{
@GET("select/{id}")
Call<Member> selectOne(@Path("id") long id);
@GET("select")
Call<List<Member>> selectAll();
}
interface InsertAPI{
@POST("insert")
Call<Member> insertOne(@Body Map<String, String> map);
}
interface UpdateAPI{
@POST("update/{id}")
Call<Member> updateOne(@Path("id") long id, @Body Map<String, String> map);
}
interface DeleteAPI{
@POST("delete/{id}")
Call<ResponseBody> deleteOne(@Path("id") long id);
}
7) MemberAdapter 정의
- delete 요청과 같이 콜백을 객체로 받지 않는 경우에는 콜백 타입을 String이 아닌 ResponseBody 타입으로 받습니다.
MemberAdapter.java
public class MemberAdapter extends RecyclerView.Adapter<MemberAdapter.ViewHolder> {
private List<Member> data;
private Context context;
private DataService dataService;
MemberAdapter(List<Member> data, Context context, DataService dataService) {
this.data = data;
this.context = context;
this.dataService = dataService;
}
@Override
public MemberAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
return new MemberAdapter.ViewHolder(inflater.inflate(R.layout.member_info, parent, false));
}
@Override
public int getItemCount() {
return data.size() ;
}
@Override
public void onBindViewHolder(final MemberAdapter.ViewHolder holder, final int position) {
holder.info_id.setText(String.valueOf(data.get(position).getId()));
holder.info_name.setText(data.get(position).getName());
holder.info_age.setText(String.valueOf(data.get(position).getAge()));
holder.info_address.setText(data.get(position).getAddress());
holder.info_created.setText(dateParser(data.get(position).getCreatedAt()));
holder.info_update.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View v) {
holder.update_id.setText(String.valueOf(data.get(position).getId()));
holder.update_name.setText(data.get(position).getName());
holder.update_age.setText(String.valueOf(data.get(position).getAge()));
holder.update_address.setText(data.get(position).getAddress());
holder.update_created.setText(data.get(position).getCreatedAt().toString());
holder.info_layout.setVisibility(View.GONE);
holder.update_layout.setVisibility(View.VISIBLE);
}
});
holder.info_delete.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View v) {
dataService.delete.deleteOne(data.get(position).getId()).enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) {
data.remove(position);
notifyItemRemoved(position);
Toast.makeText(context, "아이템 삭제 완료", Toast.LENGTH_SHORT).show();
}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
t.printStackTrace();
}
});
}
});
holder.update_btn.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View v) {
Map<String, String> map = new HashMap();
map.put("name", holder.update_name.getText().toString());
map.put("age", holder.update_age.getText().toString());
map.put("address", holder.update_address.getText().toString());
dataService.update.updateOne(data.get(position).getId(), map).enqueue(new Callback<Member>() {
@Override
public void onResponse(Call<Member> call, Response<Member> response) {
data.set(position, response.body());
notifyDataSetChanged();
Toast.makeText(context, "아이템 수정 완료", Toast.LENGTH_SHORT).show();
holder.info_layout.setVisibility(View.VISIBLE);
holder.update_layout.setVisibility(View.GONE);
}
@Override
public void onFailure(Call<Member> call, Throwable t) {
t.printStackTrace();
}
});
}
});
}
public String dateParser(Date date){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM/dd hh:mm");
return simpleDateFormat.format(date);
}
public class ViewHolder extends RecyclerView.ViewHolder {
ConstraintLayout info_layout, update_layout;
TextView info_id, info_name, info_age, info_address, info_created, update_id, update_created;
Button info_update, info_delete, update_btn;
EditText update_name, update_age, update_address;
ViewHolder(View itemView) {
super(itemView);
// 뷰 영역
info_layout = itemView.findViewById(R.id.info_layout);
info_id = itemView.findViewById(R.id.info_id);
info_name = itemView.findViewById(R.id.info_name);
info_age = itemView.findViewById(R.id.info_age);
info_address = itemView.findViewById(R.id.info_address);
info_created = itemView.findViewById(R.id.info_created);
info_update = itemView.findViewById(R.id.info_update);
info_delete = itemView.findViewById(R.id.info_delete);
// 수정 영역
update_layout = itemView.findViewById(R.id.update_layout);
update_id = itemView.findViewById(R.id.update_id);
update_name = itemView.findViewById(R.id.update_name);
update_age = itemView.findViewById(R.id.update_age);
update_address = itemView.findViewById(R.id.update_address);
update_created = itemView.findViewById(R.id.update_created);
update_btn = itemView.findViewById(R.id.update_btn);
}
}
}
8) 메인 액티비티 정의
- 메인 액티비티에서는 Select와 Insert 요청이 사용됩니다.
MainActivity.java
public class MainActivity extends AppCompatActivity {
DataService dataService = new DataService();
List<Member> members;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final RecyclerView member_list = findViewById(R.id.member_list);
final EditText et_name = findViewById(R.id.et_name);
final EditText et_age = findViewById(R.id.et_age);
final EditText et_address = findViewById(R.id.et_address);
member_list.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
dataService.select.selectAll().enqueue(new Callback<List<Member>>() {
@Override
public void onResponse(Call<List<Member>> call, Response<List<Member>> response) {
members = response.body();
setAdapter(member_list);
}
@Override
public void onFailure(Call<List<Member>> call, Throwable t) {
t.printStackTrace();
}
});
Button btn_add = findViewById(R.id.btn_add);
btn_add.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View v) {
Map<String, String> map = new HashMap();
map.put("name", et_name.getText().toString());
map.put("age", et_age.getText().toString());
map.put("address", et_address.getText().toString());
dataService.insert.insertOne(map).enqueue(new Callback<Member>() {
@Override
public void onResponse(Call<Member> call, Response<Member> response) {
members.add(response.body());
setAdapter(member_list);
Toast.makeText(MainActivity.this, "유저 등록 완료", Toast.LENGTH_SHORT).show();
et_name.setText("");
et_age.setText("");
et_address.setText("");
}
@Override
public void onFailure(Call<Member> call, Throwable t) {
t.printStackTrace();
}
});
}
});
}
void setAdapter(RecyclerView member_list){
member_list.setAdapter(new MemberAdapter(members, this, dataService));
}
}
9) 인터넷 사용 권한 설정
- 통신을 위해서 인터넷 사용 권한을 설정합니다.
- 아래 코드의 위치와 맞게 권한 설정 코드 한 줄을 추가합니다.
<uses-permission android:name="android.permission.INTERNET"/>
- 코드
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="wlh.wickies.retrofitex">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
10) 비고
- 파일을 전송할때는 Multipart 타입을 사용하며 인터페이스 @Multipart 어노테이션을 명시합니다.
- 이전에 작성한 REST API가 SSL 인증 처리가 안되어서 http 주소를 사용하는데 안드로이드 파이 버전부터는 이 부분이 문제가 됩니다. 추후 이 부분을 해결하는 방법을 포스팅하도록 하겠습니다.
감사합니다.
'프로그래밍 > Android' 카테고리의 다른 글
[Android] RecyclerView 개요 및 예제, 성능 관리 팁 (0) | 2019.05.05 |
---|