๐Ÿ”ฅ TIL - Day 73 Kotlin & Springboot 04 ํŒŒ์ผ ์—…๋กœ๋“œ (MultipartFormData ๋‹ค๋ฃจ๊ธฐ) & Junit ํŒŒ์ผ์—…๋กœ๋“œ ํ…Œ์ŠคํŠธ

Kim Dae Hyunยท2021๋…„ 12์›” 15์ผ
2

TIL

๋ชฉ๋ก ๋ณด๊ธฐ
84/93

์ „์ฒด์†Œ์Šค Github

์ œ๋ชฉ, ๋‚ด์šฉ๊ณผ ํ•จ๊ป˜ ์ด๋ฏธ์ง€ ํ•˜๋‚˜๋ฅผ ์ž…๋ ฅ๋ฐ›์•„ ์ €์žฅํ•˜๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๊ณ ์ž ํ•œ๋‹ค.
UI ์—†์ด Junit์„ ์ด์šฉํ•œ ํ…Œ์ŠคํŠธ๋งŒ ์ง„ํ–‰ํ•œ๋‹ค.

๐Ÿ“Œ Domain

ํŒŒ์ผ ์—…๋กœ๋“œ์— ์ง‘์ค‘ํ•˜๊ธฐ ์œ„ํ•ด ๊ท€์ฐฎ์€ ์ผ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์–‘๋ฐฉํ–ฅ ๊ด€๊ณ„๋Š” ๋‘์ง€ ์•Š์•˜๋‹ค.
BoardImage์—์„œ Board๋ฅผ ๋ณด๋Š” ๋‹จ๋ฐฉํ–ฅ ๊ด€๊ณ„๋งŒ ์„ค์ •ํ•œ๋‹ค.

@Entity
class Board(
    title: String,
    content: String,
    member: Member
) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @Column(nullable = false)
    var title: String = title
        protected set

    @Column(nullable = false)
    var content: String = content
        protected set

    @ManyToOne(fetch = FetchType.LAZY)
    var member: Member = member
        protected set

    fun addMember(member: Member){
        this.member = member
        member.boards.add(this)
    }

    fun updateBoard(updateDto: BoardUpdateDto) {
        this.title = updateDto.title
        this.content = updateDto.content
    }

    fun modifyMember(member: Member) {
        this.member = member    
    }
}

ํŒŒ์ผ์„ ๊ด€๋ฆฌํ•  BoardImage ์—”ํ‹ฐํ‹ฐ

saveFileName์€ ์„œ๋ฒ„ ์ธก์—์„œ ๊ด€๋ฆฌํ•˜๋Š” ํŒŒ์ผ์˜ ์ด๋ฆ„์ด๋‹ค.
UUID๋ฅผ ์ด์šฉํ•ด์„œ ๋‚œ์ˆ˜ํ™” ๋œ ์ด๋ฆ„์ด ์„ค์ •๋˜๋„๋ก ํ•  ๊ฒƒ์ด๋‹ค. ์—…๋กœ๋“œ ๋˜๋Š” ํŒŒ์ผ์˜ ์ด๋ฆ„์ด ์ค‘๋ณต๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•จ์ด๋‹ค.

originalFileName์€ ์‚ฌ์šฉ์ž๊ฐ€ ์—…๋กœ๋“œ ํ•œ ํŒŒ์ผ์ด๋ฆ„์„ ์ €์žฅํ•œ๋‹ค.

@Entity
class BoardImage(
    originalFileName: String,
    saveFileName: String,
    board: Board) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @Column(nullable = false)
    val saveFileName: String = saveFileName

    @Column(nullable = false)
    val originalFileName: String = originalFileName

    @ManyToOne(fetch = FetchType.LAZY)
    val board: Board = board
}

๐Ÿ“Œ Controller

์ด๋ฏธ์ง€๋Š” ์„ ํƒ์ ์œผ๋กœ ๋„˜์–ด์˜ค๋„๋ก (required = false) ํ•œ๋‹ค.
(์ด์ „ ํ”„๋กœ์ ํŠธ์™€ ์—ฐ๊ฒฐ๋˜์–ด Security๊ฐ€ ์ ์šฉ๋œ ์ƒํƒœ์ด๋‹ค.)

ํ˜„์žฌ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์™€ ๋ฐ›์€ ๋ฐ์ดํ„ฐ๋ฅผ Service ๊ณ„์ธต์œผ๋กœ ๋„˜๊ฒจ์„œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ˆ˜ํ–‰ํ•œ๋‹ค.

MultipartFile์„ ์ด์šฉํ•ด์„œ ๋„˜์–ด์˜ค๋Š” ๋ฐ”์ด๋„ˆ๋ฆฌ ํŒŒ์ผ์„ ๋ฐ›์•„์ค€๋‹ค.
Multipart ์ฐธ๊ณ 

@RequestMapping("/api/boards")
@RestController
class BoardController (private val boardService: BoardService) {

    @PostMapping
    fun postBoard(
        @AuthenticationPrincipal userDetails: UserDetailsImpl,
        @RequestParam title: String,
        @RequestParam content: String,
        @RequestPart(required = false) file: MultipartFile?): ResponseEntity<String> {

        val member = userDetails.member
        boardService.saveBoard(member, title, content, file)
        return ResponseEntity.ok().body("์ €์žฅ์™„๋ฃŒ")
    }
}

๐Ÿ“Œ Service

MultipartFile์˜ originalFilename ๋ฉ”์„œ๋“œ๋กœ ์—…๋กœ๋“œ ๋  ํŒŒ์ผ์˜ ์ด๋ฆ„์„ ์–ป์–ด์˜ค๊ณ  ์ด ์ด๋ฆ„์„ ์ด์šฉํ•ด์„œ ์„œ๋ฒ„์—์„œ ๊ด€๋ฆฌํ•  ํŒŒ์ผ์ด๋ฆ„๊นŒ์ง€ ๋งŒ๋“ค์–ด์ค€๋‹ค.

์›๋ณธ์ด๋ฆ„๊ณผ ์„œ๋ฒ„์—์„œ ๊ด€๋ฆฌํ•  ์ด๋ฆ„์ด ์ƒ์„ฑ๋๋‹ค๋ฉด transferTo ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด์„œ ํŒŒ์ผ์„ ์ง€์ •ํ•œ ๊ฒฝ๋กœ์— ์ €์žฅํ•˜๊ณ  BoardImage ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ƒ์„ฑํ•ด์„œ DB์— ์ €์žฅํ•œ๋‹ค.
์‹ค์ œํŒŒ์ผ์„ ์ €์žฅํ•˜๋Š” ๋ถ€๋ถ„์€ ์ดํ›„ S3์— ์—…๋กœ๋“œํ•˜๋Š” ๊ฒƒ์œผ๋กœ ๋ณ€๊ฒฝํ•  ์˜ˆ์ •์ด๋‹ค.

@Service
class BoardService(
    private val boardRepository: BoardRepository,
    private val boardImageRepository: BoardImageRepository,
) {

    val uploadeDir = "์—…๋กœ๋“œ_๋ _๊ฒฝ๋กœ"

    @Transactional
    fun saveBoard(member: Member, title: String, content: String, file: MultipartFile?) {

        val board = Board(title, content, member)
        boardRepository.save(board)
        
        // ์›๋ณธํŒŒ์ผ์ด๋ฆ„, ์ €์žฅ๋  ํŒŒ์ผ์ด๋ฆ„  
        val originalFilename: String? = file?.originalFilename
        val saveFileName = getSaveFileName(originalFilename)

	// ํŒŒ์ผ ์ €์žฅ(transferTo), ํŒŒ์ผ์ •๋ณด DB์— ์ €์žฅ
        if (file != null && originalFilename != null) {
            file.transferTo(File(uploadeDir + saveFileName))
            val boardImage = BoardImage(originalFilename, saveFileName, board)
            boardImageRepository.save(boardImage)
        }
    }
    
    // ์ €์žฅ๋  ํŒŒ์ผ์ด๋ฆ„ ์ƒ์„ฑ
    private fun getSaveFileName(originalFilename: String?): String {
        val extPosIndex: Int? = originalFilename?.lastIndexOf(".")
        val ext = originalFilename?.substring(extPosIndex?.plus(1) as Int)

        return UUID.randomUUID().toString() + "." + ext
    }
}

๐Ÿ“Œ Junit Test

ํ…Œ์ŠคํŠธ๋Š” MockMultipartFile๋ฅผ ์ด์šฉํ•ด์„œ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ง„ํ–‰ํ•œ๋‹ค.
MockMultipartFile์€ ํŒŒ์ผ์„ API๋กœ ๋„˜๊ธธ ๋•Œ ๋งคํ•‘๋  ์ด๋ฆ„๊ณผ ํŒŒ์ผ์ด๋ฆ„, contentType, ์‹ค์ œ๋ฐ์ดํ„ฐ๊นŒ์ง€ ๋งŒ๋“ค์–ด์„œ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๋Š” ์ข‹~~์€ ๋„๊ตฌ์ด๋‹ค.

ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด์„œ ํŠน์ • ๊ฒฝ๋กœ์— ํŒŒ์ผ์„ ์œ„์น˜์‹œํ‚ค๋Š” ๋“ฑ์˜ ์ž‘์—…์ด ํ•„์š” ์—†์–ด์„œ ๋ณด๋‹ค ๋…๋ฆฝ์ ์ธ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๋‹ค.

MockMultipartFile(๋งคํ•‘๋ _์ด๋ฆ„, ์›๋ณธํŒŒ์ผ์ด๋ฆ„, ContentType, ๋ฐ์ดํ„ฐ(byte))

๋จผ์ € MockMultipartFile๋ฅผ ์ด์šฉํ•ด์„œ text ํŒŒ์ผ ํ•˜๋‚˜๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
ํŒŒ์ผ๋ถ€๋ถ„์€ file๋กœ, String ๋ฐ์ดํ„ฐ๋Š” part๋กœ ์ง€์ •ํ•ด์„œ ์„œ๋ฒ„๋กœ ๋„˜๊ธด๋‹ค.
(headers ๋ถ€๋ถ„์€ security๊ฐ€ ์ ์šฉ๋œ ์ƒํƒœ๋ผ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด ์„ค์ •ํ–ˆ๋‹ค.)

์„œ๋ฒ„๋กœ ์š”์ฒญ์ด ๋๋‚˜๋ฉด DB ์ €์žฅ์—ฌ๋ถ€๊นŒ์ง€ ํ™•์ธํ•˜๋Š” ๊ฒƒ์œผ๋กœ ํ…Œ์ŠคํŠธ๋ฅผ ๋งˆ์นœ๋‹ค.

@Test
@DisplayName("์—…๋กœ๋“œ ํ…Œ์ŠคํŠธ")
fun `Board ์ €์žฅ - ํŒŒ์ผ์—…๋กœ๋“œ ํฌํ•จ`() {

    val imagePart = MockMultipartFile("file","test.txt" , "text/plain" , "hello file".byteInputStream(StandardCharsets.UTF_8))

    mockMvc.multipart("/api/boards")
    {
        file(imagePart)
            .part(MockPart("title", "title1".toByteArray(StandardCharsets.UTF_8)))
            .part(MockPart("content", "content1".toByteArray(StandardCharsets.UTF_8)))
        headers {
            header("Authorization", "bearer ".plus(token))
        }
    }
        .andDo {
            print()
        }
        .andExpect {
            status { isOk() }
        }

    val boardImages = boardImageRepository.findAll()

    Assertions.assertEquals(1, boardImages.size)
    Assertions.assertEquals("test.txt", boardImages.get(0).originalFileName)
}

ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ


์ง€์ •ํ•œ ๊ฒฝ๋กœ์— ํŒŒ์ผ์ด ์ €์žฅ๋œ ๊ฒƒ ๊นŒ์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

profile
์ข€ ๋” ์ฒœ์ฒœํžˆ ๊นŒ๋จน๊ธฐ ์œ„ํ•ด ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ๐Ÿง

0๊ฐœ์˜ ๋Œ“๊ธ€