에러 발생 시 해결 + 시큐리티

Park sang woo·2022년 12월 23일
0
post-thumbnail

🔖 Cannot resolve symbol~~ 에러

프로젝트 다시 킬 때 자주 보인다.


해결방법.

  1. 빌드를 다시 시작.
    상단 메뉴바 Build -> Rebuild Project

나의 경우 이 방법으로 해결함. (좀 시간이 걸림.)

  1. 캐시를 비우고 재실행.
    상단 메뉴바 File -> Invalidate Caches -> Restart...

2번 사용하고도 @SpringBootTest 가 에러 발생해서 그 다음은 이것을 사용.

  1. Gradle을 Refresh.
    상단 메뉴바 View -> Tool Windows -> Gradle
    프로젝트명을 마우스 우클릭하여, Refresh Gradle Dependencies 해준다.





깃허브에 있는 프로젝트 pull 땡겨오기 (가져오기)

깃허브에서 Download Code 해버리면 Cannot resolve symbol 에러가 미친듯이 발생한 것을 볼 수도 있다.
이럴 때는 위 방법대로 해도 안 되는 경우가 있을 수 있다. (하루를 거의 날려버림 ㅜㅜ)

프로젝트 켜서 File -> Close Project -> Get from VCS -> 깃허브 URL 복사해서 프로젝트 실행 시키면 끝.

(추가로 maven이라면 tomcat을 다운 받아서 서버 연결을 한다. 이것도 안 했어서 낭패봄.)






🔖 Cannot invoke . because the return value of "" is null

데이터 전달이 안됨.
실행을 했을 때 member에 대한 데이터가 데이터베이스에 들어가는지 확인을 해보고 null로 들어가게 되면 데이터 채워넣음.

mysql 사용할 때 예전 세션을 가져올 수 있어 데이터가 안 들어가질 수 있으므로 꼭 갱신을 해서 살펴보기
갱신하는 방법 -> 테이블 우클릭 후 새로고침.






🔖 Multipart 이미지 업로드

Multipart란
사진 설명을 위한 input (type="text")과 사진을 위한 input(type="file") 2개가 들어간다고 가정할 때 이 두 input 간에 Content-type은 사진 설명은 application/x-www-form-urlencoded 이 될 것이고, 사진 파일은 image/jpeg이다.
즉 하나의 요청에 Content-type이 서로 다른 것이 2개가 있다는 것이다.

두 종류의 데이터가 하나의 HTTP Request Body에 들어가야 하는데, 한 Body에서 이 2종류의 데이터를 구분해서 넣어주는 방법도 필요해졌다. 그래서 등장하는 것이 multipart 타입입니다.


이렇게 Body에서 이 데이터를 구분해야하기 때문에 요청 파라미터를 url뒤에 문자열로 추가하는 GET방식으로는 파일을 보낼 수 없습니다. 그래서 multipart타입은 POST방식에서만 사용가능합니다.






🔖 [file:null] cannot be resolved in the file system for checking its content length

이미지 업로드 multipart file 하면서 에러가 자주 발생했다.
이미지 파일이 데이터베이스에 제대로 저장되어 있지 않은데 이미지를 불러오려고 하기 때문에 발생했다.

나같은 경우 item에 대한 테이블에 이미지 파일 정보가 저장을 하지 않아서 발생했다. 그래서 item 테이블에 파일에 대한 Entity를 추가하여 DB에 저장했다.

DB에 저장할 때 같은 튜플에 저장을 해야 하는데 자꾸 2번 저장을 했다.
ex)



ItemService

@Transactional
    public void saveItem(Item item, MultipartFile files) throws IOException {

        if (files != null) {
            // 원래 파일 이름
            String origName = files.getOriginalFilename();

            // 파일 이름으로 쓸 uuid 생성
            String uuid = UUID.randomUUID().toString();

            // 확장자 추출(ex : .png)
            String extension = origName.substring(origName.lastIndexOf("."));

            // uuid와 확장자 결합
            String savedName = uuid + extension;

            // 파일을 불러올 때 사용할 파일 경로
            String savedPath = fileDir + savedName;



            // 실제로 로컬에 uuid를 파일명으로 저장
            files.transferTo(new File(savedPath));

            // 데이터베이스에 파일 정보 저장
            item.setFileOriName(origName);
            item.setSaveName(savedName);
            item.setFileUrl(savedPath);

        }

        itemRepository.save(item);

    }

파일에 대한 정보를 위처럼 형태를 바꿔서 저장을 하고 새로운 상품을 등록했다.



ItemController

@PostMapping("/~~")
    public String create(ItemForm form, @RequestParam("files") List<MultipartFile> files) throws IOException{
        Member member = memberService.findOne(form.getMemberId());
        Clothes clothes = Clothes.createClothes(member, form.getName(), form.getPrice(), form.getStockQuantity(), form.getBrand(), form.getSize(), form.getColor());


        if (files != null && !files.isEmpty()) {
            itemService.saveItem(clothes, files.get(0));
        }
        else{
            itemService.saveItem(clothes, null);
        }

        log.info("get(0) : {}", files.get(0));


        return "redirect:/items";
    }

itemService.saveItem(); 에서 파라미터를 2개를 받아야 하는데 두 번째 파라미터(files.get(0))를 어떻게 줘야 할지 몰라 계속 해맸다.
위처럼 저장을 하면 이제 하나의 튜플에 item 정보와 이미지 정보가 저장이 된다.






🔖 No serializer found for class java.io.BufferedInputStream and no properties discovered to create BeanSerializer

MvcConfig

override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
  (코드 생략)
    }

MvcConfig 파일에서 kotlin으로 img를 통해서 addResource 할 수 있도록 설정을 해줌.
그래서 multipart 이미지 업로드 해줄 때 downloadImage해줄 필요가 없음.
addResource 해주지 않으면 downloadImage를 해줘야 함.

@ResponseBody
@GetMapping("/images/{saveName}")
public Resource downloadImage(@PathVariable String saveName) throws MalformedURLException {
    return new UrlResource("file:" + fileStore.getFullPath(saveName));
}

<img src="/img/${item.saveName}">

html 부분도 img 폴더에서 가져올 수 있도록 수정해줌.

localhost:8080/img/~~~.png 이런식으로 url에 넣어보고 이미지 잘 나오면 끝.



그런데 다른 No serializer found for class 에러에서는 명확히 어느 domain에서 발생하는지가 나와있는데 나의 경우 BufferedInputStream에서 발생함.


해결

  1. .properties 파일에 spring.jackson.serialization.fail-on-empty-beans=false 옵션을 준다.

  2. 해당 엔티티 마다 @JsonIgnoreProperties({"hibernateLazyInitializer"}) 어노테이션을 달아준다. (지연로딩 관련해서 나오는 현상.)

  3. @JsonIgnore을 해당 속성들에다 달아준다.
    @ManyToOne의 Fetch 타입을 Lazy로 사용했을 때 나타나는 문제점 -> 비어있는 객체를 Serialize 하려다 에러가 발생.


3가지 방법 이후 다른 방법들도 해봤는데 같은 에러 발생.
서버단에서 값을 넘겨줄 때 Entity 데이터를 바로 넘겨주지 않고 DTO 객체로 변환하여 값만 전달하여 save한 Entity 객체를 DTO 객체로 변경하여 JSON으로 만드는 방법과 @Getter 넣어주는 방법.



이미지 없음

등록했을 때 한 번에 이미지가 출력되지 않음!!!!

Multipart 구현이 끝이 난 후에 다시 업로드를 했더니 이미지가 나오지 않았다. f12로 콘솔창을 꺼내서 http://localhost:8080/img/'파일명'을 url에 넣어 Update classes and resources 하고 새로고침 했더니 이미지가 생겼다.

콘솔 창에 보면 fail to load resources the server responded with a status of 404 () 가 발생.

이렇게 이미지가 한 번에 나오지 않는 경우가 발생했기 때문에 이 부분을 마지막으로 수정해본다. (밑에 방식대로 서비스 배포를 해서 해결.)



🔖 서비스 배포

maven의 경우

1) 인텔리제이 오른쪽에 Maven -> clean, install

2) install된 위치 보기. 이 프로젝트의 경우 C:\Users\milvus\Desktop\template_kotlin\target\templateKotlin-0.0.1-SNAPSHOT.war

pom.xml에 보면
```
<groupId>com.omnilab</groupId>
<artifactId>templateKotlin</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>
```

3) 사용자들이 사용할 때는 자바를 설치하고 인텔리제이를 설치해서 웹을 보지 않기 때문에 배포를 하는 것이다.
tomcat 폴더 -> webapps -> ROOT 에다가 다운 받은 war 파일 압축 풀기해서 넣음.

4) cmd 에서 실행시킴. C:\tomcat\bin>startup.bat 했는데 Neither the JAVA_HOME nor the JRE_HOME environment variable is defined 발생
      이럴 경우 5번 실행

 5) C:\tomcat\bin 에다가 set JAVA_HOME=C:\Program Files\Java\jdk-17 현재 사용하고 있는 자바 넣어줌.
     파일 이름은 setenv.bat

 6) 다시 C:\tomcat\bin>startup.bat 실행 시키면 이제 배포가 됨.
  1. 수정할 때는 배포해놓은 폴더에서 코드 수정할 수 있음.
  2. 추가로 그래도 이미지가 출력되지 않으면 확실히 tomcat 폴더 -> ... -> classes -> static -> img 폴더에 이미지가 저장이 되는지 확인.
  3. 저장이 확실히 되지 않고 있다면 yml 파일에서 경로를 다시 확인. 나의 경우 C:/tomcat/webapps/ROOT/WEB-INF/classes/static/img/ 인데 마지막에 '/'를 쓰지 않아서 이미지가 출력되지 않았음.
  4. 혹시 startup.bat했는데 실행이 안된다면 Intellij에서 Run이 꺼져있는지 확인.


gradle의 경우도 크게 다르지 않음.






🔖 페이징

PageRequest.of() 메소드를 사용하여 페이지 번호와 페이지 크기를 전달하며 PageRequest 객체를 생성할 수 있다.


Service

public Page<Item> getBoardList(Pageable pageable) {
    return itemRepository.findAll(pageable);
}

Controller

@GetMapping("/items")
public String items(@PageableDefault(page=0, size=8, sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable,
					@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Long memberId, Model model) throws IOException {
		int page = (pageable.getPageNumber() == 0) ? 0 : (pageable.getPageNumber()-1); // 현재 0이면 0 아니면 -1 처리 해서 1부터 시작


		Pageable pageRequest = PageRequest.of(0, 10); // 페이지 번호: 0부터 시작, 페이지당 데이터 수: 10, 정렬 방향도 가능
// 정렬 방향은 Sort.by("name").descending() 이런 식으로


		Page<Item> boardList = itemService.getBoardList(pageable);


        int nowPage = boardList.getPageable().getPageNumber() + 1; // 헌재 페이지
        int startPage = Math.max(1, (nowPage - 1) / 10 * 10 + 1); // 블럭에서 보여줄 시작 페이지
        // nowPage가 8이어도 startPage = 1, nowPage가 12면 startPage = 11

        int endPage = Math.min(boardList.getTotalPages(), startPage + 9);


        log.info("nowPage = {}", nowPage); // 처음 시작 때 현재 페이지 1
        log.info("startPage = {}", startPage);
        log.info("endPage = {}", endPage);


        model.addAttribute("boardList", boardList);
        model.addAttribute("nowPage", nowPage);
        model.addAttribute("startPage", startPage);
        model.addAttribute("endPage", endPage);

		return "";
}


<div class="btn-group me-2" role="group">
        <ul class="pagination">
            <!-- 이전과 처음으로 -->
            <li class="page-item">
                <a class="page-link" href="/items/new?page=${startPage}" aria-label="Previous">
                    <span aria-hidden="true"> << </span> <!-- 줄바꿈 X -->
                </a>
            </li>

            <li class="page-item">
                <a class="page-link" href="/items/new?page=${nowPage - 1}" aria-label="Previous">
                    <span aria-hidden="true"> < </span>
                </a>
            </li>


            <c:forEach var="page" begin="${startPage}" end="${endPage}">
                <li class="page-item">
                    <c:choose>
                        <c:when test="${boardList.pageable.pageNumber+1 != page}">
                            <a type="button" class="btn btn-outline-secondary"
                               href="/items/new?page=${page}"/>${page}</a>
                        </c:when>

                        <c:otherwise>
                            <strong type="button" class="btn btn-outline-secondary"
                                    style="color:red">${page}</strong>
                        </c:otherwise>

                    </c:choose>
                </li>
            </c:forEach>


            <!-- 다음과 마지막으로 -->
            <li class="page-item">
                <a class="page-link" href="/items/new?page=${nowPage + 1}" aria-label="Next">
                    <span aria-hidden="true"> > </span>
                </a>
            </li>

            <li class="page-item">
                <a class="page-link" href="/items/new?page=${endPage}" aria-label="Next">
                    <span aria-hidden="true"> >> </span> <!-- 줄바꿈 X -->
                </a>
            </li>

        </ul>
    </div>







🔖 시큐리티 로그인 로그아웃

loadUserByUsername

스프링 시큐리티에서는 username이라는 단어 자체가 회원을 구별할 수 있는 식별 데이터를 의미.
스프링 시큐리티는 UserDetailsService를 이용해서 username이라는 회원 아이디와 같은 식별 값으로 회원 정보를 가져오고, 이후에 password가 틀리면 'Bad Cridential(잘못된 자격증명)'이라는 결과를 냄.

@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> member = memberRepository.findByLoginId(username);


        if (member == null) {
            throw new UsernameNotFoundException(username);
        }

        else{
            return new User(member.get().getUsername(),member.get().getPassword(), new ArrayList<>());
        }
    }


AuthProvider

AuthenticationProvider 인터페이스는 화면에서 입력한 로그인 정보와 DB에서 가져온 사용자의 정보를 비교해주는 인터페이스.
loadUserByUsername 없이 회원 정보를 가져올 수 있음.


// 아이디 존재 여부 및 패스워드 일치 여부 Check를 진행할 AuthProvider, 직접 DB의 user 정보를 가져오도록 구현
@Component
public class AuthProvider implements AuthenticationProvider {

    @Autowired
    private MemberService memberService;
    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public Authentication authenticate(Authentication auth) throws AuthenticationException {
        logger.error("start");

        String loginId = (String) auth.getPrincipal();
        String password = (String) auth.getCredentials();


        PasswordEncoder passwordEncoder = memberService.passwordEncoder();
        UsernamePasswordAuthenticationToken token;


        // 유저 정보조회
        Optional<Member> user = memberService.findUserByLoginId(loginId);



        logger.error(String.valueOf(auth));
        logger.error(user.get().getLoginId());
        logger.error(user.get().getPassword());
        logger.error(loginId);



        if (user != null && passwordEncoder.matches(password, user.get().getPassword())) { // 일치하는 user 정보가 있는지 확인

            List<GrantedAuthority> roles = new ArrayList<>();
            roles.add(new SimpleGrantedAuthority("USER"));

            token = new UsernamePasswordAuthenticationToken(user.get().getLoginId(), user.get().getPassword(), new ArrayList<>());
            // 인증된 user 정보를 담아 SecurityContextHolder에 저장되는 token
            logger.error("end");
            return token;
        }


        throw new BadCredentialsException("아이디 비번 안 맞음");
    }


    // supports 메소드를 통해서 token 타입에 따라서 언제 provider를 사용할지 조건을 지정 가능
    @Override
    public boolean supports(Class<?> authentication) {
        return true;
    }
}

코틀린으로 한 경우는 내 프로젝트에서 찾기.

logout 코드






🔖 The given id must not be null

클래스에 SessionConst로 해서 @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)Long memberId 했는데 세션에 대한 로직이 없음.

데이터베이스에 session에 있는 member id를 찾지 못해서 계속 발생했었음.

LoginSuccessHandler, CustomAuthenticationProvider

데이터베이스에 기본키인 id값이 null로 들어가는 것이 아닌지 확인이 필요!!!!
또한 기본키인 id값이 1씩 증가가 되는지도 확인.






🔖 Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-XSRF-TOKEN'

csrf 공격에 대한 보안을 해줄려면 csrf 토큰을 만들어 줘야 한다.

html에서 form 태그 안에 넣어줌.

<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />

multipart의 경우는 조금 다름
경로 안에 csrf에 대한 것들을 넣어줌.

<form action="/items/new/book?${_csrf.parameterName}=${_csrf.token}" method="post" enctype="multipart/form-data">



csrf 공격이란



자바스크립트를 넣을 때 에러가 발생할 경우

<script type="text/javascript">
    // csrf 토큰
    let token = $("meta[name='_csrf']").attr("content");
    let header = $("meta[name='_csrf_header']").attr("content");
    $(function () {
        $(document).ajaxSend(function (e, xhr) {
            xhr.setRequestHeader(header, token);
        });
    });
</script>





🔖 사용자, 관리자 권한 부여

.authorizeRequests { authorize: ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry ->
            authorize
                .antMatchers(
                INDEXPAGE,
                "/",
                "/index",
                "/logout",
                "/error.mi",
                "/cimg/**",
                "/cimgd/**",
                "/test/**",
                "/members/new"
                ).permitAll()  // "members/new" 는 회원 가입.

			// Spring Security에서 prefix를 자동으로 "ROLE_"을 넣어주므로 이 때 hasRole에는 ROLE을 제외하고 뒷 부분인 ADMIN만 써주면 된다
                .antMatchers("/members").hasRole("ADMIN") // USER, ADMIN 접근 가능
                .antMatchers("/items/new").hasRole("ADMIN") // ADMIN만 접근 가능
                .anyRequest().authenticated()
        }


나의 경우 BaseEntity도 상속해야 해서 다중 상속함.
그리고 UserDetails에 대한

public class Member extends BaseEntity implements UserDetails{
	@Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String loginId;
    private String password;

    private String username;

    private String role; // role

    @Embedded
    private Address address;

    // Entity에 있는 Enum이 데이터베이스에 그대로 저장되려면 @Enumerated(EnumType.STRING) 어노테이션을 사용하면 DB에 Enum 값이 그대로 String으로 저장됨.
    //@Enumerated(EnumType.STRING)



    @JsonIgnore  //@ManyToOne의 Fetch 타입을 Lazy로 사용했을 때 나타나는 문제점 -> 비어있는 객체를 Serialize 하려다 에러가 발생
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Item> items = new ArrayList<>();


    // 회원 가입 때 멤버 만들기
    public static Member createMember(String loginId, String password, String name, String role, Address address){
        Member member = new Member();
        member.loginId = loginId;
        member.password = password;
        member.username = name;
        member.role = role;
        member.address = address;

        return member;
    }

    // 사용자의 권한을 콜렉션 형태로 반환
    // 단, 클래스 자료형은 GrantedAuthority를 구현해야함
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Set<GrantedAuthority> roles = new HashSet<>();

        for (String role : role.split(",")) {
            roles.add(new SimpleGrantedAuthority(role));
        }

        return roles;
    }


    @Builder
    public Member(String loginId, String password) {
        this.loginId = loginId;
        this.password = password;
    }

    public void changeName(String name) {
        this.username = name;
    }
//    public boolean checkPassword(String password){
//        return this.password.equals(password);
//    }



    public void update(String loginId, String password, String username, String city, String street, String detail){
        this.loginId = loginId;
        this.password = password;
        this.username = username;
        this.address.update(city,street,detail);
    }


    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getLoginId() {
        return loginId;
    }

    public void setLoginId(String loginId) {
        this.loginId = loginId;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getUsername() {
        return username;
    }


    // 권한
    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }

    public List<Order> getOrders() {
        return orders;
    }

    public void setOrders(List<Order> orders) {
        this.orders = orders;
    }

    public List<Item> getItems() {
        return items;
    }

    public void setItems(List<Item> items) {
        this.items = items;
    }
}

getAuthorities()에서 사용자의 권한을 콜렉션 형태로 반환해야하고, 콜렉션의 자료형은 무조건적으로 GrantedAuthority를 구현해야 함. 권한이 중복되면 안 되기 때문에 Set<GrantedAuthority 을 사용.


MemberRepository에서 findByUsername 또는 findByEmail 등으로 회원을 조회해야 함.
다음에는 MemberService에서 loadUserByUsername을 해야 하는데 나는 Provider로 설정해서 loadUserByUsername 사용 안 함.

loadUserByUsername을 사용한다면 이런 식으로 사용

@RequiredArgsConstructor
@Service
public class UserService implements UserDetailsService {

  private final UserRepository userRepository;

  /**
   * Spring Security 필수 메소드 구현
   *
   * @param email 이메일
   * @return UserDetails
   * @throws UsernameNotFoundException 유저가 없을 때 예외 발생
   */
  @Override // 기본적인 반환 타입은 UserDetails, UserDetails를 상속받은 UserInfo로 반환 타입 지정 (자동으로 다운 캐스팅됨)
  public UserInfo loadUserByUsername(String email) throws UsernameNotFoundException { // 시큐리티에서 지정한 서비스이기 때문에 이 메소드를 필수로 구현
    return userRepository.findByEmail(email)
        .orElseThrow(() -> new UsernameNotFoundException((email)));
  }
}


나의 createMemberForm에다가 추가해줌

<!-- 권한 넣어주기 -->
<input type="radio" name="role" value="ROLE_ADMIN,ROLE_USER"> 관리자
<input type="radio" name="role" value="ROLE_USER" checked="checked"> 사용자


Spring Security에 대해

https://shinsunyoung.tistory.com/78

로그인 기능에 대해
https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/
https://shinsunyoung.tistory.com/78






🔖 html em, px, rem 차이

px은 고정된 절대값의 단위.
em, rem은 환경에 따라 변하는 단위.

em은 HTML 요소(element)의 폰트 크기(font-size) 속성 값에 비례해서 결정되는 상대 단위

rem은 HTML 최상위(root) 요소(element)의 폰트 크기(font-size) 속성 값에 비례해서 결정되는 상대 단위

rem은 기준이 되는 폰트 크기 하나로 고정되어 있는 반면, em은 같은 엘리먼트는 어디서라도 그 기준이 바뀔 수 있기 때문에 복잡한 css를 가질 경우 변환될 크기를 예측하기 어렵다는 단점이 있음.

em은 보통 height에 사용하지 않고 글자 크기에 사용됨.






🔖 created_date, last_modified

BaseEntity에서 @CreatedDate와 @LastModifiedDate를 추가해 줬는데도 데이터베이스에 (null) 값이 INSERT되었다.

@EntityListeners(AuditingEntityListener.class) // 엔티티를 DB에 적용하기 전, 이후에 커스텀 콜백을 요청할 수 있는 어노테이션 / Auditing 을 수행할 때
@MappedSuperclass // 엔티티의 공통 매핑 정보가 필요할 때 주로 사용한다.
@Getter
public class BaseEntity {


    @CreatedDate
    @Column(updatable = false) // 생성일자(createdDate)에 대한 정보는 생성시에만 할당 가능, 갱신 불가
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModified;

}

해결 -> Main이 있는 application에다가 @EnableJpaAuditing를 추가해줬다.

지금 프로젝트에서는 Kotlin이어서 여기에 추가해줌.

@EnableWebSecurity
@EnableJpaAuditing // created_date, last_modified
@SpringBootApplication(exclude = [DataSourceTransactionManagerAutoConfiguration::class, DataSourceAutoConfiguration::class])
@ComponentScan(basePackages = ["com.omnilab.templatekotlin.*"])
class TemplateKotlinApplication

fun main(args: Array<String>) {
	runApplication<TemplateKotlinApplication>(*args)
}





🔖 enum에서 데이터 가져오기 (검색)

<div class="form-floating mb-3 col-sm-5">
<input type="text" name="itemName" class="form-control" placeholder="상품명"/>
        <label>상품명</label>
</div>

<div class="form-floating col-sm-5">
    <select name="itemType" class="form-select" id="floatingSelect">
    	<option value=""> ALL </option>

             <!-- var는 변수명, items는 반복할 객체명 enum인 itemType을 forEach시키는 방법-->
         <c:forEach var="type" items="<%= ItemType.values()%>">
         <%-- 해당 옵션이 선택될 때 서버로 제출되는 값을 명시 --%>
               <option value="${type}">${type}</option>
         </c:forEach>

     </select>
     
     <label for="floatingSelect">상품 타입</label>
</div>


<button type="submit" class="btn btn-secondary mb-3 col-sm-2">검색</button>

<%= ItemType.values() => 로 enum인 ItemType에서 값들을 가져올 수 있음.






🔖 sec:authorize, sec:authetication

sec:authetication

인증된 사용자의 정보를 출력

sec:authentication
<p>principal : <sec:authentication property="principal"/> </p>
<p>MemberVO : <sec:authentication property="principal.member"/></p>
<p>사용자 이름 : <sec:authentication property="principal.member.username"/></p>
<p>사용자 아이디: <sec:authentication property="principal.member.userid"/></p>
<p>사용자 권한 :<sec:authentication property="principal.member.authList"/></p>

<%--일반 사용자로 들어갔을 때 보여지는 내용과 관리자로 들어갔을 때 보여지는 내용이 다르게 나오도록 한다.
--%>

아직 모르겠음. 나의 경우 principal하면 사용자 아이디가 호출됨.



sec:authorize

권한이 있는 자에게는 드러내고 없는 자에게는 숨기는 기능.

sec:authorize = "isAuthenticated()" << 권한이 있는 사용자만 보이는 블럭

sec:authorize= "isAnonymouse()" << 비로그인 사용자만 보이는 블럭

sec:authorize= "hasRole("USER")" << 권한이 USER인 사람만 보이는 블럭

sec:authorize= "hasRole("ADMIN")" << 권한이 ADMIN인 사람만 보이는 블럭






🔖 No class com.omnilab.templatekotlin.domain.item.Item entity with id 186 exists!

Item Entity에서 id가 존재하지 않아서 발생한 것인데 아이템 삭제 시에 지정한 itemId가 아닌 다른 itemId를 삭제해서 발생한 것이다. 나중에 테이블 관계를 다시 설정해 줌으로써 해결했다.






🔖 Ajax 재고 부족 관련

JSON

Javascript에서 객체를 만들 때 사용하는 표현식을 의미.
태그로 표현하기 보다는 중괄호({}) 같은 형식으로 하고, 값을 ','로 나열하기에 그 표현이 간단하다.

JSON 문법

{"memberId" : memberId, "itemId" : itemId, "count" : count}
var jsonText = '{ "name": "Someone else", "lastName": "Kim" }';  // JSON 형식의 문자열

.stringify(), .parse()

JSON.parse( JSON 형식의 문자열 ) : JSON 형식의 텍스트를 자바스크립트 객체로 변환한다.
JSON.stringify( JSON 형식의 문자열로 변환할 값 ) : 자바스크립트 객체를 JSON 텍스트로 변환한다.

df

ajax에서 if 문으로 다시 주문 수량을 아무것도 입력하지 않았을 때의 경우를 해줘야 완벽하다.






🔖 재고 삭제

Cannot delete or update a parent row: a foreign key constraint fails 에러 발생

  1. 해당 테이블 또는 행을 참조하는 데이터를 삭제후 삭제를 한다.

  2. 자식 객체 삭제 후 부모 객체 삭제
    자식 객체가 있는 테이블의 데이터를 먼저 삭제하고 그 후에 부모 모델 객체가 있는 테이블의 데이터를 삭제하면 에러 해결.


해결 -> 밑에 orphanRemoval과 cascade 잘 사용해서 테이블 간의 관계를 제대로 설정하면 됨.



orphanRemoval = true
고아 객체 제거-> 부모 엔티티와 연과이 끊어진 자식 엔티티를 자동으로 삭제
@OneToMany가 있는 곳에만 사용 가능. 나는 이걸로 해결했음

부모를 삭제하면 자동으로 자식들도 삭제를 하도록 함.


@ManyToOne, @OneToMany에 cascade = CascadeType.ALL**
영속성 전이로 부모를 저장할 때 자식도 같이 저장하고 싶을 때 사용.

ALL : 상위 엔터티에서 하위 엔터티로 모든 작업을 전파할 수 있음.

특정 엔티티를 영속 상태로 만들 경우, 연관된 엔티티도 함께 영속 상태로 만들고 싶을 경우 영속성 전이를 사용.

cascade = CascadeType.REMOVE
ex)잘못하면 회원 가입해서 데이터베이스에 저장되어 있던 것 까지 모두 삭제되니 조심해서 사용.






🔖 엑셀 다운

현재 리스트에 있는 모든 상품들이나 회원들을 엑셀에 저장하여 다운받는 것은 이대로 하면 된다.
종류 별로 또는 type 별로 나눠서 다운도 가능하다. where절을 수정하든 쿼리를 따로 만들어서 적용하면 된다.

나의 경우 enum인 ItemType이 null이면 findAll()로 전부 찾고 아니면 findByDtype()을 만들어서 type 별로 나눠서 select 했다.


itemService

public void getExcelDown(HttpServletResponse response, ItemType itemType) {
    List<Item> itemList;

    if (itemType == null) {
        itemList = itemRepository.findAll();
    } else {
    	itemList = itemRepository.findByDtype(String.valueOf(itemType));
    }

	try{
        Workbook workbook = new XSSFWorkbook();
        Sheet sheet = workbook.createSheet("상품 목록");

            //숫자 포맷은 아래 numberCellStyle을 적용
        CellStyle numberCellStyle = workbook.createCellStyle();
        numberCellStyle.setDataFormat(HSSFDataFormat.getBuiltinFormat("#,##0"));

        //파일명
        final String fileName = "상품 목록";

        //헤더
        final String[] header = {"NO", "상품 이름", "상품 종류", "상품 가격", "재고"};
        Row row = sheet.createRow(0);
        for (int i = 0; i < header.length; i++) {
            Cell cell = row.createCell(i);
            cell.setCellValue(header[i]);
        }

            //바디
        for (int i = 0; i < itemList.size(); i++) {
            row = sheet.createRow(i + 1);  //헤더 이후로 데이터가 출력되어야하니 +1

            Item item = itemList.get(i);
            createRow(numberCellStyle, row, item);
        }



        response.setContentType("application/vnd.ms-excel");
        response.setHeader("Content-Disposition", "attachment;filename="+ URLEncoder.encode(fileName, "UTF-8")+".xlsx");
            //파일명은 URLEncoder로 감싸주는게 좋다!

        workbook.write(response.getOutputStream());
        workbook.close();

    }catch(IOException e){
        e.printStackTrace();
    }

}



private void createRow(CellStyle numberCellStyle, Row row, Item item) {
    Cell cell = null;

    cell = row.createCell(0);
    cell.setCellValue(item.getId());

    cell = row.createCell(1);
    cell.setCellValue(item.getName());

    cell = row.createCell(2);
    cell.setCellValue(item.getDtype());

    cell = row.createCell(3);
    cell.setCellValue(item.getPrice());

    cell = row.createCell(4);
    cell.setCellStyle(numberCellStyle);      //숫자포맷 적용
    cell.setCellValue(item.getStockQuantity());
}


itemController
엑셀 다운도 POST로.

// 엑셀 다운 컨트롤러 (전부, 검색 별로)
@PostMapping("/excel/down")
public void excelDown(HttpServletResponse response, @RequestParam(required = false) ItemType itemType) {
		// param인 itemType이 null일 수 있기 때문에 required = false로 지정.
    logger.error("{}", itemType);

	itemService.getExcelDown(response, itemType);
}


하지만 검색별로 따로 하고 싶다면 itemService에서 findAll() 쿼리로 하지 않고 itemRepository에다가 쿼리를 직접 만들어서 where 조건 넣어주면 된다. 나의 경우 JPQL 방식으로 해주었다.

public interface ItemRepository extends JpaRepository<Item, Long>, ItemRepositoryJpql {

    @Query("SELECT i FROM Item i where i.dtype = :dtype")
    List<Item> findItemByDtypeIs(@Param("dtype") String dtype);
}

JPA를 사용한다면 JPQL 사용할 필요없음.

List<Item> findByDtype(@Param("dtype") String dtype);





🔖 주문리스트

object references an unsaved transient instance - save the transient instance before flushing

@OneToMany나 @ManyToMany인 상황에서 흔히 만나는 에러이다. 부모 객체에서 자식 객체를 바인딩하여 한번에 저장하려는데 자식 객체가 아직 데이터 베이스에 저장되지 않았기 때문에 발생.
자식도 똑같이 cascade = CascadeType.ALL 해주는 것이 좋음.






🔖 아이디 중복 ajax

commence 0:0:0:0:0:0:0:1, /~~ | Full authentication is required to access this resource

이 리소스에 접근하기 위해서는 전체 인증이 필요하다는 것이다. 그래서 Security.Config에서 .permitAll()한 부분에서 지정한 리소스를 추가하면 된다.

코드를 입력하세요





🔖 ajax Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported 해결

나의 경우 form 태그 안에 form으로 한번 더 데이터를 날려서 발생했다.
그래서 form 태그를 따로 넣어주었다.

기존에는 하나의 form:form 안에 모두 넣었었다.

<form:form name="frm" action="/members/new" method="post">

  <div class="form-floating mb-3">
    <input type="text" name="loginId" id="loginId" class="form-control">
    <label>로그인 ID</label>
  </div>
  
</form:form>

<div class="form-floating mb-3">
  <button class="btn btn-outline-secondary" formaction="/duplicate" onclick="idCheck()"> 아이디 중복 확인 </button>
</div>

<form:form name="frm" action="/members/new" method="post">

<%-- 생략 --%>          
</form:form>

그런데 이렇게 하니 회원가입할 때 loginId가 데이터베이스에 저장되지 않고 null이 되었다.

해결 -> 하나의 form 안에 넣고 button의 type을 button으로 주면 된다. (button의 default는 submit이기 때문에)






🔖 @PathVariable

Cannot resolve path variable 'reviewId' in request mapping

reviewId 값을 받아오지 않아서 발생하는 에러. ajax를 통해서든 JPA로 find~() 를 사용하든 값을 받아와야 한다.






🔖 Cannot resolve property '컬럼명'

데이터베이스 '컬럼명'에 값이 들어와야 하는데 값이 들어오지 않아서 그런 것이다. 외래키로 설정된 저 '컬럼명'이 제대로 값이 저장되도록 수정해준다.






🔖 ajax @RequestBody






🔖 html display -> inline, block

  1. display:block
    화면의 가로 너비 기준으로 전체의 한 영역을 차지함.
    width, heigth로 사이즈 조절 가능.
    지정하지 않으면 width는 가로 너비 100% height는 컨텐츠 만큼의 크기를 가짐.

  2. display:inline
    컨텐츠 만큼의 크기를 가지며 줄바꿈 되지 않음. width, height 지정 불가. (컨텐츠는 내가 입력한 텍스트 만큼)

  3. display:inline-block
    block, inline 특징 모두 섞음.
    컨텐츠 만큼의 크기를 가지며 줄바꿈 불가. 사이즈 조절 가능.






🔖 object references an unsaved transient instance - save the transient instance before flushing 에러

FK 로 사용되는 컬럼값이 없는 상태에서 데이터를 넣으려다 발생한 에러

내 프로젝트에서 댓글, 대댓글 기능 추가하고나서 회원가입 기능이 망가지길래 다시 수정했더니 댓글 등록 시 에러 발생.

cascade 잘 사용!!!! + 테이블 관계 잘 설정!!!!

코드를 입력하세요





🔖 query did not return a unique result

Repository에서 find를 했을 때 나오는 값이 여러 개인데 그걸 받아주는 class가 하나일 때 나타나는 에러.
.orElseThrow() 생성






🔖 tiles -> jsp






🔖 Cannot call sendRedirect() after the response has been committed

sendRedirect 메서드가 호출된 이후에 재호출하려고 하면 발생할 수 있다. (호출된 이후에는 다시 호출할 수 없다는 뜻.)

상태를 if - else 나 if - else if - else 로 잘 묶어서 조건을 주도록하여 redirect 전략이 한 개가 될 수 있게 해야한다.






🔖 Required String parameter '인자' is not present

@RequestParam이나 기타 Parameter 값을 받아올 때 인자값이 Null 이거나 Type이 맞지 않을 경우 발생하는 오류이다.

Controller에서 @RequestParam인 파라미터를 required=false로 받으면 된다.

나의 경우는 ajax를 사용해서 map를 return할 때 id값이 제대로 전달하지 않아서 발생했다.






🔖 Optional int parameter '' is present but cannot be translated into a null value due to being declared as a primitive type.Consider declaring it as object wrapper for the corresponding primitive type.

파라미터로 속성은 넘어오지만 값이 없어서 null 처리를 하려고 함
그러나 int이기 때문에 null 변환이 되지 않을 때

해결
int를 null로 선언할 수 없으니 Long 등 다른 자료형을 사용
int 중 null이 들어갈 수 있는 곳을 찾아서 수정






🔖 org.apache.catalina.LifecycleException, java.lang.ClassNotFoundException






🔖 No candidates found for method call

Gradle > 좌측 상단에 Reload All Gradle Projects 버튼을 클릭

Maven > 아마 프로젝트 우클릭, Maven 들어가서 Reload project






🔖 Field '칼럼' doesn't have a default value 에러

이제 기능 구현 끝났나 싶더니 또 다시 에러 발생.
갑자기 회원가입을 하는데 이 에러가 발생.

원인 : DataBase에 id가 AUTO_INCREMENT 되지 않아서 발생하는 문제 즉 회원가입을 할 때 id값이 1씩 증가되지 않아서 발생한 것.

해결 : 데이터베이스에서 id가 AUTO_INCREMENT가 잘 되도록 해놓거나 @GeneratedValue를 제대로 id에 등록.
그래도 안 되면 jpa 매핑이 제대로 됐는지 확인.






🔖 nested exception is java.lang.IllegalStateException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing

@OneToMany와 @ManyToOne 어노테이션을 사용하면 자주 볼 수 있는 에러라고 한다.

JPA를 사용하여 초기 데이터를 만들다가 부모 객체에서 자식 객체를 바인딩하여 한 번에 저장하려다가 자식 객체가 아직 데이터베이스에 저장되지않아 발생함.

결론적으로는 테이블 관계를 제대로 매핑하면 해결됨.
cascade 잘 사용






🔖 TransientPropertyValueException: object references an unsaved transient instance

나의 경우 @ManyToOne과 @OneToMany 가 있는 곳에 CascadeType.ALL을 해주었는데도 이 에러가 발생했다.
(원래는 cascade = CascadeType.ALL로 에러 해결이 가능.)

내 프로젝트에는 Entity로 Member, Review, Comment가 있었는데 엔티티를 제대로 생성해 주지 않아서 발생했다.

ex) Comment에 @ManyToOne으로 member를 넣었는데 Member에는 넣어주지 않음. Member에도 @OneToMany가 필요함.

@OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();





🔖 리뷰와 댓글이 있는 상품 재고 삭제 + 리뷰 댓글 추가

Cannot delete or update a parent row: a foreign key constraint fails 에러 또 발생
마지막으로 리뷰와 댓글을 작성하는 기능까지 추가를 해줬었
다. 리뷰와 댓글이 작성된 상태에서 상품 관리에서 재고 삭제를 했는데 다시 이 에러 발생. (끝나지 않는 싸움... 그만하자 에러 많이 먹었다...)

detached entity passed to persist
수정했더니 이 에러는 덤으로 발생... 1+1
리뷰가 한 번만 추가 가능하고 그 뒤로는 추가 불가능.

해결 : CascadeType.ALL을 사용할때 부모객체가 생성될때 자식객체가 생성되도록 사용해야하고 자식객체가 부모객체를 같이 생성하게 사용하면 안되는 듯 싶어 @ManyToOne 쪽에는 ALL을 없애봤는데 해결은 무슨....

영속성 cacade, orphanRemoval 둘 다 해줬더니 된 것 같다.

@OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Review> review = new ArrayList<>();

@OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();





🔖 button 태그에 button 타입

button에 type="button" 을 지정해주는 이유?
아무런 값도 지정하지 않았다면 기본값은 submit이 되기 때문에 button을 쓰고 싶다면 type을 지정해줘야 한다.






🔖 CKEditor

마지막으로 배포까지 해서 multipart도 한 번에 이미지가 출력되도록 설정했다.
찐막으로 CKEditor로 댓글을 꾸며본다.


CKEditor 4로 사용

CKEditor4로 적용을 해봤을 때 CKEditor가 적용되지 않은 textarea가 나왔고 더 구글링 해보고 수정했는데도 달라지지 않아서 CKEditor5(최신)로 먼저 적용을 해보기로 했다.


CKEditor 5로 사용

출력할 때 글자 굵기, 표 같은 기능들은 내가 직접 모두 css를 적용시켜야 출력이 되는 것 같아 잠시 보류한다.

해결
-> 데이터를 출력할 때 textarea 태그로 출력을 해서 태그도 같이 문자로 출력이 된 것이다. textarea가 아니라 그냥 td 태그에 넣어서 출력하면 스타일이 적용돼서 출력이 된다.



이미지 업로드

글자 굵기, 번호, 표 등은 잘 입력이 되지만 이미지가 업로드 되지 않고 console에 filerepository-no-upload-adapter 에러가 발생한다.

그래서 Controller, JS 추가하여 이미지 업로드 되도록 수정.

Controller

코드를 입력하세요

Js

코드를 입력하세요

JSP

코드를 입력하세요



에러 발생

[ERROR][AuthenticationHandler] [] : handle /item/info | Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.

해결
-> header 태그

<meta name="_csrf" content="${_csrf.token}">
<meta name="_csrf_header" content="${_csrf.headerName}">

-> script 태그

let token = $("meta[name='_csrf']").attr("content");
let header = $("meta[name='_csrf_header']").attr("content");

$(function() {
    $(document).ajaxSend(function(e, xhr) {
        xhr.setRequestHeader(header,token);
    });
});

한 번에 csrf 토큰이 적용되도록 함.
일일이 data에 csrf 토큰을 넣어줄 수도 있음. (모든 data에 다 해야해서 번거로움.)


그냥 ckeditor를 사용하는 부분만 beforesend해서 token 생성해서 해결도 가능함.

이렇게 하면 CKEDITOR5 이미지 업로드까지 완성.

- https://offbyone.tistory.com/216


  1. 참고 (CKEditor5)
  1. 참고 (CKEditor4는 잘 안 됐음.)
    https://velog.io/@joon1106/CKeditor4-%EC%82%AC%EC%9A%A9%EC%82%AC%EC%A7%84-%EC%97%85%EB%A1%9C%EB%93%9C
profile
일상의 인연에 감사하라. 기적은 의외로 가까운 곳에 있을지도 모른다.

0개의 댓글