오늘 배운 점 MSA에서 Eureka를 이용해 각 서버를 등록하고, 필요한 서버를 찾을 수 있게 해주는 과정을 배웠다. 같은 서비스를 여러 서버로 실행하고, 각각 다른 포트를 사용해서 Eureka에 등록되는 것을 확인했다. Load Balancer가 등록된 여러 서버 중 하나를 골라서 요청을 보내는 것도 배웠다. Round Robin자체는 알고 있었는데, MSA에서 여러 서버가 있을 때 Load Balancer에서 어떻게 사용되는지 연결하면서 이해했다. Eureka가 서버를 직접 요청을 보내는 게 아니라 서버의 정보를 관리하고, Load Balancer가 서버를 선택해서 요청을 보내는 역할이라는 것을 다시 정리했다. 내일도 이어서 해볼 점 Eureka → Load Balancer → 서버로 요청이 넘어가는 과정을 그려보기 오늘 아쉬웠던 점 19092 → 19094 → 19093 순서로 서버 포트를 실행했는데, Eureka에서 확인되는 서버 포트 순서가 실행한 순서와 달라서 헷갈렸다. 서버를 실행한 순서대로 Eureka에 표시될 거라고 생각했는데, Eureka 내부의 네트워크나 정보 갱신 과정에 따라 순서가 달라질 수 있다는 점을 이해하는 데 시간이 걸렸다. 내일 해결을 위해 시도해볼 점 서버 포트가 Eureka에 등록되고 갱신되는 과정을 다시 확인해보기
기준 온도 넘으면 숫자가 빨개지고, 경고 문구가 뜨고, 기록 표에서도 그 줄이 빨갛게 칠해진다. 넘었는지는 데이터가 판단 public bool IsOver => Temperature > Threshold; 화면은 이 값만 보고 색을 정한다. 근데 이건 setter가 없어서 혼자서는 바뀌었다고 알릴 수가 없다. 그래서 Temperature 나 Threshold 가 바뀔 때 같이 알려 줘야 한다. OnPropertyChanged(); OnPropertyChanged(nameof(IsOver)); 이거 빼먹으면 값은 넘었는데 화면은 그대로다. Converter IsOver 는 bool인데 글자색은 Brush라서 그냥은 연결이 안 된다. 중간에서 바꿔 주는 게 Converter. public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { return value is true ? Brushes.Red : Brushes.Black; } XAML에서는 리소스로 등록해 놓고 이름으로 불러 쓴다. Foreground="{Binding IsOver, Converter={StaticResource OverToBrush}}" 경고 문구 보였다 숨겼다 하는 건 WPF에 기본으로 있는 BooleanToVisibilityConverter 를 썼다. 이건 안 만들어도 됨. 트리거 기록 표의 줄 색은 DataTrigger로 했다. <DataTrigger Binding="{Binding IsOver}" Value="True"> <Setter Property="Background" Value="#FFE5E5" /> </DataTrigger> "이 값이 이거면 이 속성 바꿔"를 XAML에서 바로 쓰는 방식이다. 둘 다 해 보니까 값 하나를 다른 타입으로 바꾸는 건 Converter, 조건에 따라 여러 속성을 바꾸는 건 트리거가 편했다. 과제 1 끝 버튼 이벤트부터 시작해서 센서 이벤트, 바인딩, 목록, 경고까지 왔다. 회사에서 준 과제 1(WPF로 UI 만들고 이벤트 처리)은 이걸로 마무리. 다음부터는 과제 2, MVVM.
구글에 서비스 등록하기 Google Cloud 접속 Google Auth Platform 접속 프로젝트 만들기 구글 인증 플랫폼 구성 클라이언트 만들기 개발과 실서비스에서 사용할 redirect uri를 둘 다 등록 Spring Security가 Google 인증 페이지로 리다이렉트할 때 redirect_uri를 함께 전달하므로, Google은 인증 완료 후 어떤 콜백 URL로 사용자를 돌려보낼지 알 수 있다. 클라이언트 ID와 비밀번호 저장 창을 닫으면 사라지므로 보관한다. 클라이언트 ID와 비밀번호 인텔리제이 환경변수에 등록 프로젝트의 WhiteList에 추가 // Google 로그인 시작 new PermitRule(HttpMethod.GET, "/oauth2/authorization/google"), // Google 로그인 콜백 new PermitRule(HttpMethod.GET, "/login/oauth2/code/google") OAuth2 로그인 성공 여부를 알아보기 위해AuthenticationSuccessHandler 생성 후 등록 @Slf4j @Component public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication ) { OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal(); Map<String, Object> attributes = oauth2User.getAttributes(); log.info("OAuth2 로그인 성공"); log.info("attributes = {}", attributes); log.info("providerId = {}", attributes.get("sub")); log.info("email = {}", attributes.get("email")); log.info("name = {}", attributes.get("name")); log.info("picture = {}", attributes.get("picture")); response.setStatus(HttpServletResponse.SC_OK); } } @Bean @Order(1) public SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception { AntPathMatcher antPathMatcher = new AntPathMatcher(); http .securityMatcher(request -> { String path = request.getRequestURI(); String method = request.getMethod(); return SecurityWhitelist.WHITELIST.stream() .anyMatch(rule -> { return rule.getHttpMethod().matches(method) && antPathMatcher.match(rule.getPattern(), path); }); }) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .logout(logout -> logout.disable()) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .oauth2Login(oauth2 -> oauth2.successHandler(oauth2LoginSuccessHandler)); // 핸들러 등록 return http.build(); } 브라우저에서 호출 후 로그 확인 localhost:8080/oauth2/authorization/google 브라우저에서 호출 구글 로그인 후 uri가 등록한 주소로 리다이렉트 된 것 확인 로그에서 성공 확인 User 엔티티에 AuthProvider 라는 Enum을 추가해서 일반으로 가입한 유저인지 소셜로그인으로 가입한 유저인지 구분 public enum AuthProvider { LOCAL, GOOGLE, KAKAO } 이름 중복을 허용하도록 기존 로직 수정 소셜 로그인으로 유저명을 받아오게 되면 이미 서버에 저장된 이름이 있을 수 있으므로 임의의 이름을 정해주기 보다는 이름의 중복을 허용 유저의 중복처리 이름의 중복을 허용하였으므로 유저들을 구분하기 위한 8자리 tag를 가입시 저장하도록 수정 LOCAL 회원가입시에는 LOCAL + 이메일 조합으로 중복을 체크하고 소셜로그인으로 회원 가입시에는 서비스에서 내려주는 식별 아이디를 이용해서 AuthProvider + 식별ID 조합으로 중복을 체크 일반 로그인에서도 LOCAL + 이메일 조합으로 유저를 찾고, 소셜 로그인에서는 AuthProvider + 식별ID 조합으로 중복을 체크 OAuth2LoginSuccessHandler 수정 oauth를 통해 받은 식별ID 와 AuthProvider 조합으로 유저를 조회 조회된 유저가 있을때 삭제된 유저이면 시작페이지로 리다이렉트 하면서 쿼리 파라미터로 오류 전달 조회된 유저가 없으면 새 유저로 저장 JwtUtils 를 활용하여 RefreshToken과 MediaToken을 생성하여 쿠키로 설정한 뒤 chat.html 페이지로 리다이렉트 package com.tgg.chat.common.security.oauth; import com.tgg.chat.common.security.jwt.JwtUtils; import com.tgg.chat.common.security.token.RedisTokenStore; import com.tgg.chat.domain.user.entity.User; import com.tgg.chat.domain.user.enums.AuthProvider; import com.tgg.chat.domain.user.repository.UserRepository; import com.tgg.chat.domain.user.service.UserTagGenerator; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import java.io.IOException; import java.time.Duration; import java.util.Map; import java.util.Optional; @Slf4j @Component @RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final UserRepository userRepository; private final JwtUtils jwtUtils; private final RedisTokenStore redisTokenStore; private final UserTagGenerator userTagGenerator; @Value("${frontend_url}") private String frontendUrl; @Value("${app.cookie.secure}") private boolean cookieSecure; @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication ) throws IOException { OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication; OAuth2User oauth2User = oauthToken.getPrincipal(); String registrationId = oauthToken.getAuthorizedClientRegistrationId(); String providerId; String username; AuthProvider authProvider; if ("google".equals(registrationId)) { providerId = oauth2User.getAttribute("sub"); username = oauth2User.getAttribute("name"); authProvider = AuthProvider.GOOGLE; } else if ("kakao".equals(registrationId)) { Map<String, Object> attributes = oauth2User.getAttributes(); Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account"); Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile"); providerId = String.valueOf(attributes.get("id")); username = (String) profile.get("nickname"); authProvider = AuthProvider.KAKAO; } else { throw new IllegalArgumentException( "지원하지 않는 OAuth Provider: " + registrationId ); } Optional<User> optionalUser = userRepository.findByAuthProviderAndProviderId(authProvider, providerId); User user; if (optionalUser.isPresent()) { User findUser = optionalUser.get(); // 탈퇴한 소셜 계정이면 로그인 차단 if (findUser.getDeleted()) { response.sendRedirect( frontendUrl + "/index.html?oauthError=deleted_account" ); return; } user = findUser; } else { String userTag; do { userTag = userTagGenerator.generate(); } while(userRepository.existsByUserTag(userTag)); user = User.of( null, null, username, userTag, providerId, authProvider ); user = userRepository.save(user); } String sid = jwtUtils.generateSid(); String refreshToken = jwtUtils.createRefreshToken(user, sid); String mediaToken = jwtUtils.createMediaToken(user, sid); redisTokenStore.saveRefreshToken( user.getUserId(), sid, refreshToken, jwtUtils.getRefreshTokenTtlMillis() ); ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) .secure(cookieSecure) .sameSite("Lax") .path("/") .maxAge(Duration.ofMillis( jwtUtils.getRefreshTokenTtlMillis() )) .build(); ResponseCookie mediaTokenCookie = ResponseCookie.from("mediaToken", mediaToken) .httpOnly(true) .secure(cookieSecure) .sameSite("Lax") .path("/") .maxAge(Duration.ofMillis( jwtUtils.getMediaTokenTtlMillis() )) .build(); response.addHeader( HttpHeaders.SET_COOKIE, refreshTokenCookie.toString() ); response.addHeader( HttpHeaders.SET_COOKIE, mediaTokenCookie.toString() ); response.sendRedirect( frontendUrl + "/chat.html" ); } } 테스트중 구글에서 리다이렉트할때 잘못된 경로로 리다이렉트 하는 것을 확인 https://tggchat.com 이 아닌 http://127.0.0.1:8080 으로 리다이렉트 시도하려고 하고 있었음 이유를 찾아본 결과 nginx가 사용자의 요청을 Spring Boot로 전달하는데 별도의 설정이 없으면 upstream의 주소가 전달되며 외부 요청이 HTTPS였다는 정보도 Spring은 알 수 없어서 http://127.0.0.1:8080 으로 요청이 들어오는 것을 알게 됨 Spring Security가 외부 서비스 주소인 https://tggchat.com이 아니라, Nginx가 요청을 전달하는 Spring Boot의 내부 주소인 http://127.0.0.1:8080을 기준으로 callback URL을 생성하고 있었던 것이 문제였다. 그래서 외부 호스트와 프로토콜을 제대로 전달할 수 있도록 아래 설정을 추가함 proxy_set_header Host $host; # 외부 호스트인 tggchat.com 전달 proxy_set_header X-Forwarded-Proto $scheme; # 외부 요청 프로토콜인 https 전달 또한 Spring 에 nginx에서 전달한 내용을 반영해서 콜백주소를 만들도록 아래설정을 추가 server: forward-headers-strategy: native
React_Day.9 어제 못 끝낸 회원·게시판 프로젝트를 마무리하고, 전역 상태 관리 라이브러리인 Redux(Redux Toolkit) 를 처음 배운 날. Day.8에서 뼈대를 잡던 프로젝트를 오늘 끝냈다. 그다음에는 Day.7의 Context 를 이어받아, 앱이 커졌을 때 쓰는 Redux로 넘어갔다. 1. 회원·게시판 프로젝트 마무리 어제는 로그인, 회원가입, 메뉴까지 하고 게시판 관련 페이지가 빈 컴포넌트였다. 오늘 나머지 페이지를 채웠다. 파일 오늘 한 일 LoginPage 로그인 성공 시 setCurrentUser + localStorage 저장 + /boardList 로 이동, 실패하면 alert MemberListPage 관리자( admin )로 로그인했을 때만 회원 아이디 목록을 보여 줌 CreatePostPage 로그인한 사람만 글 작성, localStorage 의 posts 에 저장 EditPostPage useParams 로 글 id를 읽어 폼에 채우고 수정 내용을 저장 BoardListPage 글 목록 출력, 작성자 본인에게만 수정/삭제 노출 Navibar 로그인하면 아이디님 과 로그아웃 버튼 노출 Day.8에서 짚은 로그인 문제( localStorage 만 저장하고 Context state는 안 바꿈)가 이번에 setCurrentUser(loginUser) 와 navigate('/boardList') , 실패 시 alert 로 채워졌다. 주요 코드 글 작성 : 로그인하지 않았으면 안내하고 로그인 페이지로 보낸다. const { currentUser } = useAuth() // navigate는 화면이 렌더링된 이후에 실행되어야 한다 // (이벤트 핸들러 안이나 useEffect 안에서 호출한다) useEffect(() => { if (!currentUser) { alert('로그인이 필요합니다.') navigate('/login') } }, []) const onSubmit1 = (e) => { e.preventDefault() if (!title.trim() || !content.trim()) { alert('제목과 내용을 입력해주세요') return } let posts = JSON.parse(localStorage.getItem('posts')) || [] // 기존 글 목록 (없으면 []) const newPost = { id: Date.now(), title, content, writeId: currentUser.userId, // 로그인한 사람의 아이디 } posts.push(newPost) localStorage.setItem('posts', JSON.stringify(posts)) navigate('/boardList') // 이벤트 핸들러 안이라서 바로 사용 } 필기에서 posts.push(newPost) 가 괜찮은 이유를 직접 적어 두었다. posts 는 React state가 아니라 localStorage 에서 꺼낸 지역 변수 라서 React가 감시하는 값이 아니고, 그래서 push 로 바꿔도 된다. Day.8에서 정리한 내용 그대로다. (필기에 주석 처리된 setTitle('') 는 이 페이지를 떠나면 컴포넌트가 사라지니 초기화할 필요가 없다는 의문이었는데, 맞는 판단이다.) 글 수정 : 주소의 id로 글을 찾아 폼을 채운다. const { id } = useParams() // 문자열로 반환 const [post, setPost] = useState({ title: '', content: '' }) useEffect(() => { const posts = JSON.parse(localStorage.getItem('posts')) const currentPost = posts.find((x) => x.id === parseInt(id)) if (currentPost) { setPost(currentPost) } // 찾으면 입력창에 기존 제목/내용이 채워진다 }, [id]) const onSubmit1 = (e) => { e.preventDefault() const posts = JSON.parse(localStorage.getItem('posts')) || [] // 같은 id의 글만 수정한 값(post)으로 교체. id와 writeId는 그대로 유지 const newposts = posts.map((x) => x.id === parseInt(id) ? { id: x.id, ...post, writeId: x.writeId } : x ) localStorage.setItem('posts', JSON.stringify(newposts)) navigate('/boardList') } { id: x.id, ...post, writeId: x.writeId } 에서 스프레드 뒤에 writeId 를 다시 쓴 이유 는, post 에 들어 있는 값보다 원래 글의 작성자를 우선하려는 것이다. 객체 스프레드는 뒤에 오는 값이 앞의 값을 덮어쓴다. 글 목록 : 본인 글에만 수정/삭제를 보여 준다. {currentUser && x.writeId === currentUser.userId && ( <div className="flex gap-1"> <Link to={`/posts/edit/${x.id}`}>수정</Link> <button onClick={() => handleDelete(x.id)}>삭제</button> </div> )} const handleDelete = (id) => { const newPosts = posts.filter((x) => { return x.id !== id }) // 삭제할 글만 빼고 새 배열 setPosts(newPosts) // 화면 갱신 localStorage.setItem('posts', JSON.stringify(newPosts)) // 저장소 갱신 } 화면 state( setPosts )와 저장소( localStorage )를 둘 다 갱신 하는 것도 Day.8에서 정리한 원칙이다. 회원 목록 : 관리자만 보이게 했다. {currentUser && currentUser.userId === 'admin' && currentUser.password === 'admin' ? (<ul>{users.length > 0 ? users.map((x, index) => <li key={index}>{x.userId}</li>) : <li>회원이 없습니다.</li>}</ul>) : (<div>회원목록은 관리자만 볼 수 있습니다.</div>)} 전체 흐름을 실제로 돌려 본 결과 코드를 그대로 가져다 전체 앱을 가입 → 로그인 → 글쓰기 → 수정 → 삭제 순서로 실행해 봤다. 핵심 기능은 모두 의도대로 동작했다. 가입하면 /login 으로 이동하고, 없는 계정으로 로그인하면 alert 가 뜬다. 로그인하면 /boardList 로 이동하고 메뉴에 user1님 과 로그아웃이 보인다. 로그인 없이 /posts/create 에 들어가면 로그인이 필요합니다. 가 뜨고 /login 으로 간다. 글쓰기 후 목록에 글이 보이고, 빈 제목/내용은 alert 로 막힌다. 수정 페이지는 기존 제목/내용이 채워져 열리고, 저장하면 목록에 반영된다. 다른 회원( user2 )의 목록 화면에는 수정/삭제가 안 보이고, 작성자가 삭제하면 화면과 저장소에서 같이 사라진다. 회원 목록은 admin 만 목록이 보이고 일반 회원과 비로그인은 안내 문구만 보인다. 돌려 보면서 발견한 허술한 부분 연습용 프로젝트라 기능은 충분하지만, "버튼을 숨기는 것"과 "막는 것"은 다르다 는 점이 크게 보였다. 수정 페이지에 권한 검사가 없다. 목록에서 수정 버튼을 숨겨도 주소창에 /posts/edit/글id 를 직접 입력하면 다른 회원도, 로그아웃한 사람도 폼이 열리고 저장된다. 실제로 user2 가 user1 의 글 제목을 바꿨고(작성자는 그대로 user1 ), 비로그인으로도 열렸다. 수정 페이지에서도 currentUser 와 writeId 를 비교해서 다르면 돌려보내야 한다. 이런 검사는 서버가 있다면 서버에서도 해야 한다. 글이 하나도 없을 때 수정 주소로 들어가면 에러가 난다. JSON.parse(localStorage.getItem('posts')) 가 null 이라 posts.find 에서 Cannot read properties of null (reading 'find') 로 깨진다. 다른 곳처럼 || [] 를 붙이면 된다. 같은 아이디로 여러 번 가입된다. user1 을 두 번 가입하자 users 에 2개가 들어갔다( admin 회원 목록에 user1 이 두 번 나왔다). 가입할 때 users.some(...) 으로 중복을 확인해야 한다. 관리자 판별이 admin / admin 문자열 비교다. 아무나 admin / admin 으로 가입하면 관리자 화면을 볼 수 있다. 실제로는 서버가 권한을 가진 계정을 따로 관리해야 한다. 비밀번호를 평문으로 저장하는 것은 Day.7부터 계속 연습용이라는 전제다. 목록 페이지가 localStorage 를 직접 읽어서 currentUser 를 따로 state로 갖고 있다. 필기에도 " useAuth() 를 사용해도 됨"이라고 적혀 있듯이, Context가 이미 있으니 useAuth() 로 쓰면 코드가 줄어든다. 2. Redux: 전역 상태 저장소 상태 관리 라이브러리. 리액트는 컴포넌트 단위로 상태를 관리하는데, 컴포넌트가 많아질수록 상태 전달이 복잡해진다(props drilling). Redux는 컴포넌트들이 이 상태를 쉽게 공유하도록 도와준다. Day.7에서 Context로 풀었던 문제와 같은 문제를 푸는 도구다. 한 곳(Store)에 상태를 모아 두고, 필요한 컴포넌트가 직접 꺼내 쓰고 , 필요한 컴포넌트만 다시 그린다. 필기에 풀어 쓴 설명을 정리하면 이렇다. 컴포넌트를 서로 연결해서 전달하는 대신, 한 공간에서 상태를 직접 관리할 수 있는 공간 (Store)을 만든다. "상태를 바꾸겠다"는 요청의 종류 를 정하고, 그 요청이 들어오면 상태를 어떻게 바꿀지 함수 로 만들어 둔다. Context와의 차이 구분 Context API Redux 개념 리액트 내장 기능. 전역 상태를 제공/구독하는 간단한 방법 외부 라이브러리. 예측 가능한 상태 관리 도구 주요 목적 테마, 다국어, 로그인 정보 같은 단순 전역 상태 공유 복잡한 비즈니스 로직, 상태 흐름 추적, 대규모 앱의 상태 관리 데이터 흐름 Provider → Consumer Action → Reducer → Store → View (단방향) 복잡성 비교적 단순 설정, 구조, 코드량이 많음 (하지만 툴링이 풍부) 핵심 개념 Store : 앱의 모든 상태를 보관하는 단일 저장소 (예: 사용자 정보, 장바구니, 게시글 목록). Action : 상태를 바꾸고 싶을 때 보내는 메시지 . 객체 모양이다. { type: '...' } Reducer : 액션을 받아서 새로운 state를 만들어 돌려주는 함수 . state를 직접 바꾸지 않는다. Dispatch : 액션을 store에 보내는 함수 . 컴포넌트에서 호출한다. useSelector, useDispatch : 컴포넌트에서 store의 값을 읽고( useSelector ), 액션을 보낼( useDispatch ) 때 쓰는 react-redux 훅. Redux Toolkit : 위 과정을 간단하게 만들어 주는 공식 도구. createSlice , configureStore 를 쓴다. 동작 흐름 컴포넌트 → (dispatch) → 액션 → 리듀서(slice) → 새로운 상태 → 스토어 → (useSelector) → 컴포넌트 반영 식당에 비유하면 이렇다. Action은 주문서 , Reducer는 주문서를 보고 요리(상태 변경)하는 사람 , Store는 완성된 음식(상태)이 놓이는 곳 이다. 사용자 클릭(UI)이 dispatch(action) 으로 주문서를 넣으면 Reducer가 상태를 바꾸고, Store에 저장된 새 상태가 UI에 자동으로 반영된다. Redux의 3가지 원칙 단일 스토어 : 애플리케이션 전체 상태를 하나의 store에서 관리한다. 읽기 전용 상태 : 상태는 직접 바꿀 수 없고 action을 통해서만 바꾼다. 순수 함수로 변경 : 상태 변경은 reducer라는 순수 함수 (같은 입력이면 항상 같은 결과, 바깥 값을 건드리지 않음)로만 처리한다. 설치 npm install @reduxjs/toolkit react-redux 3. Redux Toolkit 실습: 카운터 파일은 세 군데로 나뉜다. slice (상태와 변경 방법), store (slice를 모음), main.jsx (앱에 연결). (1) counterSlice.js: 상태와 상태 변경 방법을 정의 slice = 전역 상태의 한 조각 (예: counterSlice , todoSlice , userSlice ). createSlice 는 액션과 리듀서를 한 번에 만드는 함수다. import { createSlice } from "@reduxjs/toolkit"; const counterSlice = createSlice({ name: "counter", initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1; }, decrement: (state) => { state.value -= 1; }, reset: (state) => { state.value = 0; }, }, }); export const { increment, decrement, reset } = counterSlice.actions; // 자동으로 액션 생성자(action creator)를 만들어 준다. export default counterSlice.reducer; createSlice 에 넘기는 옵션이다. 옵션 필수 설명 name 필수 슬라이스 이름. 액션 타입의 앞부분 이 된다 ( counter/increment ) initialState 필수 초기 상태값 (객체, 배열, 숫자 모두 가능) reducers 필수 상태 변경 로직. 이 함수들마다 액션과 리듀서가 자동 생성 된다 extraReducers 선택 비동기 액션이나 다른 slice의 액션 을 처리 (2) store.js: slice를 모아 하나의 store로 import { configureStore } from "@reduxjs/toolkit"; import counterReducer from "./counterSlice"; const store = configureStore({ reducer: { counter: counterReducer, // 슬라이스 등록 }, }); export default store; configureStore() 에 등록하면 모든 컴포넌트가 가져다 쓸 수 있게 된다. (3) main.jsx: 앱에 store를 연결 import { Provider } from 'react-redux' createRoot(document.getElementById('root')).render( <Provider store={store}> <App /> </Provider> ) Context의 Provider와 같은 역할이다. 이 안쪽의 모든 컴포넌트가 store에 접근한다. (4) Counter.jsx: 실제 UI에서 쓰기 import { useSelector, useDispatch } from "react-redux"; import { increment, decrement, reset } from "../redux/counterSlice"; const Counter = () => { const count = useSelector((state) => state.counter.value); // store에서 값 꺼내오기 const dispatch = useDispatch(); // 액션 실행 준비 return ( <div> <h2>카운터: {count}</h2> <button onClick={() => dispatch(increment())}>+1</button> <button onClick={() => dispatch(decrement())}>-1</button> <button onClick={() => dispatch(reset())}>Reset</button> </div> ); }; useSelector : store에서 값을 꺼낸다. useDispatch : 액션을 실행시킨다. 직접 실행해 보니 처음 카운터: 0 , +1 을 두 번 누르고 -1 을 한 번 누르면 1 , Reset 을 누르면 0 이 되었다. 컴포넌트의 이벤트 → dispatch → store → slice → 다시 store → 컴포넌트가 useSelector 로 값을 가져와 {count} 를 출력한다. 액션을 보내면 store가 직접 처리하는 것이 아니라 createSlice 가 만들어 준 reducer 가 상태를 계산하고 store가 업데이트를 관리한다. Action 객체의 모양 { type: "counter/increment", payload: 10 // 추가 데이터 } 액션 생성자를 직접 호출해서 확인했다. increment() // { type: 'counter/increment' } incrementByAmount(10) // { type: 'counter/incrementByAmount', payload: 10 } increment() 처럼 데이터가 필요 없으면 type 만 있고, 데이터를 넘기면 payload 에 들어간다. 리듀서에서는 (state, action) 의 action.payload 로 꺼낸다( state.value += action.payload ). type 은 슬라이스name/리듀서이름 으로 자동 생성된다. 실제로 확인하며 바로잡은 부분 name 이 state의 key가 되는 것이 아니다. 필기의 옵션 표에는 "name: 슬라이스 이름 (state 접근 시 key로 사용됨)"이라고 적혀 있다. 확인해 보니 state.counter.value 에서 counter 는 configureStore 의 reducer: { counter: ... } 에 적은 키 였다. 키를 cnt 로 바꿔서 등록하면 state.cnt.value 로 접근해야 했고, 액션 타입은 여전히 counter/increment 였다. name 은 액션 타입의 앞부분 을 만드는 데 쓰인다. 보통은 둘을 같은 이름으로 맞춰서 헷갈리지 않게 한다. Reducer가 "새로운 Store를 생성"하는 것이 아니다. 필기의 순서에는 "Reducer는 새로운 Store를 생성한다"고 적혀 있다. dispatch 전후를 비교해 보니 store 객체는 그대로이고, store.getState() 가 돌려주는 state 객체만 새로 만들어졌다. 이전 state는 { counter: { value: 0 } } 그대로 남았다. 정확히는 "Reducer가 새로운 state 를 돌려주고, Store가 그걸 저장한다"이다. dispatch 는 store의 기능이다. 필기에 "Action을 Store에 바로 전달하는 것이 아니라 Reducer에 전달해야 한다"고 적혀 있는데, 실제로는 dispatch(action) 으로 store에 액션을 보내면 store가 내부에서 reducer를 호출 한다. 개발자가 reducer를 직접 호출하지 않는다는 점이 핵심이다. state.value += 1 이 "직접 바꾸는 것"처럼 보이는데 괜찮을까? 3원칙은 "상태는 직접 바꾸지 않는다"인데 slice 안에서는 state.value += 1 로 쓴다. Redux Toolkit이 Immer 라는 라이브러리를 내장해서, 이 코드를 받아 원본은 건드리지 않고 새 state를 만들어 준다. 직접 확인했다. reducer({value: 5}, increment()) 를 호출하면 결과는 {value: 6} 인 새 객체이고 원래 {value: 5} 는 그대로였다. 이 방식은 createSlice 안의 reducer에서만 통한다. 일반 함수나 React useState 에서는 여전히 새 객체를 만들어야 한다. 필요한 컴포넌트만 리렌더링 된다는 설명도 확인했다. Counter 는 state.counter.value 를, 다른 컴포넌트( Label )는 state.label.text 를 useSelector 로 읽게 했다. label 만 바꾸면 Label 만 1번 렌더링되고 Counter 는 0번이었고, 반대
1편에서 기존 React + Vite 프로젝트에 Capacitor를 연결하고 iOS / Android 프로젝트를 생성하는 과정까지 정리했다. 처음에는 기존 React 코드를 거의 그대로 사용할 수 있으니 웹에서 잘 동작하던 기능도 앱에서 그대로 동작할 것이라고 생각했다. 실제로 UI 대부분은 큰 수정 없이 실행할 수 있었다. 하지만 API 요청이나 실행 환경처럼 브라우저에서는 당연하게 사용하고 있던 부분들이 Capacitor 앱에서는 조금 다르게 동작했다. 이번 글에서는 환승여행을 PWA에서 Capacitor 앱으로 확장하면서 웹과 앱 환경의 차이 때문에 따로 처리했던 부분들 을 정리해보려고 한다. 1. 웹에서 사용하던 /api 요청이 앱에서는 그대로 동작하지 않았다 환승여행 웹 프로젝트에서는 API를 호출할 때 /api/... 형태의 상대 경로를 사용하고 있었다. 개발 환경에서는 Vite의 proxy 설정을 통해 /api 요청을 실제 백엔드 서버로 전달하는 구조였다. server: { proxy: { "/api": { target: apiProxyTarget, changeOrigin: true, secure: false, }, }, }, 실제 프로젝트에서도 VITE_API_BASE_URL 을 proxy target으로 읽어 /api 요청을 백엔드로 전달하도록 구성되어 있다. 웹 개발 환경에서는 문제가 없었다. 브라우저 → Vite 개발 서버 → Proxy → Backend 라는 중간 단계가 존재했기 때문이다. 하지만 Capacitor로 앱을 빌드하고 나니 상황이 달라졌다. Capacitor 앱에서는 Vite 개발 서버가 중간에 존재하지 않기 때문에 웹에서 사용하던 proxy에 그대로 의존할 수 없었다. 즉, /api/... 라고 요청한다고 해서 앱이 알아서 기존 백엔드 서버를 찾아가는 것이 아니었다. 해결 방법 웹과 Capacitor의 API 환경을 분리하기로 했다. Capacitor 전용 환경 파일을 만들고 .env.capacitor 여기에 앱에서 사용할 백엔드 주소를 따로 지정했다. 현재 프로젝트도 Capacitor 전용 VITE_API_BASE_URL 을 별도로 사용하고 있다. 그리고 앱 빌드 시에는 vite build --mode capacitor 를 사용하도록 구성했다. 일반 웹 빌드와 Capacitor 빌드의 환경을 분리하면서 같은 React 코드를 사용하더라도 실행되는 환경에 따라 다른 API 설정을 사용할 수 있게 되었다. 이번 과정에서 처음 알게 된 점은 proxy는 내 React 코드의 기능이 아니라 개발 서버가 제공해주던 기능이었다는 것 이었다. 웹에서 너무 자연스럽게 사용하고 있어서 앱으로 옮기기 전까지는 크게 의식하지 못했던 부분이었다. 2. Capacitor 전용 build mode 분리하기 API 환경을 분리하면서 빌드 과정도 웹과 앱을 따로 관리할 필요가 생겼다. 기존에는 단순히 npm run build 를 사용하면 됐지만, Capacitor에서는 앱용 환경 변수를 사용해야 했다. 그래서 package.json 에 별도의 script를 추가했다. "build:ios": "tsc -b && vite build --mode capacitor", "build:app": "tsc -b && vite build --mode capacitor && cap sync" 현재 환승여행 프로젝트에서도 웹 빌드와 Capacitor 빌드를 이렇게 분리하고 있다. 이제 앱에서 확인하고 싶을 때는 npm run build:app 을 실행하면 TypeScript Build ↓ Vite Build (capacitor mode) ↓ dist 생성 ↓ Capacitor Sync ↓ iOS / Android 프로젝트 반영 흐름을 한 번에 실행할 수 있다. 처음에는 웹 코드를 수정하고 Xcode에서 다시 Run만 누르면 당연히 변경 사항이 반영될 것이라고 생각했다. 하지만 Capacitor는 Vite의 빌드 결과물을 네이티브 프로젝트에서 사용하는 구조이기 때문에 웹 소스를 수정했다면 새로운 dist 를 만들고 다시 native 프로젝트에 sync해야 했다. 이 과정을 script로 묶어두니 수정할 때마다 명령어 여러 개를 반복해서 입력하지 않아도 돼서 훨씬 편했다. 3. HTTP 요청도 웹과 완전히 같지는 않았다 Capacitor를 연결하면서 HTTP 요청 역시 브라우저에서 보내는 요청과 동일하게만 생각하면 안 된다는 것을 알게 됐다. 현재 프로젝트의 Capacitor 설정에는 CapacitorHttp 가 활성화되어 있다. plugins: { CapacitorHttp: { enabled: true, }, }, 처음에는 “React에서 원래 사용하던 요청을 그대로 보내면 되는 것 아닌가?”라고 생각했는데, 앱으로 실행하면 WebView와 네이티브 앱의 네트워크 환경까지 같이 고려해야 했다. 특히 브라우저 환경에서 사용하던 proxy나 origin 구조가 앱에서는 동일하게 존재하지 않는다. 결국 중요한 건 기존 API 코드 자체를 전부 새로 만드는 것이 아니라 어디에서 실행되고 있는지를 기준으로 요청 환경을 분리하는 것 이었다. Capacitor를 쓰면서 가장 많이 느낀 것도 이 부분이었다. 화면만 보면 똑같은 React 앱이지만, 그 React 앱을 실행하고 있는 환경은 더 이상 일반 브라우저가 아니었다. 4. 웹앱을 그대로 넣었다고 끝은 아니었다 Capacitor 연결 자체는 생각보다 빨랐다. 기존 웹 프로젝트를 빌드하고 iOS와 Android 프로젝트를 생성하면 React 화면 자체는 꽤 빠르게 앱에서 확인할 수 있었다. 하지만 실제 모바일 앱처럼 사용하기 시작하면 웹에서는 크게 의식하지 않았던 문제들이 보이기 시작했다. 환승여행은 이전부터 PWA를 기준으로 개발하면서 모바일 Safe Area Bottom Navigation 위치 화면 높이 앱 재실행 시 인증 상태 등을 한 번씩 대응했던 상태였다. 그래서 Capacitor에서도 기존 모바일 UI를 대부분 활용할 수 있었지만, 웹에서 한 번 해결했던 문제라고 해서 네이티브 환경에서도 당연히 동일하다고 가정하면 안 됐다. 특히 실제 iPhone / Android 환경에서 실행해보면서 화면과 기능을 다시 확인하는 과정이 필요했다. 이 부분은 PWA를 개발했을 때 느낀 점과도 비슷했다. Chrome 개발자 도구에서 모바일 화면으로 잘 보이는 것과 실제 휴대폰에서 앱으로 실행했을 때 잘 동작하는 것은 별개의 문제였다. 5. 웹 코드 재사용 의 의미를 잘못 생각하고 있었다 Capacitor를 선택한 가장 큰 이유는 기존 React 코드를 재사용할 수 있다는 점이었다. 실제로 대부분의 UI와 서비스 로직은 그대로 사용할 수 있었다. 그런데 처음에는 이 말을 “React 코드가 웹에서 동작하면 앱에서도 똑같이 동작한다.” 정도로 이해했던 것 같다. 직접 사용하고 나니 조금 달랐다. 정확히는 웹 애플리케이션의 구조와 코드를 재사용할 수 있지만, 실행 환경의 차이는 별도로 처리해야 한다. 에 가까웠다. UI 컴포넌트나 상태 관리, 페이지 로직은 대부분 그대로 사용할 수 있지만 API 주소 네트워크 요청 환경변수 플랫폼 설정 네이티브 기능 처럼 브라우저 바깥의 영역과 연결되기 시작하면 iOS / Android 환경을 따로 생각해야 한다. 오히려 이 차이를 이해하고 나니 Capacitor가 왜 웹을 앱으로 변환하는 도구 라기보다 웹과 네이티브 사이를 연결하는 도구 에 가까운지도 조금 이해할 수 있었다. 💡 이번 작업에서 배운 점 이번 작업에서 가장 크게 느낀 것은 “웹에서 잘 된다”라는 말에는 생각보다 많은 전제가 숨어있다는 것 이었다. 웹에서는 브라우저와 개발 서버가 proxy를 처리하고 origin을 가지고 있고 환경변수를 읽어 빌드하고 네트워크 요청을 처리해준다. 평소에는 이런 부분을 크게 의식하지 않아도 됐지만 Capacitor로 실행 환경이 바뀌자 그동안 당연하게 사용하던 것들이 하나씩 눈에 들어오기 시작했다. 특히 proxy 문제를 확인하면서 단순히 설정을 고치는 것보다 “이 기능은 누가 제공하고 있었던 거지?” 를 먼저 생각하는 것이 문제를 이해하는 데 도움이 됐다. Capacitor를 사용하면서 네이티브 개발을 깊게 배웠다고 하기는 어렵지만, 적어도 내가 작성한 웹 애플리케이션이 어떤 환경에 의존하고 있었는지는 이전보다 많이 이해하게 된 것 같다. 마무리 Capacitor를 연결하는 것 자체는 어렵지 않았다. 오히려 그다음부터가 시작이었다. 같은 React 코드를 사용하고 있어도 브라우저, PWA, iOS, Android는 각각 다른 실행 환경이고 API 요청이나 플랫폼 기능에서는 그 차이를 고려해야 했다. 이번에는 우선 웹과 앱의 API 환경 분리 Capacitor 전용 Build Mode 네이티브 환경에서의 HTTP 요청 실제 기기에서의 재확인 과정을 정리해봤다. 현재는 Apple Login 기능까지 추가해 앱스토어 배포를 목표로 심사에 넣은 상태이다. Apple Login은 단순히 기존 로그인 버튼을 앱에 옮기는 것으로 끝나는 것이 아니라 Apple Developer 설정과 네이티브 환경의 인증 흐름까지 함께 다뤄야 해서 별도의 글로 정리해보려고 한다.
위키독스 『로봇 없이 시작하는 Physical AI 따라하기』를 바탕으로 공부하면서, 채팅에서는 imitation learning과 ACT까지 이어서 살펴봤다. 이번 기록은 코드 실습이 아니라 개념 공부다. 내가 적어본 차이 공부 중 BC와 ACT의 흐름을 이렇게 적었다. 비교한 방식 입력 출력 한 시점의 행동을 예측하는 BC observation action 하나 ACT observation 연속된 action 묶음 다만 이 표를 BC와 ACT가 서로 다른 학습 범주라는 뜻으로 읽으면 안 된다. ACT도 demonstration으로 학습하는 behavior cloning 계열이다. 여기서는 한 시점씩 예측하는 방식과 action chunk를 예측하는 방식을 비교한 것이다. Action chunk가 뜻하는 것 ACT는 현재 observation을 바탕으로 여러 미래 시점의 action을 함께 예측한다. 컵 집기를 생각하면 접근하고, 그리퍼를 닫고, 들어 올리는 흐름이 떠오른다. 이 예시는 행동의 순서를 이해하기 위한 것이고, 실제 출력은 이런 문장 목록이 아니라 로봇을 제어하는 수치 action의 시퀀스다. 원 논문에서는 Transformer를 사용해 이 시퀀스를 예측한다. 여러 행동을 묶는 것은 시연의 시간적 구조를 다루고, 한 단계씩 예측할 때의 오차 누적을 줄이려는 접근이다. 그렇다고 실패가 없어지는 것은 아니다. 묶음을 끝까지 실행하기만 할까 다음으로 배운 개념은 temporal ensembling이었다. 새로운 observation으로 chunk를 반복 예측하면, 같은 실행 시점을 가리키는 예측이 여러 개 생길 수 있다. ACT는 이 예측들을 가중 결합해 실행할 action을 만든다. 중요한 건 서로 다른 시점의 행동을 섞는 게 아니라, 같은 시점에 수행할 행동에 대한 여러 예측 을 합친다는 점이다. 최신 observation을 반영하면서 출력을 부드럽게 만드는 데 도움이 되지만, 별도의 실패 감지 기능과 같지는 않다. 앞으로 생각하고 있는 failure detection 프로젝트에서도 이 차이를 구분해야겠다. ACT가 행동을 내놓는 부분과, 그 행동이 실패로 이어지는지 판단하는 부분은 따로 살펴볼 문제다. 아직 구현하거나 실험한 내용은 없다. 다음 실습으로는 LeRobot dataset의 observation과 action 구조를 확인하려고 한다. 참고 자료 학습을 시작한 위키독스 페이지 ACT·ALOHA 연구진의 프로젝트 페이지와 논문 ACT 원 논문: Action Chunking and Temporal Ensemble
Modern Robotics 2.2와 2.3.1을 공부했다. 이번에는 공식을 계산하는 것보다, 계산한 숫자가 무엇을 뜻하는지 이해하는 데 시간이 걸렸다. Stewart platform에서 막힌 부분 Stewart platform은 아래판과 위판을 길이 조절이 가능한 다리 여섯 개로 연결한 기구다. 처음 설명을 읽고도 구조가 잘 잡히지 않아 다시 설명을 요청했다. 두 번째 설명을 듣고도 절반 정도 이해했다는 느낌이었다. 공식 강의의 모델에서는 다리 하나가 두 링크와 U·P·S 관절로 이루어진다. U는 universal joint, P는 prismatic joint, S는 spherical joint다. 각각 허용하는 상대 운동의 자유도는 2, 1, 3이다. Grübler 공식은 다음과 같다. $$ \mathrm{DOF}=m(N-1-J)+\sum_{i=1}^{J} f_i $$ N에는 고정된 바닥 링크도 포함한다. 이 모델에서는 N=14, J=18, 관절 자유도의 합은 36이고, 공간 기구이므로 m=6이다. $$ 6(14-1-18)+36=6 $$ 여기서 36은 전체 기구의 독립적인 운동 개수가 아니다. 여섯 다리가 같은 위판에 연결되어 있으므로 각 관절을 따로 움직이는 것처럼 생각하면 안 된다. 조립된 기구에서 남는 자유도는 6이다. 단, 이 공식은 관절의 구속조건이 독립이라는 가정이 필요하다. 모든 기구에 숫자만 대입하면 되는 공식은 아니다. 2R 로봇의 공간이 왜 도넛일까 이어서 배운 C-space는 로봇이 가질 수 있는 모든 configuration의 공간이다. 자유도는 이 공간의 차원이다. 회전 제한이 없는 이상적인 2R 로봇은 두 관절각으로 자세를 나타낸다. 각 각도는 한 바퀴 돌면 같은 자세로 돌아오므로 원 S1에 해당한다. 따라서 C-space는 S1×S1, 즉 torus의 표면이다. 처음에는 원기둥 두 개를 밑면끼리 붙이는 것인지 물었다. 정확한 설명은 정사각형의 양쪽 변을 같은 상태로 연결하면 원기둥이 되고, 남은 두 끝의 원 둘레도 연결하면 torus가 된다는 것이었다. 이 도넛은 실제 로봇팔의 모양이 아니다. 관절각 조합 하나가 도넛 표면의 점 하나에 대응한다. 359도에서 0도로 넘어갈 때 숫자는 크게 바뀌어 보이지만 실제 회전은 이어진다. 관절 제한이나 충돌을 고려하면 가능한 공간은 이 이상적인 모델과 달라질 수 있다. 아직 공간의 모양을 직관적으로 떠올리는 연습이 필요하다. 다음에는 C-space의 좌표 표현을 공부할 예정이다. 참고 자료 Modern Robotics 2.2 공식 강의 Modern Robotics 2.3.1 공식 강의