728x90
0.개요
https://asa9874.tistory.com/699
[SpringBoot] STOMP WebSocket 1:1 메시징 사용자별 구독관리
https://asa9874.tistory.com/697 [SpringBoot] React + SpringBoot + JWT + Stomp으로 WebSocket 채팅 구현하기1.STOMP(Simple Text Oriented Messaging Protocol)텍스트 기반의 메시징 프로토콜으로 웹소켓과 함께 사용되어 클라이언
asa9874.tistory.com
해당 글의 이어서이다.
이제 websocket을 이용해서 실시간 채팅방을 만드는것을 완성하였다.
그러면 이제 채팅방에 이미지를 업로드 하는 기능을 만들어보자.
(AWS 써서 하는거 말고 로컬에서하는 이미지 업로드 기능입니다.)
1.MultipartJackson2HttpMessageConverter
일단 이미지를 REST로 받을때 Spring의 HttpMessageConverter를 확장하여, multipart/form-data 요청에서 JSON 데이터를 처리하기 위한 변환기를 만드는 클래스를 컴포넌트로 추가하고 시작하자
https://stackoverflow.com/questions/16230291/requestpart-with-mixed-multipart-request-spring-mvc-3-2
@Component
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
/**
* https://stackoverflow.com/questions/16230291/requestpart-with-mixed-multipart-request-spring-mvc-3-2
* Converter for support http request with header Content-Type: multipart/form-data
*/
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
}
@Override
public boolean canWrite(@NonNull Class<?> clazz,@Nullable MediaType mediaType) {
return false;
}
@Override
public boolean canWrite(@Nullable Type type,@NonNull Class<?> clazz,@Nullable MediaType mediaType) {
return false;
}
@Override
protected boolean canWrite(@Nullable MediaType mediaType) {
return false;
}
}
2.WebConfig
이제 image를 처리하기 위해 설정을 만든다.
요청 경로가 /images/**로 들어오는 모든 요청을 처리하고, 실제 파일 시스템 경로는 C:/uploads/로 지정한다.
요청이 /images/example.jpg라면, Spring은 C:/uploads/example.jpg 경로에서 해당 파일을 찾아 클라이언트에 반환해준다
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**")
.addResourceLocations("file:C:/uploads/");
}
}
3.채팅 이미지 업로드 Controller
우선 지금 방식은 AWS로 도중에 거친뒤 이미지 url을 받는 방식이 아니기 때문에 @MessageMapping으로 받을수없고 기존 REST 방식처럼 PostMapping로 받아야한다.
채팅방id, 보낸사람id를 받아서 이미지 저장을 위한 서비스로 넘기고 이미지 저장 로직을 처리해준다.
그러면 이제 savedMessage에 이미지를 url로 바꾼 데이터가 저장되서 이전 @MessageMapping에서 사용했던 브로드 캐스팅,1대1 알림 로직을 그대로 사용하였다.
public class MessageImageRequestDto {
private Long chatRoomId;
private Long senderId;
}
@PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "이미지 업로드", description = "이미지를 업로드하고 URL을 반환함,브로드 캐스팅")
public ResponseEntity<MessageResponseDto> uploadImage(
@Valid @RequestPart("message") MessageImageRequestDto requestDto,
@RequestParam("file") MultipartFile file) {
MessageResponseDto savedMessage = messageService.createImageMessage(requestDto, file);
//브로드 캐스팅
messagingTemplate.convertAndSend(
"/topic/chat/" + requestDto.getChatRoomId(),
savedMessage);
List<Member> members = chatRoomService.findMembersByChatRoomId(requestDto.getChatRoomId());
MessageSocketResponseDto socketDto = MessageSocketResponseDto.from(savedMessage, requestDto.getChatRoomId());
for (Member member : members) {
if (!member.getId().equals(savedMessage.getSenderId())) {
messagingTemplate.convertAndSendToUser(
member.getId().toString(),
"/queue/messages",
socketDto);
}
}
return ResponseEntity.ok(savedMessage);
}
4.채팅 이미지 업로드 Service
특이 사항은 따로 없고 기존 채팅생성 로직에서 이미지url을 ImageService를 통해 변환하고 받아와서 content로 사용한다.
public MessageResponseDto createImageMessage(MessageImageRequestDto requestDto, MultipartFile imageFile) {
Member sender = memberRepository.findById(requestDto.getSenderId())
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
ChatRoom chatRoom = chatRoomRepository.findById(requestDto.getChatRoomId())
.orElseThrow(() -> new IllegalArgumentException("Chat room not found"));
if (!AuthUtil.isAdmin() && (!AuthUtil.isEqualMember(sender.getId()) || !AuthUtil.isChatRoomMember(chatRoom))) {
throw new RuntimeException("권한없음");
}
String imageUrl;
try {
imageUrl = "http://localhost:8080/"+imageUploadService.uploadImage(imageFile);
} catch (IOException e) {
throw new RuntimeException("이미지 업로드 실패", e);
}
Message message = Message.builder()
.content(imageUrl)
.sender(sender)
.chatRoom(chatRoom)
.timestamp(LocalDateTime.now())
.type(Message.MessageType.IMAGE)
.build();
messageRepository.save(message);
return MessageResponseDto.from(message);
}
5.이미지 업로드 Service
이미지 업로드 및 url로 변환 반환을 위한 Service이다.
업로드된 파일의 확장자도 고려하고, 로컬에 이미지를 저장후 해당 파일에 접근할 url을 반환한다.
@Service
@Log4j2
public class ImageUploadService {
private static final String UPLOAD_DIR = "C:/uploads/";
public String uploadImage(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new IOException("Image file is empty");
}
try {
Files.createDirectories(Paths.get(UPLOAD_DIR));
String originalFileName = file.getOriginalFilename();
if (originalFileName == null) {
throw new IOException("Invalid file name");
}
String fileName = System.currentTimeMillis() + "." + getFileExtension(originalFileName);
String filePath = UPLOAD_DIR + fileName;
file.transferTo(new File(filePath));
return "images/" + fileName;
} catch (IOException e) {
log.error("File upload failed", e);
throw new IOException("Image Upload Fail", e);
}
}
private String getFileExtension(String fileName) {
int index = fileName.lastIndexOf('.');
if (index > 0) {
return fileName.substring(index + 1);
}
return "";
}
}
6.React
6-1.api 함수
이미지를 채팅방에 보내는 api는 다음처러 File타입의 file과 그 이외의 매개변수를 받아
formData에 직렬화된 데이터를 넣고, 파일을 넣어서 'Content-Type': 'multipart/form-data'으로 만든 엔드포인트에 전송한다.
export async function sendImageMessage(
message:{
chatRoomId: number;
senderId: number;
},
file: File
): Promise<Message> {
try {
const formData = new FormData();
formData.append('message', new Blob([JSON.stringify(message)], { type: 'application/json' }));
formData.append('file', file);
const response = await apiClient.post<Message>('/messages/image', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} catch (error) {
console.error('Error sending image message:', error);
throw error;
}
}
6-2.사용하기
imageFile: 선택한 이미지 파일을 저장.
fileInputRef: 숨겨진 <input type="file" /> 요소를 제어하기 위한 ref.
const [imageFile, setImageFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
사용자가 이미지 파일을 선택했을 때 실행.
setImageFile을 통해 이미지 파일을 상태에 저장.
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
setImageFile(event.target.files[0]);
}
};
서버에 이미지 파일을 전송하는 함수
const handleSendImage = async () => {
if (!chatRoomId || !id) {
alert("채팅방 ID 또는 사용자 ID가 없습니다.");
return;
}
if (!imageFile) {
alert("이미지 파일을 선택해주세요.");
return;
}
const requestDto = {
chatRoomId: Number(chatRoomId),
senderId: id,
};
try {
const response = await sendImageMessage(requestDto, imageFile);
console.log("전송한 이미지 메시지:", response);
setImageFile(null); // 전송 후 상태 초기화
if (fileInputRef.current) {
fileInputRef.current.value = ""; // input 파일 선택 초기화
}
} catch (error) {
console.error("이미지 메시지 전송 실패:", error);
alert("이미지 메시지 전송에 실패했습니다.");
}
};
type="file"로 내 pc 파일을 선택하게 만들고 다음 코드들로 이미지 업로드를 제어한다.
<input
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: 'none' }}
ref={fileInputRef}
/>
<button
className="bg-blue-500 text-white rounded-md p-3 m-5"
onClick={() => fileInputRef.current?.click()}
>
이미지 선택
</button>
<button
className="bg-green-500 text-white rounded-md p-3"
onClick={handleSendImage}
disabled={!imageFile}
>
이미지 전송
</button>
</div>
{imageFile && (
<div className="text-sm text-gray-600 mt-2">
선택된 파일: {imageFile.name}
</div>
)}
이미지 전송기능이 잘 작동한것을 확인하였다.

이건 다른 사용자 시점으로 본것이다.

728x90
'BackEnd > SpringBoot' 카테고리의 다른 글
| [SpringBoot] 영속성 전이, 고아 객체, 도달 가능성 (0) | 2025.06.03 |
|---|---|
| [SpringBoot] STOMP WebSocket 1:1 메시징 사용자별 구독관리 (2) | 2025.05.29 |
| [SpringBoot] ORM, JPA, 영속성에 대해 (1) | 2025.05.27 |