코드 훔쳐보는 변태 코더
춤 좋아하는 백엔드 개발자(였으면 좋겠다)
전체 글 (95)
22-12-05~22-12-12 개발공부 회고록

서론

주간 목표 

  • 스프링부트 프로젝트 기능 구현 ⭕️
  • 알고리즘 자유롭게 공부 후 문제 풀이 ⭕️
  • 이펙티브 자바 / JPA 책 스터디 ⭕️ 

 


본론

알고리즘 자유롭게 공부 후 문제 풀이

드디어 첫 토이프로젝트가 마무리 되고, 알고리즘 공부를 다시 시작했습니다.

 

이번주에 진행한 알고리즘 파트는 링크드리스트, 해시, 힙 자료구조와 우선순위 큐 입니다.

 

문제는 해시/힙 자료구조 위주로 풀이했으며, 링크드리스트 에 대해서 요약정리도 진행했습니다.

 

 

 

링크드 리스트

https://velog.io/@brince/Linked-List-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B3%A0-%EC%9B%90%EB%A6%AC-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

 

Linked List 직접 구현해보고 원리 이해하기

링크드 리스트

velog.io

 

링크드리스트 같은 경우에는 일단 기본적으로 배열과 큰 차이점을 갖고 있습니다.

링크드 리스트는 말그대로 연결 리스트 인데요, 각각 인덱스의 원소끼리 서로 체인으로 연결되어있어서 중간에 데이터를 삽입/삭제 시에 기존에 연결된 체인을 끊고 새로 연결한다 정도로 쉽게 이해할 수 있는데요,

 

기존엔 이정도로만 익혔지만 이번에 공부하면서 새로 알게된 부분은 링크드 리스트는 각각의 노드에 데이터와 포인터로 이루어져 데이터에는 데이터가 저장되고, 포인터에는 다음 데이터의 주소를 저장하는 형식으로 이루어져있다는걸 알게 되었습니다.

 

예를들어 3개의 노드가가 연결리스트 안에 존재한다면, 마지막 노드의 포인터는 null이고, 새로 삽입을 할때 해당 포인터에 다음 노드를 참조시키는 것으로 데이터를 삽입/삭제 합니다.

 

마찬가지로 링크드리스트를 직접 구현해보고 기존에는 단순히 시간이 좀 걸린다~ 정도로만 알고있었지만 자료를 넣고 빼고 하는데 좀 복잡하구나.. 를 알게되었습니다.

 

스프링부트 프로젝트 기능 구현

 

이번 한주는 개인적으로 프로젝트에 시간을 많이 투자했던 한 주 였습니다.

 

이번주에 진행했던 것들은 게시판 프로젝트에 계정 엔티티에 이미지 입출력을 적용해 프로필 이미지를 변경하는 기능을 구현하는것과,

 

그것을 응용해 단순 파일만 업로드하고 출력하는게 아닌 JPA Repository로 파일 DB저장소를 만들고 저장/삭제/조회 를 구현하고

 

또 그것을 응용해 게시판에 외부 에디터 / 뷰어를 적용해 이미지 입출력까지 진행후 리팩토링까지 진행해보았습니다.

 

 

 

스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력)

사용기술 스프링부트 제이쿼리 타임리프 살짝 서론 디테일하게 파일을 저장하는 레포지토리를 만들어서 해당 레포지토리에서 파일을 꺼내오는 것이 아닌 파일 입출력을 스프링 부트에서 어떻

codinghentai.tistory.com

 

스프링 부트+ JPA 로 파일 입출력을 쉽게 구현해보자 (Spring Data JPA)

서론 🙋‍♂️ 스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력)를 응용해서 파일을 업로드하고 해당 파일의 경로와 이름, 확장자 그리고 사이즈까지 저장하는 Jpa Repository 를 생성

codinghentai.tistory.com

 

 

스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -1

참고한 게시글 : https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C Toast UI Editor 이미지 업로드 Spring Boots를 사용해 Toast UI Editor에 이미지 업로드하기!!!! 어느날 미래에서 삽질

codinghentai.tistory.com

 

 

스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -2

https://codinghentai.tistory.com/2 스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -1 참고한 게시글 : https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C Toast UI Editor 이미지 업

codinghentai.tistory.com


마무리

  • 알차게 보낸 한 주 였던것 같습니다. 알고리즘을 오랜만에 다시 공부하게 되었는데 확실히 전보다 이해가 더 잘되는 기분입니다. 꾸준히 코딩을 하는 습관을 들이는게 중요한것같다고 한번 더 느끼게 되었습니다.
  • 이펙티브 자바를 공부하면서 애를 먹었습니다. 정리할정도로 그것들을 활용할 길이 적었기 때문입니다. 공부하는 내용의 대부분이 직접 지금 당장 사용할 일들이 없는 레거시 코드였습니다. 마찬가지로 앞으로는 모든 파트를 공부하기 보다는 힘든 부분은 넘기고 다음에 도움이 필요할때 참고해야겠다고 다짐하게 되었습니다.
  • 이제 기본적인 스프링 프로젝트를 진행할때 크게 겪는 문제는 없는것 같은 느낌입니다. 클린코드와 리팩토링에 대해서 공부를 시작하면 좋을거같다는 생각이 들었습니다 🤔

 

  Comments,     Trackbacks
스프링 부트+ JPA 로 파일 입출력을 쉽게 구현해보자 (Spring Data JPA)

서론 🙋‍♂️

  • 스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력)를 응용해서 파일을 업로드하고 해당 파일의 경로와 이름, 확장자 그리고 사이즈까지 저장하는 Jpa Repository 를 생성해 어플리케이션 내의 파일을 관리합니다.
  • 파일을 관리할 수 있는 유틸리티 클래스를 생성해서 편리하게 가공할 수 있도록 구현합니다.

본론 🙆🏻‍♂️

전체적인 흐름은 이렇습니다.

  • 파일 엔티티 클래스 생성하기
  • 파일 Dto 생성하기
  • 파일 Repository 생성하기
  • 파일 유틸리티 클래스 생성후 정적 유틸리티 메소드 선언하기
  • 서비스 클래스 생성 후, 기본적인 CRUD 테스트 코드 작성하기
  • 각각 파일 입출력이 필요한 엔티티 서비스 계층에서 활용해보기
  • 기본적인 파일 저장 방식은 commons-io 디펜던시를 추가하여 사용하며, DB에는 파일 자체를 저장하지 않고 경로와 이름을 저장해 출력은 바이트 어레이로 경로를 참조해 출력시킵니다.

파일 엔티티 클래스 생성하고 관련된 Dto까지 생성하기 💁🏻

yaml 파일에 commons-io 관련 설정을 입력해주고, 파일이 저장될 경로를 지정해주세요. (해당 파일 자체를 DB에 저장하지 않고 경로만 참조합니다.)

(해당 글은 Spring Data Jpa 를 사용합니다.)

@Entity
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class SaveFile extends AuditingFields{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter
    @Column(nullable = false)
    private String fileName;
    @Setter
    @Column(nullable = false)
    private String filePath ;
    @Setter
    @Column(nullable = false)
    private String fileType;
    @Setter
    @Column(nullable = false)
    private Long fileSize;
    @Setter
    @Column(nullable = false)
    private String uploadUser;

기본적으로 사용될 필드변수들을 선언해줍니다.

해당 엔티티는 기존에 설정한 파일 경로를 참조해 파일을 불러와서 출력할 예정입니다.

@Builder
    public  record FileDto (
            Long id,
            String fileName,
            String filePath,
            String fileType,
            Long fileSize,
            String uploadUser,
						LocalDateTime createdAt,
            String createdBy,
            LocalDateTime modifiedAt,
            String modifiedBy
    )  {
        public static FileDto from(SaveFile saveFile) {
            return new FileDto(
                    saveFile.getId(),
                    saveFile.getFileName(),
                    saveFile.getFilePath(),
                    saveFile.getFileType(),
                    saveFile.getFileSize(),
                    saveFile.getUploadUser(),
                    saveFile.getCreatedAt(),
                    saveFile.getCreatedBy(),
                    saveFile.getModifiedAt(),
                    saveFile.getModifiedBy()
            );}
        public SaveFile toEntity() {
            return SaveFile.builder()
                    .id(id)
                    .fileName(fileName)
                    .filePath(filePath)
                    .fileType(fileType)
                    .fileSize(fileSize)
                    .uploadUser(uploadUser)
                    .build();
        }
    }

FileDto는 레코드 형식으로 생성 주었습니다. 저 같은 경우에는 Dto가 늘어남에 따라서 엔티티 클래스 안에 정적 클래스로 생성해주어서 한 도메인이 담당하는 엔티티 클래스와 Dto 클래스들을 묶어서 관리하고 있습니다.

File 엔티티 클래스와 Dto 클래스 필드에 선언된 변수가 다른 이유는 Audititng Fields 인터페이스를 생성해서 각 엔티티 클래스속 공통적으로 들어가는 필드를 따로 분리해서 관리할 수 있게 해 줬습니다.

JPA Auditing 기능이란? <- 해당 글을 참고하시면 좋을 것 같습니다 :D\

JpaRepository 생성하고 서비스 클래스 생성하기 💁🏻

@RepositoryRestResource
public interface SaveFileRepository extends JpaRepository<SaveFile, Long> {
    public SaveFile findByFileName(String fileName);
}

JpaRepository를 상속받는 인터페이스를 생성해줍니다.
(@RepositoryRestResource 같은 경우는 Spring Data Rest 디펜던시를 추가하면 사용이 가능한데, 따로 API를 구현하지 않고도 REST API를 디펜던시가 구현해주어서 사용할 수 있게 해주는 어노테이션입니다.하지만 여기서는 사용하지 않습니다.)

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class FileService {

    private final SaveFileRepository fileRepository;

    public SaveFile.FileDto getFile(Long fileId) {
        log.info("getFile() fileId: {}", fileId);
        return fileRepository.findById(fileId).map(SaveFile.FileDto::from).orElseThrow(()-> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId));
    }
    public void deleteFile(Long fileId) {
        log.info("deleteFile() fileId: {}", fileId);
        fileRepository.deleteById(fileId);
    }
    public SaveFile.FileDto saveFile(SaveFile.FileDto saveFile) {
        log.info("saveFile() saveFile: {}", saveFile);
        return SaveFile.FileDto.from(fileRepository.save(saveFile.toEntity()));
    }
}

마찬가지로 JpaRepository를 생성했으니 비즈니스 로직을 설계할 차례입니다.
테스트 코드를 작성하면서 메서드를 작성 할 수 있으나 , 기본적인 추가 삭제 조회 정도만 구현하면 되기때문에 패스해도록 하겠습니다

File 유틸리티 클래스 생성하기 💁🏻

저번 글에서는 MultipartFile 인터페이스를 사용해서 파일을 받아왔고, 파일 객체로 변환하는 작업을 모두 메소드 안에서 일일이 처리했기 때문에 이번에는 유틸리티 클래스를 생성해 관리하도록 편의성을 부여해줍니다.

public class FileUtil {
    private FileUtil() {
    }
    @Value("${com.example.upload.path.profileImg}")
    public static String uploadPath;


	//확장자를 가져오는 메소드
    public  static String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf(".") + 1);
    }
    //파일 이름만을 가져오는 메소드
    public static String getFileName(String fileName) {
        return fileName.substring(0, fileName.lastIndexOf("."));
    }
	//UUDI값을 파일이름에 붙여줘서 가져와주는 메소드
    public static String getFileNameWithUUID(String fileName) {
        return UUID.randomUUID().toString() + "_" + fileName;
    }
    //정적 팩토리 메소드
    public  static File  createFile(String uploadPath, String fileName) {
        return new File(uploadPath, fileName);
    }

    public static File getMultipartFileToFile(MultipartFile multipartFile) throws IOException {
        File file = new File(uploadPath,getFileNameWithUUID(multipartFile.getOriginalFilename()));
        multipartFile.transferTo(file);
        return file;
    }

    public static File getFileFromFileDomain(SaveFile.FileDto fileDto) {
        return new File(fileDto.filePath());
    }


    public static void deleteFile(SaveFile.FileDto profileImg) {
        File file = getFileFromFileDomain(profileImg);
        if (file.exists()) {
            file.delete();
        }
    }
    //멀티파트파일을 파일객체로 변환후 FileDto로 리턴해주는 메소드 
    public static SaveFile.FileDto getFileDtoFromMultiPartFile(MultipartFile multipartFile, String uploadUser) throws IOException {
        File file = getMultipartFileToFile(multipartFile);
        String fileName = file.getName();
        String fileType = getExtension(Objects.requireNonNull(multipartFile.getOriginalFilename()));
        Long fileSize = multipartFile.getSize();
        return SaveFile.FileDto.builder()
                .fileName(fileName)
                .filePath("yourpath"+fileName)
                .fileType(fileType)
                .fileSize(fileSize)
                .uploadUser(uploadUser)
                .build();// TODO: 경로가 자꾸 null 로 입력되기 때문에 해결 방안을 찾아야함.
    }

맨 아래 메서드를 가장 많이 사용하게 될 것 같은데요 ,저같은 경우는 다른 메소드에서는 경로를 정상적으로 참조하는 반면에 해당 메소드는 null값으로 입력이 되어서 임시방편으로 메소드 내에 경로를 다시 선언해주었습니다.

해당 유틸리티 클래스로 각각 파일을 전달받는 컨트롤러에서 멀티파트 파일을 프런트에서 받아오면 FileDto 로 변환 후에 저장 후 필요한 객체에 Set 하는 방식으로 이용합니다.

컨트롤러에서 활용해보기 💁🏻

    @Setter
    @ToString.Exclude
    @ManyToOne
    @JoinColumn(name = "profile_img_id")
    @Nullable
    private SaveFile profileImg;

저는 유저 엔티티 필드 변수 속 단순 path와 name으로 선언해두었던 프로필 파일을 엔티티를 변수로 갖도록 변경해주었습니다.

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestPart("signupDto") @Valid UserAccount.SignupDto signupDto, BindingResult bindingResult
    ,@RequestPart(value = "imgFile",required = false) MultipartFile imgFile) throws IOException {
        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }
        else if(!signupDto.getPassword1().equals(signupDto.getPassword2())){
            bindingResult.addError(new FieldError("userCreateForm","password2","비밀번호가 일치하지 않습니다."));
            return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }
        if(imgFile==null){
            userService.saveUserAccountWithoutProfile(signupDto);
        }
        else {
            userService.saveUserAccount(signupDto, fileService.saveFile(FileUtil.getFileDtoFromMultiPartFile(imgFile,signupDto.getUserId())));
        }
        return new ResponseEntity<>("success", HttpStatus.OK);
    }

기존에 작성해둔 메서드를 수정합니다. multipartFile 자체를 매개 값으로 전달했었으나, 이제는 유틸리티 클래스의 도움을 받아서 fileDto 로 변환 후jpaRepository에 저장 후에 식별자를 매개 값으로 넘겨주게 됩니다.

    public void saveUserAccount(UserAccount.SignupDto user, SaveFile.FileDto fileDto) throws IOException {
        String password = user.getPassword1();
        UserAccount account = userAccountRepository.save(user.toEntity());
        account.setUserPassword(new BCryptPasswordEncoder().encode(password));
        account.setProfileImg(fileDto.toEntity());
    }

그렇게 전달받은 file을 단순히 계정 엔티티에 set 해주면 됩니다.

출력해보기 💁🏻

이제 DB에 해당 엔티티를 참조할 수 있도록 구현해놨으니 출력하는 것도 구현해야 합니다.

    @GetMapping("/accounts/{username}")
    public ResponseEntity<?> getProfileImg (@PathVariable String username) throws IOException {
        File profileImg = FileUtil.getFileFromFileDomain(userService.getUserAccount(username).profileImg());
        byte[] imageByteArray = IOUtils.toByteArray(new FileInputStream(profileImg));
        return new ResponseEntity<>(imageByteArray, HttpStatus.OK);
    }

여기서도 마찬가지로 FileUtil 클래스를 이용해 엔티티를 파일 객체로 만들어줍니다.
그리고 IOUtils 클래스가 제공하는 바이트 어레이 변환 메서드를 이용해 바이트 배열로 변환해주고 객체로 전달해줍니다.

이제 이미지 출력이 필요한 부분에 src 태그에 해당 경로를 집어넣으면 프로필 사진이 출력되게 됩니다!

시연 화면 💁🏻

아무 사진 하나만 회원 가입할 때 업로드해줍니다.

이렇게 가입이 완료되면 이전과는 다르게 파일 경로와 이름이 DB에 저장되는 것이 아닌 외래 키로 다른 테이블 식별자를 참조하게 됩니다.

이렇게 파일 저장용 테이블이 만들어져서 업로드 유저와 함께 사이즈, 확장자, 그리고 경로 이름이 칼럼 값으로 들어가게 됩니다.

자 이렇게 업로드한 프로필 사진이 정상적으로 출력이 됩니다. (사진은 제 셀카예요;;)

마찬가지로 해당 로직을 응용해서 수정도 할 수 있습니다.

수정을 하게 될 경우엔 기존에 존재하던 파일을 삭제하고 새로 저장된 파일 엔티티를 참조하는 방식으로 구현했습니다.

; 자 이렇게 다른 이미지에서 또다시 원래 이미지로 변경하고 수정을 하게 되면

기존에 등록된 프로필 이미지는 삭제됩니다! 물론 deleteFile 메서드도 호출했으니 로컬에서도 삭제됩니다.


마무리 💆🏻‍♂️

  • 생각보다 어려워 보였던 파일 입출력을 쉽게 구현해보았습니다.
  • fileRepository 의존성 주입을 다른 클래스에서 해도 되나가 가장 큰 고민거리였습니다.
  • 해당 고민은 fileservice 를 파일 입출력이 필요한 컨트롤러에서 주입받아 이용하도록 해결했습니다.
  • 바이트 배열로 전달하는것 외에 더 효율적인 출력방법이 있을까요 ? 저는 아직 모르겠습니다 ;(
  • A(프로필 이미지 단건 업로드) -> B (단건 참조) 까지 했으니 다음 목표는 C 게시글에 이미지 여러건 첨부 후 출력 으로 잡으면 되겠습니다! 🎅🏽

(해당 글은 블로그 이전으로 인하여 붙여 넣기 된 글입니다.)

 

  Comments,     Trackbacks
스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력)
 

사용기술

  • 스프링부트
  • 제이쿼리
  • 타임리프 살짝

서론

디테일하게 파일을 저장하는 레포지토리를 만들어서 해당 레포지토리에서 파일을 꺼내오는 것이 아닌 파일 입출력을 스프링 부트에서 어떻게 구현할 수 있는지에 대한 궁금증으로 인해

단순히 account 엔티티에 프로필 이미지 경로와 이름 변수를 선언해 단건으로 구현해보기로 했습니다.

해당 글에서 작성한 코드들은 다른분들의 글을 많이 참고했음을 알려드립니다.
$ 참고글


본론

전체적인 흐름은 이렇습니다.

프로필 이미지 필드 변수가 존재하지 않는 계정 엔티티에 프로필 이미지용 경로/ 이름 변수 선언해주기
👇🏻
엔티티에 필드변수가 추가되었으니 관련된 웹 계층/서비스 계층 테스트 코드 작성 후 다듬기
👉🏻 프로필 이미지가 업로드가 되었을 때 안되었을 때두 가지 케이스로 살펴보자.
👉 스프링 시큐리티에서 리소스에 대한 접근을 허용하도록 코드를 추가해주어야 한다.
👇🏻
앞단에서 제이쿼리로 파일 업로드받아서 뒷단으로 넘겨주기
👇🏻
타임리 프로 해당 프로필 이미지 출력하기

계정 엔티티 수정하기

지금 상황은 게시판 홈페이지에 프로필 이미지 관련 기능이 하나도 구현되어있지 않은 상황이므로 무에서 유를 창조해야 합니다. 👀

일단 파일 업로드 관련 디펜던시를 build.gradle 에 추가해주고 어느 경로에 업로드된 파일을 저장해줄지 yaml파일에 지정해줍니다.

dependencies {
	implementation('commons-io:commons-io:2.11.0')
    }
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB
      location: /Users/brinc/Desktop
      enabled: true
com.example.upload.path.profileImg : /Users/brinc/Desktop 

절대 경로로 작성해야 합니다.

그리고 기존에 생성돼있는 계정 엔티티에 이미지 파일의 경로와 이름을 필드 변수로 선언해줍니다.

...
    @Setter private String profileImgName;
    @Setter private String profileImgPath;
...

계정 엔티티와 연관된 Dto에도 파일 경로를 추가해줄 수 있지 않을까? 하는 생각이 있었는데, 여러 시도 결과 @RequestPart로 회원가입 리퀘스트 Dto와 파일을 따로 요청받도록 했습니다.

제 실력선에서는 프런트단에서 업로드 시에 폼 데이터를 하나의 Dto로 전달받을 때 파일은 파일로, 양식은 json 타입으로 전달받는 방법을 찾지 못했습니다 😿

컨트롤러 내 회원가입 메서드 수정하기

이제 디펜던시와 엔티티도 수정을 해주었고, 뷰는 단순히 태그만 추가해줘도 되기 때문에 컨트롤러 내에 있는 메서드부터 수정해줍니다.

   @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestPart("signupDto") @Valid UserAccount.SignupDto signupDto, BindingResult bindingResult
    ,@RequestPart(value = "imgFile",required = false) MultipartFile imgFile) throws IOException {
        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(controllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }
        else if(!signupDto.getPassword1().equals(signupDto.getPassword2())){
            bindingResult.addError(new FieldError("userCreateForm","password2","비밀번호가 일치하지 않습니다."));
            return new ResponseEntity<>(controllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
        }// -----추가된 부분-------
        if(imgFile==null){
            userService.saveUserAccountWithoutProfile(signupDto);
        }
        else {
            userService.saveUserAccount(signupDto, imgFile);
        }
        return new ResponseEntity<>("success", HttpStatus.OK);
    }

제 목적은 최대한 기존의 코드의 큰 변경이 없이 기능만 추가할 수 있는 걸 원했기 때문에, 회원가입 관련 메서드만 수정해주면 되었습니다.

기존의 코드에서 이미지 파일이 존재한다면 디폴트 이미지로 프로필 이미지를 설정하도록 했고, 존재한다면 이미지 파일을 같이 받아와서 서비스 계층으로 넘겨주도록 코드를 추가했습니다.

비즈니스 로직 수정하기

이제 컨트롤러에서 서비스 계층으로 전달하는 값이 추가되었으니, 해당 값을 또 가공해서 디비에 반영해야 하니 메서드를 수정해야 합니다.

    public void saveUserAccount(UserAccount.SignupDto user,MultipartFile imgFile) throws IOException {
        String password = user.getPassword1();
        UserAccount account = userAccountRepository.save(user.toEntity());
        account.setUserPassword(new BCryptPasswordEncoder().encode(password));
        // ----- 추가된 부분 -----
        UUID uuid = UUID.randomUUID();
        String fileName = uuid.toString() + "_" + imgFile.getOriginalFilename();
        File profileImg=  new File(uploadPath,fileName);
        imgFile.transferTo(profileImg);
        account.setProfileImgName(fileName);
        account.setProfileImgPath(uploadPath+"/"+fileName);
    }

파일을 저장할 때는 랜덤 한 UUID를 생성해 이름에 부여합니다. (같은 이름을 가진 파일을 업로드할 때 오류가 생기면 안 되겠죠?)
그리고 UUID를 붙여서 새로 지정한 이미지 파일 이름과 경로를 기준으로 파일 객체를 생성합니다.

그리고 뷰에서 전달받은 파일을 multipartfile 클래스가 제공하는 transferTo 메서드로 파일 객체로 변환해줍니다.

그리고 간단하게 해당 엔티티의 프로필 이미지 경로와 이름을 업데이트해주면 됩니다!

회원가입 폼에 이미지 파일 업로드 태그 추가하기

이제 대충 뒷단은 구현했으니 앞단을 수정할 차례입니다.

<form method="post" style="width:700px"  enctype="multipart/form-data">
  <p class="register">회원가입</p>
  <label for="imgFile">프로필 사진</label>
    <input type="file" name="imgFile" id="imgFile"  accept=".jpg, .png" class="form-control">
    ...

단순히 기존에 작성돼있던 폼에 enctype을 추가해서 폼 데이터로 전송할 것을 명시해주고, 프로필 사진을 업로드해야 할 부분에 input 태그로 type을 file로 지정해주면 됩니다.

accept 에는 확장자를 입력하면 해당 확장자가 아닐 경우에 업로드를 하지 못하게 해 줍니다.

그리고 해당 값을 제이쿼리를 이용해서 컨트롤러로 전송합니다.

function signupCheck() {
    var formData = new FormData();
    formData.append("imgFile", $("#imgFile")[0].files[0]);
    var data = {
        info: {
            userId: $("#userId").val(),
            password1: $("#password1").val(),
            password2: $("#password2").val(),
            nickname: $("#nickname").val(),
            email: $("#email").val(),
            memo: $("#memo").val()
        }
    }
    formData.append(
        "signupDto",
        new Blob([JSON.stringify(data.info)], { type: "application/json" })
    );
    console.log(formData);
    //위에서 만든 오브젝트를 json 타입으로 바꾼다.
    $.ajax({
        type : 'POST',
        url : '/signup',
        data : formData,
        processData : false,
        contentType : false,
        success: function ( ){
            alert("회원가입 성공");
        },

저는 기존에 작성해뒀던 스크립트를 수정해서 사용했습니다.
formData 객체를 새로 생성해서 사용합니다.
객체의 첫 인덱스에는 이미지 파일을 담아주고, 두 번째 인덱스에 입력했던 값들을 json형태로 변환해서 담아줍니다.

키값에 입력되는 이름이 RequestPart 어노테이션의 이름에 들어갈 값과 같아야 합니다.

컨트롤러에 프로필 이미지를 가져와줄 메서드를 선언하자

프런트에서 해결해야 할 부분이 하나 더 남았는데, GET 메서드로 프로필 이미지를 바이트 배열로 전달받아야 합니다.

단순히 src태그에 경로를 붙여버리면, 빌드 전에 이미 존재하던 파일이 아닌 이상 읽어오지 못합니다.

    @GetMapping("/accounts/{username}")
    public ResponseEntity<?> getProfileImg (@PathVariable String username) throws IOException {
        UserAccount.UserAccountDto accountDto = userService.getUserAccount(username);
        InputStream inputStream = new FileInputStream(accountDto.profileImgPath());
        byte[] imageByteArray = IOUtils.toByteArray(inputStream);
        inputStream.close();
        return new ResponseEntity<>(imageByteArray, HttpStatus.OK);
    }

inputstream으로 파일을 읽어와서 바이트 배열로 전달해줍니다.

그리고 해당 프로필 이미지가 출력되어야 하는 부분에는 타임리프 문법인 th:src를 사용해서 이미지를 출력해줄 겁니다.

<img id="imgId" th:src="@{/accounts/}+${accountDto.userId}" width="100" height="100"  alt="첨부이미지" th:if="${accountDto.profileImgPath() != null}" />

동작하는지 확인해보기

파일을 업로드해줍니다. accept에 png와 jpg 만 받도록 명시해놨기 때문에 해당 파일 외의 파일들은 업로드하지 못합니다.

이렇게 새로 생성한 계정에 yaml 파일에 지정해둔 경로에 이미지가 업로드된 모습을 볼 수 있습니다. (파일을 업로드하지 않을 때는 단순히 같은 메서드에 default 이미지의 경로와 이름을 대입해주면 됩니다 🤓)

제가 임시로 만들어둔 페이지입니다. 이미지를 잘 출력하는 모습을 볼 수 있습니다

마찬가지로 이렇게 비슷한 패턴으로 프로필 수정 페이지를 구현해서 프로필 이미지를 변경할 수도 있습니다.

잘 변경이 되네요 😎


마치며

  • 입출력 스트림을 활용해보고 싶어서 시도해보았으나, 딥한 원리를 이해하지 못하고 구현한 느낌이 들어서 게시글 이미지/파일 첨부 기능 구현시에 좀더 딥하게 공부해볼 예정입니다.
  • 해당 기능을 구현하면서 겪었던 오류는 절대경로를 제대로 지정하지 않아 발생한 오류와, 이미지 파일 출력을 단순 절대경로로 출력하려고 할때 없는 파일로 인식이 되어 발생한 오류 이렇게 총 두가지 입니다.
  • 해당 기능을 구현할때 좀 더 살펴보면 좋은것은 MultipartFile 인터페이스가 어떠한 기능을 제공해주고 어떻게 돌아가는지 공식 문서를 참고하면 좋을 것 같습니다.
  • 단순히 파일 자체를 dto 로 전달받아 업로드 하는 방식도 있습니다만 저는 제이쿼리로 기존의 회원가입 폼을 만들었기 때문에 제이쿼리로 전달하는 방법을 찾아보았습니다.
  • 제이쿼리 스크립트 속 blob 은 [Javascript] API 활용 - Blob 해당 글을 참고하시면 좋을것 같습니다 :)

(해당 글은 블로그 이전으로 인하여 붙여넣기 된 글입니다.)

  Comments,     Trackbacks
패스트 캠퍼스 메가바이트 스쿨 첫번째 토이프로젝트 리뷰 (고객 등급 관리 프로그램)

죽음의 프로젝트 주제 💀

진행 기간 2022-11-25~2022-12-07 (저는 구체적인 프로젝트 설명이 진행되기 이전에 제 실력을 기르고자 먼저 시작했고, 2일 동안 진행했습니다.)

고객 분류 프로그램 개발 💡

스토어 사장님들을 위한 고객을 이용시간 별로 분류하고 나눠서 원하는 정보대로 정렬 / 출력 할 수 있는 프로그램을 개발한다.

유의사항 💡

  • 컬렉션 프레임워크 사용 금지 (단 Arrays 는 허용) 🚫 List,Set,Map 등에 대한 API를 직접 구현해야 한다.
  • 고객별로 이용시간, 이용금액에 따라서 분류가 가능해야 한다.
  • 분류된 데이터를 이름순, 이용금액, 이용시간 기반으로 정렬이 가능해야 한다.
  • 고객의 정보를 추가,수정,삭제,조회가 가능해야 한다.
  • 분류 기준을 수정,초기화 할 수 있어야 하고 수정될 시에 수정된 기준으로 다시 분류가 가능해야 한다.
  • 객체지향 성격이 잘 드러날 수 있도록 클래스를 설계해야 한다.

클래스 설계 🧑🏻‍💻

(클래스 관계는 기존에 프로젝트 정의서에 있던 내용들을 참고하였으며, 역할은 제 맘대로 지정했습니다.)

  • Customer 도메인 (고객 정보에 대한 모든것을 담당한다.) 🧑🏻‍💻
    • Customer : 고객들의 정보를 담고있는 객체를 생성하기 위한 클래스
      • 객체 생성자, 기본적인 Getter, Setter 그리고 자바빈 규약을 따른 멤버변수를 선언했습니다.
    • Customers : Customer 객체를 저장하고 관리하기위한 클래스
      • Customer 객체를 생성, 수정, 조회, 삭제 하기 위한 배열을 갖고 있고, 그룹 기준별로 등급을 설정, 등급별로 그룹을 나눠서 리턴해주는 기능까지 구현했습니다.
    • ClassifiedCustomers : 등급별로 분류된 Customer 를 정렬하고 출력하기 위한 클래스
      • 기존의 ClassifiedCustomers 는 분류된 고객들 이라는 뜻을 갖고있기 때문에 분류된 고객리스트가 관리를 하는것은 이상하지 않을까? 라는 생각으로 Customer 가 해당 그룹핑을 관리하도록 역할을 정했습니다.
  • Group 도메인 🧑🏻‍💻
    • Group : Customer과 마찬가지로 그룹에대한 정보를 담고있는 객체를 생성하기 위한 클래스
      • Enum타입인 GroupType 과 , 분류 기준인 Parameter 를 멤버변수로 갖고 있습니다.
    • GroupType : Enum타입으로 분류가 안된 NONE 타입, 일반 등급인 GENERAL, 그리고 VIP, VVIP 이렇게 4개의 타입이 정의되어있다.
    • Parameter : 그룹 기준을 설정할 객체로, 멤버변수로는 기준이 될 이용시간과 이용금액이 선언되어있고, 해당 기준을 초과하면 해당 등급이 된다.
    • Groups : Group을 담을 배열을 갖고있는 관리 클래스. 등급을 분류할 그룹은 이미 정해져있고, 초기화가 되지 않아서 각각 0시간과 0만원의 파라미터를 담고있는 그룹이 4개가 배열에 들어가있다.
  • Menu 도메인 🧑🏻‍💻
    • Menu 인터페이스 : 각각 메뉴가 기본적으로 가지고 있는 행동들을 메소드로 선언한다.
      • Menu를 구현한 구현클래스 : 기본적인 첫 메뉴를 담당할 클래스
      • CustomerMenu : Customer에 대한 입력 유도문과, Customer 객체를 생성하기 전 데이터 검증과, 비즈니스 검증까지 담당한다.
      • GroupMenu : 등급에 대한 기준 초기화, 수정, 조회 를 입력유도문을 통해 원하는 등급을 입력받고 해당 결과물을 출력해준다.
      • SummaryMenu : 분류된 등급에 따라 고객 명단을 출력해주고, 원하는 데이터에 따라 오름차순/내림차순 으로 목록을 출력해준다.

본격 코딩 들어가기 🤦🏻‍♂️

해당 토이프로젝트 주제는 무엇보다 객체지향 설계와 프로그래밍이 가장 큰 주제였다고 생각했습니다.

그리고 +@로 싱글톤으로 관리할 수 있는 객체들은 싱글톤 패턴으로 관리해, 스프링에서 자주나오는 객체 주입까지 체험해볼 수 있는 아주 유익한 프로젝트라 생각했습니다.

Customer 도메인 🗓️

일단 해당 클래스가 어떠한 역할을 갖고 있는지, 그리고 어떠한 부분까지 담당할 수 있을지를 처음에 고민을 많이 했습니다.

저는 일단 객체를 만들기 위한 Customer 클래스는 단순히 Customer객체에 대한 설계도 라는 역할만 갖고 있길 바랐으며,

Customers 클래스는 해당 객체! 만 관리! 하는 클래스 정도의 역할만 갖고 있길 바랐습니다.

(기본적인 ~~를 입력해주세요. 라는 부분은 Menu에서 담당하고, 말 그대로 등록/조회/수정/삭제에서 조금 더 나아가 해당 클래스에서 분류/ 그룹핑, 그리고 직접 API를 구현해야 했기 때문에 그에 따른 배열 관리 메서드 (trimToSize 나 더블링) 정도까지만 갖고 있도록 설계했습니다.)

ClassifiedCustomers 클래스에 대한 생각이 꽤 많았는데요, 강사님께서 제시해주신 클래스 다이어그램에서는

싱글톤으로 관리해야할 Customers를 배열에 담고 계셨었습니다.

최대한 주어진 설계도에 맞춰서 작업하는 게 추후에 제가 서비스를 제작해주길 원하는 고객이 기본적인 설계도를 가져온다면, 그거에 최대한 맞게 작업하는게 맞다고 생각을 했었습니다.

고민되었던 문제.. 🫠

  • Customers 객체를 싱글톤으로 관리하는데 (왜냐하면 하나의 리스트로 관리하고 싶기 때문에) 해당 객체를 배열에 저장할 수 있을까?
    • 해당 부분은 아직 해결되지 않을 상태였습니다. 하지만 제 나름대로 생각을 좀 해서 2차원 배열로 그룹핑을 해 ClassifiedCustomers에 해당 배열을 저장하도록 진행했습니다. 🥹
  • Customer객체를 생성하기 위한 검증을 Customers에서 진행해야 할까 아니면 Menu에서 진행해야 할까?
    • 담당 역할에 대해서 고민이 많았습니다. 스프링으로 따지자면 Customers는 Repository 같은 경우였고, menu는 service계층 이라고 생각했기때문에 Menu에서 검증을 진행하도록 설계했습니다. 😇
  • ClassifiedCustomers의 존재이유 .. 🤔
    • 이미 Customer 객체가 Group이라는 변수를 갖고있는데 그걸 또 다시 나눠야하는 이유가 궁금했습니다.
    • 설계는 알아서 해도 되기때문에 1차적으로는 ClassifiedCustomers 클래스를 생성하지 않고, 그룹별 정렬/출력을 해당 클래스에서 담당하도록 설계했습니다.
    • 하지만 추후에 생각을 해보니 이 방식은 매 그룹마다 처음부터 끝까지 탐색을 하며 값을 골라서 출력하도록 하는 로직이었기 때문에 비효율적이라 생각해 Classified 클래스를 생성해 배열을 전달받도록 구현했습니다.

Group 도메인 👨‍👩‍👧‍👧

해당 도메인은 하나하나가 큰 역할을 갖고 있다기보다는Customer가 필요하기 때문에 있어야 하는 Class 정도로 다가왔습니다.

단순히 Group은 분류 기준과 타입(어떻게 보면 그룹 이름)만 갖고 있기 때문에, 해당 Group들을 관리할 Groups 클래스는 Customers처럼 조회, 삭제, 수정, 추가를 구현하지 않고 이미 있는 그룹의 기준을 초기화, 수정, 조회만 가능하도록 구현했습니다. 그리고 별 차이 없이 프로젝트 정의서 속 설계도와 거의 일치하게 설계했습니다.

Group 클래스는 단순히 해당 그룹을 정의할 Enum타입 GroupType과, Parameter를 멤버 변수로 갖고 있습니다.

Parameter는 고객들을 분류하기 위한 기준을 담고 있습니다. 기준 이용금액, 기준 이용시간을 멤버 변수로 갖고 있으며 해당 기준을 초과하면 해당 등급의 멤버가 됩니다.

Groups 클래스는 Customers와 같이 Group을 관리할 클래스로서, 해당 객체가 생성될 때 생성자는 각 배열 인덱스에 0,0 값(이용 기준)을 담고 있는 깡통 그룹 객체를 담고 생성하도록 해줍니다.

고민되었던 문제..🫠

  • Group에 대한 역할이 애매모호 했습니다. 이것으로 고객을 담아야할지? 아니면 단순히 그룹타입을 Customer 클래스에 선언해서 해당 Enum으로만 관리하고 다른 설계도만 Group에 담아도 되지않을까.. 하는 고민이 있었으나, 단순 이름표 같은거라고 생각하고 최대한 설계도에 맞게 설계했습니다.

Menu도메인은 각각 클래스별로 실행하는 행동들은 같지만, 그 내용물은 다를 것이기 때문에 Menu인터페이스를 구현하도록 설계했습니다.

Menu 인터페이스는 기본적인 메뉴를 출력하는 ShowMenu와, 해당 메뉴를 골랐을 때 나올 반응인 SelectMenu 메서드를 선언했고,

나머지 메뉴 클래스들은 해당 역할에 맞도록 재정의해서 메서드를 선언해줬습니다.

첫 번째 화면이 될 MenuImpl 클래스는 어떻게 보면 다른 메뉴들의 조상이라고 할 수 있어서 부모 클래스로 생성할지를 고민했지만, 그렇게 하면 다른 메뉴 객체를 싱글톤으로 관리할 수 없기 때문에 단순 구현 클래스로 지정해주었습니다.

기본적인 첫 스타트 화면 (고객 메뉴, 그룹 메뉴, 조회 메뉴)를 선택하도록 입력 유도 문이 출력되며, 해당 메뉴를 선택하면 메뉴 값이 리턴되어 selectMenu메서드가 switch 문으로 해당 메뉴의 showmenu를 호출하도록 구현했습니다.

CustomerMenu 클래스는 Customers에 객체를 저장하기 전에, 해당 객체를 생성하고 검증하고 , 수정/삭제를 한다면 해당 아이디가 존재하는지, 그리고 배열에 아무것도 들어있지 않아서 Null예외가 발생할 수는 없을지.. 정도의 역할을 부여했습니다.

Customer객체는 생성이 되면 바로 static 변수인 count가 생성자가 호출될 때 ++되도록 되어있기 때문에, 데이터 검증을 해당 메뉴에서 진행하도록 설계했습니다.

GroupMenu 클래스는 단순 그룹의 기준을 초기화, 수정, 조회하기 전에 원하는 그룹의 이름을 받아내고, 초기화가 되어있는지 확인이 된다면 수정/조회를 할 수 있도록 해 나름의 예외처리를 진행했습니다.

SummaryMenu 클래스는 말 그대로 요악 메뉴 이기 때문에, 해당 메뉴가 직접적으로 수행할 것은 출력 정도만 있도록 역할을 부여했습니다. classifiedCustomers 클래스에서 리스트를 가져와 단순히 내림차순/오름차순 정도만 고르도록 했습니다.

고민되었던 문제...🫠

  • CustomerMenu 의 역할을 어떻게 지정해야 할지 고민이 되었습니다. MVC패턴으로 치자면 CustomerMenu 를 Controller 의 역할을 부여해야 할지.. 아니면 비즈니스 로직을 담당할 Model 의 역할을 부여해야 할지..
  • 하지만 그정도로의 과한 디테일을 요구하는 설계가 필요한 작업은 아니라고 생각됐고, 해당 프로젝트가 장기 프로젝트가 아니고 어떠한 기능과 목표가 이미 정해진 프로젝트이기 때문에 단순설계로 끝내도록 했습니다.
    Customers가 주방이라고 치자면 그 전을 담당하는 카운터 정도의 역할을 부여하도록 했습니다.

고려했던 부분 😪

  • 최대한 객체지향의 개념을 살려 어떠한 객체가 무엇을 갖고 어떠한 작업을 진행합니다. 의 내용을 갖고있도록 메소드를 선언했습니다.

Customers가 고객 고유번호로 삭제를 합니다.(주어지는 값은 고유번호)

  • 메소드의 이름을 최대한 해당 메소드가 담당하는 역할을 명시하도록 작성해주었습니다.

GroupMenu 가 Parameter를 만든다. / 입력값을 토대로 그룹의 인덱스를 가져온다.

  • 객체가 하나로만 생성되어 관리가 될 필요가 있는 객체들은 싱글톤으로 관리하고, 객체주입을 많이 활용했습니다.

스프링 개념에 자주 등장하던 의존성 주입에 대한 활용을 자바에서 연습해볼 수 있도록 진행했습니다.

아쉬웠던 점과 좋았던 점 ⭕️❌

아쉬웠던 점❌

  • 이번 토이프로젝트를 진행하면서, 기존에 다른 토이프로젝트를 진행해왔을때 깃 사용에 대한 아쉬움이 항상 있었어서 다음 토이프로젝트는 시작부터 깃으로 관리를 꾸준히 해가면서 진행해야겠다.. 하는 생각이 있었지만 실패했습니다.(?) 급한 성격이 문제인듯 합니다..... 다음엔 꼭 이러지 않도록 노력해야겠습니다.
  • 예외처리에 대한 기본적인 지식이 부족했습니다. 처음에 뭣모르고 예외관리 클래스를 만들어 모든 예외에 대한 정의를 마치 프린트문 출력하듯이 메소드를 선언했고, 아는 지인에게 피드백을 받고 찾아본결과,
    예외를 직접 생성하면 단순히 생성만으로 많은 자원을 이용하게 되고, 이것과 마찬가지로 throw를 많이할수록 또한 메모리 낭비가 심해진다는걸 알게되었습니다. 이부분에 대한 공부를 더 할 예정입니다.

좋았던 점⭕️

  • 진행하면서 개인적으로 애매모호했던 스프링의 역전제어와 의존성주입, 그리고 빈이 스프링에서 어떠한 역할을 하는지 이해가 더 잘된 느낌입니다. 스프링을 들어가기 전에 이런 토이프로젝트를 진행하고 들어갔으면 얼마나 좋았을까 라는 생각도 들지만, 강사님의 프로젝트 구성이 정말 알차다고 느껴졌습니다. 우리 국비 기수 복받았다..!
  • 클래스 설계를 거의 처음으로 진행해보고 하나부터 열까지 신경쓰려고 했던 첫 프로젝트였고, 성공적이었던것 같습니다. 객체지향이 무엇인지 단순히 말로 설명하기는 어렵고, 그걸 이해하기도 어렵지만, 이번에 확실히 개념을 챙겨가는 느낌입니다.
  • 코드가 더 깔끔해진것 같습니다. 강사님께서 강의에서 알려주신 내용을 바탕으로 더 깔끔한 메소드를 작성하려고 노력했고 그 결과 가독성이 좋고 해당 부분이 어떤 재료를 가지고 어떠한 행동을 하는지 표기할 수 있게 되었습니다.
  • 성능에 대한 고민을 할 수 있는 시간이었습니다. 그룹핑이라는건 어떠한 목록을 탐색해서 짚어내는 행동이기도 한데, 그에 따른 생각을 많이 해볼 수 있는 시간이었습니다.

시연 화면

마무리

글이 굉장히 길지만, 제가 공부를 시작한 이후로 나름 기록을 남길 수 있는 첫 토이 프로젝트라고 생각해 기록을 남기게 되었습니다.

9월 초, 제가 처음으로 자바를 시작했었을 때 JDBC API를 이용해 게시판 프로그램을 만든 적이 있었습니다.

그때의 설계와 지금의 설계는 정말 말로 설명할 수 없을 정도로 다릅니다.

공부라는 건 항상 내가 잘하고 있는지 많이 힘들 수밖에 없는 부분인데.. 이에대한 체크를 하려면 보통 과거의 나와 비교를 하는 수 밖에 없다고 생각합니다.

이번 토이 프로젝트로 인해서 내가 정말 잘하고 있구나.. 굉장히 많이 늘었구나.. 나 나름 똑똑하구나..(?)라는 생각을 갖게 되었고 공부가 더 재밌어진 것 같습니다.

물론 해야 할게 많지만 멀리 본다면 제가 배워야 할 것들이 넘어야 할 산들이 아니라 오히려 더 겪어볼 수 있는 어떠한 놀이가 될 수 있겠다는 생각에 설레는 것 같습니다.

 

 

(해당 글은 블로그 이전으로 인하여 붙여 넣기 된 게시글입니다.)

  Comments,     Trackbacks
패스트캠퍼스 메가바이트 스쿨 - 백엔드과정 월간회고-1 10/24~11/20

서론🥉

벌써 제가 지원했던 국비 교육이 시작된지 한달이 지났습니다.

저같은 경우에는 공부를 살면서 해본적이 없는데.. 생각보다 코딩이라는게 재밌게 느껴지다보니

독학도 무리없이 꾸준히 해왔던지라 국비교육을 지원해야할지 말아야할지 굉장히 고민도 많이했고..

수많은 학원들에 상담도 가보고, 지원까지 다 마치고 다시 취소하는 과정을 몇번을 반복.. 고민의 연속이었으나

어차피 손해볼것 없다고 생각이 들어서 딱 한곳만 지원하고 취소를 하지 않았고,

마지막으로 지원했던 곳이 패스트캠퍼스 메가바이트 스쿨이었습니다.

패스트캠퍼스 국비과정을 지원한 이유

개인적으로 패스트캠퍼스의 국비교육 과정이 마음에 들었던 이유는

  • 진행 시간이 오후 1시부터 오후 10시 까지였다.

저는 매일 2시정도 까지 개인적인 일과를 다 끝마치고 (운동, 개인업무 등등) 공부 혹은 토이프로젝트를 밥먹고 싸는 시간 포함 자기전까지 했었기 때문에

기존의 생활패턴과 최대한 비슷한 국비교육 과정을 듣는게 효율적이라 생각했습니다.
(물론 평소에 늦잠을 자진 않습니다만, 잘 수 있는것과 못자는건 다르다고 생각했습니다.
개인적으로 뭐 하다보면 새벽 4시에 잘때도 많았기때문에.. )

  • 프론트엔드 협업 프로젝트 / 기업연계 프로젝트

개인적으로 백엔드를 공부하는 사람들도 프론트부분은 어느정도 할 줄 알아야 한다는 생각이 있긴 했습니다만,

정말 중요한걸 진행할때는 온전히 제가 집중할 수 있는 부분에만 몰두하는게 맞다는 생각이 들었습니다.

어느정도 저도 토이프로젝트를 진행하다보니까, 물론 하면 좋겠지만 프론트 부분에 제 기준 주어진 할당량 외의 시간을 더 많이 사용하고 있었습니다.

그리고 UI/UX 디자이너, 프론트엔드 개발자 분들과 기업연계 프로젝트를 진행하는 부분도 차별점이 있다고 생각했습니다.

  • 다른 국비교육 과정보다 살짝은 더 긴 진행기간

보통 4~6개월의 기간을 두고있는 다른 국비교육과는 다르게 메가바이트 스쿨은 7개월동안 진행되기 때문에 진도가 조금이라도 더 천천히 나갈거라고 생각했고

강사님들께서 정해진 진도에 급하게 맞춰가지 않고 조금이라도 더 학생들과 소통할거같다는 생각이 들었었습니다.

개인적으로 크게 와닿았던 부분은 이 세가지였고, 지금도 충분히 만족하고 있는 부분이라고 생각합니다.

본론🥈

한달동안 진행되었던 것들 📆

생각보다 또 마음에 들었던게, 타 국비교육생들 얘기를 들어보면 굉장히 정신없게 지나가서 본인이 뭘 해야하는지, 뭘 하고있는지, 그리고 앞으로 또 나 혼자서 뭘 할 수 있을지를 모르는것 같다는게 큰 특징 같았습니다.

하지만 생각보다 타이트하게 진행된 부분은 없었고, 충분한 자습시간과 강의 수강 시간, 그리고 실시간 강의도 나름 충분히 복습이 가능하게끔 배정되있는 느낌을 받았습니다.

그룹스터디 조 배정/ 진행 👯‍♀️

처음 OT가 진행되고, 7개월동안 어떻게 국비교육 과정이 진행될지, 그리고 우리가 뭘 지켜야하고 꼭 하지 말아야할것이 무엇일지 알게되고 나면

설문을 통해 그룹스터디 조가 배정됩니다.

해당 설문은 본인이 어떠한 부분을 콕 짚어서 그룹원들과 스터디를 진행하고 싶은지를 제출하게 됩니다.

그리고 주마다 3회의 그룹스터디 시간이 주어지고, 시간이 더 필요하다면 자율적으로 조절이 가능했습니다.

각 그룹마다 원하는 시간에 원하는만큼 그룹스터디가 가능했고, 원하다면 개인공부도 가능했기때문에

남들보다 뒤쳐지거나 정해진 그룹스터디 과제를 수행하지 못해 오히려 공부 의욕이 떨어지거나 할 수 있는 분들까지도 개인 공부를 더 하실 수 있게 배려를 해주셨습니다.

그리고 또 좋았던점은 그룹마다 패스트캠퍼스 내의 온라인 강의중에 원하는 강의 한개를 골라서 모든 그룹원들이 수강할 수 있도록 제공해주셨습니다.

저희조같은 경우엔 클론코딩을 목표로 모였던 조였기 때문에 객체지향적 프로그래밍 기초부터 프로젝트 클론코딩까지 제공해주는 강의를 제공받을 수 있었습니다.

그룹스터디 진행상황 🗓️

그렇게 저희 그룹은 초반에 진도가 다들 달랐고, 제가 어느정도 자바/스프링을 예습해왔던 상황이고 제가 말이 가장 많았기 때문에 (..)

그룹장을 맡게 되었고, 회의를 통해 아직 프로그래밍 공부가 익숙하시지 않으신 분들도 계셨고, 진도가 다 달랐기 때문에

제가 그동안 개인적으로 진행했던 주마다 공부한것을 기준으로 주간 회고록 작성후 발표

로 목표를 세워 한달동안 진행했습니다. 다들 긍정적인 반응이었고 저도 개인적으로 단순히 회고록을 써야 한다는 압박감 말고는 진도나 단체행동으로 인한 부담감이 없을거라 생각해서 좋은 시도라 생각했습니다.

한달동안 진행함으로써 얻어간 것들 💁🏻

첫주는 생각보다 회고록이 정리가 안되있으신 분도 계시고, 다들 언어 공부에 있어서 어색해 하시는 모습을 보였지만,

결론은 전체적으로 다들 첨 시작하는 개발블로그 였지만, 시작한지 몇주가 지나지 않아서 공부에 대한 기록을 남기는 습관을 갖게 되셨습니다.

단순히 어느 블로그에서 복사한내용을 붙여넣기 하고 끝내신게 아니고

공부하셨던 부분에 있어서 자신의 의견을 말할줄 알게 되었고,

그룹원 분들께서 해당 부분을 공부함으로써 어떻게 활용했고, 어떤부분을 얻어갔고 어떠한 부분이 부족한지 알게되 또 새로운 목표를 세울 수 있었습니다.

그룹장으로써 굉장히 자랑스러웠습니다.. 해당부분은 그룹스터디 회고에서 더 디테일하게 작성해보도록 하겠습니다..

실시간 강의 (깃 사용법과 자바 기초) 💡

기본적인 스케줄은 오후 1시부터 6시 까지는 개인 자습과 온라인 강의 수강 시간을 갖고, 저녁시간 이후 7시~10시 까지는 실시간 강의가 진행되었습니다.

3주차 까지는 월/수/금 요일에 실시간 강의가 진행되었고, 4주차 부터는 매일 저녁시간에 강의가 진행되었던것 같았습니다. 개인적으로 적응하는 시간을 갖게 해주신것 같다는 생각이 들었습니다. ( 첨부터 몰아붙이면 겁먹을수도 있다는 개인적인 생각이 있긴 합니다 ㅎㅎ )

  • 깃 사용법 익히기와 깃허브로 협업 실습 해보기, 깃 cli 커맨드 익히기
  • 자바 기초 데이터타입부터 OOP 기초 배우기

깃 사용법 익히기 / 깃으로 협업 실습 진행해보기

저같은 경우에는 해당 교육과정을 듣기 이전에 프로젝트 강의에서 알게된 깃 크라켄으로 커맨드를 거의 하나도 모른 상태에서 형상관리를 해왔는데요..

그러다보니 개인적으로 실무에 투입되었을때 버벅이는 부분이 있을수도 있겠다는 생각을 했고, 무엇보다 혼자 토이프로젝트를 꾸준히 진행해왔기 때문에 협업하는 부분에서 처음에 어려움이 있을까 하는 고민이 있었습니다.

해당 파트 강사님께서는 처음부터 차근차근 콘솔을 이용해 깃으로 에드, 커밋, 푸쉬, 브랜치 생성, 전환 등등 여러가지 기능을 알려주셨고

직접 파일을 수정해 커밋하고 푸쉬하는 실습 환경을 만들어 주셨고,

개인적으로 궁금했던 툴을 사용해도 되는데 커맨드를 알고 git cli 를 사용할 줄 알아야 하는 이유 도 알려주시는 등, 매번 강의 진행때마다 모든 궁금한점을 해결해주셨습니다.

그리고 제일 중요했던 협업하는 과정을 알려주시고, 어떠한 한 상황에 적용시켜 직접 팀원을 초대하고, 각각 브랜치를 생성해 파일을 커밋하고 푸시 , 코드리뷰까지 완료해 머지까지 하는 상황을 만들어 실습시간을 갖게 해주셨습니다.

이렇게 각각 저희조는 이미 어떠한 서비스중인 기본 틀을 갖고 있는 프로젝트에 각각의 기능을 구현해 풀 리퀘스트를 하는 예제 상황을 만들어 실습을 진행했습니다.

처음부터 과한 팀프로젝트를 진행하며 버벅이면서 익힐 필요 없이 적응을 시켜주시는 수업 방식에 굉장히 만족했던 부분이었습니다.

자바 기초 🧑🏻‍💻

어떻게 보면 급하게 알려주시고 넘길 수 있는 부분까지 강의에서 짚어주셨습니다.

개발환경 설정부터, OOP 프로그래밍 기초까지 한달동안 진행되었습니다.

저같은 경우엔 자바 복습을 2~3번 정도 진행했던 상황이라 필요한 부분만 수강하고 다른 부분에는 자습을 진행했습니다.

마찬가지로 강사님께서도 수업 진행 도중 실습시간도 갖게 해주시고, 주마다 과제를 내주셨습니다.

과제 난이도도 무엇보다 처음 배우는거라 너무 쉽게 낼 수도 있지만, 딱 중간 난이도의 과제를 내주셔서

저같은 경우엔 단순히 A라는 방법으로 풀이할 수 있지만 B,C 라는 더 좋은 방안까지 고민할 수 있는 시간을 가질 수 있는 기회가 생겼다고 느껴졌습니다.

( 단순 문제풀이가 아닌 왜 이런 부분이 컴파일 오류가 나는지에 대해 설명해야 하거나 A가 B보다 왜 더 나은지 설명해야하는 문제도 포함되어있어 이론을 한번 더 익힐 수 있었습니다. )

과제 제출도 과제 repository 에 개인 브렌치를 생성해 제출하고 커밋/푸시 후 풀 리퀘스트를 하는 방식으로 진행됐기 때문에 버전관리 까지 가져갈 수 있는 좋은 시간을 제공해주셨다 생각이 됩니다.

결론 🥇

한달동안 진행하면서 느낀게 , 교육 과정 자체가 타이트 하지 않아서 개인적으로 누군가가 매번 감시하고 푸시해줘야지 더 열심히 하신다거나 그러신 분은

과정 자체가 좀 안맞겠다는 생각도 듭니다.

저같은 경우에는 오히려 자습시간/실시간강의 시간을 확실히 분리해두시고 자율적인 그룹스터디 시간을 제공해주신 부분에 있어서 한달동안 급하지 않지만 그렇다고 게으르게 진행하지도 않았던 기분이 들어

개인적으로 크게 만족하는것 같습니다. (심지어 달에 한번씩 휴가도 주심)

그렇게 한달동안 여러 부분에서 시너지 효과를 얻어서

혼자 공부했을때 보다 많은걸 가져가게 된것 같습니다.. 수료 후에 제 모습이 기대된다고 해야할까요.

해당 과정이 개인 후기가 하나도 없었어서 저도 시작 전에 많이 고민했었는데, 저같은 사람이 있으실까 해서 남겨봅니다!
(돈받은거 없음)

손해볼거 없다면 도전해보세요 🤺

 

(해당 글은 블로그 이전으로 붙여넣기 처리되었습니다.)

  Comments,     Trackbacks
스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -2

https://codinghentai.tistory.com/2

 

스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -1

참고한 게시글 : https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C Toast UI Editor 이미지 업로드 Spring Boots를 사용해 Toast UI Editor에 이미지 업로드하기!!!! 어느날 미래에서 삽질

codinghentai.tistory.com

해당 글과 이어집니다.

 


저번 글에서 비동기 통신으로 즉각적으로 이미지를 업로드/붙여 넣기를 하면 이미지를 서버에 업로드 후 db에 저장, 그리고 출력까지 해오는 로직을 구현했었는데요,

 

이제부터는 게시글과 파일의 연관관계를 디테일하게 설정해 잉여파일까지 관리할 수 있는 로직을 구현해봅니다.

 

게시글 등록시에 중간 관계 엔티티 영속해주기

어떠한 기능을 구현하고자 할 때 가장 1차원적으로 생각해보는 게 큰 도움이 되는 것 같습니다.

지금 프로젝트의 구조는 게시글을 등록할때 리퀘스트 dto를 전달하는 방식으로 글을 등록했기 때문에,

 

단순히 파일 업로드 후 리스폰스 엔티티로 객체를 반환해준다면, 게시글 등록시에 리퀘스트 dto에 반환된 객체의 id값을 같이 받아오면 되겠구나! 하고 생각했습니다.

 

                    success: function(data) {
                        //console.log('ajax 이미지 업로드 성공');
                        url += data.fileName;
                        fileIds += data.id + ",";
                        // callback : 에디터(마크다운 편집기)에 표시할 텍스트, 뷰어에는 imageUrl 주소에 저장된 사진으로 나옴
                        // 형식 : ![대체 텍스트](주소)
                        callback(url, '사진 대체 텍스트 입력');
                    },

이렇게 에디터의 success 부분에 받아온 객체의 id를 문자열 변수에 append 하는 형태로 구현후에 같이 전달해줍니다.

 

그리고 비즈니스 로직을 수정해줄 건데요, ArticleSaveFile 엔티티 클래스는 게시글과 파일의 연관관계를 위해서 생성했기 때문에 repository는 게시글과 파일의 서비스 클래스에 둘 다 주입해줄 겁니다.

 

 

그전에 SaveFile 서비스에서 비즈니스 로직을 구현해주어야 합니다. 받아온 fileIds로 file set을 반환해주는 로직을 구현합니다.

 

    public Set<SaveFile.SaveFileDto> getFileDtosFromRequestsFileIds(Article.ArticleRequest dto) {
        StringTokenizer st = new StringTokenizer(dto.getFileIds(), ",");
        Set<SaveFile.SaveFileDto> saveFileDtos = new HashSet<>();
        while(st.hasMoreTokens()){
            Long fileId = Long.valueOf(st.nextToken());
            log.info("fileId: {}", fileId);
            saveFileDtos.add(saveFileRepository.findById(fileId).map(SaveFile.SaveFileDto::from).orElseThrow(() -> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId)));
        }
        return saveFileDtos;
    }

이렇게 HashSet을 생성해 StringTokenizer로 잘라서 dto로 가져와서 set에 담아 반환해줍니다.

 

이제 게시글 컨트롤러에서 리퀘스트 Dto 에 담긴 파일 아이디로 파일 dto를 가져와 같이 비즈니스 로직으로 넘겨줍니다.

 

    @ResponseBody
    @PostMapping
    public ResponseEntity<String> saveArticle(@RequestBody @Valid Article.ArticleRequest dto, BindingResult bindingResult,
                                              @AuthenticationPrincipal UserAccount.BoardPrincipal boardPrincipal) {
        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(ErrorMessages.ENTITY_NOT_VALID, HttpStatus.BAD_REQUEST);
        }
        if (boardPrincipal == null) {
            return new ResponseEntity<>(ErrorMessages.ACCESS_TOKEN_NOT_FOUND, HttpStatus.BAD_REQUEST);
        }
        Set<SaveFile.SaveFileDto> saveFileDtos = saveFileService.getFileDtosFromRequestsFileIds(dto);
        saveFileService.deleteUnuploadedFilesFromArticleContent(dto.getContent(), dto.getFileIds());
        return new ResponseEntity<>(articleService.saveArticle(dto.toDto(boardPrincipal.toDto()), Hashtag.HashtagDto.from(dto.getHashtag()), saveFileDtos).id().toString(), HttpStatus.OK);
    }
    public Article.ArticleDto saveArticle(Article.ArticleDto dto, List<Hashtag.HashtagDto> hashtagDtos, Set<SaveFile.SaveFileDto> saveFileDtos) {
        Article article =articleRepository.save(dto.toEntity());
        for (Hashtag.HashtagDto hashtag : hashtagDtos) {
                Hashtag hashtag1= hashtagRepository.findByHashtag(hashtag.hashtag()).orElseGet(()-> hashtagRepository.save(hashtag.toEntity()));
                articlehashtagrepository.save(ArticleHashtag.of(article,hashtag1));
        }
        for (SaveFile.SaveFileDto saveFileDto : saveFileDtos) {
            if(dto.content().contains(saveFileDto.fileName())){
                articleSaveFileRepository.save(ArticleSaveFile.of(article, saveFileDto.toEntity()));
            }
        }
        return Article.ArticleDto.from(article);
    }

저는 해시태그도 양방향 관계를 중간 엔티티로 맺어주어서 등록하도록 했는데요, 파일도 마찬가지로 똑같은 로직으로 구현했습니다.

 

이렇게 구현하면 게시글에 이미지가 첨부되었을 때 중간 테이블에서 외래 키를 참조하여 데이터를 생성하게 됩니다.

 

중간 엔티티에 맵핑이 된 모습(1에 2,3 이렇게 생성되는게 아닙니다 :()

 

하지만 게시글을 등록할 시점에 게시글 내용에 이미지가 존재하지 않는다면?(지워진 상태라면?)

위에 코드에서 각각의 파일 이름이 게시글의 내용에 포함되지 않으면 맵핑을 하지 않는 방향으로 구현할 수 있는데요,

 

그렇게 해버리면 단순히 파일만 업로드되고 서버상에 잔여 파일이 남고 DB에도 유령 데이터가 존재하게 됩니다.

 

저는 그래서 파일 엔티티를 관리하는 비즈니스 로직에 게시글의 콘텐츠와 각 파일의 이름을 비교해 삭제해버리는 로직을 구현해보았습니다.

 -> 해당 로직을 응용해서 게시글 내에 '#'를 포함해 작성한다면 해시태그를 자동으로 등록하도록 하는 로직도 구현할 수 있지 않을까? 하는 생각도 들었습니다. 🤔

 

    public void deleteUnuploadedFilesFromArticleContent(String content,String fileIds){
        if(Objects.isNull(content) || Objects.isNull(fileIds) || fileIds.equals("")){
            return;
        }
        for(String fileId : fileIds.split(",")){
            if(!content.contains(saveFileRepository.getReferenceById(Long.parseLong(fileId)).getFileName())){
                deleteFile(Long.parseLong(fileId));
            }
        }
    }

이렇게 파일 서비스에 비즈니스 로직을 구현해주었는데요, delete를 담당하는 로직 자체에서 서버에 남아있는 파일까지 삭제해버리기 때문에 글 내용과 파일 아이디를 전달하면 작업할 수 있도록 구현해주었습니다.

 

이렇게 구현해주면 글을 등록할 시에 글 내용에 이미지 파일이 존재하지 않는다면 서버에 잔여 파일도 존재하지 않게 되고 쓸데없는 데이터를 입력하지도 않게 됩니다 🥸

 

시연 화면

 

이렇게 에디터에 이미지를 붙여 넣기 해주고 삭제 후 업로드해봅니다.

 

2번 아이디로 게시글이 등록이 되었다.
이미지를 내용에 포함시키지 않자 연관관계도 맺어지지 않는다!
그리고 마찬가지로 파일 테이블에도 데이터가 삽입되지 않았다.

이렇게 포함을 시키지 않아도 잔여 이미지가 남지 않게 됩니다. 

 

수정과 삭제의 경우엔 어떻게 대처를 할 수 있을까?

 

수정 같은 경우에는 글에 이미지가 포함되지 않았을 경우에 삭제해버리는 로직을 비슷하게 구현하면 됩니다.

다만 게시글을 등록할 때는 단순 즉각적으로 전달받은 업로드된 이미지의 id를 참조하여 삭제하면 됐지만

 

수정 같은 경우에는 파일 엔티티나 게시글 엔티티 자체가 각각 엔티티 자체에 서로의 엔티티를 참조하지 않고 중간 테이블이 존재하는 구조이기 때문에

 

수정 페이지 뷰에 기존에 게시글에 등록돼있던 fileId를 addattribute로 전달하고, 이미지를 업로드할 때마다 append 하는 방식으로 id를 받아와 마찬가지로 게시글에 포함이 안된 이미지는 삭제 후에 수정하는 방식으로 구현했습니다.

 

    public static String fileIdsToString(Set<SaveFile.SaveFileDto> saveFiles){
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < saveFiles.size(); i++) {
            sb.append(saveFiles.stream().toList().get(i).id()).append(",");
        }
        return  sb.toString();
    }

저는 이렇게 정적 유틸 메서드를 컨트롤러 유틸 클래스에 선언하고 호출했습니다.  

 

    @ResponseBody
    @PutMapping
    public ResponseEntity<?> updateArticle(
            @RequestBody @Valid Article.ArticleRequest articleRequest,
            BindingResult bindingResult,
            @AuthenticationPrincipal UserAccount.BoardPrincipal boardPrincipal) {
        try {
            if (bindingResult.hasErrors()) {
                return new ResponseEntity<>(ControllerUtil.getErrors(bindingResult), HttpStatus.BAD_REQUEST);
            }
            if (!Objects.equals(articleService.getWriterFromArticle(articleRequest.getArticleId()), boardPrincipal.username())) {
                return new ResponseEntity<>(ErrorMessages.ACCOUNT_NOT_MATCH, HttpStatus.BAD_REQUEST);
            }
            Set<SaveFile.SaveFileDto> saveFileDtos = saveFileService.getFileDtosFromRequestsFileIds(articleRequest);
            saveFileService.deleteUnuploadedFilesFromArticleContent(articleRequest.getContent(), Objects.requireNonNull(articleRequest.getFileIds()));
            articleService.updateArticle(articleRequest.getArticleId(), articleRequest, Hashtag.HashtagDto.from(articleRequest.getHashtag()), saveFileDtos);
            return new ResponseEntity<>("articleUpdating Success", HttpStatus.OK);
        }
        catch (EntityNotFoundException e){
            return new ResponseEntity<>(ErrorMessages.ENTITY_NOT_FOUND, HttpStatus.NOT_FOUND);
        }
    }

저는 이렇게 현재 등록된 이미지와 새로 등록된 이미지 id를 받아와서 내용에 포함되어있지 않으면 파일을 삭제하고 수정하는 방식으로 구현해 잔여 파일을 처리해주었습니다 💆🏻‍♂️

 

삭제 같은 경우에도 마찬가지로 중간 관계 엔티티에서 id를 가져온후 중간관계 엔티티를 삭제하고 파일 엔티티를 게시글 엔티티 id를 토대로 불러와 삭제를 진행하고 게시글 엔티티를 삭제하도록 구현했습니다 :D

 


마치며

  • 해당 기능을 구현하면서 처음으로 성능에 대한 고민을 할 수 있었습니다. (반복적인 쿼리 실행을 줄이기 위해 노력했습니다.)
  • 해당 기능을 구현하면서 가장 많이 했던 고민은 역시나 역할 분담이었습니다. 파일 엔티티는 어떻게 보면 지금 프로젝트상 단독으로 처리되지 못하고 참조가 되는 구조였기 때문에 고민을 하게 되었습니다.
  • 이번 기능 구현도 기본적인 에디터 옵션 사용을 제외한 나머지는 제가 직접 구현하게 되었습니다. 이제는 전체적인 프로젝트의 흐름을 어느 정도 머릿속으로 그릴 수 있게 된 것 같아서 기쁩니다 :D

 

해당 코드는 제 토이 프로젝트에서 작성한 코드들이며 구현 글들은 프로젝트의 문서화를 위해 작성한 글들입니다. 태클은 언제나 환영합니다!

  Comments,     Trackbacks
스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -1

참고한 게시글 : https://velog.io/@io_/Toast-UI-Editor-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C

 

Toast UI Editor 이미지 업로드

Spring Boots를 사용해 Toast UI Editor에 이미지 업로드하기!!!! 어느날 미래에서 삽질하고 있을 나를 위해...

velog.io

서론

  • 제이쿼리를 이용해 게시판 프로젝트에 Toast UI 에디터 / 뷰어를 적용시킵니다.
  • 이미지 파일 업로드 기능을 구현합니다. (에디터의 hooks 옵션을 통해 이미지를 비동기통신으로 업로드 하고 출력합니다.)
  • JPA로 게시글과 파일 사이의 맵핑용 엔티티 클래스를 생성해 양방향 관계를 맺어줍니다.
  • 게시글 등록시에 글에 첨부되지 않은 이미 서버에 업로드된 이미지 파일을 삭제하고 첨부된 이미지와 게시글을 양방향 관계로 맺어주는 로직을 구현해봅니다.
  • 게시글 수정시에 글에서 지워진 이미지나 마찬가지로 서버에 업로드되었지만 첨부되지 않은 잉여 이미지파일을 삭제해주고 연결관계를 끊어주는 로직을 구현합니다.
  • 게시글 삭제시에 등록되있던 이미지까지 통째로 삭제해주는 로직을 구현합니다.

본론

벨로그 글을 작성할 때 , 이미지를 등록하거나 복사 후 붙여넣기 할때 어떻게 업로드가 되고 첨부가 될까요?

제 깃허브 프로필 사진을 캡처 후 붙여 넣기를 해보겠습니다

이렇게 같은 이미지를 여러 번 붙여 넣기 해도 매번 새로 파일이 업로드되고 바로 게시글에서 조회가 가능한데요,

해당 로직의 흐름은 이런 것 같습니다..

  • 게시글에 이미지를 붙여 넣기 혹은 업로드 시에 비동기 통신으로 컨트롤러로 해당 이미지를 전송
  • 전송된 이미지는 서버에 저장되고, DB에 이미지의 정보 저장 후 해당 파일 이름+@ 랜덤 문자열을 반환
  • 앞단은 전달받은 파일 이름으로 GET method 로 이미지 출력을 요청
  • 뒷단은 이미지 파일을 바이트 배열로 전달해 작성 폼에서 바로 이미지 출력

만약 이미지를 붙여 넣기 혹은 업로드 후에 해당 글에서 다시 이미지를 삭제 후 업로드하거나, 게시글이 삭제되었거나 수정 시에도 이미지가 해당 글에서 지워진다면 남은 잉여 파일은 어떻게 처리할까?🤔

가 해당 글의 핵심이 될 것 같습니다. 🤔

게시판 프로젝트에 Toast UI 에디터/뷰어 적용시키기 💁🏻

기존에 제가 진행하던 토이 프로젝트의 글 작성과 뷰어는 단순히 textarea 태그와 타임리프의 th:text 문법을 이용해서 작성/출력을 했었는데요,

게시판에 에디터를 적용시켜보면 얼마나 깔끔해 보일까? 혹은 이것도 어떻게 보면 open api를 활용하는 것과 비슷하다고 생각하여 적용시켜보게 되었습니다.

저는 프런트를 전혀 알지 못하기 때문에 제이쿼리로 진행했습니다.

    <!-- TOAST UI Editor CDN(JS) -->
    <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
    <!-- TOAST UI Editor CDN(CSS) -->
    <link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />

적용을 시킬 작성용 폼과 게시글 출력을 시킬 html 파일의 head에 저것들을 다 넣어줍니다.

    const editor = new toastui.Editor({
        el: document.querySelector('#editor1'),
        initialEditType: 'markdown',
        previewStyle: 'vertical',
        height: '500px',
        initialValue: '내용을 입력해주세요',
        events: {
            change: function () {
                console.log(editor.getMarkdown());
            }
        },
        hooks: {
            addImageBlobHook: (blob, callback) => {
                // blob : Java Script 파일 객체
                //console.log(blob);

                const formData = new FormData();
                formData.append('file', blob);

                let url = '/files/';
                $.ajax({
                    type: 'POST',
                    enctype: 'multipart/form-data',
                    url: '/files/upload',
                    data: formData,
                    dataType: 'json',
                    processData: false,
                    contentType: false,
                    cache: false,
                    timeout: 600000,
                    success: function(data) {
                        //console.log('ajax 이미지 업로드 성공');
                        url += data.fileName;
                        fileIds += data.id + ",";
                        // callback : 에디터(마크다운 편집기)에 표시할 텍스트, 뷰어에는 imageUrl 주소에 저장된 사진으로 나옴
                        // 형식 : ![대체 텍스트](주소)
                        callback(url, '사진 대체 텍스트 입력');
                    },
                    error: function(e) {
                        //console.log('ajax 이미지 업로드 실패');
                        //console.log(e.abort([statusText]));

                        callback('image_load_fail', '사진 대체 텍스트 입력');
                    }
                });
            }
        }
    });

출처 :Toast UI Editor 이미지 업로드 - io_
해당 코드는 io_님 블로그에서 긁어왔습니다.

 

Toast UI Editor 이미지 업로드

Spring Boots를 사용해 Toast UI Editor에 이미지 업로드하기!!!! 어느날 미래에서 삽질하고 있을 나를 위해...

velog.io

에디터로 별다른 코드를 추가하지 않고 이미지를 업로드하거나 붙여 넣기 하면 에디터에 몇천 자+@ 의 base64형식으로 이미지 파일이 입력되는데요, 에디터 자체에 hooks 옵션이 존재합니다.

hooks 옵션은 해당 base64 형식의 이미지를 바로 처리하고 에디터에 리턴해줍니다.

    $(document).ready(function() {
        const viewer = toastui.Editor.factory({
            el: document.querySelector("#articleContent"),
            viewer: true,
            height: "500px",
            initialValue: document.getElementById("content").value,
        });
    }
    );

해당 코드는 게시글을 조회하는 뷰 script 태그에 붙여 넣으면 됩니다.

뷰어를 따로 또 적용시키는 이유는 , 에디터에서 지원하는 기능들이 단순 html 태그로는 출력이 되지 않는 경우가 있기 때문에 뷰어도 적용해주었습니다.

게시판에서 이미지를 업로드하면 불필요한 장문의 글자를 가로 채주는 기능이 구현됐으니 이제 이미지/파일을 업로드하고 받아오는 로직을 구현해야겠죠? 🤔

파일 컨트롤러 / 서비스 생성하기

연관관계 맵핑용 중간 엔티티 클래스 생성하기

기존에 File 엔티티 클래스는 작성이 되어있다는 가정하에, 게시글 엔티티와 파일 엔티티를 연결해줄 중간 엔티티 클래스를 생성해줍니다.

@Entity
@Getter
@NoArgsConstructor
public class ArticleSaveFile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter
    @OneToOne
    private Article article;

    @Setter
    @ManyToOne
    private SaveFile saveFile;

    private ArticleSaveFile(Long id, Article article, SaveFile saveFile) {
        this.id = id;
        this.article = article;
        this.saveFile = saveFile;
    }

    public static ArticleSaveFile of (Article article, SaveFile saveFile) {
        return new ArticleSaveFile(null, article, saveFile);
    }
}

해당 맵핑은 한 게시글에 여러 개의 파일이 첨부될 수 있기 때문에, 단순 게시글 엔티티와 파일 엔티티에 연관관계를 설정해주지 않고 중간 엔티티 클래스를 생성해 맵핑을 해주었습니다.

그리고 맵핑 엔티티를 저장할 리포지토리도 따로 생성해주었는데요, 여기서 고민할 수 있는 부분은 두 가지였습니다.

  • 파일 엔티티는 단순히 혼자서 업로드되고 삭제될 수 없는 것 같은데 파일 서비스가 단독적으로 동작할 수 있게 해도 될까?
    • 해당 고민은 파일 엔티티는 파일 저장소를 구현하는 프로젝트가 아닌 이상 단독으로 다뤄질 일이 없다고 생각했기 때문에 파일 CRUD가 필요한 컨트롤러에 주입해주는 것으로 타협했습니다.
      • 그러면 애초에 다른 비즈니스 로직에서 파일 리포지토리를 사용하면 되지 않나? 에 대한 고민은 지금 프로젝트는 서비스 클래스 단위로 트랜잭션이 이뤄지기 때문에 컨트롤러에서 파일 서비스로 파일을 다룰 수 있도록 역할을 분담해주었습니다.
  • 이런 중간 엔티티용 서비스와 컨트롤러도 만들어서 역할을 분리해야 할까?
    • 연관관계 맵핑 엔티티는 마찬가지로 각각의 엔티티가 존재하지 않으면 존재할 이유도 없기 때문에, 각각 비즈니스 로직에 리포지토리를 주입해 사용하도록 했습니다.

파일 엔티티 비즈니스 로직 설계하기

파일을 등록/조회/삭제 처리를 해줄 비즈니스 로직을 설계해줍니다.

@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class SaveFileService {
    private final SaveFileRepository saveFileRepository;
    private final ArticleSaveFileRepository articleSaveFileRepository;

    @Transactional(readOnly = true)
    public SaveFile.SaveFileDto getFile(Long fileId) {
        log.info("getFile() fileId: {}", fileId);
        return saveFileRepository.findById(fileId).map(SaveFile.SaveFileDto::from).orElseThrow(()-> new EntityNotFoundException("파일이 없습니다 - fileId: " + fileId));
    }
    
    @Transactional(readOnly = true)
    public SaveFile.SaveFileDto getFileByFileName(String fileName) {
        log.info("getFile() fileId: {}", fileName);
        return saveFileRepository.findByFileName(fileName).toDto();
    }

    public void deleteFile(Long fileId) {
        log.info("deleteFile() fileId: {}", fileId);
        File file = new File(saveFileRepository.getReferenceById(fileId).getFilePath());
        if (file.exists()) {
            if(file.delete()){
                log.info("파일삭제 성공");
            }
        }
        articleSaveFileRepository.deleteBySaveFileId(fileId);
        saveFileRepository.deleteById(fileId);
    }

    public SaveFile.SaveFileDto saveFile(SaveFile.SaveFileDto saveFile) {
        log.info("saveFile() saveFile: {}", saveFile);
        return SaveFile.SaveFileDto.from(saveFileRepository.save(saveFile.toEntity()));
    }

일단 해당 파일들을 저장/삭제/조회하는 로직만 구현해주었는데요, 파일 업로드 시에 db가 아닌 서버에 저장되는 로직은 유틸 클래스를 생성해서 정적 메서드로 처리하도록 했습니다.

    public static File getMultipartFileToFile(MultipartFile multipartFile) throws IOException {
        File file = new File(uploadPath,getFileNameWithUUID(multipartFile.getOriginalFilename()));
        multipartFile.transferTo(file);
        return file;
    }

이렇게 해당 메서드를 선언하여 서버에 저장하고, 또 따로 File 타입을 (해당 File타입은 엔티티가 아닙니다) File엔티티 dto 로 변환해주는 메소드를 선언해 편의성을 챙겼습니다.

 

여기서 신경 쓸건 파일이 생성되지 않거나 제대로 된 파일을 전달받지 못해서 비어있는 파일 정보를 가지고 db에 저장하는 걸 방지하기 위해 해당 메서드들은 파일 컨트롤러에서 호출하도록 구현했습니다.

 

파일 컨트롤러 생성하기

비즈니스 로직을 구현해주었으니 이제 앞단에서 전달받은 파일을 어떻게 처리할지를 컨트롤러에서 구현해줍니다.

@RestController
@Slf4j
@RequiredArgsConstructor
public class SaveFileController {
    private final SaveFileService saveFileService;

    @PostMapping("/files")
    public ResponseEntity<?> uploadFile(@RequestPart("file")MultipartFile file, @AuthenticationPrincipal UserAccount.BoardPrincipal principal) throws IOException {
        try{
        SaveFile.SaveFileDto fileDto = saveFileService.saveFile(FileUtil.getFileDtoFromMultiPartFile(file,principal!=null ? principal.getUsername() : "anonymous"));
        return new ResponseEntity<>(fileDto, HttpStatus.OK);
        }catch (Exception e){
            log.error("uploadFile() error: {}", e.getMessage());
            return new ResponseEntity<>(ErrorMessages.FILE_UPLOAD_FAIL, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

    @GetMapping("/files/{fileName}")
    public ResponseEntity<?> getFile(@PathVariable String fileName) throws IOException {
        try{
        File file = FileUtil.getFileFromSaveFile(saveFileService.getFileByFileName(fileName));
        byte[] imgArray = IOUtils.toByteArray(new FileInputStream(file));
        return new ResponseEntity<>(imgArray,HttpStatus.OK);
    }
        catch (EntityNotFoundException e){
            return new ResponseEntity<>(ErrorMessages.NOT_FOUND,HttpStatus.NOT_FOUND);
        }
    }
}

파일이 비어있다면(그럴리는 없지만) IOException 이 발생할 것이기 때문에 해당 예외 처리를 컨트롤러에서 진행해줍니다.

 

하지만 이미지가 없다고 출력이 안되면 안 되기 때문에 catch 문으로 에러 메시지를 반환해줍니다.

 

이미지 업로드 후 db 반영 결과 조회하기

 

이제 이미지를 업로드/붙여 넣기 를 하면 즉시 작성 폼에는 이미지 출력용 url과 대체 텍스트가 입력됩니다.

 

맥북 배경화면을 캡쳐해서 붙여넣기 해보았습니다.

이렇게 이미지를 붙여넣기 하면 그 즉시 POST 메서드로 비동기 통신을 하게 되는데요 , 

 

이렇게 바로 DB에 반영이 됩니다.

 

이제 여기서 파일 컨트롤러와 게시글 컨트롤러는 분리되어있는데 게시글 서비스 클래스에서 연관관계를 맺을 수 있을까?

에 대한 로직 구현이 필요합니다.

 

해당 내용은 글이 길어졌기 때문에 다음 글에서 설명하도록 하겠습니다 :)

  Comments,     Trackbacks