Post

[Springboot] 포토그램 프로필 페이지 구현


아이티윌의 국비지원 [스프링부트 SNS 포토그램 프로젝트] 강의를 수강하며 정리한 내용입니다.


오늘의 실습

  • 프로필 페이지 구현(이미지 업로드 및 렌더링)

실습

먼저 로컬디렉토리에 사용자가 업로드한 이미지가 잘 삽입되는 지 확인해보았다.

1. 이미지 업로드 하기

[Image.java]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class Image {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private int id;
	private String caption;
	private String postImageUrl; // 사진을 전송 받아서 그 사진을 서버 특정 폴더에 저장 - DB에 그 저장된 파일이름을 insert
	
	@JoinColumn(name = "userId")
	@ManyToOne
	private User user; // 누가 업로드 했는지
	
	// 이미지 좋아요 나중에 추가 예정
	
	// 댓글 나중에 추가 예정 
	
	private LocalDateTime createDate;
	
	@PrePersist
	public void createDate() {
		this.createDate = LocalDateTime.now();
	}
}

여기서 user의 경우 Object이기 때문에 DB에 저장할 수 없어 FK로 저장된다.



[ImageUploadDto.java]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Builder
@Data
@AllArgsConstructor
public class ImageUploadDto {
	private MultipartFile file; // 사진파일
	private String caption; // 사진설명
	
	public Image toEntity(String postImageUrl, User user) {
		return Image.builder()
				.caption(caption)
			    .postImageUrl(postImageUrl)
			    .user(user)
			    .build();
	}
}

이미지 업로드 때 사용할 DTO를 생성해줬다.


DTO를 만들었으면 유효성 검사를 해야 하는데, MultipartFile은 @NotBlank가 지원이 안 되기 때문에 Controller에서 처리해주었다.



[ImageController.java]

1
2
3
4
5
6
7
8
9
10
@PostMapping("/image")
	public String imageUpload(ImageUploadDto imageUploadDto, @AuthenticationPrincipal PrincipalDetails principalDetails) {
		
		if(imageUploadDto.getFile().isEmpty()) {
			throw new CustomValidationException("이미지가 첨부되지 않았습니다.", null);
		}
		
		imageService.사진업로드(imageUploadDto, principalDetails);
		return "redirect:/user/" + principalDetails.getUser().getId();
	}

이미지 업로드 정보(파일, 설명)와 사용자 정보를 받아 서비스를 호출하고 사용자페이지를 리턴해준다.



[application.yml]

1
2
3
4
5
6
7
8
  servlet:
    multipart:
      enabled: true
      max-file-size: 2MB
      
file:
  path: C:/workspace/springbootwork/upload/

yml 파일에 적어둔 값은 프로젝트에서 변수처럼 사용할 수 있는데,
이미지파일 경로를 가져올 때 편하게 가져오기 위해 application.yml에 지정해두었다. 끝에 슬러시 꼭 붙이기!


윗 부분 코드 네 줄은 멀티파트 파일 업로드 기능을 구성하는 부분이다.
enabled: true 를 통해 멀티파트 파일 업로드 기능을 활성해주었다.



[ImageService.java]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RequiredArgsConstructor
@Service
public class ImageService {
	
	private final ImageRepository imageRepository;
	
	@Value("${file.path}")
	private String uploadFolder;
	
	@Transactional
	public void 사진업로드(ImageUploadDto imageUploadDto, PrincipalDetails principalDetails) {
		UUID uuid = UUID.randomUUID();
		String imageFileName = uuid + "_" + imageUploadDto.getFile().getOriginalFilename();
		
		Path imageFilePath = Paths.get(uploadFolder + imageFileName);
		
		// 통신, I/O이 일어날 때 예외가 발생할 수 있다.
		try {
			Files.write(imageFilePath, imageUploadDto.getFile().getBytes());
		}catch(Exception e) {
			e.printStackTrace();
		}
	}
}

@Value("${file.path") : applicatioin.yml에 설정해두었던 파일경로를 가져온다.
주의) org.springframework의 어노테이션을 import 해주어야 함!


UUID란 네트워크 상에서 고유성이 보장되는 id를 만들기 위한 표준 규약이다.
사용자의 사진 정보를 서버에 담아 저장하고, DB에는 해당 경로를 저장할 건데, 같은 이름의 사진이 들어올 경우 서버에서 덮어씌어지므로 이를 예방하기 위해 고유 imageFileName을 부여해줬다.

통신, I/O이 일어날 땐 예외가 발생할 수 있으므로 예외처리도 해주었다.



[image.jsp]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 <!--사진업로드 Form-->
<form class="upload-form" action="/image" method="post" enctype="multipart/form-data">
    <input  type="file" name="file"  onchange="imageChoose(this)"/>
    <div class="upload-img">
        <img src="/images/person.jpeg" alt="" id="imageUploadPreview" />
    </div>
    
    <!--사진설명 + 업로드버튼-->
    <div class="upload-form-detail">
            <input type="text" placeholder="사진설명" name="caption" />
      	    <button class="cta blue">업로드</button>
    </div>
    <!--사진설명end-->
    
</form>

enctype="multipart/form-data : 2개 이상의 타입을 전송할 때 묶어주는 타입이다. default는 x-www-form-urlencoded로 되어있다.
바이트 데이터와(파일은 바이트화 되어서 날라감), key-value 데이터 두가지를 함께 보내기 위해 enctype을 multipart/form-data를 지정해준다.

image

업로드 해준 후 폴더에 사진이 들어와 있는 것을 확인!



업로드 폴더를 외부에 둔 이유는❓

자바 프로젝트 내부에는 .java 폴더가 존재하며, 서버가 실행될 때 이 폴더의 파일들이 컴파일 과정을 거쳐 실행된다. 컴파일된 .class 파일들은 target 폴더에 들어가며, 서버는 이 target 폴더 안의 파일들을 실행시킨다.


서버 실행 과정:

  1. .java 폴더의 파일들을 컴파일.
  2. 컴파일된 .class 파일들을 target 폴더에 넣음.
  3. arget 폴더의 파일들을 실행 (deploy).


모든 파일들은 target 폴더에 있어야 실행될 수 있는데, 사진과 같은 정적 파일들은 컴파일되지 않고 target 폴더에 그대로 들어가게 된다. 만약 upload 폴더를 내부에 만들면, 서버 실행 시 upload 폴더에 저장된 파일들(예: 1.jpg)이 target 폴더로 들어간다.


이 경우, 사진 파일이 target 폴더에 들어가기도 전에 프로젝트가 먼저 실행되는 상황이 발생할 수 있다.


이로 인해 배포(deploy) 시간보다 화면 전환 시간이 더 빠르면 시간 차이로 인해 사진 대신 엑스박스가 뜨는 문제가 발생할 수 있다.


2. 양방향 매핑을 통해 사용자 별로 업로드한 사진을 렌더링 하기

[UserProfileDto.java]

1
2
3
4
5
6
7
8
9
10
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class UserProfileDto {
	private boolean pageOwnerState; // 페이지 사용자와 로그인한 사용자가 일치하는지 여부   
	private int imageCount; // 게시글 수   
	private User user; // 사용자 정보
}

JSP파일에서 자바코드를 최대한 줄이기 위해 UserProfileDto를 생성해주었다.



[UserService.java]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RequiredArgsConstructor
@Service
public class UserService {

	private final UserRepository userRepository;
	
	public UserProfileDto 회원프로필(int pageUserId, int principalId) {
		UserProfileDto dto = new UserProfileDto();
		// SELECT * FROM image WHERE userId = :pageUserId
		User userEntity = userRepository.findById(pageUserId).orElseThrow(()->{
			throw new CustomException("해당 프로필 페이지는 없는 페이지입니다.");
		});
		
		dto.setUser(userEntity);
		dto.setPageOwnerState(pageUserId == principalId); // 1은 페이지 주인, -1은 페이지 주인 아님
		dto.setImageCount(userEntity.getImages().size());
		
		return dto;
	}
}

페이지에 해당하는 userId를 기반으로 사용자의 프로필을 조회하는 UserService 메소드를 만들어 주었다.
페이지에 해당하는 userId와 로그인한 usrId가 일치하면 사진등록 버튼이, 일치하지 않으면 구독하기 버튼이 보여지게 된다.



[UserController.java]

1
2
3
4
5
6
7
8
9
10
11
12
13
@RequiredArgsConstructor
@Controller
public class UserController {

	private final UserService userService;
	
	@GetMapping("/user/{pageUserId}")
	public String profile(@PathVariable int pageUserId, Model model, @AuthenticationPrincipal PrincipalDetails principalDetails) {
		UserProfileDto dto = userService.회원프로필(pageUserId, principalDetails.getUser().getId());
		model.addAttribute("dto", dto);
		return "user/profile";
	}
}

사용자의 프로필 페이지로 이동할 때, 해당 사용자에 대한 이미지 정보뿐만 아니라 회원정보, 구독정보까지 같이 가져가야 한다.(user, image, subscribe)


때문에 DB에서 User정보를 가져올 때, User의 Image들을 같이 뽑아오는 로직을 만들어줘야 한다. -> 양방향매핑!
아래에서 계속



[Image.java]

1
2
3
@JoinColumn(name = "userId")
	@ManyToOne
	private User user;

Image 도메인에는 User정보를 기존에 적어두었기에 그대로 두고

[User.java]

1
2
3
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY) // 나는 연관관계의 주인이 아니다.
@JsonIgnoreProperties({"user"})
	private List<Image> images;

User 도메인에 Image를 추가해준다.
OneToMany(mappedBy = "user")를 통해 User DB에는 실제로 칼럼이 생성되지는 않는다.


  • fetch = FetchType.LAZY : User를 SELECT할 때가 아닌 getImages() 함수의 image들이 호출될 때 가져오라는 뜻이다.
  • fetch = FetchType.EAGER : User를 SELECT할 때, 해당 User id로 등독된 image들을 전부 Join해서 가져오라는 뜻이다.


SELECT될 때마다 가져올 필요 없으므로 LAZY로 설정해주었다.


@JsonIgnoreProperties({"user"}) : JPA 무한참조 방지를 위해 JSON 처리 시 Image 내부에 있는 user 속성을 무시하도록 지정해주었다.


[profile.jsp]

1
2
3
4
5
6
7
8
9
10
<c:forEach var="image" items="${dto.user.images}">
		<div class="img-box">
			<a href=""> <img src="/images/home.jpg" />
			</a>
			<div class="comment">
				<a href="#" class=""> <i class="fas fa-heart"></i><span>0</span>
				</a>
			</div>
		</div>
</c:forEach>

EL표현식에선 변수명을 적으면 get함수가 자동 호출된다.



image 이렇게까지 했을 때, 실행해보면 로직은 제대로 동작하는데 사진을 제대로 못 불러온다.


image

앞에 이미지 경로없이 이미지 이름만 불러왔기 때문이다.
이미지 경로를 application.yml에 저장해두었으므로 이를 불러오는 설정을 해주어야 한다.

[WebMvcConfig.java]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration 
public class WebMvcConfig implements WebMvcConfigurer{ // web 설정 파일
	
	@Value("${file.path}")
	private String uploadFolder;
	
	@Override
		public void addResourceHandlers(ResourceHandlerRegistry registry) {
			WebMvcConfigurer.super.addResourceHandlers(registry);
			
			registry
				.addResourceHandler("/upload/**") // jsp페이지에서 /upload/** 주소 패턴이 나올 때 발동
				.addResourceLocations("file:///" + uploadFolder)
				.setCachePeriod(60*10*6) // 1시간동안 이미지 캐싱
				.resourceChain(true)
				.addResolver(new PathResourceResolver());
	}

}

Config 폴더에 WebMvcConfigurer를 구현하는 WebMvcConfig 클래스를 만들어주었다.

registry.addResourceHandler("/upload/**") : URL 패턴 /upload/**에 대한 요청이 들어올 때 이 핸들러가 발동된다.



[profile.jsp 수정]

1
2
3
4
5
6
7
8
9
10
11
<!-- EL표현식에서는 변수명을 적으면 get함수가 자동 호출됨. -->
<c:forEach var="image" items="${dto.user.images}">
	<div class="img-box">
		<a href=""> <img src="/upload/${image.postImageUrl}" />
		</a>
		<div class="comment">
			<a href="#" class=""> <i class="fas fa-heart"></i><span>0</span>
			</a>
		</div>
	</div>
</c:forEach>

<img src="/upload/${image.postImageUrl}" /> 이렇게 /upload로 시작하는 패턴이 발동하면 /upload가 //file:///C:/workspace/springbootwork/upload(설정해두었던 이미지 저장 경로)라는 주소로 바뀌게 된다.


그럼 이미지가 잘 뜨는 것을 확인할 수 있다!


위에 사진에서 보면 사진등록과 구독하기가 같이 떠 있는 걸 볼 수 있는데,

1
2
<button class="cta" onclick="location.href='/image/upload'">사진등록</button>
<button class="cta" onclick="toggleSubscribe(this)">구독하기</button>

여기서

1
2
3
4
5
6
7
8
<c:choose>
	<c:when test="${dto.pageOwnerState}">
		<button class="cta" onclick="location.href='/image/upload'">사진등록</button>
	</c:when>
	<c:otherwise>
		<button class="cta" onclick="toggleSubscribe(this)">구독하기</button>
	</c:otherwise>
</c:choose>

다음과 같은 코드를 추가해 수정해주었다.


최종
쌀 사용자로 로그인하고 쌀 사용자 페이지로 들어간 모습이다!


This post is licensed under CC BY 4.0 by the author.