S3을 이용한 프로필 사진 업로드 구현
2025. 1. 3. 19:12ㆍTIL
채팅 기능과 유저간의 상호작용 기능이 어느정도 완료되었으니 이제 유저 개개인의 프로필을 조회하고 수정하고, 이미지를 업로드하여 자신의 프로필 이미지를 설정할 수 있도록 설계할 예정이다.
먼저 이미지 업로드 기능의 경우 AWS S3 서비스를 이용하여 이미지와 같은 대용량 파일은 S3에 저장을 하고 PreSigned URL을 이용하여 서버에서 이미지를 업로드하는것이 아닌 클라이언트에서 해당 URL로 직접 업로드를 하는 식으로 구현을 할 것이다.
AWS S3을 사용하기 위해 의존성 추가
더보기
dependencies {
implementation("software.amazon.awssdk:s3:2.20.92")
}
S3 config 작성
더보기
package hjp.hjchat.infra.s3
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.presigner.S3Presigner
@Configuration
class S3Config {
@Bean
fun s3Client(): S3Client {
return S3Client.builder()
.region(Region.of("ap-northeast-2"))
.credentialsProvider(DefaultCredentialsProvider.create())
.build()
}
@Bean
fun s3Presigner(): S3Presigner {
return S3Presigner.builder()
.region(Region.of("ap-northeast-2"))
.credentialsProvider(DefaultCredentialsProvider.create())
.build()
}
}
S3에 접근해서 파일을 업로드,다운로드,조회를 하기 위해서는 ACCESS-KEY와 SECRET-KEY에 대한 정보가 있어야 하는데 이는 AWS CLI를 통해서 설정을 할 수 있다.
S3 Controller 작성
더보기
package hjp.hjchat.infra.s3
import hjp.hjchat.infra.security.jwt.UserPrincipal
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/s3")
class S3Controller(
private val s3Service: S3Service
) {
@PostMapping("/upload/url")
fun getUploadUrl(
@AuthenticationPrincipal user: UserPrincipal,
@RequestParam("fileName") fileName: String
): ResponseEntity<String> {
val userId = user.memberId
val bucketName = "hjchat-s3-bucket1"
val key = "uploads/profile/$userId/profile"
val presignedUrl = s3Service.generateUploadPresignedUrl(bucketName, key)
return ResponseEntity.ok(presignedUrl)
}
@GetMapping("/photo")
fun getProfilePhoto(@AuthenticationPrincipal user: UserPrincipal): ResponseEntity<String> {
val userId = user.memberId
val bucketName = "hjchat-s3-bucket1"
val key = "uploads/profile/$userId/profile"
// Presigned URL 생성
val presignedUrl = s3Service.generateDownloadPresignedUrl(bucketName, key)
return ResponseEntity.ok(presignedUrl)
}
}
S3 Service 작성
더보기
package hjp.hjchat.infra.s3
import org.springframework.stereotype.Service
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.GetObjectRequest
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import software.amazon.awssdk.services.s3.presigner.S3Presigner
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest
import java.time.Duration
@Service
class S3Service(
private val s3Client: S3Client,
private val s3Presigner: S3Presigner
) {
fun generateUploadPresignedUrl(bucketName: String, key: String): String {
val putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.build()
val presignRequest = PutObjectPresignRequest.builder()
.putObjectRequest(putObjectRequest)
.signatureDuration(Duration.ofMinutes(15))
.build()
val presignedUrl = s3Presigner.presignPutObject(presignRequest)
return presignedUrl.url().toString()
}
// 파일 다운로드용 Presigned URL 생성
fun generateDownloadPresignedUrl(bucketName: String, key: String): String {
val getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(key)
.build()
val presignRequest = GetObjectPresignRequest.builder()
.getObjectRequest(getObjectRequest)
.signatureDuration(Duration.ofMinutes(15))
.build()
val presignedUrl = s3Presigner.presignGetObject(presignRequest)
return presignedUrl.url().toString()
}
}
Presigned URL의 유효기간을 15분으로 설정
Postman으로 테스트
S3 URL이 나온것을 확인할수 있다.
AWS 버킷 확인
AWS 버킷에서 정상적으로 파일이 업로드 된것을 확인할 수 있다.
로컬에서 테스트 하기 위해서 HTML 작성
더보기
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket Chat</title>
<style>
body {
font-family: Arial, sans-serif;
}
#chatBox {
width: 400px;
height: 300px;
border: 1px solid #ccc;
margin: 20px 0;
padding: 10px;
overflow-y: auto;
}
#inputMessage {
width: 100%;
padding: 10px;
margin-top: 10px;
}
#chatRooms {
margin: 20px 0;
}
#createRoomBtn {
margin-top: 10px;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
</head>
<body>
<h1>HJ Chat</h1>
<!-- 로그인 섹션 -->
<div id="loginSection">
<h2>Login</h2>
<input type="text" id="username" placeholder="Username" required />
<input type="password" id="password" placeholder="Password" required />
<button onclick="login()">Login</button>
</div>
<!-- 채팅 섹션 -->
<div id="chatSection" style="display: none;">
<h2>Chat Room</h2>
<div id="chatRooms">
<h3>Available Chat Rooms</h3>
<button class="btn btn-primary" onclick="loadChatRooms()" >채팅방 조회</button>
<div id="roomList"></div>
<button id="createRoomBtn" onclick="showCreateRoomModal()">Create Chat Room</button>
</div>
<div id="chatBox"></div>
<input type="text" id="inputMessage" placeholder="Type a message..." />
<button onclick="sendMessage()">Send</button>
</div>
<!-- 채팅방 생성 섹션 -->
<div id="createRoomSection" style="display: none;">
<h3>Create Chat Room</h3>
<input type="text" id="roomName" placeholder="Enter room name" />
<div>
<label>
<input type="radio" name="roomType" value="PUBLIC" checked /> Public
</label>
<label>
<input type="radio" name="roomType" value="PRIVATE" /> Private
</label>
</div>
<button onclick="createChatRoom()">Create</button>
<button onclick="hideCreateRoomModal()">Cancel</button>
</div>
<!-- 친구 추가 섹션 -->
<div id="friendSection" style="display: none;">
<h2>Friend Management</h2>
<input type="text" id="friendUsername" placeholder="Enter username to add as a friend" />
<button onclick="sendFriendRequest()">Send Friend Request</button>
<div id="friendRequests">
<h3>Friend Requests</h3>
<div id="receivedRequests"></div>
</div>
<div id="sentRequests">
<h3>Sent Friend Requests</h3>
<div id="sentRequestList"></div>
</div>
<div id = "friendsList">
<h3 style = "color:darkslateblue"> 친구 리스트 조회 </h3>
<div id="getFriendsList">
</div>
</div>
</div>
<!-- 프로필 이미지 업로드 섹션 -->
<div id="profileUploadSection">
<h2>Upload Profile Image</h2>
<form id="uploadForm">
<label for="profileImage">Select a profile image:</label>
<input type="file" id="profileImage" accept="image/*" required>
<button type="submit">Upload</button>
</form>
<div id="uploadResult"></div>
</div>
<!-- 프로필 이미지 조회 -->
<section id="viewSection">
<h2>View Profile Image</h2>
<button id="viewProfileBtn">View My Profile</button>
<div id="profileContainer"></div>
</section>
<script>
let ws = null; // WebSocket 객체 전역 선언
let accessToken = ''; // AccessToken 저장
let currentChatRoomId = null; // 현재 채팅방 ID
// 로그인 함수
function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
fetch('http://localhost:8080/api/oauth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
.then(response => {
if (!response.ok) {
throw new Error(`로그인 실패: 서버 응답 상태 ${response.status}`);
}
const token = response.headers.get('Authorization')?.split(' ')[1];
if (!token) {
throw new Error('로그인 실패: Authorization 헤더가 비어있음');
}
accessToken = token;
// 사용자 정보 로드
return fetch('http://localhost:8080/member/get_user', {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
})
.then(response => response.json())
.then(data => {
currentUserId = data.userId; // userId 설정
localStorage.setItem('userName', data.userName); // 사용자 이름 저장
console.log("Logged in user ID:", currentUserId);
console.log("Logged in user name:", data.userName);
// UI 업데이트
document.getElementById('loginSection').style.display = 'none';
document.getElementById('friendSection').style.display = 'block';
document.getElementById('chatSection').style.display = 'block';
initializeWebSocket();
loadFriendRequests();
})
.catch(error => {
console.error('로그인 실패:', error);
alert(error.message);
});
}
// WebSocket 초기화
function initializeWebSocket() {
const socket = new SockJS(`http://localhost:8080/ws?token=${accessToken}`);
ws = Stomp.over(socket);
ws.connect({}, function (frame) {
console.log("WebSocket 연결 성공:", frame);
// 친구 요청 알림 구독
ws.subscribe(`/topic/friend/${currentUserId}`, function (message) {
const notification = JSON.parse(message.body);
if (notification.type === "REQUEST") {
alert(`친구 요청이 도착했습니다: ${notification.senderName}`);
loadFriendRequests();
} else if (notification.type === "ACCEPT") {
alert(`친구 요청이 수락되었습니다: ${notification.senderName}`);
} else if (notification.type === "REJECT"){
alert(`친구 요청이 거절되었습니다: ${notification.senderName}`)
}
});
if (currentChatRoomId) {
ws.subscribe(`/topic/chatroom/${currentChatRoomId}`, function (message) {
const receivedMessage = JSON.parse(message.body);
displayMessage(receivedMessage);
});
}
}, function (error) {
console.error("WebSocket 연결 실패:", error);
alert("WebSocket 연결 실패");
});
}
// 채팅방 목록 로드
let isChatRoomListVisible = false; // 채팅방 리스트 표시 여부 상태
// 채팅방 목록 로드 및 토글
function loadChatRooms() {
const roomList = document.getElementById('roomList');
const button = document.querySelector('button[onclick="loadChatRooms()"]');
if (isChatRoomListVisible) {
// 리스트가 표시 중이면 숨기기
roomList.innerHTML = '';
button.innerText = '채팅방 조회';
} else {
// 리스트가 숨겨져 있으면 불러오기
fetch('http://localhost:8080/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
query: `query {
getChatRooms {
id
roomName
roomType
}
}`
})
})
.then(response => response.json())
.then(data => {
roomList.innerHTML = ''; // 기존 내용을 초기화
const rooms = data.data.getChatRooms;
rooms.forEach(room => {
const roomElement = document.createElement('div');
roomElement.innerHTML = `
<button onclick="joinChatRoom(${room.id}, '${room.roomName}')">${room.roomName} (${room.roomType})</button>
`;
roomList.appendChild(roomElement);
});
button.innerText = '채팅방 숨기기';
})
.catch(error => {
console.error('채팅방 목록 로드 실패:', error);
alert('채팅방 목록 로드 실패: ' + error.message);
});
}
isChatRoomListVisible = !isChatRoomListVisible; // 상태 토글
}
function loadChatRoomMessages(chatRoomId) {
fetch(`http://localhost:8080/chatroom/${chatRoomId}/messages`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`
}
})
.then(response => response.json())
.then(messages => {
const chatBox = document.getElementById('chatBox');
chatBox.innerHTML = ''; // 기존 메시지 초기화
messages.forEach(message => displayMessage(message)); // 과거 메시지 렌더링
})
.catch(error => {
console.error('Failed to load chat room messages:', error);
});
}
// 채팅방 입장
function joinChatRoom(roomId, roomName) {
currentChatRoomId = roomId;
alert(`"${roomName}" 채팅방에 입장했습니다.`);
document.getElementById('chatBox').innerHTML = '';
loadChatRoomMessages(roomId);
if (ws) {
ws.subscribe(`/topic/chatroom/${roomId}`, function (message) {
const receivedMessage = JSON.parse(message.body);
//추후 아래 3줄은 삭제할것
const sender = receivedMessage.sender || "Unknown";
const content = receivedMessage.content || "No content";
console.log(`DEBUG: Sender: ${sender}, Content: ${content}`);
displayMessage(receivedMessage);
});
}
}
// 채팅방 생성
function createChatRoom() {
const roomName = document.getElementById('roomName').value;
const roomType = document.querySelector('input[name="roomType"]:checked').value;
if (!roomName || !roomType) {
alert('Please provide a room name and select a room type.');
return;
}
fetch('http://localhost:8080/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
query: `mutation {
createChatRoom(roomName: "${roomName}", roomType: "${roomType}") {
id
roomName
roomType
}
}`
})
})
.then(response => response.json())
.then(data => {
const chatRoom = data.data.createChatRoom;
alert(`Chat room "${chatRoom.roomName}" created as ${chatRoom.roomType.toLowerCase()}!`);
hideCreateRoomModal();
//loadChatRooms();
})
.catch(error => {
console.error('Chat room creation failed:', error);
alert('Chat room creation failed: ' + error.message);
});
}
function showCreateRoomModal() {
document.getElementById('createRoomSection').style.display = 'block';
}
function hideCreateRoomModal() {
document.getElementById('createRoomSection').style.display = 'none';
}
// 메시지 전송
function sendMessage() {
const messageContent = document.getElementById('inputMessage').value; // 입력된 메시지
console.log("Message content:", messageContent);
console.log("Current Chat Room ID:", currentChatRoomId);
console.log("WebSocket status:", ws ? "Connected" : "Not Connected");
if (messageContent.trim() && currentChatRoomId && ws) {
const message = {
chatRoomId: currentChatRoomId,
content: messageContent,
senderName: localStorage.getItem('userName') || "Unknown"
};
ws.send(`/app/send`, {}, JSON.stringify(message)); // 메시지 전송
document.getElementById('inputMessage').value = ''; // 입력 필드 초기화
} else {
if (!messageContent.trim()) {
alert('메시지를 입력해주세요.');
} else if (!currentChatRoomId) {
alert('채팅방을 선택해주세요.');
} else if (!ws) {
alert('WebSocket이 연결되지 않았습니다.');
}
}
}
// 메시지 표시
function displayMessage(message) {
const chatBox = document.getElementById('chatBox');
const messageElement = document.createElement('div');
messageElement.innerHTML = `<strong>${message.senderName}:</strong> ${message.content}`;
chatBox.appendChild(messageElement);
chatBox.scrollTop = chatBox.scrollHeight;
}
function sendFriendRequest() {
const friendUsername = document.getElementById('friendUsername').value;
if (!friendUsername) {
alert('Please enter a friend\'s username');
return;
}
fetch('http://localhost:8080/friends/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
friendId: friendUsername
})
})
.then(response => response.json())
.then(() => {
alert(`Friend request sent to ${friendUsername}`);
document.getElementById('friendUsername').value = '';
loadSentFriendRequests(); // 보낸 요청 목록 갱신
})
.catch(error => {
console.error('Failed to send friend request:', error);
alert('Failed to send friend request: ' + error.message);
});
}
// 친구 요청 수락/거절
function respondToFriendRequest(senderId, accept) {
console.log(`DEBUG: Sender ID (Friend Request Sender): ${senderId}, Accept: ${accept}`);
if(accept){
fetch('http://localhost:8080/friends/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
friendId: senderId // 요청을 보낸 사람의 ID를 friendId로 전달
})
})
.then(() => {
alert("Friend request accepted");
loadFriendRequests(); // 친구 요청 목록 다시 로드
})
.catch(error => {
console.error('Failed to respond to friend request:', error);
alert('Failed to respond to friend request: ' + error.message);
});
}
else{
fetch('http://localhost:8080/friends/reject', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
friendId: senderId // 요청을 보낸 사람의 ID를 friendId로 전달
})
})
.then(() => {
alert("Friend request rejected");
loadFriendRequests(); // 친구 요청 목록 다시 로드
})
.catch(error => {
console.error('Failed to respond to friend request:', error);
alert('Failed to respond to friend request: ' + error.message);
});
}
}
// 받은 친구 요청 로드
function loadFriendRequests() {
fetch('http://localhost:8080/friends/request', {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load friend requests: ${response.status}`);
}
return response.json();
})
.then(data => {
const requests = data; // 요청 리스트
const requestContainer = document.getElementById('receivedRequests');
requestContainer.innerHTML = ''; // 기존 내용을 초기화
// 요청 목록을 DOM에 추가
requests.forEach(request => {
const requestElement = document.createElement('div');
// 요청 내용을 표시 (userId를 대신 표시)
requestElement.id = `friendRequest-${request.friendId}`;
requestElement.innerHTML = `
<p>Friend Request from User ID: ${request.senderName}</p>
<button onclick="respondToFriendRequest(${request.friendId}, true)">Accept</button>
<button onclick="respondToFriendRequest(${request.friendId}, false)">Reject</button>
`;
requestContainer.appendChild(requestElement);
});
})
.catch(error => {
console.error('Failed to load friend requests:', error);
alert('Failed to load friend requests: ' + error.message);
});
}
function loadSentFriendRequests() {
fetch('http://localhost:8080/friends/my_request', {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
})
.then(response => response.json())
.then(data => {
const requestContainer = document.getElementById('sentRequests');
requestContainer.innerHTML = '';
data.forEach(request => {
const requestElement = document.createElement('div');
requestElement.innerHTML = `
<p>${request.senderName} - Status: ${request.status}</p>
`;
requestContainer.appendChild(requestElement);
});
})
.catch(error => {
console.error('Failed to load sent friend requests:', error);
});
}
function getMyFriendsList(){
fetch('http://localhost:8080/friends/get_list', {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
})
.then(response => response.json())
.then(data => {
const requestContainer = document.getElementById('friendsList');
requestContainer.innerHTML = '';
data.forEach(request => {
const requestElement = document.createElement('div');
requestElement.innerHTML = `
<p>${request.senderName}</p>
`;
requestContainer.appendChild(requestElement);
});
})
.catch(error => {
console.error('Failed to load get friends List:', error);
});
}
// 프로필 이미지 업로드 로직
document.getElementById('uploadForm').addEventListener('submit', async (event) => {
event.preventDefault();
const fileInput = document.getElementById('profileImage');
const file = fileInput.files[0];
if (!file) {
alert('Please select a file.');
return;
}
try {
// Step 1: Presigned URL 요청
const response = await fetch(`http://localhost:8080/api/s3/upload/url?fileName=${file.name}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}` // Presigned URL 요청 시에만 Authorization 헤더 포함
}
});
if (!response.ok) {
throw new Error('Failed to get Presigned URL.');
}
const presignedUrl = await response.text();
// Step 2: Presigned URL을 사용해 S3에 파일 업로드 (Authorization 헤더 제거)
const uploadResponse = await fetch(presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type // MIME 타입만 설정
},
body: file
});
if (uploadResponse.ok) {
document.getElementById('uploadResult').innerHTML = `<p style="color: green;">File uploaded successfully!</p>`;
} else {
throw new Error('Failed to upload file to S3.');
}
} catch (error) {
console.error(error);
document.getElementById('uploadResult').innerHTML = `<p style="color: red;">${error.message}</p>`;
}
});
// 프로필 이미지 조회
document.getElementById('viewProfileBtn').addEventListener('click', async () => {
try {
// 서버에서 Presigned URL 요청
const response = await fetch(`http://localhost:8080/api/s3/photo`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error('Failed to get profile image URL.');
}
const presignedUrl = await response.text();
// 이미지 표시
const profileContainer = document.getElementById('profileContainer');
profileContainer.innerHTML = `<img src="${presignedUrl}" alt="Profile Image" style="max-width: 300px; max-height: 300px;">`;
} catch (error) {
console.error(error);
alert('Failed to load profile image: ' + error.message);
}
});
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
</body>
</html>
S3 CORS 정책 작성
테스트 시연 영상: https://www.youtube.com/watch?v=eGmRhdsTyTc
'TIL' 카테고리의 다른 글
신규 회원가입시 𝐷𝑒𝑓𝑎𝑢𝑙𝑡-𝐼𝑚𝑎𝑔𝑒 설정 및 접근 가능한 채팅방만 조회 (0) | 2025.01.07 |
---|---|
𝒫𝓇ℯ𝓈𝒾ℊ𝓃ℯ𝒹 𝒰ℛℒ과 프로필 사진 (0) | 2025.01.06 |
AWS S3 버킷 생성하기 (0) | 2025.01.02 |
친구 추가 요청,거절 및 리팩토링 (2) | 2025.01.01 |
Apache Kafka 적용시키기 (0) | 2024.12.31 |