[안드로이드] socket.io를 이용한 간단한 채팅 어플 만들어보기 (3)

동현·2021년 2월 18일
2
post-thumbnail

지난 시간에 만들었던 실시간 메시지 채팅 기능에 실시간 이미지 전송 기능까지 구현해볼 생각이다. 우선 들어가기 전에 개선할 수 있는 점이 하나 보였다.

1. 이전 프로젝트에서 개선점

socket.on('newMessage', (data) => {
    const messageData = JSON.parse(data)
    console.log(`[Room Number ${messageData.to}] ${messageData.from} : ${messageData.content}`)
    io.to(`${messageData.to}`).emit('update', JSON.stringify(messageData))
})

이전 프로젝트에서 내가 채팅을 날렸을 때, 서버에서 어떻게 반응할지를 구현한 부분이다. io.to로 나를 포함한 모두에게 다시 이 메시지를 전송했지만, 사실상 보낸 사람의 경우 자신이 어떤 메세지를 보냈는지 알고 있으므로 바로 리사이클러뷰에 표현시켜줘도 된다고 생각했기 때문에

socket.on('newMessage', (data) => {
    const messageData = JSON.parse(data)
    console.log(`[Room Number ${messageData.to}] ${messageData.from} : ${messageData.content}`)
    socket.broadcast.to(`${messageData.to}`).emit('update', JSON.stringify(messageData))
})

다음과 같이 socket.broadcast.to로 나를 제외한 방의 다른 사람들에게 메시지를 보내도록 하였고 안드로이드 상에서도 내가 보낸 내용은 바로 리사이클러뷰에 나타나도록 하였다.

private void addChat(MessageData data) {
    runOnUiThread(() -> {
        if (data.getType().equals("ENTER") || data.getType().equals("LEFT")) {
            adapter.addItem(new ChatItem(data.getFrom(), data.getContent(), toDate(data.getSendTime()), ChatType.CENTER_CONTENT));
            binding.recyclerView.scrollToPosition(adapter.getItemCount() - 1);
        } else {
            adapter.addItem(new ChatItem(data.getFrom(), data.getContent(), toDate(data.getSendTime()), ChatType.LEFT_CONTENT));
            binding.recyclerView.scrollToPosition(adapter.getItemCount() - 1);
        }
    });
}

리사이클러뷰에 채팅을 표시할 때, 유저 이름을 식별자로 상대방이 보낸 메시지인지 자신이 보낸 메시지인지 구분하는 수고도 덜어졌다.

2. 서버에 이미지 업로드하기

실시간 이미지 전송을 위해서 내가 생각한 과정은 다음과 같다.

👨‍💻유저 1, 2가 채팅을 하고 있을 때
1. 유저1이 갤러리에서 이미지를 하나 선택한다.
2. 유저1이 보낸 이미지는 해당 uri을 통해 바로 리사이클러뷰에 표시한다.
3. 유저1이 선택한 이미지가 서버에 업로드된다.
4. 서버에 업로드가 완료되면 유저2에게 업로드된 이미지의 url이 소켓통신으로 보내진다.
5. 유저2에게 유저1이 보낸 이미지가 리사이클러뷰에 표시된다.

서버에 이미지를 업로드하고자 생각한 방법은 레트로핏을 사용하는 방법이었다. 레트로핏에 대한 내용은 여기를 참고하면 된다.

public interface ApiService {
    @Multipart
    @POST("/upload")
    Call<Result> uploadImage(@Part MultipartBody.Part image);
}

이미지 업로드를 위해 짠 레트로핏 인터페이스이다. 이미지를 post방식으로 서버의 /upload에 보내기로 하였으며 파일의 보낼 때에는 Multipart로 보내야 하므로, 안드로이드에서는 갤러리에서 선택한 이미지를 Multipart 데이터로 변환해야 되는 과정이 필요했다.

// 이미지 uri로부터 실제 파일 경로를 알아냄
private String getRealPathFromURI(Uri contentUri, Context context) {
    String[] proj = { MediaStore.Images.Media.DATA };
    CursorLoader loader = new CursorLoader(context, contentUri, proj, null, null, null);
    Cursor cursor = loader.loadInBackground();
    int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
    cursor.moveToFirst();
    String result = cursor.getString(column_index);
    cursor.close();

    return result;
}

// Node.js 서버에 이미지를 업로드
public void uploadImage(Uri imageUri, Context context) {
    File image = new File(getRealPathFromURI(imageUri, context));
    RequestBody requestBody = RequestBody.create(MediaType.parse("image/*"), image);

    MultipartBody.Part body = MultipartBody.Part.createFormData("image", image.getName(), requestBody);

    retrofitClient.getApiService().uploadImage(body).enqueue(new Callback<Result>() {
        @Override
        public void onResponse(Call<Result> call, Response<Result> response) {
            Result result = response.body();
            if (result.getResult() == 1) {
                Log.d("PHOTO", "Upload success : " + result.getImageUri());
                sendImage(result.getImageUri());
            } else {
                Log.d("PHOTO", "Upload failed");
            }
        }

        @Override
        public void onFailure(Call<Result> call, Throwable t) {
            Log.d("PHOTO", "Upload failed : " + t.getMessage());
        }
    });
}

선택한 이미지의 실제 경로를 알아내 Multipart 데이터로 변환 후 서버에 업로드하는 코드이다.

public class Result {
    @SerializedName("result")
    @Expose
    private Integer result;

    @SerializedName("imageUri")
    @Expose
    private String imageUri;

    public Integer getResult() { return result; }

    public void setResult(Integer result) { this.result = result; }

    public String getImageUri() { return imageUri; }

    public void setImageUri(String imageUri) { this.imageUri = imageUri; }
}

업로드가 완료될 경우, Result 클래스에 result (성공여부 1일 경우 업로드 완료), imageUri가 반환되도록 하였다. 이제 서버쪽 작업으로 넘어가보자.

const multer = require('multer')
const randomstring = require('randomstring')
const imageUpload = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => {
      cb(null, `${__dirname}/images`) // images 폴더에 저장
    },
    filename: (req, file, cb) => {
	var fileName = randomstring.generate(25); // 랜덤 25자의 파일 이름
	var mimetype;
	switch (file.mimetype) {
		case 'image/jpeg':
			mimeType = 'jpg';
			break;
		case 'image/png':
			mimeType = 'png';
			break;
		case 'image/gif':
			mimeType = 'gif';
			break;
		case 'image/bmp':
			mimeType = 'bmp';
			break;
		default:
			mimeType = 'jpg';
			break;
		}
		cb(null, fileName + '.' + mimeType);
	 },
  }),
  limits: {
    fileSize: 5 * 1024 * 1024,  // 5MB 로 크기 제한 (원하는 만큼 늘려도 됨)
  },
})

이미지 업로드를 위해 서버에서는 multer 모듈을 사용하였다. 서버에 images 폴더를 하나 생성해서 저장위치로 하였으며, 파일의 mimetype에 따라 파일명 뒤에 확장자가 붙어 저장되도록 하였다.

// 이미지 업로드
app.post('/upload', imageUpload.single('image'), (req, res) => {
  console.log(req.file)

  const imageData = {
    result : 1,
    imageUri : res.req.file.filename
  }
  res.send(JSON.stringify(imageData))
})

업로드 시 안드로이드 측에서 만든 Result 클래스의 내용처럼 result 값과 imageUri(업로드된 파일의 이름)을 json 형태로 반환시키도록 하였다.

3. 갤러리에서 선택한 이미지 채팅에 표시하기

보낸 사람의 경우 자신이 선택한 이미지가 채팅에 표시되어야 할 것이고, 받는 사람의 경우 보낸 사람의 이미지의 uri을 서버로부터 전달받아 채팅을 표시해야할 것이다. 이 때, 적합한 라이브러리로 Glide를 사용하였다. Glide에 대한 내용은 여기를 참고하면 된다.

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

우선 갤러리에서 이미지를 선택하려면 위의 권한을 AndroidManifest 파일에 추가해줘야 한다.

private final int SELECT_IMAGE = 100;

// 이미지 전송 버튼
binding.imageBtn.setOnClickListener(v -> {
    Intent imageIntent = new Intent(Intent.ACTION_PICK);
    imageIntent.setDataAndType(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
    startActivityForResult(imageIntent, SELECT_IMAGE);
});

// 이미지를 갤러리에서 선택했을 때 이벤트
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    Uri selectedImageUri;

    super.onActivityResult(requestCode, resultCode, data);

    if (requestCode == SELECT_IMAGE && resultCode == RESULT_OK && data != null && data.getData() != null) {
        selectedImageUri = data.getData();
        uploadImage(selectedImageUri, getApplicationContext());
        adapter.addItem(new ChatItem(username, String.valueOf(selectedImageUri), toDate(System.currentTimeMillis()), ChatType.RIGHT_IMAGE));
        binding.recyclerView.scrollToPosition(adapter.getItemCount() - 1);
    }
}

이미지 전송 버튼을 누르면 갤러리에서 이미지를 선택하도록 하였고 선택한 이미지는 서버에 업로드되며 리사이클러뷰에 추가되도록 하였다.

public class RightImageViewHolder extends RecyclerView.ViewHolder{
    ImageView image;
    TextView sendTimeText;

    public RightImageViewHolder(View itemView) {
        super(itemView);

        image = itemView.findViewById(R.id.image_view);
        sendTimeText = itemView.findViewById(R.id.send_time_text);
    }

    public void setItem(ChatItem item, Context context){
        MultiTransformation option = new MultiTransformation(new CenterCrop(), new RoundedCorners(8));

        Glide.with(context)
                .load(item.getContent())
                .apply(RequestOptions.bitmapTransform(option))
                .into(image);
        sendTimeText.setText(item.getSendTime());
    }
}

추가된 이미지는 glide를 통해 centerCrop과 테두리를 둥글게 하는 효과를 주었다.

성공적으로 리사이클러뷰에 표시된 모습이다.

4. 받는 사람에게 보낸 이미지 표시하기

앞서 만든 uploadImage 메소드에서 서버로부터 파일의 이름을 반환받을 것이다. 이제 반환받은 파일의 이름을 다시 서버에 보내주자.

retrofitClient.getApiService().uploadImage(body).enqueue(new Callback<Result>() {
        @Override
        public void onResponse(Call<Result> call, Response<Result> response) {
            Result result = response.body();
            if (result.getResult() == 1) {
                Log.d("PHOTO", "Upload success : " + result.getImageUri());
                sendImage(result.getImageUri());	// imageUri을 서버에 전송
            } else {
                Log.d("PHOTO", "Upload failed");
            }
        }

        @Override
        public void onFailure(Call<Result> call, Throwable t) {
            Log.d("PHOTO", "Upload failed : " + t.getMessage());
        }
    });
}
private void sendImage(String imageUri) {
    mSocket.emit("newImage", gson.toJson(new MessageData("IMAGE",
            username,
            roomNumber,
            imageUri,
            System.currentTimeMillis())));
    Log.d("IMAGE", new MessageData("IMAGE",
            username,
            roomNumber,
            imageUri,
            System.currentTimeMillis()).toString());
}

이제 서버에서는 서버에 있는 image의 uri를 다시 안드로이드측으로 보내주면 된다.

app.use(express.static('images')) // images 폴더를 읽도록 함

socket.on('newImage', (data) => {
  const messageData = JSON.parse(data)
    // 안드로이드 에뮬레이터 기준으로 url은 10.0.2.2, 스마트폰에서 실험해보고 싶으면 자신의 ip 주소로 해야 한다.
  messageData.content = 'http://10.0.2.2:80/' + messageData.content
  console.log(`[Room Number ${messageData.to}] ${messageData.from} : ${messageData.content}`)
  socket.broadcast.to(`${messageData.to}`).emit('update', JSON.stringify(messageData))
})

express가 images 폴더의 정적 파일들을 읽도록 해, 서버 주소 + 이미지 파일명 을 통해 이미지 파일에 접근할 수 있도록 하였고 이를 다시 소켓 통신으로 보내주면 된다.

// 리사이클러뷰에 채팅 추가
private void addChat(MessageData data) {
    runOnUiThread(() -> {
        if (data.getType().equals("ENTER") || data.getType().equals("LEFT")) {
            adapter.addItem(new ChatItem(data.getFrom(), data.getContent(), toDate(data.getSendTime()), ChatType.CENTER_MESSAGE));
            binding.recyclerView.scrollToPosition(adapter.getItemCount() - 1);
        } else if (data.getType().equals("IMAGE")) {
            adapter.addItem(new ChatItem(data.getFrom(), data.getContent(), toDate(data.getSendTime()), ChatType.LEFT_IMAGE));
            binding.recyclerView.scrollToPosition(adapter.getItemCount() - 1);
        } else {
            adapter.addItem(new ChatItem(data.getFrom(), data.getContent(), toDate(data.getSendTime()), ChatType.LEFT_MESSAGE));
            binding.recyclerView.scrollToPosition(adapter.getItemCount() - 1);
        }
    });
}

이제 받은 imageUri을 통대로 리사이클러뷰에 표시해주면 끝이다.

5. 실행 결과

자세한 코드는 깃허브에 올려놓았다.

6. 후기

Node.js를 공부하고 막상 아무것도 만들어본 것 같지 않아 이전에 만들어본 팀프로젝트를 복습하고자 하는 의미로 시작했던 프로젝트이다. 프론트엔드와 백엔드를 같이 구현해서 만드니 나름 보람도 있었고, 이전에 써왔던 레트로핏 라이브러리나 Glide도 다시 사용해보는 계기가 되었다.

7. 참조

npm, "socket.io", https://www.npmjs.com/package/socket.io
HyoGeun's android, "9. Node.js Multer 이미지 업로드", https://hyogeun-android.tistory.com/entry/9-Nodejs-Multer-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C

profile
https://github.com/DongChyeon

1개의 댓글

comment-user-thumbnail
2023년 3월 31일

실시간 채팅 앱을 구현하려고 했는데 잘 보고가요!! 많은 도움 됬어용

답글 달기