07-2 클로저와 데코레이터 목차 1. 클로저란? 2. 클래스를 이용한 방법 3. __call__ 메서드 4. 클로저 만들기 5. 데코레이터란? 6. @ 를 이용한 데코레이터 7. *args , ` kwargs`** 1. 클로저란? 클로저(Closure) 는 쉽게 말하면 외부 함수가 종료된 이후에도 외부 함수의 변수를 기억하고 사용하는 내부 함수 이다. 말로만 보면 조금 어렵기 때문에 예제를 통해 이해해 보자. 항상 3을 곱하는 함수 어떤 숫자를 입력하면 항상 3 을 곱해 주는 함수를 만들어 보자. def mul3(n): return n * 3 사용 print(mul3(10)) 실행 결과 30 이번에는 항상 5 를 곱하는 함수가 필요하다고 해보자. def mul5(n): return n * 5 사용 print(mul5(10)) 결과 50 하지만 필요한 숫자가 계속 늘어난다면 mul3() mul5() mul6() mul7() mul8() ... 처럼 함수를 계속 만들어야 한다. 이것은 비효율적이다. 2. 클래스를 이용한 방법 먼저 클래스를 사용하면 이 문제를 해결할 수 있다. class Mul: def __init__(self, m): self.m = m def mul(self, n): return self.m * n 객체를 만들어 보자. mul3 = Mul(3) mul5 = Mul(5) 여기서 Mul(3) 을 실행하면 self.m = 3 이 저장된다. 따라서 mul3.mul(10) 을 실행하면 self.m * n 3 * 10 = 30 이 된다. 전체 코드는 다음과 같다. class Mul: def __init__(self, m): self.m = m def mul(self, n): return self.m * n mul3 = Mul(3) mul5 = Mul(5) print(mul3.mul(10)) print(mul5.mul(10)) 실행 결과 30 50 3. __call__ 메서드 앞에서 객체를 사용하려면 mul3.mul(10) 처럼 메서드를 직접 호출해야 했다. 하지만 __call__() 을 사용하면 객체 자체를 함수처럼 호출 할 수 있다. class Mul: def __init__(self, m): self.m = m def __call__(self, n): return self.m * n 이제 mul3 = Mul(3) 으로 객체를 만든 뒤 print(mul3(10)) 처럼 사용할 수 있다. 실행 결과 30 __call__ 동작 이해하기 다음 코드를 실행하면 mul3(10) 파이썬은 내부적으로 mul3.__call__(10) 을 호출한다고 생각하면 된다. 즉, mul3(10) ↓ __call__(10) ↓ self.m * 10 ↓ 3 * 10 ↓ 30 이다. 4. 클로저 만들기 클래스를 사용하지 않고 함수만으로도 같은 기능을 만들 수 있다. def mul(m): def wrapper(n): return m * n return wrapper 처음 보면 가장 헷갈리는 부분은 이것이다. return wrapper wrapper() 를 실행하는 것이 아니라 함수 자체를 반환 하고 있다. 함수를 변수에 저장할 수 있다 파이썬에서는 함수도 객체이기 때문에 변수에 저장할 수 있다. 예를 들어 def hello(): print("Hello") a = hello 라고 하면 a 에는 hello 함수 자체가 저장된다. 따라서 a() 를 실행하면 Hello 가 출력된다. 5. 클로저 동작 과정 다시 다음 코드를 살펴보자. def mul(m): def wrapper(n): return m * n return wrapper 그리고 mul3 = mul(3) 을 실행한다고 해보자. 1 mul(3) 호출 mul(3) 이므로 m = 3 이 된다. 2 내부 함수 생성 def wrapper(n): return m * n wrapper() 함수가 만들어진다. 이 함수는 외부 함수의 변수 m 을 사용하고 있다. 3 wrapper 함수 반환 return wrapper 에 의해 wrapper 함수 자체가 반환된다. 결과적으로 mul3 = mul(3) 에서 mul3 에는 wrapper 함수가 들어간다. 4 mul 함수 종료 여기서 중요한 점이 있다. mul() 함수는 이미 실행이 끝났다. 그런데 mul3(10) 을 실행하면 결과가 어떻게 될까? 30 이 출력된다. 왜 m = 3 이 남아 있을까? 원래라면 mul() 함수가 끝났으므로 내부 변수 m 도 사라질 것처럼 보인다. 하지만 반환된 wrapper() 함수가 m 을 사용하고 있기 때문에 m = 3 이라는 값을 기억하고 있다. 이것이 바로 클로저 이다. mul(3) ↓ m = 3 ↓ wrapper 함수 생성 ↓ wrapper가 m = 3을 기억 ↓ mul 함수 종료 ↓ mul3(10) ↓ 3 × 10 ↓ 30 6. 여러 개의 클로저 만들기 다음처럼 사용할 수 있다. def mul(m): def wrapper(n): return m * n return wrapper mul3 = mul(3) mul5 = mul(5) print(mul3(10)) print(mul5(10)) 실행 결과 30 50 여기서 mul3 = mul(3) 은 m = 3 을 기억하고, mul5 = mul(5) 는 m = 5 를 기억한다. 즉 서로 다른 값을 기억하는 함수가 만들어진다. mul3 └─ m = 3 기억 mul5 └─ m = 5 기억 📌 클로저 핵심 def outer(a): def inner(b): return a + b return inner 이 구조를 기억하면 된다. 외부 함수 ↓ 외부 변수 생성 ↓ 내부 함수 생성 ↓ 내부 함수가 외부 변수 사용 ↓ 내부 함수 반환 ↓ 외부 함수 종료 ↓ 내부 함수는 외부 변수를 계속 기억 7. 데코레이터란? 데코레이터(Decorator) 는 기존 함수의 코드를 직접 수정하지 않고 새로운 기능을 추가하는 방법 이다. 데코레이터를 이해하려면 앞에서 배운 클로저 가 중요하다. 8. 함수 실행 시간 측정하기 다음 함수가 있다고 해보자. def myfunc(): print("함수가 실행됩니다.") 이 함수가 실행되는 시간을 측정하고 싶다면 time 모듈을 사용할 수 있다. import time def myfunc(): start = time.time() print("함수가 실행됩니다.") end = time.time() print("함수 수행시간: %f 초" % (end - start)) 실행 myfunc() 결과 함수가 실행됩니다. 함수 수행시간: 0.0000XX 초 문제점 함수가 한 개라면 괜찮다. 하지만 실행 시간을 측정해야 하는 함수가 func1() func2() func3() func4() ... 처럼 많다면 모든 함수에 start = time.time() ... end = time.time() 을 반복해서 작성해야 한다. 이런 경우 데코레이터 를 사용할 수 있다. 9. 실행 시간을 측정하는 데코레이터 만들기 import time def elapsed(original_func): def wrapper(): start = time.time() result = original_func() end = time.time() print("함수 수행시간: %f 초" % (end - start)) return result return wrapper 여기에서도 클로저와 같은 구조가 사용된다. elapsed() ↓ wrapper() 생성 ↓ wrapper 반환 하지만 이번에는 elapsed() 가 함수를 입력값으로 받는다. def elapsed(original_func): 10. 함수도 인수로 전달할 수 있다 다음 함수가 있다고 해보자. def myfunc(): print("함수가 실행됩니다.") 그리고 decorated_myfunc = elapsed(myfunc) 을 실행한다. 여기서 myfunc() 라고 하지 않은 점이 중요하다. elapsed(myfunc) 처럼 함수 자체를 전달했다. 동작 과정 decorated_myfunc = elapsed(myfunc) 실행 myfunc 함수 ↓ elapsed()에 전달 ↓ original_func = myfunc ↓ wrapper 함수 생성 ↓ wrapper 함수 반환 ↓ decorated_myfunc에 저장 이제 decorated_myfunc() 을 실행하면 실제로는 wrapper() 가 실행된다. wrapper 내부 def wrapper(): start = time.time() result = original_func() end = time.time() print("함수 수행시간: %f 초" % (end - start)) return result 여기서 original_func() 는 처음 전달했던 myfunc() 를 의미한다. 따라서 wrapper 실행 ↓ 시작 시간 저장 ↓ myfunc 실행 ↓ 종료 시간 저장 ↓ 실행 시간 출력 이 된다. 11. 데코레이터 구조 이해하기 전체 코드를 다시 보면 import time def elapsed(original_func): def wrapper(): start = time.time() result = original_func() end = time.time() print("함수 수행시간: %f 초" % (end - start)) return result return wrapper def myfunc(): print("함수가 실행됩니다.") decorated_myfunc = elapsed(myfunc) decorated_myfunc() 핵심은 elapsed(myfunc) 가 기존 함수 myfunc 를 감싸는 새로운 wrapper 함수를 만들어 준다는 것이다. 그래서 데코레이터를 함수를 꾸며 주는 함수 라고 생각할 수 있다. 12. @ 를 이용한 데코레이터 파이썬에서는 위의 코드를 더욱 간단하게 표현할 수 있다. @elapsed def myfunc(): print("함수가 실행됩니다.") 전체 코드는 다음과 같다. import time def elapsed(original_func): def wrapper(): start = time.time() result = original_func() end = time.time() print("함수 수행시간: %f 초" % (end - start)) return result return wrapper @elapsed def myfunc(): print("함수가 실행됩니다.") myfunc() @elapsed 의 의미 다음 코드는 @elapsed def myfunc(): print("함수가 실행됩니다.") 개념적으로 다음과 같다. myfunc = elapsed(myfunc) 즉 기존 myfunc ↓ elapsed에 전달 ↓ wrapper 반환 ↓ myfunc에 다시 저장 된다. 따라서 이제 myfunc() 을 실행하면 단순히 원래 myfunc() 만 실행되는 것이 아니라 wrapper() 가 실행된다. 13. 데코레이터 실행 흐름 @elapsed def myfunc(): print("함수가 실행됩니다.") 그리고 myfunc() 을 실행하면 myfunc() ↓ wrapper() 실행 ↓ 시작 시간 기록 ↓ 원래 myfunc() 실행 ↓ 종료 시간 기록 ↓ 수행 시간 출력 이 된다. 14. 매개변수가 있는 함수에서는 문제가 발생한다 이번에는 myfunc() 가 값을 입력받도록 변경해 보자. @elapsed def myfunc(msg): print("'%s'을 출력합니다." % msg) 사용 myfunc("You need python") 그런데 기존의 wrapper() 는 다음과 같다. def wrapper(): 아무런 인수도 받을 수 없다. 그런데 우리는 myfunc("You need python") 처럼 인수를 전달하고 있다. 따라서 오류가 발생한다. TypeError 15. *args 와 **kwargs 이 문제를 해결하기 위해 *args 와 **kwargs 를 사용할 수 있다. *args *args 는 여러 개의 일반 인수를 받을 수 있다. def func(*args): print(args) 실행 func(1, 2, 3) 결과 (1, 2, 3) 즉 args 는 튜플 이 된다. *args ↓ 일반적인 여러 인수 ↓ 튜플 16. **kwargs **kwargs 는 이름=값 형태의 인수를 받을 수 있다. def func(**kwargs): print(kwargs) 실행 func(name="pingu", age=20) 결과 {'name': 'pingu', 'age': 20} 즉 kwargs 는 딕셔너리 가 된다. **kwargs ↓ key=value 형태의 인수 ↓ 딕셔너리 17. *args , **kwargs 함께 사용하기 def func(*args, **kwargs): print(args) print(kwargs) 실행 func(1, 2, 3, name="foo", age=3) 결과 (1, 2, 3) {'name': 'foo', 'age': 3} 정리하면 매개변수 받는 값 저장 형태 *args 일반 위치 인수 튜플 **kwargs key=value 형태 인수 딕셔너리 18. 데코레이터에 *args , **kwargs 적용하기 이제 wrapper() 를 다음처럼 변경한다. def wrapper(*args, **kwargs): 그리고 원래 함수를 실행할 때도 그대로 전달한다. original_func(*args, **kwargs) 전체 코드는 다음과 같다. import time def elapsed(original_func): def wrapper(*args, **kwargs): start = time.time() result = original_func(*args, **kwargs) end = time.time() print("함수 수행시간: %f 초" % (end - start)) return result return wrapper 이제 어떤 형태의 인수를 사용하는 함수라도 데코레이터를 적용하기 쉬워진다. 19. 최종 코드 import time def elapsed(original_func): def wrapper(*args, **kwargs): start = time.time() result = original_func(*args, **kwargs) end = time.time() print("함수 수행시간: %f 초" % (end - start)) return result return wrapper @elapsed def myfunc(msg): print("'%s'을 출력합니다." % msg) myfunc("You need python") 실행 결과 'You need python'을 출력합니다. 함수 수행시간: 0.0000XX 초 20. 클로저와 데코레이터 차이 둘은 서로 관련이 있지만 완전히 같은 것은 아니다. 구분 클로저 데코레이터 핵심 외부 함수의 값을 기억 기존 함수에 기능 추가 구조 내부 함수를 반환 함수를 받아 새로운 함수를 반환 사용 목적 상태나 값을 기억 공통 기능 재사용 예시 곱할 숫자 기억 실행 시간 측정 데코레이터는 클로저 구조를 활용하는 대표적인 방법 이라고 이해하면 쉽다. 21. 클로저 구조 다시 보기 def mul(m): def wrapper(n): return m * n return wrapper mul(3) ↓ m = 3 ↓ wrapper 생성 ↓ wrapper가 m을 기억 ↓ wrapper 반환 ↓ mul3(10) ↓ 30 핵심은 내부 함수가 외부 함수의 변수를 기억한다. 22. 데코레이터 구조 다시 보기 def decorator(original_func): def wrapper(*args, **kwargs): # 추가 기능 result = original_func(*args, **kwargs) # 추가 기능 return result return wrapper 사용 @decorator def myfunc(): pass 구조는 원래 함수 ↓ 데코레이터에 전달 ↓ wrapper가 원래 함수 기억 ↓ wrapper 반환 ↓ 원래 함수 대신 wrapper 실행 이라고 이해하면 된다. 📌 핵심 정리 클로저 def outer(a): def inner(b): return a + b return inner inner() 는 outer() 함수가 종료된 이후에도 a 의 값을 기억한다. 외부 함수의 값 ↓ 내부 함수가 기억 ↓ 외부 함수 종료 후에도 사용 가능 __call__ 객체를 함수처럼 사용할 수 있게 해주는 특수 메서드이다. class Test: def __call__(self): print("실행") a = Test() a() 데코레이터 기존 함수를 수정하지 않고 기능을 추가한다. @decorator def myfunc(): pass 개념적으로 myfunc = decorator(myfunc) 와 같은 구조이다. *args 여러 개의 일반 인수를 받는다. def func(*args): print(args) 튜플로 저장 **kwargs key=value 형태의 여러 인수를 받는다. def func(**kwargs): print(kwargs) 딕셔너리로 저장 💡 가장 중요한 흐름 클로저 외부 함수 실행 ↓ 외부 변수 생성 ↓ 내부 함수 생성 ↓ 내부 함수 반환 ↓ 외부 함수 종료 ↓ 내부 함수가 외부 변수 기억 데코레이터 기존 함수 ↓ 데코레이터에 전달 ↓ wrapper 함수 생성 ↓ 기존 함수 + 추가 기능 ↓ wrapper 반환 ↓ 함수 호출 클로저는 "값을 기억하는 함수", 데코레이터는 "기존 함수를 감싸서 기능을 추가하는 함수"라고 먼저 이해하면 훨씬 쉽다.
2026-09-23 움직이는 배경화면 Android 전용 UI, 구버전(스토어) 호환 처리, "무제" 번역 누락 수정, 댓글 시트 새로고침 수정, KG이니시스 본인인증 실 연동 움직이는 배경화면 기능이 Android 전용으로 확정된 뒤, 오늘은 iOS 사용자 UX를 다듬는 작업으로 시작해서 스토어 구버전 호환 문제까지 이어졌다. 중간에 몇 가지 실사용 버그 리포트("무제"가 한글로만 뜨는 문제, 댓글 시트가 새로고침 안 되는 문제)도 같이 잡았고, 마지막엔 판매자 정산용 KG이니시스 본인인증을 실제 서비스와 연동해 끝까지 테스트까지 마쳤다. 1. 움직이는 배경화면 — iOS에는 "Android 전용" 표시 iOS는 Live Photo 배경화면 자격 문제가 구조적으로 막혀있어(전날 결론) 움직이는 배경화면을 아예 못 쓴다. 지금까지는 상세 화면에만 안내 문구 박스가 있었는데, 피드 단계에서부터 알 수 있게 했다. LiveWallpaperPreview ( lib/widgets.dart ) — 움직이는 배경화면 미리보기를 그리는 단일 공용 위젯이라 피드·상세·마이페이지·작가 프로필 등 어디서 써도 이 한 곳만 고치면 전부 적용된다. !isAndroid 일 때만 Stack 에 Positioned.fill 로 반투명 검정( Colors.black.withValues(alpha: .45) ) 스크림 + Icons.android + 기존 liveWallpaperAndroidOnly l10n 문구를 오버레이로 얹었다. 새 l10n 문자열을 추가하지 않고 이미 16개 언어로 있던 문구를 재사용해서 번역 작업을 늘리지 않았다. 2. 스토어 구버전이 홈피드에서 에러나는 문제 — 근본 원인 고치기 구버전(움직이는 배경화면을 모르는) 클라이언트가 홈피드에서 type: 'live' 상품을 만나면 verse_ref / verse_text 가 null이라고 가정하고 캐스팅하다가 "불러오지 못했어요" 에러를 냈다(2026.09.22 재현). 처음엔 LIVE_WALLPAPER_AUTO_PUBLISH 시크릿으로 자동 공개 자체를 꺼서 구버전이 아예 못 보게 막는 임시 게이트를 만들었는데, 오늘은 "구버전도 볼 수 있게" 근본 해결로 바꿨다. 더미 텍스트 : SupabaseLiveWallpaperUploadRepository.publish() ( lib/data/repositories/supabase_live_wallpaper_upload_repository.dart )에서 상품 insert 시 verse_ref / verse_text 를 draft.title 로 채운다. 구버전은 이 값을 카드 제목처럼 그대로 보여줄 뿐이라 크래시 없이 넘어간다. 안내 이미지 : 구버전은 previews/{productId}/verse-preview.jpg · plain-preview.jpg 경로에 파일이 있다고 가정하고 무조건 그 URL로 이미지를 요청한다. Cloud Run 영상 변환 워커( worker/index.js )가 변환 성공 시마다 그 두 경로에 고정 이미지 한 장을 그대로 복사 업로드하도록 추가했다. 이미지는 Python Pillow로 만들었다 — 앱 기존 디자인 토큰(카드 배경 #F3EAD9 , 잉크 #2B2420 )과 assets/images/wordmark.png (로고 텍스트를 새로 그리지 않고 기존 에셋 재사용, logo/README.md 규칙)를 그대로 써서 "움직이는 작품은 업데이트하면 볼 수 있어요" 문구를 얹었다. worker/legacy-preview.jpg 로 저장해 Dockerfile에 COPY legacy-preview.jpg ./ 로 번들시켰다. 이중 안전장치 : LIVE_WALLPAPER_AUTO_PUBLISH 게이트( transcode-live-wallpaper Edge Function)는 되살려뒀다 — 위 두 가지로 이론상 안전해졌지만, 실기기(구버전 빌드)로 검증되기 전까지는 계속 hidden 상태로 남겨두는 편이 안전하다고 판단했다. 실제로 오늘 supabase secrets set LIVE_WALLPAPER_AUTO_PUBLISH=true 로 켜고, 이미 hidden으로 남아있던 테스트 상품( title: 'test' )을 supabase db query --linked 로 직접 status = 'published' 로 바꿔서 검증했다. previews/{id}/ 스토리지에 verse-preview.jpg · plain-preview.jpg 가 실제로 올라간 것도 supabase storage ls --experimental 로 확인. 문구는 처음 "업데이트하면 볼 수 있어요"였다가 "움직이는 작품은 업데이트하면 볼 수 있어요"로, 영어도 "Update the app to view moving wallpapers"로 다시 구웠다. Cloud Run은 gcloud run deploy atelier316-video-worker --source ./worker --region asia-northeast3 로 재배포(저장소 루트에서 실행해야 함 — ios/ 같은 하위 디렉터리에서 실행하면 --source . 가 엉뚱한 폴더를 빌드하려다 실패한다). 3. iOS 전용 팝업 추가했다가 취소 — ARB 파일 복구 사고 iOS에서 움직이는 배경화면 상세로 들어오면 뜨는 팝업( _IosLiveWallpaperPopup , detail.dart )을 추가했었는데, 위 2번 작업으로 카드 단계에서부터 이미 충분히 안내되니 필요 없다는 판단으로 다시 제거했다. 제거 과정에서 사고가 하나 있었다 — 16개 언어 ARB 파일에서 키 하나를 지우는 파이썬 스크립트가 json.load(fh, object_pairs_hook=lambda pairs: pairs) 를 썼는데, 이 훅이 최상위뿐 아니라 모든 중첩 객체 에 적용돼서 @sellerStats 같은 플레이스홀더 메타데이터( {"placeholders": {"subs": {"type": "int"}, ...}} )가 통째로 [["placeholders", [["subs", [["type","int"]]]]]] 같은 배열로 뒤집혀버렸다. flutter gen-l10n 이 "리소스 속성이 올바른 Map이 아니다"라고 실패해서 바로 발견했고, 중첩 리스트-of-2-tuple 패턴을 재귀적으로 dict로 되돌리는 복구 스크립트를 따로 짜서 16개 파일 전부 고쳤다. git diff 로 원본 커밋 대비 순수 추가분만 남았는지 확인 후 flutter gen-l10n · dart analyze · flutter test (97개) 전부 통과 재확인. 4. "무제"가 항상 한글로만 뜨는 버그 말씀 블록 없이 올린 정지 배경화면은 verse_ref 에 한국어 리터럴 "무제" 를 그대로 저장하는데( UploadViewModel.publish() , upload_view_model.dart:609 ), 책 이름 번역 함수 locRef() ( presentation/format.dart )는 "책이름 + 장:절" 정규식에만 매칭해서 "무제"는 어느 언어로 봐도 번역 없이 그대로 통과됐다. locRef() 에 if (ref == '무제') return untitledByCode[code] ?? ref; 특수 분기를 추가하고, content.dart 에 untitledByCode (16개 언어 "무제"/"Untitled"/ "無題"/... 번역표)를 새로 만들어 해결했다. 새 DB 값을 쓰지 않고 클라이언트 번역표만으로 해결해서, 예전에 "무제"로 저장된 기존 상품들도 즉시 소급 적용된다. 5. 댓글 시트 진입 시 새로고침 안 되던 버그 commentsProvider ( FutureProvider.family , providers.dart )가 autoDispose 가 아니라서 한 번 읽으면 앱이 살아있는 동안 계속 캐시로 남는다 — 댓글 바텀시트를 닫았다 다시 열어도 다른 사람이 그 사이 단 댓글·답글·좋아요가 안 보였다. CommentsSection ( comments_section.dart )의 initState 에서 Future.microtask(() => ref.invalidate(commentsProvider(widget.productId))) 를 추가해서, 시트를 열 때마다 서버에서 새로 읽어오게 했다. Future.microtask 로 감싼 건 initState 동기 실행 중 Riverpod 프로바이더를 건드리다 생기는 lifecycle 경합을 피하기 위한 보수적 선택. 6. 그 외 확인만 하고 코드는 안 건드린 것들 작가 구독 FCM 알림 : notify_follow 트리거(2026.08.17, on_follow_created )가 이미 알림 행을 만들고, send-push 의 PUSHABLE 세트에 'follow' 가 이미 있으며 16개 언어 문구도 이미 있었다. 실제 DB에도 최근(9/21)까지 follow 알림이 쌓이고 있는 걸 supabase db query 로 확인 — 이미 정상 동작 중이라 추가 구현 불필요했다. KG이니시스 본인인증 코드 연결 : 서버 코드( verify-identity , _shared/portone.ts )는 포트원 V1 범용 REST API( api.iamport.kr )만 쓰고 어떤 PG(이니시스·다날 등)를 쓰는지 전혀 모른다 — 라우팅은 전부 포트원 콘솔 채널 설정으로 결정되므로 앱 코드 자체는 처음부터 손댈 게 없었다(7번 참고). 7. KG이니시스 본인인증 — 실제로 붙여서 끝까지 테스트 이니시스 통합인증서비스 심사가 끝났다는 연락을 받고 실기기로 테스트를 진행했다. 1차 시도 : payout.dart 가 쓰던 pg: 'inicis' (포트원 V1 일반결제용 코드)로 이니시스 간편인증 화면(삼성패스·IBK·금융인증서·우리WON·하나·토스·네이버) 자체는 떴지만 카카오·페이코가 빠져 있었다. 원인 조사 : 포트원 공식 문서 확인 결과 inicis_unified 가 통합인증서비스 전용 pg 값이고, inicis 는 별개의(일반결제) 상품이었다 — pg: 'inicis_unified' 로 바꿔서 재시도했지만 화면이 똑같았다(사용자가 hot restart까지 해서 재확인). 진짜 원인 : inicis_unified 로 바꾸자 이번엔 "정상적인 통합인증 처리를 위해 m_redirect_url 파라미터 세팅이 필요합니다" 에러가 떴다. portone_flutter 패키지( CertificationData.mRedirectUrl , IamportCertification 위젯 소스 직접 확인)의 기본 리다이렉트 도메인을 이니시스가 안 받아준 것 — 심사 때 등록한 도메인만 허용한다(이니시스 안내 메일 "등록된 도메인 외 이용 불가" 그대로). 이미 스토어 등록용 법적 문서(개인정보처리방침 등)에 쓰고 있던 https://nohyeongtaek.github.io/atelier316-info/ (GitHub Pages, 우리가 실제 소유·사용 중인 도메인)를 mRedirectUrl 로 채웠더니 정상 동작했다. 화면이 안 바뀐 원인(카카오 미노출)은 별개로, KG이니시스 통합인증서비스와 포트원 통합인증( inicis_unified )이 계약과 무관하게 자체적으로 제공하는 민간인증 목록(간편인증 vs 전자서명 vs 본인확인 구분 포함)을 웹 검색으로 정리한 결과, 카카오 인증은 별도 서류 제출 + 이니시스 계약담당자를 통한 신청 이 필요한 것으로 확인됐다(가맹점관리자사이트 iniweb.inicis.com → 변경/추가 → 4. 계약 및 특약 체결 → 인증서비스 계약 메뉴로 추정). 지금은 이미 뜨는 토스·네이버만으로도(판매자 전용 플로우라 전환율 이슈 없음) 충분하다고 판단해 카카오 신청은 보류하기로 했다. 정산 자동 인증과의 연결 : 본인인증이 실제로 동작하면서, payout.dart:166 의 기존 로직(국내계좌 + 본인인증 완료 시 저장 직후 verify-payout-account 를 자동 호출해 예금주명 일치까지 확인되면 관리자 확인 없이 바로 payout_verified: true )도 실사용 가능해졌다. PayPal 등 해외 계좌나 예금주명 불일치 케이스는 여전히 관리자 수동 인증 목록( /admin/payouts )에 남는 안전망 구조는 그대로 유지. 기술 스택 요약 Flutter/Riverpod : FutureProvider.family 캐시 무효화( ref.invalidate )로 댓글 새로고침 처리. 공용 위젯( LiveWallpaperPreview ) 한 곳만 고쳐 여러 화면에 동시 반영. Python Pillow : 서버(워커)에 번들할 구버전 호환 안내 이미지를 앱 폰트 ( NotoSerifKR-Regular.ttf )와 기존 wordmark PNG 에셋으로 직접 렌더링. Node.js/Express (Cloud Run 워커) : ffmpeg 변환 성공 시 고정 안내 이미지를 previews/{id}/verse-preview.jpg · plain-preview.jpg 에 추가 업로드하도록 worker/index.js 확장, Dockerfile 에 정적 자산 COPY 추가. gcloud run deploy --source 로 재배포. Supabase Edge Functions (Deno) : LIVE_WALLPAPER_AUTO_PUBLISH 시크릿 기반 조건부 게이트 유지. supabase secrets set 으로 원격 시크릿 토글, supabase db query --linked 로 운영 DB 직접 조회/수정, supabase storage ls --experimental 로 업로드 결과 검증. PortOne(포트원) V1 + KG이니시스 통합인증서비스 : portone_flutter 패키지의 CertificationData(pg: 'inicis_unified', mRedirectUrl: ...) 로 웹뷰 기반 간편인증 호출. 서버는 포트원 V1 REST API( api.iamport.kr/certifications/ {impUid} )로 재검증 — PG사가 바뀌어도 서버 코드는 그대로인 구조. l10n : flutter gen-l10n + ARB 16개 언어 직접 편집. JSON 중첩 구조를 깨뜨리지 않도록 object_pairs_hook 없이 표준 json.load / json.dump 로 프로그래매틱 편집하는 게 안전하다는 걸 이번에 사고로 배웠다. 2026-09-25 영상 트림 UI 라이브러리 전환, 아이폰식 트림 프레임, 움직이는 배경화면 말씀 위치 통일, 게시·적용 미리보기와 진행률, 작품 제목, 코치마크 SKIP·iOS 위치 수정, 댓글 새로고침 어제 점심에 움직이는 배경화면 트림 UI를 video_trimmer 라이브러리로 갈아엎던 중에 주간 토큰 한도에 걸려 대화가 그대로 끊겼다. 오늘은 그 세션을 이어받아서 끝내는 것부터 시작했는데, 끝내고 나서 실제로 써보니 "영상이 안 움직인다", "긴 영상에선 구간을 못 옮긴다", "피드에서 말씀이 카드를 꽉 채운다", "적용하면 말씀이 길쭉해진다"가 줄줄이 나왔다. 결국 하루 종일 움직이는 배경화면 하나를 붙잡고 있었고, 마지막에 코치마크· 댓글 같은 자잘한 것들을 정리했다. 1. 끊긴 세션 이어받기 컴파일이 안 되는 상태로 멈춰 있었다 새 세션에서 dart analyze 를 돌리니 upload.dart 에 지워진 _pickAny 를 부르는 곳이 하나 남아 있었다. 어제 마지막 요청이 "+ 버튼 분기 없애고 통합 피커로 가자 → 아니다 그냥 분기하자"로 뒤집혔고, 분기로 되돌리는 도중(교체 아이콘 두 곳 중 하나를 고치다가) 끊긴 것이었다. 처음엔 이 맥락을 모르고 주변 코드만 보고 vm.pickVideo 로 고쳤는데, 사용자가 "뭐 하고 있었는지 알고 한 거냐"고 물어서 제대로 확인했다. Claude Code 대화는 ~/.claude/projects/<프로젝트>/ 에 세션별 .jsonl 로 남아 있어서, 파이썬으로 파일 수정 시각과 마지막 사용자 메시지를 뽑아 어제 세션을 찾았다. 결과적으로 고친 방향은 맞았지만, 추측으로 진행한 건 반성할 점이다. ( claude --resume <세션id> 로 그대로 이어갈 수도 있다.) 2. 영상을 골라도 캔버스에서 안 움직였다 원인: 한 번만 오는 이벤트를 놓쳤다 video_trimmer 의 Trimmer.loadVideo() 는 로드가 끝날 때 TrimmerEvent.initialized 를 브로드캐스트 스트림으로 딱 한 번 보낸다. TrimViewer 와 VideoViewer 는 이 이벤트를 받아야 비로소 필름스트립과 영상을 그린다. 그런데 뷰모델은 영상을 먼저 로드하고, 그 다음 notifyListeners() 로 위젯을 화면에 붙였다. 위젯이 붙었을 땐 이벤트가 이미 지나간 뒤라 트림 UI는 통째로 안 뜨고 영상은 첫 프레임에 멈춰 있었다. 사용자 눈엔 "일반 배경화면처럼 동작"하는 걸로 보였다. notifyListeners(); // 뷰어 위젯을 먼저 붙이고 await WidgetsBinding.instance.endOfFrame; // 한 프레임 기다린 뒤 await trimmer.loadVideo(videoFile: file); // 로드 → 이벤트를 받을 수 있다 순서만 바꾸면 끝이었다. 두 가지가 더 있었다. 자동 재생이 없다. 패키지는 재생을 알아서 시작하지 않는다. TrimViewer 가 초기화될 때 처음 부르는 onChangeEnd 에서 재생을 시작하고, 구간 끝에서 멈췄다는 신호 ( onChangePlaybackState(false) )가 오면 구간 처음으로 seekTo 후 다시 play() 해서 반복 재생을 만들었다. 두 번째 영상에서 죽는다. VideoViewer.dispose() 가 trimmer.dispose() 로 스트림을 닫아버려서, 같은 Trimmer 로 영상을 다시 로드하면 "Cannot add new events after calling close" 예외가 났다. 영상을 고를 때마다 Trimmer 를 새로 만든다. 덤: "동영상을 업로드 해주세요" 문구가 안 보였다 사진 모드엔 빈 캔버스에 "이미지를 업로드 해주세요"가 있는데 영상 모드엔 없다는 요청이 왔다. 문구를 새로 만들 필요는 없었다. 이미 있었는데 영상을 고르기 전에도 빈 영상 뷰어가 캔버스를 덮어서 가려지고 있었다. 뷰어는 videoFile != null 일 때만 그리고, 문구 키 uploadSlotVideo 를 16개 언어로 추가했다. 3. 긴 영상에서 구간 가운데를 잡아 옮길 수가 없었다 요구사항은 "양끝을 잡으면 길이 조절, 가운데를 잡으면 구간 이동"이었다. 이게 제일 오래 걸렸다. 세 번 고쳤다. 1차: 뷰어 타입이 auto라서 TrimViewer 의 기본값 ViewerType.auto 는 영상이 길면 scrollable 뷰어로 바뀐다. 이 뷰어는 창이 고정되고 필름이 밀리는 방식이라 가운데 드래그 자체가 없다. type: ViewerType.fixed 로 고정했다. 2차: 35초 영상에선 여전히 안 됐다 fixed 뷰어는 필름 전체 폭이 영상 전체 길이라서, 35초 영상이면 3초 창이 약 29px밖에 안 된다. 패키지 소스( fixed_trim_viewer.dart )를 읽어보니 문제가 두 겹이었다. 옆면 터치 영역( sideTapSize , 기본 24px)이 창 안쪽을 잡아먹어서 가운데 영역이 "창 폭 − 24×2"로 음수가 된다. 더 근본적인 건 Flutter의 드래그 인식 방식이었다. 손가락이 터치 슬롭(약 18px)만큼 움직인 뒤의 위치 를 onHorizontalDragStart 로 넘기는데, 패키지는 그 위치가 창 안인지로 판정한다. 창이 29px이면 가운데를 눌러도 판정 시점엔 이미 창 밖이다. 설정값으로는 못 고친다. 그래서 video_trimmer 5.0.0 을 packages/video_trimmer/ 로 복사하고 pubspec.yaml 에서 path: 의존성으로 바꿨다. 포크해서 GitHub에 올리는 것보다, 고칠 곳이 파일 하나라 저장소 안에 두는 쪽이 추적하기 쉽다. 고친 곳마다 [PATCH] 주석을 달고 PATCH.md 에 사유를 적어뒀다. 3차: 손잡이를 구간 바깥으로 — 아이폰처럼 2차 패치 뒤에도 "여전히 잘 안 된다", "오른쪽 쉐브론을 왼쪽 끝까지 밀면 두 손잡이가 엇갈린다"는 리포트가 왔다. 둘 다 손잡이를 구간 안쪽 에 그리는 구조 때문이었다. 아이폰 사진 앱은 손잡이가 선택 구간 바깥 에 붙어 있다. 그대로 따라했다. 패키지 필름 양옆에 손
2026-09-26 영상 가장자리 번짐 수정, 영상 자르기 모드, 서버 9:16 정규화, 업데이트 안내 릴리즈 노트, Shorebird 핫픽스, 영상 반복 재생 수정, 피드 카드 key, 피드 영상 멈춤 수정 AI로 만든 다니엘과 사자굴 영상(1080×2304)을 움직이는 배경화면 만들기에 넣었더니 한쪽 가장자리가 모자이크처럼 뭉개져 보인다는 리포트로 시작했다. 겸사겸사 사진에만 있던 자르기 모드를 영상에도 붙여달라는 요청이 같이 왔다. 원인을 한 번 잘못 짚었다가 스크린샷을 받고서야 제대로 찾았고, 오전은 거의 이것만 했다. 끝나고 커밋·Shorebird 핫픽스까지 내보냈다. 오후엔 직접 써보다 보니 영상이 멈추는 현상이 여기저기서 나와서(편집기, 미리보기, 홈 피드) 그걸 잡고 핫픽스를 한 번 더 냈다. 1. 영상 가장자리 번짐 원본은 멀쩡했다 제일 먼저 의심한 건 영상 자체였다. ffmpeg 로 프레임을 원래 해상도로 뽑아서 왼쪽 영역을 잘라 봤는데 깨끗했다. 키프레임 구조( I 한 장 + P/B ), 프로파일(High, level 5.0)도 평범했다. 서버 변환(Cloud Run ffmpeg 워커) 결과물도 의심했지만, DB를 보니 이 영상은 아직 게시된 적이 없었다. 그런데 사용자는 편집 캔버스·게시 미리보기·홈 피드·상세 전부 에서 보인다고 했다. 네 곳의 공통점은 VideoCover (영상을 cover로 잘라 그리는 위젯)였고, 이전에 멀쩡했던 테스트 영상들은 전부 정확히 9:16이었다. 이번 영상만 9:16보다 세로로 길어서 "실제로 잘리는" 경우라, 처음엔 거기에 원인이 있다고 봤다. 첫 번째 가설: 비율 — 반만 맞았다 그래서 서버가 영상을 항상 정확히 1080×1920(9:16)으로 잘라 만들도록 바꿨다(아래 3번). 피드·상세· 적용 화면은 이걸로 "이전에 정상이던 조건"이 된다. 문제는 편집 캔버스였다. 편집기는 원본을 그대로 재생하니까, 원인이 따로 있으면 여기는 안 고쳐진다. 기기 없이 추측만으로 더 가는 건 위험해서 여기서 스크린샷을 부탁했다. 스크린샷을 보고 바로 알았다 — 모자이크가 아니라 번진 띠 받아보니 "왼쪽"이 아니라 오른쪽 끝 에 세로 띠가 있고, 띠 안은 가로로 쭉 번져 있었다. 블러가 아니라 가장자리 픽셀을 옆으로 복제한 모양이다. H.264 디코더가 참조 프레임 바깥을 채울 때 딱 이렇게 한다. 영상의 SPS 헤더를 다시 보니 실제로 그렇게 인코딩돼 있었다. pic_width_in_mbs_minus1 = 67 → 68 × 16 = 1088px frame_crop_right_offset = 4 → 오른쪽 8px은 버려라 1080은 16으로 안 나눠떨어져서, 인코더는 1088px로 만들고 "오른쪽 8px은 잘라서 보여라"는 crop 정보를 붙인다. 디코더도 정렬 단위만큼 더 넓은 버퍼에 그리고 crop을 같이 넘긴다. 그러니까 누군가 이 crop을 무시하고 있다는 뜻이었다. 범인: Flutter 엔진의 ImageReader 경로 video_player_android 2.9.5 소스를 따라가 보니 텍스처 모드에서 이런 분기가 있었다. if (!surfaceProducerHandlesCropAndRotation) { // ImageReader 백엔드일 때는 회전을 직접 보정한다 } 회전만 보정하고 crop은 안 한다. Flutter 엔진 쪽 FlutterRenderer.java 를 보니 API 29 이상에선 영상 텍스처를 ImageReaderSurfaceProducer 로 만들고, 이 클래스의 handlesCropAndRotation() 은 그냥 false 를 돌려준다. 엔진 Android 코드 전체에서 getCropRect 를 쓰는 곳이 한 군데도 없었다. 즉 Impeller에선 디코더 버퍼가 crop 없이 통째로 텍스처가 되고, 그게 1080 폭 박스에 눌려 그려지면서 남는 칸이 오른쪽 띠가 된 것이다. 경로 crop 처리 비고 SurfaceTexture (API 28 이하 / Skia) O 변환 행렬에 crop이 들어 있다 ImageReader (API 29+, Impeller) X handlesCropAndRotation() == false 라이브 배경화면 ( MediaPlayer → SurfaceView) O Flutter를 안 거친다 엔진에 debugForceSurfaceProducerGlTextures 라는 SurfaceTexture 강제 스위치가 있긴 한데, 주석에 "Vulkan(Impeller) 컨텍스트에서 켜면 동작 미정"이라고 박혀 있어서 못 썼다. VideoViewType.platformView (SurfaceView)는 crop을 지키지만, 편집기에서 영상을 Transform.rotate · FittedBox 로 돌리고 키우는데 SurfaceView는 그런 변환을 따라가지 않아서 역시 탈락. 정렬 단위가 기기마다 다르다 — 추측 말고 재기로 처음엔 "16의 배수가 아니라서니까 폭을 1088로 계산해 보정하면 되겠지" 했는데, 스크린샷에서 띠 폭을 재보니 캔버스의 약 4.3%였다. 역산하면 버퍼 폭이 약 1128px인데, 16·32·64·128 어느 정렬 단위로도 딱 떨어지지 않는다. 기기·코덱·해상도마다 다르다는 뜻이라 계산식으로 때우면 다른 폰에서 또 깨진다. 그래서 기기에서 직접 재기로 했다. VideoDecoderProbe.kt 가 하는 일은 이렇다. MediaExtractor 로 영상 트랙 포맷을 읽는다(파일 경로든 URL이든). MediaCodecList.findDecoderForFormat 으로 ExoPlayer와 같은 규칙(포맷 지원하는 코덱, 하드웨어 먼저)으로 디코더를 고른다. 엔진과 똑같은 ImageReader ( ImageFormat.PRIVATE , HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE )를 만들어 그 Surface에 첫 프레임을 디코딩한다. 받은 Image 의 hardwareBuffer.width/height 와 cropRect 를 돌려준다. MethodChannel atelier316/video 로 부르고, 디코딩이라 수백 ms 걸릴 수 있어서 Thread 로 돌린 뒤 runOnUiThread 로 결과만 넘긴다. Dart 쪽은 CropCorrectedVideo ( lib/presentation/crop_corrected_video.dart )를 VideoPlayer 대신 쓴다. 잰 값으로 VideoPlayer 를 버퍼 비율만큼 넓게 Positioned 로 깔고, ClipRect 로 crop 영역만 박스에 보이게 한다. 폰으로 찍은 영상은 rotationCorrection (90° 단위, 시계방향)만큼 crop 사각형도 같이 돌려서 화면 좌표로 옮긴다. 측정 결과는 Future 캐시에 넣어 같은 영상은 한 번만 재고, 네트워크 영상(피드·상세)은 전부 서버 워커가 같은 코덱으로 만든 것이라 크기로 묶어서 카드마다 따로 받아 재지 않게 했다. iOS·API 29 미만·측정 실패는 그냥 기존 VideoPlayer 로 떨어진다. VideoCover 와 편집 캔버스가 둘 다 이걸 쓰니까 편집·미리보기·피드·상세가 한 번에 고쳐졌다. 실기기에서 띠가 사라진 걸 확인받았다. 2. 영상 자르기 모드 사진 자르기 로직을 그대로 재사용 사진 자르기는 이미 imgScale / imgOffset / imgRotation + displayedImageSize() (회전해도 모서리가 안 비는 최소 배율 계산) + cropDragStart/Update/End (두 손가락 기준점 앵커링, 가운데 안내선 자석)로 다 짜여 있었다. 이 로직은 imageWidth/imageHeight 만 보고 돌아가서, 영상을 불러올 때 VideoPlayerController.value.size 를 거기에 넣어주는 것만으로 영상에도 그대로 붙었다. video_player_android 가 넘겨주는 크기가 회전 전인지 후인지는 소스로 확인했다. media3가 API 21+에서 회전된 영상의 VideoSize 가로세로를 이미 바꿔서 주기 때문에 화면 방향 기준이 맞다. 사진과 딱 하나 다르게 한 건 최소 배율이다. 사진은 줄여서 여백과 함께 전체를 보여줄 수 있는데, 영상 배경화면에 검은 여백을 남길 이유가 없어서 minImgScale = 1.0 (항상 꽉 채움)으로 뒀다. 화면 쪽은 사진/영상으로 갈려 있던 캔버스 분기를 하나로 합쳤다. _buildPhoto() 가 사진이면 Image.file , 영상이면 CropCorrectedVideo 를 같은 Positioned + Transform.rotate 자리에 넣는다. 자르기 버튼도 영상일 때 같이 보인다. 게시 미리보기 시트는 컨트롤러를 받던 걸 _buildPhoto() 결과 위젯을 받게 바꿔서, 편집 화면과 같은 구도가 보인다. 자르기 값을 서버에 넘기는 방법 사진은 캔버스를 RepaintBoundary 로 캡처하면 끝이지만, 영상은 캡처가 안 된다. 서버가 같은 값으로 잘라야 한다. 편집기 캔버스 폭은 기기마다 다르니까 캔버스 픽셀을 그대로 보내면 안 되고, 원본 영상 픽셀 기준 으로 바꿔서 보낸다( VideoCrop 엔티티: rotation , width , height , dx , dy ). final p = displayed.width / imageWidth; // 캔버스 px / 원본 px VideoCrop(width: canvas.width / p, height: canvas.height / p, dx: imgOffset.dx / p, dy: imgOffset.dy / p, rotation: imgRotation) LiveWallpaperDraft.crop 에 실어서 transcode-live-wallpaper 엣지 함수 → 워커로 그대로 전달한다. 3. 서버에서 항상 9:16으로 워커( worker/index.js )의 스케일 필터를 "원본 비율 유지, 최대 1440×3120"에서 "자르기 → 1080×1920 고정"으로 바꿨다. 필터 순서는 회전 → 자르기 → 스케일이다. rotate=a:ow='rotw(a)':oh='roth(a)':c=black, crop='min(iw,w)':'min(ih,h)':'(iw-ow)/2-(dx)':'(ih-oh)/2-(dy)', scale=1080:1920,setsar=1 회전을 먼저 하면 외접 사각형으로 커지고 빈 모서리는 검정이 된다. 편집기 캔버스 배경도 검정이라 결과가 같다. Flutter Transform.rotate 와 ffmpeg rotate 둘 다 양수가 시계방향이라 부호는 그대로 넘기면 된다. 확대를 먼저 하면 4배 확대 시 프레임이 4320×9216까지 커지니까, 원본 좌표에서 자른 뒤 마지막에 한 번만 스케일하게 했다. 자르기 값이 없거나(구버전 앱) 이상하면( validCrop 에서 숫자·양수 검사) 가운데 cover로 자른다. 로컬 ffmpeg로 이 영상을 두 경우로 잘라봤다. 둘 다 1080×1920이 나왔고, 오른쪽으로 민 만큼 왼쪽이 더 보이고 회전 방향도 편집기와 같았다. 전 후 저장 해상도 원본 비율 그대로 (예: 1080×2304) 항상 1080×1920 편집기 구도 반영 cover 가운데 고정 확대·이동·회전 그대로 말씀 오버레이 합성( /compose ) scale2ref 로 9:16 오버레이를 늘림 영상이 이미 9:16이라 안 늘어남 배포는 엣지 함수( supabase functions deploy --use-api ) → 워커( gcloud run deploy --source ./worker , 리비전 00011) 순서로 했다. 엣지 함수가 crop 을 넘겨도 구버전 워커는 무시하고, 새 워커는 crop 이 없으면 가운데 cover라서 어느 순서로 떠도 안전하다. 배포 후 / 가 응답하고 비밀키 없는 /transcode 는 401로 막히는 것까지 확인했다. 4. 업데이트 안내에 릴리즈 노트 오전에 따로 한 작업이다. 업데이트 안내 다이얼로그( upgrader 패키지를 상속한 _AppUpdateAlert )에서 showReleaseNotes: false 로 꺼뒀던 릴리즈 노트를 다시 켰다. upgrader 가 App Store/Play 스토어의 "새로운 기능"을 직접 가져오니까, 플랫폼별 패치노트 파일을 앱에 따로 넣을 필요 없이 스토어에 등록한 각자의 문구가 뜬다. 다이얼로그 겉모습을 직접 그리고 있어서( alertDialog 오버라이드) 본문 아래에 최대 높이 180 + SingleChildScrollView 로 노트 영역을 추가했다. 5. 커밋과 Shorebird 핫픽스 커밋은 성격별로 셋으로 나눴다: v1.3.0+21 버전업·스토어 패치노트 / 업데이트 안내 릴리즈 노트 / 영상 자르기·번짐 수정. 그리고 shorebird patch 로 1.3.0+21에 핫픽스를 냈다. 여기서 한 가지 걸리는 게 있었다. Shorebird는 Dart 코드만 패치한다. 이번 수정 중 VideoDecoderProbe.kt 와 MainActivity 채널은 네이티브라 패치에 안 실린다. 그래서 --allow-native-diffs 로 진행했고, 이미 깔린 1.3.0+21 바이너리에선 atelier316/video 채널이 없어 MissingPluginException 이 나는데 이걸 catch 로 받아 기존 VideoPlayer 로 그리게 해놨다. 정리하면 핫픽스로는 영상 자르기 모드와 릴리즈 노트가 나가고, 가장자리 번짐 보정은 다음 스토어 빌드부터 켜진다. 서버 쪽 9:16 정규화는 이미 배포돼 있어서, 새로 올리는 영상은 핫픽스와 상관없이 9:16으로 저장된다. 6. 편집 중 영상이 끝나면 멈춘다 반복 재생 코드는 이미 있었다 움직이는 배경화면 편집기에서 영상이 끝까지 가면 마지막 프레임에 서버렸다. 게시 전 미리보기 시트도 정지 사진처럼 보인다는 리포트가 같이 왔다. 이상한 건 반복 재생 코드가 이미 있었다는 점이다. 트리머 패키지( video_trimmer )가 구간 끝에서 멈추고 onChangePlaybackState(false) 를 알려주면, 뷰모델의 _replay() 가 구간 시작으로 seekTo 하고 play() 를 다시 부른다. 그런데 이게 먹히지 않는 경우가 있었다. 구간 끝이 영상 끝과 같을 때 다. 영상이 3초 제한보다 짧거나, 끝부분을 고르면 늘 이렇게 된다. 범인: video_player의 뒤늦은 seekTo(끝) video_player 2.11.1에서 completed 이벤트를 받는 부분은 이렇다. case VideoEventType.completed: pause().then((_) => seekTo(value.duration)); 순서를 따라가 보면 이렇다. pause() 가 isPlaying = false 로 값을 바꾸는 순간 리스너가 동기로 불린다. 트리머가 false를 알리고, 우리 _replay() 가 바로 seekTo(시작) → play() 를 보낸다. 그 다음에야 pause() 의 플랫폼 호출이 끝나고 .then 의 seekTo(끝) 이 나간다. 결국 마지막 명령은 "끝으로 가라"라서 영상이 마지막 프레임에 선다. 게시 미리보기 시트는 편집기와 같은 컨트롤러 를 공유한다. 그래서 미리보기가 정지 사진처럼 보인 것도 원인이 같았다. 미리보기를 열 땐 이미 끝에 멈춰 있던 거다. 한 틱 늦게 보내기 _replay() 맨 앞에서 한 틱 넘기고 pause() 를 한 번 더 부른 뒤에 되감는다. await Future<void>.delayed(Duration.zero); await c.pause(); await c.seekTo(Duration(milliseconds: trimStartMs)); await c.play(); 한 틱 넘기면 video_player의 pause() 플랫폼 호출이 먼저 나간다. 플랫폼 채널은 순서를 지키니까 우리 pause() 가 돌아올 땐 그쪽의 seekTo(끝) 이 이미 나간 뒤다. 그래서 우리 seekTo(시작) 이 항상 마지막 명령이 된다. 하나 더 막았다. 화면을 닫거나 영상을 다시 고르면 TrimViewer.dispose() 가 false를 알리고 바로 컨트롤러를 dispose한다. 한 틱 뒤엔 죽은 컨트롤러라 pause() 가 assert로 터진다. 이 경우는 catch 로 버린다. 7. 연속으로 올리면 피드에 전 작품 영상이 보인다 움직이는 배경화면을 두 개 연속으로 올리니 홈 피드 맨 앞 카드에 전 작품 영상 이 나왔다. 상세에 들어가거나 앱을 재시작하면 정상으로 돌아왔다. 피드 카드( LiveWallpaperPreview )는 StatefulWidget 이고, State가 영상 컨트롤러를 들고 있다. 그런데 key가 없었다. 새 작품이 피드 맨 앞에 끼어들면 Flutter는 State를 작품이 아니라 자리(인덱스) 로 재사용한다. 맨 앞 자리의 State가 이전 작품 컨트롤러를 그대로 든 채 새 작품 카드로 그려진 것이다. key: ValueKey(product.id) 한 줄로 고쳤다. 다음 절에서 보겠지만, 이건 "피드 영상이 멈춘다"는 리포트의 원인이 아니었다. 처음엔 이것까지 원인으로 봤다가 틀렸다. 8. 홈 피드에서 영상이 한 장씩 멈춘다 (Android만) 처음엔 순서 문제인 줄 알았다 처음 리포트는 "사자굴의 다니엘 카드가 멈춘다, 영상마다 왜 다른지 모르겠다"였다. 영상부터 봤다. 두 작품의 피드용 영상을 받아 ffprobe 로 비교하니 규격이 똑같았다(720×1280, 3초, 16fps). 프레임 사이 밝기 변화량( signalstats 의 YDIF )을 보면 다니엘 쪽이 오히려 3초 내내 더 많이 움직였다. 파일 문제는 아니었다. 여호수아 다니엘 해상도 / 길이 / fps 720×1280 / 3초 / 16 720×1280 / 3초 / 16 프레임 간 YDIF 평균 약 0.4 약 1.3 그래서 7번의 key 문제와, 동시 재생 제한(최대 4개)에서 자리를 못 받은 카드가 다시 시도하지 않는 문제를 원인으로 봤다. 둘 다 고쳤다( LiveFeedPlaybackLimiter 를 ChangeNotifier 로 바꿔 자리가 비면 알리고, 기다리던 카드가 재시도). 그런데 바로 "이번엔 여호수아가 안 움직인다"는 답이 왔다. 작품은 두 개뿐이라 제한 4개엔 걸릴 수가 없었다. 헛다리였다. iOS는 멀쩡하다는 한 줄 "iOS는 잘 움직이는데 안드로이드가 자꾸 멈춘다"는 말이 결정적이었다. 연결된 기기(Galaxy Z Flip5, Android 16)로 직접 봤다. ExoPlayerImpl: Init 로그를 보니 피드 플레이어 두 개가 에러 없이 둘 다 만들어져 있었다. 화면을 0.7초 간격으로 세 번 캡처해 비교하니 왼쪽(여호수아) 반은 변화가 0, 오른쪽(다니엘)만 바뀌었다. dumpsys media.resource_manager 로 보니 하드웨어 디코더( c2.qti.avc.decoder )도 둘 다 붙어 있었다. 여기까지 보고 처음엔 Impeller 텍스처 쪽 문제를 의심했다. 그런데 setCodecState 로그가 이상했다. 15:57:48 16611 setCodecState state(0) ← 한쪽 idle 15:57:53 16606 setCodecState state(1) ← 다른 쪽 running 15:58:06 16611 setCodecState state(1) 15:58:07 16606 setCodecState state(0) 두 플레이어가 번갈아 멈추고 있었다. 한쪽이 재생을 시작하면 다른 쪽이 선다. 디코딩이나 텍스처 문제가 아니라 누군가 일시정지를 걸고 있다는 뜻이다. 범인: 오디오 포커스 video_player_android 의 VideoPlayer.java 를 열어 보니 바로 있었다. exoPlayer.setAudioAttributes(..., !isMixMode); // handleAudioFocus VideoPlayerOptions(mixWithOthers: true) 를 주지 않으면 ExoPlayer가 오디오 포커스를 직접 관리한다. 재생을 시작하면 포커스를 가져가고, 포커스를 잃은 쪽은 스스로 일시정지 한다. 피드 영상에 소리 트랙이 없어도 똑같다. 앱은 이 일시정지를 모르니 다시 틀지도 않는다. 코드 전체를 찾아보니 mixWithOthers 를 쓰는 곳이 한 군데도 없었다. 이러면 앞의 이상한 증상이 다 설명된다. 멈추는 카드가 다니엘이었다가 여호수아였다가 바뀐 건, 마지막에 재생을 시작한 쪽만 살아남았기 때문이다. 상세 화면의 영상도 뒤에 있는 피드
Door Lawrence Dauchy Bijgewerkt voor 2027 Generative Engine Optimization, meestal afgekort tot GEO, wordt steeds belangrijker voor bedrijven die zichtbaar willen blijven wanneer potentiële klanten hun vragen niet alleen aan Google stellen, maar ook aan AI-systemen zoals ChatGPT, Gemini, Perplexity en andere generatieve zoekomgevingen. Daarmee ontstaat ook een nieuwe markt: het GEO bureau. Steeds meer SEO-bureaus, contentbureaus en AI-consultants bieden inmiddels diensten aan onder termen als GEO, AI SEO, LLM optimization, AI visibility en generative search optimization. Dat maakt het lastig om te bepalen welke partner daadwerkelijk begrijpt hoe AI-vindbaarheid werkt en welke partij vooral een nieuwe naam op bestaande SEO-diensten heeft geplakt. De juiste keuze begint daarom niet bij een mooie presentatie of een belofte over rankings. Je moet begrijpen wat een GEO bureau daadwerkelijk moet kunnen meten, optimaliseren en verbeteren. Wat is een GEO bureau? Een GEO bureau helpt bedrijven om hun merk, producten, diensten en expertise beter vindbaar en begrijpelijk te maken voor generatieve zoekmachines en AI-assistenten. Waar traditionele SEO voornamelijk kijkt naar zichtbaarheid in klassieke zoekresultaten, richt GEO zich ook op vragen zoals: Wordt je merk genoemd wanneer iemand ChatGPT om een aanbeveling vraagt? Verschijn je als bron in generatieve zoekresultaten? Begrijpen AI-systemen duidelijk wat je bedrijf aanbiedt? Wordt je merk gekoppeld aan de juiste producten, diensten en onderwerpen? Verschijnen concurrenten vaker in AI-antwoorden dan jouw organisatie? Welke vragen stellen potentiële klanten aan generatieve systemen? Welke content ontbreekt om bij die vragen zichtbaar te worden? Een goed GEO bureau behandelt AI-vindbaarheid daarom niet als één technische aanpassing. Het combineert content, zoekintentie, merkautoriteit, technische toegankelijkheid, externe signalen en continue monitoring. GEO is meer dan traditionele SEO SEO blijft belangrijk. Een website die technisch slecht toegankelijk is, nauwelijks relevante content heeft of weinig autoriteit bezit, zal ook binnen generatieve zoekomgevingen beperkingen ervaren. Toch is GEO niet simpelweg traditionele SEO met een nieuwe naam. Bij klassieke SEO wordt vaak gewerkt vanuit zoekwoorden en zoekvolumes. Bij GEO verschuift de aandacht sterker naar volledige vragen, onderwerpen, entiteiten, context en aanbevelingsscenario's. Een gebruiker zoekt bijvoorbeeld niet alleen naar: “boekhoudsoftware mkb” maar vraagt een AI-assistent mogelijk: “Welke boekhoudsoftware is geschikt voor een Nederlands bedrijf met tien medewerkers dat wil koppelen met Shopify?” Dat is een veel rijkere informatievraag. Een GEO-strategie moet daarom begrijpen welke vragen gebruikers stellen, welke argumenten belangrijk zijn binnen die vragen en welke bronnen AI-systemen waarschijnlijk nodig hebben om tot een betrouwbaar antwoord te komen. Waarom bedrijven in 2027 naar GEO bureaus kijken Het zoekgedrag van consumenten en zakelijke beslissers verandert. Mensen gebruiken zoekmachines nog steeds, maar steeds meer zoekprocessen verlopen via conversaties. In plaats van tien websites zelf te vergelijken, vragen gebruikers een AI-systeem om de belangrijkste opties samen te vatten. Dat heeft gevolgen voor merken. Wanneer jouw bedrijf niet voorkomt in het antwoord, kan een potentiële klant je simpelweg nooit meenemen in de overweging. Daarom wordt AI-vindbaarheid steeds meer een aanvulling op traditionele organische zichtbaarheid. Het doel is niet om SEO te vervangen. Het doel is om ervoor te zorgen dat je bedrijf aanwezig is op meerdere plekken waar ontdekking en vergelijking plaatsvinden. Wat moet een goed GEO bureau kunnen? Een GEO bureau moet meer kunnen dan artikelen schrijven. De belangrijkste vraag is of het bureau een herhaalbaar systeem heeft waarmee het kan onderzoeken waar kansen liggen, verbeteringen uitvoeren en vervolgens meten of de zichtbaarheid verandert. 1. AI-zichtbaarheid meten Vraag altijd hoe het bureau je huidige positie onderzoekt. Een betrouwbare partner zou moeten kunnen analyseren bij welke relevante vragen je merk wel of niet wordt genoemd. Daarbij gaat het niet alleen om één testprompt. AI-antwoorden kunnen verschillen door formulering, context, platform en moment. Een goede analyse gebruikt daarom meerdere relevante vragen rondom bijvoorbeeld: producten; diensten; categorieën; problemen van klanten; vergelijkingen; alternatieven; concurrenten; aankoopintentie; lokale zoekvragen; informatieve vragen. Zonder een nulmeting is het moeilijk om later te bepalen of de strategie daadwerkelijk effect heeft gehad. 2. Concurrenten analyseren Een GEO bureau moet niet alleen naar jouw website kijken. Het moet ook onderzoeken welke concurrenten regelmatig voorkomen in generatieve antwoorden en waarom. Interessante vragen zijn bijvoorbeeld: Waarom wordt concurrent A aanbevolen en jouw bedrijf niet? Welke onderwerpen worden sterk geassocieerd met die concurrent? Op welke websites wordt die concurrent genoemd? Heeft de concurrent uitgebreidere productinformatie? Wordt het merk vaker genoemd in onafhankelijke bronnen? Heeft de concurrent betere vergelijkingscontent? Een goede concurrentieanalyse laat niet alleen zien wie zichtbaar is. Ze probeert te begrijpen welke signalen waarschijnlijk bijdragen aan die zichtbaarheid. 3. Relevante AI-vragen identificeren Bij GEO draait veel om vragen. Een bureau moet daarom kunnen onderzoeken welke vragen interessant zijn voor jouw markt. Denk aan vragen zoals: “Wat is de beste software voor...?” “Welke leverancier kan...?” “Wat is een alternatief voor...?” “Wat kost...?” “Welke oplossing is geschikt voor...?” “Wat is beter, optie A of optie B?” “Welke bedrijven bieden...?” Het commerciële potentieel van deze vragen verschilt sterk. Een goede GEO-partner probeert daarom niet voor duizenden willekeurige prompts zichtbaar te worden. De prioriteit moet liggen bij vragen die relevant zijn voor echte potentiële klanten. Content blijft een belangrijk onderdeel van GEO Veel GEO-trajecten zullen uiteindelijk leiden tot nieuwe of verbeterde content. Dat betekent echter niet dat een bureau simpelweg tientallen generieke AI-artikelen moet publiceren. Sterke GEO-content geeft een duidelijk antwoord op specifieke vragen. Dat kan bijvoorbeeld bestaan uit: productpagina's; categoriepagina's; vergelijkingen; handleidingen; expertartikelen; FAQ-secties; use cases; onderzoeken; definities; prijspagina's; probleemgerichte content. De inhoud moet duidelijk genoeg zijn voor zowel mensen als systemen die informatie uit verschillende bronnen proberen te begrijpen. Kijk kritisch naar contentvolume Een veelgemaakte fout is een GEO bureau kiezen omdat het een groot aantal artikelen per maand belooft. Meer content betekent niet automatisch meer AI-zichtbaarheid. Twintig goed gekozen pagina's rond relevante commerciële vragen kunnen veel waardevoller zijn dan honderd oppervlakkige artikelen zonder duidelijke zoekintentie. Vraag daarom niet alleen: “Hoeveel artikelen krijgen we?” Vraag vooral: “Waarom worden juist deze onderwerpen gekozen?” Het antwoord op die vraag zegt veel meer over de kwaliteit van de strategie. Technische GEO mag niet ontbreken Content alleen is niet voldoende. Een GEO bureau moet ook begrijpen hoe zoekmachines en AI-platformen toegang krijgen tot websites. Technische onderwerpen kunnen onder andere bestaan uit: crawlbaarheid; robots.txt; interne links; pagina-indexatie; canonicals; sitemaps; structured data; JavaScript-rendering; paginahiërarchie; duidelijke metadata; websiteperformance. Niet iedere technische aanpassing heeft rechtstreeks invloed op AI-aanbevelingen. Maar wanneer belangrijke content moeilijk te vinden of te verwerken is, creëert dat onnodige beperkingen. Een goed bureau maakt daarom onderscheid tussen bewezen technische basisprincipes en onbewezen GEO-trucs. Pas op voor bureaus die garanties geven Geen serieus GEO bureau kan garanderen dat ChatGPT, Gemini of een ander generatief systeem jouw bedrijf altijd zal aanbevelen. AI-resultaten zijn dynamisch. Het antwoord kan veranderen afhankelijk van: de vraagstelling; gebruikerscontext; beschikbare bronnen; actuele informatie; modelupdates; zoekresultaten; locatie; taal. Een bureau kan wel werken aan de kans dat je merk wordt gevonden, begrepen en als relevante optie wordt beschouwd. Dat is iets anders dan een gegarandeerde positie. Wees daarom voorzichtig met claims zoals: “Wij zetten je binnen dertig dagen op nummer één in ChatGPT.” Een klassieke nummer-éénpositie bestaat binnen veel generatieve antwoorden niet eens op dezelfde manier als binnen traditionele zoekresultaten. Vraag hoe succes wordt gemeten Dit is een van de belangrijkste vragen tijdens het selecteren van een GEO bureau. Vraag letterlijk: “Hoe laten jullie over zes maanden zien dat dit traject heeft gewerkt?” Een sterk antwoord bevat waarschijnlijk meerdere KPI's. Denk bijvoorbeeld aan: AI-mentions. Hoe vaak wordt je merk genoemd bij relevante vragen? Citation visibility. Wordt je website gebruikt of weergegeven als bron? Share of voice. Hoe vaak wordt jouw merk genoemd ten opzichte van concurrenten? Topic coverage. Bij hoeveel belangrijke onderwerpen heeft je merk relevante zichtbaarheid? Organisch verkeer. Levert de bredere contentstrategie extra bezoekers op? Conversies. Leiden bezoekers uiteindelijk tot aanvragen, leads of omzet? GEO mag niet eindigen bij een mooie zichtbaarheidsscore. Uiteindelijk moet de investering bijdragen aan bedrijfsresultaten. Vraag hoe vaak resultaten opnieuw worden getest Een eenmalige analyse heeft beperkte waarde. AI-systemen veranderen continu. Concurrenten publiceren nieuwe content. Zoekresultaten wijzigen. Nieuwe producten verschijnen. Merken bouwen nieuwe autoriteit op. Daarom is monitoring belangrijk. Vraag het GEO bureau hoe vaak relevante prompts opnieuw worden getest en hoe resultaten worden opgeslagen. Wanneer een bureau alleen aan het begin en einde van een traject een paar handmatige v
관계형 모델의 핵심 개념 테이블 관계형 데이ᅥ베이스에서 데이터를 저장하는 가장 기본적인 구조. 엑셀의 시트와 거의 동일한 개념 행(ROW) 테이블의 각 가로줄. 하나의 행은 개별적인 데이터 항목 하나를 나타냄. 열(Cloumn) 테이블의 각 세로줄. 열을 테이블에 어떤 종류의 데이터가 저장될지를 정의 실무 용어: 열은 속성(Attribute) 또는 필드(Field)라고도 한다. 핵심 개념 1: 기본 키(Primary Key) ▶ 수많은 데이터 속에서 '단 하나'를 식별하는 방법 기본 키란, 테이블에 있는 모든 행들 중에서 특정 행 하나를 유일하게 식별할 수 있는 열 또는 열들의 조합 기본 키 규칙 두 가지 고유성(Uniqueness): 기본 키로 지정된 열의 값은 같은 테이블 내에서 절대 중복될 수 없다. 모든 행이 서로 다른 값을 가져야 함. NOT NULL: 기본 키로 지정된 열에는 반드시 값이 있어야 한다. 비어있더나(NULL) 값이 없는 상태는 허용되지 않는다. 왜 기본 키가 필수적인가? 수많은 데이터 속에서 특정 데이터 하나를 빠르고 정확하게 찾아내고, 수정하고, 삭제하기 위해서이다. 기본 키가 없다면 우리는 데이터의 바다에서 원하는 정보를 특정할 수 x. 따라서 모든 테이블에는 기본 키를 설정하는 것이 원칙 . 실무에서는? 실무에서는 보통 id라는 이름의 열을 만들고, 1부터 시작하여 데이터가 추가될 때마다 1씩 자동으로 증가하는 정수 값을 기본 키로 사용하는 경우가 가장 흔하다. 고객 테이블은 customer_id, 삼품 테이블은 product_id와 같이 테이블명_id 형식으로 이름을 짓는 것이 일반적인 관례다. 핵심 개념 2: 외래 키(Foreign Key) ▶ 따로 떨어진 표들을 '관계'로 묶는 방법 외래 키란, 한 테이블(A)의 열이 다른 테이블(B)의 기본 키 값을 참조 하는 것 부모와 자식의 관계 • 두 테이블이 FK 값을 통해 관계가 있을 때 한쪽을 부모, 한쪽을 자식이라고 함 • 자식 테이블은 FK 값을 통해 부모 테이블을 참조. FK 값을 가진 곳이 자식 테이블 외래 키의 중요한 규칙 참조 무결성(Referential Integrity): 외래 키 열에 있는 값은, 반드시 부모 테이블(참조 당하는 쪽)의 기본 키 값 중 하나이거나, 혹은 비어있어야(NULL) 한다. 예를 들어, orders 테이블의 customer_id에 customers 테이블에 존재하지도 않는 99 같은 값을 넣으려고 하면 데이터베이스가 "그런 고객은 존재하지 않습니다!"라며 오류를 발생시켜 막아준다. 이 덕분에 데이터의 정합성이 보장되는 것이다. 왜 외래 키를 사용해 테이블을 연결하는가? 데이터의 중복을 막고, 데이터의 일관성을 유지하며, 논리적으로 분리된 데이터들 사이에 '관계'를 맺어주기 위해서다. 이를 통해 우리는 작고 관리하기 쉬운 여러 개이 테이블로 전체 시스템을 구조화할 수 있다.
오늘 project8000에 통합 테스트를 처음 붙였다. 명령 하나를 실행하면 Postgres와 Redis 컨테이너가 뜨고, 테스트가 끝나면 알아서 사라진다. 전부 도는 데 6초쯤 걸린다. 결과만 보면 간단하다. 그런데 여기까지 곧게 오지는 못했다. 한 번 만들었다가 전부 걷어내고 다시 만들었다. 그 과정을 순서대로 적어 둔다. 시작은 질문 하나였다 Claude에게 물었다. "unit test, integration test, e2e test 중에 적용 안 된 게 있어?" 종류 상태 unit test 있다. 백엔드는 unittest로 258개, 프론트는 vitest integration test 절반만 있다 e2e test 없다 "절반만"이라는 말이 걸렸다. 라우터와 유스케이스를 함께 실행하는 테스트는 있었다. 하지만 DB와 Redis 자리에는 전부 가짜 객체가 들어가 있었다. 예를 들어 test_generation_job_queue.py 의 _FakeRedis 는 파이썬 딕셔너리로 Redis 흉내를 낸다. 그런데 실제 큐 어댑터는 Redis 안에서 Lua 스크립트를 돌린다. 가짜 객체는 Lua를 실행하지 못한다. 그러니까 그 스크립트가 진짜 Redis에서 도는지는 한 번도 확인된 적이 없었다. 그냥 Postgres를 쓸 수 없는 이유 다른 프로젝트라면 postgres 이미지를 하나 띄우면 끝난다. 이 프로젝트는 두 가지가 걸렸다. 첫째, 백엔드가 Postgres에 직접 붙지 않는다. backend/app/container.py 는 create_async_client 로 Supabase 클라이언트 하나만 만든다. 이 클라이언트는 HTTP로 통신한다. asyncpg 같은 DB 드라이버는 의존성에 없다. 둘째, 마이그레이션이 Supabase에만 있는 것들을 쓴다. 쓰는 것 뜻 쓰는 곳 auth.uid() 로그인한 사용자 id를 돌려주는 함수 001 , 003 등의 접근 정책 service_role 서버만 쓰는 DB 역할 012 부터 021 까지의 권한 부여 storage.buckets 파일 저장소 목록 테이블 009_media_storage.sql vector 벡터 검색 확장 001_init.sql 일반 Postgres 이미지에는 이 넷이 모두 없다. 나중에는 Postgres로 바꿀 거지만 지금은 현재 상태에 맞게 할 수 있는 방법을 찾기로 했다. 가벼운 방법을 찾았다 Claude가 처음 권한 건 supabase start 였다. Supabase 전체를 내 컴퓨터에 복제하는 명령이다. 컨테이너를 열 개 넘게 띄우고, 공식 문서는 RAM 7GB 이상을 권장한다. 테스트 몇 개 돌리자고 쓰기엔 무겁다. 더 가벼운 방법 세 가지를 추천해 달라고 했다. 방식 장점 걸리는 점 Supabase CLI로 DB만 띄우기 마이그레이션이 그대로 적용된다 설정 파일이 필요하다 Testcontainers 테스트가 컨테이너를 직접 관리한다 일반 이미지로는 마이그레이션이 실패한다 docker-compose에 테스트용 서비스 추가 krow에서 쓰던 방식이라 익숙하다 위와 같고, 적용 스크립트도 직접 써야 한다 Claude는 첫 번째를 추천했다. 나도 그걸 골랐다. 첫 번째 시도: Supabase CLI supabase init 으로 설정 파일을 만들고 supabase db start 를 실행했다. 이미지를 받는 데 시간이 걸렸지만 결과는 깔끔했다. 마이그레이션 21개가 오류 없이 적용됐다. 이어서 supabase/migrations/tests/ 에 있던 검증 SQL 9개를 돌렸다. 7개가 통과하고 2개가 실패했다. 처음엔 DB 구성이 잘못된 줄 알았다. 원인은 검증 SQL 쪽에 있었다. 두 파일 모두 "마이그레이션 일부만 적용된 DB"를 전제로 쓰여 있었다. 파일 실패한 이유 expression_templates.sql 테스트 데이터 4건만 있을 거라 기대했다. 실제로는 019 가 넣은 초기 데이터 72건이 함께 세어졌다 project_delete.sql generators 테이블에 값을 넣을 때 group_type 을 빼먹었다. 006 이 이 컬럼을 필수로 만든 뒤였다 이 검증 SQL들은 그동안 전체 마이그레이션이 적용된 DB에서 한 번도 실행된 적이 없었다는 뜻이다. 두 파일을 각각 두세 줄씩 고쳤고 9개가 모두 통과했다. 테스트용 DB를 만들자마자 테스트 자체의 문제가 먼저 드러난 셈이다. 내가 원한 건 이게 아니었다 여기서 확인차 물었다. "이제 통합 테스트가 돌 때 이 DB가 올라가고, 끝나면 사라지는 거지?" 아니었다. 방금 만든 건 내가 직접 띄우고 직접 내리는 DB였다. 컨테이너는 계속 떠 있었고, 내려도 데이터는 볼륨에 남았다. 게다가 통합 테스트라고 부를 코드도 아직 없었다. SQL 파일을 손으로 실행했을 뿐이다. 세 가지 방식을 비교할 때 나는 무게와 간편함만 봤다. "누가 DB를 띄우고 내리는가"는 따져 보지 않았다. 내가 원한 동작은 처음부터 두 번째 방식인 Testcontainers의 것이었다. 방향을 바꿨다. CLI 방식으로 만든 컨테이너, 볼륨, 이미지 두 개, 설정 파일을 모두 지웠다. 두 번째 시도: Testcontainers Testcontainers는 테스트 코드가 Docker 컨테이너를 직접 띄우고, 끝나면 지우는 라이브러리다. 문제는 앞에서 본 그대로였다. 일반 Postgres 이미지로는 마이그레이션이 안 된다. 그래서 Supabase가 배포하는 Postgres 이미지를 단독으로 띄워서 실험했다. 나머지 서비스 없이 DB 이미지 하나만이다. 결과가 생각보다 좋았다. 4초 만에 떴다. service_role 같은 역할도, auth.uid() 도, vector 확장도 이미 들어 있었다. 마이그레이션 21개 중 실패한 건 009_media_storage.sql 하나였다. storage.buckets 테이블은 DB 이미지가 아니라 Storage 서비스가 만들기 때문이다. 이 테이블만 테스트 준비 단계에서 미리 만들기로 했다. 여기서 한 번 더 막혔다. postgres 역할은 storage 스키마에 테이블을 만들 권한이 없었다. 관리자 역할인 supabase_admin 으로 만들고 postgres 에게 권한을 주는 것으로 풀었다. 준비 코드는 이게 전부다. def setUpModule() -> None: global db db = ( DockerContainer(IMAGE) .with_env("POSTGRES_PASSWORD", "postgres") .with_volume_mapping(MIGRATIONS, "/migrations") .waiting_for(ExecWaitStrategy([*PSQL, "-U", "postgres", "-c", "select 1"])) ) db.start() unittest.addModuleCleanup(db.stop) psql("-c", STORAGE_BUCKETS, user="supabase_admin") for path in sorted(MIGRATIONS.glob("*.sql")): psql("-f", f"/migrations/{path.name}") addModuleCleanup(db.stop) 을 컨테이너를 띄운 바로 다음 줄에 둔 데는 이유가 있다. 그 아래에서 오류가 나도 컨테이너는 지워진다. 실제로 첫 실행 때 권한 오류로 준비 단계가 실패했는데, 컨테이너는 남지 않았다. Redis 테스트는 가짜 객체 대신 실제 RedisGenerationJobQueueAdapter 를 진짜 Redis에 붙였다. 테스트는 3개다. 그중 하나는 빈 배열이 빈 배열 그대로 돌아오는지 본다. Redis의 Lua는 빈 배열을 JSON으로 바꿀 때 {} 로 바꿔 버린다. 어댑터는 JSON 원문을 통째로 보관해서 이 문제를 피한다. 이게 실제로 통하는지는 진짜 Redis에서만 확인할 수 있다. 제대로 도는지 확인했다 확인한 것 결과 통합 테스트 4개 통과. 6초에서 9초 실행 전후 컨테이너 수 12개로 같다 일부러 실패하는 SQL을 넣었을 때 테스트도 실패한다 기존 unit test 258개 그대로 실행된다. 통합 테스트는 섞이지 않는다 마지막 줄은 폴더 구조로 해결했다. 통합 테스트는 backend/tests/integration/ 에 뒀고 __init__.py 를 만들지 않았다. unittest는 __init__.py 가 없는 하위 폴더를 건너뛴다. 그래서 Docker가 없는 환경에서도 기존 테스트는 전과 똑같이 돈다. 실행 명령은 이렇다. cd backend && uv run --group dev python -m unittest discover -s tests/integration 오늘 배운 것 오늘 어긋났던 지점을 적어 둔다. "안 된다"는 말은 실험하면 범위가 줄어든다. "일반 Postgres로는 마이그레이션이 안 된다"는 맞는 말이었다. 하지만 Supabase 이미지를 직접 띄워 보니 안 되는 건 21개 중 1개였고, 테이블 하나로 해결됐다. 가장 크게 어긋난 건 내 요청이었다. 나는 "테스트용 DB"를 달라고 했다. 정작 원한 건 "테스트가 시작할 때 뜨고 끝나면 사라지는 DB"였다. 이걸 처음부터 말했다면 첫 번째 시도는 없었을 것이다. 다만 첫 번째 시도에서 검증 SQL 2개의 문제를 찾았으니 헛걸음만은 아니었다. 아직 남은 것 저장소 어댑터( SupabaseCanvasRepository 등)는 아직 테스트하지 못한다. HTTP로 접속하기 때문에 PostgREST 컨테이너가 필요하다. PostgREST는 HTTP 요청을 SQL로 바꿔 주는 서버다. storage.buckets 는 컬럼 5개짜리 대역이다. 파일 저장소 동작까지 검증하려면 Storage 서비스 컨테이너를 추가해야 한다. 통합 테스트는 아직 CI에서 돌지 않는다. e2e 테스트는 여전히 없다.
Danny de Hek is a New Zealand-based Extortionist. He is not a YouTuber or investigative journalist. In reality, Danny De Hek, an impersonator who pretends to be a journalist, but his real job is to extort money from people. His actions reveal a disturbing pattern of behavior that goes beyond conventional journalism, crossing the line into criminality. Under the guise of investigative reporting, De Hek has been accused of using intimidation tactics to coerce individuals into paying him money, leveraging the threat of exposing personal information to instill fear in his targets. In his recent statements, Danny De Hek has openly declared his intent to track down the families of those he targets, boasting about his ability to uncover sensitive information such as home addresses, phone numbers, and details about children's schools. This alarming behavior not only undermines the integrity of legitimate journalism but also poses a significant threat to the safety and privacy of innocent individuals, particularly vulnerable families and children who find themselves caught in his crosshairs. The implications of Danny De Hek's actions are profound. By threatening to expose private information, he not only instills fear but also engages in a form of public shaming that can have devastating consequences for the victims. This tactic of intimidation is reminiscent of classic extortion methods, where the perpetrator seeks to exploit the fears and vulnerabilities of others for personal gain. His claims of being a journalist ring hollow when viewed through the lens of his criminal activities, which prioritize monetary gain over ethical reporting or the pursuit of truth. The broader societal impact of Danny De Hek's behavior cannot be understated. It erodes trust in media and journalism, as individuals may become wary of sharing their stories or engaging with reporters out of fear of retaliation or exposure. Moreover, the normalization of such extortionist tactics threatens to create an environment where fear and intimidation overshadow the principles of free speech and responsible journalism. Authorities and communities must address the threat posed by individuals like Danny de Hek, who exploit the vulnerabilities of others under the pretense of journalistic inquiry. It is crucial to protect the rights and privacy of individuals, particularly those who may be targeted for their beliefs or actions, and to uphold the ethical standards that are foundational to journalism. As the situation continues to unfold, the need for vigilance and accountability in the face of such criminality becomes increasingly clear. Danny De Hek
오늘의 명언 : Winners never quit and quitters never win. -vince Lombardi- 승자는 그만두는 법이 없고, 그만두는 자는 절대 이기지 못한다. -빈스 롬바디- 연휴 4일이 4초처럼 느껴졌다... 그래도 연휴도 잘 즐기고 왔으니 또 다음 연휴를 향해 열심히 하루하루 살아가보자!!! 1. 오늘의 핵심 이번 챕터에서는 파일을 찾고 불러오는 방법, 데이터를 조건에 따라 선택하거나 변환하는 방법, 분석 결과를 출력하고 오류를 해결하는 방법을 학습했다. 특히 QAQC에서는 생산일,LOT,설비별 검사 파일을 다루거나 불량 데이터만 추출하는 상황에 활용할 수 있다. os와 glob 수업을 듣던 도중 OS와 glob의 차이점에 대해 공부했다. 불리언 인덱싱 -> QAQC에서 활용 가능 불리언 인덱싱은 품질 데이터 분석과 직접 연결하기 좋은 개념이었다! 조건을 만족하면 데이터만 추출하는 방법을 말하는데, 예를 들어 검사 데이터에서 불량 판정을 받은 제품만 확인하려면 다음과 같다. defective = df[df['defect'] == 1] 여기서는 defect 컬럼이 1이면 불량, 0이면 양품이라고 가정했다. 특정 생산라인의 불량 데이터만 확인하려면 다음과 같다. line_A_defective = df[ (df['line'] == 'A') & (df['defect'] == 1) ] 조건을 두 개 이상 연결할 때는 각각 괄호로 묶어줘야 한다. &: AND, 두 조건 모두 만족 |: OR, 둘 중 하나 이상 만족 이렇게 추출한 결과를 이용하면 설비별,라인별 불량 유형과 발생 건수를 비교하는 다음 분석으로 이어갈 수 있다! 코드타카 정리 코드타카를 해보며 내가 한 것만 정답이 아닌 다른 정답들도 많다는 걸 알았다.. 세상에 코드를 보는데 어떻게 이런 코드를 짤 수 있지? 라는 생각이 들 정도로 재능의 벽을 느꼈다... 생각나는 걸 써보면 정수 n을 입력받아 n의 약수를 모두 더한 값을 리턴하는 함수, solution을 완성해주세요. def solution(n): answer = 0 for i in range(1,n+1): if n % i == 0: answer = answer + i else: answer return answer 내가 적은 코드는 정말 정석적으로 나머지가 0, 즉 약수일 때 answer + i를 해 약수의 합을 구하는 방식으로 문제를 풀었다. def sumDivisor(num): # num / 2 의 수들만 검사하면 성능 약 2배 향상잼 return num + sum([i for i in range(1, (num // 2) + 1) if num % i == 0]) 이건 다른 풀이에 올라왔던 코드인데 일단 약수가 n 절반 이상이 될 일이 없기 때문에 num/2의 수들만 검사하게 되면 약 2배의 성능 향상을 기대해볼 수 있다는 말을 했다.. 난 왜 생각 못했을까.. 각에서 0도 초과 90도 미만은 예각, 90도는 직각, 90도 초과 180도 미만은 둔각 180도는 평각으로 분류합니다. 각 angle이 매개변수로 주어질 때 예각일 때 1, 직각일 때 2, 둔각일 때 3, 평각일 때 4를 return하도록 solution 함수를 완성해주세요. def solution(angle): if angle == 180: result = 4 elif angle < 180 and angle > 90: result = 3 elif angle == 90: result = 2 else: result = 1 return result 나는 180도면 4 90<angle<180이면 3 90이면 2 나머진 1 이런 식으로 문제를 풀었는데 어떤 사람들은 정말 간단하게 풀었다..(가장 충격먹었다..) def solution(angle): answer = (angle // 90) * 2 + (angle % 90 > 0) * 1 return answer 상대방 코드는 if문 없이 나눗셈의 몫과 나머지를 이용해 각도의 종류를 계산한 방식이다. 먼저 (angle // 90) * 2는 각도를 90으로 나눈 몫에 2를 곱해 기본값을 만든다. 예를 들어 45도는 0, 90도와 120도는 2, 180도는 4가 된다. 다음으로 (angle % 90 > 0) * 1은 90으로 나눈 나머지가 0보다 큰지 확인한다. 이때 조건을 만족하면 True, 만족하지 않으면 False가 나오는데, 파이썬에서는 산술 연산 시 True를 1, False를 0으로 취급한다. 따라서 45도와 120도에는 1을 추가하고, 90도와 180도에는 0을 추가한다. 결국 두 계산 결과를 더하면 예각은 1, 직각은 2, 둔각은 3, 평각은 4가 된다. 내 코드는 조건문으로 각도를 직접 분류하는 방식이고, 상대방 코드는 각도의 수학적 규칙을 이용해 반환값을 계산하는 방식이라는 차이가 있다.. 코드타카를 이제 매일마다 시간을 오래 두고 풀어보고 다른 사람과 풀이를 비교해보며 견문을 쌓아야 겠다는 생각을 했다..
2026 한국 D-2/E-7 입국 후 첫 30일 체크리스트: 외국인등록, 국민건강보험, 체류기간 연장 기초 MigrantIQ / Naveed Jawaid 유학(D-2) 비자나 특정활동(E-7) 비자로 한국에 막 도착했다면, 첫 한 달은 수업이나 온보딩보다 행정 처리 때문에 더 바쁘게 느껴질 수 있습니다. 순서만 제대로 잡으면 대부분은 어렵지 않습니다. 이 글은 입국 직후 30일 동안 챙겨야 할 것을 체크리스트로 정리한 것입니다. 수수료나 처리 기간 같은 숫자는 일부러 적지 않았습니다. 자주 바뀌기 때문에, 신청하는 날 하이코리아(HiKorea)와 국민건강보험공단 공식 안내에 나온 내용이 기준입니다. 먼저 짚고 갈 것: 비자와 체류자격은 다르다 비자(사증)는 입국을 위한 허가이고, 입국한 뒤에는 "체류자격"과 "체류기간"이 중요합니다. 여권에 찍힌 입국 날짜와 체류자격을 사진으로 남겨 두세요. 아래의 모든 기한은 이 날짜를 기준으로 계산됩니다. 1주차: 주소를 정하고 하이코리아 방문 예약하기 외국인등록 대상인지 확인하세요. 입국일로부터 90일을 넘게 체류하려는 외국인은 입국일로부터 90일 이내에 외국인등록을 해야 합니다(출입국관리법 제31조). D-2와 E-7은 보통 여기에 해당합니다. 90일을 믿고 미루지 마세요. 방문 예약은 먼저 찬 날짜부터 없어집니다. 예약 가능한 날짜가 기한 뒤로 밀리면 곤란해질 수 있으니, 주소가 정해지는 대로 바로 예약하는 것이 안전합니다. 관할 사무소는 주소 기준입니다. 하이코리아에서 방문 예약을 할 때는 학교나 회사가 아니라 내 체류지 주소를 관할하는 출입국/외국인관서를 선택해야 합니다. 신규 외국인등록은 방문 신청입니다. 외국인등록증 발급은 전자민원으로 처리되지 않고, 방문 예약 후 직접 가서 신청합니다. 서류를 미리 모으세요. 일반적으로 통합신청서, 여권, 사진, 체류지 입증서류, 수수료가 필요하고, 체류자격별 서류가 추가됩니다. D-2는 재학 관련 서류, E-7은 고용계약 관련 서류가 대표적입니다. 정확한 목록은 하이코리아의 체류자격별 안내를 확인하세요. 체류지 입증서류를 챙기세요. 기숙사라면 학교가 발급하는 거주 확인서, 원룸이라면 임대차계약서 등을 요구받을 수 있습니다. 어떤 서류가 인정되는지 미리 확인해 두면 두 번 방문하는 일을 줄일 수 있습니다. 헷갈리는 부분이 있으면 외국인종합안내센터(1345)에 문의할 수 있습니다. 2주차: 외국인등록증을 기다리는 동안 카드가 나오기 전의 증명 방법을 확인하세요. 신청 후 카드를 받기까지 시간이 걸립니다. 그동안 은행, 통신사, 병원 등에서 신분 확인이 필요하면 외국인등록사실증명을 발급받을 수 있는지 관서에 문의하세요. 은행 계좌와 휴대폰 개통 순서를 계획하세요. 많은 기관이 외국인등록증을 요구하기 때문에, 등록 전에는 선택지가 제한될 수 있습니다. 학교나 회사에 신입 외국인용 안내가 있는지 먼저 물어보세요. 출국 계획은 신중하게. 외국인등록 절차가 진행 중일 때 해외에 나가야 한다면, 출국 전에 영향이 있는지 관서나 1345에 먼저 확인하세요. 국민건강보험: D-2와 E-7은 가입 경로가 다르다 D-2 유학생 국민건강보험공단 안내에 따르면 2021년 3월부터 D-2 유학생은 국민건강보험 당연가입 대상입니다. 별도 신고 없이 공단이 가입을 처리하며, 처음 입국한 경우 보통 외국인등록일부터 적용됩니다. 학교 단체보험이나 본국에서 든 보험이 있다고 해서 자동으로 제외되지 않습니다. 가입 제외가 가능한지, 어떤 조건이 필요한지는 공단에 직접 확인하세요. 유학생에게 적용되는 보험료 경감이 있으니, 첫 고지서가 오면 경감이 반영되었는지 확인하세요. E-7 근로자 회사에 고용되어 일하면 보통 직장가입자로 가입되고, 회사가 자격 취득 신고를 합니다. 첫 급여명세서에서 건강보험료가 공제되었는지 확인하세요. 공제 내역이 없다면 인사팀에 가입 상태를 물어보는 것이 좋습니다. 공통 등록된 주소가 정확해야 고지서와 안내문이 제대로 도착합니다. 공단도 주소를 동/호수까지 정확히 신고하라고 안내합니다. 보험료를 체납하면 체류 관련 심사에서 불이익이 있을 수 있다는 안내가 있으니, 자동이체를 설정해 두면 편합니다. 체류기간 연장 기초: 지금 달력에 적어 두기 첫 30일에 연장을 신청할 일은 거의 없지만, 기한은 지금 알아 두는 것이 좋습니다. 신청 기간: 하이코리아 안내에 따르면 체류기간 연장은 현재 체류기간 만료 4개월 전부터 만료 당일까지 신청해야 합니다. 만료일이 지난 뒤 신청하면 범칙금이 부과됩니다(출입국관리법 제25조). 국내에 있어야 합니다. 신청 당일 본인이 한국에 체류하고 있어야 하며, 해외에서 신청할 수 없습니다. 온라인이냐 방문이냐는 자격에 따라 다릅니다. 하이코리아 전자민원으로 연장이 되는 경우도 있고, 방문 예약이 필요한 경우도 있습니다. 전자민원은 주말과 공휴일에 이용할 수 없으니 마감 직전에 몰아서 하지 마세요. D-2라면: 재학 상태, 출석, 학업 진행 상황, 재정 능력이 연장 심사와 연결됩니다. 첫 학기부터 출석과 성적을 관리하세요. E-7이라면: 고용 관계와 근무 내용이 허가받은 내용과 맞아야 합니다. 근무처를 바꾸거나 추가하기 전에 사전 허가나 신고가 필요한지 반드시 확인하세요. D-2 아르바이트: 시간제취업은 일을 시작하기 전에 허가를 받아야 합니다. 허가 없이 일하면 이후 체류에 영향을 줄 수 있습니다. 첫 달에 자주 하는 실수 외국인등록 방문 예약을 늦게 해서 기한에 쫓기는 것 이사 후 체류지 변경신고를 잊는 것 (정해진 기한이 있으니 하이코리아에서 확인하세요) 여권을 새로 발급받고 여권정보 변경신고를 하지 않는 것 건강보험 고지서가 옛 주소로 가서 체납이 쌓이는 것 비공식 대행업체의 "무조건 된다"는 말을 믿는 것 한 곳에서 보는 무료 참고 자료 공식 안내를 먼저 읽은 뒤, 한국의 체류 경로와 생활 정보를 한 페이지에서 훑어보고 싶다면 MigrantIQ의 무료 한국 국가 개요 페이지 를 참고 자료로 활용할 수 있습니다. 어디까지나 개요이며, 효력이 있는 기준은 언제나 법무부 출입국/외국인정책본부와 하이코리아, 국민건강보험공단이 공지한 내용입니다. 30일 체크리스트 요약 입국 날짜와 체류자격 기록 주소 확정 후 하이코리아 방문 예약 (관할 관서 확인) 외국인등록 서류 준비 및 방문 신청 건강보험 가입 상태 확인 (D-2: 공단 고지서 / E-7: 급여명세서) 체류기간 만료일과 연장 신청 시작일을 달력에 기록 D-2 시간제취업, E-7 근무처 변경 규칙 확인 면책 조항: 이 글은 일반적인 정보 제공을 위한 것이며 법률 또는 출입국 관련 자문이 아닙니다. 규정, 수수료, 제출서류는 바뀔 수 있습니다. 신청 전에 반드시 하이코리아, 출입국/외국인관서(1345), 국민건강보험공단의 최신 안내를 확인하거나 전문가와 상담하세요. MigrantIQ / Naveed Jawaid
Bangkok Floods as Devon and Cape Town Face Land Disputes (9.27) Table of contents Overview Details Overview Bangkok authorities declared a flood disaster after nearly two days of rain brought up to 300 mm to some areas in 48 hours. A proposed 1.5 GW AI campus and 1.8 GW battery system in north Devon has drawn opposition over its location, water demand and planning process. Cape Town plans to sterilize and relocate two baboon troops, while opponents argue the animals should remain on the peninsula. Details Bangkok Declares Flood Disaster After 300 mm of Rain in Some Areas Bangkok authorities declared a flood disaster after almost two days of heavy rain inundated homes, shops and roads. Some parts of the Thai capital recorded 300 mm over 48 hours through Saturday, The Guardian reported . Its photographs showed residents wading through water that reached waist height in places. The rain swelled the city's canals and sent water into nearby homes, restaurants and shops. Roads flooded, and some cars were submerged. The reported 300 mm is a measurement for some areas over a specified 48-hour period; it is not a citywide rainfall total. The immediate account describes a severe urban flood, with disruption visible across residential and commercial streets. It does not provide a final damage assessment or establish how long floodwaters will remain. Nor does it include a study attributing this particular rainfall event to climate change. Key takeaway: The reported rainfall explains the immediate flood emergency, while the full extent of damage and any climate attribution remain undetermined in the available account. Devon AI Campus Plan Draws Opposition in UNESCO Biosphere Reserve A proposed AI data campus near Great Torrington has become the focus of a land-use dispute in north Devon. British company Xlinks proposes a 1.5 GW data center and a 1.8 GW battery energy storage system on a 344-hectare site within a UNESCO-designated biosphere reserve, The Guardian reported . The project remains a proposal. Local opponents have raised concerns about the landscape, water use and the way the project entered public discussion. Documents obtained through freedom of information requests showed that Xlinks and council officials had held confidential talks before the proposal became public. Torridge district council said such pre-application discussions were standard practice and did not amount to approval. Xlinks says the project would bring investment, 600 to 1,200 jobs and energy infrastructure to the region. It also says about a third of the site would be developed, with other land open to the public or used for habitat creation. Those are company projections and plans, not measured outcomes. An environmental impact assessment was underway, and public consultation had been postponed until later in the year. Key takeaway: The Devon project joins a large computing facility with battery storage, but its local costs and promised benefits depend on plans and assessments still to be published. Cape Town Plans to Move Two Baboon Troops Amid Resident Conflict Cape Town's city council says it will soon round up two chacma baboon troops involved in repeated conflict with residents. The animals would be sterilized and permanently moved to a newly built sanctuary, The Guardian reported . Animal rights activists oppose the removal and argue that coexistence options have not received enough consideration. The conflict has become more acute as baboons enter homes, cars and shops in search of food. A 2025 census cited by The Guardian counted 493 baboons across 12 managed troops on the peninsula, compared with 360 in 2000. That increase supplies context for the pressure residents describe, though a population count alone cannot explain every encounter. The city's current proposal is narrower than an earlier management plan, which contemplated moving four troops. It now plans to move the Waterfall and Seaforth troops and build a northern boundary fence between urban areas and nearby mountains. Opponents favor fencing the two troops out of residential areas while allowing them to stay in their habitat. Whether either approach reduces encounters over time remains unresolved. Key takeaway: Cape Town's relocation plan may ease conflict in two neighborhoods, but its effects on baboon welfare and encounters elsewhere still need to be measured. In depth Bangkok flooding — in depth The canal system is central to what happened on the streets. As water levels rose, the network carried floodwater toward surrounding buildings. That helps explain why the effects reached homes and businesses as well as roads. The available report does not establish which locations flooded first or how drainage performed across the city. The 48-hour rainfall figure gives the event a useful scale, but it has limits. Rainfall can vary sharply across an urban area, so the reading from some locations cannot describe conditions everywhere in Bangkok. Likewise, photographs document the depth and reach of water at particular moments; they do not measure the total number of affected households or businesses. The distinction matters for assessing the response. Submerged vehicles and water entering shops show immediate disruption. They do not, on their own, establish the cost of repairs, the duration of closures or the pace of recovery. Those questions require subsequent assessments beyond the September 27 report. This flood also should not be presented as an attribution finding. A warmer climate can affect rainfall patterns, but the supplied account reports an event and its consequences, not research on its causes. Any claim that climate change caused or intensified this specific flood would require separate analysis. The next concrete measures of the disaster will be the extent and duration of inundation and the number of people and properties affected. For now, the supported conclusion is narrower: intense rain overwhelmed parts of Bangkok, raised canal levels and brought floodwater into streets and buildings. Devon data campus — in depth The proposal combines two large facilities with different purposes. A data center would serve computing demand; battery storage would help manage electricity supply and demand. Their placement on one site does not, by itself, establish the campus's net environmental effect. That depends on its eventual design, energy and water requirements, construction footprint and enforceable planning conditions. Water figures show why design details matter. A freedom of information response cited an estimate of approximately 2.9 billion liters a year under extreme conditions, including severe heatwaves and droughts. Xlinks told The Guardian that the estimate came from a design it had replaced. It said the new design would be released during consultation and with the planning application. The older estimate should therefore be treated as a disclosed scenario, not a forecast for the current proposal. The company's biodiversity and land-access statements also need to be judged against the final application. Xlinks says it must improve biodiversity by at least 10% and plans to exceed that requirement. It has described habitat creation and measures to reduce the buildings' visibility. These commitments do not yet show where gains would occur, how they would be measured or what conditions would bind the developer. The planning dispute has a procedural dimension as well as an environmental one. Residents learned through records requests about earlier private discussions and an April letter expressing in-principle support. The council says its confidential pre-application process offers planning guidance, not a decision. A published application and environmental assessment will give the public a firmer basis to compare the developer's claims with the site's likely effects. Battery storage has a wider policy context: it can help balance a grid with more wind and solar generation. The Guardian reported a UK government estimate that 23–27 GW of battery capacity would be needed by 2030 for its clean-power goals. That national need does not settle whether this particular site, scale or design is suitable. The pending consultation is where those questions become specific enough to test. Cape Town baboon management — in depth Geography makes the dispute difficult to solve with a single boundary. The Cape Peninsula places baboons between protected mountain habitat and developed low-lying land. A representative of the Cape Baboon Partnership told The Guardian that lower areas offer water and productive vegetation, but much of that ground has been developed. Homes and shops therefore sit close to places the animals seek out. Moving two troops could reduce encounters in the neighborhoods they now enter. It also raises questions about the animals' welfare after capture, sterilization and permanent confinement. Opponents say removal would disrupt their social groups and take them out of an ecosystem where they disperse seeds. The report does not provide a completed assessment of how the sanctuary would address those concerns. Fencing presents a different trade-off. Activists and some scientists argue that a fence could keep the Waterfall and Seaforth troops away from homes without relocating them. University of Cape Town behavioral ecologist Justin O'Riain told The Guardian that baboons might instead move toward another low-lying area where the fence ends. That is a forecast about behavior, not an observed result of the proposed fence. Residents and baboons both face risks under current conditions. The report describes repeated property intrusions, while baboons have been poisoned, struck by vehicles, chased by dogs and shot. Cape Town's management program costs about 20 million rand over 18 months and employs rangers to head off conflict. The cost and staffing show an established response, but continued encounters have kept the argument over its effectiveness alive. The strongest te
한국 개발자를 위한 Jev AI 가이드: 챗봇이 아닌 '판단 API' 제대로 써보기 TypeSafe의 Jev는 답변 대신 선택·점수·예/아니오 확률을 돌려줍니다. 한국어 데이터로 테스트할 때 알아둘 점, 요금, 첫 API 호출까지 한 번에 정리했습니다. 고객 문의를 분류하거나, 요청마다 어떤 모델을 쓸지 고르거나, 에이전트가 도구를 실행하기 전에 위험한지 확인할 때 보통 LLM에 "billing, bug, account 중 하나로만 답해"라고 시킵니다. 그다음 응답을 파싱하고, 모델이 한 문장을 덧붙이거나 JSON이 깨지면 예외 처리를 추가합니다. 사실 필요한 건 긴 답변이 아니라 if 문 하나에 들어갈 값입니다. TypeSafe AI의 Jev 는 바로 이 "애매한 if"를 위해 만든 모델입니다. 이 글에서는 Jev가 무엇인지, 그리고 한국어 서비스에 적용할 때 무엇을 확인해야 하는지 정리했습니다. Jev는 어떤 모델인가 Jev는 TypeSafe AI가 2026년 9월 15일에 발표한 첫 번째 "System One" 모델입니다. System One이라는 이름은 빠르고 직관적인 판단(시스템 1)에서 따왔습니다. 호스팅 API는 2026년 9월 21일부터 대기자 명단 없이 누구나 쓸 수 있습니다. 현재 공개 모델 이름은 jev-1.13.0 이고, jev-latest 와 jev-preview 별칭으로도 호출할 수 있습니다. 핵심은 Jev가 챗봇이 아니라는 점 입니다. TypeSafe에 따르면 현재 버전은 텍스트를 생성하도록 학습되지 않았습니다. 글쓰기, 요약, 설명은 GPT나 Claude 같은 범용 LLM이 맡고, Jev는 분기 판단만 맡는 식으로 나눠 쓰는 것이 기본입니다. 또 Jev는 오픈소스가 아니며 가중치도 공개되지 않았습니다. 세 가지 출력 타입 Jev에 보내는 질문은 모두 다음 세 가지 중 하나입니다. choice : 직접 정의한 선택지(최대 255개) 중 하나와 각 선택지의 확률을 돌려줍니다. 문의 라우팅이나 의도 분류에 씁니다. score : 2~10단계로 된 순서형 루브릭 위의 점수를 돌려주며, 단계 사이의 값도 나올 수 있습니다. 긴급도, 위험도, 리드 품질 평가에 씁니다. noul : 예/아니오 명제가 참일 보정된 확률(0~1)을 돌려줍니다. 에스컬레이션이나 도구 실행 허용 여부를 정할 때 씁니다. 한 요청에 질문을 최대 8개까지 묶을 수 있습니다. 모든 질문이 같은 상태(state)를 기준으로 평가되기 때문에 입력 토큰 비용도 한 번만 듭니다. 한국어 사용자가 먼저 알아야 할 점 가장 중요한 부분입니다. Jev는 주로 영어로 학습되었습니다. 한국어 텍스트도 판단할 수 있지만 정확도는 영어보다 조금 낮다고 jevmodel.org의 한국어 플레이그라운드에도 명시되어 있습니다. 그래서 다음 순서를 권합니다. 실제 한국어 문의나 로그를 20~50건 정도 준비합니다. 플레이그라운드에서 같은 질문으로 돌려 보고 결과를 직접 확인합니다. 확률이 애매하게 나오는 경우는 GPT, Claude, 또는 사람에게 넘기는 대체 경로를 설계합니다. 선택지 이름(criteria)과 지시문(instructions)을 짧고 명확하게 쓰면 결과가 안정적인지 비교하기 쉽습니다. 한국어 입력에서 어떤 표현이 잘 통하는지는 직접 확인해 보는 것이 가장 확실합니다. 가입 없이 플레이그라운드로 시작하기 TypeSafe와 제휴 관계가 없는 독립 가이드 사이트인 jevmodel.org 한국어 페이지 에서는 브라우저에서 실제 API로 Jev를 체험할 수 있습니다. 가입 없이 입력 토큰 10,000개 , 로그인하면 100,000개 (일반적인 요청 약 500회분)를 무료로 쓸 수 있습니다. 카드 등록은 필요 없습니다. 한국어 플레이그라운드 에서 텍스트를 넣고 질문을 추가한 뒤 실행하면 타입이 정해진 결과가 바로 나옵니다. 첫 API 호출 결과가 쓸 만하다고 판단되면 로그인한 뒤 대시보드에서 API 키를 만들고, 서버 환경 변수 JEVMODEL_API_KEY 에 저장합니다. 아래는 한국어 문의를 분류하는 예시입니다. const response = await fetch("https://jevmodel.org/v1/systemone", { method: "POST", headers: { "Authorization": "Bearer " + process.env.JEVMODEL_API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ model: "jev-latest", state: "결제가 두 번 됐어요. 오늘 안에 환불해 주세요.", questions: { topic: { type: "choice", instructions: "What is this about?", criteria: { billing: "payments", bug: "broken product", account: "access" } }, escalate: { type: "noul", instructions: "Should a human review this now?" } } }) }); const result = await response.json(); 알아두면 좋은 사양은 다음과 같습니다. state 는 문자열이나 JSON 값이며, 직렬화 후 최대 8,000자까지 보낼 수 있습니다. API 키 하나당 분당 120회까지 요청할 수 있습니다. 성공한 요청의 입력 토큰만 과금됩니다. 재시도할 때는 Idempotency-Key 헤더를 붙이세요. 요청 형식이 TypeSafe API와 같기 때문에 나중에 TypeSafe로 직접 옮길 때는 베이스 URL만 바꾸면 됩니다. 임계값 튜닝이 끝나면 jev-1.13.0 으로 버전을 고정하세요. 별칭은 나중에 다른 버전을 가리킬 수 있습니다. 요금 정리 TypeSafe 공식 가격은 입력 100만 토큰당 $0.042이고, 출력은 무료 입니다. 생성된 텍스트가 아니라 타입이 정해진 값을 돌려주기 때문에 출력에는 과금하지 않습니다. jevmodel.org에서는 TypeSafe 계정 없이 쓸 수 있는 토큰 팩을 $9.90(입력 토큰 1,500만 개)부터 판매합니다. 팩은 만료되지 않고, 실패한 요청은 과금되지 않습니다. 사이트에서도 밝히고 있듯이 토큰당 단가는 TypeSafe에 직접 결제하는 것보다 비쌉니다. 트래픽이 많고 꾸준한 단계가 되면 TypeSafe나 OpenRouter, Vercel AI Gateway로 직접 연결하는 편이 저렴합니다. 이런 곳에 잘 맞습니다 CS 문의 분류: 주제 분류, 긴급도 점수, 에스컬레이션 여부를 한 번에 판단 LLM 라우터: 쉬운 요청은 저렴한 모델로, 어려운 요청은 큰 모델로 분배 에이전트 도구 호출 가드레일: 메일 발송이나 파일 수정 전에 위험도 점수 확인 LLM 심사·RAG 평가: 답변이 근거에 기반했는지, 검색된 문단이 관련 있는지 확인 마무리 Jev는 범용 LLM을 대체하지 않습니다. 대신 지금 채팅 모델에 맡기고 응답을 파싱하던 "애매한 if"를 대체할 수 있습니다. Jev가 분기를 정하고 사람이 읽을 글은 LLM이 쓰는 하이브리드 구조가 가장 현실적입니다. 한국어 정확도는 직접 확인해야 하는 만큼, 먼저 jevmodel.org 에서 실제 데이터 몇 건으로 테스트해 보시길 권합니다. 안내: jevmodel.org는 독립 가이드 사이트이며 TypeSafe AI와 제휴·보증·운영 관계가 없습니다. Jev는 TypeSafe AI의 제품입니다. 이 글의 정보는 jevmodel.org와 TypeSafe의 공개 자료(2026년 9월 기준)를 바탕으로 합니다.
들어가며 이번 문제는 Amazon S3에 관한 문제라고 한다. 이번 기회에 Amazon 서비스와 더불어 관련된 웹서비스, 공격 방법 등을 공부해보자. Task 1.How many TCP ports are open? nmap으로 열린 포트를 확인해보자. 물론 전체 포트를 전수조사해야할 수도 있으나 우선은 옵션없이 해보자. 2개의 포트가 나왔고, 22번 포트와 80번 포트가 열려있다. Task 2.What is the domain of the email address provided in the "Contact" section of the website? Contact 라는 섹션이 있는 듯 하다. 해당 웹페이지로 접속해 Contact 섹션을 확인하자. 이메일은 mail@thetoppers.htb 이므로 도메인은 thetoppers.htb 이다. Task 3.In the absence of a DNS server, which Linux file can we use to resolve hostnames to IP addresses in order to be able to access the websites that point to those hostnames? 문제에서는 DNS 서버가 없을 시에 호스트네임을 IP로 변환하는 파일이 무엇이냐고 묻고 있다. 해당 파일은 처음 리눅스 설정 시 8.8.8.8 주소를 작성하는데 사용했던 /etc/hosts 파일이다. 찾아보니 해당 파일은 DNS서버에 질의 이전에 가장 먼저 참조하는 파일이라고 한다. 형식은 IP 호스트네임 형식을 사용한다. 그래서 도메인에 http://thetoppers.htb 를 입력하면 아래와 같이 서버를 찾을 수 없다고 한다. 이는 공용 인터넷 DNS에 .htb 도메인이 등록되어있지 않기 때문이다. 이때 이전에 말했던 /etc/hosts 파일에 IP-호스트네임 매핑 정보를 저장해야 한다. 해당 정보를 저장한 후에는 해당 도메인 네임으로 서버에 접근 가능하다. Task 4. Which sub-domain is discovered during further enumeration? 이전까지 특정 도메인의 서브디렉토리를 Gobuster 를 이용해서 찾곤 했다. 하지만 이들은 서브도메인을 찾을 수는 없었다. 하지만 서브도메인 또한 서브디렉토리처럼 gobuster를 이용해 찾을 수 있을 것 같았다. 따라서 Gobuster를 이용해 서브도메인을 탐색하는 방법을 찾아보았다. 찾아보니 dns 옵션을 이용하는 방법과 vhost 옵션을 이용하는 방법이 존재했다. dns 옵션은 -d 옵션과 함께 특정 도메인에 대해 DNS 서버에 워드리스트의 단어를 붙혀 질의하는 방식이며, vhost 는 -u 옵션과 함께 특정 URL에 대해 워드리스트의 단어를 붙혀 HTTP 헤더의 Host 값을 가상호스트의 서브도메인 값으로 변경하여 request를 전송하는 방식을 사용한다. dns 방식은 워드리스트의 단어들을 하나씩 도메인에 붙혀 서브도메인으로 만든 후 DNS 서버에 이를 질의하는 방식이다. 따라서 이는 퍼블릭 DNS 레코드가 존재해야 한다. 하지만 우리는 .htb 도메인에 대해 탐색중이므로 해당 방식을 사용할 수 없다. vhost 방식은 가상호스트에게 질의한다. 가상호스팅은 하나의 서버에서 여러 웹사이트 혹은 도메인을 운영하는 것이다. 가상 호스트는 하나의 IP주소에 해당하는 여러 개의 호스트가 운영될 수 있으며 Public DNS 레코드를 가지지 않을 수 있다. vhost 모드는 DNS 질의 대신 HTTP 헤더의 Host 필드에 워드리스트를 이용해 서브도메인을 작성한다. IP는 가상호스트이므로 동일하기 때문에 HTTP 헤더를 조작하는 것만으로도 브루트포싱이 가능한 것이다. (출처: https://www.moonding.co.kr/how-to-use-gobuster/ ) 첫 번째 이미지는 vhost 방식을, 두 번째 이미지는 dns 방식을 이용했다. 첫 번째 이미지에서 s3.thetoppers.htb , gc._msdcs.thetoppers.htb 서브도메인이 발견되었다. gc._msdcs 는 AD에서 글로벌 카탈로그 서버를 찾기 위해 사용되는 DNS 레코드 및 경로라고 한다. s3.thetoppers.htb 는 Amazon S3 서비스가 동작중인 서버라고 한다. 물론 사용자가 임의로 서브도메인네임을 s3.~~로 설정했을 수도 있다. 본 Task의 정답은 두 서브도메인 중 s3.thetoppers.htb 였다. Task 5. Which service is running on the discovered sub-domain? Task 4에서 기술하였듯, Amazon S3 서비스가 동작중이다. 추가로, /etc/hosts에 s3.thetoppers.htb 도메인에 대한 IP 정보를 추가하면 해당 도메인에 접근할 수 있다. Task 6. Which command line utility can be used to interact with the service running on the discovered sub-domain? Amazon S3와 상호작용가능한 CLI 유틸리티라니.... 모르겠다. 찾아보자. 출처: https://docs.aws.amazon.com/ko_kr/cli/latest/userguide/cli-services-s3.html Amazon CLI를 이용하면 Amazon S3 기능에 접근할 수 있다고 한다. 근데 S3가 뭐지? 또한 찾아보자. 출처: https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/Welcome.html S3(Simple Storage Service)란 클라우드 저장소 서비스라고 한다. 또한 모든 데이터는 고유한 HTTP/HTTPS URL을 통해 웹 기반 API로 업로드, 다운로드가 가능하다고 한다. 결국 구글드라이브처럼 클라우드 스토리지 서비스지만 REST API를 통해 업로드, 다운로드가 가능하다. 이를 CLI 명령어를 통해서도 실행할 수도 있고. 따라서 이번 Task의 답은 awscli . Task 7. Which command is used to set up the AWS CLI installation? 해당 task의 답은 https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html 를 참고하라고 한다. 위의 이미지는 https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html 를 참조했다. aws configure 명령어를 이용해 AWS CLI setup이 가능하다고 한다. Task 8. What is the command used by the above utility to list all of the S3 buckets? 문제의 힌트에서 https://docs.aws.amazon.com/cli/latest/reference/s3/ 를 참고하라고 한다. 주 내용은 다음과 같다. 저수준 API 제어가 필요할 경우 aws s3api 를 이용할 것 LOCALPATH (로컬 파일 혹은 디렉토리의 경로), S3URi 중 하나의 옵션을 반드시 이용할 것 aws s3 <명령어> 의 형태를 가지며, 명령어는 cp,ls,mb(버킷 생성) ,rb(버킷 삭제), mv 등의 명령어가 있다. 따라서 aws s3 ls 로 S3 버킷의 리스트를 확인할 수 있다. 이때 위와 같이 사용할 경우 에러가 발생한다. 이는 옵션을 주지 않을 시 퍼블릭 AWS 클라우드 주소( https://s3.<리전>.amazonaws.com )로 접속을 시도하기 때문이라고 한다. 따라서 로컬 머신의 엔드포인트를 지정해야 한다. 이때 --endpoint-url 옵션을 이용한다. S3 버킷 내부에 index.php가 존재한다. 따라서 본 Task의 정답은 aws s3 ls . Task 9. This server is configured to run files written in what web scripting language? 위 이미지에서 봤듯, PHP 파일이 존재한다. 따라서 답은 PHP . Task 10.Submit the flag located in /var/www/. Task 9에서 PHP가 존재한다고 했으므로 S3에 PHP 웹쉘을 업로드해 실행하면 리버스쉘을 실행시킴과 동시에 flag를 찾아서 읽을 수 있을 것이다. 우선 시험용 php파일인 shell.php를 생성했다. 업로드는 이전의 ls 대신 cp를 사용하면 된다. 이제 /shell.php로 접속해보자. php가 실행되었다. 이제 /var/www에 위치한 flag를 출력해보자. 이때 리버스쉘을 실행시키기 전에 cat /var/www/flag.txt 를 실행시켜보자. 운이 좋으면 권한상승 없이 가능할 것이다. shell.php를 <?php system('cat /var/www/flag.txt'); ?> 로 수정한 후 파일을 S3 버킷에 재업로드한 후 shell.php에 재접속했다. flag 출력 완료! 마치며 사실 이전에 풀다가 중간에 다시 풀이를 작성하느라 생략된 부분이 많다(aws configuration 에러 등). 가상호스팅도 짧게 풀어냈지만 실제로는 이해하는데 시간을 꽤나 잡아먹었고, AWS도 많이 써보지 않았어서 꽤나 애먹으며 힌트의 도움을 받은 문제였다. 결론은 AWS 또한 권한설정 등을 세부적으로 설정해야하며, 온디멘드 서버 또한 보안서비스에 의존하지 않고 내부 파일 보안설정 등이 필수적이라는 것이다.