[Springboot] Project 연습

yml파일 이해하기

음료 공장 만드는 기술자.

  1. 두번째 공장
  2. 공장 입구는 서쪽
  3. 음료요청은 전부 한글 문서로 변경해서 받는다.
  4. 음료는 전부 콜라로 만들어서 출시된다.
  5. 음료 창고는 컨테이너 박스를 사용한다.
  6. 음료는 요청에 따라 페트,캔, 병으로 출시된다.
  7. 공장이 재 가동시 기존에 만들어진 음료는 버리지 않는다.
  8. 음료 요청시 A4용지 2장 이상의 문서는 받지 않는다.
  9. 음료 요청은 아무나 할 수 없다. 암호 아는 사람만 요청한다.

yml은 스프링부트로 공장을 만들건데 그 공장을 만들기 위한 문서이다.

뷰 리졸버 설정

20210706_020842

port-8080 = 입구 부분

20210706_022539

결국 yml은 스프링이라는 성을 어떻게 구성할지 정하는게 yml(properties도 마찬가지.)


컨트롤러란?(FrontController와 Dispatcher)

  1. 요청을 할 때마다 java 파일이 호출된다.
  2. 요청의 종류가 3개면 3개의 Java파일이 존재한다.
  3. 하나의 Java파일에서 모든 요청을 받는 FrontController 사용
  4. 너무 많은 요청이 한 곳으로 모이는 것을 위해 도메인 별로 분기
  5. 분기의 일은 Dispatcher가 해준다.

로그인 요청과 회원가입, 게시글 처리를 FrontController에서 하나의 컨트롤러에서 처리 가능. 근데 너무 많은 요청이 한 곳으로 모이게 된다.

도메인이란

이 세상에 남자 여자만 있다 이런 범주를 주는게 도메인이다.

로그인 , 회원가입은 UserController, 글쓰기 삭제, 구정 등 글 관련은 BoardController에서 처리하겠다. 상품 등록 ,목록, 보기 등은 ProductController에서 처리하겠다.

이런식으로 적당한 양이 쌓이게 된다.

20210706_024016

여기서 문제는 로그인이 올떄 이런 요청이 들어올 때 이 요청들을 어디에 보내야 될지 분기가 필요한데 그게 Dispatcher가 한다.(정확히는 ServletDispatcher, RequestDispatcher라고도 한다.)

이 Dispatcher로 분기를 시킨다.

스프링 프레임워크는 이미 만들어져 있고(디스패쳐가) 컨트롤러도 미리 만들어져서 편하게 사용가능.

우리가 만들어야 되는건 컨트롤러만 만들면 된다.

20210706_024400

컨트롤러를 잘 만들면 요청에 대한 처리가 다 가능하다.


HTTP 4가지 요청 방식

클라이언트가 웹 서버에 요청, 웹서버는 DB에 Select, Insert, Update, Delete 요청을 해서 응답

20210706_025641

get 요청해서 웹서버로 보내면 웹 서버는 select 해서 db로 요청한다( 웹서버가 데이터를 들고 있지는 않기 때문) 그럼 디비가 응답하고 웹서버가 클라이언트로 응답한다.

브라우저는 .html로 이해해서 브라우저는 이해하지만

핸드폰으로 응답되면 망함(핸드폰은 html을 이해 못함) .html이 파일이 아니라 문자열(데이터) 응답할 때 쿼리에 대한 결과를 레코드라 하고 웹서버(클라이언트)

이 4가지만 알면 거의 다 컨트롤 하고 만들 수 있다.

post,put은 꼭 기억해야되는게 http에 Body가 필요하다. Body엔 데이터를 담아야 하고 어떤 데이터를 전송해야되는데 그게 Body에 담김, put도 수정해야되서 그 수정정보가 body에 담

20210706_033521

이걸 이렇게 받게 된다.(바꿔서)

20210706_033838


Http 쿼리 스트링(QueryString), 주소 변수 매핑(path variable) (실습)

  1. 구체적인 데이터 요청 시에 쿼리스트링이나 주소변수 매핑이 필요하다.

  2. 스프링 부트에서는 주소변수 매핑을 주로 사용한다. 훨씬 편리하다.

어떤 구체적으로 전달하는 방식으로는

20210706_120245

  1. 쿼리스트링 방식과

20210706_120350

  1. PathVariable 방식이 있다.

이 중 좀 더 보기 좋고 간편한 게 PathVariable 방식이다.

20210706_123834

스프링 부트는 쿼리스트링 보다 PathVariable 방식을 더 많이 쓴다.


http body 데이터 전송하기

  • http header의 Content-Type이해

  • 스프링 부트는 기본적으로 x-www-form-urlencoded 타입을 파싱(분석)해준다.
  • x-www-form-urlencoded
  • plain/text
  • application/json

여기서 쌀을 바디 데이터, 그리고 문서를 헤더 데이터

쌀이라 적혀있으면 좀 그래서 헤더 분석해보자.

헤더의 문서에 여러개가 있다.

20210706_131521

package com.cos.controllerdemo.web;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.cos.controllerdemo.domain.User;

@RestController
public class HttpBodyController {



	private static final Logger log = LoggerFactory.getLogger(HttpBodyController.class);

	@PostMapping("/body1")
	public String xwwwformformuerlencoded(String username) {
		log.info(username);
		return "key=value 전송옴 ";
	}


	@PostMapping("/body2")
	public String plaintext(@RequestBody String data) 	{	//평문: 안녕
		log.info(data);
		return "plain/text 전송옴";
	}

	@PostMapping("/body3")
	public String applicationjson(@RequestBody String data) {
		log.info(data);
		return "json 전송옴";
	}

	@PostMapping("/body4") //오브젝트로 바로 받음.
	public String applicationtoObject(@RequestBody User user) {
		log.info(user.getUsername());
		return "json 전송옴";
	}
}



20210706_141244

20210706_141236


Http 요청을 json으로 응답하기

예제

package com.cos.controllerdemo.web;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HttpResponseJsonController {
	@GetMapping("/resp/json")
		public String respJson() {
			return "{\"username\":\"cos\"}";
	}
}


20210706_141723

20210706_142141

이런 경우도 있따. user오브젝트를 리턴했는데 문자열이 나옴.

여기서 원리는 user 오브젝트 리턴하고 싶은데 일일히 데이터를 뺴와서 데이터를 만들어서 리턴.

근데 아래 유저를 그대로 리턴. 리턴이 되면서 클라이언트 웹 브라우저로 통신이 됨(위의 일이 자동으로 일어남.)

그 과정을 MessageConverter가 자동으로 JavaObject를 Json으로 변경해서 통신을 통해 응답을 해준다.


HTML응답

http 요청을 file로 응답하기
  1. txt파일 응답하기(기본 경로는 resource/static)
  2. 스프링부트가 지원하는 .mustache파일 응답하기
  3. 스프링부트가 버린 .jsp파일 응답하기

.jsp와 .mustache파일은 템플릿 엔진을 가지고 있다.

템플릿 엔진이란 html 파일에 java코드를 쓸 수 있는 친구들이다.

20210706_152105

이렇게 쓰라고요청하는게 아님(위 기준)

jsp라 치면 client라 하고 요청 .jsp 준다.

브라우저가 자바 코드 이해 못할 텐데? 얘를 누구한테 던지냐면 웹 서버가 톰캣이라는 WAS에 던진다. 이 어플리케이션을(아파치) 쓰는데

20210706_152350

index.jsp에서 자바코드 해석해서 index.html파일로 만든다.

20210706_152444

이렇게 자바코드 해석해서 html 파일 만드는게 템플릿 엔진이라고 한다.

.jsp파일을 스프링부트가 버리긴 했지만 아직까진 대한민국에선 jsp 많이 쓴다.(mustache파일은 하루면 된다.)

package com.cos.controllerdemo.web;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller //파일을 리턴할 것이기 때문
public class HttpRespController {
	@GetMapping("/txt")
	public String txt() {
		return "a.txt";	//프레임워크 사용(틀이 이미 정해져 있음)
	}
}


localhost:8080/txt 주소창 쳐보면 파일 리턴도 된다.

  1. mustache파일도 리턴해보자.

20210706_153116

mvnRepository에서 다운이 필요하다.

20210706_153733

스프링에서 쓰는 mustache 검색 후 제일 최신버전 받아서 쓰자.

20210706_154749

이동이 아니라 다운로드를 받음? mustache를 스프링에서 인식 못해서

즉 웹 서버가 mustache를 응답해야되는데

20210706_154916

이 톰캣부분에서 응답을 안한 것이다.

@GetMapping("/mus")
	public String mus() { 	//2.mustache 파일 응답하기(기본 경로는 resource/static)

		return "b";	//머스테치 템플릿 엔진 라이브러리 등록 완료 - templates폴더 안에 .mustache놔두면 확장자 없이 자동으로 찾아감.
	}

20210706_155132

사람들이 많이 받은 9.0.41로 받고 사용하자

20210706_155221

20210706_155550

20210706_155807

여기서 jsp 설정하면 경로가 안 뜸(스프링 부트가 지원 안해서)

application.properties를 yml로 바꾸자.

20210706_160621

이 부분을 ViewResolver라고 한다.


JSP 파일에 JAVA 코드 사용해보기

  • Java 코드 사용
  • model 사용

20210706_174757

이렇게 html 페이지로 model을 통해 자바코드를 전달할 수 있다.

그 이유는 jsp가 템플릿 엔진이기 떄문이다.


Http 요청 재분배하기 - redirection

  • http 상태코드 300번대
  • 다른 주소로 요청을 재분배한다.

20210706_182321

20210706_183309

여기서 /away로 실행하면

20210706_183442

20210706_183455

이렇게 home으로 가게되고 away,home 2개가 생기는데 home 부분에 200이 뜬다.

이게 리다이렉션(/away로 줬는데 home으로 이동)

더 자세한건 http상태코드를 보자

https://developer.mozilla.org/ko/docs/Web/HTTP/Status


회원가입 - SecurityConfig 생성

20210706_194121

localhost:8080갔는데 login 부분으로 간다 왜?


<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>

이 pom.xml에 있는 security dependency 부분에 인증이 되지 않은 모든 사용자를 가로채서 redirection해서 주소요청을 변경해서 다른 주소로 보냄.

20210706_194728

status가 302면 redirection되서 login 페이지가 뜸.

우리가 만든 로그인으로 가고싶다.

package config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@EnableWebSecurity //해당 파일로 시큐리티를 활성화
@Configuration //IOC 등록
public class SecurityConfig extends WebSecurityConfigurerAdapter{
		@Override
		protected void configure(HttpSecurity http) throws Exception {

//			super.configure(http);	//얘가 실행되서 리다이렉션가로챈다 tkrwpgkwk
//			얘를 삭제하면 기존 시큐리티가 가진 기능이 다 비활성화 된다.

		}
}

에서 저 super.configure 부분에서 낚아채서 리다이렉션 됐던것.

저 부분을 지워야한다.

20210706_200125

이제 리다이렉션이 안된다.

”/”,”/user/”, “/image/”, “/subscribe/”,”/comment/

이런 구조로 지정하게 되면 인증이 필요하게 만든다.

20210706_205649

300번대가 뜨면 요청 재분배가 되었다고 생각하면 된다.


CSRF

인증 구현하기
  • 시큐리티 세팅
  • 회원가입 구현
  • 로그인 구현
  • 회원정보 수정 구현

insert 하기 위해선 post 요청

20210706_222033

//회원가입 버튼 -> /auth/signup->auth/signin
	//회원가입 버튼 클릭했는데 아무것도 안되었네?
	//form 로그인 할 때 저기 보면 csrf토큰이라는 게 활성화되어있기 떄문.
	@PostMapping("/auth/signup")
	public String signup() {	//실제 signup
		System.out.println("실행됨?");
		return "auth/signin";	//회원가입이 성공하면 로그인으로 가게 한다.
	}

CSRF 토큰

클라이언트가 서버에 전송하는데 서버가 받기전 시큐리티가 감싸고 있고 CSRF라는 토큰을 검사를 한다.

CSFR는

회원가입창으로 서버에 요청하는데 회원가입 페이지로 응답한다.

signup.jsp응답할 때 시큐리티가 토큰을 심는다. CSRF 토큰을 심고 돌려주는데 input 태그들에 임시의 난수값이 생김.

20210706_223149

20210706_223244

만약 kfc를 보낸다 치면 시큐리티가 이런 내용을 달아서 전달해준다.

이 상태에서 다시 요청을 하면 얘가 만든 csrf 토큰이 있는지를 확인한다.

post맨을 열어서

20210706_223349

이렇게 요청하거나(비정상적)

20210706_223509

회원가입 페이지를 요청하는 사람이 있을건데(정상적) 이걸 구분하기 위해 csrf를 사용

근데 이걸 쓰면 자바스크립트로 요청하는 것도 힘들고 비활성화 시켜서 시큐리티에서 안 쓸거

(참고)

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
//@Controller
public class ViewControllerTest {

	@GetMapping("/auth/signup")
	public String signupPage() {
		return "auth/signup";
	}

	@GetMapping("/auth/signin")
	public String signinPage() {
		return "auth/signin";
	}

	@GetMapping("/image/story")
	public String storyPage() {
		return "image/story";
	}

	@GetMapping("/image/popular")
	public String popularPage() {
		return "image/popular";
	}

	@GetMapping("/image/upload")

여기서 ViewControllerTest한다고 컨트롤러 했었었는데 여기를 주석처리 안하고 계속 post요청하니까 요청을 컨트롤러가 못 잡았다.

컨트롤러 주석처리하고

package com.cos.photogramstart.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@EnableWebSecurity //해당 파일로 시큐리티를 활성화

@Configuration //IOC 등록
public class SecurityConfig extends WebSecurityConfigurerAdapter{
		@Override
		protected void configure(HttpSecurity http) throws Exception {


			http.csrf().disable();	//시큐리티의 csrf를 사용하지 않음. 그럼 포스트맨에든 회원가입 창이든 어디서 해도 상관 안하겠다(403 안뜸)
//			super.configure(http);	//얘가 실행되서 리다이렉션가로챈다 삭제하자

//			얘를 삭제하면 기존 시큐리티가 가진 기능이 다 비활성화 된다.
			http.authorizeRequests()
			.antMatchers("/","/user/**", "/image/**", "/subscribe/**","/comment/**").authenticated()
			.anyRequest().permitAll()
			//"/","/user/**", "/image/**", "/subscribe/**","/comment/** 이런 구조로 지정하게 되면 인증이 필요하게 만든다.
			.and()
			.formLogin()	//form login 할텐데 auth의 sigin이 하면 이쪽으로 가게
			.loginPage("/auth/signin")
			.defaultSuccessUrl("/");	//로그인을 정상처리하면 /로 가라
		}
}


이부분 다시 컨트롤러 넣고 실행하면 정상 작동이 된다.


User모델 만들기

Singup하는데 요청 DTO와 응답 DTO만들 거 DTO는 통신을 위해 담는 데이터

@PostMapping("/auth/signup")
	public String signup(SignupDto signupDto) {	//key =value(x-www-form-urlencoded)방식이라고 했었따.

		log.info(signupDto.toString());	//문자열만 받을 수 있는 toString
		return "auth/signin";
	}

이렇게 바꾸고 info를 확인해보면

20210707_004042

잘 넘어온게 확인이 된다.

이제 이걸 디비에 인서트 해야된다. insert하기위해선 model이 필요하다. userModel을 만들어보자.

create user ‘root’@’%’ identified by ‘root’; GRANT ALL PRIVILEGES ON . TO ‘root’@’%’; create database photogram;

자바에서 오브젝트를 만들면 이 오브젝트를 기준으로 테이블이 만들어진다.

20210707_011729


회원가입 완료

  • User라는 오브젝트에 SignUpDto를 넣을 것.(SignupDto의 4개 값을 User에 집어넣을 거)
  • 담을때 DTO에서 함수를 하나 만들어서 전달.
public User toEntity() {
		return User.builder() //빌더패턴 사용
				.username(username)
				.password(password)
				.email(email)
				.name(name)
				.build();
	}

20210707_015607

받아온 거 잘 넘겼다.

여기서 User에 role은 좀 있다해도 된다.

이걸 DB에 집어넣을건데 집어넣을 때 service가 된다.

그리고 서비스를 만들고 서비스를 쓰기 위해선 Repository를 만들어야 한다.


import com.cos.photogramstart.domain.user.User;

@Service	//1. IOC 관리 2.트랜잭션 관리
public class AuthService {

	public void 회원가입(User user) {
		//회원가입 진행(Repository가 필요)


	}
}


package com.cos.photogramstart.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

//어노테이션이 없어도 IOC등록이 자동으로 된다.
public interface UserRepository extends JpaRepository<User, Integer>{
	//첫번쨰는 오브젝트를 적어주고 두번쨰는 프라이머리 키의 타입을 적어준다.

}

auth서비스를 요청해야되는데 이걸 DI에서 불러와야한다.

20210707_021130

20210707_021051

전역변수에 final걸려있으면 무조건 생성자 실행 될때 초기화를 무적권 해줘야하는데

@RequiredArgsConstructor를 만들면 final이 걸린 모든 객체에 대한 생성자를 만들어준다. 이건 final 필드를 di 할떄 사용한다.

save되면 s타입 리턴한다 s를 집어넣고 (내가넣은 타입) 그 타입으로 리턴받음.

20210707_023210

20210707_024725

실행시 이게 안 들어갔다고 에러가 났었는데

이유는 얘를 만들기 전에 디비에 테이블 만들고 얘를 만들어서 저장함. 그래서 테이블에 저장을 하려면 yml의

20210707_024810

이 부분을 update에서 create로 바꾸고 저장해야한다. 그리고 다시 데이터가 안 사라지게 update로 바꾸고 저장해야 한다.

20210707_025035

insert가 잘 되었다고 나오고 실제 디비에도 보면

20210707_025126

정상적으로 들어간 게 보인다.

여기서 문제는 패스워드가 암호화 되지 않아서 들어갔다고, 권한을 넣어줘야한다(role)


회원가입 - 비밀번호 해시화

@Transactional붙여주면 이 함수가 실행되고 종료될때까지 트랜잭션 관리를 해준다. Write 할떄 트랜잭션 붙여줄 것(insert, update,delete)

BCrypt를 사용해 암호화 할 거

private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Transactional
//붙여주면 이 함수가 실행되고 종료될때까지 트랜잭션 관리를 해준다.Write 할떄 트랜잭션 붙여줄 것(insert, update,delete)

public User  회원가입(User user) {	//타입도 User로 바꿔야
  //회원가입 진행(Repository가 필요)
  //여기서 받아서 인서트가 필요하고 그게 service 역할
  String rawPassword =user.getPassword();
  String encPassword = bCryptPasswordEncoder.encode(rawPassword);//암호화된 패스워드
  user.setPassword(encPassword);
  user.setRole("Role_User"); //관리자 Role_admin

이 부분 서비스에 추가하고 실행해보자

그리고 Securityconfig부분에

@Bean
  public BCryptPasswordEncoder encode() {
      return new BCryptPasswordEncoder();
  }

이 부분이 없어서 에러났었는데 이 부분도 넣어주자

20210707_123130

회원가입 하면 디비에 들어가고 비밀번호도 알아볼 수 없게 암호화가 잘 되었다.

20210707_124459

근데 아이디와 패스워드, 유저네임 이런게 같으면 중복이라 안되기 떄문에 이거또한 처리해 줘야한다.

20210707_124146

20210707_130644

이부분은 에러가 나는데 유니크한 제약조건을 위배했다고 에러를 뱉는다.(이게 정상) 근데 이렇게 사용자한테 보여줄 수는 없으니까 고쳐야 한다.


전처리 후처리 개념잡기

서버가 데이터 받아서 JPA통해 DB에 인서트(기본)

20210707_142525

20210707_142447

20210707_142938

이걸 바꾸면 스키마를 바꾼거라(디비 테이블을) 다시 반영해야되서 이걸 create로 해야 적용이 된다.

디비에 물어볼게 있고 안물어 볼게 있는데

1번에서는 유저네임이 동일한지는 판단 못하고 2번에서만 가능하다.

20자 이상인지는 1에서도 가능하다. 그럼 유저네임이 중복되면 로직이

그래서 1번에서 처리가 가능해서 처리하는걸 전처리라 하고 Validation을 이용할것이다

2번에서 하는걸 후처리라 하며, ExceptionHandler를 사용 할 것


유효성 검사하기

20210707_143956 이게 validation cpzmek.

근데 이렇게 하면 굉장히 길어짐

20210707_144431

전처리 하기위해서 startert Validation을 설치하자.(pom.xml)

20210707_144859

20210707_150149

20210707_150203 테이블이 생성되고 notnull이 들어간게 보인다.


@ResponseBody 사용하기

만약 프론트단에서 막아도(localhost:8080/auth/signup) 포스트맨에서 넣을 수도 있는데 이걸 막아야 한다.

20210707_165809

프론트 단에서 막는건

20210707_170115

이렇게 required를 넣어주면 글자를 안 넣을 시 안 넘어가게 된다.

@ResponseBody를 사용하면 컨트롤러지만 데이터를 받을 수 있다.

오류 나면 이제 콘솔이 아닌 페이지로 이동시켜주게 만들 것이다.

@PostMapping("/auth/signup")
public @ResponseBody String signup(@Valid SignupDto signupDto, BindingResult bindingResult) {	//key =value(x-www-form-urlencoded)방식이라고 했었따.
  //signupDto에서 오류가 있으면 bindingResult에 다 모아준다.
  //getFieldErrors에 다 모아주고 그걸 errors로 담는다.

  if(bindingResult.hasErrors()) {//에러가 있으면(유저길이가 넘어가거나 빈칸이 있으면
    Map<String, String> errorMap = new HashMap<>();

    for(FieldError error : bindingResult.getFieldErrors()) {
      errorMap.put(error.getField(), error.getDefaultMessage());
    }
    return "오류남";
  }else {

글로벌 예외처리하기 (Validation 체크)

다시 @ResponseBody를 지우자

전체 예외 처리를 하는 핸들러를 만든다.

@ControllerAdvice	//이걸 붙이면 모든 Exception을 다 낚아챈다.

20210707_170724 그리고 데이터를 응답하기 위해 RestController 어노테이션 사용

20210707_171026

이제 실행해보면 UI가 더 나아진게 보인다.

유효성 검사 실패하면(DTO중 유효성 하나라도 실패하면) bindingresult 에 다 담기고, bindingResult에 에러가 하나라도 있으 얘가 만든 해쉬맵에 throw 해서 Exception 강제로 발생시킴.

throw new CustomValidationException("유효성 검사 실패", errorMap); //강제 예외 발생

익셉션 강제로 throw 로 발생시키고 실행해보자.


공통 응답 DTO 만들기

public Map<String, String> validationException(CustomValidationException e) {

부분을 CMRespDto로 받게 하자.

20210707_190640

그리고 다 바꾸고 실행하면 아름다운 에러들 발생.


공통응답 Script 만들기

@RestController
@ControllerAdvice
public class ControllerExceptionHanlder {

	@ExceptionHandler(CustomValidationException.class)
	public String validationException(CustomValidationException e) {
		// CMRespDto, Script 비교
		// 1. 클라이언트에게 응답할 때는 Script가 더 좋음.(응답을 브라우저가 받음)
		// 2. Ajax통신 - CMRespDto(Ajax랑 통신할 떈 이게 더 좋음)
		// 3. Android 통신 - CMRespDto
		//Ajax는 자바쪽으로 서버 연결해서 통신
		//차이는 클라이언트가 응답받을땐 스크립트, 개발자는 코드로 응답받을때가 좋다
		return Script.back(e.getErrorMap().toString());
//		return new CMRespDto(-1, e.getMessage(), e.getErrorMap());	//실패했을떄 -1, 두번째에 메시지 3번째에 에러맵

	}
}


로그인 - UserDetailsService 이해하기 및 완료

AOP

시큐리티 설정파일 post:/auth/signin이 들어오는지 계속 감시

20210708_015455

20210708_015601

principalDetailsService에서 로그인 진행


	// 1. 패스워드는 알아서 체킹하니까 신경쓸 필요 없다.
	// 2. 리턴이 잘되면 자동으로 UserDetails 타입을 세션으로 만든다.
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		//유저네임만 받아오면 됨. 왜냐면 시큐리티가 알아서 패스워드 처리하기 때문.
		User userEntity = userRepository.findByUsername(username);

		if(userEntity == null) {
			return null;
		}else {
			return new PrincipalDetails(userEntity);
		}
	}


JPA Naming Query 도 찾아보자.

함수를 넣고싶은데 자바는 매개변수에 함수 못넣음(일급객체가 아니라) 대신 인터페이스를 넘기게 되는데 자바에서 함수를 넘기고 싶으면 인터페이스를 넘기게 된다.

@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collector = new ArrayList<>();
		collector.add(() -> { return user.getRole();});	//자바에서는 매개변수에
		return collector;
	}


view, 세션정보 확인하기.

home 눌렀을때 이미지 스토리로 가는데 /로 가게 바꾸자(스토리의 메인페이지로 온다.)

20210708_151750

일단 회원가입로그인 뷰까지 연결 끝

그럼 세션이 만들어졌는데 세션이 어디 만들어졌는지 확인해야한다. 시큐리티로 로그인을 했었는데 사용자가 aut/signin 요청을 하는데 시큐리티config에 설정되어 있기 때문에 서버로 가는 내용을 시큐리티가 받아서

20210708_154506

유저네임이 있는지 확인하고 있는 경우, 없는 경우를 확인한다. 없으면 내보내고 있으면 리턴한 principalDetails를 세션에 저장한다.

20210708_174113


회원 정보 수정 내용 -시큐리티 태그 라이브러리.

20210709_003150

헤더에 이걸 걸어놨는데 어느 곳에서든 헤더부분 사용 된다.

<sec:authorize access="isAuthenticated()">  //인증된 정보에 접근하는 방법(세션에 접근하는 방법)
	<sec:authentication property="principal" var="principal"/>

</sec:authorize>

인데 principal이 왜 저렇게 설정해놨냐면 접근주체, 인증주체라는 뜻으로 천재 프로그래머님들이 해놨다고 생각하면 된다.

20210709_003448

이제 이 과정이 되면 모델에 안 넘겨도 된다.(지우자)

https://codevang.tistory.com/273

이 부분에 시큐리티 관련 검색하니 잘 나와있다.


Ajax 사용하기

<button onclick="update(${principal.user.id},event)">제출</button>

버튼이 form태그 안에 있으면 폼이 가진 이벤트가 발생 폼은 데이터가 가진 내용 전송하는 거라 타입 바꿔줘야 한다.

<button type = "button" onclick="update(${principal.user.id},event)">제출</button>

이렇게 타입 추가해야된다.

근데 우리는 이벤트를 넘기지 않고 폼태그가 지닌 모든 태그를 지녀야 전송이 가능하다.

제이쿼리 쓰자.

// (1) 회원정보 수정
function update(userId) {

	let data = $("#profileUpdate").serialize();
	console.log(data);
}

값을 바꾸고 넘겨보면

20210709_012335

잘 넘어간다.


	let data = $("#profileUpdate").serialize();
	console.log(data);
	$.ajax({
		type: "put",
		url: `/api/user/${userId}`,
		data: data,
		contentType: "application/x-www-form-urlencoded; charset=utf-8",
		dataType: "json"
	}).done(res => {
		console.log("update실패")

	}).fail(error => {
		console.log("update실패")
	});

ajax를 주면 페이지나 파일이 아닌 데이터를 응답해야하는, 데이터를 응답하는 걸 api라 한다.

20210709_014615

이 데이터들을 넘겨받고 실행할거라 이 데이터들을 담을 Dto를 만들어야 한다.


public class UserApiController {
	@PutMapping("/api/user/{id}")
	public String update(UserUpdateDto userUpdateDto) {
		System.out.println(userUpdateDto);
		return "ok";
	}

로 작성하고 회원가입 실행해보면

20210709_015017

입력한 정보들이 잘 넘어온게 보인다. 저 업데이트 실패는 String으로 타입 줬는데 json으로 리턴해서 그럼.

신경 써야 할 부분은 여기

20210709_020740

콘솔에 입력값이 잘 들어왔는 지 볼 수 있다.

20210709_024332 영속화 해서 영속화된 객체의 값만 바꾸면 디비에 자동으로 값이 바뀐게 들어가게 해야한다.

그리고 존나 병신 짓 했었는데 @RequiredArgsConstructor

를 써야 final로 선언한 객체도 초기화로 null 선언이라던지 초기화 선언 안해도 사용 가능하다.

그리고 컨트롤러 부분가서

@PutMapping("/api/user/{id}")
public CMRespDto<?> update(@PathVariable int id,UserUpdateDto userUpdateDto) {
  User userEntity = userService.회원수정(id, userUpdateDto.toEntity());//유저 오브젝트 날림
//		PrincipalDetails.setUser(userEntity); // 세션 정보 변경
  return new CMRespDto<>(1, "회원수정완료", userEntity);
}

요청이 들어오면 회원 수정을 하고 수정된 결과를 받아서 ajax 호출한 쪽으로 응답만 해줬다.

이제 업데이트 해보고 디비 확인해보면

20210709_030049

성별부분과 그외 입력 부분이 바뀐게 보인다.

디비는 변경이 되었는데 다시 들어가면 그대로다. 세션 정보가 안바ㅜ끼어서 그런데 세션 정보를 바꿔줘야한다.

세션에 접근하고 바꿔줘야함.

20210709_031240

저 뒤의 @authenticationPrincipal을 이용해 세션정보에 접근했다.

이제 프론트 단에선 막았다. 근데 포스트맨 같은 백엔드에서 접근하면 막지 못하는데 그거도 막아야 하낟.

ControllerExceptionHanlder에 모든ㄱ 모였었는데

브라우저 던져줄떄 상태코드도 같이 던져주는 게 좋다.


유효성 검사

20210709_100058)

프론트단에서 할일

  1. 유효성 검사(서버에 정보 잘 들어왔는지 확인

20210709_100323

1번 유저 수정(디비에 접근해야 확인할 수 있음, 뒷단에서 처리) -> 영속화 -> 1번 유저 없음


구독 - 연관관계 개념 잡기

20210709_143655

실제 테이블 보면

이떄 Foreign Key는 누가 가질까>? 연관관계가 없으면 이 게시글을 누가 쓰는지 모르고 Ssar이 누가 썼는지 모른다.

여기에 만약에 ssar이라는 애가 게시글을 1번에서 쓰고

ssar이라는 애가 1번에서 근데 얘는 글을 여러개 쓰기가 가능하다.

정규화에서 원자성이 깨졌다(하나의 컬럼에는 하나의 데이터가 들어가야)

20210709_144151

연관관계 개념

  1. FK는 Many가 가진다.

사람은 한명이 여러개 영화 보기 가능

한개의 영화는 사람이 여러명이 보기 가능.

이 경우 N:N 의 관계

1번유저 2번유저와 1번영화 2번영화가 있다고 치자.

1번 유저가 어벤져스 볼거. 근데 1번유저가 2번도 볼수 있는데 같이는 못함(원자성 깨짐)

어벤져스도 1번유저가 보는데 2번유저가 보는거도 원자성 꺠짐.

그래서 N:N에서는 무조건 중간테이블이 항상 생성되게 된다.

20210709_144547

20210709_144606

20210709_144854

중간테이블을 예매라는 테이블로 두고

한명의 유저는 예매를 여러번 가능하다.

하나의 예매는 한명의 예매라는 자체를 한명의 유저가 하는데 이 2개의 관계를 봤을 때 이 둘의 관계는 1:N이 되고

하나의 영화는 예매를 몇번 할 수 있나? 여러번 가능하다. 하나의 영화가 예매가 한번밖에 안되면 한명밖에 못본다. 유저1,2,3, 다 영화 하나를 볼수 있게 된다.

하나의 예매는 여러가지 영화를 한번에 할수 있나?->ㄴㄴ

그럼 예매와 영화의 관계는 1:N이 된다.

공식으로 하면 N:N은

N:N은 항상 중간 테이블이 생긴다.

중간테이블이 1이 되고 내 테이블이 N이 된다.(중요) 반대쪽도 바찬가지

N:1:N이 된다.(1은 중간 테이블)

20210709_145806

20210709_145850

이 서브스크라이브 테이블은 중간 테이블이 항상 N이 되야하고 1:N:1의 형식이 되게 된다.

그럼 이런식으로 테이블 설계가 가능하다.

Foreign Key는 subscribe가 가지게 된다.

20210709_152354


20210709_172801

이 경우 1번이 2번을 구독했는데 또 2를 구독한다 중복이 나게 되서 에러발생

쿼리 작성


	@Modifying // INSERT, DELETE, UPDATE 를 네이티브 쿼리로 작성하려면 해당 어노테이션 필요!!
	@Query(value = "INSERT INTO subscribe(fromUserId, toUserId, createDate) VALUES(:fromUserId, :toUserId, now())", nativeQuery = true)
	void mSubscribe(int fromUserId, int toUserId);

	@Modifying
	@Query(value = "DELETE FROM subscribe WHERE fromUserId = :fromUserId AND toUserId = :toUserId", nativeQuery = true)
	void mUnSubscribe(int fromUserId, int toUserId);

@Modifying // INSERT, DELETE, UPDATE 를 네이티브 쿼리로 작성하려면 해당 어노테이션 필요!!

그리고 쿼리 안에 : 문법은 fromUserId 로 들어온 걸 안에 넣겠다는 뜻.


예외처리 ~ 시큐리티 설정

20210710_143207

20210710_143829

20210710_143859

로그인을 먼저해야 된다. 그래야 구독이 가능하기 때문

20210710_144105

그리고 똑같은 걸 또 하면 에러나게 해야

20210710_144142

20210710_144216

그리고 시큐리티config 부분

나가는게 user,command,image,subscribe가 나가게 했는데 하나 더 api로 시작하는 모든거도 나가게 설정.

20210710_150955


Profile페이지 - 이미지 모델 만들기~ 유효성 검사.

한명의 유저는 여러 이미지 업로드 가능

관계가 유저가 1 이고 이미지가 N이 된다.

반대로 하나의 이미지는 몇명의 유저가 만들 수 있나?

파일 저장 위치

20210710_163454

<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"> <!--  얘를 전송하는데 얘는 key-value로 날아감-->
				<button class="cta blue">업로드</button>
		</div>

“multipart/form-data”

이건 여러가지 타입 데이터를 묶어서 전달한다는 의미

파일과 key-value전송하고 싶은데 굳이 키 밸류라 xwwwformformuerlencoded로 전송 안하고 파일과 키밸류 동시 전송하고 싶을 때 멀티파트 사용

20210710_171302 사진 업로드 해보면 로그인 한 사용자 페이지로 오고 yml에 등록한 폴더에도

20210710_171331

잘 들어있는 거 확인

이걸 디비에 실제 등록하고 캡챠 넣어보자.


업로드 폴더를 프로젝트 외부에 두는 이유

서버 실행하면 이 코드가 실행되는게 아니라 이 코드가 컴파일 되서 .class 파일로 바꿔서 실행되어야 한다.

타겟이라는 폴더가 있는데 서버가 실행 될 떄 컴파일을 해서 .class 파일들이 여러개 들어간다 그리고 실행은 타겟폴더에 있는 .clas파일이 실행한다.

포토그램이라는 폴더안에 있는 모든 것들은 타겟프로그램으로 들어가야한다. 업로드 폴더, 사진파일도 마찬가지 1.jpg가 들어오면 타겟프로그램으로 가야 실행된다.

서버 실행시 컴파일 해서 이 자바코드를 집어넣는것과 집어넣는것.

20210710_201638

타겟폴더로 .class나 정적파일로 집어넣는 걸 deploy한다고 한다.

서버 실행해서 사진 업로드 됐는데 어떤일이 일어나나? 포토그램 내부에 있으니까 업로드 폴더 안에 1.jpg저장 이 파일이 타겟폴더로 이동됨

1.jpg가 타겟폴더로 들어오고 들어온 애들이 실행된다. 그리고 이러한 행위를 deploy(배포)된다고 한다.

자바파일에서 실행하는건 얼마 안 걸리는데 이런 사진같은 파일들은 .java .classs가 0.1초인거에 비해 이런 큰 폴더들은 몇초가 걸릴 수 있다.

20210710_202238

그래서 실행시 타겟폴더에 모든걸 넣으면 타겟폴더 들어가기도 전에 1.jpg가 실행 될 수도 있다.

20210710_202210

여기서 업로드 누르면 deploy되야데는데 여기 올라가는 시간보다

20210710_202230

메인으로 오는 시간이 더 빠르게 되고 그러면 엑박이 나오게 된다.

포토그램이 있는데 내부가 아닌 외부에 두고 거기에 1.jpg를 두면 포토그램 내부에 있는게 아니니까 deploy할 필요가 없다.

찾을 때도 타겟에 있는게 아니라 업로드 폴더에 있는 걸 찾는다.

그래서 큰 파일은 해당 폴더에 두는 걸 추천하지 않는다.

User프로필 정보로 가면서 이미지로 갈 때 어떻게 써야 할까

양방향 매핑해야된다.


이미지 뷰 렌더링

@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		WebMvcConfigurer.super.addResourceHandlers(registry);

		//file:///C:/workspace/springbootwork/upload/
//			  path: C:/STS4_WorkSpace\photogram\workspace/springbootwork/upload/

//			이렇게만 적어도 실제 주소인 path부분으로 간다.
		registry
			.addResourceHandler("/upload/**") // jsp페이지에서 /upload/** 이런 주소 패턴이 나오면 발동
			.addResourceLocations("file:///"+uploadFolder)
			.setCachePeriod(60*10*6) // 1시간
			.resourceChain(true)
			.addResolver(new PathResourceResolver());

20210713_015924

이런 패턴이 발동하면 /updalod 이런 패턴이 발동하면 실제 주소로 바꿔준다.

WebMvcconfig가 낚아채서 실행 만약 리소스핸들러부분을 바꾸면 발동 안한다.


Open in View

클라이언트가 요청하면 톰켓에는 스프링 컨테이너가 있고 안에 디스패쳐 존재하고 스프링 컨테이너 안에는 컨트롤러 있고 컨트롤러가 서비스 호출, 서비스는 레파지토리 호출하고 레파지토리는 영속성 컨텍스트를 가지고 있다.

그다음 얘가 DB를 호출한다.

요청이 들어오면 디스패쳐가 받아서 어떤 컨트롤러 줄지 찾고 잡음. 이떄 세션이 만들어지는데 디비에 접근할 수 있는 세션이 만들어짐.(컨트롤러에)

그 다음 요청하고 영속성 컨텍스트에 만약 데이터 있으면 바로 응답 가능하고 없으면 영속성 컨텍스트가 디비에 요청에서 응답받고 다시 컨텍스트 응답. 레파지토리가 받으면 다시 역순으로 응답. 서비스가 비즈니스 처리하고 컨트롤러에 돌려받으면 컨트롤러는 그걸 받아 데이터로 줄지 html로 줄지 정함. 그리고 마지막으로 돌려줌.

20210713_022937

세션이 닫히는 타이밍이 언제인가? 서비스에서 컨트롤러 돌려주는 시점에 닫히면 Lazy로딩이 불가능해진다.

20210713_023411

20210713_023353

지연로딩은 예를 들어 User라는 클래스가 있는데 아이디를 들고있고 username을 들고있고 images를 들고있고 이런 경우에서 images가 다른 테이블인 onetoMany인지 manytoOne인지 중요한데 fetchType이 lazy면 초반에 유저 셀렉트 할떄 유저만 들고옴. 만약 얘가 Eager면 조인해서 바로 들고오니까 이미지 같이 들고옴.

20210713_023330

20210713_023330

20210713_023449

바꾸면 user1 페이지 가는거 조차도 안된다.

유저만 들고온 상태에서 유저가 들고있는 images에서

20210713_023551

얘가 동작을 안하게 됨.

20210713_023637

OpenInView가 꺼져있으면 세션이 컨트롤러와 서비스단 사이에서 종료됨.

대신 Eager전략이면 세션이 저깃 닫혀도 되긴 함.

OpenInView라는 건 뷰단 까지 세션을 오픈한다는 뜻.

20210713_024203

그럼 세션종료가 컨트롤러 이후에 종료되므로 Lazy로딩이 가능해짐.

DB 업로드시 @Transactional을 걸어야 하는 이유?

송금서비스와 계좌가 있다 치면 함수를 하나 만든다(송금에) 송금 2번 유저에 머니를 건드린다.

20210713_024549

여기서 1번 유저 실패했다 치면

30000인데 그럼 5000원이 허공으로 날아감.

현재 단순한 insert,upload라 하나에 한번의 행동밖에 없지만 여러개의 업데이트나 인서트가 섞여오면?

2개의 로직이 하나의 송금이 되고 이걸 하나로 트랜잭션이라 본다.

트랜잭션은 일의 최소 단위라 본다.

송금을 하기 위해 최소 단위는 2가지 업데이트가 일어난다.

이런일이 안 일어나기 위해선 @Transactional 을 넣어주면 된다.

위의 일 중 하나라도 잘못되면 롤백하고 일이 전부 잘 수행 되어야 commit을 실행한다.

그래서 디비에 무슨 값을 넣을때 crud를 잘 실행 하기 위해서 @Transactional 을 거는게 아주 좋은 습관이 될 것.

20210713_024822

회원 프로필의 경우 read만 한다해도 트랜잭션 걸어주는게 좋다(readonly)

select 하는데 트랜젝션 거는 이유? user 1번 정보 업데이트 하고 싶다.

20210713_025808

어떻게 해야하나? 1번정보 select 해서 들고옴 레포지토리가 있으면 요청시 1번정보 없으면 디비에 요청해서 받아옴.

그리고 이 user1이라는 정보는 어느 곳에서나 다 동기화가 되어있다.

이상태에서 user1의 1번정보에 username을 변경한다 치면 레퍼런스가 다 같아서 영속성 컨텍스트 부분도 바뀌게 되고

응답이 되는 시점에(C,s사이) 영속성 컨텍스트는 변경된 오브젝트를 DB에 자동 flush해서 업데이트가 된다.

영속성 컨텍스트는 서비스가 끝나는 시점에 변경된 오브젝트를 감지해야한다.

감지해야되고 바뀐걸 감지해야 넣지. 감지해서 넣으면 그걸 더티체킹이라고 했었다.

그래서 변경감지를 하기 위해 readonly=true 이런거 넣어준다. 디비 고립성 이런거도 있는데 그건 나중에


~DTO 페이지 완성

20210713_030254

막 sout 하고 있는데 이런 거 떄문에 오류 난다. 유저 호출시 내부적으로 이미지 호출할거고 sout를 어디서 잘못했는지 확인하자.


구독하기 뷰 렌더링

스칼라 서브쿼리? select 절에 select 넣는거.

SELECT u.id, u.username,u.profileImageUrl
FROM user u INNER JOIN subscribe s
ON u.id = s.toUserId
WHERE s.fromUserId=1;

QLRM이란?

데이터베이스에서 result된 결과를 자바클래스에 매핑해주는 라이브러리. 이거로 dto 쉽게 받고 만들 수 있다.

20210714_132507



-- 구독수
-- SELECT COUNT(*) FROM subscribe WHERE FROMUserID=1;

-- 구독 여부(ssar(1)로 로그인, cos(2)페이지로 감)
SELECT COUNT(*) FROM subscribe WHERE FROMUserID=1 AND touserid=2;

-- 로그인(1 ssar) -- 구독정보(2 cos)
SELECT * FROM subscribe;

SELECT * FROM user;

SELECT * FROM subscribe WHERE FromUserid=1;

SELECT * from user WHERE id =1 OR id=3;	--조인

--조인쿼리 (user.id = subscribe.toUserID)

SELECT u.id, u.username,u.profileImageUrl
FROM user u INNER JOIN subscribe s
ON u.id = s.toUserId
WHERE s.fromUserId=1;


스토리 페이지

포토리스트 만들기 ~ 페이징로딩 구현

스토리 페이지 API

쿼리(ImageRepositroy)

@Query(value = "SELECT * FROM image WHERE userId IN (SELECT toUserId FROM subscribe WHERE fromUserId = :principalId) ORDER BY id DESC", nativeQuery = true)
	Page<Image> mStory(int principalId, Pageable pageable);
}

/**
	2. 스토리 페이지
	(1) 스토리 로드하기
	(2) 스토리 스크롤 페이징하기
	(3) 좋아요, 안좋아요
	(4) 댓글쓰기
	(5) 댓글삭제
 */

// (1) 스토리 로드하기
let page = 0;

function storyLoad() {
	$.ajax({
		url: `/api/image?page=${page}`,
		dataType: "json"
	}).done(res => {
		//console.log(res);
		res.data.content.forEach((image)=>{
			let storyItem = getStoryItem(image);
			$("#storyList").append(storyItem);
		});
	}).fail(error => {
		console.log("오류", error);
	});
}

storyLoad();

function getStoryItem(image) {
	let item = `<div class="story-list__item">
	<div class="sl__item__header">
		<div>
			<img class="profile-image" src="/upload/${image.user.profileImageUrl}"
				onerror="this.src='/images/person.jpeg'" />
		</div>
		<div>${image.user.username}</div>
	</div>
	<div class="sl__item__img">
		<img src="/upload/${image.postImageUrl}" />
	</div>
	<div class="sl__item__contents">
		<div class="sl__item__contents__icon">
			<button>
				<i class="fas fa-heart active" id="storyLikeIcon-1" onclick="toggleLike()"></i>
			</button>
		</div>
		<span class="like"><b id="storyLikeCount-1">3 </b>likes</span>
		<div class="sl__item__contents__content">
			<p>${image.caption}</p>
		</div>
		<div id="storyCommentList-1">
			<div class="sl__item__contents__comment" id="storyCommentItem-1"">
				<p>
					<b>Lovely :</b> 부럽습니다.
				</p>
				<button>
					<i class="fas fa-times"></i>
				</button>
			</div>
		</div>
		<div class="sl__item__input">
			<input type="text" placeholder="댓글 달기..." id="storyCommentInput-1" />
			<button type="button" onClick="addComment()">게시</button>
		</div>
	</div>
</div>`;
	return item;
}

// (2) 스토리 스크롤 페이징하기
$(window).scroll(() => {
	//console.log("윈도우 scrollTop", $(window).scrollTop());
	//console.log("문서의 높이", $(document).height());
	//console.log("윈도우 높이", $(window).height());

	let checkNum = $(window).scrollTop() - ( $(document).height() - $(window).height() );
	//console.log(checkNum);

	if(checkNum < 1 && checkNum > -1){
		page++;
		storyLoad();
	}
});



좋아요 구현

Like 모델 만들기

좋아요 API 구현

20210727_141711

20210727_141409

먼저 post맨에서 로그인(post)를 하고 좋아요 api를 실행해야 좋아요가 성공한다.

20210727_141935


###

Likes안에 유저 안에 이미지가 안나오면 해결 됨.

SELECT imageId
FROM(
SELECT imageId, COUNT(imageId) likeCount
FROM likes GROUP BY imageId
ORDER BY likeCount desc
) c;

인기순으로 데이터 뽑아옴. ]


SELECT i.*
FROM image i INNER JOIN (SELECT imageId, COUNT(imageId) likeCount FROM likes GROUP BY imageId) c
ON i.id = c.imageid
ORDER BY likeCount DESC;
;


프로필 유저 사진 변경

20210728_030536

  1. pageUserId, principalId 를 비교해서 같을 때만 동작하기

  2. 사진 클릭 시 input type=”file” 강제로 클릭 이벤트 발생 실행시키기

  3. 이미지를 put 방식(ajax)로 서버로 전송하기

    • FormData 객체이용.






© 2021.03. by yacho

Powered by github