Loading the catalog…
Loading the catalog…
현대중공업 글에서는 백엔드가 쓴 API 명세를 읽고 화면을 붙인 이야기를 했다. 그 글에서 나는 API를 쓰는 쪽이었다. 명세가 먼저 있었고, 나는 그 계약대로 요청을 보내고 응답을 그렸다. 유라와 KT는 위치가 달랐다. 유라에서는 회원 기능 API의 요청과 응답 형태, 에러 코드까지 내가 정하고 화면도 내가 붙였다. 만드는 쪽과 쓰는 쪽을 한 사람이 다 쥔 경우다. KT에서는 이미 돌아가던 조회 API의 계약을 성능 때문에 바꿨다. 응답 필드를 빼고 요청 필드를 넣고 선택 항목을 필수로 만드는 일이었고, 화면과 서버를 같은 배포에 맞춰야 했다. 세 경험을 한 줄씩 놓으면 이렇다. 회사 내 위치 명세는 어디에 계약을 정한 사람 현대중공업 명세를 읽고 화면을 붙였다 Confluence, 백엔드가 작성 백엔드 유라 서버와 화면을 다 만들었다 GitLab 위키, 내가 작성 나 KT 화면 쪽 API를 만들고 계약을 바꿨다 화면 정의서와 요청관리 시스템 기획과 내가 같이 이 글은 아래 두 줄을 채우는 글이다. 코드와 URL과 필드 이름은 구조를 보이기 위해 다시 적은 것이라 실제 이름과 글자 단위로 같지는 않다. 1. 유라: 화면 하나에 API가 서너 개 1-1. JSP인데 왜 API 기반이 됐나 환경은 전자정부프레임워크에 JSP와 jQuery, DB는 PostgreSQL이었다. 겉으로는 서버가 HTML을 만들어 내려주는 전통적인 구조로 보인다. 그런데 회원 기능은 JSP가 껍데기만 그리고 데이터는 전부 Ajax로 받는 구조였다. 이유는 인증 방식 하나에서 나왔다. 로그인 결과로 JWT를 발급해 브라우저 localStorage에 두고, 요청마다 Authorization: Bearer 헤더에 실어 보내는 방식이었다. 세션도 쿠키도 없다. 그러면 JSP를 렌더링하는 시점에 서버는 누가 왔는지 모른다. 토큰은 브라우저의 JS만 읽을 수 있고, 주소창으로 들어오는 페이지 요청에는 그 헤더가 붙지 않기 때문이다. 서버가 JSP 안에 사용자 데이터를 박아 넣을 수 없으니, 데이터는 토큰을 실어 보낼 수 있는 Ajax로만 받게 된다. 그래서 화면 하나가 열리는 순서가 이렇다. JSP가 빈 표와 버튼을 그린다 => 페이지 로드 시 JS가 localStorage의 토큰을 확인하고 없거나 만료면 로그인으로 보낸다 => 목록 API를 Bearer 헤더를 붙여 부른다 => 응답 JSON으로 표를 채운다. 관리자 회원 목록 화면 하나만 봐도 목록 조회, 상세 조회, 승인, 반려 네 개의 API가 붙는다. JSP 프로젝트였지만 회원 기능은 사실상 API 기반으로 만든 셈이다. 임직원 SSO는 유라 IT가 이미 쓰던 방식이라 내가 건드리지 않았다. 이 글에 적은 것은 사내 계정이 없는 협력사와 해외법인 사용자, 즉 외부 사용자 계정 쪽이다. 1-2. 내가 만든 엔드포인트 회원 기능은 controller, service, Mapper, SQL까지 내가 전담했다. 정리하면 이런 목록이다. 기능 메서드와 URL 권한 비고 인증번호 발송 POST /api/auth/email-codes 공개 60초 쿨다운, 재발송하면 시도 횟수 초기화 인증번호 검증 POST /api/auth/email-codes/verify 공개 5분 만료, 5회 제한 이메일 가입 POST /api/members 공개 서버가 인증번호를 다시 검증하고 승인 대기 상태로 생성 로그인 POST /api/auth/login 공개 5회 실패 잠금, 최신 토큰 저장, 로그인 이력 기록 소셜 로그인 POST /api/auth/social/{provider} 공개 프론트가 받은 소셜 토큰을 서버가 검증. 신규면 임시 계정 생성, 휴면이면 자동 해제 추가정보 입력 PUT /api/members/me/additional-info 로그인 (임시 계정) 소셜 가입자의 사업자번호와 담당자 정보 비밀번호 재설정 요청 POST /api/auth/password-reset/request 공개 등록 이메일로 인증번호 비밀번호 재설정 확정 POST /api/auth/password-reset/confirm 공개 본인이 새 비밀번호 설정, 잠금도 함께 해제 휴면 해제 POST /api/auth/reactivate 공개 이메일 계정용. 인증번호 확인 후 ACTIVE로 내 정보 조회와 수정 GET, PUT /api/members/me 로그인 탈퇴 DELETE /api/members/me 로그인 즉시 로그인 차단, 30일 후 배치가 마스킹 약관 조회 GET /api/terms 로그인 최신 버전과 재동의 필요 여부 약관 동의 POST /api/members/me/agreements 로그인 UPDATE가 아니라 INSERT, 버전별 이력 관리자 회원 목록 GET /api/admin/members 관리자 상태 필터, 서버 페이징 관리자 회원 상세 GET /api/admin/members/{memberNo} 관리자 로그인 이력 포함 승인, 반려, 잠금 해제, 휴면 해제, 탈퇴 처리 POST /api/admin/members/{memberNo}/approve 외 4개 관리자 반려는 사유 필수, 사유가 메일로 나간다 가입 폼의 사업자번호는 폼 안에서 형식을 확인했고, 실제로 그 회사 사람인지는 담당 부서가 승인 단계에서 봤다. 해외법인을 고르면 사업자번호 대신 법인 목록이 나오고 검증 대신 승인자가 달라진다. 1-3. 응답 봉투와 에러 코드를 먼저 정했다 이 목록을 만들기 전에 정한 것이 둘 있다. 응답의 겉모양과 에러 코드다. 화면 열 개가 API 스무 개를 부르는데 응답 모양이 API마다 다르면 화면마다 파싱 코드가 달라진다. 그래서 성공이든 실패든 같은 봉투에 담았다. { "success": true, "data": { "memberNo": 1042, "status": "PENDING_APPROVAL" }, "error": null } { "success": false, "data": null, "error": { "code": "TOKEN_MISMATCH", "message": "다른 기기에서 로그인되었습니다." } } HTTP 상태 코드만으로는 화면이 무엇을 해야 하는지 모른다. 401 하나 안에 토큰이 없는 경우, 만료된 경우, 다른 기기에서 로그인해 최신 토큰이 아닌 경우가 다 들어가는데 화면의 반응은 각각 다르다. 그래서 상태 코드는 크게 가르고, 화면이 분기할 이유는 error.code 에 담았다. HTTP code 뜻 화면이 하는 일 401 TOKEN_MISSING 토큰이 없다 로그인 화면으로 401 TOKEN_INVALID 서명이 맞지 않는다 토큰을 지우고 로그인 화면으로 401 TOKEN_EXPIRED 8시간이 지났다 로그인 화면으로, "다시 로그인해 주세요" 401 TOKEN_MISMATCH 최신 토큰이 아니다 강제 로그아웃, "다른 기기에서 로그인되었습니다" 401 LOGIN_FAILED 비밀번호 불일치 detail.remaining 으로 남은 횟수 표시 403 ACCOUNT_LOCKED 5회 실패로 잠김 잠금 안내와 비밀번호 찾기 링크 403 ACCOUNT_DORMANT 12개월 미접속 휴면 휴면 해제 화면으로 403 ACCOUNT_REJECTED 승인 반려 반려 사유 표시 403 NOT_APPROVED 승인 전 계정이 업무 API를 불렀다 승인 대기 화면으로 403 FORBIDDEN 권한 없음 접근 불가 안내 400 VALIDATION_FAILED 입력값 오류 detail.fields 로 항목별 표시 400 SOCIAL_ACCOUNT 소셜로 가입된 이메일로 비밀번호 로그인 시도 "카카오로 가입된 계정입니다" 안내 400 CODE_MISMATCH, CODE_EXPIRED, CODE_ATTEMPTS_EXCEEDED 인증번호 문제 각각 다른 문구, 마지막은 재발송 유도 409 DUPLICATE_EMAIL 이미 가입된 이메일 로그인 유도 429 CODE_COOLDOWN 60초 안에 재발송 요청 카운트다운 표시 502 MAIL_SEND_FAILED 사내 메일 서버 실패 "잠시 후 다시 시도" 안내 계정 상태도 코드로 정했다. 로그인 응답의 member.status 가 이 값 중 하나고, 화면은 이 값으로 첫 화면을 고른다. status 뜻 로그인하면 PENDING_INFO 소셜 가입 직후, 추가정보 입력 전 토큰 발급, 추가정보 화면으로 PENDING_APPROVAL 승인 대기 토큰 발급, 대기 화면만 보인다 ACTIVE 승인 완료 정상 REJECTED 반려 403 ACCOUNT_REJECTED LOCKED 5회 실패 잠금 403 ACCOUNT_LOCKED DORMANT 12개월 미접속 403 ACCOUNT_DORMANT WITHDRAWN 탈퇴 로그인 차단 승인 전에도 로그인이 되게 한 이유는 문의를 줄이기 위해서다. 로그인을 막으면 본인이 승인 대기 중인지 비밀번호를 틀린 것인지 구분을 못 한다. 로그인은 되게 하고 대기 중이라는 것을 화면으로 보여주는 쪽이 문의가 덜 온다. 대신 승인 전 계정이 업무 API를 부르면 서버가 NOT_APPROVED를 내린다. 메뉴만 숨긴 것이 아니라 서버가 막는다. 로그인 응답은 이렇게 생겼다. { "success": true, "data": { "accessToken": "eyJhbGciOiJIUzI1NiJ9...", "expiresIn": 28800, "member": { "memberNo": 1042, "name": "김담당", "role": "PARTNER", "status": "ACTIVE", "passwordChangeRequired": false, "termsReagreeRequired": true } }, "error": null } passwordChangeRequired 는 관리자가 임시 비밀번호로 발급한 계정의 첫 로그인을 비밀번호 변경 화면으로 보내기 위한 것이고, termsReagreeRequired 는 약관이 개정돼 재동의가 필요하면 팝업을 띄우기 위한 것이다. 둘 다 서버가 판단하고 화면은 값만 본다. 에러 코드 표가 있으면 화면 쪽 공통 처리가 한 곳에 모인다. jQuery의 전역 Ajax 설정에 토큰을 붙이는 일과 에러 코드를 처리하는 일을 넣었다. // common/api.js (function () { var TOKEN_KEY = 'accessToken'; function getToken() { return localStorage.getItem(TOKEN_KEY); } // 만료 선검사. 서명 검증이 아니라 디코딩만이다. // 만료 토큰으로 401을 받는 왕복을 줄이는 용도고, 진짜 검증은 서버 필터가 한다. function isExpired(token) { try { var payload = jwt_decode(token); return payload.exp * 1000 <= Date.now(); // exp는 초 단위라 1000을 곱한다 } catch (e) { return true; } } function forceLogout(message) { localStorage.removeItem(TOKEN_KEY); if (message) alert(message); location.href = '/login.do'; } $.ajaxSetup({ beforeSend: function (xhr, settings) { if (settings.skipAuth) return; // 로그인, 가입 같은 공개 API var token = getToken(); if (!token || isExpired(token)) { forceLogout('로그인이 필요합니다.'); return false; // 요청 자체를 보내지 않는다 } xhr.setRequestHeader('Authorization', 'Bearer ' + token); } }); // 모든 Ajax 실패가 한 번 거쳐 가는 자리 $(document).ajaxError(function (event, xhr, settings) { if (settings.skipGlobalError) return; var body = xhr.responseJSON || {}; var code = body.error && body.error.code; switch (code) { case 'TOKEN_MISSING': case 'TOKEN_INVALID': case 'TOKEN_EXPIRED': forceLogout('로그인이 만료되었습니다. 다시 로그인해 주세요.'); break; case 'TOKEN_MISMATCH': forceLogout('다른 기기에서 로그인되었습니다.'); break; case 'NOT_APPROVED': location.href = '/member/pending.do'; break; case 'ACCOUNT_DORMANT': location.href = '/member/reactivate.do'; break; default: // 400대 검증 오류는 화면별 처리에 맡기고, 여기서는 서버 오류 공통 문구만 띄운다 if (xhr.status >= 500) alert('일시적인 오류입니다. 잠시 후 다시 시도해 주세요.'); } }); })(); 화면마다 .fail() 에서 토큰 만료를 처리했다면 화면 수만큼 같은 코드가 생겼을 것이다. 코드 표를 먼저 정한 덕에 이 파일 하나로 끝났고, 나중에 "다른 기기에서 로그인되었습니다" 문의가 1위로 올라왔을 때도 고칠 곳이 한 곳이었다. 1-4. 서버 필터: 세 가지 검증과 URL별 권한 서버 쪽은 필터 하나가 문지기다. 순서는 토큰 파싱 => 서명 검증 => 만료 검증 => DB의 최신 토큰과 비교 => URL 권한 확인이다. 앞의 둘은 토큰만 보면 되고 세 번째만 DB를 본다. JWT인데 매 요청 DB를 보면 무상태의 의미가 줄어드는 것은 맞다. 다른 기기 로그인을 끊는 것이 요구사항이어서 최신 토큰 비교 하나만큼은 서버 상태를 뒀다. public class JwtAuthFilter implements Filter { private final JwtProvider jwtProvider; // 서명 검증, 만료 검증, 클레임 추출 private final MemberMapper memberMapper; // 최신 토큰, 역할, 상태 조회 private final UrlPolicy urlPolicy; // URL별 권한 규칙 @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; String path = request.getRequestURI(); if (urlPolicy.isPublic(path)) { // 로그인, 가입, 인증번호, 비밀번호 찾기 chain.doFilter(req, res); return; } String token = resolveBearer(request.getHeader("Authorization")); if (token == null) { ApiError.write(response, 401, "TOKEN_MISSING", "로그인이 필요합니다."); return; } Claims claims; try { claims = jwtProvider.parse(token); // 1) 서명과 2) 만료를 여기서 함께 검증한다 } catch (ExpiredJwtException e) { ApiError.write(response, 401, "TOKEN_EXPIRED", "로그인이 만료되었습니다."); return; } catch (JwtException e) { ApiError.write(response, 401, "TOKEN_INVALID", "유효하지 않은 토큰입니다."); return; } Long memberNo = claims.get("memberNo", Long.class); // 최신 토큰을 보러 DB에 가는 길에 역할과 상태도 같이 읽는다. // 상태는 승인 직후 바로 반영돼야 하니 토큰 클레임이 아니라 DB 값을 쓴다. MemberAuth auth = memberMapper.selectAuthInfo(memberNo); if (auth == null || !token.equals(auth.getLatestToken())) { // 3) 최신 토큰인가 ApiError.write(response, 401, "TOKEN_MISMATCH", "다른 기기에서 로그인되었습니다."); return; } if (urlPolicy.isAdmin(path) && !"ADMIN".equals(auth.getRole())) { ApiError.write(response, 403, "FORBIDDEN", "권한이 없습니다."); return; } if (urlPolicy.requiresActive(path) && !"ACTIVE".equals(auth.getStatus())) { ApiError.write(response, 403, "NOT_APPROVED", "승인 대기 중인 계정입니다."); return; } request.setAttribute("memberNo", memberNo); // 컨트롤러와 쿼리가 이 값을 쓴다 chain.doFilter(req, res); } private String resolveBearer(String header) { if (header == null || !header.startsWith("Bearer ")) return null; return header.substring(7); } } URL 규칙은 이렇게 나눴다. 경로 규칙 /api/auth/** 공개 /api/admin/** 관리자만 /api/members/me/additional-info, /api/members/me/agreements 로그인만. 승인 전 계정도 써야 하는 API 그 외 /api/** 로그인하고 승인된(ACTIVE) 계정만 기본은 차단이다. 열어둘 것만 적고 나머지는 승인된 계정만 통과한다. 이것이 첫 겹이다. 두 번째 겹은 영구 삭제 같은 민감한 기능의 service 안에서 권한을 한 번 더 확인하는 것이고, 세 번째 겹은 수정과 삭제 쿼리의 WHERE에 로그인한 회원 번호를 넣는 것이다. 권한 필터를 통과한 사람이 남의 회원 번호를 요청 본문에 넣어 보내도 자기 행만 바뀐다. <update id="updateMyInfo"> UPDATE member SET manager_name = #{managerName}, manager_phone = #{managerPhone}, updated_at = now() WHERE member_no = #{memberNo} <!-- 필터가 넣어준 로그인 회원 번호. 요청 본문의 값이 아니다 --> </update> 버튼을 숨기는 것은 화면을 정리하는 일이고, 막는 일은 이 세 겹이 한다. 1-5. 외부 API를 부른 쪽: 네이버, 카카오, 메일 만든 API만 있는 것이 아니다. 부른 API가 셋 있다. 네이버 로그인, 카카오 로그인, 사내 SMTP 메일이다. 이 중 네이버가 가장 손이 많이 갔다. 네이버는 SDK를 걷어내고 인가 URL을 직접 조립했다. 같은 PC에서 로그아웃한 뒤 다음 사람이 네이버 버튼을 누르면 이전 사람 계정으로 바로 들어가지는 문제가 있었고, 원인은 네이버 쪽 로그인 쿠키가 남아 있어서였다. 매번 재인증을 강제하는 auth_type=reauthenticate 값을 붙여야 했는데 네이버 SDK에는 그 값을 받는 옵션이 없었고, 주소는 SDK가 내부에서 만들어서 끼워 넣을 수도 없었다. 그래서 문서를 보고 URL을 직접 만들었다. // 네이버 로그인 버튼. SDK 없이 인가 URL을 문서대로 조립한다. function openNaverLogin() { var state = crypto.getRandomValues(new Uint32Array(1))[0].toString(16); sessionStorage.setItem('naverState', state); var url = 'https://nid.naver.com/oauth2.0/authorize' + '?response_type=token' // 토큰이 콜백 URL의 해시(#)에 실려 온다
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
API 만든 것과 바꾼 것: 유라와 KT. 현대중공업 글에서는 백엔드가 쓴 API 명세를 읽고 화면을 붙인 이야기를 했다. 그 글에서 나는 API를 쓰는 쪽이었다. 명세가 먼저 있었고, 나는 그 계약대로 요청을 보내고 응답을 그렸다. 유라와 KT는 위치가 달랐다. 유라에서는 회원 기능 API의 요청과 응답 형태, 에러 코드까지 내가 정하고 화면도 내가 붙였다. 만드는 쪽과 쓰는 쪽을 한 사람이 다 쥔 경우다. KT에서는 이미 돌아가던 조회 API의 계약을 성능 때문에 바꿨다. 응답 필드를 빼고 요청 필드를 넣고 선택 항목을 필수로 만드는 일이었고, 화면과 서버를 같은 배포에 맞춰야 했다. 세 경험을 한 줄씩 놓으면 이렇다. 회사 내 위치 명세는 어디에 계약을 정한 사람 현대중공업 명세를 읽고 화면을 붙였다…
Open source