GROWTH LOG STORIES

함께 만든 성장을,
기록으로 연결합니다.

AI와 함께 배우고, 직접 만들고, 서로 나눈
그로스로그 멤버들의 생생한 성장 기록을 만나보세요.

성장 기록
5기+이어진 커뮤니티
HEALTHIO
PROJECT

사진 한 장으로 시작하는 건강 루틴, Health IO

대표 이야기 읽기

ALL STORIES

성장의 순간을
한곳에서 만나보세요.

프로젝트의 시작부터 모임에서 나눈 인사이트까지,
그로스로그가 함께 만든 기록을 모았습니다.
개의 성장 기록

성장일지

[GL 5기 3회차 ①] 어디에 두느냐가 성질을 바꾼다 — 앱 무결성 검증, 뷰가 아는 것, 컴포넌트 경계 🌱

GROWTH LOG🌱 2026. 8. 13. 02:35

안녕하세요! 그로스로그 입니다!😊 🌱

검증 로직을 어디 두느냐, 판단 근거를 어디 두느냐, 경계를 어디 긋느냐. 세 편 다 같은 종류의 결정을 다룹니다.

5기 3회차에는 iOS 보안, Django 리팩토링, Next.js 아키텍처가 나란히 올라왔습니다. 층이 다 다른데 고민의 모양이 같아요.

 

01

🔓 [Swift/iOS] Jailbreak Detection(탈옥 감지)의 필요성

주한솔 님

이런 내용이에요
iOS의 샌드박스코드 서명이 탈옥으로 어떻게 무너지는지, 그 위에서 공격자가 실제로 무엇을 할 수 있는지를 다섯 가지로 짚습니다.
탈옥 감지 기법을 코드와 함께 소개하고, 각각의 우회 가능성까지 같이 적어요. 특히 rootless 탈옥으로 경로가 바뀐 부분을 반영합니다.
후반부가 이 글의 핵심입니다. Team ID 해시 비교 방식을 직접 구현하고 탈옥 환경에서 테스트했더니 뚫렸다는 기록, 그리고 그 원인이 무엇이었는지요.

보안 글인데 "이렇게 막으세요"가 아니라 "이건 이렇게 뚫립니다"를 같이 씁니다.

조금 더 들어가 보면

도입부가 정직합니다. 보안 작업이 왜 뒤로 밀리는지부터 인정하고 시작해요.

"애플의 보안성을 믿기도 하고, 요즘도 탈옥하는 사람이 있나? 싶은 마음도 있습니다. 기능 개발도 바쁜데, 탈옥 감지나 앱 무결성 검증까지 신경 써야 하나 싶기도 합니다."

그래서 먼저 탈옥이 무엇을 무너뜨리는지를 구조로 설명합니다. iOS의 모든 앱은 샌드박스라는 독립된 방 안에서 돌고, 앱스토어 앱은 코드 서명으로 검증됩니다. 탈옥은 커널 취약점으로 이 두 가지를 동시에 해제하는 일이에요.

여기서 최신 상황을 반영한 대목이 좋습니다. 과거의 탈옥은 루트 권한 획득이 주였지만, 현대 도구들은 PPL(Page Protection Layer) — 커널조차 함부로 못 건드리게 ARM 하드웨어가 한 겹 더 지켜주는 자물쇠 — 마저 우회하거나, 아예 건드리지 않으면서 코드 서명 제약만 무너뜨린다는 것. 이걸 rootless 탈옥이라 부릅니다.

⚔️ 탈옥 위에서 가능한 일 다섯 가지

"탈옥은 앱 하나 더 설치하는 수준이 아니다"라는 말을 다섯 개로 풀어냅니다.

· 런타임 조작 — Frida 같은 툴로 실행 중인 함수를 후킹합니다. 로그인 판정 함수를 후킹하면 어떤 비밀번호를 넣어도 "인증 성공"이 나옵니다

· 네트워크 트래픽 가로채기 — 여기 설명이 특히 좋습니다. SSL Pinning 우회는 탈옥 없이도 가능하지만, 탈옥 환경에서는 TLS 스택 자체를 후킹할 수 있어요. 암호화되기 전이나 복호화된 직후 데이터에 바로 접근하니, "SSL Pinning 로직이 아무리 정교해도 의미가 없어집니다"

· 메모리 덤프 — 암호화해서 저장한 데이터도 쓰려면 복호화해서 메모리에 올려야 합니다. 그 순간을 노리면 토큰과 세션 키가 나옵니다

· 바이너리 위변조 후 재배포 — 겉보기엔 같은 앱인데 개인정보가 조용히 빠져나갑니다

· 결제·인앱구매 우회 — 검증 함수를 후킹해 실제 결제 없이 "결제 완료"로

메모리 덤프 항목에 붙인 한 줄이 남습니다. "'나는 저장할 때 암호화하니까 괜찮다'는 생각이 왜 충분하지 않은지 보여주는 사례입니다." 저장은 정지 상태의 보호이고, 사용은 반드시 평문을 거친다는 것이죠.

🧩 감지 기법마다 우회법을 같이 적었다

탈옥 감지 기법을 소개할 때 각각의 신뢰도까지 함께 적습니다.

파일 존재 확인이 대표적입니다. 예전 방식만 알고 있으면 요즘 탈옥은 못 잡아요.

private func checkForJailbreakFiles() -> Bool {
    let paths = [
        // rooted 탈옥 환경 경로
        "/Applications/Cydia.app",
        "/bin/bash",
        "/etc/apt",
        // rootless 탈옥 환경 경로 (palera1n 등)
        "/var/jb/Applications/Cydia.app",
        "/var/jb/usr/bin/ssh",
        "/var/jb/etc/apt"
    ]
    return paths.contains { FileManager.default.fileExists(atPath: $0) }
}

rootless 환경에서는 탈옥 관련 파일이 /var/jb/ 하위로 옮겨갔기 때문에 양쪽 경로를 다 봐야 합니다. 감지 로직도 탈옥 방식의 진화에 맞춰 갱신해야 한다는 뜻이에요.

코드 서명 플래그(CS_VALID) 확인 방식에 대해서도 한계를 답니다. 구형 탈옥에서는 이 플래그가 꺼졌지만 현대 도구들은 CS_VALID를 유지한 채 검증만 선택적으로 우회하는 방향으로 진화했다고요. 그래서 "이 방법은 현대 탈옥 환경에서 신뢰도가 제한적이므로, 다른 감지 기법과 함께 보조적으로 사용하는 것이 적절합니다."

📝 원문에서는 이렇게 뚫렸습니다

이 글에서 가장 값어치 있는 부분입니다. Team ID 해시 비교 방식을 구현했어요. 앱의 Bundle ID와 Team ID를 SHA256으로 해싱해 하드코딩된 값과 비교하는 방식입니다.

private func checkToAreIDsNotMatched() -> Bool? {
    guard let bundleID = Bundle.main.bundleIdentifier else { return nil }
    guard let teamID = getTeamID() else { return nil }
    guard let bundleHash = getSha256(bundleID),
          let teamHash  = getSha256(teamID) else { return nil }

    // Bundle ID 해시와 Team ID 해시 중 하나라도 원본과 다르면 위변조로 판단
    if bundleHash != "..." || teamHash != "..." { return true }
    return false
}

그리고 실제로 돌려본 결과를 적습니다.

"이 방식을 실제로 구현하고 탈옥 환경에서 테스트해봤을 때, 위변조된 앱을 제대로 잡지 못하는 경우가 있었습니다."

처음엔 getTeamID()가 쓰는 Keychain API가 후킹당한 걸 의심했다고 해요. Frida로 SecItemAddSecItemCopyMatching을 후킹하면 항상 정상 Team ID를 돌려주게 만들 수 있으니까요.

그런데 더 아래에 원인이 있었습니다.

"하드코딩된 해시값과 비교하는 로직 자체가 바이너리 안에 들어 있다는 것입니다. 공격자는 (…) 해시 비교 조건을 찾아낸 뒤, 그 분기 자체를 패치해버릴 수 있습니다. getTeamID()가 뭘 반환하든 상관없이, 비교 로직이 항상 '일치함'을 반환하도록 바이너리를 수정해버리는 것입니다."

입력을 아무리 잘 지켜도 판정하는 코드가 상대 손안에 있으면 소용없다는 겁니다. 글은 이걸 "클라이언트 무결성 검증의 근본적인 한계"라고 부릅니다.

🛡️ 그래서 검증을 밖으로 옮긴다 — App Attest

해결은 검증 로직의 위치를 바꾸는 것입니다. Apple이 iOS 14부터 제공하는 App Attest는 클라이언트가 Apple 서버에 무결성 증명을 요청하고, 그 증명을 우리 서버가 Apple 인프라를 통해 검증하는 구조예요.

"핵심은 검증 로직이 클라이언트 바이너리가 아닌 서버와 Apple 인프라에 있다는 것입니다. 공격자가 클라이언트 코드를 아무리 뜯어봐도 검증을 우회할 수 없습니다."

여기서 두 가지를 짚어둔 것도 실무적입니다.

· challenge의 역할 — 서버가 일회성 난수를 주는 이유는 리플레이 공격을 막기 위해서입니다. 없으면 정상 기기에서 한 번 통과한 증명을 녹화해뒀다가 변조된 앱에서 그대로 제출할 수 있어요

· attestKey()는 최초 1회만 — 매 실행마다 무결성을 증명할 때는 attestKey()가 아니라 generateAssertion()을 씁니다. 반복 호출하면 rate limit을 소진하고 Apple이 의도한 설계에서도 벗어납니다

그리고 App Attest에도 한계를 답니다 — 시뮬레이터와 일부 기기에서 미지원, 네트워크가 필요하니 오프라인 정책도 정해야 한다고요.

큐레이터 노트

자기가 구현한 방어가 뚫린 기록을 남긴 글이라 뽑았습니다.

보안 글에서 가장 보기 어려운 게 이겁니다. 대개 "이렇게 하면 막힙니다"로 끝나요. 이 글은 Team ID 검증을 코드까지 다 보여준 다음 "돌려봤더니 안 잡히더라"를 적고, 원인을 두 단계로 파고듭니다. 처음 의심한 것(Keychain 후킹)과 실제 원인(비교 분기 자체가 패치 가능)이 다르다는 것까지요.

한 기법을 소개할 때마다 우회법을 붙인 것도 같은 태도입니다. 파일 경로 확인은 rootless로 무력화되고, CS_VALID는 현대 도구가 유지한 채 우회하고, 클라이언트 해시 비교는 바이너리 패치로 뚫립니다. 이러면 읽는 사람이 "내 앱의 방어선이 지금 어느 단계인지"를 셀 수 있습니다.

결론이 "완벽한 방어"가 아니라 비용의 언어인 것도 좋았습니다.

"각각의 기법은 하나씩 떼어놓으면 우회 가능하지만 여러 겹으로 쌓이면 공격자의 부담이 기하급수적으로 늘어납니다. (…) 하나가 뚫려도 다음 레이어가 버텨주는 구조, 이것이 보안을 설계하는 기본 구조인 것입니다."

막을 수 있느냐가 아니라 얼마나 귀찮게 만들 수 있느냐로 목표를 다시 잡은 것입니다. 앞에서 "이 앱은 공격하기 귀찮다는 인식을 심어주는 것만으로도 의미가 있습니다"라고 적은 것과 이어져요.

원문 읽으러 가기  ↗modelinspring.tistory.com/99
 

02

🧹 6개월만의 리팩토링 feat. 라이프다이어리

송영록 님

이런 내용이에요
하루를 10분 단위 144개 슬롯으로 기록하는 서비스 Life Diary를 리팩토링한 기록입니다. Django 프로젝트가 커지면서 뷰가 무거워진 과정을 먼저 짚어요.
붙잡은 질문이 하나입니다 — "뷰는 어디까지 책임져야 하는가." 뷰에 남길 것 네 가지와 덜어낼 것 다섯 가지를 나누고, 덜어낸 것을 Use Case와 Repository로 옮깁니다.
후반부는 운영 비용입니다. 쿼리 수, 캐시 TTL, 세션 write, DB 연결 재사용까지. 마지막엔 "측정해야 알 수 있다"로 맺습니다.

리팩토링 기록인데 새 아키텍처를 도입한 이야기가 아니라 판단의 자리를 옮긴 이야기입니다.

조금 더 들어가 보면

시작이 Django에 대한 옹호입니다. 처음엔 뷰에서 다 하는 게 빠르고 자연스럽다고요. 문제는 나중에 생깁니다.

"처음에는 단순한 저장 로직이었던 코드에 권한 검증이 붙고, 중복 처리와 트랜잭션이 붙고, 통계 캐시 무효화가 붙는다. (…) 뷰는 요청을 받아 응답하는 입구라기보다, 서비스의 여러 판단을 함께 떠안는 위치가 된다."

시간 기록 저장 하나가 실제로는 이렇게 됩니다 — 슬롯 인덱스가 0~143인지 확인, 메모 길이 확인, 태그가 기본 태그이거나 본인 소유인지 확인, 기존 슬롯이면 수정하고 빈 슬롯이면 생성, 저장 끝나면 통계 캐시 무효화.

그리고 진단이 정확합니다.

"불편했던 것은 뷰의 길이 자체가 아니었다. 진짜 부담은 뷰가 너무 많은 판단 근거를 알고 있다는 데 있었다."

"뷰를 얇게"가 줄 수를 줄이는 일이 아니라는 것. 무엇을 알고 있느냐의 문제라는 겁니다.

✂️ 남길 것과 옮길 것을 목록으로 나눈 것

그래서 나눕니다. 뷰에 남길 것은 넷 — 요청 파싱, Command 객체 생성, Use Case 호출, 결과를 응답으로 변환.

덜어낼 것은 다섯 — 태그 접근 권한 확인, 기존/신규 분기 처리, 트랜잭션 경계 설정, 통계 캐시 무효화, 여러 Repository를 조합하는 처리 순서.

기준도 한 줄로 답니다. 덜어낼 것들은 "HTTP 요청 자체보다 기능의 처리 순서에 더 가깝다"는 것. 사용자가 "시간을 기록한다"는 기능은 API로 들어오든 다른 인터페이스로 들어오든 본질이 같으니까요.

그리고 목표를 낮춰 잡은 것도 좋습니다.

"목표는 완전한 클린 아키텍처가 아니었다. 그 정도의 층위를 감당해야 할 규모도 아니었다."

🔐 트랜잭션을 어디에 둘 것인가

같은 질문이 반복됩니다. bulk_createbulk_update가 함께 도는데 하나만 성공하면 데이터가 반쯤 저장됩니다. 그래서 transaction.atomic을 넣는데, 어디에 넣느냐가 문제였어요.

답이 한 문장으로 정리됩니다.

"Repository는 저장 방법을 안다. Use Case는 저장 과정 전체를 안다."

그러니 "처리 전체가 하나의 단위로 성공해야 한다"는 판단은 Use Case에 둡니다. 아는 범위가 곧 책임의 범위라는 기준이에요.

⚠️ bulk_create가 지나가는 검증

실무에서 걸리기 쉬운 함정 하나를 짚습니다.

"bulk_create는 개별 모델 인스턴스의 full_clean()을 호출하지 않는다. 따라서 모델에 적어둔 검증 규칙이 있다고 해서 모든 저장 경로에서 자동으로 보장되지는 않는다."

모델에 clean()을 써두면 모든 저장에서 돌 것 같은데 아니라는 겁니다. 그래서 Command DTO(UpsertTimeBlocksCommand)를 둬서 Use Case로 넘기기 전에 검증합니다. 이유가 명확해요.

"사용자의 요청은 불안정하다. DB에 들어가는 데이터는 안정적이어야 한다. 그 사이에는 검증 경계가 필요하다."

📝 원문에서는 이렇게 정리합니다

Repository를 둔 이유도 흔한 답과 다릅니다. ORM을 감추려는 게 아니라 조회 의도를 한곳에 모으려는 것이었다고요.

find_by_date
find_by_date_range
find_by_slots
is_tag_in_use
"쿼리가 각 뷰와 서비스에 흩어져 있으면 같은 개념을 서로 다른 방식으로 조회하게 된다. 반대로 Repository에 모아두면 조회 방식에 이름이 생긴다."

그리고 그 효과가 성능으로 이어집니다. 주간 통계가 날짜별 조회 7번에서 기간 조회 1번 + Python 그룹핑으로 바뀌었는데, "Repository에 조회 코드가 모여 있었기 때문에 비교적 안전하게 바꿀 수 있었다"고요.

운영 비용 정리도 구체적입니다. 캐시 TTL을 데이터 성격에 따라 갈랐어요.

· 과거 날짜 24시간 — 기록이 거의 변하지 않으니까

· 오늘 날짜 5분 — 자주 바뀌니까

기술적으로 복잡하지 않은데 서비스의 성격을 반영한 선택입니다. 세션 설정도 손봤어요. SESSION_SAVE_EVERY_REQUEST를 끄고 CONN_MAX_AGE로 DB 연결을 재사용하도록요.

그런데 마지막이 이 글에서 제일 좋습니다.

"쿼리 수를 줄이고, 캐시를 넣고, 세션 write를 줄였다고 해서 효과가 자동으로 증명되는 것은 아니다. 구조상 더 나아졌다고 말할 수는 있지만, 실제 운영에서 얼마나 좋아졌는지는 측정해야 한다."

큐레이터 노트

개선했다고 주장하지 않고 측정해야 한다고 적은 글이라 뽑았습니다.

리팩토링 기록의 흔한 마무리는 "훨씬 깔끔해졌다"입니다. 이 글은 쿼리 7번을 1번으로 줄이고 캐시를 넣고 세션 write를 줄인 다음, 그게 실제로 얼마나 좋아졌는지는 아직 모른다고 씁니다. 그리고 다음 단계로 MetricsMiddleware를 도입해 응답 시간과 쿼리 수 추이를 보겠다고 이어가요. 한계를 명시하고 다음 행동까지 붙인 형태입니다.

용어를 앞세우지 않은 것도 좋았습니다. Use Case, Repository, Command DTO를 다 쓰는데 "Use Case라는 이름은 다소 거창하게 들릴 수 있다. 하지만 (…) 뷰에 섞여 있던 처리 순서를 옮겨 놓는 용도에 가까웠다"고 먼저 눌러둡니다. 클린 아키텍처를 목표로 삼지 않았다고도 분명히 하고요.

의존성을 다룬 대목의 관찰도 정확했습니다. 순환 참조가 없어도 의존성은 불편해질 수 있다는 것. users.viewsstats.logic을 직접 부르는 게 당장 오류를 내진 않지만, "stats.logic의 내부 구성을 바꾸려 할 때, 통계 화면뿐 아니라 마이페이지까지 함께 고려해야 한다"는 비용이 생깁니다. 그걸 정리하면서 필요한 통계만 계산하게 바꿨더니 의존도 줄고 계산도 줄었다는 대목 — "코드를 나누는 일이 성능 개선과 만나는 지점" — 이 이 글의 압축입니다.

원문 읽으러 가기  ↗youngroknroll.github.io/posts/lifediadry_refactoring/
 

03

🧱 Server Components가 가져온 '컴포넌트 경계'의 재정의

이상원 님

이상원 님 원문에 실린 이미지
이런 내용이에요
Next.js App Router에서 'use client'가 엉뚱한 곳까지 번져나가는 문제에서 출발합니다. 버튼 하나에 onClick을 달았을 뿐인데 부모의 부모까지 클라이언트 번들에 들어가는 상황이요.
진단은 이렇습니다 — 클라이언트 경계를 처음부터 잘못 그어놓은 것. RSC가 들어오면서 컴포넌트를 나누는 축에 실행 환경이 하나 더 생겼다고요.
팀에서 여러 서비스를 만들며 정한 경계 설계 원칙 세 가지를 코드와 함께 공유합니다.

기능 소개는 넘치는데 "이 컴포넌트를 서버로 둘 것인가 클라이언트로 둘 것인가"의 감각은 정리된 게 없더라는 문제의식에서 나온 글입니다.

조금 더 들어가 보면

왜 경계를 다시 배워야 하는지를 두 가지로 답니다.

첫째, 번들 사이즈입니다. 기존 SPA에서는 어차피 전부 번들에 들어가니 "여기는 번들에 포함되면 안 돼"라는 감각 자체가 필요 없었습니다. 그런데 RSC에서는 서버에 둘 수 있었던 컴포넌트를 무심코 클라이언트로 만드는 순간 그 컴포넌트가 의존하는 모든 라이브러리가 브라우저로 날아갑니다. date-fns 하나 잘못 끌고 들어와 초기 로딩 JS가 수십 KB 늘어나는 게 흔한 사고라고요.

둘째, 보안 경계입니다. Server Component 안에서는 DB 클라이언트·환경변수·파일 시스템에 접근할 수 있는데, 이 컴포넌트가 어떤 경로로든 클라이언트 번들에 편입되면 민감한 키가 브라우저로 노출됩니다.

"컴포넌트 경계가 곧 보안 경계가 되어버린 셈이죠. 더 이상 '프론트/백엔드'라는 서버 단위의 구분만으로는 이 경계를 지킬 수 없게 되었습니다."

🦠 'use client'의 전파 방향

이 글에서 가장 중요한 교정이 여기 있습니다.

"'use client'는 '여기서부터 클라이언트'가 아니라 '여기가 경계'라는 표식입니다. 대부분의 사고는 이 지시어를 '아, 이 컴포넌트만 클라이언트구나'로 이해한 데서 시작됩니다. 사실은 '여기부터 아래는 전부 클라이언트 번들'이라는 뜻입니다."

방향도 정확히 정리합니다.

· 아래로만 전파됩니다. 그 파일부터 아래로 import된 모든 컴포넌트가 자동으로 Client Component가 됩니다

· 위로는 전파되지 않습니다. Client Component가 Server Component를 import로 끌어당길 수는 없어요

· 하지만 children이나 props로는 받을 수 있습니다. 이게 핵심입니다

마지막 항목이 뒤에 나오는 두 번째 원칙의 근거가 됩니다.

📐 세 가지 경계 설계 원칙

1. 경계는 가능한 한 leaf에 둔다

 

게시글 상세 페이지에 "좋아요 버튼"이 있다고 할 때, PostDetail 전체를 Client Component로 만들면 본문·작성자 정보·댓글 목록까지 전부 클라이언트로 내려옵니다. LikeButton만 따로 떼면 나머지는 서버 렌더링 HTML로 옵니다.

여기 붙은 부수 효과가 실무적이에요. "리프에 경계를 두는 습관을 들이면, PR 리뷰에서 '이 컴포넌트는 왜 클라이언트죠?'라는 질문에 답하기가 훨씬 쉬워집니다." 원칙이 리뷰의 언어가 되는 것입니다.

2. Composition으로 서버 데이터를 주입한다

 

// ClientProvider.tsx
'use client';
export function ClientProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState(...);
  return <Context.Provider value={state}>{children}</Context.Provider>;
}

// page.tsx (Server Component)
export default async function Page() {
  const data = await fetchFromDB();
  return (
    <ClientProvider>
      <ServerPostList data={data} />   {/* Server Component */}
    </ClientProvider>
  );
}

글이 이 패턴을 설명한 문장이 정확합니다.

"Context Provider 같은 클라이언트 전용 래퍼를 쓰면서도 그 내부의 콘텐츠는 여전히 서버에서 렌더링된다는 점입니다. 경계가 '위아래'가 아니라 '바깥과 안'의 관계가 되는 것이죠."

3. 경계를 넘는 데이터는 직렬화 가능해야 한다

 

Server → Client로 props를 넘기면 그 값은 네트워크를 타고 직렬화됩니다. 그래서 함수·클래스 인스턴스·Map·Set은 못 넘기거나 주의가 필요해요.

📝 원문에서는 이렇게 컨벤션을 만듭니다

세 번째 원칙에 실제 사고 기록이 붙어 있습니다.

"이 규칙을 깜빡하고 Prisma 모델 인스턴스를 그대로 Client Component에 넘기다가 런타임 에러를 마주하는 일이 초기에 정말 잦았습니다."

해결이 개인의 주의가 아니라 팀 규칙입니다. "우리는 경계를 넘기기 직전에 반드시 plain object로 변환하는 매퍼 함수를 거치도록 컨벤션을 정했습니다."

그리고 서버/클라이언트가 각각 무엇을 할 수 있고 없는지를 표로 정리해뒀습니다.

  Server Component Client Component
데이터 접근 DB 직접 쿼리·파일 시스템·환경변수 브라우저 API·상태 관리·이벤트
React 기능 async/await 함수 컴포넌트 모든 훅, 이벤트 핸들러
불가능한 것 useState·useEffect·이벤트 핸들러 서버 전용 API·async 함수 컴포넌트
번들 영향 포함되지 않음 포함됨

큐레이터 노트

한 지시어의 뜻을 고쳐 잡은 글이라 뽑았습니다.

'use client'를 "이 컴포넌트는 클라이언트"로 읽느냐 "여기가 경계"로 읽느냐. 이 한 글자 차이에서 대부분의 사고가 시작된다는 진단이 이 글의 중심입니다. 그리고 그 진단에서 세 원칙이 자연스럽게 따라 나와요 — 경계니까 말단에 두고(1), 경계 너머는 꽂아 넣고(2), 경계를 넘는 값은 규격을 맞춥니다(3).

팀에서 겪은 것을 컨벤션으로 만든 흔적이 좋았습니다. Prisma 인스턴스를 그대로 넘겨 터진 경험이 매퍼 함수 규칙이 됐고, leaf에 경계를 두는 원칙은 PR 리뷰 질문으로 이어집니다. 개인의 교훈이 팀의 절차가 되는 지점이 기록돼 있어요.

문제 제기의 방식도 좋았습니다. "기능 소개 글은 이미 차고 넘치는데, 막상 실무에서 (…) 결정하는 감각은 글로 잘 정리되어 있지 않았습니다."자료가 없는 자리를 정확히 짚고 그 자리를 채우는 글입니다.

원문 읽으러 가기  ↗vpvm96.tistory.com/86

💡 세 편을 겹쳐 읽으며

iOS 보안, Django 백엔드, Next.js 프론트엔드. 세 층이 다 다른데 세 분이 같은 종류의 결정을 붙들고 있습니다.

무엇을 어디에 두느냐가 그것의 성질을 바꾼다

주한솔 님 글에서 이게 가장 극적으로 드러납니다. Team ID 해시 비교는 로직 자체는 멀쩡합니다. 문제는 그 로직이 공격자의 손안(클라이언트 바이너리)에 있다는 것이었어요. 같은 검증을 서버로 옮기니(App Attest) 우회할 수 없게 됩니다. 코드는 그대로인데 위치가 성질을 바꿉니다.

송영록 님 글도 같습니다. 태그 권한 확인이나 캐시 무효화는 어디에 있어도 동작은 합니다. 뷰에 있으면 뷰가 무거워지고, Use Case에 있으면 기능의 처리 순서가 됩니다. "뷰가 너무 많은 판단 근거를 알고 있다"는 게 문제였지 코드가 틀린 게 아니었어요.

이상원 님 글의 LikeButton도 그렇습니다. 상위에 두면 그 아래 전부가 클라이언트 번들이 되고, 말단에 두면 나머지가 서버에 남습니다. 같은 버튼인데 놓인 자리가 번들 크기와 보안 경계를 바꿉니다.

경계에는 규격이 필요하다

세 글 다 경계를 정한 다음, 그 경계를 넘는 것에 규칙을 붙였습니다.

이상원 님은 직렬화 가능한 값만 넘기도록 하고 매퍼 함수를 거치는 컨벤션을 만들었습니다. 송영록 님은 Command DTO를 둬서 "사용자의 요청은 불안정하고 DB 데이터는 안정적이어야 하니 그 사이에 검증 경계가 필요하다"고 했고요. 주한솔 님의 challenge도 같은 성격입니다 — 서버와 클라이언트 사이를 오가는 증명이 재사용되지 않도록 일회성 난수를 강제합니다.

선을 긋는 것만으로는 부족하고, 그 선을 통과하는 것의 모양을 정해야 경계가 유지됩니다.

직접 해보고 안 되는 지점을 적었다

세 번째 공통점은 기록의 성격입니다.

주한솔 님은 구현하고 테스트했더니 안 잡혔다고 적었습니다. 송영록 님은 개선을 다 하고 나서 아직 측정하지 않았으니 증명된 건 아니다라고 적었고요. 이상원 님은 Prisma 인스턴스를 넘기다 런타임 에러를 자주 만났다고 적었습니다.

셋 다 성공담 사이에 실패를 끼워 넣었습니다. 그 대목이 이 글들을 소개서가 아니라 참고 자료로 만듭니다.

이번 회차에서 가져갈 것

1. 검증 로직이 검증 대상 안에 있는지 확인하기.

클라이언트에서 하는 무결성 검사는 비교 분기 자체를 패치당할 수 있습니다. 판정은 조작될 수 없는 쪽에 두어야 하고, 그게 서버 검증(App Attest 등)이 필요한 이유입니다.

2. 저장 시 암호화는 사용 시점을 지켜주지 않는다.

앱이 쓰려면 복호화해서 메모리에 올려야 하고, 탈옥 환경에서는 그 순간이 노출됩니다. 저장·전송·사용 세 구간을 따로 세어보세요.

3. 'use client'는 "여기가 경계"로 읽기.

아래로 전부 전파되니 상호작용이 필요한 가장 작은 단위에 붙입니다. 클라이언트 래퍼 안에 서버 콘텐츠를 넣고 싶으면 import가 아니라 children으로 꽂습니다.

4. "뷰를 얇게"는 줄 수가 아니라 아는 것의 문제.

요청 파싱과 응답 변환만 남기고, HTTP보다 기능의 처리 순서에 가까운 것은 밖으로 옮깁니다. 트랜잭션 경계는 "저장 방법을 아는 곳"이 아니라 "저장 과정 전체를 아는 곳"에 둡니다.

5. bulk_createfull_clean()을 부르지 않는다.

모델에 검증을 써뒀다고 모든 저장 경로가 덮이는 게 아닙니다. 벌크 경로가 있다면 저장 이전 경계에서 명시적으로 검증하세요.

6. 데이터 성격에 따라 캐시 TTL을 가르기.

과거 기록 24시간, 오늘 기록 5분처럼요. 기술적으로 어렵지 않은데 서비스의 성질을 캐시 정책에 반영하는 방법입니다.

7. 개선했으면 측정할 계획까지 세우기.

쿼리를 줄이고 캐시를 넣었다고 효과가 증명되는 건 아닙니다. 구조가 나아졌다는 말과 실제로 좋아졌다는 말은 다릅니다.


성장일지는 멤버들이 2주에 한 번씩 자신의 성장을 기록하는 활동이에요. 매 회차 운영진이 모든 글을 읽고, 그중 특히 마음에 남은 글을 함께 골라 큐레이션합니다.

좋은 글 남겨주신 주한솔 님, 송영록 님, 이상원 님께 감사드립니다! 😊

다음 큐레이션으로 다시 찾아오겠습니다! 🌱

- 에디터 · 성장일지 큐레이터 -


※ 본 큐레이션은 각 저자가 공개한 글을 소개하는 것이며, 모든 원문의 저작권은 저자에게 있습니다. 저자 본인의 요청이 있을 경우 즉시 수정 또는 삭제합니다.

WRITE YOUR GROWTH

오늘의 배움을
내일의 성장으로.

기록은 나를 돌아보게 하고, 공유는 우리를 연결합니다.
당신의 다음 성장 이야기도 그로스로그에서 시작해보세요.

커뮤니티 둘러보기