Anroid REST API 연동기 [GET]

2023. 12. 22. 21:00·Android

드디어 안드로이드에서 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  (2) 2023.12.15
'Android' 카테고리의 다른 글
  • Android Platform Architecture
  • Android Main Thread
  • Android Gallery Permission
  • Android Runtime Permission
빨주노초잠만보
빨주노초잠만보
  • 빨주노초잠만보
    과거의 나를 통해 미래의 나를 성장시키자
    빨주노초잠만보
  • 전체
    오늘
    어제
    • 분류 전체보기 (108)
      • 우아한테크코스 (6)
      • TEKHIT ANDROID SCHOOL (4)
      • Android Architecture (8)
      • Android (38)
      • PROJECT (11)
      • KOTLIN (10)
        • 코루틴의 정석 (3)
      • BACK END (12)
      • CS (4)
      • 컨퍼런스 (4)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    2025 우아콘 후기
    orbit
    STATEFLOW
    process Context Switching
    android view lifecylce
    value class
    callbackflow
    Compose Typography
    의존성 주입
    안드로이드 디자인 시스템
    컴포즈 디자인 시스템
    코틀린 코루틴의 정석
    Room
    ThrottleFirst
    Two pass process
    coroutine Context Switching
    android clean architecture
    Throttle
    android Room
    thread Context Switching
    view 생명주기
    flow
    Clean Architecture
    repository
    DI
    Repository Pattern
    MVI
    sealed class
    DataSource
    retrofit call
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
빨주노초잠만보
Anroid REST API 연동기 [GET]
상단으로

티스토리툴바