팀 프로젝트 회고록(코드)

박재언·2023년 12월 26일
0

회고록

목록 보기
2/2

데이터 랜덤하게 출력

사용자가 카테고리를 선택하면 해당 카테고리의 질문을 보여주는 로직을 작성 할 때 고민했던 부분이다.

data JPA의 메서드를 이용해서 데이터를 처리하려니
DB에서 해당 카테고리의 데이터를 가져오기만하고 랜덤으로 질문의 갯수를 출력하는 로직을 서비스에서 따로 구현해줘야 한다는 문제점이 있었다.

    @Query(value = "select * " +
            "from question " +
            "where category = :category " +
            "order by RAND() limit 5", nativeQuery = true)
    List<Question> findByCategoryRandom(@Param("category")String category);

그래서 native Query를 이용하여 직접 작성하였다.

하지만 여러 자료를 찾아보면서 이 방법은 데이터 전체를 불러와서 랜덤으로 정렬한뒤 5개를 추출하는 방식이기 때문에 대량의 데이터를 보유 시 성능저하가 발생한다는 문제점을 발견했다.

만약 대량의 데이터를 조회한다면 이런 방식보다는 랜덤한 인덱스를 생성시켜 놓고 해당 인덱스에서 원하는 데이터 만큼 가져오는 방식이 더 효율적이다.


Security

이 프로젝트의 security 흐름은 OAuth2로 Naver Login 후 인증된 사용자의 정보를 이용하여 내부적인 JWT토큰을 생성하여 JWT토큰으로 인가하는 방식으로 진행된다.

naver Token 받기와 유저 정보 가져오기를 통하여 회원가입과 회원 데이터를 관리하는데는 문제가 없다.
그럼 귀찮게 왜 해당 과정을 거치고 나서 다시 내부적으로 JWT 토큰을 발급해서 사용하느냐?
보안 때문이다.
내부적인 JWT토큰은 탈취 당하더라도 애플리케이션의 데이터만 탈취당할 위험이 있지만 naver Token을 탈취 당할시 애플리케이션의 데이터와 naver에 있는 데이터까지 탈취 당할 위험이 있기 때문이다.
여담으로 kakao token을 그대로 클라이언트에게 넘겨주는 방식으로 사용한다면 고소당할 수도 있다고 한다...
그래서 Naver Token은 서버에서만 관리하고 클라이언트에게는 전달되는 토큰은 자체 발급된 토큰만 사용한다.

@Service
public class AuthService {

    @Value("${naver.client.id}")
    private String NAVER_CLIENT_ID;

    @Value("${naver.client.secret}")
    private String NAVER_SECRET;

    @Value("${naver.client.redirect-uri}")
    private String NAVER_REDIRECT_URI;

    @Value("${naver.provider.token-uri}")
    private String NAVER_TOKEN_URI;

    @Value("${naver.provider.user-info-uri}")
    private String NAVER_USER_INFO_URI;

    private final MemberRepository memberRepository;

    private final TokenProvider tokenProvider;

우선 @value를 이용하여 NaverLogin에 필요한 데이터를 yml파일에서 가져온다.
@value를 사용한 이유는 해당 데이터들이 절대 유출되면 안되고
배포 서버와 테스트 서버를 변경할 때마다 데이터 값을 매번 변경하기 보다
변수 이름만 변경하는게 더 낫기 때문에 사용하였다.

 @Transactional
    public NaverTokenDto getNaverAccessToken(String code, String state) {

        HttpHeaders headers = new HttpHeaders();

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", NAVER_CLIENT_ID);
        params.add("client_secret", NAVER_SECRET);
        params.add("code", code); 
        params.add("state", state); 
        params.add("redirect_uri", NAVER_REDIRECT_URI);

        HttpEntity<MultiValueMap<String, String>> naverTokenRequest = new HttpEntity<>(params, headers);

        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> accessTokenResponse = restTemplate.exchange(
                NAVER_TOKEN_URI,
                HttpMethod.POST,
                naverTokenRequest,
                String.class
        );

        // JSON Parsing (-> naverTokenDto)
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        NaverTokenDto naverTokenDto = null;
        try {
            naverTokenDto = objectMapper.readValue(accessTokenResponse.getBody(), NaverTokenDto.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        return naverTokenDto;
    }
HttpHeaders headers = new HttpHeaders();

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();

요청을 보낼 때 Header와 Body를 생성해준다.
Body는 key와 value로 짝지어서 이루어지기 때문에 Map을 이용해야한다.
MultiValueMap을 사용한 이유는 한 key에 여러 value가 존재할 수도 있기 때문에 사용하였다.
참고로 MultiValueMap은 스프링에서 제공하는 인터페이스로 순수 자바에서는 사용할 수 없다.

Naver는 AccessToken을 받기 위해서는 위와 같은 파라미터들이 필요하다

params.add("grant_type", "authorization_code");
params.add("client_id", NAVER_CLIENT_ID);
params.add("client_secret", NAVER_SECRET);
params.add("code", code); 
params.add("state", state); 
params.add("redirect_uri", NAVER_REDIRECT_URI);

HttpEntity<MultiValueMap<String, String>> naverTokenRequest = new HttpEntity<>(params, headers);

인증은 프론트에서 담당하기 때문에 code와 state 값은 프론트에 존재한다.
따라서 매개변수로 code와 state값을 받아와서 params.add를 사용하여 필요한 파라미터를 추가한 뒤 HttpEntity 객체를 생성하여 합친다.

        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> accessTokenResponse = restTemplate.exchange(
                NAVER_TOKEN_URI,
                HttpMethod.POST,
                naverTokenRequest,
                String.class
        );

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        NaverTokenDto naverTokenDto = null;
        try {
            naverTokenDto = objectMapper.readValue(accessTokenResponse.getBody(), NaverTokenDto.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        return naverTokenDto;

로 ResTemplate의 exchange 메서드를 사용하여 HTTP 메서드를 함께 요청을 보내고 응답을 accessTokenResponse에 저장한다.

objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
이 코드는 deserialize 시 알지 못하는 property가 오더라도 실패하지 않도록 처리하는 것이다.
spring boot에서는 Jackson2ObjectMapperFactoryBean 에서 default 값이 false지만 new objectMapper 로 직접 생성한다면
해당 빈을 거치지 않아 true가 default 값이 된다.

따라서 불필요한 데이터들은 누락시키기 위해 해당 코드를 작성하였다.

이후 JSON 데이터를 Java 객체로 deserialization 하기 위해 readValue() 를 사용하였다.
또한 readValue() 는 예외처리가 필수이기 때문에 try-catch 문으로 감싸줬다.


 public NaverUserInfoDto.Response getNaverInfo(String naverAccessToken) {
        RestTemplate restTemplate = new RestTemplate();

        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + naverAccessToken);

        HttpEntity<MultiValueMap<String, String>> accountInfoRequest = new HttpEntity<>(headers);

        ResponseEntity<String> accountInfoResponse = restTemplate.exchange(
                NAVER_USER_INFO_URI,
                HttpMethod.POST,
                accountInfoRequest,
                String.class
        );

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        NaverUserInfoDto naverUserInfoDto = null;
        try {
            naverUserInfoDto = objectMapper.readValue(accountInfoResponse.getBody(), NaverUserInfoDto.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        return naverUserInfoDto.getResponse();
    }

위의 Naver Token 받기 과정과 코드가 매우 유사하다.


naver의 사용자 정보를 가져오기 위해서는 위와 같이 헤더설정이 필요하기 때문에 headers.add("Authorization", "Bearer " + naverAccessToken);
이 추가되었다.


해당 요청에 대해 위와 같은 형식으로 응답이 된다.
그렇다면 response 안에 추가적인 필드가 있어야 하는데 이것을 Map으로 정해 key와 value로 값을 넣어줄지 아니면 response를 클래스로 생성할지가 고민이였다.

나의 선택은 response를 내부 클래스로 생성하는 것이였다.
NaverUserInfDto가 존재해야만 response가 존재할수 있는 관계이기에 클래스를 묶어서 코드의 복잡성을 줄이고 캡슐화를 증가시키려는 목적으로 내부클래스로 작성하였다.

나는 response안에 데이터만 필요하기 때문에naverUserInfoDto.getResponse();로 response 값만 가져왔다.


Loging Service

Naver Token을 이용하여 얻어낸 사용자 정보를 토대로 내부적인 회원가입을 진행하는 코드이다.

public TokenDto naverLogin(String naverAccessToken) {
        NaverUserInfoDto.Response naverInfo = getNaverInfo(naverAccessToken);

        Member existOwner = memberRepository.findByEmail(naverInfo.getEmail()).orElse(null);
        if (existOwner == null) {

            String refreshToken = tokenProvider.createRefreshToken();

            existOwner = Member.builder()
                    .email(naverInfo.getEmail())
                    .name(naverInfo.getName())
                    .refreshToken(refreshToken)
                    .role(Role.USER)
                    .build();
            memberRepository.save(existOwner);
        }

        String accessToken = tokenProvider.createAccessToken(existOwner.getEmail());
        String refreshToken = tokenProvider.createRefreshToken();

        tokenProvider.updateRefreshToken(existOwner.getEmail(), refreshToken);

        return TokenDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

기존에 존재하지 않는 사용자면 DB에 저장해주고 그 외에 경우는 token만 발급한다.
refresh Token은 DB에 저장됨으로 토큰 생성 후 바뀐 토큰으로 update 해주고 토큰 정보를 DTO에 담아서 return 해준다.

    @GetMapping("/login/oauth2/code/naver")
    public ResponseEntity<String> naverLogin(HttpServletRequest request, HttpServletResponse response) {
        String code = request.getParameter("code");
        String state = request.getParameter("state");

        String naverAccessToken = authService.getNaverAccessToken(code, state).getAccess_token();

        TokenDto tokenDto = authService.naverLogin(naverAccessToken);

        tokenProvider.sendAccessAndRefreshToken(response, tokenDto.getAccessToken(), tokenDto.getRefreshToken());

        return ResponseEntity.ok().body("토큰 헤더에 전달");

    }
}

@GetMapping("/login/oauth2/code/naver") 여기서 주소는
CallBack URI로 설정하여 프론트단에서 API를 호출할 필요없이
자동으로 읽어올수 있도록 설계했다.

파라미터 값인 code와 state를 받아와 위에 작성했던 메서드를 실행하고
return 된 토큰 정보를 헤더에 담아서 보낸다.


위의 과정을 통해서 로그인을 진행하고 내부적인 토큰을 발급하여 프론트에 넘겨주는 것까지 완성하였다.
이 다음은 토큰을 다루는 메서드를 모아둔 TokenProviderfilter에 대해서 설명하겠다.

TokenProvider

TokenProvider의 메서드 대해 설명하기 전에 이 메서드를 사용하기 위해서는implementation 'com.auth0:java-jwt:4.3.0'를 추가해줘야한다.

JWT토큰의 개념에 대한 설명은 아래 링크에 정리했다.

https://velog.io/@pju114/JWT%EB%9E%80

public class TokenProvider {

    @Value("${jwt.secretKey}")
    private String secretKey;

    @Value("${jwt.access.expiration}")
    private Long accessTokenEXP;

    @Value("${jwt.refresh.expiration}")
    private Long refreshTokenEXP;

    @Value("${jwt.access.header}")
    private String accessHeader;

    @Value("${jwt.refresh.header}")
    private String refreshHeader;

JWT토큰을 사용하기 위해서는 secretkey, token 유효 기간, 해당 토큰 헤더에 보낼때 필요한 헤더 key가 필요하다.
나는 헤더 키를 보편적으로 사용하는 AuthorizationAuthorization-refresh 로 설정하였다.

    public String createAccessToken(String email) {
        log.info("AccessToken 발급");
        Date now = new Date();
        return JWT.create()
                .withSubject(ACCESS_TOKEN)
                .withExpiresAt(new Date(now.getTime() + accessTokenEXP))

                .withClaim(EMAIL_CLAIM, email)
                .sign(Algorithm.HMAC512(secretKey));
    }

withSubject는 해당 토큰의 이름이고
withExpiresAt는 토큰의 유효시간을 설정한다.
그래서 Date를 통해 현재 시간을 받아오고 거기에 유효기간을 더 해서 유효시간을 설정해줬다.
withClaim은 토큰에 담길 정보로 나는 email만 추가해줬다.
Header와 Payload는 누구나 디코딩할수 있기 때문에 중요한 정보는 담지 않는게 좋다.
sign으로 토큰을 HMAC512 알고리즘으로 secretKey를 암호화 하였다.

    public String createRefreshToken() {
        Date now = new Date();
        return JWT.create()
                .withSubject(REFRESH_TOKEN)
                .withExpiresAt(new Date(now.getTime() + refreshTokenEXP))
                .sign(Algorithm.HMAC512(secretKey));
    }

RefreshTokenAccessToken과 유사하지만 담겨야할 정보가 없음으로 witchClaim은 사용하지 않는다.

    public Optional<String> extractEmail(String accessToken) {
        try {
            return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
                    .build() 
                    .verify(accessToken) 
                    .getClaim(EMAIL_CLAIM)
                    .asString());
        } catch (Exception e) {
            log.error("액세스 토큰이 유효하지 않습니다.");
            return Optional.empty();
        }
    }

token에서 email 정보를 추출하기 위한 메서드이다.
JWT.require(Algorithm.HMAC512(secretKey)
secretKey 를 사용하여 verify Signature를 복호화하는 것이다.
그래서 복호화한 토큰의 유효성을 검증하고 Claim에서 Email 정보를 가져온다.
이 메서드는 토큰을 이용해 유저정보를 SecurityContext에 저장하기 위해서 사용된다.

    public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
        response.setStatus(HttpServletResponse.SC_OK);

        response.setHeader(accessHeader, accessToken);
        response.setHeader(refreshHeader, refreshToken);
       
        log.info("Access Token, Refresh Token 헤더 설정");

    }
   
       public void sendAccessToken(HttpServletResponse response, String accessToken) {
        response.setStatus(HttpServletResponse.SC_OK);

        response.setHeader(accessHeader, accessToken);
        log.info("Access Token 헤더 설정");
    }

발급받은 AccessTokenRefreshToken을 헤더에 설정하는 메서드이다.

    public void updateRefreshToken(String email, String refreshToken) {
        memberRepository.findByEmail(email)
                .ifPresentOrElse(
                        member -> member.updateRefreshToken(refreshToken),
                        () -> new Exception("일치하는 회원이 없습니다.")
                );
    }

생성된 Refresh Token으로 업데이트하는 메서드이다.
memberRepository의 save 메서드를 사용해서 업데이트를 해도 되지만 Member 안에

    public void updateRefreshToken(String updateRefreshToken) {
        this.refreshToken = updateRefreshToken;
    }

메서드를 사용하여서 업데이트를 했다.

    public boolean isTokenValid(String token) {
        try {
            JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
            return true;
        } catch (Exception e) {
            log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
            return false;
        }
    }

Token 검증 로직이다.
extractEmail과 같이 복호화 한 후 verfiy메서드를 통하여 유효성을 검증한다.


JwtAuthFilter로 사용자 인증

    public void saveAuthentication(Member myMember) {
        String password = RandomPasswordCreate.getRandomPassword(10);
        UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
                .username(myMember.getEmail())
                .password(password)
                .roles(myMember.getRole().name())
                .build();

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(userDetailsUser, null,
                        authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

이 프로젝트의 로그인은 Naver만 지원하기 때문에 비밀번호 입력이 필요가 없다.
그래서 비밀번호가 비어있는데 권한을 가진 사용자를 생성하기 위해서는 username, password, role이 필요함으로 password를 랜덤한 글자 10개로 설정한다.

userDetails로 사용자를 생성해준 뒤

new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));

UsernamePasswordAuthenticationToken 구현체를 사용하여 인증된 사용자의 정보를 저장한 Authentication을 생성한다.

이후 생성된 AuthenticationSecurityContextHolder에 보관한다.

AccessToken으로 인증 처리 메서드

    public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
                                                  FilterChain filterChain) throws ServletException, IOException {
        log.info("checkAccessTokenAndAuthentication() 호출");
        tokenProvider.extractAccessToken(request)
                .ifPresent(accessToken -> tokenProvider.extractEmail(accessToken)
                        .ifPresent(email -> memberRepository.findByEmail(email)
                                .ifPresent(myMember -> saveAuthentication(myMember))));

        filterChain.doFilter(request, response);
    }

Token에서 extractAccessToken 로 email정보를 추출한 후 사용자가 DB에 존재한다면 email을 통해 saveAuthentication을 사용하여 권한정보를 등록한다.

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {


        Optional<String> accessToken = tokenProvider.extractAccessToken(request);
        String refreshToken = getRefreshToken(request);


        if(accessToken.isPresent()){
            if(tokenProvider.isTokenValid(accessToken.get())) {
                checkAccessTokenAndAuthentication(request, response, filterChain);
                return;
            }
            else if(!tokenProvider.isTokenValid(accessToken.get()) && refreshToken != null){
                log.info("토큰 재발급");
                    Optional<Member> member = memberRepository.findByRefreshToken(refreshToken);
                    if(member.isPresent()){
                        String recreateAccessToken = tokenProvider.createAccessToken(member.get().getEmail());
                        tokenProvider.sendAccessToken(response, recreateAccessToken);
                    }
                }
            }
        filterChain.doFilter(request, response);
    }

페이지가 이동될때 헤더에서 토큰 정보를 추출하여 인증하는 필터이다.
만약 AccessToken이 만료된 경우에는 쿠키에 있는 RefreshToken을 꺼내와서 AccessToken을 재발급해준다.

AccessToken이 만료됐을 경우에만 프론트에서 RefreshToken을 헤더에 실어서 보내줘야되는데 그럴경우 AccessToken이 만료된 것을 프론트에서 알아야 하기 때문에 추가 작업이 필요하다.
그래서 프론트에서 RefreshToken을 담아서 보내는 것이 아닌 백에서 토큰 검증을 하고 만료시에 자동으로 쿠키에서 꺼내가는 방식으로 작성했다.

쿠키에서 Refresh Token 꺼내기

    public String getRefreshToken(HttpServletRequest request){
        Cookie[] cookies=request.getCookies(); // 모든 쿠키 가져오기
        if(cookies!=null){
            for (Cookie c : cookies) {
                String name = c.getName(); // 쿠키 이름 가져오기
                String value = c.getValue(); // 쿠키 값 가져오기
                if (name.equals("refresh_token")) {
                    return value;
                }
            }
        }
        return null;
    }

배열을 돌면서 refresh_token의 이름을 가진 쿠키를 찾아서
값을 반환한다.


CORS 문제

CORS에 대해 알고 나니 쉽지만 처음 접했을 때 CORS가 무엇인지, 왜 오류가 발생하는지, 어떤 해결법이 있는지 등 이해해야할 것들이 많아서 해당 내용은 따로 작성했다.

https://velog.io/@pju114/CORSCross-Origin-Resource-Sharing

이 문제를 해결하면서 가장 어려웠던 점은 내가 오류 메세지와 오류 발생 상황을 모른다는 것이였다.
CORS 문제를 해결하기위해 코드를 수정하고 난 뒤, 해당 코드가 올바르게 작동하여 CORS 문제가 해결됐는지에 대한 내용을 직접 확인할 수가 없었던 부분이 답답했었다.

글을 쓰는 지금은 cURL을 통해서 백엔드에서도 CORS 설정이 잘작동되는지
확인하는 방법을 알게되었지만 저 당시엔 너무 급해서 테스트 방법까지 찾아볼 여력이 안됐다...

그래서 프론트엔드의 요청사항을 들으면서 계속 수정하고 재배포를 하며 해결했는데 알고있는 개념도 없이 코드로만 해결하려다보니 여러 번 작업하게 되었다.
지금 와서 생각해보면 시간이 촉박하더라도 문서를 접하고 난 뒤에 코드를 작성하는게 오히려 시간을 단축하지 않았을까 싶다.

해결 과정

나는 처음에 WebMvcConfigurer를 사용하여 CORS 설정을 해주었다.

	@Bean
	public WebMvcConfigurer corsConfigurer() {
		return new WebMvcConfigurer() {
			@Override
			public void addCorsMappings(CorsRegistry registry) {
				registry.addMapping("/**")
						.allowedOrigins("앱 도메인", "https://localhost:3000")
						.allowedMethods("*")
						.allowCredentials(true);

			}
		};
	}

하지만 응답 헤더에 Authorization token들이 넘어오지 않았다.
이후 .allowedHeaders를 추가해줬는데도 인증헤더가 포함되지 않았다.

그래서 이것에 대한 해결방법을 찾던 중 Credentials을 포함하려면 Origin*(와일드카드)를 사용하면 안된다는 것을 알았고
Spring Security를 사용한다면 Security Config 에서 CORS를 해결할 수 있다는 것을 알았다.

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("https://앱 도메인", "https://localhost:3000"));
        configuration.addAllowedMethod("*");
        configuration.addAllowedHeader("*");
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

그래서 위와 같이 Security Config에 CORS를 설정해주면서 헤더도 허용해줬는데 이번엔 CORS 문제가 아예 해결되지 않았다.
그 이유는 Bean으로 등록만 했지 configure에 추가하지 않아서 발생한 문제였다. .cors().configurationSource(corsConfigurationSource())
해당 코드를 추가하고 나서 CORS가 해결이 된줄 알았는데 여전히 토큰이 전달되지 않았다.

개발자 도구를 통해서는 토큰이 전달되는것을 확인했는데 프론트엔드에는 토큰이 전달되지 않는다는 것이다.

그래서 열심히 찾아보니 그 이유는 해당 헤더가 클라이언트의 접근이 허용되지 않았기 때문에 전달이 되지 않은거라고 한다.

        configuration.addExposedHeader("Authorization");
        configuration.addExposedHeader("Authorization-Refresh");

해당 코드를 추가하여 클라이언트가 token이 들어가 있는 헤더들을 받을 수 있도록 하였다.

이렇게 CORS를 문제를 해결하였다.


후기

작성한 코드에 대한 리뷰가 끝났다.
물론 더 많은 코드가 있지만 고민과 성장에 영향을 준 코드만 작성했다.
밤을 새며 코드를 작성하다 보니 어이없는 실수도 많이 했는데 트러블 슈팅을 하면서 기본을 더 다질 수 있는 기회도 되어 값진 경험이였다.
또한 회고록을 작성하기 위해 코드 전체를 살펴봤는데 처음에 구현할 때는 발견하지 못했던 미흡한 부분들을 발견할 수 있어 내가 작성한 코드들을 되돌아보며 진정으로 회고할 수 있었다.
이후 데이터의 정규화와 데이터 조회 성능을 향상 시키는 리팩토링을 하려고 한다.

0개의 댓글