본문 바로가기

안드로이드 앱개발

안드로이드 에뮬레이터에서 이미지 불러와서 Spring 모바일 앱 서버를 이용해 DB에 저장하기

반응형

안녕하세요. 오늘은 안드로이드 에뮬레이터에서 이미지 불러와서 Spring 모바일 앱 서버를 이용해 DB에 저장에 대하여 작성해보겠습니다.

1. Image upload의 기본 원리

1. 안드로이드 에뮬레이터에서 이미지를 가져온다.

2. Multipart 형식으로 image를 지정한다.

3. retrofit2를 이용해 Spring에서 해당 image를 MultipartFile 형식으로 받아온다.

4. MultipartFile형식으로 받아온 이미지에 대하여, 해당 파일을 local 컴퓨터의 파일에 저장하고, 저장한 파일의 경로를 DB에 저장해준다.

 

2. Image download의 기본 원리

1. 안드로이드에서 필요한 이미지가 저장되어 있는 Local computer file path를 Spring에게 요청한다.

2. Spring은 String값으로 안드로이드에게 path값을 보내준다.

2. 안드로이드에서 해당 path를 parameter로 해당 이미지를 요청한다.

3. Spring에서 해당 path를 바탕으로 안드로이드에게 image파일을 제공한다.

 

3. Image upload 구현

1) Android emulator에서 image를 가져오는 방법

 

해당 화면에 대한 전체 코드는

 

@Composable
fun AddNoticeScreen(
    navController: NavHostController,
    viewModel: HomeViewModel,
    modifier: Modifier = Modifier
){

    //var == 가변
    var selectedImageUri by remember { mutableStateOf<Uri?>(null)}

    val context = LocalContext.current

    //val == 불변(final)
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        selectedImageUri = uri
        viewModel.imageUri = uri
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Top,
        horizontalAlignment = Alignment.Start
    ) {
        Text(text = "제목")

        Spacer(modifier = Modifier.height(16.dp))

        TextField(
            value = viewModel.title,
            onValueChange = { viewModel.title = it },
            label = { Text("제목") },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(8.dp))

        Text(text = "내용")
        TextField(
            value = viewModel.text,
            onValueChange = { viewModel.text = it },
            label = { Text("내용") },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(16.dp))


        Row {
            Text(text = "사진 첨부")
            Button(onClick = {
                launcher.launch("image/*")
            }) {
                Text("사진첨부")
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        Text(text = "제한 조건")

        Spacer(modifier = Modifier.height(16.dp))

        selectedImageUri?.let {
            Image(
                painter = rememberImagePainter(it),
                contentDescription = "Selected Image",
                modifier = Modifier.size(100.dp)
            )
        } ?: Image(
            painter = painterResource(id = R.drawable.add_img),
            contentDescription = "addImage",
            modifier = Modifier.size(100.dp)
        )



        Spacer(modifier = Modifier.height(16.dp))

        Box(modifier = Modifier.fillMaxWidth()){
            Button(
                onClick = {
                    viewModel.addNotice(context)
                    navController.popBackStack()
                },
                modifier = Modifier.align(Alignment.Center)
            ) {
                Text("작성하기")
            }
        }
        
    }
}

 

위와 같이 구현하였습니다.

이 코드에 대한 설명을 해보겠습니다.

 

var selectedImageUri by remember { mutableStateOf<Uri?>(null)}

 

selectedImageUri를 통해 emulator로 부터 받아온 image에 대한 uri를 저장합니다.

 

val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        selectedImageUri = uri
        viewModel.imageUri = uri
    }

 

launcher 변수에는 rememberLauncherForActivityResult를 사용하여 사용자가 갤러리에서 이미지를 선택할 수 있도록 하는 런처를 생성합니다.

 

ActivityResultContracts.GetContent()를 이용해 image/* 타입의 이미지를 선택할 수 있도록 구성합니다.

 

이미지 선택 후 URI값이 selectedImageUri 및 viewModel.imageUri에 설정됩니다.

 

selectedImageUri?.let {
            Image(
                painter = rememberImagePainter(it),
                contentDescription = "Selected Image",
                modifier = Modifier.size(100.dp)
            )
        } ?: Image(
            painter = painterResource(id = R.drawable.add_img),
            contentDescription = "addImage",
            modifier = Modifier.size(100.dp)
        )

 

이후, selectedImageUri를 기반으로 + 모양 이미지를 선택한 이미지로 대체하여 표시해줍니다.

 

2) 가져온 이미지를 Spring 서버로 보내는 방법

fun addNotice(context: Context){
        val file = File(context.cacheDir, "upload_image.jpg")
        try {
            context.contentResolver.openInputStream(imageUri!!)?.use { inputStream ->
                file.outputStream().use { outputStream ->
                    inputStream.copyTo(outputStream)
                }
            }
            Log.d("Add Notice", "File created at: ${file.absolutePath}")
        } catch (e: Exception) {
            Log.e("Add Notice", "Failed to process image file: ${e.message}")
            return
        }

        val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
        val fileBody = MultipartBody.Part.createFormData("noticeImg", file.name, requestFile)

        viewModelScope.launch(Dispatchers.IO) {
            val userDao = UserDatabase.getDatabase(context).userDao()
            val usersRepository: UsersRepository = OfflineUsersRepository(userDao)

            val userEmail = usersRepository.getMostRecentUserName()

            val response = SecretDiaryObject.getRetrofitSDService.upload(userEmail!!,title,text,fileBody)
            if(response.isSuccessful){
                Log.d("Add Notice","Success")
            } else {
                val errorBody = response.errorBody()?.string()
                val statusCode = response.code()
                Log.d("Add Notice", "Failed: StatusCode = $statusCode, Error = $errorBody")
            }
        }
    }

 

이 코드는 viewModel에 정의한 게시물을 Spring을 이용해 DB에 저장하는 코드입니다.

 

위의 AddNoticeScreen에서 설정한 viewModel.uri 값과 작성하기 버튼을 눌러 호출한 해당 함수를 통해 이미지를 저장하는 과정을 수행하게 됩니다.

 

이제 해당 함수의 코드에 대해 설명해보겠습니다.

 

File(context.cacheDir, "upload_image.jpg")를 사용하여 캐시 디렉토리에 upload_image.jpg라는 이름의 빈 파일을 생성하고 file 변수에 저장합니다.

 

파일 생성 후 context.contentResolver.openInputStream(imageUri!!)를 통해 사용자가 선택한 이미지의 URI를 이용하여 입력 스트림(InputStream)을 열고, 이 스트림을 생성한 파일로 복사합니다.

 

이후에는 file.asRequestBody("image/*".toMediaTypeOrNull())를 사용하여 파일을 RequestBody로 변환합니다. 

asRequestBody는 파일을 서버에 전송 가능한 형태로 변환하는 함수입니다.

 

이후, MultipartBody.Part.createFormData("noticeImg", file.name, requestFile)를 사용하여 Multipart 요청의 Part 객체를 생성합니다.

 

"noticeImg"은 서버가 이미지 파일을 받을 때 사용하는 필드명이며, file.name은 서버에 전송될 파일의 이름을 나타냅니다.

 

이제 그 이후 코드에 대한 내용은 제 블로그에 이미 작성했었으니 아래의 링크를 참고 부탁드립니다.

 

https://pinlib.tistory.com/entry/Kotlin-coroutine

 

Kotlin Coruoutins과 비동기에 관하여

안녕하세요. 오늘은 Kotlin Coroutine과 비동기에 대하여 작성해보겠습니다.코루틴에 대해 이해하기 위해서는 우선적으로 비동기 처리에 대한 이해가 선행되어야 합니다. 1. 비동기 처

pinlib.tistory.com

 

https://pinlib.tistory.com/entry/retrofit2-1

 

Retrofit2를 이용한 안드로이드와 스프링 서버 통신(안드로이드편)(안드로이드 서버통신)

안녕하세요. 오늘은 Retrofit2를 이용한 안드로이드와 스프링 서버 통신에 대하여 작성해보겠습니다. 1. Retrofit2란Retrofit2는 Android 및 Java 애플리케이션에서 HTTP 네트워크 통신을 쉽게 

pinlib.tistory.com

 

 

이제는 retrofit2를 이용해 Spring과 연결하여 보내줄 차례입니다.

 

@Multipart
    @POST("upload")
    suspend fun upload(
        @Part("userEmail") userEmail: String,
        @Part("noticeTitle") noticeTitle: String,
        @Part("noticeText") noticeText: String,
        @Part noticeImage: MultipartBody.Part
    ): Response<ResponseBody>

 

이 코드에 대한 설명을 해보겠습니다.

 

위에서도 언급했던 @Multipart란 해당 API method가 Multipart형식으로 요청을 보낼것을 나타내는 annotation입니다.

Multipart 요청은 파일 업로드와 같은 바이너리 데이터 전송에 사용됩니다. 

@Multipart을 지정하면 각 파라미터가 @Part로 정의된 형태의 Multipart 본문으로 변환됩니다.

 

@PartMultipart 요청의 각 파트(부분)을 지정하는 데 사용됩니다. 예를 들어, 텍스트 데이터와 파일 데이터를 함께 서버에 전송하려면 각각을 @Part로 설정하여 요청 본문을 구성합니다.

 

즉, 이 함수는 단순 텍스트 데이터뿐만 아니라 파일 데이터를 서버로 전송할 수 있습니다.

 

MultipartBody.Part의 경우 이미지 파일을 Multipart 형식으로 전송할 때 사용됩니다.

파일업로드가 핵심인 파라미터로, 서버의 필드명은 noticeImage로 사용되며, 이 필드에 해당 파일이 전송됩니다.

 

MultipartBody.Part.createFormData("noticeImg", file.name, requestFile)와 같은 방식으로 생성된 MultipartBody.Part 객체를 서버에 전달하게 됩니다.

 

 

3) Spring 서버에서 저장하는 방법

이제 Spring의 controller에서 해당 게시물에 대한 data를 받아올 차례입니다.

 

@PostMapping("upload")
    public ResponseEntity<String> uploadFile(
            @RequestPart("noticeImg") MultipartFile file,
            @RequestPart("userEmail") String userEmail,
            @RequestPart("noticeTitle") String title,
            @RequestPart("noticeText") String text) {

        if (file.isEmpty()) {
            return new ResponseEntity<>("File is empty", HttpStatus.BAD_REQUEST);
        }

        try {
            String fileName = file.getOriginalFilename();
            // 파일 처리 로직 추가
            NoticeDTO noticeDTO = new NoticeDTO();
            noticeDTO.setUserEmail(userEmail);
            noticeDTO.setNoticeTitle(title);
            noticeDTO.setNoticeText(text);
            noticeDTO.setNoticeImg(file);
            noticeDTO.setDate(LocalDateTime.now().toString());

            NoticeDTO response = noticeService.saveNotice(noticeDTO);
            return new ResponseEntity<>("File uploaded successfully: " + fileName, HttpStatus.OK);
        } catch (Exception e) {
            return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

 

@RequestPart Spring에서 Multipart 요청의 각 파트를 매핑하기 위해 사용하는 어노테이션입니다.

 

MultipartFile Spring에서 제공하는 파일업로드를 처리하기 위한 클래스입니다.

 

MultipartFile 객체를 통해 업로드된 파일의 이름, 크기, 내용, 파일 유형 등을 가져올 수 있습니다.

 

이후 해당 파일에 대하여 getOriginalFilename()을 통해 파일의 원래 이름을 반환합니다.

 

그 다음에는 dto에 저장한 내용을 noticeService의 saveNoticed에 전달하여 DB저장 과정을 계속합니다.

 

 

이제부터 service 파일에 정의한 메소드들에 대해 작성하겠습니다.

@Value("${upload.path}")
    String uploadPath;

 

우선적으로 해당 코드를 통해 application.properties에 정의한 이미지를 저장할 로컬 컴퓨터의 파일 경로를 지정합니다.

 

@Override
    public NoticeDTO saveNotice(NoticeDTO dto){

        MultipartFile file = dto.getNoticeImg();

        String originalFileName = file.getOriginalFilename();

        String saveFileName = createSaveFileName(originalFileName);


        try {
            //내 컴퓨터에 파일을 저장함
            dto.getNoticeImg().transferTo(new File(getFullPath(saveFileName)));

        } catch (IOException e){
            e.printStackTrace();
            throw new RuntimeException("Failed to save file", e);
        }

        Notice notice = noticeDataHandler.saveNoticeEntity(dto.getUserEmail(), dto.getNoticeTitle(), dto.getNoticeText(), saveFileName, dto.getDate());
        NoticeDTO noticeDTO = new NoticeDTO(notice.getNoticeId(), notice.getUserEmail(), notice.getNoticeTitle(), notice.getNoticeText(), dto.getNoticeImg(), notice.getDate());

        return noticeDTO;
    }

 

일단 getOriginalFilename()을 통해 업로드된 파일의 원본명을 가져옵니다.

이후, 원본 파일명을 바탕으로 createSaveFileName 메소드를 통해 저장할 파일명을 생성합니다. 

 

private String createSaveFileName(String originalFileName){
        String ext = extractExt(originalFileName);
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + ext;
    }

 

createSaveFileName 메소드를 이용해 UUID 형식으로 고유한 파일명을 생성하여 중복된 파일명이 저장되지 않도록 합니다.

 

 

private String extractExt(String originalFileName){
        int pos = originalFileName.lastIndexOf(".");
        return originalFileName.substring(pos+1);
    }

 

createSaveFileName에서 extractExt를 이용해 파일명에서 .뒤에 붙는 확장자를 추출하여 저장할 파일명에 확장자를 설정합니다.

 

private String getFullPath(String fileName){
        return uploadPath + fileName;
    }

 

이후에는 다시 saveNotice함수로 돌아와 saveFileName의 경로를 getFullPath를 통해 설정하고

transferTo() 메소드를 통해 File객체로 지정한 경로에 파일을 저장합니다.

 

이후에는 notice객체에 saveFileName이라는 로컬 컴퓨터에 저장한 파일명을 parameter로 dataHandler로 해당 data를 전달하고 

다음에는 dao로 전달하고 마지막으로 Repository에 전달하며 DB에는 결론적으로 파일명이 저장되는 원리입니다.

 

 

 

4. Image download 구현

1) Android에서 Spring 서버로부터 image 요청하기

 

위 사진과 같이 Secret Diary에서는 게시물들의 이미지를 서버로부터 받아와서(download) 구현하고 있습니다. 

세부적으로 그 과정을 알아보자면 

각각의 LazyColumn에서 호출하는 NoticeListItem의 경우

@Composable
fun NoticeListItem(notice: RNoticeModel, navController: NavHostController, onClick: () -> Unit) {
    Row(
        modifier = Modifier
            .clickable {
                onClick()
            }
            .padding(8.dp)
    ) {
        NoticeImage(
            notice = notice,
            modifier = Modifier
                .padding(8.dp)
                .size(84.dp)
                .clip(RoundedCornerShape(16.dp))
        )
        Column {
            Text(text = "this Id is ${notice.noticeId}")
            Text(text = notice.noticeTitle)
            Text(text = notice.noticeText)
            //Text(text = notice.date) //test date update
        }
    }
}

 

NoticeImage라는 method를 호출하여 이미지를 표시하고 있습니다. 

 

NoticeImage의 경우 

@Composable
fun NoticeImage(
    notice: RNoticeModel,
    modifier: Modifier
) {
    val imageUrl = "http://10.0.2.2:8080/notice/image/${notice.noticeImgPath}"

    GlideImage(
        imageModel = imageUrl,
        contentDescription = "Notice Image",
        modifier = modifier.fillMaxWidth(),
        contentScale = ContentScale.Crop,
        error = ImageBitmap.imageResource(id = R.drawable.test)

    )
}

 

위와 같은 형식으로 Glide library를 이용하여 구현하였습니다.

 

Glide의 경우 

//glide
    implementation ("com.github.bumptech.glide:glide:4.12.0")
    kapt ("com.github.bumptech.glide:compiler:4.12.0")
    implementation ("com.github.skydoves:landscapist-glide:1.4.7")

 

app수준의 build.grdle에 위 코드를 추가해야 합니다.

 

 

다시 NoticeImage 코드를 보시면 결론적으로 notice.noticeImgPath를 이용해 이미지를 download하는 것을 확인할 수 있습니다.

 

그렇다면 notice.noticeImgPath의 경우 어떻게 가져오는지 집중해보겠습니다.

NoticeImage의 경우 notice:RNoticeModel을 parameter로 하고 있고, 

NoticeListItem으로부터 이를 받아오며, 이 역시도 notice:RNoticeModel을 parameter로 하고 있고

결론적으로는 HomeScreen에서 

val notices by homeViewModel.notices.collectAsState()

 

이에 의해  제어되는듯한 모습을 보입니다.

허나 놓치고 있는 사실은 viewModel입니다.

 

//read All Notice
private val _notices = MutableStateFlow<List<RNoticeModel>>(emptyList())
val notices: StateFlow<List<RNoticeModel>> = _notices

init {
        readMyNotice()
    }

 

fun readMyNotice(){
        viewModelScope.launch(Dispatchers.IO) {
            val userEmail = userRepository.getMostRecentUserName()
            val response = SecretDiaryObject.getRetrofitSDService.readUserNotice(userEmail!!)
            if(response.isSuccessful){
                response.body()?.let {
                    _notices.value = it
                }
            } else {
                response.errorBody()?.let {
                    Log.d("read user notice", "Error body: ${it.string()}")
                }
            }
        }
    }

 

흐름을 다시 설명드리자면, viewModel의 init안에 들어있는 readMyNotice(게시물 읽어오기) method가 viewModel이 생성되자마자 수행됩니다.

 

이에 따라 viewModel에 정의한 _notices의 값을 _notices.value를 통해 값을 변경하게 되고,

그 다음으로 notices의 값이 변경되게 되며, 이를 collectAsState를 사용하는 notices에서 변화를 감지하고 UI를 변경하게 되며 모든 notice의 값을 전달하고 UI의 변화가 생기는 것입니다. 

 

이제는 readMyNotice method에서 사용한 readUserNotice의 경우 retrofit2 API method입니다.

 

@GET("read/notice/user")
    suspend fun readUserNotice(@Query("userEmail") query: String): Response<List<RNoticeModel>>

 

위와 같은 형식으로 Spring에 해당 데이터를 요청하게 됩니다.

 

RNoticeModel의 경우

data class RNoticeModel(
    @SerializedName("noticeId")
    val noticeId: Long,

    @SerializedName("userEmail")
    val userEmail: String,

    @SerializedName("noticeTitle")
    val noticeTitle: String,

    @SerializedName("noticeText")
    val noticeText: String,

    @SerializedName("noticeImgPath")
    val noticeImgPath: String?,

    @SerializedName("date")
    val date: String
)

 

noticeImgPath라는 String값을 Spring에 요구하며, 실질적으로는 data type이 String type임을 볼 수 있습니다.

 

 

2) Spring에서 Android요청에 따른 image 보내주기

Spring의 controller에서는 위 요청에 대하여 아래와 같은 코드로 받아옵니다.

@GetMapping("read/notice/user")
    public List<RNoticeDTO> readUserNotice(@RequestParam("userEmail") String userEmail){
        return noticeService.getReadUserNotice(userEmail);
    }

 

controller에서의 readUserNotice method는 service의 getReadUserNotice method를 call하고 있습니다.

 

service의 getReadUserNotice method의 경우

@Override
    public List<RNoticeDTO> getReadUserNotice(String userEmail){
        String formattedEmail = "\"" + userEmail + "\"";

        // 큰따옴표가 포함된 email로 검색
        List<Notice> entities = noticeRepository.findByUserEmail(formattedEmail);
        List<RNoticeDTO> dtos = new ArrayList<>();

        for (Notice entity : entities) {
            RNoticeDTO noticeDTO_R = new RNoticeDTO();

            noticeDTO_R.setNoticeId(entity.getNoticeId()); //test
            noticeDTO_R.setUserEmail(entity.getUserEmail());
            noticeDTO_R.setNoticeTitle(entity.getNoticeTitle());
            noticeDTO_R.setNoticeText(entity.getNoticeText());
            noticeDTO_R.setNoticeImgPath(entity.getNoticeImg());
            noticeDTO_R.setDate(entity.getDate());
            dtos.add(noticeDTO_R);
        }
        return dtos;
    }

 

게시물에 대한 호출은 된 것처럼 보이나, 이미지 처리에는 뭔가 부족해보이는 듯한 느낌이 보입니다.

서버에 저장한 image의 path만 가져올 뿐, 이미지 자체에 대한 download는 수행하지 않기 떄문입니다.

 

결론부터 말씀드리자면, Image의 경우 android에서 NoticeImage함수에서 호출했던

val imageUrl = "http://10.0.2.2:8080/notice/image/${notice.noticeImgPath}"

 

이에 대한 Spring controller method를 따로 구현해줘야 한다는 것입니다.

 

@GetMapping("/notice/image/{filename}")
    public ResponseEntity<Resource> getImage(@PathVariable("filename") String filename) {
        try {
            Path filePath = Paths.get(uploadPath).resolve(filename);
            Resource resource = new UrlResource(filePath.toUri());

            if (!resource.exists() || !resource.isReadable()) {
                throw new RuntimeException("Could not read the file!");
            }

            String contentType = Files.probeContentType(filePath);
            if (contentType == null) {
                contentType = "application/octet-stream";
            }

            return ResponseEntity.ok()
                    .contentType(MediaType.parseMediaType(contentType))
                    .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + resource.getFilename() + "\"")
                    .body(resource);

        } catch (MalformedURLException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
        } catch (IOException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
        }
    }

 

이제 이 코드에 대한 설명을 시작하겠습니다.

Path filePath = Paths.get(uploadPath).resolve(filename);

 

resolve(filename) 메서드는 uploadPath와 요청된 파일명을 결합하여 파일의 전체경로를 나타내는 filePath를 생성합니다.

 

Resource resource = new UrlResource(filePath.toUri());

 

UrlResource 클래스를 사용하여 파일의 URI경로 Resource 객체로 변환합니다.

 

String contentType = Files.probeContentType(filePath);
if (contentType == null) {
    contentType = "application/octet-stream";
}

 

Files.probeContentType(filePath) 메서드를 통해 파일의 MIME 타입(콘텐츠 타입)을 확인합니다.

만약 MIME 타입을 확인할 수 없는 경우, 기본값으로 "application/octet-stream"을 사용하여 일반 파일(binary data)로 처리합니다.

 

요약하자면 

 getImage 메소드는 클라이언트가 서버에 저장된 이미지 파일을 요청할 때, 해당 파일을 찾아서 적절한 HTTP 응답으로 반환하는 역할을 합니다.

요청된 파일이 존재하고 읽을 수 있는 경우, MIME 타입에 따라 파일을 브라우저에서 바로 열거나 다운로드할 수 있도록 합니다.

이 메소드는 주로 이미지나 파일을 클라이언트에게 제공할 때 사용되며, 서버에 저장된 파일을 Resource 형태로 쉽게 반환할 수 있게 해줍니다.

 

이렇게 안드로이드 에뮬레이터에서 이미지 불러와서 Spring 모바일 앱 서버를 이용해 DB에 저장하기편에 대하여 작성을 마무리하겠습니다.

전체 코드가 궁금하시다면

https://github.com/junseop619/SecretDiary_Front_End

 

GitHub - junseop619/SecretDiary_Front_End

Contribute to junseop619/SecretDiary_Front_End development by creating an account on GitHub.

github.com

 

https://github.com/junseop619/Secret_Diary_Back_End

 

GitHub - junseop619/Secret_Diary_Back_End

Contribute to junseop619/Secret_Diary_Back_End development by creating an account on GitHub.

github.com

 

저의 github를 참고 부탁드립니다.

반응형