[CodeSquad] Issue-Tracker 프로젝트 회고

naneun·2022년 7월 9일
5

CodeSquad

목록 보기
4/5
post-thumbnail

저장소: https://github.com/naneun/issue-tracker

프로젝트 개요

  • 프로젝트 주제 : 이슈 관리 프로젝트

  • 프로젝트 기간 : 6/13 ~ 7/1

  • 팀원 :

    • Android - @히데, @Jung Park
    • BE - @BC, @Riako
  • 안드로이드 앱 데모




  • BE 사용 기술:

    • Java, Mysql, Redis, Spring Boot, Junit5, Swagger, Github, Amazon AWS, Git Action, Docker
  • Swagger API 문서




프로젝트 회고

  • 클라이언트와의 호흡

    간단한 방식으로 프로젝트를 진행해서인 지 마지막 전체 공유 시간에 안드로이드 팀이 시연하기 전까지 모든 기능을 완료했다! 물론 당일 오전 내내 로그인 및 이슈 수정, 일괄 삭제 등과 같은 기능들을 클라이언트와 다시 한 번 점검하면서 발생하는 에러들을 수정하느라 정신이 없었지만.. 😅 무엇보다 3주 간 오전 회의를 하루도 빠짐 없이 참여하여 클라이언트와의 호흡을 맞춰갈 수 있었다!

    다음과 같이 @BC 와 함께 기존 기획서로 요구사항을 분석하며 필요한 내용들을 작성해놨더니 오전 회의 시간에 단순히 시간을 채우는 것이 아닌 밀도 있는 소통을 할 수 있어서 좋았다. 나는 특히 안드로이드 팀에서 이슈 관련 기능을 담당한 @히데 와 대부분의 기능들이 겹쳐서 요구사항 분석 이후에도 특히 많은 의견을 나눌 수 있었다. 로그인 처리 방식 Jwt 토큰 갱신 로직, 이미지 업로드 방식 및 누락된 필드 추가 등 하루에 한 두 개씩 꾸준히 의견을 주고 받았더니 크게 놓치는 부분이 없이 진행될 수 있었다.

  • 백엔드 팀원과의 호흡

    기존 TODO List, Sidedish, AirBnb 프로젝트에선 다음과 같은 순서로 프로젝트를 진행했었다.

    요구사항 분석 -> 테이블 설계 -> 도메인 설계 -> 컨트롤러 클래스 작성 -> Swagger 로 API 문서 자동화 -> 레포지토리 클래스 작성 -> 서비스 클래스 작성 -> 예외 처리 및 유효성 검사 -> 테스트 코드 작성

    세 번의 프로젝트 모두 같은 방식으로 진행하다보니 마지막 프로젝트는 조금 색다르게 진행해보고 싶었다. 그 동안 프로젝트를 진행하면서 TODO List 를 제외하곤 테스트 코드를 제대로 작성하지 않았었다. 지속적으로 변경되는 설계로 인해 테스트 코드 또한 수정해야했고 2주, 3주라는 짧은 기간 동안 구현해야할 기능들이 많았기에 테스트 코드 작성을 소홀히 했었다. 팀원 @BC 의 동의를 얻어 요구사항 분석을 바탕으로 테스트 코드를 먼저 작성하고 그에 맞춰 설계를 진행해보기로 했다.

    요구사항 분석 -> 레포지토리 테스트 코드 작성 -> 도메인 설계 -> 레포지토리 클래스 작성 -> 서비스 테스트 코드 작성 -> 서비스 클래스 작성 -> 컨트롤러 클래스 작성 -> Swagger 로 API 문서 자동화 -> 예외 처리 및 유효성 검사

    이러한 순서로 프로젝트를 진행하기로 하고,

    https://github.com/naneun/issue-tracker/issues/10
    https://github.com/naneun/issue-tracker/issues/16
    https://github.com/naneun/issue-tracker/issues/22

    위와 같이 이슈를 등록해가며 순차적으로 처리했다.

        /**
         * @implNote - 클라이언트가 닫기 버튼을 클릭하면 이슈가 닫히고, 실행 취소를 하면 이슈가 다시 열린다. - '해당하는 ID를 가진 이슈를 닫는다' ->
         * '해당하는 ID를 가진 이슈의 상태를 변경한다' 로 수정한다. - 2022/06/15
         */
        @Test
        void 해당하는_ID를_가진_이슈의_상태를_변경한다() {
    
            // given
            Long id = 1L;
            Issue foundIssue = issueRepository.findById(id)
                .orElseThrow(IssueException::new);
    
            foundIssue.changeState();
    
            // when
            Issue changedIssue = issueRepository.save(foundIssue);
    
            // then
            assertAll(
                () -> assertThat(changedIssue.getState()).isEqualTo(CLOSE),
                () -> assertThat(changedIssue).usingRecursiveComparison().isEqualTo(foundIssue)
            );
        }

    테스트 코드 메서드 위에 요구사항을 분석하며 생각한 내용들을 javaDoc 주석으로 작성해가며 테스트 코드를 작성하다보니 요구사항을 좀 더 세밀하게 분석할 수 있었다.

  • 새롭게 시도해본 내용

    테스트 코드 단순화

    // data.sql
    
    /* emoji */
    insert into emoji(unicode, description)
    values ('❤', '좋아요'),
           ('👍', '최고에요'),
           ('👎', '싫어요'),
           ('✅', '확인했어요');

    data.sql 을 사용하여 테스트 시 사용할 데이터를 추가하고,

    static List<Emoji> registeredEmojis = List.of(
        Emoji.of(1L, "❤", "좋아요"),
        Emoji.of(2L, "👍", "최고에요"),
        Emoji.of(3L, "👎", "싫어요"),
        Emoji.of(4L, "✅", "확인했어요")
    );

    테스트 클래스 정적 변수로 동일한 데이터를 생성한 뒤,

    @Test
    void 등록된_모든_이모지를_조회한다() {
    
        // given
    
        // when
        List<Emoji> emojis = emojiRepository.findAll();
    
        // then
        emojis.forEach((emoji) -> assertThat(emoji)
                .usingRecursiveComparison()
                .isEqualTo(registeredEmojis.get(emojis.indexOf(emoji))));
    }

    forEach(), usingRecursiveComparison(), indexOf() 메서드의 조합으로 다음과 같이 간결하게 테스트 코드를 작성하여 설계가 변경되더라도 유연하게 대처할 수 있도록 했다.

    EmojiRepositoryTest Class

    
    @Import(DataJpaConfig.class)
    @DataJpaTest
    @AutoConfigureTestDatabase(replace = Replace.NONE)
    class EmojiRepositoryTest {
    
        final EmojiRepository emojiRepository;
    
        static List<Emoji> registeredEmojis = List.of(
            Emoji.of(1L, "❤", "좋아요"),
            Emoji.of(2L, "👍", "최고에요"),
            Emoji.of(3L, "👎", "싫어요"),
            Emoji.of(4L, "✅", "확인했어요")
        );
    
        @Autowired
        EmojiRepositoryTest(EmojiRepository emojiRepository) {
            this.emojiRepository = emojiRepository;
        }
    
        @Test
        void 등록된_모든_이모지를_조회한다() {
    
            // given
    
            // when
            List<Emoji> emojis = emojiRepository.findAll();
    
            // then
            emojis.forEach((emoji) -> assertThat(emoji)
                .usingRecursiveComparison()
                .isEqualTo(registeredEmojis.get(emojis.indexOf(emoji))));
        }
    }

    ✔ usingRecursiveComparison() 메서드 사용 시 제외 필드 설정

        @Test
        void 오픈된_모든_이슈를_조회한다() {
    
            // given
    
            // when
            List<Issue> foundOpenedIssues = issueRepository.findByState(OPEN);
    
            // then
            foundOpenedIssues.forEach(issue -> {
                Issue comparisonTarget = registeredOpenedIssues.get(foundOpenedIssues.indexOf(issue));
                assertThat(issue).usingRecursiveComparison()
                    .comparingOnlyFields()
                    .ignoringFields("comments")
                    .ignoringFields("milestone.issues")
                    .isEqualTo(comparisonTarget);
            });
        }
    .comparingOnlyFields()
                    .ignoringFields("comments")
                    .ignoringFields("milestone.issues")

    또한 JPA Entity 의 경우 fetch 전략이 LAZY or EAGER 에 따라 의도한 대로 테스트가 진행이 되지 않는 경우가 있었다. 따라서, 연관 관계를 맺고 있는 참조 객체 중 동등성 비교에서 제외할 필드들을 지정하여 테스트 메서드 실행 시에 컨트롤할 수 있었다.

    Request Scope Bean 과 Spring Data Auditing 기능 연계

    public class OpenEntityManagerInViewInterceptor extends EntityManagerFactoryAccessor implements AsyncWebRequestInterceptor {
    
        /**
         * Suffix that gets appended to the EntityManagerFactory toString
         * representation for the "participate in existing entity manager
         * handling" request attribute.
         * @see #getParticipateAttributeName
         */
        public static final String PARTICIPATE_SUFFIX = ".PARTICIPATE";
    
        @Override
        public void preHandle(WebRequest request) throws DataAccessException {
            String key = getParticipateAttributeName();
            WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
            if (asyncManager.hasConcurrentResult() && applyEntityManagerBindingInterceptor(asyncManager, key)) {
                return;
            }
    
            EntityManagerFactory emf = obtainEntityManagerFactory();
            if (TransactionSynchronizationManager.hasResource(emf)) {
                // Do not modify the EntityManager: just mark the request accordingly.
                Integer count = (Integer) request.getAttribute(key, WebRequest.SCOPE_REQUEST);
                int newCount = (count != null ? count + 1 : 1);
                request.setAttribute(getParticipateAttributeName(), newCount, WebRequest.SCOPE_REQUEST);
            }
            else {
                logger.debug("Opening JPA EntityManager in OpenEntityManagerInViewInterceptor");
                try {
                    EntityManager em = createEntityManager();
                    EntityManagerHolder emHolder = new EntityManagerHolder(em);
                    TransactionSynchronizationManager.bindResource(emf, emHolder);
    
                    AsyncRequestInterceptor interceptor = new AsyncRequestInterceptor(emf, emHolder);
                    asyncManager.registerCallableInterceptor(key, interceptor);
                    asyncManager.registerDeferredResultInterceptor(key, interceptor);
                }
                catch (PersistenceException ex) {
                    throw new DataAccessResourceFailureException("Could not create JPA EntityManager", ex);
                }
            }
        }
    
        .
        .
        .
    
        @Override
        public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException {
            if (!decrementParticipateCount(request)) {
                EntityManagerHolder emHolder = (EntityManagerHolder)
                        TransactionSynchronizationManager.unbindResource(obtainEntityManagerFactory());
                logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor");
                EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());
            }
        }
    }

    OpenEntityManagerInViewInterceptor 는 현재 스레드에서 JPA EntityManagers 를 사용할 수 있도록 하며, 이는 transaction managers 가 자동으로 감지한다.

    @Configuration
    @EnableJpaAuditing
    public class DataJpaConfig {
    
        @Bean
        public OpenEntityManagerInViewInterceptor openEntityManagerInViewInterceptor() {
            return new OpenEntityManagerInViewInterceptor();
        }
    }

    따라서, 인터셉터에서 영속성 컨텍스트를 유지하기 위해 위와 같이 직접 OpenEntityManagerInViewInterceptor 빈을 등록해준다.

    @Component
    @RequiredArgsConstructor
    public class AuthInterceptor implements HandlerInterceptor {
    
        private final JwtTokenProvider jwtProvider;
        private final LoginService loginService;
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
            Object handler) {
            String header = request.getHeader(HttpHeaders.AUTHORIZATION);
            return validateJwtToken(response, header);
        }
    
        private boolean validateJwtToken(HttpServletResponse response, String header) {
            if (header.isBlank() || !header.startsWith(BEARER)) {
                response.setStatus(HttpStatus.FORBIDDEN.value());
                return false;
            }
    
            Claims claims;
    
            try {
                String jwtToken = header.replaceFirst(BEARER, Strings.EMPTY).trim();
                claims = jwtProvider.verifyToken(jwtToken);
            } catch (ExpiredJwtException e) {
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                return false;
            } catch (JwtException e) {
                response.setStatus(HttpStatus.FORBIDDEN.value());
                return false;
            }
    
            loginService.updateLoginMemberById(Long.parseLong(claims.getAudience()));
    
            return true;
        }
    }

    loginService.updateLoginMemberById(Long.parseLong(claims.getAudience())); - Jwt Payload 에 저장된 Audience(MemberId) 를 조회하여 LoginService 의 updateLoginMemberById 메서드를 실행시켜준다.

    @Service
    @RequiredArgsConstructor
    public class LoginService {
    
        private final MemberRepository memberRepository;
        private final LoginMember loginMember;
    
        @Transactional(readOnly = true)
        public void updateLoginMemberById(Long id) {
            Member member = memberRepository.findById(id)
                .orElseThrow(MemberException::new);
    
            loginMember.update(member);
        }
    }

    해당 메서드는 LoginMember 빈을 영속성 컨텍스트에서 찾은 Member 을 사용하여 필드 값들을 초기화시킨다. 그렇다면 LoginMember 빈은 어떻게 되어있을까?

    @Component
    @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
    public class LoginMember {
    
        private Long id;
        private String serialNumber;
        private ResourceServer resourceServer;
        private String name;
        private String email;
        private String profileImage;
        private String oAuthAccessToken;
    
        public void update(Member member) {
            this.id = member.getId();
            this.serialNumber = member.getSerialNumber();
            this.resourceServer = member.getResourceServer();
            this.name = member.getName();
            this.email = member.getEmail();
            this.profileImage = member.getProfileImage();
            this.oAuthAccessToken = member.getOAuthAccessToken();
        }
    
        public Member toEntity() {
            return Member.builder()
                .id(id)
                .serialNumber(serialNumber)
                .resourceServer(resourceServer)
                .name(name)
                .email(email)
                .profileImage(profileImage)
                .oAuthAccessToken(oAuthAccessToken)
                .build();
        }    
    }

    스코프 값을 "request" 로 하여 빈의 생명주기를 요청이 들어오고 나갈 때까지 스프링 컨테이너에서 관리하도록 한다. 따라서, 요청이 새로 들어올 때마다 빈이 생성되기 때문에 해당 API 로 요청한 로그인 회원 정보를 LoginMember 에 저장하여 관련된 기능들에서 범용적으로 사용할 수 있도록한다.

    @Component
    @RequiredArgsConstructor
    public class LoginMemberAuditorAware implements AuditorAware<Member> {
    
        private final LoginMember loginMember;
    
        @Override
        public Optional<Member> getCurrentAuditor() {
            return Optional.of(loginMember.toEntity());
        }
    }

    마지막으로 AuditorAware 을 상속받아 getCurrentAuditor 을 구현해준다. 이 때 LoginMember 를 다시 Entity(Member) 로 변환하여 반환하도록 한다.

    
    @Entity
    @Getter
    @ToString
    @EqualsAndHashCode(of = "id", callSuper = false)
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Issue extends BaseTimeEntity {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        .
        .
        .
    
        @CreatedBy
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(updatable = false)
        private Member creator;
    
        @LastModifiedBy
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn
        private Member modifier;
    }

    상속 받은 BaseTimeEntity 클래스의 @EntityListeners(AuditingEntityListener.class) 어노테이션과 Issue 엔티티의 @CreatedBy, @LastModifiedBy 어노테이션 사용으로 Issue 엔티티에 포함된 Member 타입의 creator (처음 등록되었을 때만), modifier (수정 시 마다) 필드들에 자동으로 로그인한 회원 객체가 등록된다.

    Github Login

    Issue 등록


    Issue List 조회


    등록한 Issue 상세 조회


    결론적으로 웹 계층(Controller) 에서 로그인한 사용자의 Id (MemberId) 를 알고 있지 않아도 되는 방식으로 구현할 수 있었다.

    @Target({ElementType.PARAMETER})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface LoginUser {
    
    }
    @Component
    @RequiredArgsConstructor
    public class LoginUserResolver implements HandlerMethodArgumentResolver {
    
        private final LoginMember loginMember;
    
        @Override
        public boolean supportsParameter(MethodParameter parameter) {
            return parameter.hasParameterAnnotation(LoginUser.class);
        }
    
        @Override
        public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
    
            return loginMember.toEntity();
        }
    }

    위와 같이(이전 버전) 커스텀 어노테이션 (@LoginUser) 을 생성하고 ArgumentResolver 를 사용하여 불필요하게 웹 계층 (Controller) 에서 엔티티 (Member) 를 알고 있을 필요 또한 없어진다.

    @ConstructorBinding, @ConfigurationProperties 효율적 사용

    이전에 혼자 구현해봤던 OAuth, Jwt 토큰을 사용하여 로그인하는 기능을 이번 프로젝트에서 팀원 @BC 와 같이 개선하면서 가장 많이 바뀐 부분이다.

    oauth:
      github-client-id: ${GITHUB_CLIENT_ID}
      github-client-secret: ${GITHUB_CLIENT_SECRET}
      github-access-token-uri: ...
      github-user-uri: ...
    
      google-client_id: ${GOOGLE_CLIENT_ID}
      google-client-secret: ${GOOGLE_CLIENT_SECRET}
      google-access-token-uri: ...
      google-user-uri: ...

    기존에 위와 작성했던 oauth.yml 파일을

    oauth:
      vendors:
        google:
          client_id: ${GOOGLE_CLIENT_ID}
          client_secret: ${GOOGLE_CLIENT_SECRET}
          access_token_uri: ..
          user_info_uri: ..
          redirect_uri: ..
          code_request_uri: ..
    
        github:
          client_id: ${GITHUB_CLIENT_ID}
          client_secret: ${GITHUB_CLIENT_SECRET}
          access_token_uri: ..
          user_info_uri: ..
          user_email_info_uri: ..
          code_request_uri: ..

    다음과 같이 vendors, vendor - 'google', 'github' 로 계층을 두어 세밀하게 변경하였다.

    @Getter
    @RequiredArgsConstructor
    public class VendorProperties {
    
        private final String clientId;
        private final String clientSecret;
        private final String accessTokenUri;
        private final String userInfoUri;
        private final String redirectUri;
        private final String userEmailInfoUri;
        private final String codeRequestUri;
    }
    @Getter
    @RequiredArgsConstructor
    @ConstructorBinding
    @ConfigurationProperties(prefix = "oauth")
    public class AuthProperties {
    
        private final Map<String, VendorProperties> vendors;
    
        public VendorProperties getVendorProperties(String vendor) {
            return vendors.get(vendor);
        }
    }

    Map 타입 필드인 vendors 에 key 값으로 vendor - 'google', 'github' 이 들어가고, vendor 의 하위 값 (client_id, client_secret ...) 들이 VendorProperties 클래스의 필드들과 매핑되어 각 vendor 별로 설정 값들을 관리할 수 있게 되었다.

    @Service("github")
    public class GithubOAuthService implements OAuthService {
    
        private final VendorProperties vendorProperties;
    
        @Autowired
        public GithubOAuthService(AuthProperties oAuthProperties) {
            this.vendorProperties = oAuthProperties.getVendorProperties("github");
        }
    
        .
        .
        .
    }
    
    @Service("google")
    public class GoogleOAuthService implements OAuthService {
    
        private final VendorProperties vendorProperties;
    
        @Autowired
        public GoogleOAuthService(AuthProperties oAuthProperties) {
            this.vendorProperties = oAuthProperties.getVendorProperties("google");
        }
        .
        .
        .
    }

    각 OAuthService 추상화 인터페이스의 구체화 클래스 (GithubOAuthService, GoogleOAuthService) 의 생성자가 한결 간결해졌다. 다만, "google" 과 같은 매직 리터럴이 아닌 @Service("google") 에서 명시된 빈 이름으로 해당 vendor 의 설정 값을 생성자에서 불러와 사용하고 싶었으나 빈 생명주기에 대한 학습이 부족하여 보류한 상태이다.

    @Component
    @RequiredArgsConstructor
    public class LoginFilter implements Filter {
    
        private final AuthProperties authProperties;
    
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
            FilterChain chain) throws IOException {
    
            HttpServletRequest request = (HttpServletRequest) servletRequest;
            HttpServletResponse response = (HttpServletResponse) servletResponse;
    
            String requestUri = request.getRequestURI();
            response.sendRedirect((authProperties.getVendorProperties(
                    requestUri.substring(requestUri.lastIndexOf("/") + 1))
                .getCodeRequestUri()));
        }
    }

    또한 OAuth Login Url 요청을 OAuth Authorization Code 요청 url 로 리다이렉트 시키는 작업 또한 Controller 까지 가지 않고 간단하게 Filter 에서 작업할 수 있도록 구현했다.

아쉬운 점

  • IssueService 의 경우 LabelRepository, MilestoneRepository, MemberRepository 와 같이 여러 레포지토리에 의존하고 있기에 해당 로직들을 따로 분리해보고자 했었다. 그래서 작성했던 코드가 아래와 같다. (현재는 삭제되어있다..) 작성하면서도 "하.. 이건 아닌거 같은데.." 를 수차례 되내였는데 역시나! 였다.

    @Configuration
    @RequiredArgsConstructor
    public class DomainMapperConfig {
    
        private final LabelRepository labelRepository;
        private final MilestoneRepository milestoneRepository;
        private final MemberRepository memberRepository;
    
        @Bean
        @Qualifier("addRequestToIssueMapper")
        public ModelMapper addRequestToIssueMapper() {
            ModelMapper modelMapper = new ModelMapper();
    
            modelMapper
                .getConfiguration()
                .setSkipNullEnabled(true);
    
            modelMapper
                .typeMap(IssueAddRequest.class, Issue.class)
                .addMappings(
                    mapper -> {
                        mapper.map(IssueAddRequest::getTitle, Issue::changeTitle);
                        mapper.map(IssueAddRequest::getContent, Issue::changeContent);
                        mapper.map(IssueAddRequest::getState, Issue::setState);
                        mapper.using((Converter<Long, Label>) converter ->
                                labelRepository.findById(converter.getSource())
                                    .orElseThrow(LabelException::new)
                            )
                            .map(IssueAddRequest::getLabelId, Issue::changeLabel);
    
                        mapper.using((Converter<Long, Milestone>) converter ->
                                milestoneRepository.findById(converter.getSource())
                                    .orElseThrow(MilestoneException::new)
                            )
                            .map(IssueAddRequest::getMilestoneId, Issue::changeMilestone);
    
                        mapper.using((Converter<Long, Member>) converter ->
                                memberRepository.findById(converter.getSource())
                                    .orElseThrow(MemberException::new)
                            )
                            .map(IssueAddRequest::getAssigneeId, Issue::changeAssignee);
                    }
                );
    
            return modelMapper;
        }
    
        .
        .
        .
    }

결론

  • 조회(Query) 모델, 명령(Command) 모델을 분리하는 방식과 연관있지 않을까? 학습해봐야겠다!
  • 깃 장인 @BC 와의 협업은 너무 즐거웠다! 많이 배워서 대만족스럽다! 😄
profile
riako

12개의 댓글

comment-user-thumbnail
2022년 7월 10일

이게 진정한 스압의 글이군요;;; 좋은 내용 공유해주셔서 감사합니다..
그리고 말씀 중에 죄송합니다만, 돈냉 먹으러 조만간 또 출격 하겠습니다;;

2개의 답글
comment-user-thumbnail
2022년 7월 10일

리아코 글 잘봤습니다 ㅋㅋㅋ;; LoginUser를 빈으로 활용해서 주입받는 아이디어가 색다르군요
중간에 채점도 해놓으시고;;;
그리고 백엔드와의 호흡 제 1형 벽력일섬 도 잘봤습니다;; 나중에 모각코때 봅시다 ㅎㅎ

1개의 답글
comment-user-thumbnail
2022년 7월 10일

유저 정보를 컨트롤러에서 굳이 몰라도 사용가능하도록 변경하신 부분 인상 깊네요;;
저런 꿀팁 공유 감사합니다! 악고 만세;;

1개의 답글
comment-user-thumbnail
2022년 7월 11일

리아코 선생님 글 너무 잘 봤습니다;; 저번부터 느낀 것이지만 정말 정리의 천재;;

1개의 답글
comment-user-thumbnail
2022년 7월 11일

저희 코드에 이런 깊은 내용이 있었다닛? 복습해야겠군요...
리악코 만세에에!!

1개의 답글