유저 드리븐(User driven)하면서 프로바이더 드리븐(provider driven)한 웹페이지를 제작하고자 크몽을 선택하게 되었습니다. 이는 개발자의 기본적인 역량을 키우려는 것과 동시에 새로운 기능구현을 하는 것을 목표한 결과입니다.
부트캠프 항해99의 처음 한 달이 조금 넘은 시간동안 수강생들은 회원가입, 로그인 그리고 CRUD 기능을 중점적으로 다룹니다. 그렇기에 클론에 대한 목표로 인스타그램, 슬랙, 당근마켓과 같은 유저와 프로바이더가 상호작용하는 서비스를 클론하는 경향이 생깁니다. 이에 대한 참고자료로서 앞선 항해99 수강생들의 작품을 확인할 수 있었습니다.
팀원들과 기나긴 논의를 끝으로 저희 팀은 이전 수강생들이 하지 않은 클론의 목적으로 ‘크몽'을 선택했습니다. 대서양을 건넜던 콜롬버스의 마음을 가져보기로 한 것입니다. 참고자료 많지 않기에 막막하지만 작은 성공을 꿈꾸기에 크몽은 저희에게 실현 가능한 목표처럼 보였습니다.
크몽의 컴포넌트가 많은 관계로 ‘크몽 엔터프라이즈(kmong.com/enterprise)’을 클론했습니다. 구현된 기능의 간단한 개요는 아래와 같습니다.
- ‘프로젝트 의뢰하기’ 버튼 클릭 시, 요소 선택 후 등록.
- 의뢰 된 프로젝트들의 리스트 반환.
- 회원가입 진행과 로그인.
보다 자세한 사항은 하기 ‘**3. Wireframe - 크몽’**에서 참고하실 수 있습니다.
- 2022년 06월 16일 ~ 2022년 06월 23일
이름 | 개인 블로그 링크 | 깃허브 링크 | 프론트&백엔드 |
---|---|---|---|
이가연 | https://2022gygy.tistory.com/ | https://github.com/gygy2022 | 프론트 |
조해솔 | https://velog.io/@solpine | https://github.com/sol-pine | 프론트 |
한지용 | https://velog.io/@jigom | https://github.com/jigomgom | 프론트 |
이동재 | https://velog.io/@djlesque | https://github.com/Epikoding | 백엔드 |
박세열 | https://park-se-yeol.tistory.com/ | https://park-se-yeol.tistory.com/ | 백엔드 |
김민지 | https://velog.io/@alswlwkd20 | https://github.com/minji-kim525 | 백엔드 |
조원 역할 및 기능 개발 설명
조해솔
- Project List view, Detail view, Create view 작업
- 프로젝트 의뢰 생성 구현
- 프로젝트 의뢰 파일 첨부 및 썸네일 미리보기 구현
한지용
- Main view 작성
- 서버간 통신 테스트 및 사전 가이드라인 작성
- 로그인, 회원가입, 의뢰 수정 및 삭제 구현
이가연
- Login view, SignUp view, MyKmong view 작업
- 서버에서 받아오는 데이터를 화면에 뿌리는 작업
- 페이지네이션 구현
이동재
- 로그인 & 회원가입 기능 구현
- security 설정
박세열
- 홈 화면 조회 기능 및 프로젝트 리스트 조회 기능
- 파일 업로드 및 검색 기능, 소셜 로그인 기능
김민지
- 프로젝트 CRUD, 페이징 처리
- MVC 패턴 설계
- Java 8
- SpringBoot
- Spring Security
- Gradle
- JPA
- MySQL 8.0
- AWS S3
- JWT
- OAuth2
- React
- react-router-dom
- Axios
- Redux
- Styeld Component (for es6 and css)
- Fortawesome
- redux-toolkit
- AWS
- FileZilla
https://docs.google.com/spreadsheets/d/1xkkSbZWIB8ChC1NRSAErkekziXt4bmB7EdIGHJpftzs/edit#gid=1824601528
https://www.youtube.com/watch?v=aTwMly1ICzE
-
회원가입, 로그인 & 로그아웃
- jwt을 사용한 유효성 검사
- 이메일 양식 정규식으로 유효성 검사
-
프로젝트 CRUD
- 각 프로젝트마다 상세 페이지 구성 후 세부 사항 확인
- 정렬 기능을 추가하여 키워드 별로 확인 가능
-
홈페이지와 프로젝트 리스트 조회
- 각 상황에 맞는 정보 전달
-
파일 업로드
- 여러 개의 파일들을 리스트 형태로 전달 받아 s3에 저장
- 이후 서버에서 s3 URL을 난수화된 파일 이름과 함께 저장
- 상세 페이지에서 파일 확인 가능
-
파일 업로드(2)
- 프론트에서 파일을 업로드하기 위해 JSON.stringfy와 Blob으로 2진화 데이터 생성
Redux의 state( 1 )
이슈 내용 : Redux의 state 값을 가져올 때 갱신 전 값을 읽어와 문제가 발생
해결 방법 : initialState에 값을 추가하여 관리, useEffect에 dependency를 두어 인자값이 바뀔 때 useState로 값을 인지
Redux의 state( 2 )
이슈 내용 : 디테일 페이지 로딩과 서버로부터 전달받은 데이터 값이 들어오는 것에 딜레이가 있어 로딩 시, 데이터 배열의 인덱스에 접근하지 못해 에러가 발생
해결 방법 : 리덕스에 배열의 데이터를 넣고 스토어에서 빼오는 방식으로 해결
Radio button checked 속성
이슈 내용 : radio button의 checked 속성을 전달하는 문제
해결 방법 : Radio button 으로 묶인 부분은 map을 활용하여 생성하고 useState를 통해 checked 여부를 수정
데이터 parsing
이슈 내용 : 서버에서 받아온 Data를 추출
해결 방법 : dictionary로 묶인 부분을 미리 선언해둔 배열에서 some과 filter를 이용하여 중복되는 부분을 추출하여 활용
파일 업로드
이슈 내용 : formdata 내부에 딕셔너리 데이터를 append하여 전달할 때 400 에러가 발생
해결 방법 : ****JSON.stringify 로 변환 후 append하여 해결
editProject
에서response
값이슈 내용 : 수정할 때, 중복체크에서
String
으로 받은 값을 프론트에 어떻게response
해줄지에 대한 이슈해결 방법 : “,” 로
split
한 다음,map
으로string(key):true(value)
로 보내는 것으로 결정
modal 접근 불가
이슈 내용:
projects/modal
로 했을 때 접근이 안 되었던 이슈.해결 방법 :
List
는 복수 개의 fetch 가 안됨으로 컬럼 타입Set
으로 변경
올바른 jwt 토큰이 아닙니다 에러
이슈 내용: 기타 조회 기능들을 사용할 때 500 internal server error 이슈
해결 방법 :
skipPathList
에GET
매핑 api 추가
프로젝트 추가 시 파일 제외
이슈 내용: 기존에 있던 컨트롤러에서는 파일을 꼭 올려야 했던 이슈
해결 방법 :
@RequestPart(value = "files",required = false) required = false
추가
검색 기능 관련 키워드 조회 이슈
이슈 내용: JPA 키워드 중
LIKE(”%KEYWORD%”)
와 같은 역할을 하는 문법 필요 이슈해결 방법 :
findByTitleContainingOrderByCreatedAt(String keyword)
문법을 통해 해결, Containing 문법이 SQL의 LIKE 역할을 함
존재하지 않는 회원이 로그인을 할 때 500에러가 발생.
이슈 내용 :
JpaRepository
를 상속받은UserRepository
에서Optional
클래스를 사용한User
객체를 사용하는 조건에서 존재하지 않는 회원ID로 로그인을 시도할 때 500에러를 발생함.해결 방법 :
UserRepository
에서Optional<User>
을User
로 변경. 자세한 설명은 하기 ‘8-2.의 Optional 객체’ 참고.
-
filter
와some
을 통해 중복된 값을 추출const Valuelist = [ { ...action.payload.responseDtoMap } ]; const keylist = Object.keys ( Valuelist[0] ); let resultRequired = keylist.filter( x1 => RequiredFunction.some(x2=> x1 === x2 ) )[0]; let resultCommerce = keylist.filter( x1 => Commerce.some(x2=> x1 === x2 ) )[0]; let resultSites = keylist.filter( x1 => Sites.some(x2=> x1 === x2 ) )[0]; let resultUserRelated = keylist.filter( x1 => userRelated.some(x2=> x1 === x2 ) )[0];
-
checked
를 확인const CurrentStatus = ["아이디어만 있음", "기획서 보유", "디자인 보유", "개발환경 보유"]; const RequiredFunction = ["갤러리", "게시판", "일정 관리", "SNS 연동"]; { Commerce.map( ( item, index ) => { return ( <Select key={index}> <Input type="radio" name="commerceRelatedFunction" value={item} onChange={handleCommerceRelatedFunction} checked={ setCommerce === item } /> <span>{item}</span> </Select> ); })}
-
객체를 stringfy와 Blob으로 처리
const formData = new FormData(); formData.append( "projectDto", new Blob( [JSON.stringify(projectDto, { contentType: "application/json" })], { type: "application/json", } ) ); formData.append("files", file);
-
AwsS3Service
아마존 서비스 파일을 통해 업로드 및 삭제 구현@Service @RequiredArgsConstructor public class AwsS3Service { @Value("${cloud.aws.s3.bucket}") private String bucket; private finalAmazonS3amazonS3; @Transactional publicList<FileRequestDto> uploadFile(List<MultipartFile> multipartFile) { List<String> fileNameList = new ArrayList<>(); List<FileRequestDto> fileRequestDtos = new ArrayList<>(); // forEach구문을 통해 multipartFile로 넘어온 파일들 하나씩 fileNameList에 추가 for(MultipartFilefile : multipartFile){ String fileName = createFileName(file.getOriginalFilename()); ObjectMetadata objectMetadata = new ObjectMetadata(); objectMetadata.setContentLength(file.getSize()); objectMetadata.setContentType(file.getContentType()); try(InputStream inputStream = file.getInputStream()) { amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) .withCannedAcl(CannedAccessControlList.PublicRead)); fileRequestDtos.add(new FileRequestDto(amazonS3.getUrl(bucket,fileName).toString(),fileName)); } catch(IOException e) { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."); } } return fileRequestDtos; } @Transactional public void deleteFile(String fileName) { amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); } private String createFileName(String fileName) {//먼저 파일 업로드 시,파일명을 난수화하기 위해 random으로 돌립니다. return UUID.randomUUID().toString().concat(getFileExtension(fileName)); } private String getFileExtension(String fileName) {// file형식이 잘못된 경우를 확인하기 위해 만들어진 로직이며,파일 타입과 상관없이 업로드할 수 있게 하기 위해 .의 존재 유무만 판단하였습니다. try { return fileName.substring(fileName.lastIndexOf(".")); } catch (StringIndexOutOfBoundsException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 형식의 파일(" + fileName + ") 입니다."); } } }
-
AmazonS3Config
컨피큐레이션의 빈 등록@Configuration public class AmazonS3Config { @Value("${cloud.aws.credentials.access-key}") private String accessKey; @Value("${cloud.aws.credentials.secret-key}") private String secretKey; @Value("${cloud.aws.region.static}") private String region; @Bean public AmazonS3Client amazonS3Client() { BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); return (AmazonS3Client) AmazonS3ClientBuilder.standard() .withRegion(region) .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) .build(); } }
-
GoogleUserService
를 통해 프론트에서 구글 클라이언트로 리다이렉트,이후 로그인 및 회원가입을 진행하고 jwt토큰 발급
@Service @RequiredArgsConstructor public class GoogleUserService { private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; @Value("${spring.security.oauth2.client.registration.google.client-id}") private String clientId; @Value("${spring.security.oauth2.client.registration.google.client-secret}") private String clientSecret; public GoogleUserResponseDto googleLogin(String code) throws JsonProcessingException { //HTTP Request를 위한 RestTemplate RestTemplate restTemplate = new RestTemplate(); // 1. "인가 코드"로 "액세스 토큰" 요청 String accessToken = getAccessToken(restTemplate, code); // 2. "액세스 토큰"으로 "구글 사용자 정보" 가져오기 GoogleUserInfoDto snsUserInfoDto = getGoogleUserInfo(restTemplate, accessToken); // 3. "구글 사용자 정보"로 필요시 회원가입 및 이미 같은 이메일이 있으면 기존회원으로 로그인 User googleUser = registerGoogleOrUpdateGoogle(snsUserInfoDto); // 4. 강제 로그인 처리 final String AUTH_HEADER = "Authorization"; final String TOKEN_TYPE = "BEARER"; String jwt_token = forceLogin(googleUser); // 로그인처리 후 토큰 받아오기 HttpHeaders headers = new HttpHeaders(); headers.set(AUTH_HEADER, TOKEN_TYPE + " " + jwt_token); GoogleUserResponseDto googleUserResponseDto = GoogleUserResponseDto.builder() .token(TOKEN_TYPE + " " + jwt_token) .username(googleUser.getUsername()) .build(); System.out.println("Google user's token : " + TOKEN_TYPE + " " + jwt_token); System.out.println("LOGIN SUCCESS!"); return googleUserResponseDto; } private String getAccessToken(RestTemplate restTemplate, String code) throws JsonProcessingException { //Google OAuth Access Token 요청을 위한 파라미터 세팅 GoogleOAuthRequest googleOAuthRequestParam = GoogleOAuthRequest .builder() .clientId(clientId) .clientSecret(clientSecret) .code(code) //.redirectUri("https://memegle.xyz/redirect/google") //.redirectUri("http://localhost:3000/redirect/google") .redirectUri("http://localhost:8080/api/user/google/callback") .grantType("authorization_code").build(); //JSON 파싱을 위한 기본값 세팅 //요청시 파라미터는 스네이크 케이스로 세팅되므로 Object mapper에 미리 설정해준다. ObjectMapper mapper = new ObjectMapper(); mapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); //AccessToken 발급 요청 ResponseEntity<String> resultEntity = restTemplate.postForEntity("https://oauth2.googleapis.com/token", googleOAuthRequestParam, String.class); //Token Request GoogleOAuthResponse result = mapper.readValue(resultEntity.getBody(), new TypeReference<GoogleOAuthResponse>() { }); String jwtToken = result.getId_token(); return jwtToken; } private GoogleUserInfoDto getGoogleUserInfo(RestTemplate restTemplate, String jwtToken) throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); String requestUrl = UriComponentsBuilder.fromHttpUrl("https://oauth2.googleapis.com/tokeninfo") .queryParam("id_token", jwtToken).encode().toUriString(); String resultJson = restTemplate.getForObject(requestUrl, String.class); Map<String,String> userInfo = mapper.readValue(resultJson, new TypeReference<Map<String, String>>(){}); GoogleUserInfoDto googleUserInfoDto = GoogleUserInfoDto.builder() .username(userInfo.get("email")) .nickname(userInfo.get("name")) .profileImage(userInfo.get("picture")) .build(); return googleUserInfoDto; } private User registerGoogleOrUpdateGoogle(GoogleUserInfoDto googleUserInfoDto) { User sameUser = userRepository.findUserByUsername(googleUserInfoDto.getUsername()); if (sameUser == null) { return registerGoogleUserIfNeeded(googleUserInfoDto); } else { return updateGoogleUser(sameUser, googleUserInfoDto); } } private User registerGoogleUserIfNeeded(GoogleUserInfoDto googleUserInfoDto) { // DB 에 중복된 google Id 가 있는지 확인 String googleUserId = googleUserInfoDto.getUsername(); User googleUser = userRepository.findUserByUsername(googleUserId); if (googleUser == null) { // 회원가입 // username: google ID(email) String username = googleUserInfoDto.getUsername(); // profileImage: google profile image String profileImage = googleUserInfoDto.getProfileImage(); // password: random UUID String password = UUID.randomUUID().toString(); String encodedPassword = passwordEncoder.encode(password); googleUser = User.builder() .username(username) .password(encodedPassword) .build(); userRepository.save(googleUser); } return googleUser; } private User updateGoogleUser(User sameUser, GoogleUserInfoDto googleUserInfoDto) { if (sameUser.getUsername() == null) { System.out.println("중복"); sameUser.setUsername(googleUserInfoDto.getUsername()); userRepository.save(sameUser); } return sameUser; } private String forceLogin(User googleUser) { UserDetailsImpl userDetails = new UserDetailsImpl(googleUser); Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); return JwtTokenUtils.generateJwtToken(userDetails); } }
-
Before
public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByUsername(String username);
public class UserDetailsServiceImpl implements UserDetailsService { ~ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Optional<User> user = userRepository.findByUsername(username); return new UserDetailsImpl(user.get());
public class JWTAuthProvider implements AuthenticationProvider { @Override public Authentication authenticate(Authentication authentication) Optional<User> user = userRepository.findUserByUsername(username).get();
java.util.NoSuchElementException: No value present at java.base/java.util.Optional.get(Optional.java:143) ~[na:na]
-
After
public interface UserRepository extends JpaRepository<User, Long> { User findByUsername(String username);
public class UserDetailsServiceImpl implements UserDetailsService { ~ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findUserByUsername(username); return new UserDetailsImpl(user);
public class JWTAuthProvider implements AuthenticationProvider { @Override public Authentication authenticate(Authentication authentication) User user = userRepository.findUserByUsername(username);
-
Why
public class FormLoginAuthProvider implements AuthenticationProvider { @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { UserDetailsImpl userDetails = (UserDetailsImpl) userDetailsService.loadUserByUsername(username); if (userDetails.getUser() == null){ throw new UsernameNotFoundException("이메일 주소가 존재하지 않습니다."); }
Optional<User>
변수를 선언 후.get()
메소드로 가져올 때Optional
이 비어있을 때NoSuchElementException
이 반환된다.Optional
을 사용하여 반환된 값은 null이 아닌 공란값이며 공란값을.get()
하기에java.util.NoSuchElementException: No value present
이 발생하는 것이다.
기능 | MTHD | URL | request | response | 비고 |
---|---|---|---|---|---|
회원가입, signup | POST | /api/signup | { "username": "test1231246", "password": "test", "passwordCheck": "test", "businessPart": "test", "job": "test" } |
{ ’ok’: true, message: ‘회원가입 성공’ } OR { ‘ok’: false, message:’회원가입 실패’ } |
|
로그인, login | POST | /api/login | { ”username”: ”username”, ”password”: ”password”, } |
{ ’ok’: true, message: ‘로그인 성공’ } OR { ‘ok’: false, message:’로그인 실패’ } |
|
홈페이지 조회 | GET | / | [ { ”project_id”: Long(int), ”imageUrl” ”title”:”title”, "budget": ”description”:, "workingPeriod": ”total_len”: int }, ] 4개 제한 |
||
프로젝트 리스트 페이지 모두 조회 | GET | /projects | { "page": (int) page, "size": (int) size, "sortBy” : (String)"createAt" or "budget"or "volunteerValidDate" } |
[ { ”project_id”: Long(int), ”imageUrl”:, "progressMethod":, ”leftDaysForEnd”:”volunteerValidDate”-”todayDate”, ”title”:”title”, "budget": "bigCategory": "smallCategory": ”description”:, "WorkingPeriod":, ”taxInvoice”:, ”progressmethod” }, ] |
기본값→최신등록순 |
마이페이지 프로젝트 조회 | GET | /mypage/projects | [ { ”project_id”: Long(int), ”imageUrl”:”서버 내부 저장된 사진”, ”title”:”title”, "budget":"budget", "bigCategory":"bigCategory", "smallCategory":"smallCategory" } ] |
||
게시글 작성 | POST | /projects/project | header : token { "bigCategory":"[string] 상위 카테고리", "smallCategory":"[string] 하위 카테고리", "progressMethod":"(프로젝트 진행 방식)외주", "projectScope":"(500만원 미만)", "title": "[string] 의뢰서비스의 제목", ”description”:"[string] 설명", "currentStatus":"[string] 프로젝트 준비상황", "requiredFunction":"[string] 기본기능", "userRelatedFunction":"[string] 회원 관련 기능", "commerceRelatedFunction":"[string] 커머스 관련 기능", "siteEnvironment":"[string] 사이트 환경", "solutionInUse":"df", "reactable":"[string] 반응형 적용 여부", "budget":100000, "taxInvoice":"true", "volunteerValidDate": "dflfke", "dueDateForApplication":"dfdfe", "workingPeriod":30 }, ”files”:[{ ”fileUrl”:”fileUrl”, ”fileName”:”fileName” },] } |
{ ’ok’: true, message: ‘게시글 수정 완료’ } OR { ‘ok’: false, message:’게시글 수정 완료’ } |
요청 기능은 한글 스트링 그대로 보내시면 됩니다. |
게시글 조회 | GET | /projects/{projectId} | "progressMethod":"(프로젝트 진행 방식)외주", "projectScope":"(500만원 미만)", "title": "[string] 의뢰서비스의 제목", ”description”:"[string] 설명", "currentStatus":"[string] 프로젝트 준비상황", "requiredFunction":"[string] 기본기능", "userRelatedFunction":"[string] 회원 관련 기능", "commerceRelatedFunction":"[string] 커머스 관련 기능", "siteEnvironment":"[string] 사이트 환경", "solutionInUse":"df", "reactable":"[string] 반응형 적용 여부", "budget":100000, "taxInvoice":"true", "volunteerValidDate": "dflfke", "dueDateForApplication":"dfdfe", "workingPeriod":30 }, ”files”:[{ ”fileUrl”:”fileUrl”, ”fileName”:”fileName” },] } |
요청 기능은 한글 스트링 그대로 보내시면 됩니다. | |
게시글 수정 | PUT | /projects/project/{projectId} | header : token { "bigCategory":"[string] 상위 카테고리", "smallCategory":"[string] 하위 카테고리", "progressMethod":"(프로젝트 진행 방식)외주", "projectScope":"(500만원 미만)", "title": "[string] 의뢰서비스의 제목", ”description”:"[string] 설명", "currentStatus":"[string] 프로젝트 준비상황", "requiredFunction":"[string] 기본기능", "userRelatedFunction":"[string] 회원 관련 기능", "commerceRelatedFunction":"[string] 커머스 관련 기능", "siteEnvironment":"[string] 사이트 환경", "solutionInUse":"df", "reactable":"[string] 반응형 적용 여부", "budget":100000, "taxInvoice":"true", "volunteerValidDate": "dflfke", "dueDateForApplication":"dfdfe", "workingPeriod":30 } |
{ "bigCategory":"[string] 상위 카테고리", "smallCategory":"[string] 하위 카테고리", "progressMethod":"(프로젝트 진행 방식)외주", "projectScope":"(500만원 미만)", "title": "[string] 의뢰서비스의 제목", ”description”:"[string] 설명", "currentStatus":"[string] 프로젝트 준비상황", "requiredFunction":"[string] 기본기능", "userRelatedFunction":"[string] 회원 관련 기능", "commerceRelatedFunction":"[string] 커머스 관련 기능", "siteEnvironment":"[string] 사이트 환경", "solutionInUse":"df", "reactable":"[string] 반응형 적용 여부", "budget":100000, "taxInvoice":"true", "volunteerValidDate": "dflfke", "dueDateForApplication":"dfdfe", "workingPeriod":30 }, ”files”:[{ ”fileUrl”:”fileUrl”, ”fileName”:”fileName” },] } |
마이페이지 편집 완료를 눌렀을 때 요청해야 할 API |
게시글 수정 전 조회 | GET | /modal/{projectId} | token | { "bigCategory":"[string] 상위 카테고리", "smallCategory":"[string] 하위 카테고리", "progressMethod":"(프로젝트 진행 방식)외주", "projectScope":"(500만원 미만)", "title": "[string] 의뢰서비스의 제목", ”description”:"[string] 설명", "currentStatus":"[string] 프로젝트 준비상황", "requiredFunction":"[string] 기본기능", "userRelatedFunction":"[string] 회원 관련 기능", "commerceRelatedFunction":"[string] 커머스 관련 기능", "siteEnvironment":"[string] 사이트 환경", "solutionInUse":"df", "reactable":"[string] 반응형 적용 여부", "budget":100000, "taxInvoice":"true", "volunteerValidDate": "dflfke", "dueDateForApplication":"dfdfe", "workingPeriod":30 } |
마이페이지에서 편집 버튼을 눌렀을 때의 게시글 조회 |
게시글 삭제 | DELET | /projects/project/{projectId} | header: token | { ”ok” : true, message : 삭제 완료 } |
|
전체 게시글 조회 | GET | /projects | [ { ”서버 내부 저장된 사진” ”leftDaysForEnd”:”volunteerValidDate”-”todayDate”(자바 현재 시간), ”title”:”title”, "budget": "bigCategory": "smallCategory": ”description”:, "WorkingPeriod": }, ] |
||
파일 전송 | POST | /projects/project/file | { 폼데이터로 파일 보내기 } |
{ ’ok’: true, message: ‘파일 업로드 완료’ } OR { ‘ok’: false, message:’파일 업로드 실패’ } |
|
파일 삭제 | DELETE | /projects/project/file/{projectId} | { ’ok’: true, message: ‘파일 삭제완료’ } OR { ‘ok’: false, message:’파일 삭제실패’ } |
누군가의 뒷모습이 보이기 시작하는 것은 사랑 때문만이 아니라는 것이 아니라는 것을 배운 한 주였습니다. 지난 삼 주 동안 스프링을 공부하면서 “아 내가 왜 이렇게 어려운 스프링을 선택해서 이렇게 고생하는 것일까?”란 생각이 들었습니다. 자바와 스프링은 가시적이지 않을 뿐더러 MVC과 역할과 책임을 분할하는 수 많은 클래스를 만들어야하고, 보안은 섣불리 손을 대기 어려운 수준이었으니까요.
하지만 리액트와 같이 협업을 하고나니 알았습니다. 리액트가 스프링보다 훨씬 더 많은 시간과 노력을 쏟아야 프로젝트가 끝날 수 있다는 것을 말이에요. 스프링이 구조 설계하고 여유가 있을 때도 리액트에서는 머리를 쥐어짜고 코드를 짜고 있는걸 보니 그들의 등이 보이기 시작했습니다. 백엔드의 구조 설계는 끝이 있지만 심미적 요소가 감미된 프론트에서는 그 끝을 정해야 하니까요.
한 주 동안 잠을 아껴가며 프로젝트를 마무리 해주셨던 모든 분들에게 감사 인사를 돌립니다. 항해99의 남은 기간 동안 스트레스 없이 원하시는 결과 얻으시길 기도하겠습니다. - 이동재
클론 코딩 1주일 동안 많은 것들을 배웠던 것 같습니다. 저번 주차가 프론트와 백 간의 협업의 개념을 중심으로 생각하는 관점을 길러주는 주차였다면 이번 주차는 실제 서비스 중인 사이트들이 어떤 방향으로 비즈니스 로직이 구현되어 있는지 알 수 있는 주차였습니다. 스프링에서 특정 로직을 구현하기 위해 어떠한 함수를 써야 하는가, 웹소켓, 스프링 시큐리티, S3 등등 목적에 맞게 사용하는 기능 함수들은 어떠한 것인가 이런 고민을 많이 하게 된 주간이었습니다. 물론 사정상 프론트와 백 간의 협의 하에 구현하지 못한 기능들이 많았지만 추후 실전 프로젝트에서는 시간을 길게 잡고 구현하지 못한 기능들을 추가해 클론 코딩에서 실제 서비스와 현재 나의 실력 간에 존재하던 괴리를 좁히고 싶습니다. 특히나 고생이 많으셨던 프론트 분들에게 응원의 메세지를 보내고 싶습니다. - 박세열
실전 프로젝트 전 마지막 팀 프로젝트이다 보니 아쉬움이 꽤나 남는다. 알고는 있었지만 API 설계의 중요성을 다시 한번 느꼈다. 급하게 API 를 설계해서인지 코드를 짜는 중간중간 틈이 보였고 그 틈을 급하게 메꿔가며 코드를 짜다 보니 좀 더 효율적이고 보기 좋은 코드에 대한 고민이 부족했던 것 같다. CRUD 기능을 맡게 되면서 CRUD 에 더 익숙해지는 시간을 가질 수 있었고, 프론트 분들이 어떤 부분이 왜 필요한지 자세히 설명해주셔서 어떤 재료를 주는 게 더 편할지에 대한 감이 잡혔다. 한 주 동안 함께 힘내주셨던 8조 팀원들에게 감사드립니다. - 김민지
짧았던 클론코딩이지만, 여러 CSS 속성을 알 수 있었습니다. 시간이 짧아 많은 부분을 공부하진 못했지만 기회가 된다면 좀 더 다듬을 수 있는 시간을 만들 수 있으면 좋겠습니다. 이번에 기능 구현이 많이 늦어져서 많은 기능을 담을 수 없었던 부분에 대해 백엔드 분들께 사과의 말씀 드리고 싶습니다. - 한지용
클론 프로젝트를 하면서 실제 사이트가 얼마나 꼼꼼하고 세밀하게 설계가 되었는지 알 수 있었습니다. 평소에는 그저 이용하기만 했던 사이트를 클론하기 위해 구조를 살피고 만드는 과정에서 사용자가 편리하게 사용할수록 뒤에서 개발자들은 더 일을 많이 해야하는 것이구나! 를 깨달았습니다. 저희가 앞에서 작업할 때 편하게 데이터를 받아서 쓸 수 있도록 같이 열심히 하신 백엔드 분들도 모두 수고하셨습니다! -이가연
실전 프로젝트 전 기본 CRUD 기능에 대해 완벽하게 정리할 수 있었던 시간이었습니다. 실제 서비스가 페이지에 그려지는 것을 잠시나마 경험해보니 프론트 개발자로서 UX 적으로 얼마나 디테일하고 치밀하게 접근해야하는 지를 알게 되었습니다. 뒤에서 든든하게 받쳐주시는 백 분들 덕분에 마음 편히 프론트들은 앞만 보고 달릴 수 있었습니다. 한 주동안 너무 고생많으셨고 감사했습니다! 남은 항해 시간도 크몽이들 순항하세요🛳 - 조해솔