주차별 미션
1주차
2주차
3주차
4주차
5주차
개발 환경
•
OS : macOS Big Sur 11.5
•
IDE : IntelliJ
•
Build Tool : Gradle
•
데이터베이스 접근 툴 : DataGrip
기술 스택
•
Java - JDK 11
•
Spring Boot 2.6.4
•
Spring Data JPA 2.6.2
•
Spring Security 5.6.2
•
Spring Cloud 3.1.0
•
Maria DB - Docker Container
•
Thymeleaf
•
Swagger
•
Docker
•
AWS EC2
패키지 구조 설계
•
project-lion-web 패키지 구조
└── src
├── main
│ ├── java
│ │ └── com
│ │ └── shop
│ │ └── projectlion
│ │ ├── domain
│ │ │ └── base
│ │ │ └── member
│ │ │ └── model
│ │ │ └── enumclass
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── item
│ │ │ └── model
│ │ │ └── enumclass
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── itemimage
│ │ │ └── model
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── delivery
│ │ │ └── model
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── order
│ │ │ └── model
│ │ │ └── enumclass
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── orderitem
│ │ │ └── model
│ │ │ └── repository
│ │ │ └── service
│ │ ├── global
│ │ │ └── component
│ │ │ └── error
│ │ │ └── exception
│ │ │ └── config
│ │ ├── infra
│ │ └── web
│ │ │ └── main
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
│ │ │ └── adminitem
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
│ │ │ └── login
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
│ │ │ └── itemdtl
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
│ │ │ └── orderhist
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
Plain Text
복사
•
project-lion-api 패키지 구조
└── src
├── main
│ ├── java
│ │ └── com
│ │ └── shop
│ │ └── projectlion
│ │ ├── api
│ │ │ └── health
│ │ │ └── client
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
│ │ │ └── login
│ │ │ └── client
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
│ │ │ └── logout
│ │ │ └── controller
│ │ │ └── service
│ │ │ └── adminitem
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
│ │ │ └── token
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
│ │ ├── domain
│ │ │ └── common
│ │ │ └── jwt
│ │ │ └── constant
│ │ │ └── dto
│ │ │ └── service
│ │ │ └── member
│ │ │ └── constant
│ │ │ └── entity
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── exception
│ │ │ └── item
│ │ │ └── model
│ │ │ └── enumclass
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── itemimage
│ │ │ └── model
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── delivery
│ │ │ └── model
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── order
│ │ │ └── model
│ │ │ └── enumclass
│ │ │ └── repository
│ │ │ └── service
│ │ │ └── orderitem
│ │ │ └── model
│ │ │ └── repository
│ │ │ └── service
│ │ ├── global
│ │ │ └── error
│ │ │ └── config
│ │ │ └── resolver
│ │ │ └── util
│ │ ├── infra
│ │ └── web
│ │ │ └── kakaotoken
│ │ │ └── client
│ │ │ └── controller
│ │ │ └── dto
│ │ │ └── service
Plain Text
복사
주요 개발 내용 및 리팩토링
•
도메인 객체 및 DTO 생성 시 정적 팩토리 메서드 사용
◦
Builder 패턴 사용
public class MemberService {
...
@Transactional
public void register(MemberRegisterDto request, PasswordEncoder passwordEncoder) throws BusinessException{
...
Member member = Member.builder()
.memberName(request.getName())
.email(request.getEmail())
.password(request.getPassword())
.memberType("GENERAL")
.role(MemberRole.ADMIN)
.refreshToken(null)
.tokenExpirationTime(null)
.build();
memberRepository.save(member);
}
...
}
Java
복사
◦
정적 팩토리 메서드 사용
public class Member {
...
private Member(String memberName, String email, String password) {
this.memberName = memberName;
this.email = email;
this.password = password;
this.memberType = MemberType.GENERAL;
this.role = MemberRole.ADMIN;
this.refreshToken = null;
this.tokenExpirationTime = null;
}
public static Member of(String memberName, String email, String password) {
return new Member(memberName, email, password);
}
...
}
...
public class MemberService {
...
@Transactional
public void register(MemberRegisterDto request, PasswordEncoder passwordEncoder) throws BusinessException{
...
memberRepository.save(
Member.of(
request.getName(),
request.getEmail(),
passwordEncoder.encode(request.getPassword())
));
}
...
}
Java
복사
◦
DTO 클래스로 변환 시에도 정적 팩토리 메서드 사용
public class DeliveryDto {
private Long deliveryId;
private String deliveryName;
private int deliveryFee;
public static DeliveryDto fromEntity(Delivery delivery) {
return DeliveryDto.builder()
.deliveryId(delivery.getDeliveryId())
.deliveryName(delivery.getDeliveryName())
.deliveryFee(delivery.getDeliveryFee())
.build();
}
}
Java
복사
•
Spring Data JPA 사용 시 변경 감지 기능(Dirty Checking)과 영속성을 위해 엔티티에는 setter를 개방하지 않고, 수정이 필요할 경우 의미 있는 이름의 메서드를 구현하여 사용
@Entity
...
public class Item extends BaseEntity {
...
public void increaseStock(Integer orderStock) {
this.stockNumber += orderStock;
if (!isSoldOut()) {
updateItemSellStatus(SELL);
}
}
public void updateItemSellStatus(ItemSellStatus itemSellStatus) {
this.itemSellStatus = itemSellStatus;
}
...
}
Java
복사
•
상품 등록 / 수정 시 화면에 출력해줄 배송 정보 Model 을 @ModelAttribute를 통해 공통 적용
public class AdminItemController {
...
@ModelAttribute("deliveryDtos")
public List<DeliveryDto> deliveryDtos(@AuthenticationPrincipal CustomUserDetails userDetails) {
Member member = userDetails.getMember();
List<DeliveryDto> deliveryDtos = adminItemService.findDeliveryByMember(member);
return deliveryDtos;
}
...
}
Java
복사
•
JPA를 사용할 때 단점으로 복잡한 쿼리의 경우 SQL문을 직접 작성하는게 좋을 때도 있다. 이를 보완하기 위해 QueryDSL과 JPQL을 사용 → JPQL의 조건을 수정하여 하나의 메서드로 구현
◦
변경 전
@Query("select i from Item i where i.itemSellStatus='SELL'")
Page<Item> findAllWithPagination(Pageable pageable);
@Query("select i from Item i " +
"where (i.itemName like CONCAT('%',:keyword,'%') " +
"OR i.itemDetail like CONCAT('%',:keyword,'%'))" +
"AND i.itemSellStatus='SELL'")
Page<Item> findBySearchKeyword(Pageable pageable,@Param("keyword") String keyword);
Java
복사
◦
변경 후
@Query("select i from Item i " +
"where (:keyword is null " +
"OR i.itemName like CONCAT('%',:keyword,'%') " +
"OR i.itemDetail like CONCAT('%',:keyword,'%'))" +
"AND i.itemSellStatus='SELL'")
Page<Item> findBySearchKeyword(Pageable pageable,@Param("keyword") String keyword);
Java
복사
•
주문 이력 조회 시 Stream 사용하여 필요 정보 DTO로 변환
→ 복잡성과 호출되는 쿼리가 많을 것으로 예상되어 fetch join 적용 필요
public class OrderHistService {
...
public Page<OrderHistDto> getOrderHistDto(Member member, Pageable pageable) {
Page<Orders> ordersPages = orderService.findAllByMember(pageable, member);
long totalElements = ordersPages.getTotalElements();
List<Orders> ordersList = ordersPages.getContent();
// todo fetch join을 사용하여 주문 정보를 가져올 때 필요한 정보 영속화하여 사용
List<OrderHistDto> orderHistDtos = ordersList.stream()
.map(orders -> {
List<OrderItem> orderItemList = orders.getOrderItemList();
List<Delivery> deliveryList = orderItemList.stream()
.map(orderItem -> {
Item findItem = orderItem.getItem();
return findItem.getDelivery();
})
.collect(Collectors.toList());
List<OrderHistDto.OrderItemHistDto> orderItemHistDtos = orderItemList.stream()
.map(orderItem -> {
Item findItem = orderItem.getItem();
return OrderHistDto.OrderItemHistDto.from(orderItem, findItem);
})
.collect(Collectors.toList());
OrderHistDto orderHistDto = OrderHistDto.from(orders, deliveryList);
orderHistDto.setOrderItemHistDtos(orderItemHistDtos);
return orderHistDto;
})
.collect(Collectors.toList());
return new PageImpl<>(orderHistDtos, pageable, totalElements);
}
...
}
Java
복사
•
Spring Security 관련 설정
◦
접근 URL 별 권한 설정 및 default username email로 변경
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception{
http
.authorizeRequests()
.antMatchers("/","/login","/register").permitAll()
.antMatchers("/orderhist","/admin/items/*").hasAnyAuthority("ADMIN")
;
http
.formLogin()
.usernameParameter("email")
.passwordParameter("password")
.loginPage("/login")
.failureHandler(failureHandler)
.defaultSuccessUrl("/")
.and()
.logout()
.logoutSuccessUrl("/")
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.invalidateHttpSession(true)
;
}
...
}
Java
복사
◦
회원가입 및 로그인 시 필요한 PasswordEncoder 객체를 매번 생성하지 않고 Bean으로 등록
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
...
// 회원가입 및 로그인 시 필요한 PasswordEncoder Bean으로 등록
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
...
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
}
Java
복사
•
Header Authorization 검증을 위한 Validator 구현
→ 해당 로직이 컨트롤러마다 중복되어 있었기에 별도의 Validator를 구현하여 중복 제거
@Component
public class HeaderValidator {
public void check(String authorization) {
if (authorization.isEmpty()) {
throw new BusinessException(NOT_EXISTS_AUTHORIZATION);
} else if (!"Bearer".equals(authorization.split(" ")[0])) {
throw new BusinessException(NOT_VALID_BEARER_GRANT_TYPE);
}
}
}
Java
복사
•
카카오로부터 사용자 정보 가져올 때 RestTemplate 에서 FeignClient 로 변경
◦
RestTemplate 사용했을 때
private KakaoUserInfo getUserInfo(HttpHeaders requestHeaders) throws BusinessException {
HttpEntity<MultiValueMap<String, String>> kakaoProfileRequest = new HttpEntity<>(requestHeaders);
String oauthRequestUrl = "https://kapi.kakao.com/v2/user/me";
return restTemplate.postForObject(oauthRequestUrl, kakaoProfileRequest, KakaoUserInfo.class);
}
...
KakaoUserInfo response = getUserInfo(header);
Java
복사
◦
FeignClient 사용했을 때
@FeignClient(url = "https://kapi.kakao.com", name = "kakaologinFeignClient")
public interface LoginFeignClient {
@PostMapping(value = "/v2/user/me")
KakaoUserInfo getUserInfo(@RequestHeader HttpHeaders headers);
}
...
KakaoUserInfo response = loginFeignClient.getUserInfo(header);
Java
복사
•
카카오 Access Token 받아올 때 RestTemplate 에서 FeignClient 로 변경
→ FeignClient 사용하여 여러 개의 파라미터를 전달할 때 @SpringQueryMap 사용하여 dto 전달
◦
RestTemplate 사용했을 때
@RequiredArgsConstructor
public class KakaoTokenService {
private final RestTemplate restTemplate;
public KakaoTokenResponseDto getKakaoTokenDto(String code, String clientId, String clientSecret) {
RestTemplate restTemplate = new RestTemplate();
MultiValueMap<String, String> kakaoTokenRequest = getBody(code, clientId, clientSecret);
String oauthRequestUrl = "https://kauth.kakao.com/oauth/token";
return restTemplate.postForObject(oauthRequestUrl, kakaoTokenRequest, KakaoTokenResponseDto.class);
}
public MultiValueMap<String, String> getBody(String code, String clientId, String clientSecret) {
String redirectUrl = "http://localhost:8080/auth/kakao/callback";
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("redirect_uri", redirectUrl);
body.add("code", code);
body.add("client_secret", clientSecret);
return body;
}
}
Java
복사
◦
FeignClient 사용했을 때
@FeignClient(name = "kakaoFeignClient", url = "https://kauth.kakao.com")
public interface KakaoFeignClient {
@PostMapping(value = "/oauth/token", consumes = "application/x-www-form-urlencoded;charset=utf-8")
KakaoTokenResponseDto getKakaoToken(MultiValueMap<String, String> request);
}
...
public KakaoTokenResponseDto getKakaoTokenDto(String code, String clientId, String clientSecret) {
MultiValueMap<String, String> kakaoTokenRequest = getBody(code, clientId, clientSecret);
return kakaoFeignClient.getKakaoToken(kakaoTokenRequest);
}
public MultiValueMap<String, String> getBody(String code, String clientId, String clientSecret) {
String redirectUrl = "http://localhost:8080/auth/kakao/callback";
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("redirect_uri", redirectUrl);
body.add("code", code);
body.add("client_secret", clientSecret);
return body;
}
Java
복사
◦
@SpringQueryMap 사용하여 dto 전달할 수 있도록 변경 후
@FeignClient(name = "kakaoFeignClient", url = "https://kauth.kakao.com")
public interface KakaoFeignClient {
@PostMapping(value = "/oauth/token", consumes = "application/x-www-form-urlencoded;charset=utf-8")
KakaoTokenResponseDto getKakaoToken(@SpringQueryMap KakaoTokenRequestDto request);
}
...
public KakaoTokenResponseDto getKakaoTokenDto(String code, String clientId, String clientSecret) {
KakaoTokenRequestDto kakaoTokenRequest = KakaoTokenRequestDto.of(code, clientId, clientSecret);
return kakaoFeignClient.getKakaoToken(kakaoTokenRequest);
}
Java
복사
•
인증 / 인가 검증 기능을 위한 Interceptor 적용 설정
public class WebConfig implements WebMvcConfigurer {
...
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(requestHeaderInterceptor)
.order(1)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/health/**","/api/oauth/login","/api/token","/api/logout");
registry.addInterceptor(requestRoleInterceptor)
.order(2)
.addPathPatterns("/api/admin/**")
.excludePathPatterns();
}
...
}
Java
복사
•
인증 / 인가 검증을 위한 Interceptor 구현
◦
인증 검증을 위한 AuthenticationInterceptor 구현
@Component
@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {
private final TokenManager tokenManager;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws BusinessException {
String authorization = request.getHeader("Authorization");
if (isNull(authorization)) {
throw new AuthorizationException(NOT_EXISTS_AUTHORIZATION); // 비었을 때
} else if (!"Bearer".equals(authorization.split(" ")[0])) {
throw new AuthorizationException(NOT_VALID_BEARER_GRANT_TYPE); // Bearer이 아닐때
}
String accessToken = authorization.split(" ")[1];
if (!tokenManager.validateToken(accessToken)) {
throw new AuthorizationException(NOT_VALID_TOKEN); // 유효하지 않은 토큰
}
Claims claims = tokenManager.getTokenClaims(accessToken);
Date expireTime = claims.getExpiration();
String tokenType = claims.getSubject();
if (!TokenType.isAccessToken(tokenType)) {
throw new AuthorizationException(NOT_ACCESS_TOKEN_TYPE); // access 토큰이 아닐 때
}
if (tokenManager.isTokenExpired(expireTime)) {
throw new AuthorizationException(ACCESS_TOKEN_EXPIRED); // access 토큰 만료
}
return true;
}
private boolean isNull(String authorization) {
return authorization == null || authorization.isEmpty();
}
}
Java
복사
◦
인가 검증을 위한 AdminAuthorizationInterceptor 구현
@Component
@RequiredArgsConstructor
public class AdminAuthorizationInterceptor implements HandlerInterceptor {
private final TokenManager tokenManager;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws BusinessException {
// todo 인증 인터셉터에서 빈 값이나 널 값을 검증하고 있는데, 여기서도 한 번 더 검증을 해주는 것이 좋은가.
String accessToken = request.getHeader("Authorization").split(" ")[1];
Claims claims = tokenManager.getTokenClaims(accessToken);
String tokenRole = (String) claims.get("role");
if (Role.isAdmin(tokenRole)) {
return true;
}
throw new AuthorizationException(NOT_ADMIN_ROLE_EXISTS);
}
}
Java
복사
개발 간 발생한 이슈
•
Spring Security를 사용하여 인증/인가 부분을 구현하면 회원가입 로직 구현 시 입력받은 데이터 DTO를 DB에 저장할 때 비밀번호를 암호화 해줘야 된다는 사실을 몰라서 해맸습니다. 분명 DB에는 정상적으로 데이터가 저장되어 있는데 로그인을 시도하면 계속해서 실패 했었습니다. 이후 관련 자료를 찾아보던 중 Spring Security를 사용하면 비밀번호는 꼭 Encoding 해줘야한다는 자료를 찾아 해결했습니다.
•
데이터 검증을 Front-End 쪽에서 하지않고 DTO에 대해서 검증을 해줘야 하는 것을 처음에 잘못 접근하여 Entity에 Validation 관련 애노테이션을 설정 했었습니다. 다른 코드는 문제없이 돌아갔는데, 비밀번호의 경우 암호화를 하게되니 16자가 초과되어 정상적으로 데이터 저장이 안되었습니다. 이후 입력된 데이터 검증에 대한 에러 메세지를 DTO에서 처리하도록 수정하여 해결했습니다.
•
각 주차별 미션들에서 html 파일에 주어진 css를 별도로 모아서 fragment를 이용해 모듈화 해두었습니다. 그런데 css 클래스 이름이나 속성에서 중복이 일어나 웹 페이지가 깨지는 현상이 발생해 이를 해결하는데 시간을 조금 소요했습니다.
•
주문 하기 기능 구현 시 ajax로 비동기 통신을 해야하는데 뷰에서 전달되는 Dto의 일부 값이 자꾸 null로 넘어오는 이슈가 발생했었습니다. Dto의 생성자 혹은 Builder를 만들어줘야 서버로 요청이 왔을때 해당 객체가 생성된다는 점을 발견하고 해결했습니다. 사소한 어노테이션 하나라도 발견하지 못하면 진행을 할 수가 없기에 꼼꼼하게 코딩을 해야겠다고 생각했습니다.
개발한 쇼핑몰 결과물
•
Web 서비스
◦
메인 화면(상품 조회)
◦
로그인
◦
회원가입
◦
상품 등록
◦
상품 검색
◦
상품 상세 조회
◦
상품 수정
◦
주문 하기
◦
주문 이력
◦
주문 취소
•
API 서버
◦
카카오 로그인
◦
로그인 / 회원가입
◦
토큰 재발급
◦
로그아웃
◦
상품 조회
◦
상품 수정
◦
인증 / 인가 검증
▪
Header 가 빈 값
▪
유효하지 않은 토큰
▪
인증 타입 Bearer 아닐 때
▪
Access Token이 아닐 때
▪
Access Token 만료
▪
관리자 권한이 아닐 때
◦
Swagger를 사용한 API 문서화
◦
CI/CD
▪
develop 브랜치에서 push
▪
git Actions
▪
AWS EC2 Server
▪
docker hub
▪
API 서버 health check
회고
•
전체적으로 정말 만족스러웠고 많이 배웠던 시간이었습니다. 실무에 계신 강사님께서 해주시는 1:1 코드 리뷰와 각 조원들끼리 진행하는 그룹 피어리뷰 덕에 나쁜 코딩 습관이나 무심코 지나칠 수 있었던 에러를 수정할 수 있었습니다. 개인적으로 조금 아쉽다고 생각되는 부분은 직업이 장교였다보니 해당 Externship을 진행할 때 군부대라는 외부망과 단절되고 보안이 철저한 곳에서 근무했기 때문에 실질적으로 작업을 할 수 있는 시간과 환경에서 아쉬운 부분이 있었습니다.
•
프로젝트를 시작하고는 주어진 프로젝트의 패키지 등의 구조를 살펴보며, 이후에는 주어진 미션의 요구사항을 순차적으로 하나씩 확인해가며 개발을 진행했습니다. 주어진 미션 요구사항을 모두 만족한 후에는 코드를 리펙토링 하면서 코드의 중복은 없는지, 필드의 네이밍은 괜찮은지, 예외 처리는 되어있는지 확인하며 코드를 수정하려고 노력했습니다. 또한 도메인 계층과 웹 계층, API 계층 간의 책임 분리에 신경썼습니다. 해설 강의 중에 강사님이 말씀해주신 내용으로 'web', ‘api’ 하위 모든 파일을 삭제하고 나서도 코드 컴파일이 정상적으로 된다면 Dto나 web, api 쪽에 의존하고 있지 않다는 내용에 부합하려고 의존하지 않도록 노력했습니다.
•
Spring Security를 처음 사용해보기 때문에 기초부터 천천히 공부하기 위해 노력했습니다. 어떻게보면 간단한 로그인/로그아웃 이지만 WebSecurityConfig 클래스의 각 설정들이 무엇을 의미하는지 체크하면서 구현했습니다.
•
평소 Spring Boot를 이용한 API 서버 개발만 경험을 해왔었는데 View 부분을 함께 구현하기 위해서 Thymeleaf의 기본 사용법이나 Controller의 반환값인 경로가 어떻게 찾아가는지를 찾아봤습니다. 또한 Thymeleaf Layout Dialect, Thymeleaf fragment를 이용한 공통 레이아웃 생성으로 .html 코드의 중복을 줄일 수 있었습니다.
•
API 서버 개발을 위한 새로운 프로젝트로 시작하는 주차에서는 웹 서비스와는 다른 패키지 구조 파악과 기존에 사용한 클래스를 어떻게 재사용 할 수 있을지에 초점을 뒀습니다. 그리고 무엇보다 소셜 서비스와의 로그인 연동을 처음해보는거라 OAUTH와 JWT에 대한 지식을 쌓는 것을 주력으로 진행 했습니다. 처음 카카오 측으로 API 요청을 보낼 때 RestTemplate를 사용해서 구현 했었는데 추후에 FeignClient를 알게되서 해당 부분 로직을 리팩토링 해보았습니다. RestTemplate를 사용할 때보다 비교적 코드가 단순해지고 깔끔하게 사용할 수 있었습니다.
•
이번 미션에서는 카카오만 로그인을 하지만 우리가 시중에서 사용하는 서비스들은 구글, 네이버, 페이스북 등 다른 SNS와도 사용자 인증 연동이 되어있기에 해당 부분을 추상화하여 다형성을 만족시키려고 하였으나 시간이 조금 부족했던 것 같습니다.
•
5주차 미션에서 API 구현을 제외한 부분들은 경험이 없던 부분들이 었어서 굉장히 흥미가 있었습니다. API 문서 자동화는 Swagger를 사용했고, AWS 사용 방법에도 굉장히 관심이 많았는데 사용 해보는 것 뿐만 아니라 GitAction과 함께 사용하여 push 만으로 자동으로 CI/CD 자동화 Pipeline을 만들어 놓은 것이 아주 좋았던 부분이었습니다.