배꼽파지 않도록 잘 개발해요

[꿀단집] 배포환경에서 multer로 프로필 이미지 변경이 안 되는 문제 - 서버 파일 경로 구분자 문제를 path.normalize로 해결 본문

Project

[꿀단집] 배포환경에서 multer로 프로필 이미지 변경이 안 되는 문제 - 서버 파일 경로 구분자 문제를 path.normalize로 해결

꼽파 2024. 9. 10. 16:37

우리 서버에서 multer를 활용하여 프로필 이미지를 변경할 수 있는 기능이 있다.

클라이언트에서 사진을 업로드하면 서버에서 multer를 통해 이미지를 서버의 public/uploads 폴더 안에 저장을 하고, 해당 이미지 경로DB의 사용자 정보에 저장한다.

이걸 클라이언트가 받아서 화면에 렌더링하는 식으로 구현을 하였다.

문제는 로컬에서는 아주 잘 작동을 하였으나, 배포 후 작동을 하지 않는다는 것이었다.

 

여기 모달창에서 왼쪽 프로필이미지 버튼을 누르면 변경할 수 있는 창이 뜬다.

 

여기서 미리보기로 바뀐 이미지가 뜨고, 저 모달창을 닫아도 변경된 이미지가 렌더링되어야 한다.

이걸 내가 구현하였는데, 순서가 헷갈려서 시간이 꽤 걸렸던 기억이 났다.

프론트엔드는 렌더링 되는 화면과 데이터 로직을 모두 고려해야해서 어려운 것 같다. 

 

이런 문제가 발생할 경우 프론트엔드/백엔드/로컬환경/배포환경을 모두 살펴봐야한다.

분명히 배포 서버에서 문제가 생긴 것 같았는데, 일단 프론트엔드 상황도 한번 더 점검을 하기로 하였다.

 

이 유저 정보 불러오는 부분에서 서버에서 profileImage를 잘 불러오는지 console.log를 통해 확인하였다.

 

콘솔로 출력해보니 이미지가 불러와지지 않았다.

 

배포 서버와 통신을 할 때 배포 서버의 CNAME으로 요청을 보내고 있다.

 

프론트엔드 측에서 이미지가 존재하지 않을 경우 기본 프로필 이미지를 넣어주는 것으로 설정하였다.

서버에서 이미지 URL을 처리할 때 문제가 생긴 것 같았다.

 

지금 Request URL을 보니까 'src/public/uploads/profileImage-어쩌구'가 404 Not Found 에러가 떴다.

여기서 이상함을 느꼈다. Express 서버에서 asset 파일은 public 경로 안으로 저장되도록 설정하였기 때문이다.

 

MongoDB에서 배포 서버에서 저장된 프로필이미지 경로를 확인해보았다.

'src/public/uploads/profileImage-어쩌구' 로 저장이 된다. 이게 문제였다.

 

실제 배포 서버에는 이미지가 잘 저장이 된다. 이로써 경로를 저장할 때가 문제가 있다는 것이 확정되었다.

 

 

src/public/uploads 이런식으로 URL이 지정되어 있으면 서버에서는 이를 인식하지 못한다.

 

express의 app.js에 설정된 내용

  // 정적파일 경로 설정
  expressApp.use(express.static(path.join(__dirname, 'public')));

public 폴더 안에 정적파일을 넣을 것이라고 설정을 해 놓은 상태이다.

 

올바른 경로

{배포서버의CName}/uploads/profileImage-1234

→ public 안에 있는 것부터 써야 서버가 인식을 한다.

 

틀린 경로

{배포서버의CName}/src/public/uploads/profileImage-1234

→ 이러면 서버가 인식을 못한다.

 

코드를 살펴보니 아래 부분이 문제였다.

user.profileImage = imageUrl.replace('src\\public\\', '');

내 컴퓨터는 윈도우에서 실행되지만, 배포 컴퓨터는 리눅스에서 실행되기 때문에 이 경로 구분자가 인식이 되지 않았던 것이다.

그리하여 저 코드가 실행되지 않고, 경로가 src부터 그대로 저장이 되어 서버가 이미지를 찾을 수 없었던 것이었다.

 

윈도우에서는 \ (역슬래시)를 사용하지만, 리눅스에서는 / (슬래시)경로 구분자로 사용한다.

 

프로필 이미지 변경 서비스 코드를 아래와 같이 변경하였다.

  // 프로필 이미지 변경
  async uploadProfileImage(req) {
    return new Promise((resolve, reject) => {
      multerConfig.getUploadHandler()(req, null, async (err) => {
        if (err) {
          if (err.code === 'LIMIT_FILE_SIZE') {
            reject({
              success: false,
              message: 'File size limit exceeded.',
            });
          } else {
            console.error('Error uploading profile image:', err);
            reject({
              success: false,
              message: 'Failed to upload profile image.',
            });
          }
        } else {
          try {
            const imageUrl = req.file.path;

            // JWT 토큰에서 사용자 이메일을 추출하여 사용자 정보 가져오기
            const token = req.headers.authorization.split(' ')[1];
            const decodedToken = jwt.verify(token, config.jwtSecret);
            const userId = decodedToken.id;

            // 사용자 찾기
            let user = await userDAO.findById(userId);

            if (!user) {
              throw new AppError(
                commonErrors.resourceNotFoundError,
                '해당 이메일로 가입한 회원이 없습니다.',
                400,
              );
            }

            // 사용자 정보 업데이트
            // 윈도우는 경로 구분자가 '\', 리눅스는 '/'를 사용하여 배포환경에서 이 코드가 동작하지 않았음.
            // user.profileImage = imageUrl.replace('src\\public\\', '');

            const normalizedPath = path.normalize(imageUrl);

            user.profileImage = normalizedPath.replace(path.join('src', 'public') + path.sep, '');

            const newProfileImage = user.profileImage;

            user = await userDAO.updateById(userId, {
              profileImage: newProfileImage,
            });

            resolve({ success: true, imageUrl: newProfileImage });
          } catch (error) {
            console.error('Error saving profile image URL:', error);
            reject({
              success: false,
              message: 'Failed to save profile image URL.',
            });
          }
        }
      });
    });
  }
}

 

user.profileImage = normalizedPath.replace(path.join('src', 'public') + path.sep, '');

  • path.join('src', 'public') : src/public 또는 src\public
  • path.sep : / 또는 \
  • path.join('src', 'public') + path.sep :  src/public/ 또는 src\public\

이걸 replace를 활용하여 빈 문자열로 변환하여 제거한다.
마지막으로 normalizedPath를 활용하여 윈도우와 리눅스 환경에서 모두 일관되게 동작하도록 하였다.


Node.js Path.normalize 관련 내용

https://nodejs.org/api/path.html

 

Path | Node.js v22.8.0 Documentation

Path# Source Code: lib/path.js The node:path module provides utilities for working with file and directory paths. It can be accessed using: const path = require('node:path'); copy Windows vs. POSIX# The default operation of the node:path module varies base

nodejs.org

 

path.normalize를 사용하면 POSIX (유닉스 계열 운영체제)와 Windows에서 각각 해당 운영체제에 맞는 경로 구분자로 표기가 된다는 내용이다.

 

이를 수정하고 배포환경에서 잘 동작하는 것을 확인하였다.


글 내용 요약

원인

  • 서버에서 파일을 저장하고 해당 경로를 유저 프로필 이미지 URL로 DB에 저장할 때, 윈도우와 리눅스의 경로 구분자 차이로 인해 문제가 발생하였음. 
  • 윈도우는 \를, 리눅스는 /를 사용하기 때문에, 경로 처리 코드가 환경에 따라 다르게 동작했음.

해결

  • Node.js의 path.normalize를 사용하여 경로를 표준화한 후, path.join과 path.sep을 이용해 경로를 올바르게 처리하도록 수정함.
  • 이로 인해 로컬 환경과 배포 환경 모두에서 일관된 경로 처리가 가능하게 되었음.
728x90