드디어 안드로이드에서 REST 서버와의 연동이 끝났습니다!! 처음엔 그냥 안드로이드와 MySQL을 연동하려고 했던건데 하다보니까 Node JS, Docker, Nginx를 사용하게 됐고 모두 처음 사용해 보는거라 너무 어렵고 머리 아팠지만 다 하고나니까 너무 뿌듯하고 눈물이 날거 같네요 😭 😭 😭 😭
우선 저는 안드로이드와 MySQL을 연결하기 위한 백엔드를 구축하기에 앞서 NodsJS로 리버스 프록시를 하기 위해 웹 서버로 Ngix를 사용했습니다.
이제 GET, POST, PUT, DELETE 차례대로 코드를 하나 하나 살펴보면서 리뷰 해볼겠습니다.
이번장은 GET Process에 관한 글 입니다. 도커에 대한 내용은 다른글을 참조해 주세요
Docker + Node.js + Nginx 4
이번엔 MySQL 이미지를 설치해 보겠습니다.root 경로에 작업 폴더를 생성해줍시다. mysql/conf.d/my.conf에는 다음과 같이 작성해 줍시다. [client] default-character-set = utf8mb4 [mysql] default-character-set = utf8mb4 [my
chanho-study.tistory.com
[Nginx.conf]
upstream nodeserver {
server node:3000; # docker-compose.yml에서 정의한 node container 이름
}
server {
listen 80;
# 클라이언트가 Nginx에 / 경로로 들어올 경우 upstream에 정의한 경로(localhost:3000)로 포워딩
location / {
proxy_pass http://nodeserver/;
proxy_redirect default;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
location db/storeinfo {
proxy_pass http://nodeserver/db/storeinfo;
proxy_redirect default;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
먼저 Nginx쪽 코드부터 살펴보겠습니다.
upstream nodeserver {
server node:3000; # docker-compose.yml에서 정의한 node container 이름
}
- nodeserver 라는 이름의 upstream을 정의합니다. upstream은 여러 서버를 하나로 묶는 개념입니다. server node:3000 으로 되어있는데 여기서 node는 docker-compose.yml에서 정의한 express container 이름입니다. 만약 docker-compose.yml 에서 이름을 express가 아닌 server로 줬다면 server server:3000; 으로 명시해줘야 합니다.
location db/storeinfo {
proxy_pass http://nodeserver/db/storeinfo;
proxy_redirect default;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
- Clien가 /db/storeinfo 경로로 요청을 보내면 nodeserver upstream 에 정의된 경로로 포워딩 해줍니다. 즉, Client가 앞단의 Nginx로 접속시 express container 3000번 포트로 패킷을 포워딩하고 최종 요청 경로는http://localhost:3000/db/storeinfo 입니다.
[server.js]
// server.js
// NET Stop HTTP
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const mysql = require('mysql2');
const app = express();
const port = 3000;
//Localhost:80 또는 3000으로 들어오는 요청을 받음
app.get('/', (req, res) => {
res.send("Docker With Nodejs")
})
app.use(express.json());
// post 요청 시 값을 객체로 바꿔줌
app.use(express.urlencoded({ extended: true }));
//정적 파일 제공하기위한 static
//현재 '/store_images_volume' 의 디렉토리에 있는 정적 파일에 외부 접근을 허용한 상태
app.use(express.static('/store_images_volume'));
const fileFilter = (req, file, cb) => {
// 확장자 필터링
if (
file.mimetype === "image/png" ||
file.mimetype === "image/jpg" ||
file.mimetype === "image/jpeg"
) {
cb(null, true); // 해당 mimetype만 받겠다는 의미
} else {
// 다른 mimetype은 저장되지 않음
req.fileValidationError = "jpg,jpeg,png,gif,webp 파일만 업로드 가능합니다.";
cb(null, false);
}
};
// 디스크에 이미지 저장을 위한 multer 설정
const storage = multer.diskStorage({
destination: '/store_images_volume',
filename: (req, file, cb) => {
const fileName = 'main_img' + Date.now() +'.'+ file.originalname.split('.').pop();
cb(null, fileName);
},
fileFilter : fileFilter,
//최대 30MB
limits: { fileSize: 30 * 1024 * 1024 }
});
const upload = multer({ storage: storage });
// MySQL 연결 설정
const connection = mysql.createConnection({
host: 'mysql',
user: 'root',
password: 'root',
database: 'cloudbridge_database'
});
app.get("/db/storeinfo/:crn", (req, res) =>{
const crn = req.params.crn
connection.query(
`SELECT * FROM STORE_INFO WHERE crn = ?`,
[crn],
(err, result, fields) => {
if (!err) {
// 쿼리가 성공하면 결과를 클라이언트에게 보냄
const storeInfo = result[0];
if(storeInfo){
const imgPath = storeInfo.image_path;
// 이미지 경로 삭제
delete storeInfo.image_path;
// 이미지를 Base64로 인코딩
const imageBase64 = fs.readFileSync(imgPath, 'base64');
res.setHeader('Content-Type', 'image/*');
// 이미지와 result 데이터를 함께 응답
const responseData = {
image: imageBase64,
result: storeInfo
};
res.json(responseData);
}else{
res.status(404).send("Store not found");
}
} else {
// 쿼리 오류 시 에러를 클라이언트에게 보냄
console.error("Error executing query:", err);
res.status(500).send("Internal Server Error");
}
}
)
})
app.listen(port, () => {
console.log("서버가 3000 포트에서 실행 중입니다.");
});
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const mysql = require('mysql2');
const app = express();
const port = 3000;
- Multer : Node.js에서 파일 처리를 위한 미들웨어 입니다. 특히 "multipart/form-data" 형식으로 전송되는 데이터에서 파일 업로드를 처리할 수 있도록 도와줍니다. multipart에 대한 내용은 여기서 확인하실 수 있습니다.
HTTP multipart/form-data
이번엔 node.js로 이미지를 전송하는 서버를 만들던 중 multipart/form-data란 개념이 있어 알아보고 HTTP, multipart에 대해서도 알아보겠습니다. 1. HTTP(Hypertext Transfer Protocol)란? 인터넷 상에서 클라이언트
chanho-study.tistory.com
- fs : 파일을 읽고 쓰는 등 파일 조작에 사용되는데 여기선 파일을 읽고 삭제하는데 사용되었습니다.
- mysql2: mysql2는 MySQL 데이터베이스에 대한 Node.js 드라이버입니다. 이 모듈을 사용하여 MySQL 데이터베이스에 연결하고 쿼리를 실행할 수 있습니다
app.use(express.json());
// post 요청 시 값을 객체로 바꿔줌
app.use(express.urlencoded({ extended: true }));
//정적 파일 제공하기위한 static
//현재 '/store_images_volume' 의 디렉토리에 있는 정적 파일에 외부 접근을 허용한 상태
app.use(express.static('/store_images_volume'));
- app.use(express.json() : 클라이언트에서 전송된 JSON 형식의 데이터를 서버에서 사용하기 위해 필요한 변환 작업을 수행 합니다. Request의 본문이 JSON 형식인 경우 데이터를 파싱해 JavaScrpit 객체로 변환합니다.
- app.use(express.urlencoded({ extended: true })) : application/x-www-form-urlencoded 형식의 데이터를 파싱하여 JavaScript 객체로 변환합니다.
- app.use(express.static('/store_images_volume')) : /store_images_volume 경로에 있는 정적 파일(이미지 파일 등)에 대한 외부 접근을 허용합니다.
const fileFilter = (req, file, cb) => {
// 확장자 필터링
if (
file.mimetype === "image/png" ||
file.mimetype === "image/jpg" ||
file.mimetype === "image/jpeg"
) {
cb(null, true); // 해당 mimetype만 받겠다는 의미
} else {
// 다른 mimetype은 저장되지 않음
req.fileValidationError = "jpg,jpeg,png,gif,webp 파일만 업로드 가능합니다.";
cb(null, false);
}
};
- 클라이언트에게 이미지를 전달받을 때 확장자를 필터링 하기 위한 구문입니다. 지정되지 않은 타입의 파일이 전송될 경우 callback 함수로 에러를 반환합니다.
// 디스크에 이미지 저장을 위한 multer 설정
const storage = multer.diskStorage({
destination: '/store_images_volume',
filename: (req, file, cb) => {
const fileName = 'main_img' + Date.now() +'.'+ file.originalname.split('.').pop();
cb(null, fileName);
},
fileFilter : fileFilter,
//최대 30MB
limits: { fileSize: 30 * 1024 * 1024 }
});
- destination : 클라이언트에게 전달받은 이미지를 저장할 Directory를 지정합니다. 이 때 Directory는 Local Directory가 아닌 Docker NodeJS 컨테이너에 존재하는 Directory 입니다. 자세한 내용은 링크를 참조해 주세요!
우당탕탕 Node JS Server 다시보기
이미지를 전송 받는 서버를 위해 고군분투 했던 저의 피 땀 눈물을 담은 글입니다. 나중에 똑같은 일을 두번 겪고 싶지 않아서 제가 했던 과정들을 기록해 볼게요 첫번째 난관은 바로 POST 요청
chanho-study.tistory.com
- filename : 파일 이름에 현재 시간을 추가해 중복을 방지해 줍니다. 생성된 파일 네임은 콜백 함수로 반환됩니다.
- fileFilter : 위에서 생성해준 fileFiler를 지정해줍니다.
- limits : 저장할 수 있는 최대 파일의 크기를 지정해 줍니다. 여기선 30MB를 지정했습니다.
// MySQL 연결 설정
const connection = mysql.createConnection({
host: 'mysql',
user: 'root',
password: 'root',
database: 'cloudbridge_database'
});
- MySQL과의 연결을 할 객체를 생성해 줍니다. docker-compose.yml에서 MySQL 컨테이너에 지정해준 정보를 작성해 주면 됩니다.
app.get("/db/storeinfo/:crn", (req, res) =>{
const crn = req.params.crn
connection.query(
`SELECT * FROM STORE_INFO WHERE crn = ?`,
[crn],
(err, result, fields) => {
if (!err) {
// 쿼리가 성공하면 결과를 클라이언트에게 보냄
const storeInfo = result[0];
if(storeInfo){
const imgPath = storeInfo.image_path;
// 이미지 경로 삭제
delete storeInfo.image_path;
// 이미지를 Base64로 인코딩
const imageBase64 = fs.readFileSync(imgPath, 'base64');
res.setHeader('Content-Type', 'image/*');
// 이미지와 result 데이터를 함께 응답
const responseData = {
image: imageBase64,
result: storeInfo
};
res.json(responseData);
}else{
res.status(404).send("Store not found");
}
} else {
// 쿼리 오류 시 에러를 클라이언트에게 보냄
console.error("Error executing query:", err);
res.status(500).send("Internal Server Error");
}
}
)
})
# Anroid Request Code
@GET("/db/storeinfo/{crn}")
suspend fun getMyStoreInfo(@Path("crn") crn: String): MyStoreInfoResponseModel
- app.get("/db/storeinfo/:crn") : MySQL에선 crn이라는 값이 Primary Key로 각 데이터를 구분하는 키 입니다. 그래서사용자에게 GET 요청과 함께 crn이라는 값을 전달받습니다. 전달 받은 값은 req.params.crn을 통해 가져올 수 있습니다. 안드로이드 Retrofit에서는 @Path 어노테이션을 통해 이 값을 전달할 수 있습니다.
connection.query(
`SELECT * FROM STORE_INFO WHERE crn = ?`,
[crn],
- MySQL에 전달할 쿼리를 작성합니다. 이 때 ? 쓰는 것을 프리페어드 스테이먼트(Prepared Statement) 방식이라고 합니다. 이는 SQL 인젝션 공격을 방지하는데 도움이 됩니다. 사용자 입력을 직접 쿼리에 삽입하는 대신, 데이터베이스 드라이버가 입력 값을 안전하게 처리합니다.
if(storeInfo){
const imgPath = storeInfo.image_path;
// 이미지 경로 삭제
delete storeInfo.image_path;
// 이미지를 Base64로 인코딩
const imageBase64 = fs.readFileSync(imgPath, 'base64');
res.setHeader('Content-Type', 'image/*');
// 이미지와 result 데이터를 함께 응답
const responseData = {
image: imageBase64,
result: storeInfo
};
res.json(responseData);
- const imageBase64 = fs.readFileSync(imgPath, 'base64') : 파일을 읽어 Base64 타입으로 변환합니다. Base64는 데이터를 텍스트로 인코딩하는 방법 중 하나로, 이진 데이터를 ASCII 문자로 변환하는 인코딩 체계입니다. 이진 데이터를 텍스트로 안전하게 전송하거나 저장할 때 주로 사용됩니다.
- responseData : 이미지와 storeInfo를 JSON 형태로 사용자에게 response를 보냅니다.
- 안드로이드에서 서버로 부터 응답을 받을 데이터의 형태를 지정해줍니다.
data class MyStoreInfoResponseModel(
val image: String,
val result: MyStoreInfoResultModel
)
data class MyStoreInfoResultModel(
val CRN: String,
val address: String,
val ceoName: String,
val contact: String,
val kind: String,
val latitude: String,
val longitude: String,
val storename: String
)
object MySQLIStoreInstance {
val BASE_URL = "http://172.30.1.7/"
private val gson : Gson = GsonBuilder()
.setLenient()
.create()
private val client: Retrofit = Retrofit
.Builder()
.baseUrl(BASE_URL)
.client(MyOkHttpClient.client)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
fun getInstance(): StoreInfoApi = client.create(StoreInfoApi::class.java)
}
- Retrofit 객체를 생성해 줍니다. Retrofit에 대한 내용은 이 글 을 참조해 주세요! Base URL은 Window의 경우에는 사용자 로컬 컴퓨터의 IP를 지정해 줍니다. 윈도우를 사용하신다면 cmd 창에서 ipconfig 를 입력하면 확인할 수 있습니다.
Android Network Programming 1
이 글 을 마지막으로 기본적인 Docker 기반의 서버 구축이 완료 되었습다. 추후 테이블도 변경하고 수정할 사항이 많겠지만 빨리 내가 직접 만든 서버와 통신을 해보고 싶어서 메다닥 달려왔습니
chanho-study.tistory.com
interface StoreInfoApi {
@GET("/db/storeinfo/{crn}")
suspend fun getMyStoreInfo(@Path("crn") crn: String): MyStoreInfoResponseModel
}
- Retrofit Service Interface를 선언해 주었습니다. @Path 어노테이션을 사용해 crn을 파라미터로 전달했습니다.
class MyPageViewModel: ViewModel() {
private val storeInfoApi = MySQLIStoreInstance.getInstance()
private val _myStore = MutableLiveData<MyStoreInfoResponseModel>()
val myStore: LiveData<MyStoreInfoResponseModel>
get() = _myStore
fun setMyStoreInfo() = viewModelScope.launch(Dispatchers.IO) {
try {
MyDataStore().getCrn().collect{ crn->
val response = storeInfoApi.getMyStoreInfo(crn)
_myStore.postValue(response)
}
}catch (e: Exception){
Log.d("MyPageViewModel","MyPageViewModel: $e")
}
}
- UI와 비지니스 로직을 분리하기 위해 ViewModel Class를 생성해 주었습니다.
private val _myStore = MutableLiveData<MyStoreInfoResponseModel>()
val myStore: LiveData<MyStoreInfoResponseModel>
get() = _myStore
- myStore는 외부에서 이 데이터를 읽을 수 있지만 변경할 수 없습니다.
- _ myStore는 변경 가능한 객체지만 외부에서 읽을 수 없습니다.
- 이 패턴을 사용하는 이유는 데이터의 단방향성을 보장하고 코드를 더 모듈화 하기 위함 입니다.
viewModelScope.launch(Dispatchers.IO)
- viewModel 생명주기 내에서 I/O 작업을 비동기 식으로 처리하기 위한 Coroutine을 생성합니다.
- viewmodelScope : viewModel의 생명주기를 따라가는 Coroutune입니다. ViewModel이 활성 상태일 때 Coroutine이 실행되고, ViewModel이 파괴되면 Coroutine도 함께 취소됩니다.
- launch : viewModelScope에서 제공하는 Coroutine 범위에서 Coroutine을 시작하는 함수입니다.
- Dispatchers.IO : 입출력 작업을 위해 최적화된 백그라운드 스레드를 사용하도록 지정합니다.
MyDataStore().getCrn().collect{ crn->
val response = storeInfoApi.getMyStoreInfo(crn)
_myStore.postValue(response)
}
- MyDataStore().getCrn().collect : 저는 매장 등록을 할 때 사업자 등록 번호(crn)을 DataStore에 저장 해주었습니다.
- val response = storeInfoApi.getMyStoreInfo(crn) : 네트워크 통신을 수행합니다.
- _myStore.postValue(response) : 네트워크 통신으로 전달받은 response를 Live data에 저장해 줍니다.
class MyStoreActivity : AppCompatActivity() {
private lateinit var binding: ActivityMyStoreBinding
private val viewModel: MyPageViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMyStoreBinding.inflate(layoutInflater)
setContentView(binding.root)
viewModel.setMyStoreInfo()
viewModel.myStore.observe(this){ data ->
val bitmap = StringToBitmaps(data.image)
binding.mainImage.setImageBitmap(bitmap)
binding.storeNameTextView.text = data.result.storename
binding.storeCprTextView.text = data.result.CRN
binding.storePhoneTextView.text = data.result.contact
binding.storeRepreNameTextView.text = data.result.ceoName
binding.storeKindTextView.text = data.result.kind
binding.storeAddrTextView.text = data.result.address
}
- 응답으로 받아온 데이터를 기반으로 UI를 그려주었습니다!
'Android' 카테고리의 다른 글
Android Platform Architecture (0) | 2023.12.25 |
---|---|
Android Main Thread (2) | 2023.12.23 |
Android Gallery Permission (0) | 2023.12.17 |
Android Runtime Permission (1) | 2023.12.17 |
OkHTTP httpLoggingInterceptor (1) | 2023.12.15 |