# 진재명의 블로그 전체 글 컨텍스트
> 만들며 알게 된 것과 먹고 다닌 것을 기록합니다.
Corpus updated: 2026.10.08
Source: https://blog.jaemyeong.com/llms.txt
# Miso oden bar, Dongnae: an easy place to talk over oden
> An oden bar in Dongnae where I went for a second round on a Thursday night. I had oden and grilled toothfish with a bottle of Junmai Momokawa sake.
- Canonical: https://jaemyeong.com/en/blog/dongnae-miso-20260507/
- Published: 2026.10.08
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #oden, #bar, #sake
After webfoot octopus at [Horangi Yeontan](/en/posts/dongnae-horangi-yeontan-20260507/), I went to Miso for a second round at about 9:40 on a Thursday night. It is an oden bar near Dongnae Station, and it is where I usually head for a second round. I also have [a separate note from a visit in June](/en/posts/dongnae-miso-20260625/).
There are two oden places in Dongnae that I go to often, and I like both. Miso is a good place to come for a second round and talk comfortably over oden.
It is open Monday through Saturday from 6 p.m. to 1 a.m., and closed on Sundays.
That night I had oden and grilled toothfish, and for the drink I ordered Junmai Momokawa sake. It is 35,000 won.

I did not get a photo of the toothfish.
---
# 동래 오뎅바 미소, 오뎅을 놓고 편하게 이야기하기 좋았어요
> 호랑이연탄에서 쭈꾸미를 먹고 목요일 밤에 2차로 간 동래의 오뎅바예요. 오뎅과 메로구이를 먹고 준마이 모모카와를 한 병 시켰어요. 오뎅을 놓고 편하게 이야기하기 좋은 자리예요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-miso-20260507/
- Published: 2026.10.08
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #oden, #bar, #sake
[호랑이연탄](/posts/dongnae-horangi-yeontan-20260507/)에서 쭈꾸미를 먹고 나서, 목요일 밤 9시 40분쯤에 2차로 미소에 갔어요. 동래역 근처의 오뎅바이고, 저는 평소에도 2차 자리로 여기를 찾아요. [6월에 간 날의 기록](/posts/dongnae-miso-20260625/)도 따로 있어요.
동래에는 제가 자주 가는 오뎅집이 두 곳 있는데, 두 곳 모두 좋아요. 미소는 2차로 와서 오뎅을 놓고 편하게 이야기하기에 좋은 집이에요.
영업은 월요일부터 토요일까지 저녁 6시부터 다음 날 새벽 1시까지이고, 일요일에 쉬어요.
이날은 오뎅과 메로구이를 먹었고, 술은 준마이 모모카와를 시켰어요. 35,000원이에요.

메로구이는 사진을 찍지 못했어요.
---
# Horangi Yeontan, Dongnae: decent webfoot octopus, small portion
> A briquette-grill bar in Dongnae I visited because I wanted webfoot octopus. The 39,900 won set for two tasted fine, but there was less octopus than I expected.
- Canonical: https://jaemyeong.com/en/blog/dongnae-horangi-yeontan-20260507/
- Published: 2026.10.07
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #grill, #seafood, #bar
I wanted webfoot octopus, so at about 8 on a Thursday evening I went to Horangi Yeontan in Dongnae. It is a bar that serves food grilled over coal briquettes, near Dongnae Station. There was a short wait before I got in. The second stop that night is in a separate post, [my May note on Miso](/en/posts/dongnae-miso-20260507/).
I ordered the set for two that comes with briquette-grilled spicy webfoot octopus and grilled mackerel. It is 39,900 won.

There was less octopus than I expected, which was disappointing. The taste was fine. For grilled webfoot octopus alone, I prefer Huiyane Seoksoe Jjukkumi.
The staff were friendly. If you want webfoot octopus in Dongnae, I would recommend it.
It opens at 5 p.m. on weekdays and at 4 p.m. on Saturdays and Sundays. It closes at midnight, and at 11 p.m. on Sundays. For parking you have to use the paid Dongnae public lot.

The grilled mackerel tasted fine too. I prefer regular grilled mackerel, though.
---
# 동래 호랑이연탄, 쭈꾸미 맛은 괜찮은데 양이 적었어요
> 쭈꾸미가 먹고 싶어서 목요일 저녁에 간 동래의 연탄구이 술집이에요. 39,900원짜리 연탄 불쭈꾸미와 고갈비 2인 세트를 시켰어요. 맛은 괜찮았는데 쭈꾸미가 생각보다 적었어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-horangi-yeontan-20260507/
- Published: 2026.10.07
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #grill, #seafood, #bar
쭈꾸미가 먹고 싶어서 목요일 저녁 8시쯤에 동래의 호랑이연탄에 갔어요. 연탄으로 구운 안주를 내는 술집이고, 동래역 근처에 있어요. 들어가기 전에 잠깐 기다렸어요. 이날의 두 번째 자리는 [미소 5월 글](/posts/dongnae-miso-20260507/)에 따로 적었어요.
연탄 불쭈꾸미와 고갈비가 같이 나오는 2인 세트를 시켰어요. 39,900원이에요.

쭈꾸미는 생각한 것보다 양이 적어서 아쉬웠어요. 맛은 괜찮았어요. 쭈꾸미 구이만 놓고 보면 저는 희야네석쇠쭈꾸미를 더 좋아해요.
직원분들은 친절했어요. 동래에서 쭈꾸미를 먹고 싶은 분께는 권할 만해요.
영업은 평일에 오후 5시부터, 토요일과 일요일에는 오후 4시부터 해요. 자정에 닫고 일요일에는 밤 11시에 닫아요. 주차는 유료인 동래 공영주차장을 써야 해요.

고갈비도 맛이 무난했어요. 다만 제 취향은 일반 고등어구이 쪽이에요.
---
# Suyeong Naengsam, Dongnae branch: thin pork belly, great fried rice
> A visit to the since-closed Dongnae branch of Suyeong Naengsam. The 1 mm frozen pork belly was affordable, and the fried rice was a great finish.
- Canonical: https://jaemyeong.com/en/blog/suyeong-naengsam-dongnaejeom-20260504/
- Published: 2026.10.06
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #pork, #barbecue
One thing first. The place I went to was the Dongnae branch of Suyeong Naengsam, and as I write this it has closed. It no longer shows up on Naver Map either. So the map and opening hours in this post are those of the main branch in Suyeong.
I went to the Dongnae branch with a friend at about 7 on a Monday evening. It was my first time at that branch, and I had been to the main branch in Suyeong before.
The restaurant sells quick-frozen pork belly sliced very thin, at 1 mm. The first meat order has to be at least 400 g. As I remember it we ordered about 500 g, though I am not certain.

Because the meat is thin, it cooks quickly. It is better to lay the slices out one at a time than to stack them.

It felt affordable, and the quality of the meat was fine. It went well with soju.
The Suyeong main branch is open daily from 3 p.m. to 11 p.m., with last orders at 10 p.m. It has parking.
I am sorry the Dongnae branch is gone, but the main branch is still there, so I plan to go when I get the chance.
At the end we fried our own rice.

That fried rice was a very satisfying way to end the meal.
---
# 수영냉삼 동래점, 얇은 냉삼과 셀프 볶음밥이 좋았어요
> 지금은 문을 닫은 수영냉삼 동래점에 월요일 저녁에 친구와 갔던 기록이에요. 1mm로 얇게 썬 급냉삼겹은 값이 부담 없고 고기도 괜찮았고, 직접 볶아 먹는 볶음밥이 마무리로 아주 좋았어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-naengsam-dongnaejeom-20260504/
- Published: 2026.10.06
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #pork, #barbecue
먼저 밝혀 둘 것이 있어요. 제가 간 곳은 수영냉삼 동래점인데, 이 글을 쓰는 지금은 문을 닫았어요. 네이버 지도에서도 찾을 수 없어요. 그래서 이 글의 지도와 영업 정보는 수영에 있는 본점의 것이에요.
동래점에는 월요일 저녁 7시쯤에 친구와 갔어요. 동래점은 이날이 처음이었고, 수영의 본점은 전에 가 본 적이 있어요.
1mm로 아주 얇게 썬 급냉삼겹을 파는 집이에요. 고기는 처음에 400g 이상 시켜야 해요. 저희는 500g쯤 시킨 것으로 기억하는데 확실하지는 않아요.

고기가 얇아서 금방 익어요. 여러 장을 겹쳐 올리지 말고 한 장씩 펴서 굽는 편이 좋아요.

값이 부담 없다고 느꼈고, 고기의 질도 괜찮았어요. 소주를 곁들이기에 좋았어요.
수영본점은 매일 오후 3시부터 밤 11시까지 하고 주문은 10시에 마감해요. 주차를 할 수 있어요.
동래점이 없어진 것은 아쉽지만 본점이 남아 있으니, 기회가 되면 본점에 갈 생각이에요.
마지막에는 볶음밥을 직접 볶아 먹었어요.

이 볶음밥이 식사의 마무리로 아주 만족스러웠어요.
---
# Yetnal Omakjip, Daesin-dong: the tripe and intestine were great again
> My favorite beef tripe restaurant in Busan, with a friend visiting from another city. The meat was as good as ever, but service suffered on a busy night.
- Canonical: https://jaemyeong.com/en/blog/daesin-yetnal-omakjip-20260502/
- Published: 2026.10.05
- Category: 식기(食記)
- Tags: #Busan, #Daesin, #gopchang, #barbecue
Of the beef tripe restaurants in Busan, this is the one I like best. But I find it expensive, so I do not go often. That day a friend was visiting from another city and we were having dinner together, so we went at about 7 on a Saturday evening.
It is a beef tripe specialist near Dongdaesin Station. I am told it has been featured on a TV food program. Many of the customers looked like they were there for family occasions or business dinners.
We ordered the premium tripe and the large intestine.

Here the staff usually grill the meat for you. That day, though, it was crowded, the staff were going back and forth between many tables, and I saw meat burning at a few of them. The service was a letdown. Even so, the tripe and the intestine tasted just as good as on my earlier visits.
I thought it would be better to come on a quiet weekday. It is the place I recommend when you want beef tripe on a special day.
It is open from noon to 9:30 p.m., with last orders at 8:30 p.m. It closes on the second Monday of each month. Valet parking is available.
To finish, we ordered both the udon and the fried rice. I wanted my friend to be able to taste a little of each.


Both were good. Which one to pick is a matter of taste. I usually finish with the udon.
---
# 대신동 옛날오막집, 특양과 대창은 이번에도 아주 맛있었어요
> 다른 도시에서 놀러 온 친구와 토요일 저녁에 간 대신동의 양곱창집이에요. 특양과 대창은 여전히 아주 맛있었고 우동과 볶음밥으로 마무리했어요. 손님이 많은 날이라 응대는 아쉬웠어요.
- Canonical: https://jaemyeong.com/ko/blog/daesin-yetnal-omakjip-20260502/
- Published: 2026.10.05
- Category: 식기(食記)
- Tags: #Busan, #Daesin, #gopchang, #barbecue
부산의 양곱창집 가운데 제가 가장 좋아하는 곳이에요. 다만 값이 비싸다고 느껴서 평소에 자주 가지는 못해요. 이날은 다른 도시에서 놀러 온 친구와 밥을 먹게 돼서, 토요일 저녁 7시쯤에 갔어요.
동대신역 근처에 있는 양곱창 전문점이에요. TV 음식 프로그램에 소개된 적이 있다고 해요. 가족 행사나 직장 접대로 온 듯한 손님이 많아 보였어요.
특양과 대창을 시켰어요.

이 집은 보통 직원분이 고기를 구워 줘요. 그런데 이날은 손님이 많아서 직원분이 여러 테이블을 오가며 응대했고, 몇몇 테이블에서는 고기가 타는 모습도 보였어요. 응대는 아쉬웠어요. 그래도 특양과 대창의 맛은 전에 왔을 때와 마찬가지로 아주 좋았어요.
붐비지 않는 평일에 오는 편이 낫겠다고 생각했어요. 특별한 날에 양곱창이 먹고 싶을 때 권하는 집이에요.
영업은 낮 12시부터 밤 9시 반까지이고 주문은 8시 반에 마감해요. 매달 둘째 월요일에 쉬어요. 주차는 발렛으로 할 수 있어요.
마무리로는 우동과 볶음밥을 둘 다 시켰어요. 친구가 두 가지를 조금씩 맛볼 수 있게 하려고 그랬어요.


둘 다 맛있었어요. 어느 쪽을 고를지는 취향대로 하면 돼요. 저는 평소에 우동으로 마무리하는 때가 더 많아요.
---
# Seugeun in Jeonpo: very good beef tendon suyuk on a full stomach
> A bar specializing in beef tendon, our second stop on a Wednesday night. The tendon suyuk was very good, and the Chapagetti went well with scallion kimchi.
- Canonical: https://jaemyeong.com/en/blog/jeonpo-seugeun-20260930/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Jeonpo, #bar, #beef, #noodles
After the raw fish with perilla at [Noriba](/en/posts/seomyeon-noriba-20260930/), we looked for somewhere to keep drinking and ended up here. It was about 8:20 on Wednesday night, and there were two of us. It is over by Seomyeon Station.
It is a bar whose food centers on beef tendon. We were already full, so after weighing the tendon suyuk against the jjageuli stew, we chose the suyuk.

The suyuk was very good.
Partway through we added Chapagetti. Maybe it was because the food had settled.

The Chapagetti comes with scallion kimchi, and the two went well together.
There is parking. I think the two of us drank about eight bottles of soju that day, though I am not sure of the count.
---
# 전포 스근, 배가 불렀는데도 스지 수육이 아주 맛있었어요
> 노리바에서 먹고 수요일 밤 8시 20분쯤 2차로 간 스지 전문 요리주점이에요. 배가 불렀지만 스지 수육이 아주 맛있었고, 먹다가 더 시킨 짜파게티는 파김치와 잘 어울렸어요.
- Canonical: https://jaemyeong.com/ko/blog/jeonpo-seugeun-20260930/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Jeonpo, #bar, #beef, #noodles
[노리바](/posts/seomyeon-noriba-20260930/)에서 깻잎막회를 먹고 나서, 한잔 더 할 곳을 찾다가 들어간 집이에요. 수요일 밤 8시 20분쯤이었고 둘이서 갔어요. 서면역 쪽에 있어요.
스지를 주로 내는 요리주점이에요. 이미 배가 부른 상태라 스지 수육과 짜글이를 놓고 고민하다가 수육으로 정했어요.

수육이 아주 맛있었어요.
먹는 도중에 짜파게티를 더 시켰어요. 소화가 돼서 그랬는지도 모르겠어요.

짜파게티에는 파김치가 같이 나오는데, 둘이 잘 어울렸어요.
주차를 할 수 있어요. 이날 둘이서 소주를 여덟 병쯤 마신 것 같은데, 기억이 확실하지는 않아요.
---
# Noriba in Seomyeon: the raw fish with perilla and sides were good
> A bar in Seomyeon picked by a friend and former coworker. We walked in as the first customers and ordered the raw fish with perilla on its own.
- Canonical: https://jaemyeong.com/en/blog/seomyeon-noriba-20260930/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #bar, #raw, #seafood
I had plans on Wednesday evening with a friend who is also a former coworker. My friend goes to Seomyeon often and chose Noriba. For me it was the first time in Seomyeon in a while.
We arrived at about 6:30 and were the first customers. We stayed the only ones the whole time we ate.

It is a bar that serves food, and my first impression of the menu was good. There are set menus too. But I do not like mulhoe, the cold raw fish soup, so I ordered the raw fish with perilla on its own. On its own it has to be ordered for at least two.

The raw fish with perilla was good. So were the side dishes that came with it.
After eating here we moved on to [Seugeun](/en/posts/jeonpo-seugeun-20260930/). It was a happy day, back in Seomyeon after a long while and pleased with the food at both places.
---
# 서면 노리바, 깻잎막회도 밑반찬도 맛있었어요
> 예전 직장 동료였던 친구가 고른 서면의 요리주점이에요. 수요일 저녁 6시 반쯤 첫 손님으로 들어갔어요. 물회를 좋아하지 않아 세트 대신 깻잎막회 단품을 시켰고 밑반찬까지 맛있었어요.
- Canonical: https://jaemyeong.com/ko/blog/seomyeon-noriba-20260930/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #bar, #raw, #seafood
친구이자 예전 직장 동료와 수요일 저녁에 약속이 있었어요. 서면에 자주 가는 친구가 노리바를 골랐어요. 저는 오랜만에 서면에 갔어요.
저녁 6시 반쯤에 도착했는데 저희가 첫 손님이었어요. 먹는 동안에도 손님은 저희뿐이었어요.

요리주점이고, 메뉴판을 처음 봤을 때 인상이 좋았어요. 세트 메뉴도 있어요. 다만 저는 물회를 좋아하지 않아서 깻잎막회를 단품으로 시켰어요. 단품은 2인분부터 주문할 수 있어요.

깻잎막회가 맛있었어요. 같이 나온 밑반찬도 맛있었어요.
이날은 여기서 먹고 나서 [스근](/posts/jeonpo-seugeun-20260930/)으로 자리를 옮겼어요. 오랜만에 간 서면에서 두 집 모두 맛이 좋아서 즐거운 날이었어요.
---
# Opo in Gijang: good coffee and pastry with a sea view
> A cafe on the Gijang coast where my family and I stopped after eel. We were full, so we shared one pastry with an iced americano and a vanilla latte.
- Canonical: https://jaemyeong.com/en/blog/gijang-opo-20260930/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Gijang, #cafe, #bakery, #coffee
After [grilled eel at Woljeon](/en/posts/gijang-woljeon-hwareo-20260930/), my family and I decided to go to a cafe. We were on our way to a mango shaved ice cafe we often visit. Then Opo came into view on the way, my family member suggested trying it, and we changed course. It was about 2:20 on a Wednesday afternoon.
We parked in front. I was told parking is free. Seeing the many kinds of bread on display inside, I took it for a bakery cafe.

We had just eaten, so we picked only one pastry. I ordered an iced americano, and my family member a vanilla latte.

Both the pastry and the coffee were good.
Most of the seats face the sea, and there are many terrace seats as well. We sat at a corner table inside. Perhaps because the weather was good, the sea felt calm. My family member liked the view too. I heard that their acquaintances also come to this cafe often.
Our regular mango shaved ice cafe is close by, but this one was good too.
---
# 기장 오프오, 바다 보이는 카페에서 빵과 커피가 맛있었어요
> 월전에서 장어를 먹고 수요일 오후에 가족과 들른 기장 해안의 카페예요. 배가 불러서 빵은 하나만 고르고 아이스 아메리카노와 바닐라 라떼를 마셨어요. 빵도 커피도 맛있었어요.
- Canonical: https://jaemyeong.com/ko/blog/gijang-opo-20260930/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Gijang, #cafe, #bakery, #coffee
[월전에서 장어구이를 먹고](/posts/gijang-woljeon-hwareo-20260930/) 나서 가족과 카페에 가기로 했어요. 원래는 자주 가는 망고빙수 카페로 가던 길이었어요. 가는 도중에 오프오가 보였고, 가족이 여기로 가 보자고 해서 방향을 바꿨어요. 수요일 오후 2시 20분쯤이었어요.
가게 앞에 차를 댔어요. 주차는 무료라고 해요. 안에 빵이 여러 가지 진열돼 있는 것을 보고 베이커리 카페구나 했어요.

밥을 먹고 온 터라 빵은 하나만 골랐어요. 저는 아이스 아메리카노를, 가족은 바닐라 라떼를 시켰어요.

빵도 커피도 맛있었어요.
자리는 대부분 바다가 보이게 놓여 있고 테라스 자리도 많아요. 저희는 안쪽 구석 자리에 앉았어요. 날씨가 좋아서 그랬는지 바다가 고요하게 느껴졌어요. 가족도 바다 풍경을 좋아했어요. 가족의 지인들도 이 카페에 자주 온다고 들었어요.
가까이에 단골 망고빙수 카페가 있지만, 여기도 충분히 좋았어요.
---
# Woljeon, Gijang: 1 kg of eel for 30,000 won, fresh and plentiful
> The row of tent stalls at Woljeon in Gijang, where I went with family for lunch. One kilogram of eel cost 30,000 won and we grilled it over charcoal.
- Canonical: https://jaemyeong.com/en/blog/gijang-woljeon-hwareo-20260930/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Gijang, #seafood, #grilled, #family
On Wednesday morning I went to the bank with family, and afterwards, talking about lunch, my family member brought up the eel at Woljeon. I drove, and we got there at about 1 in the afternoon.
Woljeon, in Jukseong-ri, Gijang, is where tent stalls cluster by the live fish market. You can park in front of the market, so we parked and went into a stall close by. I did not feel there was much difference between the stalls. Just pick one with a view of the sea.

We ordered 1 kg of eel, which was 30,000 won. Once you order, the market cleans it on the spot and brings it over. It was fresh enough that it was still moving after being cleaned.

We grilled it over charcoal. My family member did the grilling. It tasted good, and there was more of it than I expected. I think it would be enough even for two grown men. I recommend it if you want to eat eel with a sea view without spending much. Afterwards we stopped at a cafe nearby, [Opo](/en/posts/gijang-opo-20260930/).
The table setting charge is 6,000 won per person. Soft drinks and soju are set out first, and you pay only for what you drink. I was driving, so I did not have soju. The seating is outdoors, so it may not operate in some seasons.
---
# 기장 월전, 3만 원 장어 1kg이 싱싱하고 넉넉했어요
> 가족과 수요일 낮에 간 기장 월전의 포장마차촌이에요. 장어 1kg을 30,000원에 사서 바다가 보이는 자리에서 숯불에 구워 먹었어요. 싱싱했고 생각보다 양이 넉넉했어요.
- Canonical: https://jaemyeong.com/ko/blog/gijang-woljeon-hwareo-20260930/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Gijang, #seafood, #grilled, #family
수요일 오전에 가족과 은행 일을 보고 나서, 밥을 먹자며 가족이 월전의 장어를 이야기했어요. 제가 운전해서 낮 1시쯤에 도착했어요.
기장 죽성리의 월전은 활어판매장 쪽으로 포장마차가 모여 있는 곳이에요. 판매장 앞에 차를 댈 수 있어서, 차를 대고 가까운 포장마차로 들어갔어요. 포장마차끼리는 차이가 크지 않다고 느꼈어요. 바다가 보이는 자리로 고르면 돼요.

장어를 1kg 시켰어요. 30,000원이에요. 주문하면 판매장에서 바로 손질해서 가져다줘요. 손질한 뒤에도 움직일 만큼 싱싱했어요.

숯불에 구워 먹었어요. 굽는 것은 가족이 맡았어요. 맛있었고, 양은 생각한 것보다 넉넉했어요. 어른 남자 둘이 먹어도 모자라지 않을 것 같아요. 바다를 보면서 장어를 큰돈 들이지 않고 먹고 싶은 분께 권해요. 먹고 나서는 근처 카페 [오프오](/posts/gijang-opo-20260930/)에 들렀어요.
상차림비는 한 사람에 6,000원이에요. 음료와 소주는 먼저 가져다 놓고, 마신 만큼만 계산해요. 저는 운전을 해야 해서 소주를 마시지 않았어요. 바깥에 차린 자리라서 철에 따라서는 열지 않을 수도 있어요.
---
# Oneurui Tokyo in Deokcheon: the yakitori was good again
> An izakaya in Deokcheon-dong I have often visited alone. After a big first round we ordered just a chicken skin skewer, a tsukune, gyoza, and some oden.
- Canonical: https://jaemyeong.com/en/blog/deokcheon-oneurui-tokyo-20260929/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #izakaya, #skewers, #bar
After eating first at [Gaekgi](/en/posts/deokcheon-gaekgi-20260929/), we came to Oneurui Tokyo for a second round at about 9 on Tuesday night. It is an izakaya in Deokcheon-dong, and I have come here many times to drink alone. I recommend it to anyone looking for an izakaya in Deokcheon-dong. There is whisky on the shelves.
We had eaten a lot already, so this time we ordered only a little. For yakitori, I picked one chicken skin skewer, and my friend, who likes tsukune, picked one of those.


The yakitori was good, just as it was on my earlier visits.
We ordered gyoza too.

We also ordered a few pieces of oden. The rice cake in it was not the long cylinder kind. I think it used to be, but I may be remembering it wrong.
---
# 덕천 오늘의도쿄, 야키토리는 이번에도 맛있었어요
> 객기에서 많이 먹고 화요일 밤 9시쯤 2차로 간 덕천동의 이자카야예요. 혼자서도 여러 번 온 집이에요. 껍질과 츠쿠네 꼬치, 교자, 오뎅을 조금씩 시켰고 꼬치는 이번에도 맛있었어요.
- Canonical: https://jaemyeong.com/ko/blog/deokcheon-oneurui-tokyo-20260929/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #izakaya, #skewers, #bar
[객기](/posts/deokcheon-gaekgi-20260929/)에서 먼저 먹고, 화요일 밤 9시쯤 두 번째 자리로 온 곳이 오늘의도쿄예요. 덕천동에 있는 이자카야이고, 저 혼자서도 한잔하러 여러 차례 들른 집이에요. 이 동네에서 이자카야에 가고 싶다면 권하는 곳이에요. 선반에는 위스키가 놓여 있어요.
1차에서 배를 채우고 온 터라 여기서는 적게 시켰어요. 야키토리는 제가 껍질을 하나 골랐고, 츠쿠네를 좋아하는 친구가 츠쿠네를 하나 골랐어요.


야키토리는 전에 왔을 때와 마찬가지로 맛있었어요.
교자도 시켰어요.

오뎅도 몇 개 시켰어요. 물떡은 가래떡 모양이 아니었어요. 전에는 가래떡이었던 것 같은데, 제 기억이 확실하지는 않아요.
---
# Gaekgi in Deokcheon: raw beef good enough that we added pasta
> I went back to Deokcheon-dong a day after my last visit. At Gaekgi, a bar I had saved on the map, we had the 49,000 won raw beef spread and added pasta.
- Canonical: https://jaemyeong.com/en/blog/deokcheon-gaekgi-20260929/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #bar, #raw, #beef
[Going to Jinujip the day before](/en/posts/deokcheon-jinujip-20260928/) took me to Deokcheon-dong for the first time in a while, and I noticed several things there I wanted to eat. So a day later, on Tuesday, I met the same friend in Deokcheon-dong again. It was about 7:40 in the evening.
The first stop I chose was Gaekgi. It is a bar I had saved on the map some time ago, and I picked it because I like mungtigi, the raw beef slices. It is not far from Deokcheon Station, and it is closed on Sundays. The second stop is in [my Oneurui Tokyo note](/en/posts/deokcheon-oneurui-tokyo-20260929/).
We ordered the raw beef spread, which is 49,000 won. The dishes that make up the spread come first, and a plate of raw beef follows.


The beef looked to be in good condition and tasted good. I liked it enough that we ordered a pasta as well.

The pasta tasted average.
The Chamisul on our table was one of the bottles printed with MBTI types, and the cap read ENTJ. I am looking for one that says INTJ.
---
# 덕천 객기, 뭉티기 한상이 좋아서 파스타까지 시켰어요
> 전날 오랜만에 간 덕천동이 마음에 들어 하루 만에 다시 갔어요. 지도에 저장해 둔 술집 객기에서 49,000원짜리 뭉티기 한상을 먹었고, 고기가 좋아서 파스타를 더 시켰어요.
- Canonical: https://jaemyeong.com/ko/blog/deokcheon-gaekgi-20260929/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #bar, #raw, #beef
[전날 지누집에 가느라](/posts/deokcheon-jinujip-20260928/) 오랜만에 덕천동에 들렀는데, 먹어 보고 싶은 것이 여럿 눈에 띄었어요. 그래서 하루 뒤인 화요일에 같은 친구와 다시 덕천동에서 만났어요. 저녁 7시 40분쯤이었어요.
첫 자리로 고른 곳은 객기예요. 전부터 지도에 저장해 둔 술집이고, 제가 뭉티기를 좋아해서 골랐어요. 덕천역에서 멀지 않고, 일요일에 쉬어요. 두 번째 자리는 [오늘의도쿄 글](/posts/deokcheon-oneurui-tokyo-20260929/)에 적었어요.
뭉티기 한상을 시켰어요. 49,000원이에요. 한상에 들어 있는 음식들이 먼저 나오고, 그 뒤에 뭉티기 한 판이 나와요.


뭉티기는 고기 상태가 좋아 보였고 맛도 좋았어요. 그게 마음에 들어서 파스타를 하나 더 시켰어요.

파스타의 맛은 보통이었어요.
상에 있던 참이슬은 MBTI가 적혀 나오는 병이었고, 뚜껑에는 ENTJ가 적혀 있었어요. 저는 INTJ가 적힌 것을 찾고 있어요.
---
# Jinujip in Deokcheon: good frozen pork belly, slightly slow service
> I had a craving for thin frozen pork belly and called a friend out to Deokcheon-dong. We grilled four servings, added bibim noodles, and ended with fried rice.
- Canonical: https://jaemyeong.com/en/blog/deokcheon-jinujip-20260928/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #pork, #barbecue
I got a craving for thin frozen pork belly, which I had not eaten in a while. Deokcheon-dong is a neighborhood I rarely go to, so I called a friend out there to spend the evening. We reached Jinujip at about 7:30 on a Monday evening. It is near Deokcheon Station.
There were no free tables when we arrived. They said they would seat us once a table was cleared, and we went in after about 10 minutes.
The grill plate is set first, and then the basic side dishes come out. The quick-frozen pork belly is 6,500 won per 100 g, and we started with four servings.

When I said it was my first time, a server showed us how to grill it. I grilled it the way I was shown. They also said to blot it with a paper towel if it seemed too greasy, but it was fine to me as it was. The meat had no off smell and tasted good.
I added Paldo bibim noodles to eat with the meat. They are 3,000 won.

At the end we ordered one serving of fried rice. It comes already fried, so there is no need to cook it at the table. We drank four bottles of soju.
I was happy with the taste and the price. I saw several things in the neighborhood I wanted to eat, so I came back the next day, to [Gaekgi](/en/posts/deokcheon-gaekgi-20260929/). It was a pity the food took a little while to come out. The restaurant is closed on Sundays.
---
# 덕천 지누집, 냉삼은 맛있었지만 음식이 조금 늦게 나왔어요
> 오랜만에 냉삼이 생각나서 월요일 저녁에 친구를 덕천동으로 불렀어요. 10분쯤 기다려 들어간 지누집에서 급냉삼겹 4인분을 구웠고, 비빔면을 곁들이고 볶음밥으로 마무리했어요.
- Canonical: https://jaemyeong.com/ko/blog/deokcheon-jinujip-20260928/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #pork, #barbecue
한동안 먹지 않은 냉삼이 생각났어요. 덕천동은 평소에 잘 가지 않는 동네라서, 그쪽에서 시간을 보낼 겸 친구를 덕천동으로 불렀어요. 월요일 저녁 7시 반쯤에 지누집에 도착했어요. 덕천역 근처예요.
도착했을 때는 빈 테이블이 없었어요. 자리를 치우면 안내하겠다고 해서 10분쯤 기다렸다가 들어갔어요.
불판이 먼저 놓이고 기본 반찬이 나와요. 급냉삼겹은 100g에 6,500원이고, 처음에 4인분을 시켰어요.

처음 왔다고 하니 직원분이 굽는 방법을 알려 줬어요. 알려 준 대로 구웠어요. 기름이 많다 싶으면 키친타월로 닦아 먹으라고도 했는데, 저는 그냥 먹어도 괜찮았어요. 고기에서 잡내가 나지 않았고 맛있었어요.
고기와 같이 먹으려고 팔도비빔면을 더 시켰어요. 3,000원이에요.

마지막에는 볶음밥을 하나 시켰어요. 다 볶아서 가져다주기 때문에 상에서 따로 볶을 필요가 없어요. 소주는 네 병을 마셨어요.
맛과 값에는 만족했어요. 이 동네에 먹어 보고 싶은 것이 여럿 보여서 다음 날 [객기](/posts/deokcheon-gaekgi-20260929/)에 또 갔어요. 음식이 나오는 데 시간이 조금 걸린 것은 아쉬웠어요. 가게는 일요일에 쉬어요.
---
# Ondaljip in Dongnae: chicken better in perilla than in dumplings
> A grilled chicken place in Dongnae. This time flat dumplings came with the chive chicken, but we both preferred perilla wraps. We added cheese tteokbokki.
- Canonical: https://jaemyeong.com/en/blog/dongnae-ondaljip-20260922/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #chicken, #grilled, #bar
A friend kept asking to go for a drink, so we met in Dongnae at about 7:40 on a Tuesday evening. I had not been to Dongnae for a while. Ondaljip is a grilled chicken place I also visited in [April](/en/posts/dongnae-ondaljip-20260403/) and [August](/en/posts/dongnae-ondaljip-20260808/).
As always, I ordered two servings of the charcoal-grilled chicken with chives. It is 14,000 won per serving. I recommend it if you want spicy grilled chicken as an easy meal.

This time about five flat dumplings came with it. That is new. A sign on the wall says ten cost 2,000 won. A server suggested wrapping the chicken in a dumpling. That was fine, but my friend and I both liked it better wrapped in a perilla leaf.
My friend was hungry, so we added the cheese tteokbokki.

The tteokbokki tasted similar to the sauce on the chicken. I do not know whether they use the same seasoning. A bite with the cheese was fine. I like tteokbokki, while my friend only has it now and then when we drink together.
It is open from 5:30 p.m. to midnight Monday through Thursday, and until 1 a.m. on Fridays and Saturdays.
The Chamisul I saw that night had an ordinary cap. Once again I did not see the INTJ cap I keep looking for.
---
# 동래 온달집, 닭구이는 깻잎에 싸 먹는 게 더 좋았어요
> 술 한잔하자는 친구의 연락에 화요일 저녁에 간 동래의 닭구이집이에요. 부추 숯불 닭구이 2인분에 납작만두가 같이 나왔는데 둘 다 깻잎에 싸 먹는 쪽이 좋았어요. 치즈떡볶이도 더했어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-ondaljip-20260922/
- Published: 2026.10.04
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #chicken, #grilled, #bar
술 한잔하자는 친구의 연락이 거듭 와서, 화요일 저녁 7시 40분쯤에 동래에서 만났어요. 동래는 한동안 뜸했다가 오랜만에 갔어요. 온달집은 [4월](/posts/dongnae-ondaljip-20260403/)과 [8월](/posts/dongnae-ondaljip-20260808/)에도 온 닭구이집이에요.
늘 고르던 대로 부추 숯불 닭구이를 2인분 시켰어요. 1인분에 14,000원이에요. 매운 닭구이를 가볍게 먹고 싶은 분께 권하는 메뉴예요.

이번에는 납작만두가 다섯 장쯤 같이 나왔어요. 전에는 없던 것이에요. 벽에는 10장에 2,000원이라고 적혀 있어요. 직원분이 만두에 닭구이를 싸서 먹어 보라고 알려 줬어요. 그렇게 먹어도 괜찮았지만, 저와 친구는 둘 다 깻잎에 싸 먹는 쪽이 더 좋았어요.
친구가 배가 고프다고 해서 치즈떡볶이를 더 시켰어요.

떡볶이는 닭구이의 소스와 비슷한 맛으로 느껴졌어요. 같은 양념을 쓰는지는 몰라요. 치즈와 같이 한입 먹으면 무난했어요. 저는 떡볶이를 좋아하고, 친구는 저와 술을 마실 때 가끔 같이 먹는 정도예요.
영업은 월요일부터 목요일까지 오후 5시 반부터 자정까지이고, 금요일과 토요일에는 다음 날 새벽 1시까지 해요.
이날 본 참이슬은 뚜껑도 보통 모양이었어요. 찾고 있는 INTJ 뚜껑은 이번에도 보지 못했어요.
---
# Pogunamujip, Geumjeong: fair pork skirt meat, too much smoke
> A pork skirt meat grill in Geumjeong-gu I found on social media. The meat was fair and the charcoal good, but orders came late and the smoke lingered.
- Canonical: https://jaemyeong.com/en/blog/geumjeong-pogunamujip-20260920/
- Published: 2026.10.03
- Category: 식기(食記)
- Tags: #Busan, #Geumjeong, #barbecue, #pork
This was dinner on the day I had [seafood in Yongwon](/en/posts/jinhae-yongwon-hoechojang-20260920/) at lunchtime. A friend and I went to a pork skirt meat restaurant I had come across in a social media post. It was about 7:20 on a Sunday evening, and I drove. So I had a soft drink instead of alcohol.
When we arrived there were about two groups waiting, which made us third. We got in without a long wait.
We ordered two servings of the salt-grilled meat and two of the marinated. Each is 14,000 won for a 120 g serving.


I have had this cut at other restaurants many times. Here it was fair, with nothing that stood out and nothing bad. I cannot call the price cheap or expensive either. The charcoal looked good. The soybean paste stew with rice is 2,000 won, so it is easy to add.
There were inconveniences. Things we ordered were missed or came late. Whether that was because they were busy or because of how the place is run, I can only guess. The smoke did not clear well, and when we left the smell was in my clothes and hair.
Of the other restaurants I found through the same person's posts, none has stood out yet by my own standards. From now on I mean to choose restaurants by my own taste.
It is open daily from 10 a.m. to 10 p.m. and has parking. I do not think I would drive there again myself just for this. If someone else were driving, I might go.
---
# 금정 포구나무집, 갈매기살은 무난했고 연기가 아쉬웠어요
> SNS에서 보고 일요일 저녁에 친구와 간 금정구의 갈매기살집이에요. 소금구이와 양념구이를 2인분씩 먹었어요. 고기는 무난했고 숯은 좋았는데, 주문이 늦게 나왔고 연기가 잘 빠지지 않았어요.
- Canonical: https://jaemyeong.com/ko/blog/geumjeong-pogunamujip-20260920/
- Published: 2026.10.03
- Category: 식기(食記)
- Tags: #Busan, #Geumjeong, #barbecue, #pork
낮에 [용원에서 해산물을 먹은 날](/posts/jinhae-yongwon-hoechojang-20260920/)의 저녁이에요. SNS에 올라온 글을 보고 알게 된 갈매기살집에 친구와 갔어요. 일요일 저녁 7시 20분쯤이었고 제가 운전해서 갔어요. 그래서 술 대신 음료를 마셨어요.
도착했을 때 앞에 두 팀쯤 기다리고 있어서 저희가 세 번째였어요. 오래 기다리지 않고 들어갔어요.
소금구이 2인분과 양념구이 2인분을 시켰어요. 둘 다 1인분이 120g에 14,000원이에요.


갈매기살은 다른 집에서도 여러 번 먹어 봤어요. 이 집 고기는 딱히 뛰어난 점도, 나쁜 점도 없이 무난했어요. 값은 싸다고도 비싸다고도 말하기 어려워요. 숯은 좋아 보였어요. 된장찌개와 밥은 2,000원이라 부담 없이 더할 만해요.
불편한 점도 있었어요. 주문한 것이 빠지거나 늦게 나왔어요. 바빠서 그런 것인지 일하는 방식 때문인지는 저도 짐작만 해요. 연기가 잘 빠지지 않아서, 먹고 나오니 몸과 머리카락에 냄새가 배어 있었어요.
그 SNS 글을 쓴 분의 글을 보고 찾아간 다른 가게들 가운데서도, 제 기준에서 특별히 좋았던 곳은 아직 없었어요. 앞으로는 제 취향대로 식당을 고르려고 해요.
영업은 매일 오전 10시부터 밤 10시까지이고 주차를 할 수 있어요. 이것 하나를 먹으려고 제가 직접 운전해서 다시 갈 것 같지는 않아요. 누가 운전해 주는 자리라면 갈 수도 있어요.
---
# Yongwon Hoechojang, Jinhae: steamed mud crab, hard shell, lots of meat
> Back in Yongwon with a friend for the mud crab I skipped last time. We bought crab, kuruma prawns, and spoon worm and had them prepared.
- Canonical: https://jaemyeong.com/en/blog/jinhae-yongwon-hoechojang-20260920/
- Published: 2026.10.02
- Category: 식기(食記)
- Tags: #Changwon, #Jinhae, #seafood, #market, #crab
[When I came with family on September 9](/en/posts/jinhae-yongwon-hoechojang-20260909/), I saw the mud crabs and did not get to eat them, and that stayed with me. So at about 2:40 on a Sunday afternoon I went back to Yongwon, this time with a friend. It works the same way as before. You buy seafood at Miju Susan and take it to Yongwon Hoechojang to be prepared.
At Miju Susan we bought mud crab, kuruma prawns, and spoon worm. The crab is marked at 40,000 won per kilogram, and they gave us about 1.2 kg for 40,000 won. The prawns are 20,000 won for three, and they added one more. The spoon worm was 10,000 won. They gave us sea squirt for free.
Odori is what the kuruma prawn is called here. My friend said it was a first, and I remember having eaten it before.

We ate the prawns, the spoon worm, and the sea squirt raw. I could not tell a big difference between the kuruma prawn and whiteleg shrimp. It did seem larger and a little sweeter, though. That may just have been my impression. Spoon worm is a seafood I like, and its nutty taste was satisfying. I feel that it has gotten more expensive lately.
We asked for the crab to be steamed.

It differed from blue crab in both taste and texture. The shell is very thick and hard, so it needs some work. There was plenty of meat inside. It is worth trying once. For two grown men, I would plan on 2 kg, one each, or order something else with it. That is only my rough estimate.
I am told you can fish for a bit behind the Yongwon fish market. After eating, we went to Cheonseong Port on Gadeokdo, fished for a short while, and went back to Busan. We had dinner at [Pogunamujip](/en/posts/geumjeong-pogunamujip-20260920/). It was a satisfying day as a weekend outing too.
---
# 진해 용원회초장, 찐 청게는 껍질이 단단하고 살이 많았어요
> 지난번에 못 먹은 청게가 아쉬워서 일요일 낮에 친구와 다시 간 진해 용원이에요. 미주수산에서 청게와 오도리, 게불을 사서 초장집에서 먹었어요. 청게는 껍질이 단단했고 살이 넉넉했어요.
- Canonical: https://jaemyeong.com/ko/blog/jinhae-yongwon-hoechojang-20260920/
- Published: 2026.10.02
- Category: 식기(食記)
- Tags: #Changwon, #Jinhae, #seafood, #market, #crab
[9월 9일에 가족과 왔을 때](/posts/jinhae-yongwon-hoechojang-20260909/) 청게를 보고도 못 먹은 것이 아쉬웠어요. 그래서 일요일 오후 2시 40분쯤, 이번에는 친구와 다시 용원에 갔어요. 방식은 지난번과 같아요. 미주수산에서 해산물을 사서 용원회초장으로 가져가 조리를 부탁해요.
미주수산에서 산 것은 청게와 오도리, 게불이에요. 청게는 1kg에 40,000원이라고 적혀 있는데, 1.2kg쯤 되는 것을 40,000원에 줬어요. 오도리는 세 마리에 20,000원이고 한 마리를 더 얹어 줬어요. 게불은 10,000원이에요. 멍게는 그냥 줬어요.
오도리는 보리새우예요. 친구는 처음 먹어 보는 것이라고 했고, 저는 먹어 본 기억이 있어요.

오도리와 게불, 멍게는 회로 먹었어요. 오도리는 흰다리새우와 크게 다르다고 느끼지는 못했어요. 다만 크기가 더 크고 단맛이 조금 더 진한 것 같았어요. 기분 탓일 수도 있어요. 게불은 제가 좋아하는 해산물이고, 고소해서 만족스러웠어요. 요즘 게불 값이 올랐다고 느껴요.
청게는 쪄 달라고 했어요.

꽃게와는 맛도 식감도 달랐어요. 껍질이 아주 두껍고 단단해서 손질이 필요해요. 살은 넉넉하게 들어 있었어요. 한 번은 먹어 볼 만해요. 어른 남자 둘이라면 한 사람에 1kg씩 2kg을 잡거나, 다른 것을 더 시키는 편이 좋겠어요. 이것은 제 어림이에요.
용원어시장 뒤쪽에서 잠깐 낚시를 할 수도 있다고 해요. 저희는 먹고 나서 가덕도 천성항으로 가서 잠깐 낚시를 하고 부산으로 돌아왔어요. 저녁은 [포구나무집](/posts/geumjeong-pogunamujip-20260920/)에서 먹었어요. 주말 나들이로도 만족스러운 하루였어요.
---
# Rentan Suyeong: one springy grilled cuttlefish for a second stop
> A second stop after barbecue on a Saturday night. I was full, so I asked to order just one dish, the signature cuttlefish, with one bottle of soju.
- Canonical: https://jaemyeong.com/en/blog/suyeong-rentan-20260919/
- Published: 2026.10.01
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #grill, #bar
After a light round of barbecue at [Baeksu Nongwon](/en/posts/suyeong-baeksu-nongwon-20260919/), I went to Rentan for a second stop at about 8 on a Saturday evening. It is a bar that serves food grilled over coal briquettes, and I have separate notes from visits in [March](/en/posts/suyeong-rentan-20260324/) and [August](/en/posts/suyeong-rentan-20260827/).
I was already full. I was told the first order has to be two dishes, so I asked whether one would be all right, and they said yes.
So I ordered only the signature cuttlefish, the dish the place is known for. It is open Monday through Saturday from 5:30 p.m. to midnight, with last orders at 10:30 p.m. It is closed on Sundays.

I liked the springy bite of the grilled cuttlefish. It is the right kind of snack to have lightly after a meal.
That night I stopped at one bottle of soju and left.
---
# 렌탄 수영본점, 2차로 시킨 갑오징어가 탱탱했어요
> 백수농원에서 먹고 토요일 저녁 8시쯤 2차로 들른 렌탄이에요. 배가 불러서 메뉴를 하나만 시켜도 되는지 여쭤보고 시그니처 갑오징어만 시켰어요. 소주 한 병을 마시고 나왔어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-rentan-20260919/
- Published: 2026.10.01
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #grill, #bar
[백수농원](/posts/suyeong-baeksu-nongwon-20260919/)에서 고기를 가볍게 먹고, 토요일 저녁 8시쯤 두 번째 자리로 렌탄에 갔어요. 연탄으로 구운 안주를 내는 술집이고, [3월](/posts/suyeong-rentan-20260324/)과 [8월](/posts/suyeong-rentan-20260827/)에 온 날도 따로 적어 뒀어요.
이미 배가 부른 상태였어요. 첫 주문은 메뉴를 두 가지 시켜야 한다고 안내받았는데, 하나만 시켜도 되는지 여쭤봤더니 그렇게 하라고 해 줬어요.
그래서 이 집의 대표 메뉴인 시그니처 갑오징어 하나만 시켰어요. 영업은 월요일부터 토요일까지 오후 5시 반부터 자정까지이고, 주문은 밤 10시 반에 마감해요. 일요일에 쉬어요.

구운 갑오징어는 씹는 맛이 탱탱해서 좋았어요. 밥을 먹고 나서 가볍게 곁들이기에 알맞은 안주예요.
이날은 소주 한 병으로 끝내고 나왔어요.
---
# Skewers at Sutnom Kkochigui in Yeonsan-dong: tasty and good value
> A skewer izakaya for a second round after makchang. I was full, so we ordered only the ten-skewer set. It tasted good and felt worth the price.
- Canonical: https://jaemyeong.com/en/blog/yeonsan-sutnom-kkochigui-20260429/
- Published: 2026.09.30
- Category: 식기(食記)
- Tags: #Busan, #Yeonsan, #skewers, #izakaya
After makchang at [Yeonmakchang Yeonsan](/en/posts/yeonsan-yeonmakchang-20260429/), I came here for a second round at about 9 on a Wednesday night. I was already full, so I picked it to eat lightly on skewers. It is an izakaya that serves skewers, and I am told you see it straight ahead at the end of the alley on the Wellness Hospital side.
We ordered just the assorted skewers, 10 kinds.

The ingredients vary. One skewer has tomato in it, and one is wrapped in chives. The seasoned skewer comes separately on a small dish with mayonnaise.
It tasted good, and it felt worth the price. Around the time I first wrote this visit up on my Naver blog, the menu posted on the map listed the medium assorted skewers at 35,000 won and the large, with 15 kinds, at 45,000 won.
Next time I plan to start the evening here. I want to order other dishes then, such as okonomiyaki or fish cake soup.
---
# 연산동 숯놈꼬치구이의 모듬꼬치, 2차로 먹기에 값도 맛도 좋았어요
> 연막창에서 막창을 먹고 2차로 간 꼬치 이자카야예요. 배가 불러서 모듬꼬치 10종만 시켰는데 맛도 좋고 값에 비해서도 만족스러웠어요. 다음에는 1차로 가 보려고 해요.
- Canonical: https://jaemyeong.com/ko/blog/yeonsan-sutnom-kkochigui-20260429/
- Published: 2026.09.30
- Category: 식기(食記)
- Tags: #Busan, #Yeonsan, #skewers, #izakaya
[연막창 연산점](/posts/yeonsan-yeonmakchang-20260429/)에서 막창을 먹고 나서 수요일 밤 9시쯤에 2차로 간 곳이에요. 배가 찬 뒤라서 가볍게 꼬치만 먹을 곳을 찾았어요. 꼬치구이를 하는 이자카야예요. 웰니스 병원 옆 골목을 끝까지 들어가면 바로 앞에 보인다고 들었어요.
모듬꼬치 10종 하나만 시켰어요.

열 가지가 서로 다른 재료예요. 그 가운데에는 토마토를 넣은 것과 부추를 두른 것이 있었어요. 양념을 바른 꼬치만 마요네즈를 곁들여 작은 접시에 담아 줘요.
맛에도 만족했고, 낸 돈을 생각해도 만족스러웠어요. 참고로 네이버 블로그에 이 방문을 적을 무렵 지도에 올라온 메뉴에는 모듬꼬치 중이 35,000원, 열다섯 가지가 나오는 대가 45,000원으로 적혀 있었어요.
다음에는 배가 부르기 전에, 1차로 여기에 올 생각이에요. 그날은 꼬치 말고 오코노미야끼와 오뎅탕도 먹어 보려고 해요.
---
# Baeksu Nongwon, Suyeong: I like the sweet potato after the meat
> A barbecue place in Suyeong I keep going back to. Pork belly first, marinated ribs last, and the sweet potato from the coals as dessert.
- Canonical: https://jaemyeong.com/en/blog/suyeong-baeksu-nongwon-20260919/
- Published: 2026.09.30
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #barbecue, #pork
At about 6:40 on a Saturday evening I went to Baeksu Nongwon with a younger companion. I have been many times, so there are already notes from [April](/en/posts/suyeong-baeksu-nongwon-20260416/) and [August](/en/posts/suyeong-baeksu-nongwon-20260815/). That night we ate lightly here and went on to [Rentan](/en/posts/suyeong-rentan-20260919/) for a second round.
When I come here I start with pork belly. I did the same this time.

For the last round of meat I chose the marinated ribs.

I recommend it as a barbecue place in Suyeong that gives good value. On weekdays it opens at 5 p.m.; on Saturdays, Sundays, and holidays it opens at noon. It closes at 1 a.m., with last orders at midnight. There is parking.
They put sweet potatoes in the brazier by default. You take them out after the meat and eat them like dessert, and I like that.
---
# 수영 백수농원, 고기 뒤에 꺼내 먹는 고구마가 좋았어요
> 여러 번 간 수영의 고깃집에 토요일 저녁에 동생과 갔어요. 늘 하던 대로 삼겹살을 먼저 굽고 양념갈비로 마무리했어요. 화로에 넣어 주는 고구마를 마지막에 꺼내 먹는 재미가 있어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-baeksu-nongwon-20260919/
- Published: 2026.09.30
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #barbecue, #pork
토요일 저녁 6시 40분쯤, 동생과 백수농원에 갔어요. 여러 번 간 집이라 [4월의 기록](/posts/suyeong-baeksu-nongwon-20260416/)과 [8월의 기록](/posts/suyeong-baeksu-nongwon-20260815/)이 이미 있어요. 이날은 여기서 가볍게 먹고 2차로 [렌탄](/posts/suyeong-rentan-20260919/)에 갔어요.
이 집에 오면 삼겹살부터 시켜요. 이날도 그랬어요.

고기를 먹는 마지막에는 양념갈비를 골랐어요.

수영에서 값에 비해 만족스러운 고깃집으로 권하는 곳이에요. 영업은 평일에 오후 5시부터, 토요일과 일요일, 공휴일에는 낮 12시부터 하고 다음 날 새벽 1시에 닫아요. 주문은 자정에 마감해요. 주차를 할 수 있어요.
화로 안에는 고구마를 기본으로 넣어 줘요. 고기를 다 먹고 나서 후식처럼 꺼내 먹는데, 이게 좋아요.
---
# At Oedo Nursery, I ordered two drinks and liked the terrace view
> I could not get a ferry ticket to Oedo, so I put my name on the waiting list and walked into this cafe. I ordered a piña colada and a hibiscus omija drink.
- Canonical: https://jaemyeong.com/en/blog/geoje-oedo-nursery-20260502/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Geoje, #cafe, #terrace
That day I meant to take the ferry to Oedo. But I had come without a plan or a reservation and could not get a ticket. I put my name on the ferry waiting list and walked around Gujora, and at about 2:30 on a Saturday afternoon I went into Oedo Nursery. The same day I also went to [Jisimdo Hoetjip](/en/posts/geoje-jisimdo-hoetjip-20260502/) and [Jjaekjjaek Coffee](/en/posts/geoje-jjaekjjaek-coffee-20260502/).
It is a cafe I often stop at when I am in Gujora. I have come on every trip to Geoje, so I think I have been four or five times.
The grounds are landscaped in a way that brings a botanical garden to mind. The view from the terrace is good, and photos come out well.
I ordered a piña colada and a hibiscus omija drink. Each is 8,500 won.

If you drive, I was told to enter the parking lot address (구조라로6길 37) in the navigation, not the address of the cafe.
I usually have a dessert here too. It is shaped like a beach pebble, pretty to look at, and it tasted fine. That day I did not order dessert, and I do not remember why.
---
# 거제 외도널서리에서 음료 두 잔을 시켰고, 테라스 전망이 좋았어요
> 외도에 가는 유람선 표를 구하지 못해 대기를 걸어 두고 구조라를 걷다가 들어간 카페예요. 피나콜라다와 히비스커스 오미자를 시켰어요. 식물원 같은 조경과 테라스 전망이 좋아요.
- Canonical: https://jaemyeong.com/ko/blog/geoje-oedo-nursery-20260502/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Geoje, #cafe, #terrace
이날은 유람선을 타고 외도에 가려고 했어요. 그런데 계획도 예약도 없이 간 터라 표를 구하지 못했어요. 유람선 대기를 걸어 두고 구조라 주변을 걷다가, 토요일 오후 2시 반쯤에 외도널서리에 들어갔어요. 같은 날 [지심도횟집](/posts/geoje-jisimdo-hoetjip-20260502/)과 [짹짹커피](/posts/geoje-jjaekjjaek-coffee-20260502/)에도 갔어요.
구조라에 오면 자주 들르는 카페예요. 거제에 올 때마다 왔으니 네댓 번쯤 온 것으로 기억해요.
식물원이 떠오르게 조경을 해 놓은 곳이에요. 테라스에서 보는 전망이 좋고, 사진도 잘 나와요.
피나콜라다와 히비스커스 오미자를 시켰어요. 둘 다 8,500원이에요.

차를 가져간다면 내비게이션에는 가게 주소가 아니라 주차장 주소(구조라로6길 37)를 넣으라고 안내받았어요.
평소에는 디저트도 먹어요. 몽돌 모양으로 만든 디저트인데 모양이 예쁘고 맛은 무난했어요. 이날은 디저트를 시키지 않았는데, 왜 그랬는지는 기억나지 않아요.
---
# Jjaekjjaek Coffee in Gohyeon, Geoje: a warehouse-like room to rest in
> After lunch in Geoje we went to a cafe in Gohyeon that my friend found. The 4,500 won americano was fine, and I liked the room. I spilled some coffee.
- Canonical: https://jaemyeong.com/en/blog/geoje-jjaekjjaek-coffee-20260502/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Geoje, #cafe, #coffee
After lunch in Geoje we thought about where to go next and decided on a cafe. My friend, who was traveling from another city, found the place. It was about 12:30 on a Saturday. There is also a [Jisimdo Hoetjip](/en/posts/geoje-jisimdo-hoetjip-20260502/) note from the same day.
Jjaekjjaek Coffee is in Gohyeon. I am told it is about 5 minutes by car from Gohyeon Bus Terminal, or 15 to 20 minutes on foot.

The outer wall has the word Nonghyup on it. It looked like a Nonghyup warehouse turned into a cafe. I liked how the inside was decorated. I would recommend it to anyone who wants to spend time at ease.
I ordered an americano. It is 4,500 won. The taste was fine.
While drinking I spilled a little coffee. Cleaning it up took some effort.
---
# 거제 고현 짹짹커피, 창고 같은 공간에서 쉬어 가기 좋아요
> 거제에서 점심을 먹고 친구가 찾아낸 고현의 카페에 갔어요. 4,500원짜리 아메리카노는 무난했고, 농협 창고를 바꾼 듯한 실내가 마음에 들었어요. 커피를 흘려서 치우느라 애를 먹었어요.
- Canonical: https://jaemyeong.com/ko/blog/geoje-jjaekjjaek-coffee-20260502/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Geoje, #cafe, #coffee
거제에서 점심을 먹고 나서 다음에 어디로 갈지 생각하다가 카페에 가기로 했어요. 가게는 다른 도시에서 여행 온 친구가 찾았어요. 토요일 낮 12시 반쯤이었어요. 같은 날의 [지심도횟집](/posts/geoje-jisimdo-hoetjip-20260502/) 기록도 있어요.
짹짹커피는 고현에 있어요. 고현버스터미널에서 차로 5분쯤, 걸어서는 15분에서 20분쯤 걸린다고 해요.

외벽에 농협이라는 글자가 있어요. 농협 창고를 카페로 바꾼 곳처럼 보였어요. 실내를 꾸며 놓은 것이 마음에 들었어요. 여유롭게 시간을 보내고 싶은 분께 권할 만해요.
아메리카노를 시켰어요. 4,500원이에요. 맛은 무난했어요.
마시다가 커피를 조금 흘렸어요. 그것을 치우느라 애를 먹었어요.
---
# The special set at Jisimdo Hoetjip in Geoje: many dishes, but lacking
> A friend and I went to Geoje on a whim and found a raw fish place that opens at 11. The set has raw fish, sushi, grilled fish, and fried food. It fell short.
- Canonical: https://jaemyeong.com/en/blog/geoje-jisimdo-hoetjip-20260502/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Geoje, #seafood, #sashimi, #lunch
A friend from another city and I were talking about where to go and went to Geoje on a whim. It was about 11 on a Saturday morning. We were hungry and searched for a raw fish restaurant, but not many were open. I think it was because of the early hour. We found one that opens at 11 and went in. I wrote about the cafe we visited the same day in the [Jjaekjjaek Coffee](/en/posts/geoje-jjaekjjaek-coffee-20260502/) note.
It is next to the Jangseungpo community center, across from the Jisimdo ticket office, on the second floor.
We ordered the Jisimdo special set. It comes with raw fish, sushi, grilled fish, and fried food.

There are many dishes. But for me the selection and the amount fell short. That is how it looks next to a neighborhood raw fish place in Busan.
Travelers may find it fine. The friend I went with seemed fairly satisfied.
---
# 거제 지심도횟집의 스페셜 정식, 가짓수에 비해 아쉬웠어요
> 친구와 즉흥으로 거제에 가서 오전 11시에 문을 여는 횟집을 찾아 들어갔어요. 지심도 스페셜 정식에는 회와 초밥, 생선구이, 튀김이 나오는데 구성과 양이 아쉬웠어요.
- Canonical: https://jaemyeong.com/ko/blog/geoje-jisimdo-hoetjip-20260502/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Geoje, #seafood, #sashimi, #lunch
다른 도시에서 온 친구와 어디를 갈지 이야기하다가 즉흥으로 거제도에 갔어요. 토요일 오전 11시쯤이었어요. 배가 고파서 횟집을 검색했는데 문을 연 곳이 많지 않았어요. 시간이 일러서 그랬던 것 같아요. 11시에 문을 여는 집을 찾아서 들어갔어요. 같은 날 들른 카페는 [짹짹커피](/posts/geoje-jjaekjjaek-coffee-20260502/) 글에 적었어요.
장승포주민센터 옆이고 지심도 매표소 건너편이에요. 2층에 있어요.
지심도 스페셜 정식을 시켰어요. 회와 초밥, 생선구이, 튀김이 함께 나와요.

가짓수는 많아요. 그런데 저는 구성과 양이 아쉬웠어요. 부산의 동네 횟집과 견주어 보면 그래요.
여행 온 분들은 무난하다고 느낄 수도 있어요. 같이 간 친구는 어느 정도 만족한 듯했어요.
---
# Triploid oysters at Haerujil Gwangalli: big and tasty
> A seafood place in Gwangalli I had wanted to try since around its opening. My first triploid oysters were big and tasty. The seafood was fresh.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-haerujil-20260501/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #seafood, #oyster
I had wanted to try this place since around the time it opened. A friend and I went at about 7:40 on a Friday evening. I am told you head toward Gwangalli Beach from Geumnyeonsan Station. At about 9 that night we were at [Joseph](/en/posts/gwangalli-joseph-20260501/).
There were many guests, and I worried whether we could get in. Luckily two seats were left in a corner, and we took them. The menu says you must order at least 25,000 won, not counting alcohol.
I had never had triploid oysters, so we ordered the two-piece plate. It was 15,000 won. Around the time I first wrote this visit up on my Naver blog, the menu posted on the map listed it at 16,000 won.

The oysters were big and tasty. Each comes with one slice of lemon on top. All the seafood was fresh and tasty. I plan to drop by now and then when I am in Gwangalli and there is no line.
We also ordered the sweet shrimp and ankimo set. It is 25,000 won.

Sweet shrimp, seaweed, and wasabi come on one plate. I think my friend felt like having ankimo that day.
---
# 해루질 광안리점의 삼배체굴, 크고 맛있었어요
> 문을 열 무렵부터 가 보고 싶었던 광안리의 해산물집에 친구와 갔어요. 처음 먹어 본 삼배체굴은 크고 맛있었고, 단새우 안키모 삼합도 시켰어요. 해산물이 신선했어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-haerujil-20260501/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #seafood, #oyster
이 집이 문을 열 무렵부터 가 보고 싶었어요. 금요일 저녁 7시 40분쯤에 친구와 갔어요. 금련산역에서 광안리 해변 쪽으로 가면 된다고 해요. 같은 날 밤 9시쯤에는 [조셉](/posts/gwangalli-joseph-20260501/)에 있었어요.
손님이 많아서 들어갈 수 있을지 걱정했어요. 다행히 구석에 두 자리가 남아 있어서 앉았어요. 메뉴판에는 술을 빼고 25,000원어치 이상을 주문해야 한다고 적혀 있어요.
삼배체굴을 먹어 본 적이 없어서 2개짜리를 시켰어요. 15,000원이었어요. 네이버 블로그에 이 방문을 적을 무렵에는 지도에 올라온 메뉴에 16,000원으로 적혀 있었어요.

굴이 크고 맛있었어요. 굴마다 레몬이 한 조각씩 올라가 있어요. 해산물은 모두 신선하고 맛있었어요. 광안리에 갔을 때 줄이 없으면 가끔 들르려고 해요.
단새우 안키모 삼합도 시켰어요. 25,000원이에요.

단새우와 김, 와사비가 한 접시에 나와요. 친구가 이날 안키모를 먹고 싶었던 것 같아요.
---
# Crab-marinade sashimi at Sarangppang in Namcheon-dong: worth one visit
> A seafood place in Namcheon-dong I found on social media and booked ahead. You wrap raw fish with soy-marinated crab. The taste was fine, and it was crowded.
- Canonical: https://jaemyeong.com/en/blog/namcheon-sarangppang-20260501/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Namcheon, #seafood, #sashimi
I found this place on social media. A friend came to Busan from another city, so I booked ahead and we went together at about 6 on a Friday evening. It serves seafood dishes in Namcheon-dong. It is on the second floor above Gimbap Maeul, on the way from Namcheon Station toward Haebyeon Market. [Haerujil Gwangalli](/en/posts/gwangalli-haerujil-20260501/) is another place I wrote up from this day.
The place was crowded, and many people were waiting. Without a reservation it would have been hard to get in. The reservations seemed to be full as well.
We had the special female crab marinade sashimi. You wrap the raw fish together with the marinated crab and eat it.

The taste was fine. I liked how the room was decorated. I also liked that the menu is built on seafood I do not often see elsewhere, such as crab, shrimp, and razor clams.
It is worth one visit. Next time I want to try the razor clams.
---
# 남천동 사랑빵의 암꽃게장 사시미, 한 번 가 볼 만해요
> SNS에서 보고 예약해서 친구와 간 남천동의 해산물집이에요. 회를 꽃게장과 함께 싸 먹는 특미 암꽃게장 사시미를 먹었어요. 맛은 무난했고, 가게가 붐벼서 예약하기를 잘했어요.
- Canonical: https://jaemyeong.com/ko/blog/namcheon-sarangppang-20260501/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Namcheon, #seafood, #sashimi
SNS에서 보고 알게 된 가게예요. 다른 도시에서 친구가 부산에 와서, 금요일 저녁 6시쯤에 미리 예약하고 같이 갔어요. 남천동에서 해산물 요리를 내는 집이에요. 남천역에서 해변시장 쪽으로 가면 나오는 김밥마을 위층, 2층에 있어요. 이날의 다른 기록으로는 [해루질 광안리점](/posts/gwangalli-haerujil-20260501/)이 있어요.
가게는 붐볐고 기다리는 사람도 많았어요. 예약하지 않고 갔다면 들어가기 어려웠을 거예요. 예약도 꽉 찬 듯했어요.
특미 암꽃게장 사시미를 먹었어요. 회를 꽃게장과 함께 싸서 먹는 음식이에요.

맛은 무난했어요. 실내를 꾸며 놓은 것은 마음에 들었어요. 꽃게와 새우, 맛조개처럼 다른 곳에서 잘 보지 못한 해산물로 메뉴를 짠 점도 좋았어요.
한 번쯤 가 볼 만한 집이에요. 다음에 가면 맛조개를 먹어 보고 싶어요.
---
# Animal Forest, an animal cafe in Gwangalli: even a pig and a chicken
> An animal cafe in Gwangalli I visited with a friend over the Children's Day holiday. You buy a ticket, not food. We saw cats, dogs, a pig, and a chicken.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-animal-forest-20260501/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #animal cafe, #cafe
At about 4:30 on a Friday afternoon, I went to Animal Forest with a friend from another city. It was during the Children's Day holiday. It is an animal cafe on the 6th floor of the corner building on the sea side of Millak Subyeon-ro in Gwangalli. The place we went to that evening is [Sarangppang](/en/posts/namcheon-sarangppang-20260501/).
It is less a place to order food than a place you enter with a ticket. Around the time I first wrote this visit up on my Naver blog, the map listed it at 14,000 won for 90 minutes. I am told children under 18 months get in free after their documents are checked. A snack for the animals is 2,000 won per kind, and they also sell snack sets by course.
The cats and dogs were resting at ease.


We could see a pig up close too. You can also try holding a white chicken on your arm. There are not only unfamiliar animals but familiar pets as well, and there are several animals you can touch.
I recommend it to anyone who wants to try something different. You can park in the basement, and cars enter from behind the building.
There were many children that day. I think it was because of the holiday. I wonder whether there are fewer people on other days.
---
# 광안리 동물카페 애니멀숲, 돼지와 닭까지 가까이에서 봤어요
> 다른 도시에서 온 친구와 어린이날 연휴에 간 광안리의 동물카페예요. 음식보다는 입장권을 사서 동물을 만나는 곳이에요. 고양이와 강아지뿐 아니라 돼지와 닭도 가까이에서 봤어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-animal-forest-20260501/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #animal cafe, #cafe
금요일 오후 4시 반쯤, 다른 도시에서 온 친구와 애니멀숲에 갔어요. 어린이날 연휴 중이었어요. 광안리 민락수변로의 바다 쪽 모서리 건물 6층에 있는 동물카페예요. 같은 날 저녁에 간 곳은 [사랑빵](/posts/namcheon-sarangppang-20260501/)이에요.
음식을 시켜 먹는 곳이라기보다 입장권을 사서 들어가는 곳이에요. 네이버 블로그에 이 방문을 적을 무렵 지도에는 90분에 14,000원으로 적혀 있었어요. 18개월 미만인 아이는 증빙 서류를 확인한 뒤 무료로 들어간다고 해요. 동물에게 주는 간식은 한 가지에 2,000원이고, 코스별 간식 세트도 팔아요.
고양이와 강아지는 느긋하게 쉬고 있었어요.


돼지도 가까이에서 볼 수 있었어요. 흰 닭을 팔에 올려 보는 체험도 할 수 있어요. 낯선 동물만 있는 것이 아니라 익숙한 반려동물도 있고, 직접 만져 볼 수 있는 동물이 여럿이에요.
평소와 다른 체험을 해 보고 싶은 분께 권해요. 주차는 지하에 할 수 있고, 차는 건물 뒤쪽으로 들어가요.
이날은 아이들이 많았어요. 연휴라서 그랬던 것 같아요. 연휴가 아닌 날에는 사람이 적을지 궁금해요.
---
# Yeonhwa-ri Haenyeochon in Gijang: a great 30,000 won seafood platter
> On a family member's day off we ate by the window at Yeonhwa-ri Haenyeochon. The small seafood platter was a great deal at 30,000 won. No cards.
- Canonical: https://jaemyeong.com/en/blog/gijang-haenyeochon-20260427/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Gijang, #seafood, #abalone porridge
A family member had a day off, so we went to Gijang to eat together. It was about 3 on a Monday afternoon. My family member and I both like seafood and have similar tastes.
Seafood stalls are gathered along Yeonhwa 1-gil in Gijang-eup. The area is called Yeonhwa-ri Haenyeochon. I am told there are several stalls run by women divers and that they change places with the season. We went to Manseong Ajumma, the one my family knows well.
As I see it, the prices and what you get are about the same at each stall. So you can simply pick one with a free window seat that looks out on the sea. We sat at a window with a sea view too.

We ordered the small assorted seafood for 30,000 won. Sea squirt, abalone, shrimp, and other seafood come on one tray. The menu lists the largest size at 70,000 won.

I was very happy to get this much for this price. Compared with other haenyeo villages in Busan, I think it is cheaper and the selection is better. That is my personal comparison.
The abalone porridge is served in a pot. It is 12,000 won per person, and the minimum order is two servings. We ordered two. It arrives in the pot and each person ladles out a portion.

The menu says cards cannot be used. As far as I know, all the stalls close in summer and reopen in autumn. It is a long way to go, but I recommend it to anyone who wants to eat seafood while looking at the sea.
After the meal we moved to [Cafe 824](/en/posts/gijang-cafe-824-20260427/) and had mango shaved ice.
---
# 기장 연화리 해녀촌, 3만 원짜리 해물모듬 소의 구성이 훌륭했어요
> 가족이 쉬는 날에 연화리 해녀촌의 만성아줌마에서 창가에 앉아 먹었어요. 해물모듬 소는 30,000원에 구성이 아주 좋았고, 솥단지 전복죽을 2인분 시켰어요. 카드는 안 돼요.
- Canonical: https://jaemyeong.com/ko/blog/gijang-haenyeochon-20260427/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Gijang, #seafood, #abalone porridge
가족이 쉬는 날이어서 같이 밥을 먹으러 기장에 갔어요. 월요일 오후 3시쯤이었어요. 저와 가족은 둘 다 해산물을 좋아하고 입맛이 비슷해요.
기장읍 연화1길에는 해산물 포장마차가 모여 있어요. «연화리 해녀촌»이라고 부르는 곳이에요. 해녀집이 여러 곳 있고 계절에 따라 자리를 옮긴다고 해요. 저희는 가족이 잘 아는 만성아줌마로 갔어요.
제가 보기에 집마다 가격과 구성은 거의 같아요. 그래서 바다가 보이는 창가 자리가 비어 있는 집을 고르면 돼요. 저희도 바다가 보이는 창가에 앉았어요.

해물모듬은 소 크기로 시켰고 30,000원이에요. 멍게와 전복, 새우 같은 해산물이 한 쟁반에 나와요. 메뉴판에는 가장 큰 특이 70,000원으로 적혀 있어요.

이 값에 이 구성이라는 점이 아주 만족스러웠어요. 부산의 다른 해녀촌과 견주어도 싸고 구성이 낫다고 생각해요. 제 개인적인 비교예요.
전복죽은 솥단지 전복죽으로, 1인에 12,000원이고 2인분부터 주문할 수 있어요. 저희는 2인분을 시켰어요. 솥째 나와서 각자 덜어 먹어요.

메뉴판에는 카드를 쓸 수 없다고 적혀 있어요. 여름에는 가게들이 모두 쉬고 가을에 다시 연다고 알고 있어요. 멀리 가야 하는 곳이지만 바다를 보면서 해산물을 먹고 싶은 분께 권해요.
이날은 식사를 마치고 [카페 824](/posts/gijang-cafe-824-20260427/)로 옮겨 망고 빙수를 먹었어요.
---
# Yeonmakchang in Yeonsan-dong: pre-grilled makchang, clean taste
> A place in Yeonsan-dong where a friend and I went for makchang after work. It came pre-grilled with hardly any smell. We added an order of spicy chicken feet.
- Canonical: https://jaemyeong.com/en/blog/yeonsan-yeonmakchang-20260429/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Yeonsan, #makchang, #barbecue
I wanted makchang. At about 7 on a Wednesday evening I met a friend after work and we went to Yeonmakchang Yeonsan. I do not come to Yeonsan-dong often.
The place was nearly full. A little later and we would have had to wait.
We ordered makchang first. It comes pre-grilled, so you grill it on the plate right away and eat it.

The taste was clean. There was hardly any smell, so it was easy to eat. The staff were kind too.
While eating the makchang we added one order of flame-grilled spicy chicken feet.

After eating here we moved on to [Sutnom Kkochigui](/en/posts/yeonsan-sutnom-kkochigui-20260429/).
---
# 연산동 연막창, 초벌 막창이 냄새가 거의 없고 정갈했어요
> 친구가 퇴근한 뒤에 막창을 먹으러 간 연산동의 가게예요. 한 번 익혀서 나온 막창은 냄새가 거의 없어서 먹기 편했고, 먹다가 직화불닭발을 하나 더 시켰어요.
- Canonical: https://jaemyeong.com/ko/blog/yeonsan-yeonmakchang-20260429/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Yeonsan, #makchang, #barbecue
막창이 먹고 싶었어요. 수요일 저녁 7시쯤, 친구가 퇴근한 뒤에 만나서 연막창 연산점에 갔어요. 저는 연산동에 자주 오는 편이 아니에요.
가게는 손님으로 거의 차 있었어요. 조금만 늦었으면 기다릴 뻔했어요.
막창을 먼저 시켰어요. 한 번 익힌 상태로 나와서, 불판에 올려 바로 구워 먹으면 돼요.

맛이 정갈했어요. 냄새가 거의 나지 않아서 먹기 편했어요. 직원분들도 친절했어요.
막창을 먹다가 직화불닭발을 하나 더 시켰어요.

이날은 여기에서 먹고 [숯놈꼬치구이](/posts/yeonsan-sutnom-kkochigui-20260429/)로 자리를 옮겼어요.
---
# Singsing Domae Hoetjip, Dongnae: good mullet and gizzard shad
> A friend kept asking for gizzard shad, so we went on a Thursday evening. We ordered it as a set with mixed sashimi; the mullet and the shad were good.
- Canonical: https://jaemyeong.com/en/blog/dongnae-singsing-domae-hoetjip-20260917/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #sashimi, #seafood
A friend had messaged me several times about wanting gizzard shad. So we met at about 6 on a Thursday evening at Singsing Domae Hoetjip, right in front of Dongnae Station. It is the place I pick on days when I want sashimi without spending much. [In July I had rock bream here](/en/posts/dongnae-singsing-domae-hoetjip-20260711/).
I thought gizzard shad alone would get tiring fast, so I chose the set that comes with mixed sashimi. It is open from 4 p.m. to midnight Sunday through Thursday, and until 1 a.m. on Fridays and Saturdays.
Grilled fish and a kimchi pancake came out first. I wondered if the grilled fish was a small flounder, but I am not sure.


I believe the fish were olive flounder, mullet, starry flounder, and gizzard shad. The mullet and the shad were good. I did not care for the chew of the starry flounder. Even so, the mullet and the shad made up for it.
---
# 동래 싱싱도매횟집, 모듬회에서 밀치와 전어가 맛있었어요
> 전어를 먹고 싶다는 친구의 연락이 이어져서 목요일 저녁에 간 동래역 앞 횟집이에요. 전어만 먹으면 물릴 것 같아 모듬회 세트로 시켰고, 밀치와 전어가 맛있었어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-singsing-domae-hoetjip-20260917/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #sashimi, #seafood
전어를 먹고 싶다는 친구의 연락이 여러 번 왔어요. 그래서 목요일 저녁 6시쯤에 동래역 바로 앞의 싱싱도매횟집에서 만났어요. 큰돈 들이지 않고 회를 먹고 싶은 날에 제가 고르는 집이에요. [7월에는 여기서 돌돔을 먹었어요](/posts/dongnae-singsing-domae-hoetjip-20260711/).
전어만 시키면 금방 물릴 것 같아서 전어와 모듬회가 같이 나오는 세트를 골랐어요. 영업은 일요일부터 목요일까지 오후 4시부터 자정까지이고, 금요일과 토요일에는 다음 날 새벽 1시까지 해요.
생선구이와 김치전이 먼저 나왔어요. 구이로 나온 생선은 작은 도다리가 아닐까 싶었는데, 확실하지는 않아요.


회는 광어와 밀치, 강도다리, 전어였던 것 같아요. 밀치와 전어가 맛있었어요. 강도다리는 씹는 느낌이 제 입에 맞지 않았어요. 그래도 밀치와 전어가 좋아서 아쉬움이 덜했어요.
---
# Lee Jae-mo Pizza in Seomyeon: I am not sure it is worth the line
> I saw the sign on an errand in Seomyeon and went in. I ate about two slices of the Lee Jae-mo Crust and took the rest home. Not bad, but worth a long line?
- Canonical: https://jaemyeong.com/en/blog/seomyeon-leejaemo-pizza-20260428/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #pizza
At about 5 on a Tuesday afternoon I was in Seomyeon on another errand and saw the Lee Jae-mo Pizza sign. About 20 years ago I went to the main store often. I had heard that the taste changed after they switched cheese and that people think less of it now, so I went in to see how different it is.
I like pizza right out of the oven, so I chose to eat in. I ordered the Lee Jae-mo Crust.

I ate about two slices and took the rest home. The taste did not leave much of an impression. I could not really tell what had changed from before either.
It is not bad. But I doubt it is worth standing in a long line for. I also wondered whether visitors expect too much. I am someone who does not find Sungsimdang special either, so it may just be my taste. I do not think people who live in Busan would wait in line for it.
I would come here only if a friend asked to go. Domino's cheese pizza suits me better.
---
# 이재모피자 서면중앙점, 줄을 설 만큼인지는 모르겠어요
> 서면에 볼일이 있어 갔다가 간판을 보고 들어갔어요. 이재모크러스트를 매장에서 두 조각쯤 먹고 나머지는 포장했어요. 못 먹을 맛은 아니지만 긴 줄을 설 만큼인지는 의문이에요.
- Canonical: https://jaemyeong.com/ko/blog/seomyeon-leejaemo-pizza-20260428/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #pizza
화요일 오후 5시쯤, 서면에 다른 볼일이 있어 나갔다가 이재모피자 간판을 봤어요. 20년쯤 전에는 본점에 자주 갔어요. 치즈를 바꾼 뒤로 맛이 달라져서 평이 떨어졌다는 이야기를 들은 터라, 얼마나 달라졌는지 궁금해서 들어갔어요.
저는 갓 구운 피자를 좋아해서 매장에서 먹기로 했어요. 이재모크러스트를 시켰어요.

두 조각쯤 먹고 남은 것은 포장해서 가져왔어요. 맛은 특별히 인상적이지 않았어요. 예전과 무엇이 달라졌는지도 저는 잘 모르겠어요.
못 먹을 맛은 아니에요. 다만 긴 줄을 서 가면서 먹을 만큼인지는 의문이에요. 여행 온 분들의 기대가 지나친 것은 아닌가 하는 생각도 들었어요. 저는 성심당도 특별하다고 느끼지 못하는 편이니, 제 취향이 그런 것일 수도 있어요. 부산에 사는 사람들은 줄까지 서서 먹지는 않을 것 같아요.
이 집은 친구가 가자고 하면 같이 들를 정도예요. 제 입맛에는 도미노피자의 치즈피자가 더 맞아요.
---
# A glass of Yoichi at Acorn in Suyeong: a smooth end to the night
> The third stop of the night, after Tansuho. I had a glass of Yoichi single malt, a Japanese whisky, and it was smooth. I chatted with the owner and went home.
- Canonical: https://jaemyeong.com/en/blog/suyeong-acorn-20260425/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #whisky, #bar
At about 8 on a Saturday night, after the second stop at [Tansuho](/en/posts/suyeong-tansuho-20260425/), I went to Acorn as the third. I wanted to end the day's drinking with one glass of whisky. There is also [a note from my visit on April 16](/en/posts/suyeong-acorn-20260416/).
That day I had Yoichi single malt. It is a Japanese whisky.

It was smooth. The owner is kind, so it is a good place to end a night of drinking over a conversation. I talked with the owner for a while and then went home.
I also like Yamazaki and Kavalan. Next time I plan to try those two. The menu posted on the map lists highballs at 11,000 won and cocktails at 15,000 won. [I came once more in August](/en/posts/suyeong-acorn-20260815/) as well.
---
# 수영 에이콘에서 요이치 한 잔, 부드럽게 하루를 마무리했어요
> 탄수호에서 나와 세 번째 자리로 간 위스키바예요. 일본 위스키 요이치 싱글몰트를 한 잔 마셨는데 부드러웠어요. 사장님과 이야기를 나누다가 집에 갔어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-acorn-20260425/
- Published: 2026.09.29
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #whisky, #bar
토요일 밤 8시쯤, [탄수호](/posts/suyeong-tansuho-20260425/)에서 두 번째 자리를 마치고 세 번째로 에이콘에 갔어요. 하루의 술자리를 위스키 한 잔으로 끝내고 싶었어요. [4월 16일에 왔던 기록](/posts/suyeong-acorn-20260416/)도 있어요.
이날은 요이치 싱글몰트를 마셨어요. 일본 위스키예요.

부드러웠어요. 사장님이 친절해서, 이야기를 나누면서 술자리를 끝내기에 좋은 곳이에요. 저도 사장님과 이야기를 하다가 집으로 돌아갔어요.
저는 야마자키와 카발란도 좋아해요. 다음에 오면 그 두 가지를 마셔 보려고 해요. 지도에 올라온 메뉴에는 하이볼이 11,000원, 칵테일이 15,000원으로 적혀 있어요. [8월에도 한 번 더 왔어요](/posts/suyeong-acorn-20260815/).
---
# Mango shaved ice at Cafe 824 in Gijang, on a terrace by the sea
> A seaside cafe my family and I went to after seafood at Gijang Haenyeochon. We had the signature mango shaved ice for two on the terrace, facing the sea.
- Canonical: https://jaemyeong.com/en/blog/gijang-cafe-824-20260427/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gijang, #cafe, #dessert
After seafood at [Gijang Haenyeochon](/en/posts/gijang-haenyeochon-20260427/), my family and I moved to a cafe. It was about 4 on a Monday afternoon. Cafe 824 is on Gijang Haean-ro, and we sat at an outdoor terrace table with a view of the sea.
The surroundings were quiet. There were not many guests, so it felt relaxed and still. I think that was because it was a weekday during the day.
We ordered the signature mango shaved ice. It is for two. Large pieces of mango, scored with a knife, sit on top, and the red beans come separately in a small dish.

The shaved ice was good. Still, the main reason I recommend this cafe is the view from the seats that face the sea.
I was the driver that day. The weather was so nice that I felt like going fishing and having a drink. About a month later I came once more with a friend.
---
# 기장 카페 824의 망고 빙수, 바다 보이는 테라스에서 먹었어요
> 기장해녀촌에서 해산물을 먹고 가족과 옮겨 간 바닷가 카페예요. 테라스 자리에서 바다를 보며 2인용 시그니처 망고 빙수를 먹었어요. 한 달쯤 뒤에 친구와 다시 갔어요.
- Canonical: https://jaemyeong.com/ko/blog/gijang-cafe-824-20260427/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gijang, #cafe, #dessert
[기장해녀촌](/posts/gijang-haenyeochon-20260427/)에서 해산물을 먹은 뒤에 가족과 카페로 옮겼어요. 월요일 오후 4시쯤이었어요. 카페 824는 기장해안로에 있고, 저희는 바다가 보이는 바깥 테라스 자리에 앉았어요.
주변이 조용했어요. 손님도 많지 않아서 한가롭고 고요했어요. 평일 낮이라서 그랬던 것 같아요.
시그니처 망고 빙수를 시켰어요. 2인용이에요. 위에 큼직한 망고가 칼집을 낸 채로 올라가 있고, 팥은 작은 그릇에 따로 나와요.

빙수는 맛있었어요. 그래도 이 카페를 권하는 가장 큰 이유는 바다가 보이는 자리의 경치예요.
이날은 제가 운전을 했어요. 날씨가 좋아서 낚시를 하고 술도 한잔하고 싶은 마음이 들었어요. 한 달쯤 뒤에는 친구와 한 번 더 왔어요.
---
# Corning in Gwangan-dong: gelato with a glass of white wine
> A cafe in Gwangan-dong I stopped by on a fine Friday afternoon with a friend. It sells gelato and wine by the glass. I liked the stylish room.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-corning-20260501/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #cafe, #gelato, #wine
A friend came to Busan from another city. At about 3 on a Friday afternoon, with good weather, we stopped by Corning together. It is a cafe that sells gelato and wine by the glass. We also dropped by [Animal Forest](/en/posts/gwangalli-animal-forest-20260501/) that day.
It sits inside the alleys on the way down from Gwangan Station to the beach. I am told you turn into an alley twice from the station exit.
I picked a glass of white wine. Wine by the glass costs 9,000 won, red or white. We added a cold coffee and gelato. Gelato costs 7,000 won if you pick two flavors and 9,000 won if you pick three.

The gelato was fine. I liked the feel of the place more. The room is stylish. On a day with good weather you can sit at the outdoor tables too. I got the impression that many of the guests were couples.
I recommend this cafe to people who like this kind of room. At our seat was a sheet for a collaboration menu offered for five days only (May 1 to 5). It had a lemon à la mode and a few pound cakes on it.
---
# 광안동 코닝, 젤라또에 화이트 와인 한 잔을 곁들였어요
> 다른 도시에서 온 친구와 날씨 좋은 금요일 오후에 들른 광안동의 카페예요. 젤라또와 글라스 와인을 팔아요. 젤라또 맛은 무난했고, 세련된 분위기가 마음에 들었어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-corning-20260501/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #cafe, #gelato, #wine
친구가 다른 도시에서 부산에 왔어요. 금요일 오후 3시쯤, 날씨가 좋은 날에 같이 코닝에 들렀어요. 젤라또와 글라스 와인을 파는 카페예요. 이날은 [애니멀숲](/posts/gwangalli-animal-forest-20260501/)에도 들렀어요.
광안역에서 해수욕장으로 내려가는 길의 골목 안쪽에 있어요. 역 출구에서 골목을 두 번 꺾어 들어간다고 들었어요.
저는 화이트 와인을 한 잔 골랐어요. 잔으로 파는 와인은 레드든 화이트든 9,000원을 받아요. 여기에 차가운 커피와 젤라또를 더했어요. 젤라또 값은 맛을 두 가지 고르면 7,000원이고 세 가지 고르면 9,000원이에요.

젤라또는 무난하게 먹었어요. 제 마음에 더 든 것은 세련된 공간이에요. 바깥에도 테이블이 있어서 날이 좋으면 거기에 앉아도 돼요. 둘러보니 연인으로 보이는 손님이 많은 듯했어요.
이런 분위기를 좋아하는 분께 권해요. 자리에는 닷새 동안(5월 1일~5일)만 내는 협업 메뉴를 알리는 종이가 있었어요. 레몬 아라모드와 파운드 케이크 몇 가지가 올라 있었어요.
---
# Aged kimchi gimbap at Tansuho in Mangmi: good food, nice room
> A second stop after Ijo Sutbul Galbi, at a Korean restaurant. I expected a long line, but we got in right away. The aged kimchi gimbap was good.
- Canonical: https://jaemyeong.com/en/blog/suyeong-tansuho-20260425/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Mangmi, #Korean food, #bar
After the first stop at [Ijo Sutbul Galbi](/en/posts/mangmi-ijo-sutbul-galbi-20260425/), my younger companion and I moved to Tansuho at about 7 on a Saturday evening. It is a Korean restaurant close to the KT Suyeong branch.
It looks like a place that usually has a long line, but that day we walked right in without waiting. I think we were lucky. They take reservations too.
I liked the decoration and the feel of the room. A baseball player's autograph hangs on the wall. I guessed that players may come here often.
What I ate that day was the aged kimchi gimbap.

I liked the food. The menu here seems to change often with the season. The aged kimchi gimbap also seemed to be off the menu around the time I wrote this up.
The menu had an octopus and aged kimchi carpaccio. The oyster bossam seems to be sold when oysters are in season. Next time I want to try those two.
From here we went to [Acorn](/en/posts/suyeong-acorn-20260425/) and finished with a glass of whisky.
---
# 망미 탄수호의 묵은지 김밥, 음식도 공간도 깔끔했어요
> 이조숯불갈비에서 나와 2차로 간 한식 술집이에요. 줄이 길 것 같았는데 운 좋게 바로 들어갔어요. 묵은지 김밥이 깔끔했고 실내 분위기도 좋았어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-tansuho-20260425/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Mangmi, #Korean food, #bar
[이조숯불갈비](/posts/mangmi-ijo-sutbul-galbi-20260425/)에서 첫 자리를 마치고 토요일 저녁 7시쯤에 동생과 탄수호로 옮겼어요. KT수영지점 가까이에 있는 한식집이에요.
평소에는 줄이 길 것 같은 집인데, 이날은 기다리지 않고 바로 들어갔어요. 운이 좋았다고 생각해요. 예약도 받아요.
실내는 꾸밈새와 분위기가 좋았어요. 벽에는 야구 선수의 사인이 걸려 있어요. 선수들이 자주 오는 곳인가 하고 짐작했어요.
이날 먹은 것은 묵은지 김밥이에요.

음식이 깔끔했어요. 이 집은 계절에 따라 메뉴를 자주 바꾸는 것 같아요. 묵은지 김밥도 이 글을 쓸 무렵에는 팔지 않는 듯했어요.
메뉴에 돌문어 묵은지 카르파쵸가 있었어요. 굴보쌈은 굴이 나오는 철에 파는 것 같아요. 다음에 가면 이 두 가지를 먹어 보고 싶어요.
이날은 여기에서 나와 [에이콘](/posts/suyeong-acorn-20260425/)에서 위스키 한 잔으로 마무리했어요.
---
# Ijo Sutbul Galbi in Mangmi Market: cheap pork belly grilled outdoors
> At my companion's suggestion we grilled fresh pork belly at an outdoor table in Mangmi Jungang Market. 9,000 won for 130 g, and less smoke outdoors.
- Canonical: https://jaemyeong.com/en/blog/mangmi-ijo-sutbul-galbi-20260425/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Mangmi, #pork, #barbecue
At about 5:30 on a Saturday evening, the younger companion I was with suggested it, and we went to Ijo Sutbul Galbi as the first stop of the day. It is inside Mangmi Jungang Market.
We ate at an outdoor table. Even at that hour there were quite a few guests, many of them young. I wondered whether it is because outdoor tables are in fashion these days. I also got the impression that people who live nearby come often.
We ordered fresh pork belly. The menu lists it at 9,000 won for 130 g. Several side dishes come with it.

The price felt low. Grilling outdoors also meant the smoke bothered me less. The staff who served us were kind. The menu also lists pork ribs at 9,000 won for 130 g and a soybean paste stew, ordered after the meat, at 2,000 won.
There was a cat shelter among the stacked boxes, and I saw a cat too. It is a good place to grill meat outdoors on a clear day, so I plan to go again when the weather is good.
After eating here we went to [Tansuho](/en/posts/suyeong-tansuho-20260425/).
---
# 망미중앙시장 이조숯불갈비, 야장에서 싸게 굽는 생삼겹살이 좋았어요
> 같이 간 동생의 제안으로 망미중앙시장의 야외 자리에서 생삼겹살을 구웠어요. 130g에 9,000원이고, 밖에서 구우니 연기 걱정이 덜했어요. 직원분들이 친절했어요.
- Canonical: https://jaemyeong.com/ko/blog/mangmi-ijo-sutbul-galbi-20260425/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Mangmi, #pork, #barbecue
토요일 저녁 5시 반쯤, 같이 간 동생이 가자고 해서 이날의 첫 자리로 이조숯불갈비에 갔어요. 망미중앙시장 안에 있어요.
저희는 바깥 자리에서 먹었어요. 그 시간에도 손님이 꽤 있었고 젊은 손님이 많이 보였어요. 요즘 야장이 유행이라 그런가 싶었어요. 근처에 사는 분들이 자주 오는 곳 같다는 인상도 받았어요.
생삼겹살을 시켰어요. 메뉴판에는 130g에 9,000원으로 적혀 있어요. 밑반찬이 여러 가지 나와요.

값이 싸다고 느꼈어요. 밖에서 구우니 연기가 덜 신경 쓰이는 것도 좋았어요. 응대하는 직원분들이 친절했어요. 메뉴판에는 돼지갈비가 130g에 9,000원, 고기를 먹은 뒤에 시키는 된장찌개가 2,000원으로 적혀 있어요.
상자가 쌓인 곳에 고양이 쉼터가 있었고 고양이도 한 마리 봤어요. 맑은 날에 야장에서 고기를 굽기에 좋은 집이라서, 날씨가 좋을 때 다시 가려고 해요.
이날은 여기에서 먹고 난 뒤 [탄수호](/posts/suyeong-tansuho-20260425/)로 갔어요.
---
# All-you-can-eat skewers and hot pot at Yangyang near PNU: fair value
> A place near PNU that my friend found for malatang and lamb skewers. Skewers and hot pot, all you can eat, for 24,000 won per person. Better than I expected.
- Canonical: https://jaemyeong.com/en/blog/jangjeon-yangyang-yangkkochi-20260423/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Jangjeon, #lamb, #hot pot
My friend wanted malatang and lamb skewers, and my friend also found the place. At about 6 on a Thursday evening we went to Yangyang Yangkkochi near Pusan National University. It is on the second floor, and there is no parking. It has no regular closing day.
It is an all-you-can-eat place for skewers and hot pot. They also sell skewers only or hot pot only. We chose the combined skewers and hot pot, at 24,000 won per person.
You bring the skewers and hot pot ingredients yourself from the self-service bar. The skewers turn on their own over the charcoal as they cook.

Because it is cheap, I did not expect much from the taste. It turned out to be fine. For this price the offer makes sense. Since you fetch things yourself, I ate at ease without feeling watched.
It is worth a visit when you want hot pot or lamb skewers near PNU without spending much. I remember that we ate quite a lot. The cup for used skewers filled up.
---
# 부산대 양양양꼬치의 꼬치+훠궈 무한리필, 값에 비해 먹을 만했어요
> 마라탕과 양꼬치가 먹고 싶다는 친구가 찾아낸 부산대 앞 가게예요. 1인 24,000원에 꼬치와 훠궈를 마음껏 먹었어요. 맛은 기대를 낮췄던 것에 비해 괜찮았어요.
- Canonical: https://jaemyeong.com/ko/blog/jangjeon-yangyang-yangkkochi-20260423/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Jangjeon, #lamb, #hot pot
친구가 마라탕과 양꼬치를 먹고 싶다고 했어요. 가게도 친구가 찾았어요. 목요일 저녁 6시쯤에 부산대 앞에 있는 양양양꼬치에 갔어요. 건물 2층이고, 주차는 안 돼요. 정기 휴무일은 없어요.
꼬치와 훠궈를 무제한으로 먹는 집이에요. 꼬치만, 또는 훠궈만 무제한으로 먹는 상품도 따로 팔아요. 저희는 둘을 합친 꼬치+훠궈 무한리필을 골랐고 1인에 24,000원이에요.
꼬치와 훠궈에 넣을 재료는 셀프바에서 직접 가져와요. 꼬치는 숯불 위에서 저절로 돌아가면서 익어요.

값이 싸서 맛은 크게 기대하지 않았어요. 그런데 먹어 보니 괜찮았어요. 이 값이면 수긍할 만한 구성이에요. 직접 가져다 먹는 방식이라 눈치 보지 않고 편하게 먹었어요.
부산대 근처에서 적은 돈으로 훠궈나 양꼬치를 먹고 싶을 때 갈 만해요. 저희는 꽤 많이 먹었던 것으로 기억해요. 다 먹은 꼬챙이를 꽂는 통이 가득 찼어요.
---
# Joseph, a bar in Gwangalli: Macallan 12 at a candlelit bar seat
> The third stop of the night, after Yasumu. It is the bar I end up at when I am in Gwangalli. I had Macallan 12 and my friend had a non-alcoholic cocktail.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-joseph-20260421/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #whisky, #bar
Joseph is a bar in Gwangalli that I like. When I am in Gwangalli I end up coming here. That day, at about 9 on a Tuesday night, my friend and I stopped by as the third place, after the second round at [Izakaya Yasumu Gwangan](/en/posts/gwangalli-yasumu-20260421/).
I am told it is behind Pascucci, across from the Haman parking lot. The room is dim, with candles lit here and there. We sat at the bar.
I chose Macallan 12.

The friend I was with does not drink, so my friend ordered a non-alcoholic cocktail. [I went back on May 1](/en/posts/gwangalli-joseph-20260501/), and [once more in July of the same year](/en/posts/gwangalli-joseph-20260730/).
---
# 광안리 바 조셉, 촛불 켜진 바 자리에서 맥캘란 12를 마셨어요
> 야수무에서 나와 친구와 세 번째로 들른 곳이에요. 제가 광안리에 오면 찾게 되는 바예요. 저는 맥캘란 12를, 술을 마시지 않는 친구는 무알콜 칵테일을 골랐어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-joseph-20260421/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #whisky, #bar
조셉은 제가 좋아하는 광안리의 바예요. 광안리에 오면 이곳을 찾게 돼요. 이날은 화요일 밤 9시쯤, [이자카야 야수무 광안점](/posts/gwangalli-yasumu-20260421/)에서 2차까지 한 뒤에 친구와 세 번째로 들렀어요.
파스쿠찌 뒤쪽이고 함안주차장 맞은편이라고 해요. 실내는 어둡고, 곳곳에 촛불이 켜져 있어요. 저희는 바 자리에 앉았어요.
저는 맥캘란 12를 골랐어요.

함께 간 친구는 술을 마시지 않아요. 그래서 무알콜 칵테일을 시켰어요. [5월 1일에도 다시 갔고](/posts/gwangalli-joseph-20260501/), 같은 해 [7월에 한 번 더 갔어요](/posts/gwangalli-joseph-20260730/).
---
# Half-and-half platter at Izakaya Yasumu Gwangan: tasty, good value
> A second stop after Durumi and a walk on the beach. We ordered the 37,000 won platter of raw beef and salmon sashimi. I was too full to eat much.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-yasumu-20260421/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #izakaya, #sashimi
We had pork for the first meal at [Durumi Gwangan](/en/posts/gwangalli-durumi-20260421/) and then walked along Gwangalli Beach to help it settle. After that my friend and I went into Izakaya Yasumu Gwangan for a second round. It was about 8 on a Tuesday night.
I am told it is across the street from Lucky Sanghoe Gwangan, in the building next to Millak Silbi. A staff member led us to the one table that was left. The other tables were said to be reserved.

The interior is nicely decorated. I thought it suits couples on a date more than a group of friends.
We ordered the half-and-half platter. It comes with raw beef sashimi and salmon sashimi, and the menu posted on the map lists it at 37,000 won.

White mist rose from under the plate. I think they used dry ice. It tasted good, and the price felt low. I was full, though, and could not eat much. I think it was because I had eaten just before.
I hear the signature dish here is handmade yakitori. After the meal we moved on to [Joseph](/en/posts/gwangalli-joseph-20260421/).
---
# 이자카야 야수무 광안점의 반반 한상, 맛있고 값도 좋았어요
> 두루미에서 먹고 해변을 걸은 뒤 2차로 간 이자카야예요. 육 사시미와 연어 사시미가 함께 나오는 37,000원짜리 반반 한상을 시켰는데, 배가 불러서 많이 먹지는 못했어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-yasumu-20260421/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #izakaya, #sashimi
[두루미 광안점](/posts/gwangalli-durumi-20260421/)에서 돼지고기로 첫 식사를 하고, 소화를 시킬 겸 광안리 해변을 걸었어요. 그다음에 친구와 2차로 들어간 곳이 이자카야 야수무 광안점이에요. 화요일 밤 8시쯤이었어요.
럭키상회 광안점의 길 건너편이고 민락실비 옆 건물이라고 해요. 저희는 직원분의 안내로 하나 남아 있던 테이블에 앉았어요. 다른 테이블은 예약이 돼 있다고 했어요.

실내가 잘 꾸며져 있어요. 친구끼리 오는 것보다 연인이 데이트하러 오기에 더 어울리겠다고 생각했어요.
반반 한상을 시켰어요. 육 사시미와 연어 사시미가 함께 나오고, 지도에 올라온 메뉴에는 37,000원으로 적혀 있어요.

접시 아래에서 흰 연기가 피어올랐어요. 드라이아이스를 쓴 것 같아요. 맛있었고 값도 싸다고 느꼈어요. 다만 배가 불러서 많이 먹지는 못했어요. 앞에서 먹고 온 탓인 것 같아요.
이 집의 대표 메뉴는 수제 야키토리라고 해요. 이날은 식사 뒤에 [조셉](/posts/gwangalli-joseph-20260421/)으로 자리를 옮겼어요.
---
# Pork belly and fried rice at Durumi Gwangan: worth standing in line
> The first stop of the day with a friend. The staff explained and grilled the meat for us. We had Jeju pork belly and rib meat, then finished with fried rice.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-durumi-20260421/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #pork, #barbecue
At about 5 on a Tuesday afternoon, a friend and I went to Durumi Gwangan as the first stop of the day. I am told it is in an alley about a 5-minute walk toward the sea from exit 3 of Geumnyeonsan Station.
The Durumi main store in Jeonpo has a long line and is hard to get into. At the Gwangan branch, I think getting in is easier than at the main store if you come at opening time.
The table was a little greasy. It is a barbecue place, so I let it pass.
We first ordered the Jeju premium pork belly. A staff member explains and grills it for you. I liked the way they explained things.

Next we had the Hwangje rib meat.
Among the pork barbecue places I have been to in Busan, I think this is one of the tastier ones. I could see why it is so popular. If there is no line, or you have time to wait, I would recommend it.
The last dish was fried rice. I suggest you order it.

After the meal we walked along Gwangalli Beach and then went to [Izakaya Yasumu Gwangan](/en/posts/gwangalli-yasumu-20260421/).
---
# 두루미 광안점의 오겹살과 볶음밥, 줄을 설 만한 맛이었어요
> 친구와 이날의 첫 자리로 간 돼지고깃집이에요. 직원분이 설명하면서 직접 구워 줬고, 제주 특상 오겹살과 황제갈비살을 먹은 뒤 볶음밥으로 마무리했어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-durumi-20260421/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #pork, #barbecue
화요일 오후 5시쯤, 친구와 이날 모임의 첫 자리로 두루미 광안점에 갔어요. 금련산역 3번 출구에서 바다 쪽으로 5분쯤 걸으면 나오는 골목 안에 있다고 해요.
두루미 전포본점은 줄이 길어서 가기가 어려워요. 광안점은 문 여는 시간에 맞춰 가면 본점보다는 들어가기가 수월할 것 같아요.
테이블에는 기름기가 조금 있었어요. 고깃집이니 그러려니 했어요.
처음에는 제주 특상 오겹살을 시켰어요. 직원분이 설명을 해 주면서 직접 구워 줘요. 설명하는 태도가 좋았어요.

이어서 황제갈비살을 먹었어요.
제가 부산에서 가 본 돼지고깃집 중에서도 맛있는 편이라고 생각해요. 이렇게 인기가 많은 것이 이해가 됐어요. 줄이 없거나 기다릴 여유가 있는 날이라면 권하고 싶어요.
마지막은 볶음밥이었어요. 볶음밥은 꼭 시키기를 권해요.

이날은 식사를 마치고 광안리 해변을 걸은 뒤에 [이자카야 야수무 광안점](/posts/gwangalli-yasumu-20260421/)으로 갔어요.
---
# Acorn, a whisky bar in Suyeong: easy even for a first-timer
> A small whisky bar in Suyeong that an acquaintance recommended. I had an Old Fashioned and my friend had Bunnahabhain 12. The owner is kind and cheerful.
- Canonical: https://jaemyeong.com/en/blog/suyeong-acorn-20260416/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #whisky, #bar
After the first stop at [Baeksu Nongwon](/en/posts/suyeong-baeksu-nongwon-20260416/), a friend and I came here for a second round. It is a whisky bar recommended by a close acquaintance, and we went at about 8:30 on a Thursday night.
From the McDonald's at Suyeong Station, go into the alley on the right and head straight toward Daiso. I am told it is easy to find. Whisky bottles line the inside, and the room is small. Seats may run out, so it is better to ask before you go.
I ordered an Old Fashioned. I enjoyed it.

My friend had Bunnahabhain 12.

The owner is kind and cheerful. They recommend whisky to people who do not know it well, and they sell beer too. So I felt it is a place even a first-timer can visit comfortably.
On the menu posted on the map, cocktails are listed at 15,000 won and highballs at 11,000 won, and whisky is listed with variable pricing. I wrote about going back on the 25th of the same month in [my April 25 visit note](/en/posts/suyeong-acorn-20260425/), and [I went once more in August](/en/posts/suyeong-acorn-20260815/).
---
# 수영 위스키바 에이콘, 처음이어도 편하게 마실 수 있었어요
> 지인의 추천으로 친구와 2차로 간 수영의 작은 위스키바예요. 저는 올드패션드를, 친구는 부나하벤 12년을 마셨어요. 사장님이 친절하고 유쾌해서 위스키를 잘 몰라도 편했어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-acorn-20260416/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #whisky, #bar
[백수농원](/posts/suyeong-baeksu-nongwon-20260416/)에서 첫 자리를 마치고 친구와 2차로 간 곳이에요. 친한 지인이 추천해 준 위스키바이고, 목요일 밤 8시 반쯤에 갔어요.
수영역 맥도날드를 기준으로 오른쪽 골목으로 들어가서 다이소 쪽으로 곧장 가면 있어요. 찾기 쉬운 편이라고 해요. 안에는 위스키가 진열돼 있고 공간은 작아요. 자리가 없을 수도 있으니 가기 전에 문의해 보는 것이 좋겠어요.
저는 올드패션드를 시켰어요. 맛있게 마셨어요.

친구는 부나하벤 12년을 마셨어요.

사장님이 친절하고 유쾌한 분이에요. 위스키를 잘 모르는 사람에게는 추천도 해 주고, 맥주도 팔아요. 그래서 위스키가 처음인 사람도 편하게 갈 수 있는 곳이라고 느꼈어요.
지도에 올라온 메뉴를 보면 칵테일은 15,000원, 하이볼은 11,000원으로 적혀 있고 위스키는 변동 가격으로 표시돼 있어요. 같은 달 25일에 다시 간 날은 [4월 25일 방문 기록](/posts/suyeong-acorn-20260425/)에 적었고, [8월에도 한 번 더 갔어요](/posts/suyeong-acorn-20260815/).
---
# Galbit in Yangjeong: good marinated ribs, weak gochujang yukhoe
> A rib restaurant by Yangjeong Station. I liked the sweet marinated ribs and preferred them in a lettuce wrap, but I cannot recommend the gochujang yukhoe.
- Canonical: https://jaemyeong.com/en/blog/yangjeong-galbit-20260909/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Yangjeong, #galbi, #barbecue
I often eat with a friend from college. This Wednesday evening the first plan was boiled pork. But while my friend was running late I looked around the area by Yangjeong Station, found I wanted ribs more, and we went into Galbit at about 7:20.
The restaurant looked clean. It is a place I would suggest when you want to eat meat somewhere clean near Yangjeong Station. When you order, side dishes and cold boiled pork come out together on one tray.

I liked the marinated ribs for their sweet marinade. They go on a wire grill.

They also show you how to tuck the ribs into a dinner roll with cheese and eat it like a burger. I preferred the ribs in a lettuce wrap.
It is open from 11:30 a.m. to 11 p.m., with last orders at 10:30 p.m. If you use the Buddhist Center parking lot, they cover one hour of parking.
We ordered the gochujang yukhoe too, and it disappointed in both looks and taste. I have had a good gochujang yukhoe in Jinju, but I cannot recommend this one.
---
# 양정 갈빛, 양념갈비는 좋았고 고추장육회는 아쉬웠어요
> 대학 동기와 수요일 저녁에 간 양정역 앞 갈빗집이에요. 달달한 양념갈비는 마음에 들었고 저는 빵보다 쌈에 싸 먹는 쪽이 좋았어요. 고추장육회는 모양도 맛도 권하기 어려웠어요.
- Canonical: https://jaemyeong.com/ko/blog/yangjeong-galbit-20260909/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Yangjeong, #galbi, #barbecue
대학 동기인 친구와는 평소에도 자주 같이 밥을 먹어요. 이 수요일 저녁에는 처음에 간받이수육을 먹으려고 했어요. 그런데 친구가 늦는 동안 양정역 근처를 둘러보다가 갈비가 더 당겨서, 저녁 7시 20분쯤 갈빛으로 들어갔어요.
가게는 깨끗한 인상이었어요. 양정역 근처에서 깨끗한 곳에 앉아 고기를 먹고 싶을 때 권할 만해요. 주문하면 밑반찬과 냉수육이 한 상에 같이 나와요.

양념갈비는 양념이 달달해서 마음에 들었어요. 석쇠에 올려 구워요.

모닝빵과 치즈에 갈비를 넣어서 버거처럼 먹는 방법도 알려 줘요. 저는 그보다 쌈에 싸 먹는 쪽이 더 좋았어요.
영업은 오전 11시 반부터 밤 11시까지이고 주문은 10시 반에 마감해요. 불교회관 주차장을 쓰면 한 시간 주차비를 지원해 줘요.
고추장육회도 시켰는데, 모양도 맛도 아쉬웠어요. 진주에서 먹은 고추장육회는 맛있었는데 이 집의 것은 권하기가 어려워요.
---
# Pork belly at Baeksu Nongwon in Suyeong: a fair place for a quick stop
> A close younger friend suggested it, and we had only pork belly at a barbecue place in Suyeong. The pork, 11,500 won for 130 g, and the service were fair.
- Canonical: https://jaemyeong.com/en/blog/suyeong-baeksu-nongwon-20260416/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #pork, #barbecue
A close younger friend suggested it, so at about 7 on a Thursday evening we went to Baeksu Nongwon. It is a barbecue restaurant I have been to before, mostly with this same friend. It is a suitable place to drop by without much thought.
The hall is large. In my experience I have rarely had to wait. It is on the exit 11 side of Suyeong Station, and the entrance is lit up.
We ordered the Korean pork belly. It is 11,500 won for 130 g. Several side dishes come with it.

The quality of the meat was fair. So was the service. That day we had only pork belly and kept the first stop short. I have [a separate note from another visit in August](/en/posts/suyeong-baeksu-nongwon-20260815/) and [one from September](/en/posts/suyeong-baeksu-nongwon-20260919/).
---
# 수영 백수농원의 한돈 삼겹살, 가볍게 들르기에 무난해요
> 친한 동생이 가자고 해서 수영의 고깃집에서 삼겹살만 먹고 일어났어요. 130g에 11,500원인 한돈 삼겹살은 고기도 응대도 무난했고, 홀이 커서 기다린 적이 드물어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-baeksu-nongwon-20260416/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #pork, #barbecue
친한 동생이 가자고 해서 목요일 저녁 7시쯤에 백수농원에 갔어요. 전에도 온 적이 있는 고깃집이고, 올 때는 주로 이 동생과 함께였어요. 부담 없이 들르기에 적당한 곳이에요.
홀이 커요. 그래서 제 경험으로는 기다린 일이 드물었어요. 수영역 11번 출구 쪽에 있고, 입구에는 조명이 켜져 있어요.
한돈 삼겹살을 시켰어요. 130g에 11,500원이에요. 반찬이 여러 가지 같이 나와요.

고기의 질은 무난했어요. 응대도 무난했어요. 이날은 삼겹살만 먹고 첫 자리를 짧게 끝냈어요. [8월에 다시 간 기록](/posts/suyeong-baeksu-nongwon-20260815/)과 [9월의 기록](/posts/suyeong-baeksu-nongwon-20260919/)도 따로 있어요.
---
# The A set at Michin Makchang in Dongnae: not much smell, easy to eat
> With a university friend after work, I tried a makchang place that looked new. We had the A set of salted and seasoned makchang and finished with pork skin.
- Canonical: https://jaemyeong.com/en/blog/dongnae-michin-makchang-20260414/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #makchang, #pork
At about 7 on a Tuesday evening I met a friend I have known since university, after my friend got off work. While choosing where to eat, we saw a makchang place that looked newly opened and went in. I do not know when it actually opened.
Come out of exit 4 of Dongnae Station, cross at the crosswalk, and go into the alley.
The menu was neatly organized. We ordered the A set, which comes with both salted and seasoned makchang.

The makchang did not have much of an unpleasant smell and was easy to eat. I would recommend it as a place for makchang.
We finished with pork skin. Lately I have seen the place full several times.
---
# 동래 미친막창의 A 세트, 잡내가 덜해서 편하게 먹었어요
> 퇴근한 대학 친구와 새로 생긴 듯한 막창집에 들어가 봤어요. 소금막창과 양념막창이 나오는 A 세트를 먹고 돼지껍데기로 마무리했어요. 잡내가 덜했어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-michin-makchang-20260414/
- Published: 2026.09.28
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #makchang, #pork
화요일 저녁 7시쯤, 대학 때부터 알고 지낸 친구가 퇴근한 뒤에 만났어요. 어디에서 먹을지 고르다가 새로 생긴 가게처럼 보이는 막창집이 있어서 들어가 봤어요. 실제로 언제 문을 열었는지는 몰라요.
동래역 4번 출구로 나와 횡단보도를 건넌 다음 골목 안으로 들어가면 있어요.
메뉴는 깔끔하게 정리돼 있었어요. 소금막창과 양념막창이 함께 나오는 A 세트를 시켰어요.

막창은 잡내가 덜해서 편하게 먹을 수 있었어요. 막창을 먹을 곳으로 권할 만해요.
마지막에는 돼지껍데기를 먹었어요. 요즘은 자리가 꽉 찬 모습을 여러 번 봤어요.
---
# Kalmat Hoetjip: grunt sold out, a pricier fish at no extra charge
> A raw fish place near PNU I have used for almost 20 years. I asked for striped jack and grunt mixed, the grunt was sold out, and they swapped in a pricier fish.
- Canonical: https://jaemyeong.com/en/blog/jangjeon-kalmat-hoetjip-20260411/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Jangjeon, #sashimi, #raw fish
This is the first place I think of when I want sashimi. It is my favorite raw fish restaurant in Busan, and I have been going for almost 20 years. I went with a friend at about 4 on a Saturday afternoon. The restaurant opens at 4, and sometimes they let you in earlier.
Ordering here is by fish, one kind at a time. They sometimes accept a request to mix, so that day I asked for striped jack (shima-aji) and grunt together. I was told the grunt was sold out. They swapped in dot-benjari and did not charge extra. The menu says dot-benjari costs 10,000 won more than grunt. I felt lucky to eat a fish I had not expected.
The two fish together came to 70,000 won.

Corn cheese is a side dish that comes as standard. Special cuts also came on a small plate.

The regular assorted sashimi is fine too. I especially like what goes into the premium sashimi, though, so I suggest the premium to anyone who goes. If you can match the hours, it is worth a visit.
I wrote about going back in July in [my July visit note](/en/posts/jangjeon-kalmat-hoetjip-20260717/).
---
# 칼맛나는푸짐한횟집, 벤자리가 떨어져 돗벤자리를 먹는 행운
> 20년 가까이 다닌 부산대 앞 횟집이에요. 시마아지와 벤자리를 섞어 달라고 했는데 벤자리가 떨어져서, 더 비싼 돗벤자리를 추가 비용 없이 받았어요.
- Canonical: https://jaemyeong.com/ko/blog/jangjeon-kalmat-hoetjip-20260411/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Jangjeon, #sashimi, #raw fish
회를 먹으려고 할 때 제가 가장 먼저 떠올리는 가게예요. 부산의 횟집 가운데 제일 좋아하는 곳이고, 다닌 지 20년 가까이 됐어요. 토요일 오후 4시쯤에 친구와 갔어요. 가게는 4시에 문을 여는데, 그보다 일찍 들여보내 주는 때도 있어요.
이 집은 생선마다 따로 주문하는 것이 기본이에요. 섞어 달라는 부탁을 들어주는 때도 있어서, 이날은 흑점줄 전갱이(시마아지)와 벤자리를 섞어 달라고 했어요. 그런데 벤자리가 다 떨어졌다고 해요. 그 대신 돗벤자리로 바꿔 줬고, 추가 비용은 받지 않았어요. 메뉴판에는 돗벤자리가 벤자리보다 10,000원 비싸다고 적혀 있어요. 생각하지 못한 생선을 먹게 돼서 운이 좋다고 느꼈어요.
두 생선을 합쳐서 70,000원이었어요.

콘치즈는 기본으로 나오는 반찬이에요. 특수부위도 작은 접시에 따로 나왔어요.

일반 모듬회도 무난해요. 다만 저는 고급회의 구성이 특히 마음에 들어서, 가는 분께는 고급회를 권해요. 시간을 맞출 수 있는 분이라면 가 볼 만해요.
7월에 다시 간 날의 이야기는 [7월 방문 기록](/posts/jangjeon-kalmat-hoetjip-20260717/)에 적었어요.
---
# Charcoal chicken with chives at Ondaljip in Dongnae: good with soju
> I met a friend after work at our usual charcoal chicken place in Dongnae. We ordered the 14,000 won chive chicken as always and drank two bottles of soju.
- Canonical: https://jaemyeong.com/en/blog/dongnae-ondaljip-20260403/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #chicken, #barbecue
At about 7 on a Friday evening, timed to when my friend got off work, we met at Ondaljip Dongnae. I have come here with this friend before. The restaurant grills chicken over charcoal and is on the exit 4 side of Dongnae Station.
We ordered what we always do: the charcoal-grilled chicken with chives. The cut is chicken thigh, and it is 14,000 won.

The two of us drank two bottles of Chamisul. We had the chicken alongside the soju. I have [a separate note from another visit in August](/en/posts/dongnae-ondaljip-20260808/) and [one from September](/en/posts/dongnae-ondaljip-20260922/).
---
# 동래 온달집의 부추 숯불 닭구이, 소주에 곁들이기 좋아요
> 퇴근한 친구와 늘 가던 동래의 숯불 닭구이집에서 만났어요. 평소처럼 14,000원짜리 부추 숯불 닭구이를 시키고 둘이서 참이슬 두 병을 마셨어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-ondaljip-20260403/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #chicken, #barbecue
금요일 저녁 7시쯤, 친구가 퇴근하는 시간에 맞춰 온달집 동래점에서 만났어요. 이 친구와는 전에도 여기에 왔어요. 숯불에 닭고기를 구워 내는 집이고, 동래역 4번 출구 쪽에 있어요.
주문도 늘 하던 대로 했어요. 부추 숯불 닭구이이고, 부위는 닭다리살이에요. 14,000원이에요.

둘이서 참이슬 두 병을 마셨어요. 닭구이는 소주에 곁들여 먹었어요. [8월에 다시 간 기록](/posts/dongnae-ondaljip-20260808/)과 [9월의 기록](/posts/dongnae-ondaljip-20260922/)도 따로 있어요.
---
# Daebak Donkkaseu in Mandeok: big portions, prices not what they were
> A Korean-style pork cutlet place I have known since its days in Mandeok Market. Generous portions, with dough soup on the side. Prices have risen.
- Canonical: https://jaemyeong.com/en/blog/mandeok-daebak-donkkaseu-20260402/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #pork cutlet, #lunch
I had not eaten pork cutlet for a while and started craving it, so I went at about 3 on a Thursday afternoon. I lived in Mandeok for a long time. I have known this place since it was in an alley inside Mandeok Market.
After a short appearance on the pork cutlet episode of a TV food show, it got more guests, and then it moved out of the market to its current building. People still line up after the move.
I had the pork cutlet with rice. A small serving of hand-pulled dough soup comes with the cutlet.

What stands out here is the generous portion. It is closer to a neighborhood restaurant than a place you travel to for outstanding taste.
Here is one way to handle the large portion. Ask for the sauce on the side, eat about half, then ask for a container and take the rest home. Of course, if you can finish it alone, eating it all there is better.
I remember the dough soup being refilled without limit when the place was in the market. Now it seems to be served once, but that is my guess from what I saw.
When I want Korean-style pork cutlet there are few other places nearby, so I do expect to go again. But it used to be a place that was easy on the wallet. As I remember it, the cutlet costs more than twice what it did before the move. I used to order the cutlet with spicy chewy noodles, and now that combination comes to more than 20,000 won. So I have gone less often since the move.
---
# 만덕 박용채의대박터진돈까스, 양은 많고 값은 예전 같지 않아요
> 만덕시장 골목에 있을 때부터 알던 경양식 돈까스집이에요. 양이 넉넉하고 수제비가 같이 나와요. 맛으로 찾아갈 정도는 아니고, 옮긴 뒤로 값이 많이 올랐어요.
- Canonical: https://jaemyeong.com/ko/blog/mandeok-daebak-donkkaseu-20260402/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #pork cutlet, #lunch
한동안 돈까스를 먹지 않았더니 생각이 나서, 목요일 오후 3시쯤에 다녀왔어요. 저는 만덕에서 오래 살았어요. 이 집은 만덕시장 안쪽 골목에서 장사하던 때부터 알던 곳이에요.
백종원의 3대천왕 돈까스 편에 잠깐 소개된 뒤로 손님이 늘었고, 그 뒤에 시장 밖의 지금 건물로 옮겼어요. 옮긴 뒤에도 줄을 서는 집이에요.
돈까스에 밥을 곁들여 먹었어요. 돈까스를 시키면 수제비가 조금 같이 나와요.

이 집의 특징은 넉넉한 양이에요. 맛이 뛰어나서 일부러 찾아갈 집이라기보다는 동네 식당에 가까워요.
먹는 요령이 있어요. 소스는 따로 달라고 하고, 반쯤 먹은 뒤에 용기를 부탁해서 남은 것을 싸 가면 좋아요. 물론 혼자서 다 먹을 수 있다면 그 자리에서 다 먹는 쪽이 낫고요.
시장에 있을 때는 수제비를 계속 더 줬던 것으로 기억해요. 지금은 한 번만 주는 것 같은데, 제가 본 대로 짐작한 것이에요.
경양식 돈까스가 먹고 싶을 때 근처에 달리 갈 곳이 많지 않아서 다시 가게 될 것 같기는 해요. 다만 예전에는 값이 부담 없는 집이었어요. 옮긴 뒤에는 돈까스 값이 예전의 두 배를 넘었다고 기억해요. 전에는 돈까스와 쫄면을 같이 시키곤 했는데, 지금 그렇게 먹으면 20,000원이 넘어요. 그래서 옮긴 뒤로는 발길이 줄었어요.
---
# The set at Ninano in Jeonpo: my friend picks the kimchi tuna gimbap
> A second stop with friends after Paldak Paldak Hoetjip. We ordered the 47,000 won set: aged kimchi tuna gimbap, suyuk katsu, and corn cheese fire chicken.
- Canonical: https://jaemyeong.com/en/blog/jeonpo-ninano-20260328/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Jeonpo, #bar, #gimbap
After the first stop at [Paldak Paldak Hoetjip](/en/posts/jeonpo-paldak-paldak-hoetjip-20260328/), my friends and I moved to Ninano Jeonpo at about 6 on a Saturday evening. A friend who had been there before suggested it. I gather that friend liked it. It is a bar on the second floor of a building on the exit 6 side of Jeonpo Station.
A basic side dish comes before the food you order. They draw the bar's name and a cat face on the plate with sauce.
We ordered the Pilseung Johap set. It bundles aged kimchi tuna gimbap, suyuk katsu, and corn cheese fire chicken, and costs 47,000 won.

The aged kimchi tuna gimbap is the dish my friend named as the best one here.

The dishes other than the gimbap looked good as well. [I went back at the end of July](/en/posts/jeonpo-ninano-20260730/) and tried dishes that are not in this set.
---
# 전포 니나노의 필승조합 세트, 친구가 꼽은 것은 묵은지참치김밥
> 팔딱팔딱횟집에서 나와 친구들과 2차로 간 술집이에요. 묵은지참치김밥, 수육카츠, 콘치즈불닭을 묶은 필승조합 세트를 47,000원에 시켰어요. 친구는 그중에서 김밥을 가장 맛있다고 했어요.
- Canonical: https://jaemyeong.com/ko/blog/jeonpo-ninano-20260328/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Jeonpo, #bar, #gimbap
토요일 저녁 6시쯤, [팔딱팔딱횟집](/posts/jeonpo-paldak-paldak-hoetjip-20260328/)에서 나온 친구들과 다음 자리로 고른 곳이 니나노 전포본점이에요. 일행 가운데 먼저 다녀온 친구가 권했는데, 그때 인상이 좋았던 듯해요. 건물 2층에 자리한 술집이고 전포역 6번 출구 방향이에요.
자리에 앉으면 기본 안주부터 내줘요. 접시 위에는 소스로 그린 고양이 얼굴과 가게 이름이 있어요.
저희가 고른 것은 47,000원짜리 필승조합 세트예요. 세 가지 음식, 곧 묵은지참치김밥과 수육카츠와 콘치즈불닭이 한 묶음이에요.

친구 말로는 이 집 음식 가운데 이 김밥이 으뜸이라고 해요.

나머지 음식도 눈으로 보기에는 맛이 괜찮을 것 같았어요. 그 뒤 [7월 말에 한 번 더 들러서](/posts/jeonpo-ninano-20260730/), 세트에 들어 있지 않은 메뉴도 시켜 먹었어요.
---
# Sashimi course at Jagalchi Hoetjip, Suyeong: go when the line is short
> Back at a raw fish place in Suyeong after a long gap. The assorted sashimi course, 35,000 won a person, came with good fish and side dishes.
- Canonical: https://jaemyeong.com/en/blog/suyeong-jagalchi-hoetjip-20260324/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #sashimi, #raw fish
I went back to Jagalchi Hoetjip, which I had not visited for a while. I was with a close acquaintance who lives in Haeundae, and it was about 6 on a Tuesday evening. It is a raw fish restaurant on the exit 5 side of Suyeong Station. It is fairly well known to locals and to travelers alike.
That day we had the assorted sashimi course. It is 35,000 won per person.

The sashimi and side dishes came out well, as you would expect from a known place. The basic course is fine. For my own taste, though, I like Marine Boy nearby better.
One step up, the premium sashimi course is 45,000 won per person. I am told the braised dish comes from that course up. If your budget allows, the premium course is the better pick. I remember the premium course being 35,000 won in the past, so the price seems to have gone up. I am not sure my memory is right.
I suggest coming here when the line is short or there is none. If the line is long, it is better to go to another raw fish place nearby.
After the meal I moved on to [Rentan Suyeong](/en/posts/suyeong-rentan-20260324/).
---
# 수영 자갈치횟집의 모둠회코스, 구성은 좋고 줄이 짧을 때 가요
> 오랜만에 다시 간 수영의 횟집이에요. 1인 35,000원인 모둠회코스는 회와 밑반찬 구성이 좋았어요. 줄이 길면 근처 다른 횟집으로 가는 편이 낫다고 생각해요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-jagalchi-hoetjip-20260324/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #sashimi, #raw fish
한동안 가지 않았던 자갈치횟집에 다시 갔어요. 해운대에 사는 친한 지인과 함께였고, 화요일 저녁 6시쯤이었어요. 수영역 5번 출구 쪽에 있는 횟집이에요. 동네 분들에게도 여행 온 분들에게도 꽤 알려진 곳이에요.
이날은 모둠회코스를 먹었어요. 1인에 35,000원이에요.

회와 밑반찬이 알려진 가게답게 잘 나왔어요. 기본 코스로도 무난해요. 다만 제 취향으로는 근처의 마린보이를 더 좋아해요.
코스는 한 단계 위인 고급회코스가 1인 45,000원이에요. 조림은 그 코스부터 나온다고 해요. 예산에 여유가 있다면 고급회코스를 고르는 것이 좋겠어요. 예전에는 고급회코스가 35,000원이었던 것으로 기억하는데, 그사이 값이 오른 것 같아요. 제 기억이 정확한지는 모르겠어요.
이 집은 줄이 짧거나 없을 때 가기를 권해요. 줄이 길다면 근처의 다른 횟집으로 가는 편이 나아요.
이날은 식사를 마치고 [렌탄 수영본점](/posts/suyeong-rentan-20260324/)으로 자리를 옮겼어요.
---
# Assorted clams at Suminine in Cheongsapo: the staff grilled everything
> After a trip to Gwangalli with my family, we went on to Cheongsapo for assorted clams. The staff grilled the scallops, clams, and shrimp for us.
- Canonical: https://jaemyeong.com/en/blog/cheongsapo-suminine-20260321/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Cheongsapo, #grilled clams, #seafood
On a Saturday my family and I went out to Gwangalli and decided to have dinner in Cheongsapo. It was about 6 in the evening. Cheongsapo is a place I often go to for clams, and now and then for fishing.
Suminine is a grilled clam restaurant in Cheongsapo. By the map directions, you go down the main road toward the Blue Line Park Cheongsapo stop and turn right, then turn right again into the side alley before you reach the big pine tree.
One funny thing happened. Perhaps because my hair is long and bleached, the staff seemed to take me for a foreigner. They first spoke to me in a foreign language and were surprised to learn I am Korean.
I ordered the assorted clams. Scallops and several kinds of clams come on one plate. A female staff member did all the grilling. She did the same the last time I came.


I enjoyed the meal. I think my family ate well too. I had to drive, so I could not drink.
---
# 청사포 수민이네의 조개모듬, 직원분이 다 구워 줘서 편했어요
> 가족과 광안리에 갔다가 청사포까지 넘어가 조개모듬을 먹었어요. 가리비와 조개, 새우를 직원분이 전부 구워 줬고 맛있게 먹었어요. 운전 때문에 술은 못 마셨어요.
- Canonical: https://jaemyeong.com/ko/blog/cheongsapo-suminine-20260321/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Cheongsapo, #grilled clams, #seafood
토요일에 가족과 광안리에 나갔다가 저녁은 청사포에서 먹기로 했어요. 저녁 6시쯤이었어요. 청사포는 제가 조개를 먹으러 자주 가는 동네이고, 가끔은 낚시를 하러 가기도 해요.
수민이네는 청사포의 조개구이집이에요. 지도 안내로는 블루라인파크 청사포 정거장 쪽 큰길로 내려가다가 오른쪽으로 가고, 큰 소나무에 닿기 전 옆 골목에서 다시 오른쪽으로 들어가요.
재미있는 일이 하나 있었어요. 제가 머리를 길게 탈색해서 그런지 직원분이 저를 외국인으로 본 것 같아요. 처음에는 외국어로 말을 걸었고, 제가 한국인인 것을 알고는 놀라셨어요.
조개모듬을 주문했어요. 한 접시에 가리비와 여러 가지 조개가 담겨 나와요. 굽는 것은 여성 직원분이 전부 해 줬어요. 전에 왔을 때도 그렇게 해 주셨어요.


저는 맛있게 먹었어요. 가족도 잘 먹은 것 같아요. 저는 운전을 해야 해서 술은 마시지 못했어요.
---
# Momos Coffee at Domoheon: crowded, but the garden is worth it
> Coming down from Hwangnyeongsan, my family and I stopped at the Momos Coffee in Domoheon. Two iced coffees, a madeleine, and a cookie. It was crowded.
- Canonical: https://jaemyeong.com/en/blog/suyeong-momos-coffee-domoheon-20260321/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #cafe, #coffee
My family and I had gone on an outing to Hwangnyeongsan and stopped here for a rest on the way down. It was about 4:30 on a Saturday afternoon. The Momos Coffee main store always has a lot of people waiting, which puts me off. So I chose the branch in Domoheon.
The cafe is on the first floor of the Domoheon main building. I parked in the Domoheon lot. This branch was crowded too, but by luck we got a seat in the corner.
We ordered two iced coffees, one madeleine, and one cookie.

My family enjoyed what we had.
As far as I know, Domoheon used to be the mayor's official residence. It is now open to the public. The building is well kept and the interior is nicely decorated. I thought it would be a good place for couples to stop by as well. We walked around the building and the garden, and from the garden you can see Gwangan Bridge in the distance.

---
# 모모스커피 도모헌점, 붐볐지만 건물과 정원이 볼 만했어요
> 황령산에서 내려오는 길에 가족과 도모헌에 있는 모모스커피에 들렀어요. 아이스커피 두 잔에 마들렌과 쿠키를 먹었고, 가족이 맛있어했어요. 사람이 많았어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-momos-coffee-domoheon-20260321/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #cafe, #coffee
가족과 황령산에 나들이를 갔다가 내려오는 길에 쉬어 가려고 들렀어요. 토요일 오후 4시 반쯤이었어요. 모모스커피 본점은 늘 기다리는 사람이 많아서 부담스러워요. 그래서 도모헌에 있는 지점을 골랐어요.
카페는 도모헌 본관 1층에 있어요. 차는 도모헌 주차장에 세웠어요. 이 지점도 사람이 많았는데, 운 좋게 구석 자리를 잡았어요.
아이스커피 두 잔과 마들렌 하나, 쿠키 하나를 시켰어요.

함께 간 가족은 맛있게 먹었어요.
도모헌은 예전에 시장 관사였던 곳으로 알고 있어요. 지금은 시민에게 열려 있어요. 건물 관리가 잘돼 있고 실내도 보기 좋게 꾸며져 있었어요. 연인끼리 들르기에도 좋은 곳이라고 생각했어요. 건물과 정원을 둘러봤는데, 정원 너머로 멀리 광안대교가 보여요.

---
# Sashimi at Makseoreo Hoetjip in Oncheonjang: better than expected
> A raw fish place found by chance in an alley after Dakdukkeobi. I expected little, but the sashimi and side dishes were in good shape. Prices are average.
- Canonical: https://jaemyeong.com/en/blog/oncheonjang-makseoreo-hoetjip-20260314/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #sashimi, #raw fish
It was about 7 on a Saturday evening, after chicken galbi at [Dakdukkeobi](/en/posts/oncheonjang-dakdukkeobi-20260314/). I have no regular raw fish place in Oncheonjang, so I looked around and found Makseoreo Hoetjip in an alley in Oncheonjang.
I did not expect much. I ordered the assorted sashimi.

The fish was in better condition than I expected. The side dishes, such as steamed egg, pancake, and greens for wraps, were fine too. The price range felt average.
It is a fair choice when you want a light sashimi meal. I would not go as far as telling someone who already has a favorite raw fish place to come here on purpose.
I ate in the restaurant. I have a feeling that it stopped dine-in service after that, but I am not sure. When I wrote this up, the delivery menu on Naver Map listed small flounder at 40,000 won and medium flounder at 55,000 won. Those are not the price of the sashimi I had that day.
---
# 온천장 막서러횟집의 모듬회, 기대보다 괜찮았어요
> 닭두꺼비에서 나와 골목에서 우연히 찾은 횟집이에요. 기대하지 않았는데 모듬회와 밑반찬의 상태가 좋았어요. 가격대는 보통이고, 가볍게 회를 먹고 싶을 때 갈 만해요.
- Canonical: https://jaemyeong.com/ko/blog/oncheonjang-makseoreo-hoetjip-20260314/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #sashimi, #raw fish
[닭두꺼비](/posts/oncheonjang-dakdukkeobi-20260314/)에서 닭갈비를 먹고 나온 토요일 저녁 7시쯤이었어요. 온천장에는 정해 두고 가는 횟집이 없어서 주변을 둘러보다가, 온천장 골목에서 막서러횟집을 발견했어요.
기대는 크지 않았어요. 모듬회를 시켰어요.

회는 기대했던 것보다 상태가 좋았어요. 계란찜과 전, 쌈 채소 같은 밑반찬도 괜찮았어요. 가격대는 보통 수준이라고 느꼈어요.
가볍게 회 한 끼를 먹고 싶을 때 고를 만한 집이에요. 다만 이미 즐겨 가는 횟집이 있는 분께 일부러 찾아가라고 권할 정도는 아니에요.
매장에서 먹었어요. 그 뒤로는 홀 영업을 하지 않는 것 같기도 한데, 확실하지 않아요. 글을 쓰면서 네이버 지도를 보니 배달 메뉴에 광어 소 40,000원, 광어 중 55,000원이 올라 있었어요. 이날 먹은 모듬회의 값은 아니에요.
---
# Chicken galbi at Dakdukkeobi in Oncheonjang: both kinds were good
> A chicken galbi place a friend who works nearby introduced. We had two servings each of the salt and the gochujang kind. Easy on the wallet, and both were good.
- Canonical: https://jaemyeong.com/en/blog/oncheonjang-dakdukkeobi-20260314/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #chicken, #barbecue
I met friends I have known since high school at about 5:30 on a Saturday evening. Dakdukkeobi was the first stop of the gathering. It is a chicken galbi restaurant introduced by a friend who works nearby and knows the places in Oncheonjang well.
It is next to Sarang Nursing Hospital, in front of the Bando parking lot, and across from Bando Apartments.
The menu says the chicken takes 10 minutes of pre-grilling and that orders start at three servings. We ordered two servings each of the salt chicken galbi and the gochujang chicken galbi. Both are 9,900 won. Greens for wraps, garlic, and several sauces are set out as standard.

The price was not a burden, and both kinds tasted good. I would suggest it to anyone who wants chicken galbi.
After eating here we went to [Makseoreo Hoetjip](/en/posts/oncheonjang-makseoreo-hoetjip-20260314/).
---
# 온천장 닭두꺼비의 닭갈비, 소금과 고추장 둘 다 맛있었어요
> 근처에서 일하는 친구의 소개로 고등학교 친구들과 간 닭갈비집이에요. 소금닭갈비와 고추장닭갈비를 2인분씩 먹었고, 값이 부담 없고 둘 다 맛있었어요.
- Canonical: https://jaemyeong.com/ko/blog/oncheonjang-dakdukkeobi-20260314/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #chicken, #barbecue
고등학교 때부터 알고 지낸 친구들과 토요일 저녁 5시 반쯤에 만났어요. 이날 모임의 첫 자리가 닭두꺼비였어요. 근처에서 일해서 온천장의 식당을 잘 아는 친구가 소개한 닭갈비 전문점이에요.
사랑의요양병원 옆이고 반도주차장 앞, 반도아파트 건너편이에요.
메뉴판에는 닭갈비 초벌에 10분이 걸리고 기본 3인분부터 주문한다고 적혀 있어요. 저희는 소금닭갈비와 고추장닭갈비를 2인분씩 시켰어요. 둘 다 9,900원이에요. 쌈 채소와 마늘, 여러 가지 소스가 기본으로 차려져요.

값이 부담스럽지 않았고, 두 가지 모두 맛있었어요. 닭갈비가 먹고 싶은 분께 권할 만해요.
이날은 여기에서 먹고 난 뒤 [막서러횟집](/posts/oncheonjang-makseoreo-hoetjip-20260314/)으로 갔어요.
---
# Yongwon Hoechojang, Jinhae: very fresh shrimp and octopus, low prices
> In Yongwon, Jinhae, we bought shrimp, abalone, and octopus at a seafood shop and took them upstairs across the road to be cooked. All of it was fresh.
- Canonical: https://jaemyeong.com/en/blog/jinhae-yongwon-hoechojang-20260909/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Changwon, #Jinhae, #seafood, #market, #shrimp
On a Wednesday morning I ran some errands with family, and afterwards we were hungry. I had heard you can eat grilled shellfish cheaply in Yongwon, Jinhae, so we drove over. We arrived at about 3 in the afternoon.
It turned out the grilled shellfish place had been torn down, so we dropped that plan. Instead we were pointed to a place that cooks what you buy at Miju Susan, a seafood shop. That place is Yongwon Hoechojang, on the second floor of the building across the road from the shop.
At Miju Susan we bought shrimp, abalone, and octopus. They gave us sea squirt for free. They were also selling mud crabs, which caught my eye. But the shrimp and octopus we had already picked looked like plenty, so I left the crab for another time.
At the restaurant, the sea squirt comes out with the basic table setting.
We had all of the shrimp salt-grilled.

I wished I had kept a few back to eat raw.


All of the seafood was very fresh. What we paid for it was low, and the charge for cleaning and steaming felt low too.
I was driving, so I chose a non-alcoholic beer. I was sorry to be limited in what I could drink.
Yongwon Hoechojang is open daily from 11 a.m. to 9 p.m., and Miju Susan daily from 5 a.m. to 8:30 p.m. Next time I would like to try the mud crab. Eleven days later [I did go back for it](/en/posts/jinhae-yongwon-hoechojang-20260920/).
---
# 진해 용원회초장, 수산집에서 산 새우와 문어가 싱싱했어요
> 조개구이를 먹으러 간 진해 용원에서 계획이 바뀌어, 미주수산에서 새우와 전복, 문어를 사서 길 건너 2층의 초장집으로 가져갔어요. 해산물이 싱싱했고 값도 부담이 적었어요.
- Canonical: https://jaemyeong.com/ko/blog/jinhae-yongwon-hoechojang-20260909/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Changwon, #Jinhae, #seafood, #market, #shrimp
수요일 오전에 가족과 볼일을 보고 나니 배가 고팠어요. 진해 용원에 가면 조개구이를 싸게 먹을 수 있다는 말을 듣고 차를 몰고 갔어요. 도착한 때는 오후 3시쯤이었어요.
그런데 조개구이집은 철거됐다고 해서 계획을 접었어요. 대신 미주수산에서 해산물을 사면 조리해 주는 곳으로 안내받았어요. 미주수산 맞은편, 길 건너 건물 2층에 있는 용원회초장이에요.
미주수산에서 새우와 전복, 문어를 샀어요. 멍게는 그냥 줬어요. 청게라고도 부르는 톱날꽃게도 팔고 있어서 눈길이 갔어요. 하지만 이미 고른 새우와 문어만으로도 양이 넉넉해 보여서 다음으로 미뤘어요.
초장집에서는 기본 상차림과 함께 멍게를 내 줘요.
새우는 모두 소금구이로 했어요.

몇 마리는 날것으로 남겨 둘 걸 하는 아쉬움이 있었어요.


해산물은 모두 아주 싱싱했어요. 산 값도 쌌고, 손질하고 찌는 값도 싸다고 느꼈어요.
운전을 해야 해서 저는 제로 맥주를 골랐어요. 마실 수 있는 것이 정해져 있다는 점은 아쉬웠어요.
용원회초장은 매일 오전 11시부터 밤 9시까지, 미주수산은 매일 새벽 5시부터 저녁 8시 반까지 해요. 다음에 가면 청게를 먹어 보고 싶어요. 열하루 뒤에 [실제로 청게를 먹으러 갔어요](/posts/jinhae-yongwon-hoechojang-20260920/).
---
# Raw beef at Minjeong Hanu Suyuk Gukbap in Yeonsan-dong: fresh and fair
> A second stop after Beolttejip, at a Korean beef soup place. The raw beef, 300 g for 20,000 won, was fresh, and the 9,000 won beef soup was good too.
- Canonical: https://jaemyeong.com/en/blog/yeonje-minjeong-hanu-suyuk-gukbap-20260311/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Yeonsan, #beef, #gukbap
This was the second stop after eating at [Beolttejip](/en/posts/yeonje-beolttejip-20260311/). I was with a close younger friend who lives in Haeundae, and it was about 7:30 on a Wednesday evening. Minjeong Hanu Suyuk Gukbap is a Korean beef soup restaurant in Yeonsan-dong. It seemed that people line up there from time to time.
It is a soup place, but I also like its mungtigi, raw beef. The mungtigi is 20,000 won for 300 g and the soup is 9,000 won, so both are quite easy on the wallet.

If you like mungtigi, I would recommend it. The meat was fresh.
The Korean beef soup comes in an earthenware pot. The soup was good too.

As for why the mungtigi is so fresh, my guess is that many guests mean the meat turns over quickly.
---
# 연산동 민정한우수육국밥의 뭉티기, 신선하고 값도 부담 없어요
> 벌떼집에서 나와 2차로 간 한우국밥집이에요. 300g에 2만 원인 뭉티기가 신선했고, 뚝배기에 나오는 9천 원짜리 한우국밥도 맛있었어요. 둘 다 값이 부담스럽지 않았어요.
- Canonical: https://jaemyeong.com/ko/blog/yeonje-minjeong-hanu-suyuk-gukbap-20260311/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Busan, #Yeonsan, #beef, #gukbap
[벌떼집](/posts/yeonje-beolttejip-20260311/)에서 먼저 먹고 2차로 옮긴 곳이에요. 해운대에 사는 친한 동생과 함께였고, 수요일 저녁 7시 반쯤이었어요. 민정한우수육국밥은 연산동에 있는 한우국밥집이에요. 줄을 서는 일이 종종 있는 것 같았어요.
간판은 국밥집이어도 저는 여기 뭉티기를 좋게 봐요. 300g 한 접시가 20,000원이고 국밥은 9,000원이라서, 둘 다 값이 꽤 부담 없는 편이에요.

뭉티기를 즐기는 분께는 추천해요. 받아 보니 고기 상태가 싱싱했어요.
국밥은 뚝배기째 나오고, 이것도 맛이 좋았어요.

뭉티기가 싱싱한 것은 찾는 사람이 많아 고기가 금방 나가서 그런 것이 아닐까 하고 짐작해요.
---
# Aged kimchi chicken stew at Chiaksan Matjip, Wonju: great kimchi
> On the way home from Yongpyong to Busan, I stopped in Wonju and had the small aged kimchi chicken stew with a friend. The kimchi was especially good.
- Canonical: https://jaemyeong.com/en/blog/wonju-chiaksan-matjip-20260308/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Wonju, #chicken, #stew
On the way home to Busan from the Yongpyong ski resort in Daegwallyeong, I stopped in Wonju. At about 6 on a Sunday evening I had dinner with a friend who used to live in Wonju.
Chiaksan Matjip Main Branch is a chicken stew restaurant in Dangye-dong, Wonju. The map description puts it across from the Dangye-dong Nonghyup. A large menu board hangs on the inside wall, with braised dishes, stews, hot pots, and stir-fries, plus meals such as hand-pulled dough soup, fried rice, and noodle soup. There was a lot to choose from.
I ordered the aged kimchi chicken stew, listed as the signature dish on the map, in the small size.

It was good. The aged kimchi was especially good. I had to drive, so I did not drink, but it was the kind of dish I would have wanted a drink with.
---
# 원주 치악산맛집의 묵은지 닭볶음탕, 묵은지가 특히 맛있었어요
> 용평에서 부산으로 돌아오는 길에 원주에 들러 친구와 묵은지 닭볶음탕 소를 먹었어요. 묵은지 맛이 좋아서, 운전만 아니었다면 술을 곁들이고 싶었어요.
- Canonical: https://jaemyeong.com/ko/blog/wonju-chiaksan-matjip-20260308/
- Published: 2026.09.27
- Category: 식기(食記)
- Tags: #Wonju, #chicken, #stew
대관령의 용평 스키장에서 부산 집으로 돌아오는 길에 원주에 들렀어요. 일요일 저녁 6시쯤, 원주에 살았던 친구와 저녁을 먹었어요.
들른 곳은 단계동의 치악산맛집 본점이고, 닭볶음탕을 전문으로 해요. 지도에는 단계동 농협 맞은편이라고 나와요. 가게 안 벽면을 큼직한 메뉴판이 차지하고 있어요. 찜, 탕, 전골, 볶음 종류에 더해 식사로 수제비와 볶음밥, 칼국수도 팔아서 선택지가 넓은 편이에요.
저희가 고른 것은 묵은지 닭볶음탕 소예요. 지도에도 이 집의 대표 메뉴로 적혀 있어요.

먹어 보니 만족스러웠고, 그중에서도 묵은지 맛이 돋보였어요. 차를 몰고 온 터라 술은 참았는데, 술 생각이 날 만한 맛이었어요.
---
# Seafood stew at Beolttejip in Yeonsan-dong: generous and fairly priced
> Dinner with a friend at a seafood stew place near Yeonsan Station. The pot was full of seafood, and we had raw squid too. Good value for what we paid.
- Canonical: https://jaemyeong.com/en/blog/yeonje-beolttejip-20260311/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Yeonsan, #seafood, #stew
I met a close younger friend who lives in Haeundae in Yeonsan-dong at about 6 on a Wednesday evening. Beolttejip is a seafood stew restaurant in an alley on the exit 4 side of Yeonsan Station.
The seafood stew comes in small, medium, and large, and the small is 25,000 won. The pot sits on a portable burner on the table and you eat while it boils.

The pot held a generous amount of seafood, and the mix was good.
We also ordered raw hanchi squid. It is a seasonal item, so my guess is that it may not be available out of season. The menu here changes often with what is in season.

For what we paid, I was very satisfied. Most of the seafood dishes and sashimi here are easy on the wallet and fairly fresh. I would suggest it to anyone looking for sashimi or seafood stew in Yeonsan-dong.
After the meal we moved on to [Minjeong Hanu Suyuk Gukbap](/en/posts/yeonje-minjeong-hanu-suyuk-gukbap-20260311/).
---
# 연산동 벌떼집의 해물탕, 해산물이 넉넉하고 값이 괜찮았어요
> 해운대에 사는 친구와 연산역 근처 해물탕집에서 저녁을 먹었어요. 냄비에 해산물이 넉넉히 들어 있었고, 한치회도 곁들였어요. 들인 돈에 비해 만족스러웠어요.
- Canonical: https://jaemyeong.com/ko/blog/yeonje-beolttejip-20260311/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Yeonsan, #seafood, #stew
해운대에 사는 친한 동생과 수요일 저녁 6시쯤에 연산동에서 만났어요. 벌떼집은 연산역 4번 출구 쪽 골목 안에 있는 해물탕 전문점이에요.
해물탕은 소, 중, 대가 있고 소가 25,000원이에요. 냄비를 테이블 위의 휴대용 버너에 올려 끓이면서 먹어요.

냄비 안에 해산물이 넉넉하게 들어 있었고 구성도 좋았어요.
한치회도 시켰어요. 계절 메뉴라서 철이 아닐 때는 없을 수도 있겠다고 짐작해요. 이 집은 제철 재료에 따라 메뉴 구성이 자주 바뀌어요.

들인 돈에 비해 만족이 컸어요. 이 집의 해산물 요리와 회는 대부분 값이 부담스럽지 않고 신선한 편이에요. 연산동에서 회나 해물탕을 먹을 곳을 찾는 분께 권할 만해요.
이날은 식사를 마치고 [민정한우수육국밥](/posts/yeonje-minjeong-hanu-suyuk-gukbap-20260311/)으로 자리를 옮겼어요.
---
# Joseph in Gwangalli: a happy night with Dongdong the dog
> A bar in Gwangalli where a friend and I went at about 9 at night to see Dongdong, a dog who sometimes visits. The owner called the dog over for us.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-joseph-20260501/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #bar, #dog
At about 9 on a Friday night, a friend and I went to Joseph as the last stop of the day. It is the cocktail and whisky bar in Gwangalli that [I also visited on April 21](/en/posts/gwangalli-joseph-20260421/). It is behind Pascucci, in front of the Haman parking lot.
This time the reason was a dog. A dog named Dongdong sometimes comes to this bar. My friend is not used to drinking but likes animals, so I wanted my friend to meet Dongdong.
When we arrived, Dongdong was not there. I said that was a pity, and the owner called Dongdong over.

My friend liked Dongdong too. The owner sat the dog on the seat beside us, and the sight of it sitting there made me laugh. The dog was heavier than I expected and hard to set down on the floor.
We drank while playing with Dongdong and ended the day in a good mood. [I went again in July](/en/posts/gwangalli-joseph-20260730/) and met Dongdong once more.
---
# 광안리 조셉, 강아지 동동이를 만나서 즐거웠던 밤이에요
> 가끔 가게에 오는 강아지 동동이를 보러 친구와 밤 9시쯤에 간 광안리의 바예요. 처음에는 동동이가 없었는데 사장님이 불러 주셨어요. 술을 마시며 하루를 즐겁게 마무리했어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-joseph-20260501/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #bar, #dog
금요일 밤 9시쯤, 친구와 이날의 마지막 일정으로 조셉에 갔어요. [4월 21일에도 왔던](/posts/gwangalli-joseph-20260421/) 광안리의 칵테일·위스키 바예요. 파스쿠찌 뒤편이고 함안주차장 앞쪽에 있어요.
이번에 온 까닭은 강아지예요. 이 가게에는 동동이라는 강아지가 가끔 와요. 친구는 술에는 익숙하지 않지만 동물을 좋아해서, 동동이를 보여 주고 싶었어요.
도착했을 때는 동동이가 없었어요. 아쉽다고 했더니 사장님이 동동이를 불러 주셨어요.

친구도 동동이를 좋아했어요. 사장님이 동동이를 저희 옆자리에 앉혀 주셨는데, 자리에 앉아 있는 모습에 웃음이 났어요. 생각보다 묵직해서 아래로 내려놓기가 힘들었어요.
동동이와 놀면서 술을 마셨고, 하루를 기분 좋게 끝냈어요. [7월에도 가서](/posts/gwangalli-joseph-20260730/) 동동이를 또 만났어요.
---
# Sashimi at Paldak Paldak Hoetjip in Jeonpo: fresh, thick slices
> A raw fish place in Jeonpo-dong I often go to. The assorted sashimi was fresh, and the price and portion were fair. A grilled flounder comes with it.
- Canonical: https://jaemyeong.com/en/blog/jeonpo-paldak-paldak-hoetjip-20260328/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Jeonpo, #sashimi, #raw fish
When I want sashimi in Jeonpo-dong, I often go to Paldak Paldak Hoetjip. At about 5 on a Saturday afternoon I went with friends, as the first stop of the gathering.
If you come late, the line can be long and the fish can run out. So it is better to come early.
We ordered the assorted sashimi. The menu says it is made up of flounder, rockfish, and mullet. The small size (for 2) starts at 43,000 won, and the largest is the extra large (for 5).

The fish was fresh. This place tends to cut its sashimi fairly thick. The price and the portion felt fair.
One grilled flounder comes along with it.

There was also rice for sushi at the back of the table. I vaguely remember that they sold yellowtail in winter.
I would recommend it as a place for sashimi in Jeonpo-dong. After eating here we went to [Ninano Jeonpo](/en/posts/jeonpo-ninano-20260328/).
---
# 전포동 팔딱팔딱횟집의 모듬회, 신선하고 도톰하게 썰어 줘요
> 전포동에서 회를 먹을 때 자주 가는 집이에요. 친구들과 첫 자리로 갔고, 모듬회는 신선했고 값과 양도 무난했어요. 가자미구이가 한 마리 같이 나와요.
- Canonical: https://jaemyeong.com/ko/blog/jeonpo-paldak-paldak-hoetjip-20260328/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Jeonpo, #sashimi, #raw fish
전포동에서 회를 먹을 때 저는 팔딱팔딱횟집에 자주 가요. 토요일 오후 5시쯤, 친구들과 이날 모임의 첫 자리로 갔어요.
늦게 가면 줄이 길 수 있고 재료가 떨어질 수도 있어요. 그래서 일찍 가는 편이 좋아요.
모듬회를 시켰어요. 메뉴판에는 광어와 우럭, 밀치로 구성한다고 적혀 있어요. 소(2인)가 43,000원부터이고 가장 큰 것은 특대(5인)예요.

회는 신선했어요. 이 집은 회를 비교적 두툼하게 썰어 주는 편이에요. 값과 양은 무난하다고 느꼈어요.
가자미구이가 한 마리 같이 나와요.

상 뒤쪽에는 초밥용 밥도 놓여 있었어요. 겨울에는 방어도 팔았던 것으로 어렴풋이 기억해요.
전포동에서 회를 먹을 곳으로 권할 만해요. 이날은 여기에서 먹고 난 뒤 [니나노 전포본점](/posts/jeonpo-ninano-20260328/)으로 갔어요.
---
# Pork cartilage ribs and udon at Rentan in Suyeong: a fair second stop
> A second stop after Jagalchi Hoetjip, at a briquette grill bar. The seasoned pork cartilage ribs had a smoky taste and were fine. I had udon too.
- Canonical: https://jaemyeong.com/en/blog/suyeong-rentan-20260324/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #grill, #bar
After eating at [Jagalchi Hoetjip](/en/posts/suyeong-jagalchi-hoetjip-20260324/), I moved to Rentan Suyeong for a second round. It was about 8:20 on a Tuesday night. It is a bar that mainly serves food grilled over coal briquettes, and I have come here several times for a second round.
I am told it is about a 3-minute walk from Suyeong Station.
Here I usually order the signature cuttlefish. Nights when I did have it are in [my August visit note](/en/posts/suyeong-rentan-20260827/) and [my September one](/en/posts/suyeong-rentan-20260919/). That day I chose the seasoned Korean pork cartilage ribs.

The taste was fair. Because it is grilled over briquettes, it has a smoky flavor. The grilled dishes here are mostly satisfying.
I also ordered the street-stall style udon. I finished the second round with those two dishes that day.

---
# 수영 렌탄의 오돌갈비와 우동, 2차로 무난했어요
> 자갈치횟집에서 나와 2차로 간 연탄구이 술집이에요. 한돈 양념 오돌갈비는 연탄 불맛이 났고 무난했어요. 포장마차 즉석우동도 같이 먹었고, 2차로 여러 번 간 집이에요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-rentan-20260324/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #grill, #bar
[자갈치횟집](/posts/suyeong-jagalchi-hoetjip-20260324/)에서 먼저 먹고 2차로 옮긴 곳이 렌탄 수영본점이에요. 화요일 밤 8시 20분쯤이었어요. 연탄으로 굽는 구이를 주로 내는 술집이고, 저는 2차로 여러 번 온 적이 있어요.
수영역에서 걸어서 3분쯤 걸린다고 해요.
이 집에 오면 평소에는 시그니처 갑오징어를 자주 시켜요. 갑오징어를 먹은 날은 [8월 방문 기록](/posts/suyeong-rentan-20260827/)과 [9월 방문 기록](/posts/suyeong-rentan-20260919/)에 있어요. 이날은 한돈 양념 오돌갈비를 골랐어요.

맛은 무난한 편이었어요. 연탄으로 구워서 불맛이 나요. 이 집의 구이 메뉴는 대체로 만족스러운 편이에요.
포장마차 즉석우동도 시켰어요. 이 두 가지로 이날의 2차를 마쳤어요.

---
# Udon at the Mandeok oden truck: cheap oden, an old street-stall feel
> After two stops in Suyeong, I stopped at the oden truck in Mandeok. I asked for udon with oden and rice cake in one bowl. The oden and rice cake were cheap.
- Canonical: https://jaemyeong.com/en/blog/mandeok-odeng-truck-20260309/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #oden, #street food
It was about 9 on a Monday night, and I was heading home after [Shanghai Tan](/en/posts/suyeong-shanghai-tan-20260309/) and [Haesam Meongge Malmijal](/en/posts/suyeong-haesam-meongge-malmijal-20260309/) in Suyeong. I wanted something to settle my stomach after drinking, so I stopped at the oden truck in Mandeok. I have used it many times.
The truck has no name of its own and is not registered on the map. It parks in front of the 24-hour Jeonju Myeongga bean sprout soup restaurant on Mandeok 2-ro, so I marked that restaurant on the map. It is close to Mandeok Station. "Mandeok Oden Truck" is simply what I call it.

I ordered udon and asked for oden and rice cake in the same bowl.

The oden and rice cake are cheap, so there is no burden. I also like that it still feels like an old street stall. My impression, though, is that it is not a place to expect much in the way of hygiene. There are tables where you can sit.
I stop by when it comes to mind on my way home. I have [a separate note from a visit in August](/en/posts/mandeok-odeng-truck-20260822/) as well.
---
# 만덕 오뎅트럭의 우동, 오뎅과 떡이 싸고 노점 느낌이 좋아요
> 수영에서 두 군데를 들르고 집에 가는 길에 해장을 하러 만덕의 오뎅트럭에 섰어요. 우동에 오뎅과 떡을 넣어 달라고 했어요. 오뎅과 떡은 값이 싸서 부담이 없었어요.
- Canonical: https://jaemyeong.com/ko/blog/mandeok-odeng-truck-20260309/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #oden, #street food
수영에서 [상하이탄](/posts/suyeong-shanghai-tan-20260309/)과 [해삼멍게말미잘](/posts/suyeong-haesam-meongge-malmijal-20260309/)을 거쳐 집으로 돌아가던 월요일 밤 9시쯤이었어요. 해장을 하고 싶어서 만덕의 오뎅트럭 앞에 섰어요. 만덕에서 여러 번 이용한 곳이에요.
이 트럭에는 따로 상호가 없고 지도에도 등록돼 있지 않아요. 만덕2로의 24시전주명가콩나물국밥 만덕점 앞에 서는 트럭이라서, 지도에는 그 국밥집을 표시해 두었어요. 만덕역에서 가까워요. «만덕 오뎅트럭»은 제가 부르는 이름이에요.

우동을 시키면서 오뎅과 떡을 한 그릇에 같이 넣어 달라고 했어요.

오뎅과 떡은 값이 싸서 부담이 없어요. 오래된 노점의 느낌이 남아 있는 것도 좋아요. 다만 위생까지 기대할 곳은 아니라는 것이 제 인상이에요. 앉을 수 있는 테이블도 있어요.
저는 집에 가는 길에 생각이 나면 여기에 들러요. [8월에 다시 간 기록](/posts/mandeok-odeng-truck-20260822/)도 따로 있어요.
---
# Seasonal seafood platter at Haesam Meongge Malmijal in Suyeong: fresh
> A second stop after Shanghai Tan, at a seasonal seafood bar. The seasonal platter was fresh, and the sea urchin in the dish I added had no fishy taste.
- Canonical: https://jaemyeong.com/en/blog/suyeong-haesam-meongge-malmijal-20260309/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #seafood, #bar
After the meal at [Shanghai Tan](/en/posts/suyeong-shanghai-tan-20260309/), I wanted seafood and looked for a place nearby. That is how we came to Haesam Meongge Malmijal for a second round. I was with a friend I have known since high school, and it was about 6:30 on a Monday evening. I wrote about going back in August in [my August visit note](/en/posts/suyeong-haesam-meongge-malmijal-20260815/).
It is a bar in Suyeong that serves seasonal seafood, set inside an alley. The room was tidy, and I thought it would suit couples as well.
We ordered the seasonal platter first.

The seafood was fresh and tasted good. Then we added the purple sea urchin, sweet shrimp, and avocado.

The sea urchin had no fishy taste, and I enjoyed it. Both dishes are worth recommending, though the seasonal platter suited me better.
On the way home that night I made one more stop, at [the Mandeok oden truck](/en/posts/mandeok-odeng-truck-20260309/).
---
# 수영 해삼멍게말미잘의 제철 모듬, 신선하고 맛있었어요
> 상하이탄에서 나와 고등학교 친구와 2차로 간 제철 해산물 술집이에요. 제철 모듬이 신선했고, 추가한 성게·단새우·아보카도는 성게가 비리지 않아 좋았어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-haesam-meongge-malmijal-20260309/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #seafood, #bar
[상하이탄](/posts/suyeong-shanghai-tan-20260309/)에서 식사를 마치고, 해산물이 먹고 싶어서 근처에서 가게를 찾았어요. 그렇게 2차로 간 곳이 해삼멍게말미잘이에요. 고등학교 때부터 알고 지낸 친구와 함께였고, 월요일 저녁 6시 반쯤이었어요. 8월에 다시 간 날은 [8월 방문 기록](/posts/suyeong-haesam-meongge-malmijal-20260815/)에 적었어요.
수영에 있는, 제철 해산물을 내는 술집이에요. 골목 안쪽에 있어요. 실내가 깔끔해서 연인끼리 오기에도 어울리겠다고 생각했어요.
먼저 제철 모듬을 시켰어요.

재료가 신선했고 맛도 좋았어요. 이어서 보라성게 우니&단새우&아보카도를 추가했어요.

성게는 비린 느낌이 없어서 맛있게 먹었어요. 두 가지 모두 권할 만한데, 제 입에는 제철 모듬이 더 맞았어요.
이날은 집으로 돌아가는 길에 [만덕 오뎅트럭](/posts/mandeok-odeng-truck-20260309/)에 한 번 더 들렀어요.
---
# Lamb skewers at Shanghai Tan in Suyeong: cheap and good, tricky fire
> A lamb skewer place by Suyeong Station I have visited since it opened. Skewers, mala skewers, lamb ribs, and egg fried rice with a friend. Easy on the wallet.
- Canonical: https://jaemyeong.com/en/blog/suyeong-shanghai-tan-20260309/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #lamb, #skewers
Shanghai Tan is a lamb skewer restaurant I have been to several times since its early days. At about 5 on a Monday afternoon, a friend and I went there as the first stop of the day. It is on the first floor of the building right to the left as you come out of exit 7 of Suyeong Station.
If you come late, there is often a line. If you want a quiet meal, it is better to arrive early.
## Skewers and ribs
We ordered lamb skewers and mala lamb skewers. The mala ones have seasoning on the outside, so you can tell them apart.

The lamb skewers were very good. The lamb ribs are grilled on a wire grill.

The fire is hard to manage. Fat drips from the skewers and flames can flare up.
## Egg fried rice
The egg fried rice was good too.

I did not note the price of each dish, but the meal felt easy on the wallet. From here we moved on to [Haesam Meongge Malmijal](/en/posts/suyeong-haesam-meongge-malmijal-20260309/).
---
# 수영 상하이탄의 양꼬치, 값이 싸고 맛있는데 불 조절이 어려워요
> 개업 초기부터 여러 번 간 수영역 앞 양꼬치집이에요. 친구와 양꼬치, 마라양꼬치, 양갈비에 계란볶음밥을 먹었어요. 값은 부담이 적고, 기름 때문에 불이 붙기도 해요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-shanghai-tan-20260309/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #lamb, #skewers
상하이탄은 문을 연 초기부터 여러 번 간 양꼬치 전문점이에요. 월요일 오후 5시쯤, 친구와 이날의 첫 자리로 갔어요. 수영역 7번 출구를 나오면 바로 왼쪽 건물의 1층이에요.
늦게 가면 줄을 서는 일이 잦아요. 한가하게 먹고 싶다면 일찍 가는 것이 좋아요.
## 양꼬치와 양갈비
양꼬치와 마라양꼬치를 시켰어요. 마라양꼬치는 겉에 양념이 묻어 있어서 구분이 돼요.

양꼬치는 아주 맛있었어요. 양갈비는 불판에 올려 구워요.

불 조절은 까다로워요. 꼬치에서 기름이 떨어지면서 불이 붙기도 해요.
## 계란볶음밥
계란볶음밥도 맛있었어요.

메뉴별 가격은 적혀 있지 않지만, 먹고 나서 느끼기에 값은 부담이 적었어요. 이날은 여기에서 나와 [해삼멍게말미잘](/posts/suyeong-haesam-meongge-malmijal-20260309/)로 자리를 옮겼어요.
---
# Fresh pork makchang at Taeyoung in Dongnae: no off smell, nice chew
> On a friend's day off we went to a makchang place in Dongnae. The fresh pork makchang had no off smell and a good chew, and the staff grilled all of it.
- Canonical: https://jaemyeong.com/en/blog/dongnae-taeyoung-saengmakchang-20260310/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #makchang, #pork
A friend I have known since university had a day off, so we decided to eat together. At about 4:40 on a Tuesday afternoon we went to Taeyoung Saengmakchang in Dongnae. I think I found the place on social media, but I do not remember for sure.
I like makchang, grilled pork intestine. My friend is not very fond of makchang or beef tripe. Looking back, I think I mostly went to beef tripe places in Dongnae.
The restaurant is on the exit 4 side of Dongnae Station. I was told to face the main gate of Myeongnyun Xi and take the alley on the left. There were no other guests when we went.
When I want makchang in Dongnae, this is the place I plan to choose. That day we ordered the fresh pork makchang, the signature dish. The staff did all of the grilling.

The makchang had no unpleasant smell and a nice, springy chew. We had soju with it.
I do not know what it is like when the place is busy.
---
# 동래 태영생막창의 돼지생막창, 잡내 없이 쫄깃했어요
> 친구가 쉬는 날에 동래의 막창집에 같이 갔어요. 대표 메뉴인 돼지생막창은 냄새가 없고 쫄깃했고, 직원분이 전부 구워 줘서 편했어요. 동래에서 막창이 생각나면 갈 집이에요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-taeyoung-saengmakchang-20260310/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #makchang, #pork
대학 때부터 알고 지낸 친구가 쉬는 날이어서 같이 밥을 먹기로 했어요. 화요일 오후 4시 40분쯤에 태영생막창 동래점에 갔어요. 이 가게는 SNS에서 보고 알게 됐던 것 같은데 기억이 확실하지는 않아요.
저는 막창을 좋아해요. 친구는 막창이나 양곱창을 그리 좋아하지 않아요. 돌이켜 보면 동래에서는 주로 양곱창집에 갔던 것 같아요.
가게는 동래역 4번 출구 쪽에 있어요. 명륜자이 정문을 마주 보고 왼쪽 골목으로 들어가면 된다고 들었어요. 저희가 갔을 때는 다른 손님이 없었어요.
동래에서 막창이 먹고 싶어지면 이 집을 고를 생각이에요. 이날은 대표 메뉴인 돼지생막창을 시켰어요. 굽는 것은 직원분이 전부 해 줬어요.

막창은 불쾌한 냄새가 없었고 씹는 맛이 쫄깃해서 좋았어요. 소주도 같이 마셨어요.
손님이 많을 때는 어떨지 모르겠어요.
---
# Makssereo Hoetjip, Oncheonjang: a fair platter with gizzard shad
> A sashimi place in Oncheonjang where three of us went after hagfish. We ordered the medium seasonal platter with gizzard shad. Fish and price were average.
- Canonical: https://jaemyeong.com/en/blog/oncheonjang-makssereo-hoetjip-20260925/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #sashimi, #seafood
After a little hagfish at [Wonjo Halmaejip](/en/posts/oncheonjang-wonjo-halmaejip-gomjangeo-20260925/), the three of us moved to a second place at about 7:30 on Friday evening. I thought the friend visiting from another city would be glad to have gizzard shad, so we chose a sashimi restaurant.
This is the place I meant to go to [last month, when I ended up at Namhae Hoetjip next door](/en/posts/oncheonjang-namhae-hoetjip-20260823/). Both then and now I could not see gizzard shad in the tank and wondered if it had sold out. This time I asked the owner, was told they had it, and went in. The tanks are out front by the entrance.
I am not that fond of gizzard shad. So rather than ordering it alone, I ordered it with the seasonal platter. At first I had the small in mind. The owner asked whether that would be enough for three, and I explained that we had already eaten. In the end we went with the medium, as my friend suggested.
Side dishes and seaweed soup came first, and the sashimi arrived while we were eating those.

The sashimi was fair, with nothing especially memorable. The price felt average too. I do not think I will come back here. It is open daily from 2 p.m. to 2 a.m.
When I judge a sashimi place, I have a habit of measuring its price, taste, and portions against [Kalmat Hoetjip near Pusan National University](/en/posts/jangjeon-kalmat-hoetjip-20260717/). I still have no regular sashimi place in Oncheonjang, so I plan to try others.
---
# 온천장 막썰어횟집, 전어를 넣은 모듬회는 무난했어요
> 곰장어를 먹고 나서 친구 둘과 간 온천장의 횟집이에요. 수조에 전어가 안 보여서 사장님께 여쭤보고 들어갔고, 계절모듬회와 전어를 중으로 시켰어요. 회도 값도 보통이었어요.
- Canonical: https://jaemyeong.com/ko/blog/oncheonjang-makssereo-hoetjip-20260925/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #sashimi, #seafood
[원조할매집산곰장어](/posts/oncheonjang-wonjo-halmaejip-gomjangeo-20260925/)에서 곰장어를 조금 먹고 나서, 금요일 저녁 7시 반쯤에 셋이서 두 번째 가게로 옮겼어요. 다른 도시에서 온 친구가 전어를 반가워할 것 같아서 횟집으로 정했어요.
여기는 [지난달에 옆집 남해횟집에 갔을 때](/posts/oncheonjang-namhae-hoetjip-20260823/)도 가려고 했던 집이에요. 그때도 이번에도 수조에서 전어가 보이지 않아서 다 팔렸나 했어요. 이번에는 사장님께 여쭤봤더니 있다고 해서 들어갔어요. 입구 앞쪽에 수조가 있어요.
저는 전어를 그리 좋아하지 않아요. 그래서 전어만 시키지 않고 계절모듬회와 같이 시켰어요. 처음에는 소를 생각했어요. 사장님이 셋이 먹기에 적지 않겠느냐고 물어서, 앞에서 먹고 왔다고 말씀드렸어요. 결국 친구의 말을 따라 중으로 정했어요.
밑반찬과 미역국이 먼저 나왔고, 먹고 있으니 회가 나왔어요.

회는 특별히 기억에 남는 점 없이 무난했어요. 값도 보통이라고 느꼈어요. 이 집에 다시 가지는 않을 것 같아요. 영업은 매일 오후 2시부터 다음 날 새벽 2시까지예요.
저는 횟집을 볼 때 값과 맛, 양을 [부산대 앞 칼맛나는푸짐한횟집](/posts/jangjeon-kalmat-hoetjip-20260717/)에 견줘 보는 버릇이 있어요. 온천장에는 아직 단골 횟집이 없어서 다른 집도 찾아볼 생각이에요.
---
# Wonjo Halmaejip hagfish, Oncheonjang: my favorite in Busan
> A hagfish restaurant in Oncheonjang where old friends met on Chuseok evening. They grill it over briquettes by the door; we had it salted and marinated.
- Canonical: https://jaemyeong.com/en/blog/oncheonjang-wonjo-halmaejip-gomjangeo-20260925/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #hagfish, #grilled, #seafood
At about 6:30 on Friday evening, the day of Chuseok, I got together with friends for the holiday. They are friends I have known since high school, including one who had come down from another city for Chuseok. The restaurant is one friend's regular spot. That friend is close with the owner too. We come here on regular days too.
Even on a holiday evening it was nearly full. It seems to be a popular place in this neighborhood.
The hagfish is grilled over coal briquettes in a brazier by the entrance and then brought to you. You eat it right away without grilling it further at the table. We had two servings salt-grilled and two servings marinated. We kept it short that night and went on to [Makssereo Hoetjip](/en/posts/oncheonjang-makssereo-hoetjip-20260925/) for a second round.


Of the hagfish restaurants I have tried in Busan, this is the one I like best. I do feel that hagfish has gotten more expensive lately. Around the time I wrote this visit up on my Naver blog, the map listing showed 30,000 won for a small and 60,000 won for the top size (teuk). I usually have fried rice with it.
I know about the promotion where Chamisul caps come printed with MBTI types, so I check every time for INTJ, which is mine. That night the caps were ISTP and INTP. These days many caps have no letters at all, and I wonder if the promotion has ended.
---
# 온천장 원조할매집산곰장어, 제가 가장 좋아하는 곰장어예요
> 추석 당일 저녁에 오랜 친구들과 모인 온천장의 곰장어집이에요. 입구의 연탄 화로에서 다 구워서 내 주고, 소금구이와 양념구이를 2인분씩 먹었어요. 부산에서 제가 가장 좋아하는 곰장어집이에요.
- Canonical: https://jaemyeong.com/ko/blog/oncheonjang-wonjo-halmaejip-gomjangeo-20260925/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #hagfish, #grilled, #seafood
추석 당일인 금요일 저녁 6시 반쯤, 명절을 맞아 친구들과 모였어요. 고등학교 때부터 알고 지낸 친구들이고, 다른 도시에서 추석에 내려온 친구도 있었어요. 가게는 한 친구의 단골집이에요. 그 친구는 사장님과도 가깝게 지내요. 저희가 평소에도 들르는 곳이에요.
명절 저녁인데도 자리가 거의 다 찰 만큼 손님이 많았어요. 이 동네에서 인기 있는 집인 것 같아요.
곰장어는 입구에 있는 화로에서 연탄불로 구운 다음에 가져다줘요. 상에서 더 굽지 않고 바로 먹으면 돼요. 소금구이 2인분과 양념구이 2인분을 먹었어요. 이날은 짧게 먹고 일어나서 2차로 [막썰어횟집](/posts/oncheonjang-makssereo-hoetjip-20260925/)에 갔어요.


제가 부산에서 먹어 본 곰장어집 가운데 이 집이 가장 좋아요. 다만 요즘 곰장어 값이 올랐다고 느껴요. 네이버 블로그에 이 방문을 적을 무렵, 지도에 올라온 가격은 소가 30,000원이고 특이 60,000원이었어요. 평소에는 볶음밥도 곁들여요.
저는 참이슬 뚜껑에 MBTI가 적혀 나오는 행사를 알고 있어서, 제 유형인 INTJ가 나오는지 매번 확인해요. 이날 나온 것은 ISTP와 INTP였어요. 요즘은 글자가 없는 뚜껑이 많아서 행사가 끝난 것인지 궁금해요.
---
# OK Hoe Pocha in Mandeok: they cut us the gizzard shad they forgot
> A sashimi place in Mandeok I often visit with family. The gizzard shad was left off our platter, so they sliced one for us separately at no charge.
- Canonical: https://jaemyeong.com/en/blog/mandeok-ok-hoepocha-20260908/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #sashimi, #seafood, #family
This is a sashimi restaurant I have been to many times with family. Now and then, on a day when I have nothing else on, we time it to when a family member gets off work and have sashimi together. This visit was at about 7 on a Tuesday evening. It is close to Mandeok Station.
We ordered the assorted sashimi. They said they would put a little gizzard shad on it. But the person cutting the fish forgot, and the platter came without it.

When my family member said that was a pity, the person serving us sliced one gizzard shad separately and brought it over. They did not charge for it. The sashimi was very fresh. The prices are reasonable and the people who work there are kind, so it is a sashimi place I would recommend to anyone who lives near Mandeok.
We were having a few glasses of soju with the sashimi when grilled fish came out. So we drank a little more.

We finished with the spicy fish stew and went home.
---
# 만덕 오케이회포차, 빠진 전어를 한 마리 따로 썰어 줬어요
> 가족과 여러 번 간 만덕의 횟집이에요. 화요일 저녁에 모듬회를 먹었는데, 넣어 주기로 한 전어가 빠져서 한 마리를 따로 썰어 줬어요. 생선구이와 매운탕까지 먹고 왔어요.
- Canonical: https://jaemyeong.com/ko/blog/mandeok-ok-hoepocha-20260908/
- Published: 2026.09.26
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #sashimi, #seafood, #family
가족과 여러 번 간 횟집이에요. 가족의 퇴근 시간에 맞춰서, 제게 다른 일이 없는 날에 가끔 같이 회를 먹어요. 이날은 화요일 저녁 7시쯤에 갔어요. 만덕역에서 가까워요.
모듬회를 시켰어요. 전어를 조금 넣어 주겠다는 안내를 받았어요. 그런데 회를 뜨는 분이 깜빡해서 전어 없이 모듬회가 나왔어요.

같이 간 가족이 아쉬워하자, 응대하던 분이 전어 한 마리를 따로 회로 떠서 줬어요. 값은 받지 않았어요. 회는 아주 싱싱했어요. 값이 크게 부담스럽지 않고 일하는 분들이 친절해서, 만덕 근처에 사는 분께 권할 만한 횟집이에요.
회에 소주를 몇 잔 마시고 있는데 생선구이가 나왔어요. 그래서 술을 조금 더 마셨어요.

마지막으로 매운탕까지 먹고 집에 왔어요.
---
# Zennin in Dongnae: a low-cost five-pick set with gyoza and hot pot
> An izakaya in Dongnae I kept seeing on Instagram. A college friend and I ordered the 42,900 won special set and chose a hot pot, grilled fish, and gyoza.
- Canonical: https://jaemyeong.com/en/blog/dongnae-zennin-20260907/
- Published: 2026.09.25
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #izakaya, #dumpling, #bar
I have a friend from college, and we tend to get in touch around the time my friend leaves work. That is how we met up this Monday evening too. I had seen this izakaya on Instagram several times and was curious. Above all, I wanted to try the gyoza.
It is near Dongnae Station. There is a large teppan griddle inside. The room was nearly empty when we walked in, and we were the first customers of the day.
We ordered the special five-pick set, which is 42,900 won. You choose five items, and a hot pot counts as two. So we chose four things: a hot pot, grilled Patagonian toothfish, tako wasabi, and gyoza.


It suits anyone who wants an izakaya without a big bill. It is open all year, and from Monday to Thursday it opens at 5:30 p.m. For parking they point you to the public lot at Dongnae Station.
The set let us try several dishes without spending much. The à la carte prices seemed low as well, and the food off the griddle tasted good.
---
# 동래 젠닌, 5종 세트로 부담 없이 교자와 탕을 먹었어요
> 인스타그램에서 여러 번 보고 교자가 궁금해서 대학 동기와 월요일 저녁에 간 동래의 이자카야예요. 42,900원짜리 스페셜 5종 세트로 탕과 메로구이, 타코와사비, 교자를 골랐어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-zennin-20260907/
- Published: 2026.09.25
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #izakaya, #dumpling, #bar
대학 동기인 친구와는 친구의 퇴근 시간에 맞춰 연락해서 만나곤 해요. 이 월요일 저녁에도 그렇게 만났어요. 인스타그램에서 여러 번 본 이자카야가 궁금했고, 그중에서도 교자를 먹어 보고 싶었어요.
동래역 근처에 있어요. 안에는 큰 철판이 놓여 있어요. 들어갔을 때는 손님이 거의 없었고, 저희가 그날 첫 손님이었어요.
스페셜 5종 세트를 시켰어요. 42,900원이에요. 다섯 가지를 고르는 세트인데 탕은 두 개 몫으로 쳐요. 그래서 탕과 메로구이, 타코와사비, 교자 이렇게 네 가지를 골랐어요.


돈을 많이 쓰지 않고 이자카야에 가고 싶은 분께 맞는 집이에요. 연중무휴이고, 월요일부터 목요일까지는 오후 5시 반에 열어요. 주차는 동래역 공영주차장을 쓰라고 안내해요.
세트 덕분에 큰 부담 없이 여러 음식을 먹어 볼 수 있었어요. 단품 가격도 낮다고 느꼈고, 철판에서 만든 음식이 맛있었어요.
---
# Uri Dongne Jeonbokjip: the abalone and shrimp course fell short
> An abalone restaurant in Mandeok. A friend and I had the abalone and shrimp course for two. It tasted fine, but it did not feel worth 84,000 won.
- Canonical: https://jaemyeong.com/en/blog/mandeok-uri-dongne-jeonbokjip-20260906/
- Published: 2026.09.24
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #seafood, #abalone, #shrimp
It was a Sunday, and I had been resting at home when I felt like having a drink with a friend. We went to an abalone restaurant I had been curious about since it opened but had kept putting off. It is over by Mandeok Station.
We went in at about 5:20 p.m., not long after it opened. Nearly every table was reserved, and we took the one round table left.
We ordered the course of abalone and shrimp for two. It is 42,000 won per person, so 84,000 won for the two of us. Most of the shrimp in the tank looked dead to me. I briefly thought about switching to the abalone course (jeonbok-kase), but stayed with the original order.
First came salt-grilled shrimp, raw shrimp, and raw abalone.


My guess was that they picked the shrimp in good condition to serve raw. Butter-grilled abalone came next, and the last dish was abalone soup.

The abalone was not especially large, just ordinary in size. It was decent to eat and tasted fine. The owners were kind, which left a good impression. The two of them seemed to be a married couple.
Customers who came after us could not order shrimp. It is open daily from 5 p.m. to 1 a.m., with last orders at 11:30 p.m. For parking you have to use a paid lot nearby.
Still, for 84,000 won it was a letdown, and I have not decided whether I will go back. I even wondered if we should have gone to a sashimi restaurant. A few days earlier a family member had brought home fresh abalone, which we boiled and ate, and the difference felt large. Was that memory the reason I was less satisfied? I am not sure myself.
---
# 만덕 우리동네전복집, 전새카세는 값에 비해 아쉬웠어요
> 일요일 오후 5시 20분쯤 친구와 간 만덕의 전복 전문점이에요. 새우와 전복이 차례로 나오는 전새카세를 2인분 시켰어요. 맛은 괜찮았고 사장님도 친절했지만 84,000원에는 못 미쳤어요.
- Canonical: https://jaemyeong.com/ko/blog/mandeok-uri-dongne-jeonbokjip-20260906/
- Published: 2026.09.24
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #seafood, #abalone, #shrimp
집에서 쉬다가 친구와 한잔하고 싶어져서 나간 일요일이에요. 문을 열었을 때부터 궁금했는데 가는 것을 미뤄 온 전복 전문점으로 갔어요. 만덕역 쪽에 있어요.
오후 5시 20분쯤, 문을 연 지 얼마 안 된 때에 들어갔어요. 자리는 거의 다 예약이 잡혀 있었고, 하나 남은 둥근 테이블에 앉았어요.
전복과 새우가 차례로 나오는 전새카세를 2인분 시켰어요. 한 사람에 42,000원이라 둘이서 84,000원이에요. 수조의 새우는 대부분 죽은 듯 보였어요. 그래서 전복카세로 바꿀까 잠깐 생각했지만, 원래대로 시켰어요.
먼저 새우 소금구이와 새우회, 전복회가 나왔어요.


상태가 좋은 새우를 골라서 회로 낸 것이 아닐까 하고 짐작했어요. 그다음에 전복버터구이가 나왔고, 마지막은 전복탕이었어요.

전복은 아주 크지는 않았고 보통 크기였어요. 먹을 만했고 맛도 괜찮았어요. 사장님의 응대도 친절해서 인상이 좋았어요. 두 분은 부부인 것 같았어요.
저희 뒤에 온 손님부터는 새우를 주문할 수 없었어요. 영업은 매일 오후 5시부터 다음 날 새벽 1시까지이고 주문은 밤 11시 반에 마감해요. 주차는 근처 유료 주차장을 써야 해요.
다만 84,000원을 낸 것에 비하면 아쉬웠고, 다시 갈지는 아직 정하지 못했어요. 다른 횟집에 갈 것을 그랬나 하는 생각도 했어요. 며칠 전에 가족이 가져온 싱싱한 전복을 집에서 삶아 먹었는데, 그것과 차이가 크게 느껴졌어요. 그 기억 때문에 덜 만족스러웠던 것일까요. 저도 잘 모르겠어요.
---
# Sambari in Dongnae: good grilled octopus and shrimp, not cheap
> I have been here many times. The staff grill the octopus over charcoal and clean the shrimp for you. It is not cheap, but I was happy with the food again.
- Canonical: https://jaemyeong.com/en/blog/dongnae-sambari-20260604/
- Published: 2026.09.24
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #seafood, #bar
The Sambari main branch is a place I had already visited several times. This time a friend and I went on a Thursday evening, a little before eight. It is a seafood bar near Dongnae Station that also serves grilled clams.
What I like here is the charcoal-grilled octopus and the shrimp. We had both that day.
## They grill and clean it for you
The staff put the seafood on the charcoal one item at a time, grill it, and clean it for you.

The raw shrimp comes to the table already peeled.

The shrimp heads are served separately.
## Price and crowd
The price range is not low. I was still happy with the taste this time, and I plan to go again. I remember standing in a long line here in the past, but these days there seemed to be somewhat fewer guests. That is only my impression.
We finished with ramyeon.

---
# 동래 삼바리의 낙지와 새우, 맛은 좋았지만 가격은 부담스러웠어요
> 여러 번 간 집이에요. 직원이 숯불에 낙지를 구워 주고 새우도 손질해 줘요. 가격대는 낮지 않지만 맛에는 이번에도 만족했어요. 목요일 저녁에 친구와 다녀온 기록이에요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-sambari-20260604/
- Published: 2026.09.24
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #seafood, #bar
삼바리 본점은 전에도 여러 번 갔던 집이에요. 이번에는 목요일 저녁 여덟 시가 조금 안 된 시간에 친구와 갔어요. 동래역 근처에 있는, 조개구이도 하는 해산물 술집이에요.
제가 이 집에서 좋아하는 것은 낙지숯불구이와 새우예요. 이날 그 두 가지를 먹었어요.
## 구워 주고 손질해 주는 집
해산물은 직원이 숯불에 올려 하나씩 차례로 구워 주고 손질까지 해 줘요.

생새우는 껍질을 벗긴 상태로 접시에 담겨 나와요.

새우 머리는 따로 내줘요.
## 가격과 손님 수
가격대는 낮지 않아요. 그래도 맛에는 이번에도 만족했고, 또 갈 생각이에요. 예전에는 줄을 길게 섰던 기억이 있는데, 요즘은 손님이 조금 줄어든 것처럼 보였어요. 제가 받은 인상일 뿐이에요.
마지막에는 라면을 먹었어요.

---
# Grilled chicken platter at Isegye in Seomyeon after an hour in line
> A friend and I had the chicken platter and a grilled sticky rice ball in Seomyeon. The staff grill everything. The long wait is the one drawback.
- Canonical: https://jaemyeong.com/en/blog/seomyeon-isegye-20260514/
- Published: 2026.09.24
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #grilled chicken, #bar
On a Thursday evening I met a friend in Seomyeon for a drink, timed to when my friend got off work. We went to Isegye, a restaurant that specializes in chicken. It is close to exit 8 of Seomyeon Station.

Many people wait for a table here. As I remember it, we waited about an hour that day.
At the charcoal grill in front of the table, the staff explain every dish and grill it for you, so guests can simply eat.
## The platter and the rice ball
We ordered the medium assorted platter.

We added the grilled sticky rice ball later.

The chicken was good overall and tasted clean. I have been to a number of yakitori places in Seomyeon and Dongnae, and compared with those, this one felt easier on the wallet and tasted good too.
I was happy with the taste and the price. The only thing that makes me hesitate is the long wait. On a day when I can accept that, I would like to go often.
---
# 서면 이세계의 닭 모듬구이, 한 시간쯤 기다려서 먹었어요
> 퇴근한 친구와 서면에서 닭 모듬구이와 구운 찰주먹밥을 먹었어요. 직원이 화로에서 전부 구워 줘서 편했고, 긴 대기만 감수하면 자주 가고 싶은 집이에요.
- Canonical: https://jaemyeong.com/ko/blog/seomyeon-isegye-20260514/
- Published: 2026.09.24
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #grilled chicken, #bar
목요일 저녁, 친구가 퇴근하는 시간에 맞춰 서면에서 만나 술을 한잔했어요. 간 곳은 닭요리 전문점인 이세계예요. 서면역 8번 출구에서 가까워요.

기다리는 손님이 많은 집이에요. 이날은 한 시간쯤 기다린 것으로 기억해요.
테이블 앞 화로에서 직원이 모든 메뉴를 설명해 주면서 직접 구워 줘요. 손님은 굽는 데 신경을 쓰지 않고 먹기만 하면 돼요.
## 모듬구이와 구운 찰주먹밥
모듬구이는 중 크기로 시켰어요.

구운 찰주먹밥은 나중에 추가로 주문했어요.

닭요리는 전체적으로 맛있었고 깔끔했어요. 서면과 동래에서 야키토리 가게를 여러 곳 다녀 봤는데, 그 경험에 비추어 보면 이 집은 가격 부담이 덜하고 맛도 좋은 편이었어요.
맛과 가격에는 만족했어요. 망설여지는 것은 긴 대기 하나예요. 그 시간을 감수할 수 있는 날이라면 자주 가고 싶어요.
---
# Squid and cuttlefish were the best part at Eobuui Sulsang, Songjeong
> Two of us ordered the scallop and red snow crab set and the squid and cuttlefish plate. Both squid dishes were good. The crab had less meat than I hoped.
- Canonical: https://jaemyeong.com/en/blog/songjeong-eobuui-sulsang-20260510/
- Published: 2026.09.24
- Category: 식기(食記)
- Tags: #Busan, #Songjeong, #seafood, #scallop
I wanted bigfin reef squid, so a friend and I went to Songjeong. It was a Sunday evening, shortly after the place opened, and there were no other guests yet. The staff still seemed to be setting up. The sea is visible in front of the restaurant, and the view was good.
We ordered plenty for two men. The set of Hokkaido scallops and steamed red snow crab was 89,000 won, and the bigfin reef squid with steamed cuttlefish was 80,000 won.
## Scallops and red snow crab
You grill the scallops yourself on the table grill. They were fresh and tasted good.

The steamed red snow crab was fresh and tasty as well. For its size, though, there was not much meat to eat, and that was a letdown.

## Bigfin reef squid and steamed cuttlefish
The bigfin reef squid was the reason for the trip. It was as good as I had hoped.

The steamed cuttlefish had an especially good chew. I was happy with both squid dishes.

We did not order sea squirt, but it came on the house.

## Price and setting
The prices are on the high side. Still, the seasonal seafood is fresh and the sea is right in front of the restaurant, so I thought it would suit people looking for fresh seafood, or a couple who want to eat with a view. The menu changes with the season.
There are outdoor tables, so I guessed that eating outside is possible when the weather is right. I plan to go once more for the bigfin reef squid.
---
# 송정 어부의 술상, 무늬오징어와 갑오징어찜이 만족스러웠어요
> 친구와 둘이서 가리비·붉은대게찜 세트와 무늬오징어·갑오징어찜을 시켰어요. 오징어 두 가지는 만족스러웠고, 대게는 먹을 살이 적어 아쉬웠어요. 가게 앞으로 바다가 보이는 것도 좋았어요.
- Canonical: https://jaemyeong.com/ko/blog/songjeong-eobuui-sulsang-20260510/
- Published: 2026.09.24
- Category: 식기(食記)
- Tags: #Busan, #Songjeong, #seafood, #scallop
무늬오징어가 먹고 싶어서 친구와 둘이 송정에 다녀왔어요. 일요일 저녁, 가게가 문을 연 지 얼마 지나지 않은 때에 들어갔더니 다른 손님이 아직 없었어요. 영업 준비가 덜 끝난 듯한 분위기였어요. 가게 앞으로는 바다가 보이는데, 그 경치가 좋았어요.
남자 둘이 먹기에는 넉넉하게 시켰어요. 북해도 가리비와 붉은대게찜을 묶은 세트가 89,000원, 무늬오징어와 갑오징어찜이 80,000원이었어요.
## 가리비와 붉은대게찜
가리비는 불판에 올려 직접 구워 먹어요. 신선했고 맛도 좋았어요.

붉은대게찜도 신선하고 맛있었어요. 다만 크기에 비해 먹을 수 있는 살이 많지 않아서 그 점은 아쉬웠어요.

## 무늬오징어와 갑오징어찜
이날 송정까지 간 이유가 무늬오징어였어요. 기대한 만큼 맛있었어요.

갑오징어찜은 씹는 느낌이 특히 좋았어요. 오징어 두 가지는 모두 만족스러웠어요.

멍게는 주문하지 않았는데 서비스로 받았어요.

## 가격과 분위기
가격은 부담스러운 편이에요. 그래도 제철 해산물이 신선하고 가게 앞으로 바다가 보여서, 신선한 해산물을 찾는 사람이나 바다를 보며 식사하고 싶은 연인에게 어울리겠다고 생각했어요. 메뉴 구성은 계절에 따라 바뀐다고 해요.
야외 테이블이 있어서 날씨가 맞는 계절에는 밖에서 먹을 수도 있겠다고 짐작했어요. 저는 무늬오징어를 먹으러 한 번 더 갈 생각이에요.
---
# RnT in Daegwallyeong, my burger stop: a single should be enough
> A handmade burger shop I visit often in Daegwallyeong. I had my usual bacon burger set. The double is a big portion. A single would be enough for me.
- Canonical: https://jaemyeong.com/en/blog/pyeongchang-rnt-20260622/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Pyeongchang, #Daegwallyeong, #burger
When I am in Daegwallyeong and want a burger, RnT is the place that comes to mind. Getting to a McDonald's means going all the way to Gangneung. I tend to stop by in ski season and whenever I come to Daegwallyeong, and this time I went on a Monday at about 12:40. The shop is right next to the Alpensia promotion hall. As far as I know it is already fairly well known. I think it is a good place and suggest it to people visiting Daegwallyeong.
I ordered the bacon burger set, which is what I usually pick.

The double burger is a generous portion. I think a single would be enough for me.
---
# 대관령에서 버거가 생각나면 가는 알앤티, 싱글이면 충분할 듯해요
> 대관령에 머물 때 자주 가는 수제버거 가게예요. 평소처럼 베이컨 버거 세트를 먹었어요. 더블은 양이 넉넉해서 저는 싱글이면 충분할 것 같아요. 월요일 점심에 다녀온 기록이에요.
- Canonical: https://jaemyeong.com/ko/blog/pyeongchang-rnt-20260622/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Pyeongchang, #Daegwallyeong, #burger
대관령에 있는 동안 버거가 먹고 싶어지면 떠올리는 가게가 알앤티예요. 맥도날드에 가려면 강릉까지 나가야 하거든요. 스키철이나 대관령에 올 때마다 들르는 편이고, 이번에는 월요일 점심 12시 40분쯤에 갔어요. 가게는 대관령 알펜시아 홍보관 바로 가까이에 있어요. 이미 꽤 알려진 가게라고 알고 있고, 저도 맛집이라고 생각해서 대관령에 오는 분께 권해요.
주문은 평소에 자주 고르는 베이컨 버거 세트로 했어요.

더블 버거는 양이 넉넉해요. 저는 싱글이면 충분할 것 같아요.
---
# The Mandeok oden truck: cheap fish cake and an old-time feel I like
> An oden truck in Mandeok where I stop on the way home after drinking. A fish cake skewer is 700 won. It is cheap, and I like the old-time feel of it.
- Canonical: https://jaemyeong.com/en/blog/mandeok-odeng-truck-20260822/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #oden, #street food
At about 8:40 on a Saturday night I stopped at the oden truck in Mandeok. It is where I stop on the way home to settle my stomach on days I have been drinking with a friend. There is also [a note from a stop in March](/en/posts/mandeok-odeng-truck-20260309/).
The truck has no name of its own and is not registered on the map. It stands near Mandeok Station, in front of the 24-hour Jeonju Myeongga bean sprout soup restaurant. "Mandeok Oden Truck" is just what I call it.

A fish cake skewer is 700 won. It used to be 500 won. I usually have one rice cake skewer and two fish cake skewers. That is enough to fill me up. Otherwise I order udon. They sell noodles and soju too.
The truck has been in business in Mandeok for a long time. Taxi drivers stop by often. It is open in the small hours too. There is a small table next to the truck.
The food is not special in taste. I go because it costs little and I like the feel of it, which brings back old times. After eating, the hunger goes and I feel a little more sober.
---
# 만덕 오뎅트럭의 700원 오뎅, 싸고 옛날 분위기가 좋아요
> 술을 마신 날 집에 가는 길에 속을 달래러 들르는 만덕의 오뎅트럭이에요. 오뎅은 한 개에 700원이고, 저는 물떡 하나에 오뎅 두 개를 먹거나 우동을 먹어요. 값이 싸고 오래된 분위기가 좋아요.
- Canonical: https://jaemyeong.com/ko/blog/mandeok-odeng-truck-20260822/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #oden, #street food
토요일 밤 8시 40분쯤에 만덕의 오뎅트럭에 들렀어요. 친구와 술을 마신 날이면 집에 가는 길에 속을 달래려고 들르는 곳이에요. [3월에 들렀던 날의 기록](/posts/mandeok-odeng-truck-20260309/)도 있어요.
이 트럭에는 따로 상호가 없고 지도에도 등록돼 있지 않아요. 만덕역 근처, 24시전주명가콩나물국밥 만덕점 앞에 서요. «만덕 오뎅트럭»은 제가 부르는 이름이에요.

오뎅은 한 개에 700원이에요. 전에는 500원이었어요. 저는 보통 물떡 하나와 오뎅 두 개를 먹어요. 그 정도면 배가 차요. 아니면 우동을 시켜요. 국수와 소주도 팔아요.
만덕에서 오래 장사를 해 온 트럭이에요. 택시 기사분들이 자주 들러요. 새벽에도 해요. 트럭 옆에는 작은 테이블이 있어요.
음식 맛이 특별한 곳은 아니에요. 돈이 적게 들고, 옛날 생각이 나는 분위기가 좋아서 가요. 먹고 나면 허기가 가시고 술도 조금 깨는 기분이 들어요.
---
# Boiled turban shell at Ara Saengseongui, a good second stop
> A second round with a friend who lives in Haeundae, at a grilled fish place in Jwadong Market. The boiled turban shell tasted good and the price felt fair.
- Canonical: https://jaemyeong.com/en/blog/haeundae-ara-saengseongui-20260618/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #seafood, #market
A friend who lives in Haeundae and I wanted one more drink, so we went to Ara Saengseongui in Jwadong Market. It was about 8:50 on a Thursday night. I had been to this place a few times before. It is good for a light drink in Jwadong Market, and I plan to go again.
Jwadong Market sits further in than Haeundae Market. In my view it has a number of places worth eating at. The restaurant is inside that market, and the nearest station is Jangsan. There seemed to be quite a few local guests, though that is only my impression.
The name says grilled fish, but there are also soups and boiled meat, so there is a lot to choose from. That night we ordered the boiled turban shell.

It tasted good and the price felt reasonable.
---
# 좌동재래시장 아라생선구이의 참소라 숙회, 2차로 좋았어요
> 해운대에 사는 친구와 2차로 좌동재래시장의 생선구이집에 갔어요. 참소라 숙회는 맛있었고 가격도 부담스럽지 않았어요. 전에도 몇 번 간 집이에요.
- Canonical: https://jaemyeong.com/ko/blog/haeundae-ara-saengseongui-20260618/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #seafood, #market
해운대에 사는 친구와 한잔 더 하려고 좌동재래시장에 있는 아라생선구이에 갔어요. 목요일 밤 8시 50분쯤이었어요. 이 집은 전에도 몇 번 가 봤고, 좌동재래시장에서 가볍게 한잔할 때 가기 좋아서 또 갈 생각이에요.
좌동재래시장은 해운대시장보다 안쪽에 있는 시장이에요. 제가 보기에는 먹을 만한 가게가 여럿 있는 곳이에요. 가게는 그 시장 안에 있고, 가까운 역은 장산역이에요. 손님은 동네 분들이 많아 보였는데, 제 인상일 뿐이에요.
이름은 생선구이집이지만 탕과 수육도 있어서 고를 것이 많아요. 이날은 참소라 숙회를 시켰어요.

숙회는 맛있었고 가격도 합리적이라고 느꼈어요.
---
# Rough-cut raw fish at a fair price at the Gwangalli Eobang Festival
> My first time at the Gwangalli Eobang Festival, with friends. We ate raw fish from a beach food stall. The price was moderate and the slices were rough.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-eobang-chukje-20260614/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #Eobang Festival, #raw fish
I knew the Gwangalli Eobang Festival is held every year, but this was my first visit. I went with friends on a Sunday, at about half past two in the afternoon.
We passed exit 4 of Gwangan Station and walked toward the beach.
The festival grounds had a number of hands-on booths, and there seemed to be plenty of space for children to play.
## Raw fish from the beach stalls
We kept the meal simple and ate at the food stalls set up on the beach. We ordered only raw fish.

The price was in the middle, neither cheap nor expensive. The fish was cut roughly. I thought it had been prepared ahead and kept cold, but that is a guess based on it being a festival stall and not a raw fish restaurant.
We went during the day. I imagined that eating at the outdoor tables after sunset would have some charm.
## Will I go next year
Going back for next year's festival feels like a hassle, so I do not plan to return.
After the festival we moved to a grilled clam restaurant nearby. That story is in [my visit to Jogaeview Gwangan](/en/posts/gwangalli-jogaeview-20260614/).
---
# 광안리 어방축제에서 먹은 회, 가격은 무난하고 모양은 투박했어요
> 친구들과 처음 가 본 광안리 어방축제에서 해변 음식 코너의 회를 먹었어요. 가격은 무난했고 썰어 낸 모양은 투박했어요. 내년에 다시 갈 생각이 없는 이유도 적었어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-eobang-chukje-20260614/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #Eobang Festival, #raw fish
광안리 어방축제가 해마다 열린다는 것은 알고 있었는데, 가 본 것은 이번이 처음이에요. 일요일 오후 두 시 반쯤 친구들과 함께 갔어요.
저희는 광안역 4번 출구를 지나 해변 쪽으로 걸어갔어요.
축제장에는 체험 부스가 여러 개 있었고, 아이들이 놀 만한 공간도 넉넉해 보였어요.
## 해변 음식 코너의 회
식사는 해변에 차려진 음식 코너에서 간단하게 했어요. 회만 주문했어요.

가격은 싸지도 비싸지도 않은 중간 정도였어요. 회는 썰어 낸 모양이 투박했어요. 미리 손질해서 차게 보관해 둔 것 같다고 생각했는데, 횟집이 아닌 축제 판매대라서 그렇게 짐작한 것이에요.
저희는 낮에 갔어요. 해가 진 뒤에 야장에서 먹으면 운치가 있겠다고 생각했어요.
## 내년에도 갈까
내년 축제 때 다시 찾아가기는 번거롭게 느껴져요. 그래서 다시 갈 생각은 없어요.
이날은 축제를 본 뒤에 근처 조개구이집으로 자리를 옮겼어요. 그 이야기는 [조개뷰 광안본점 방문 기록](/posts/gwangalli-jogaeview-20260614/)에 적었어요.
---
# Eochon Hoetjip in Suyeong: good sashimi, awkward upstairs seats
> A first visit to a raw fish place inside Suyeong Paldo Market. The small 40,000 won platter was good for the price. The low attic-like room was not comfortable.
- Canonical: https://jaemyeong.com/en/blog/suyeong-eochon-hoetjip-20260616/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #raw fish, #market
A friend I often eat out with and I went to Eochon Hoetjip, inside Suyeong Paldo Market, for the first time. It was about 6:40 on a Tuesday evening. I am not sure where I first heard of the place. I think I saw it on Instagram.
We were seated on the second floor. The room looks like a converted attic, and the ceiling is low, so it was hard to walk around standing straight. The seats were not comfortable either. There is no call button, which made ordering more from upstairs a hassle. A staff member from the first floor carried the food up.
We ordered the small assorted sashimi for 40,000 won. Sushi, soft tofu, and salad come with it.

The fish was in good condition, and at this price it felt easy on the wallet. The two of us drank three bottles of soju. Near the end of the meal a grilled fish head came out.

This place suits people who care more about the fish and the price than about a comfortable seat. I was happy with the meal and wanted to return, and about two months later [I did go once more](/en/posts/suyeong-eochon-hoetjip-20260818/).
---
# 수영 어촌횟집의 모듬회 소, 회는 좋았고 2층 자리는 불편했어요
> 수영팔도시장 안의 횟집에 친구와 처음 가 봤어요. 4만 원짜리 모듬회 소는 회 상태도 가격도 마음에 들었고, 천장이 낮은 2층 자리와 주문하기 번거로운 점은 아쉬웠어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-eochon-hoetjip-20260616/
- Published: 2026.09.23
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #raw fish, #market
자주 같이 먹으러 다니는 친구와 수영팔도시장 안에 있는 어촌횟집에 처음 가 봤어요. 화요일 저녁 6시 40분쯤이었어요. 어디에서 이 가게를 알게 됐는지는 기억이 확실하지 않은데, 인스타그램에서 봤던 것 같아요.
저희는 2층으로 안내받았어요. 다락을 손본 것처럼 보이는 공간이라 천장이 낮아서 허리를 펴고 걸어 다니기가 힘들었고, 앉은 자리도 편하지는 않았어요. 호출 벨이 없어서 2층에서 추가 주문을 하기도 번거로웠어요. 음식은 1층에 계신 직원분이 들고 올라와 주셨어요.
모듬회는 소 크기로 시켰고 40,000원이었어요. 초밥과 순두부, 샐러드가 기본으로 함께 나와요.

회는 상태가 좋았고, 이 가격이면 부담이 적다고 느꼈어요. 둘이서 소주를 세 병 마셨어요. 식사가 끝나 갈 무렵에는 구운 생선 머리가 나왔어요.

자리가 편한 것보다 회의 상태와 가격이 더 중요한 사람에게 맞는 집이에요. 저는 식사에 만족해서 다시 가고 싶다고 생각했고, 두 달쯤 지나서 [실제로 한 번 더 갔어요](/posts/suyeong-eochon-hoetjip-20260818/).
---
# Moving Time Capsule backups to SMB on macOS 27
> macOS 27 dropped the AFP client and ended Time Machine backups to a 4th-generation Time Capsule. How TimeCapsuleSMB restored them over SMB3, and the risks.
- Canonical: https://jaemyeong.com/en/blog/macos-27-time-capsule-timecapsulesmb/
- Published: 2026.09.14
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #macOS, #Time Machine, #SMB, #Troubleshooting
The Macs on my network used a 4th-generation AirPort Time Capsule as the destination for automatic Time Machine backups. After the upgrade to macOS 27.0, those backups stopped working. [Apple's support article](https://support.apple.com/en-us/102423) states that AFP-based Time Capsule backups are not supported on macOS 27 or later. As rechecked on October 4, 2026, the article is dated September 14, 2026.
To keep using the device, I chose [TimeCapsuleSMB](https://github.com/jamesyc/TimeCapsuleSMB). It is an unofficial tool that runs Samba 4 inside the device so that backups go over SMB3. On September 14, 2026, I confirmed that a first Time Machine backup finished from a Mac mini on macOS 27. Below, what I ran myself and what I only read in documentation are kept apart.
## What went away is the AFP client
The stock server on a Time Capsule offers AFP and SMB1. The TimeCapsuleSMB README and [an article from The Eclectic Light Company](https://eclecticlight.co/2025/05/15/check-your-network-backups-and-shares-as-afp-is-being-removed/) describe it this way. In macOS 27.0 the AFP client is gone, and `mount_smbfs` remains.
There was advance notice. [The enterprise release notes for macOS Sequoia 15.5](https://support.apple.com/en-us/121011) announced that the AFP client would be removed in the future. The support article says backups to an AFP NAS are not recommended and are not supported on macOS 27 or later, and it applies the same condition to AirPort Extreme and Time Capsule. Earlier, [the macOS Sequoia 15 release notes](https://developer.apple.com/documentation/macos-release-notes/macos-15-release-notes) recorded a fix for a failure to create a new encrypted backup on a Time Capsule or AFP server.
[A MacRumors article](https://www.macrumors.com/2026/06/17/macos-27-golden-gate-kills-time-capsule-support/) explains the cause with TLS 1.2, but that is a different matter. The minimum TLS requirement in [the macOS 27 Golden Gate release notes](https://developer.apple.com/documentation/macos-release-notes/macos-27-release-notes) covers some processes related to device management, automated device enrollment, installing configuration profiles and apps, and software update. Time Machine is not among them. The same release notes have no item about removing AFP either. So the conclusion that AFP is gone rests on the support article and on the fact that the files are missing in macOS 27.0.
This is the result of trying an AFP mount on macOS 27.0. I used an address that does not exist, and only the directory path is replaced with `
`.
```text
$ mount -t afp //example.invalid/share
mount: exec /Library/Filesystems/afp.fs/Contents/Resources/mount_afp for : No such file or directory
mount: failed with 72
```
The command ends before it even resolves the name, because the helper file is missing. `/sbin/mount_afp` does not exist, there is no AFP bundle in `/System/Library/Filesystems`, and the network file system plug-ins have no AFP entry. On the other hand, the `setdestination` section of `man tmutil` still mentions AFP and SMB shares. Wording in a manual page is not evidence that AFP works.
AirPort Utility changed as well. A fresh install of macOS 27 does not include it. After an upgrade the existing app can remain, but it is not guaranteed to work from 27 on. My Mac was updated from macOS 26.6.2, and AirPort Utility 6.3.9 was still there.
## The available paths
The replacements Apple lists are an external storage device, a NAS that supports Time Machine, and a shared folder on another Mac.
- A directly attached external drive: the configuration Apple commonly gives. A network outage does not matter, but a laptop backs up only while the drive is plugged in.
- A NAS with SMB: several Macs can back up to one place. The device costs money.
- A shared folder on another Mac: it uses a Mac that is already there. Apple recommends that both Macs run macOS 11 or later.
- Staying on macOS 26: AFP keeps working for a while. It is hard to keep going once security updates end.
- TimeCapsuleSMB: it keeps the device in use and leaves room to carry over old backups. The cost is accepting an unofficial tool running as root on the device.
I took the last path. I installed with the default settings first, then examined the binaries and the issues, and changed the settings and deployed again based on what I found. The security section below is what I learned after installing, but it belongs before installation, so I put it first.
## What Time Machine needs from an SMB server
For Time Machine to use an SMB share as a backup destination, the server has to support Apple-specific features. In Samba, the [vfs_fruit](https://gitlab.com/samba-team/samba/-/blob/master/source3/modules/vfs_fruit.c) module does this.
This share definition comes from Samba's [public test configuration](https://gitlab.com/samba-team/samba/-/blob/master/selftest/target/Samba3.pm). Nothing in it is edited during installation. The excerpt only shows which options are needed.
```ini
[vfs_fruit_timemachine]
vfs objects = fruit streams_xattr acl_xattr xattr_tdb
fruit:time machine = yes
```
A share with `fruit:time machine = yes` is advertised over Bonjour as `_adisk._tcp`. The registration is in [Samba's Avahi registration code](https://gitlab.com/samba-team/samba/-/blob/master/source3/smbd/avahi_register.c). `vfs_fruit` negotiates the AAPL extension of SMB2 and handles Finder metadata. The extension needs SMB2 or later, so the SMB1 of the stock server cannot meet the condition.
## What TimeCapsuleSMB does on the device
The v2.2.9 I installed runs Samba 4.24.3 and accepts connections over SMB3. A separate mDNS helper advertises `_smb._tcp` and `_adisk._tcp`. By default only SMB2 and SMB3 are allowed, and turning on `Allow Any SMB Protocol` lifts that limit.
The installed files are split across two storage areas. `/mnt/Flash` is small but survives a reboot, so the boot script, the configuration, and the small mDNS helper go there. The large Samba binaries live in `.samba4` on the hard disk. Every time the device starts, they are copied to the `/mnt/Memory` RAM disk and run from there. The Apple firmware spins down an idle hard disk, so they cannot run directly from the disk.
During installation, the app first turns on SSH on the device and sends the files through it. Data that was already on the disk is not touched. The uninstall feature removes the files on the hard disk and the loader in `/mnt/Flash`. Deleting the installed files and restoring the boot hook are two different jobs, which is worth knowing. The command that returns the firmware to stock is `flash --restore`.
The binaries differ by generation. The 5th generation is NetBSD 6, and generations 1 to 4 are NetBSD 4. My 4th-generation device got the build for NetBSD 4.0 little-endian.
## Security conditions to know before installing
The first thing to know about is the heartbeat. The `nbns-advertiser` in v2.2.9 contains a heartbeat feature. It downloads `heartbeat4le` and a signature file over HTTP, verifies them, and runs the result. The address found in the strings of the binary is `http://timecapsulesmb.jamesyc.com/downloads/bin/heartbeat4le`. The source of this feature is not in the v2.2.9 tag.
[Issue 299](https://github.com/jamesyc/TimeCapsuleSMB/issues/299) reports that this feature runs every 12 hours with root privileges and sends device information. The maintainer answered that it is telemetry, that it can be turned off with a setting, and that the structure was changed in v3.0.0. On September 14, 2026, when I installed it, v3.0.0 had not been released. Looking at the release list again on October 4, 2026, v3.0.0 was published on September 15 (UTC), and the latest release is v3.2.0. I have not installed the v3 line, so the installation and telemetry details in this post are based on v2.2.9. Signature verification keeps an arbitrary file from being run, but what gets run depends on the maintainer's server and signing key.
I also checked how far the telemetry setting reaches. The app and the CLI write `TELEMETRY=false` to `.bootstrap` on the Mac, and that stops the usage events sent from the Mac. But in the deployed code and binary strings of v2.2.9, this value is not passed into the device's configuration. Which setting issue 299 refers to is not clear.
The way to stop the heartbeat on the device is to turn NBNS off. `nbns-advertiser` is always placed in `.samba4` on the hard disk, but it is copied to the RAM disk and run only when `NBNS_ENABLED=1`. In the app, turning off `Enable NBNS` and running `Install/Update` again deploys `NBNS_ENABLED=0`. In the CLI it is `deploy --no-nbns`. The basis for this conclusion is the boot code and the strings in the binary. I did not verify with packets that the network requests actually stopped.
With NBNS off, NetBIOS name responses stop, so the device can drop out of discovery on Windows. Macs find it through Bonjour and do not strictly need NBNS, and my device worked normally after the redeploy.
The remaining conditions are these.
- `Bind SMB to LAN Only` is off by default. When it is off, the server can bind to every interface including WAN, and when it is on, it is limited to the LAN. If the device also acts as the router, turning it on is the safe choice.
- SMB authentication accepts any user name and uses the device password of the Time Capsule. Guests are blocked, and an authenticated user is mapped to root.
- The README says to use it only on the LAN and not to open ports to the internet. The FAQ says it can be used on a home network and should be avoided in security-sensitive environments.
- The first launch of the app needs the Gatekeeper warning to be allowed by hand. [Issue 284](https://github.com/jamesyc/TimeCapsuleSMB/issues/284) includes a case where the maintainer provided an unpublished build through an external share link. Getting the app from an official release is the better choice.
## The steps I followed with the app
I installed with the app, not the CLI. The steps were as follows.
1. Download the app from the GitHub releases. After unpacking and opening it, Gatekeeper blocks it, and it runs only after it is allowed by hand.
2. Give the app the local network permission. It is under "Privacy & Security" in System Settings, and the app has to be quit and reopened after the permission is given.
3. In `Add Device`, pick the device, enter the device password, and save it with `Save Device`. The app then turns on SSH, and I waited for that to finish.
4. Change three settings: Mac telemetry off, `Enable NBNS` off, and `Bind SMB to LAN Only` on.
5. Press `Install/Update`. The NBNS and LAN settings go to the device at this step.
6. For generations 1 to 4, write the boot hook.
7. Run `Checkup` after 5 to 10 minutes.
On the 4th-generation device, the deploy took about 6 minutes. The checks that the device rebooted and that the runtime was activated succeeded, and Checkup showed 0 failures and 0 warnings.
The README also describes what to do on failure. If the SSH step is stuck, restart the app and register the device again, or reboot the device itself. If the deploy step is stuck, delete the device entry saved in the app, register it again, and run the deploy once more. In some cases one deploy does not transfer every file, and it has to be repeated several times.
### The boot hook on generations 1 to 4
The NetBSD 4 firmware on generations 1 to 4 cannot keep a boot hook. Without a patch, `tcapsule activate` has to be run after every reboot. In the app, the `Persistent NetBSD4 Boot Hook` menu runs `Back Up and Inspect`, `Plan Patch`, and `Write Patch` in that order.
The firmware on my device is 7.8.1. Both banks were backed up to the Mac, and an image built from 7.8.1 in Apple's catalog was written only to the primary bank. The secondary bank is still the original. The check that reads the bank back after writing and compares it also passed. The reboot command is not available in patch mode, so I unplugged the power cord and plugged it back in, and after that Samba started without `activate`.
Writing to flash carries risk. [The v2.2.9 FAQ](https://github.com/jamesyc/TimeCapsuleSMB/blob/v2.2.9/FAQ.md) warns that losing power during the write can leave the device unusable. [The v2.2.9 README](https://github.com/jamesyc/TimeCapsuleSMB/blob/v2.2.9/README.md) says `tcapsule flash --restore` returns the chosen bank to stock, but I did not test a restore. According to the README and the FAQ, the 5th generation needs no boot hook, starts Samba automatically after a reboot, and reboots once during the deploy. I did not test the 5th generation either.
## Installing with the CLI
I did not run the CLI. The order in the README, with the option that turns NBNS off added, looks like this.
```bash
./tcapsule bootstrap
.venv/bin/tcapsule configure
.venv/bin/tcapsule deploy --no-nbns
.venv/bin/tcapsule doctor
```
`bootstrap` creates a Python virtual environment inside the repository, and `configure` writes the address and password to a configuration file. `deploy --no-nbns` deploys with NBNS off, and `doctor` checks the state. On generations 1 to 4, after `deploy`, `flash` makes a backup and `flash --patch` writes the boot hook.
The documented requirements are macOS 14 or later, Python 3.9 or later, Homebrew, and `smbclient`. If the target is a NetBSD 4 device, `sshpass` is needed as well.
## Reusing existing backups
This section summarizes [the FAQ on main](https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/FAQ.md). The baseline is commit 34c075f on September 14, 2026, and I did not move a backup myself. Installation does not delete the disk or an existing `.sparsebundle`.
1. First check that the SMB connection works.
2. Put the `.sparsebundle` at the root of the SMB share. With standard internal-disk settings, this is the `ShareRoot` folder. A bundle inside a per-user folder is moved to the same place.
3. Set the Time Machine destination to the SMB share again.
4. When Time Machine offers to continue with the earlier backup, accept it.
If the earlier bundle is not found or cannot be reused, Time Machine may create a new bundle. When macOS still cannot find the existing backup after the move, the remedy the FAQ gives is a reboot. With the backup not mounted, reboot the Time Capsule first and then the Mac.
## What I verified
This is the environment I verified. The Mac used for installation runs macOS 27.0, and the Mac that backed up is a Mac mini on macOS 27. The device is a 4th-generation AirPort Time Capsule with firmware 7.8.1 and NetBSD 4.0. The app is 2.2.9, Samba is 4.24.3, and the date is September 14, 2026. On the Mac mini I set the SMB share as the backup destination, and the first Time Machine backup finished.
A few commands help when checking the state. This one finds devices that advertise an SMB service over Bonjour.
```bash
dns-sd -B _smb._tcp local.
```
This one finds devices that advertise a Time Machine backup service. The two commands are run separately, and since the output keeps going, each is stopped with Control-C.
```bash
dns-sd -B _adisk._tcp local.
```
This one lists the backup destinations registered on the Mac.
```bash
tmutil destinationinfo
```
The first command shows the SMB device name, and the second shows the device name of the backup disk. Seeing the advertisement does not mean that authentication and writing work. According to the documentation, `doctor` checks the Samba processes, port 445, the Bonjour advertisement, the authenticated share list, and file operations inside the share.
## What to watch while it stays in use
Keeping the app or the CLI folder after installation is the better choice. Running Checkup, deploying again, `activate`, and uninstalling all need that tool. The FAQ explains that the CLI folder can be deleted, but that it has to be kept or downloaded again to run maintenance commands. To move to a new version, do not uninstall first. Deploy once more on top of the existing installation. After that, check that the NBNS setting, the LAN setting, and the telemetry structure are unchanged, and finish with Checkup. Generations 1 to 4 without the boot hook need `activate` after a reboot.
Changes on the OS side matter too. The FAQ records a regression of Time Machine network backups in macOS 26.4.x and 15.7.5 through 15.7.7. After an OS update, it is worth confirming that the first backup finishes.
There are also cases in the issues. [Issue 294](https://github.com/jamesyc/TimeCapsuleSMB/issues/294) is about SMB stalling during a backup. It reports that wired connections or a newer external access point were more stable than the built-in Wi-Fi, but the cause is not settled. In [issue 271](https://github.com/jamesyc/TimeCapsuleSMB/issues/271), the maintainer judged the cause to be instability in Samba and the network. [Issue 177](https://github.com/jamesyc/TimeCapsuleSMB/issues/177) is a case where the device was factory reset during the first deploy. The data on the disk remains, but the network has to be set up again with AirPort Utility, and on macOS 27 that app is not guaranteed to work. For that reason, write down the device's settings before installing.
I also read the main branch, which was still unreleased when I installed. In [commit 34c075f](https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/README.md) from September 14, 2026 and [the native helper README](https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/build/native/README.md), the heartbeat is split out into a telemetry helper, which downloads and runs a signed debug executable when the server tells it to. This is code before release, and I did not confirm how it behaves, so the release notes and the FAQ should be read again before updating.
This setup puts an unofficial project on a device that Apple no longer supports. Important backups are better kept in one more place, on an external drive or an SMB NAS.
What I did not verify myself is the CLI installation, the restore to stock firmware, and the move of existing backups. I did not test a 5th-generation device, an AirPort Extreme, or an external disk attached to the device either. I did not test accessing files over the stock SMB1 on macOS 27 either. This post is about the Time Machine problem. I did not confirm with a packet capture that the heartbeat stopped either.
---
# macOS 27에서 Time Capsule 백업을 SMB로 옮긴 기록
> macOS 27에서 AFP 클라이언트가 빠지면서 4세대 AirPort Time Capsule로 하던 Time Machine 백업이 끊겼어요. TimeCapsuleSMB로 기기 안에서 Samba 4를 돌려 SMB3 백업으로 바꾼 과정과, 설치 전에 알아야 할 보안 조건을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/macos-27-time-capsule-timecapsulesmb/
- Published: 2026.09.14
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #macOS, #Time Machine, #SMB, #Troubleshooting
4세대 AirPort Time Capsule을 같은 네트워크의 Mac들이 Time Machine 자동 백업 대상으로 쓰고 있었어요. macOS 27.0으로 올린 뒤 이 백업이 되지 않았어요. [Apple의 지원 문서](https://support.apple.com/en-us/102423)는 AFP 기반의 Time Capsule 백업을 macOS 27 이상에서 지원하지 않는다고 밝혀요. 이 문서의 게시일은 2026년 10월 4일에 다시 확인했을 때 2026년 9월 14일이었어요.
기기를 버리지 않고 쓰는 방법으로 [TimeCapsuleSMB](https://github.com/jamesyc/TimeCapsuleSMB)를 골랐어요. 기기 안에서 Samba 4를 실행해서 SMB3로 백업하게 해 주는 비공식 도구예요. 2026년 9월 14일에 macOS 27의 Mac mini에서 첫 Time Machine 백업이 끝나는 것까지 확인했어요. 아래에는 직접 실행한 것과 문서로만 읽은 것을 구분해서 적었어요.
## 없어진 것은 AFP 클라이언트예요
Time Capsule의 순정 서버는 AFP와 SMB1을 제공해요. TimeCapsuleSMB의 README와 [The Eclectic Light Company의 글](https://eclecticlight.co/2025/05/15/check-your-network-backups-and-shares-as-afp-is-being-removed/)이 이렇게 설명해요. macOS 27.0에서는 AFP 클라이언트가 빠졌고, `mount_smbfs`는 남아 있어요.
예고는 있었어요. [macOS Sequoia 15.5의 기업용 릴리스 노트](https://support.apple.com/en-us/121011)가 앞으로 AFP 클라이언트를 없앤다고 알렸어요. 지원 문서는 AFP NAS로의 백업을 권장하지 않고 macOS 27 이상에서 지원하지 않는다고 적었으며, AirPort Extreme과 Time Capsule에도 같은 조건을 적용했어요. 그 전의 [macOS Sequoia 15 릴리스 노트](https://developer.apple.com/documentation/macos-release-notes/macos-15-release-notes)에는 Time Capsule이나 AFP 서버에 새 암호화 백업을 만들지 못하던 문제를 고쳤다는 기록이 있어요.
원인을 TLS 1.2로 설명한 [MacRumors의 기사](https://www.macrumors.com/2026/06/17/macos-27-golden-gate-kills-time-capsule-support/)도 있는데, 이것은 다른 이야기예요. [macOS 27 Golden Gate 릴리스 노트](https://developer.apple.com/documentation/macos-release-notes/macos-27-release-notes)의 최소 TLS 요건이 적용되는 범위는 기기 관리, 자동 기기 등록, 구성 프로파일과 앱 설치, 소프트웨어 업데이트와 관련된 일부 프로세스이고, Time Machine은 들어 있지 않아요. 같은 릴리스 노트에 AFP 제거 항목도 없어요. 그래서 AFP가 빠졌다는 판단은 지원 문서와 macOS 27.0에 파일이 없다는 사실에 근거했어요.
macOS 27.0에서 AFP 마운트를 시도한 결과예요. 존재하지 않는 주소를 썼고, 디렉터리 경로만 ``로 바꿨어요.
```text
$ mount -t afp //example.invalid/share
mount: exec /Library/Filesystems/afp.fs/Contents/Resources/mount_afp for : No such file or directory
mount: failed with 72
```
이름을 해석하기도 전에 헬퍼 파일이 없어서 끝나요. `/sbin/mount_afp`가 없고, `/System/Library/Filesystems`에 AFP 번들이 없고, 네트워크 파일시스템 플러그인에도 AFP 항목이 없어요. 반면 `man tmutil`의 `setdestination` 설명에는 AFP와 SMB 공유라는 문구가 남아 있어요. 매뉴얼의 문구는 AFP가 동작한다는 증거가 되지 않아요.
AirPort Utility도 달라졌어요. macOS 27을 새로 설치하면 들어 있지 않고, 업그레이드한 경우에는 기존 앱이 남을 수 있지만 27 이후의 동작은 보장되지 않아요. 제 Mac은 macOS 26.6.2에서 업데이트했고 AirPort Utility 6.3.9가 남아 있었어요.
## 고를 수 있는 길
Apple이 제시하는 대체 저장소는 외장 저장장치, Time Machine을 지원하는 NAS, 다른 Mac의 공유 폴더예요.
- 직접 연결하는 외장 저장장치: Apple이 흔하게 드는 구성이에요. 네트워크가 끊겨도 상관없지만, 노트북은 꽂아 둔 동안에만 백업이 돌아요.
- SMB를 지원하는 NAS: Mac 여러 대를 한곳에 받을 수 있어요. 장비 값이 들어요.
- 다른 Mac의 공유 폴더: 집에 있는 Mac을 그대로 써요. Apple의 권장 조건은 두 Mac 모두 macOS 11 이상이에요.
- macOS 26에 남기: AFP는 한동안 그대로 쓸 수 있어요. 보안 업데이트가 끊기면 유지하기 어려워요.
- TimeCapsuleSMB: 쓰던 기기를 살리고, 예전 백업을 넘겨받을 여지도 있어요. 비공식 도구가 기기에서 root로 도는 것은 감수해야 해요.
저는 마지막 길을 골랐어요. 기본 설정으로 설치한 뒤에 바이너리와 이슈를 조사했고, 그 결과에 따라 설정을 바꿔 다시 배포했어요. 아래의 보안 절은 설치한 뒤에 알게 된 내용이지만, 설치하기 전에 읽어야 할 내용이라서 앞에 뒀어요.
## SMB로 Time Machine을 받으려면
Time Machine이 SMB 공유를 백업 대상으로 쓰려면 서버가 Apple 전용 기능을 지원해야 해요. Samba에서는 [vfs_fruit](https://gitlab.com/samba-team/samba/-/blob/master/source3/modules/vfs_fruit.c) 모듈이 이 일을 맡아요.
Samba의 [공개 테스트 설정](https://gitlab.com/samba-team/samba/-/blob/master/selftest/target/Samba3.pm)에 있는 공유 정의예요. 설치하면서 직접 편집하는 파일이 아니고, 어떤 옵션이 필요한지 보여 주는 발췌예요.
```ini
[vfs_fruit_timemachine]
vfs objects = fruit streams_xattr acl_xattr xattr_tdb
fruit:time machine = yes
```
`fruit:time machine = yes`가 켜진 공유는 Bonjour의 `_adisk._tcp`로 광고돼요. 광고를 등록하는 부분은 [Samba의 Avahi 등록 구현](https://gitlab.com/samba-team/samba/-/blob/master/source3/smbd/avahi_register.c)에 있어요. `vfs_fruit`는 SMB2의 AAPL 확장을 협상해서 Finder의 메타데이터를 처리해요. 이 확장은 SMB2 이상이 필요하므로 순정 서버의 SMB1로는 조건을 채울 수 없어요.
## TimeCapsuleSMB가 기기에서 하는 일
제가 설치한 v2.2.9는 Samba 4.24.3을 실행하고 SMB3로 접속을 받아요. 별도의 mDNS 헬퍼가 `_smb._tcp`와 `_adisk._tcp`를 광고해요. 기본 설정에서는 SMB2와 SMB3만 허용하고, `Allow Any SMB Protocol`을 켜면 이 제한이 풀려요.
설치물은 저장 영역 두 군데로 갈라져요. `/mnt/Flash`는 용량이 작지만 재부팅해도 남는 영역이라서 부팅 스크립트와 설정, 크기가 작은 mDNS 헬퍼를 여기에 둬요. 덩치가 큰 Samba 본체의 자리는 하드디스크 안의 `.samba4`예요. 기기가 켜질 때마다 본체를 램디스크인 `/mnt/Memory`로 옮겨서 거기서 돌려요. Apple 펌웨어가 한가한 하드디스크를 꺼 버리는 탓에 디스크 위에서 바로 실행하지 못하기 때문이에요.
설치할 때에는 앱이 먼저 기기의 SSH를 켜고, 그 통로로 파일을 보내요. 원래 디스크에 들어 있던 데이터는 건드리지 않아요. 제거 기능은 하드디스크 쪽 설치물과 `/mnt/Flash`에 있는 로더를 없애요. 설치 파일 삭제와 부트 훅 복원은 서로 다른 작업이라는 점은 알아 둬야 해요. 펌웨어를 순정으로 되돌리는 명령은 `flash --restore`예요.
바이너리는 세대에 따라 달라요. 5세대는 NetBSD 6이고 1~4세대는 NetBSD 4예요. 제 4세대 기기에는 NetBSD 4.0 little-endian용이 설치됐어요.
## 설치하기 전에 알아야 할 보안 조건
가장 먼저 알아야 할 것은 heartbeat예요. v2.2.9의 `nbns-advertiser`에는 heartbeat 기능이 들어 있어요. HTTP로 `heartbeat4le`와 서명 파일을 받아서 검증한 뒤 실행해요. 바이너리의 문자열에서 찾은 주소는 `http://timecapsulesmb.jamesyc.com/downloads/bin/heartbeat4le`예요. 이 기능의 소스는 v2.2.9 태그에 없어요.
[이슈 299](https://github.com/jamesyc/TimeCapsuleSMB/issues/299)는 이 기능이 12시간 간격으로 root 권한으로 실행되고 기기 정보를 전송한다고 보고해요. 관리자는 이것이 텔레메트리이고 설정으로 끌 수 있으며 v3.0.0에서 구조를 바꿨다고 답했어요. 제가 설치한 2026년 9월 14일에는 v3.0.0이 나오지 않았어요. 릴리스 목록을 2026년 10월 4일에 다시 보니 v3.0.0은 9월 15일(UTC)에 게시됐고, 가장 최근 릴리스는 v3.2.0이에요. v3 계열은 설치해 보지 않았으므로, 이 글의 설치와 텔레메트리 내용은 v2.2.9 기준이에요. 서명 검증은 아무 파일이나 실행되는 것을 막아 주지만, 무엇이 실행되는지는 관리자의 서버와 서명 키에 달려 있어요.
텔레메트리 설정이 닿는 범위도 확인했어요. 앱과 CLI는 Mac의 `.bootstrap`에 `TELEMETRY=false`를 적고, 이것은 Mac에서 보내는 사용 이벤트를 멈춰요. 그런데 v2.2.9의 배포 코드와 바이너리 문자열을 보면 이 값이 기기의 설정으로 전달되지 않아요. 이슈 299에서 말한 설정이 어느 항목인지는 분명하지 않아요.
기기의 heartbeat를 멈추는 방법은 NBNS를 끄는 것이에요. `nbns-advertiser`는 항상 하드디스크의 `.samba4`에 놓이지만, `NBNS_ENABLED=1`일 때에만 램디스크로 복사돼서 실행돼요. 앱에서는 `Enable NBNS`를 끄고 `Install/Update`를 다시 실행하면 `NBNS_ENABLED=0`으로 배포돼요. CLI에서는 `deploy --no-nbns`예요. 이 판단의 근거는 부팅 코드와 바이너리의 문자열이에요. 네트워크 요청이 실제로 사라졌는지 패킷으로 검증하지는 않았어요.
NBNS를 끄면 NetBIOS 이름 응답이 멈춰서 Windows의 탐색에서 빠질 수 있어요. Mac은 Bonjour로 찾으므로 NBNS가 꼭 필요하지는 않고, 제 기기도 다시 배포한 뒤에 정상이었어요.
나머지 조건은 이래요.
- `Bind SMB to LAN Only`는 기본값이 꺼짐이에요. 꺼져 있으면 WAN을 포함한 모든 인터페이스에 바인딩될 수 있고, 켜면 LAN으로 한정돼요. 기기가 공유기를 겸한다면 켜는 것이 안전해요.
- SMB 인증은 사용자 이름을 가리지 않고 Time Capsule의 기기 비밀번호를 써요. 게스트는 막혀 있고, 인증된 사용자는 root로 매핑돼요.
- README는 LAN에서만 쓰고 인터넷으로 포트를 열지 말라고 해요. FAQ는 가정의 네트워크에서는 쓸 수 있다고 하면서, 보안에 민감한 환경에서는 쓰지 말라고 해요.
- 앱을 처음 열 때 Gatekeeper 경고를 직접 허용해야 해요. [이슈 284](https://github.com/jamesyc/TimeCapsuleSMB/issues/284)에는 외부 공유 링크로 공개되지 않은 빌드를 제공한 사례가 있어요. 공식 릴리스에서 받는 것이 좋아요.
## 앱으로 설치한 순서
저는 CLI가 아니라 앱으로 설치했어요. 순서는 이래요.
1. 앱은 GitHub 릴리스에서 내려받아요. 압축을 풀어 열면 Gatekeeper가 막는데, 직접 허용해야 실행돼요.
2. 앱에 로컬 네트워크 권한을 줘요. 위치는 시스템 설정의 «개인정보 보호 및 보안»이고, 권한을 준 다음 앱을 껐다가 다시 열어요.
3. `Add Device`에서 기기를 고르고 기기 비밀번호를 입력해 `Save Device`로 저장해요. 그러면 앱이 SSH를 켜는데, 끝나기를 기다려요.
4. 설정 세 가지를 바꿔요. Mac 텔레메트리는 끔, `Enable NBNS`는 끔, `Bind SMB to LAN Only`는 켬이에요.
5. `Install/Update`를 눌러요. NBNS와 LAN 설정은 이 단계에서 기기에 들어가요.
6. 1~4세대라면 부트 훅을 써요.
7. `Checkup`은 5~10분 지난 뒤에 돌려요.
4세대 기기에서 배포는 약 6분 걸렸어요. 기기가 다시 부팅되고 런타임이 활성화되는 검증이 성공했고, Checkup은 실패 0건, 경고 0건이었어요.
README는 실패했을 때의 대응도 적어 뒀어요. SSH 단계에서 막히면 앱을 껐다 켜고 기기 등록을 새로 하거나, 기기 자체를 재부팅해요. 배포 단계에서 막히면 앱에 저장된 기기 항목을 삭제하고, 새로 등록해서 배포를 한 번 더 돌려요. 한 번의 배포로 파일이 다 넘어가지 않아서 여러 차례 반복해야 하는 경우도 있어요.
### 1~4세대의 부트 훅
1~4세대의 NetBSD 4 펌웨어는 부팅 훅을 유지하지 못해요. 패치하지 않으면 재부팅할 때마다 `tcapsule activate`를 실행해야 해요. 앱에서는 `Persistent NetBSD4 Boot Hook` 메뉴에서 `Back Up and Inspect`, `Plan Patch`, `Write Patch`를 차례로 실행해요.
제 기기의 펌웨어는 7.8.1이에요. 두 뱅크를 모두 Mac에 백업했고, Apple 카탈로그의 7.8.1을 바탕으로 만든 이미지를 primary 뱅크에만 썼어요. secondary 뱅크는 원본 그대로예요. 쓴 뒤에 다시 읽어서 일치하는지 확인하는 검증도 통과했어요. 패치 모드에서는 재부팅 명령을 쓸 수 없어서 전원 코드를 뽑았다가 다시 꽂았고, 그 뒤로는 `activate` 없이 Samba가 시작됐어요.
플래시 쓰기에는 위험이 있어요. [v2.2.9의 FAQ](https://github.com/jamesyc/TimeCapsuleSMB/blob/v2.2.9/FAQ.md)는 쓰는 도중에 전원이 끊기면 기기를 못 쓰게 될 수 있다고 경고해요. [v2.2.9의 README](https://github.com/jamesyc/TimeCapsuleSMB/blob/v2.2.9/README.md)에 따르면 `tcapsule flash --restore`로 고른 뱅크를 순정으로 되돌릴 수 있는데, 저는 복원을 시험하지 않았어요. 5세대는 README와 FAQ에 따르면 부트 훅이 필요 없고, 재부팅 뒤에 Samba가 자동으로 시작되며, 배포 중에 한 번 재부팅해요. 5세대도 시험하지 않았어요.
## CLI로 설치하는 경우
CLI는 실행하지 않았어요. README의 순서에 NBNS를 끄는 옵션을 더하면 아래와 같아요.
```bash
./tcapsule bootstrap
.venv/bin/tcapsule configure
.venv/bin/tcapsule deploy --no-nbns
.venv/bin/tcapsule doctor
```
`bootstrap`은 저장소 안에 Python 가상환경을 만들고, `configure`는 주소와 비밀번호를 설정 파일에 적어요. `deploy --no-nbns`는 NBNS를 끈 채로 배포하고, `doctor`는 상태를 검사해요. 1~4세대는 `deploy` 뒤에 `flash`로 백업하고 `flash --patch`로 부트 훅을 써요.
문서에 적힌 요구 환경은 macOS 14 이상, Python 3.9 이상, Homebrew, `smbclient`예요. NetBSD 4 기기가 대상이면 `sshpass`도 필요해요.
## 기존 백업을 이어 쓰는 방법
이 절은 [main의 FAQ](https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/FAQ.md)를 정리한 것이에요. 기준은 2026년 9월 14일의 커밋 34c075f이고, 제가 직접 옮겨 본 것이 아니에요. 설치는 디스크와 기존 `.sparsebundle`을 지우지 않아요.
1. 먼저 SMB 접속이 되는지부터 봐요.
2. SMB 공유의 최상위에 `.sparsebundle`을 가져다 놓아요. 기본 내부 디스크 설정에서는 `ShareRoot` 폴더가 그 위치예요. 사용자 폴더 안쪽에 있던 묶음도 같은 자리로 꺼내요.
3. Time Machine의 백업 대상을 SMB 공유로 새로 지정해요.
4. 예전 백업을 이어서 쓰자는 제안이 뜨면 받아들여요.
예전 묶음이 보이지 않거나 재사용에 실패하면 Time Machine은 묶음을 새로 만들어 버릴 수 있어요. 옮긴 뒤에도 macOS가 기존 백업을 찾지 못할 때 FAQ가 권하는 조치는 재부팅이에요. 백업을 마운트하지 않은 상태에서 Time Capsule부터 재부팅하고, 이어서 Mac을 재부팅해요.
## 확인한 범위
확인한 환경은 이래요. 설치에 쓴 Mac은 macOS 27.0이고, 백업한 Mac은 macOS 27의 Mac mini예요. 기기는 4세대 AirPort Time Capsule, 펌웨어 7.8.1, NetBSD 4.0이에요. 앱은 2.2.9, Samba는 4.24.3이고, 날짜는 2026년 9월 14일이에요. Mac mini에서 SMB 공유를 백업 대상으로 지정했고 첫 Time Machine 백업이 끝났어요.
상태를 볼 때 쓸 수 있는 명령이 몇 가지 있어요. Bonjour에서 SMB 서비스를 광고하는 기기를 찾는 명령이에요.
```bash
dns-sd -B _smb._tcp local.
```
Time Machine 백업 서비스를 광고하는 기기를 찾는 명령이에요. 두 명령은 따로 실행하고, 출력이 계속 이어지므로 Control-C로 끝내요.
```bash
dns-sd -B _adisk._tcp local.
```
지금 Mac에 등록된 백업 대상을 보는 명령이에요.
```bash
tmutil destinationinfo
```
첫 명령에는 SMB 기기 이름이, 둘째 명령에는 백업 디스크의 기기 이름이 나와요. 광고가 보인다고 해서 인증과 쓰기가 된다는 뜻은 아니에요. 문서에 따르면 `doctor`는 Samba 프로세스, 445 포트, Bonjour 광고, 인증된 공유 목록, 공유 안의 파일 작업을 검사해요.
## 계속 쓰면서 볼 것
설치가 끝난 뒤에도 앱이나 CLI 폴더는 보관해 두는 것이 좋아요. Checkup을 돌릴 때도, 다시 배포할 때도, `activate`나 제거를 할 때도 그 도구가 있어야 해요. FAQ는 CLI 폴더를 지워도 되지만 유지보수를 하려면 보관하거나 다시 받아야 한다고 설명해요. 새 버전으로 올릴 때에는 제거부터 하지 않고, 설치돼 있는 상태에서 배포를 한 번 더 해요. 그런 다음 NBNS 설정, LAN 설정, 텔레메트리 구조가 그대로인지 보고 Checkup으로 마무리해요. 부트 훅을 쓰지 않은 1~4세대는 재부팅 뒤에 `activate`를 실행해 줘야 해요.
OS 쪽의 변화도 봐야 해요. FAQ에는 macOS 26.4.x와 15.7.5~15.7.7에서 Time Machine 네트워크 백업이 퇴행했다는 기록이 있어요. OS를 올린 뒤에는 첫 백업이 끝나는지 확인하는 것이 좋아요.
이슈에 올라온 사례도 있어요. [이슈 294](https://github.com/jamesyc/TimeCapsuleSMB/issues/294)는 백업 중에 SMB가 멈추는 문제이고, 내장 Wi-Fi보다 유선이나 최신 외부 AP가 안정적이었다고 하지만 원인은 확정되지 않았어요. [이슈 271](https://github.com/jamesyc/TimeCapsuleSMB/issues/271)에서 관리자는 Samba와 네트워크의 불안정으로 판단했어요. [이슈 177](https://github.com/jamesyc/TimeCapsuleSMB/issues/177)은 첫 배포 중에 기기가 공장 초기화된 사례예요. 디스크의 데이터는 남지만 네트워크는 AirPort Utility로 다시 설정해야 하는데, macOS 27에서는 그 앱이 동작한다는 보장이 없어요. 그래서 설치하기 전에 기기의 설정을 기록해 둬요.
설치할 때에는 아직 출시 전이던 main도 읽어 봤어요. 2026년 9월 14일의 [커밋 34c075f](https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/README.md)와 [네이티브 헬퍼의 README](https://github.com/jamesyc/TimeCapsuleSMB/blob/34c075f59836ff91f366e21c476abf2c8f6c56b6/build/native/README.md)에서는 heartbeat가 telemetry 헬퍼로 분리됐고, 서버의 지시에 따라 서명된 debug 실행물을 받아 실행해요. 출시 전의 코드이고 동작을 확인하지 않았으므로, 업데이트하기 전에 릴리스 노트와 FAQ를 다시 읽어야 해요.
Apple의 지원이 끝난 장비에 비공식 프로젝트를 얹은 구성이에요. 중요한 백업은 외장 저장장치나 SMB NAS에 하나 더 두는 것이 좋아요.
직접 확인하지 않은 것은 CLI 설치, 순정 펌웨어 복원, 기존 백업의 이전이에요. 5세대 기기, AirPort Extreme, 기기에 연결한 외장 디스크도 시험하지 않았어요. macOS 27에서 순정 SMB1으로 파일에 접속하는 것도 시험하지 않았어요. 이 글이 다룬 것은 Time Machine 문제예요. heartbeat가 멈췄다는 것도 패킷으로 확인하지 않았어요.
---
# A Guinness on the terrace at Issi in Haeundae, facing the sea
> After barbecue, a friend and I moved to Issi, across from Haeundae Beach, for Guinness. The terrace is a good spot to look at the sea and talk.
- Canonical: https://jaemyeong.com/en/blog/haeundae-issi-20260628/
- Published: 2026.09.08
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #beer, #terrace
After the barbecue at [Butasinise Haeundae](/en/posts/haeundae-butasinise-20260628/), a friend and I went to Issi for a second round. It was about 8:30 on a Sunday night.
Issi is a Western-style restaurant on the first floor of Palais de Seas, right across from Haeundae Beach. From the subway it is a fair walk. There are terrace seats outside, and that day I saw that pets were allowed on the terrace.
I mostly drink Guinness stout. I had one that day as well.

From the terrace you can see the sea. It is a good place to talk without hurry over a drink, and it suits a light beer, whisky, or wine. It also fits well into a walk along the beach.
---
# 해운대 잇시 테라스에서 기네스 한 잔, 바다 보며 쉬기 좋아요
> 고기를 먹은 뒤 친구와 해운대해수욕장 앞 잇시로 옮겨 기네스를 마셨어요. 바깥 테라스 자리에서 바다를 바라보면서 느긋하게 이야기를 나누기 좋은 곳이에요.
- Canonical: https://jaemyeong.com/ko/blog/haeundae-issi-20260628/
- Published: 2026.09.08
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #beer, #terrace
[부타시니세 해운대점](/posts/haeundae-butasinise-20260628/)에서 고기를 먹고 나서 친구와 2차로 간 곳이 잇시예요. 일요일 밤 8시 반쯤이었어요.
가게는 팔레드시즈 건물 1층에 있어요. 해운대해수욕장 바로 앞이고, 음식은 양식을 해요. 지하철역에서는 거리가 좀 있어요. 건물 바깥쪽으로 테라스 좌석을 두었는데, 테라스에는 반려동물과 함께 앉을 수 있어요.
저는 평소에 기네스 흑맥주를 주로 마셔요. 이번에도 한 잔을 시켰어요.

바깥 좌석에서는 바다 쪽이 내다보여요. 서두를 것 없이 이야기를 나누기에 좋겠다고 생각했고, 맥주든 위스키든 와인이든 한두 잔 곁들이기에 알맞아요. 해변을 걷다가 들르기에도 무리가 없는 위치예요.
---
# Striped jack sashimi at Dongnae Gimssi: good fish, slow to arrive
> I went to an izakaya near Dongnae Station for striped jack. The 70,000 won sashimi was good fish, but it took long to arrive and came with little else.
- Canonical: https://jaemyeong.com/en/blog/dongnae-dongnae-gimssi-20260704/
- Published: 2026.09.07
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #sashimi, #izakaya
It was a Saturday and I wanted striped jack. I called a friend who was getting off work, and at about 6:45 in the evening we went to Dongnae Gimssi together. It is an izakaya near Dongnae Station.
For striped jack I usually go to [Kalmat Hoetjip](/en/posts/jangjeon-kalmat-hoetjip-20260717/) near Pusan National University. But getting in there takes a long wait and it closes early, which can be a burden. On a day when that place is hard to get into, I think of this one as an alternative for striped jack, and I plan to go again.
We ordered the seasonal striped jack sashimi as a single dish, not a set, for 70,000 won.

I was fairly happy with the quality of the fish. We did wait a long time between ordering and getting the plate, though. The only side dish was some peanuts, so there was almost nothing to eat with it. I tried to allow for it being an izakaya and not a raw fish restaurant, but so few side dishes was a letdown.
The two of us drank two bottles of soju.
---
# 동래김씨의 시마아지회, 생선은 좋았지만 늦게 나왔어요
> 시마아지가 먹고 싶어서 퇴근하는 친구와 동래역 근처 이자카야에 갔어요. 7만 원짜리 시마아지회는 생선 상태가 좋았는데, 나오는 데 오래 걸렸고 곁들임은 땅콩 정도였어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-dongnae-gimssi-20260704/
- Published: 2026.09.07
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #sashimi, #izakaya
시마아지가 먹고 싶던 토요일이었어요. 퇴근하는 친구에게 연락해서 저녁 6시 45분쯤에 동래김씨에 같이 갔어요. 동래역 근처에 있는 이자카야예요.
시마아지를 먹을 때 평소에는 부산대 앞의 [칼맛나는푸짐한횟집](/posts/jangjeon-kalmat-hoetjip-20260717/)에 가요. 그런데 그곳은 들어가려면 오래 기다려야 하고 일찍 닫아서 부담스러울 때가 있어요. 그 집에 가기 어려운 날에 시마아지를 먹을 대안으로 이 집을 생각하고, 또 갈 생각이에요.
세트가 아니라 단품으로 제철 시마아지회를 시켰고 70,000원이었어요.

생선 상태는 만족스러운 편이었어요. 다만 주문하고 회가 나올 때까지 오래 기다렸어요. 기본으로 나오는 것도 땅콩 정도라서 곁들여 먹을 것이 거의 없었어요. 횟집이 아니라 이자카야라는 점을 감안하려고 했지만, 반찬이 이렇게 적은 것은 아쉬웠어요.
소주는 둘이서 두 병을 마셨어요.
---
# All-you-can-eat clams at Jogaeview Gwangalli: great view, weak meal
> Three of us had the all-you-can-eat clam table with extra scallops. The clams were fresh, but the refills and the heat mean I will not go back.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-jogaeview-20260614/
- Published: 2026.09.07
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #grilled clams, #all-you-can-eat
After looking around [the Gwangalli Eobang Festival](/en/posts/gwangalli-eobang-chukje-20260614/), my friends and I went for grilled clams. There were three of us, and it was around three on a Sunday afternoon, early for a meal.
Because we were early, we got a seat that looks down on Gwangalli. The view was really good.

## The clam table and the scallops
We ordered the all-you-can-eat Sanhaejinmi table and added seasonal scallops.

The clams were fresh. Taken as a whole, though, the meal fell short of what I expected, for three reasons.
- Scallops: I expected large scallops, but the ones that came were small. They looked like red scallops to me, which is my own guess.
- Refills: When we asked for more, some clams came uncleaned and the portion got smaller. That is what happened to us that day.
- Heat: The grill gave off so much heat that it was very hot while we ate.
I have no plan to go back to this restaurant. I have rarely been satisfied at raw fish or grilled clam places near Gwangalli, so I tend to avoid seafood in this area. In my experience, neighborhood raw fish restaurants were better in both price and taste. That is only a personal comparison.
---
# 광안리 조개뷰의 무한리필 한상, 전망은 좋았는데 식사는 아쉬웠어요
> 어방축제를 보고 셋이서 조개 무한리필 한상에 제철 가리비를 추가했어요. 조개는 신선했고 전망도 좋았지만, 리필과 화덕의 더위 때문에 다시 갈 생각은 없어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-jogaeview-20260614/
- Published: 2026.09.07
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #grilled clams, #all-you-can-eat
[광안리 어방축제](/posts/gwangalli-eobang-chukje-20260614/)를 구경한 뒤에 친구들과 조개구이를 먹으러 갔어요. 세 명이었고, 일요일 오후 세 시쯤이라 식사 시간으로는 이른 편이었어요.
일찍 간 덕분에 광안리가 내려다보이는 자리에 앉았어요. 전망은 정말 좋았어요.

## 산해진미한상과 가리비
산해진미한상 무한리필을 시키고 제철 가리비를 추가했어요.

조개는 신선했어요. 그런데 식사 전체로 보면 기대에 못 미쳤어요. 이유는 세 가지예요.
- 가리비: 왕가리비를 기대했는데 나온 것은 크기가 작았어요. 홍가리비인 것 같았는데, 제가 그렇게 짐작한 것이에요.
- 리필: 리필을 받았을 때 손질이 되지 않은 조개가 나오기도 했고 양이 줄기도 했어요. 제가 이날 겪은 일이에요.
- 더위: 화덕의 열기 때문에 먹는 동안 많이 더웠어요.
이 집에 다시 갈 계획은 없어요. 광안리 근처의 횟집이나 조개구이집에서 만족한 적이 드물어서, 이 동네에서는 해산물을 먹는 일을 꺼리게 돼요. 제 경험으로는 동네 횟집이 가격과 맛 모두 나았어요. 어디까지나 개인적인 비교예요.
---
# Thin-cut squid delivered from Uosin in Mandeok: plenty, but pricey
> I ordered delivery from a nearby live squid restaurant. It was plenty for one and well cut, but spending over 50,000 won on one meal was a burden.
- Canonical: https://jaemyeong.com/en/blog/mandeok-uosin-20260626/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #squid, #delivery
At about 5 on a Friday afternoon I wanted squid at home, so I ordered delivery. Uosin is a live squid restaurant close to my home, and I have eaten there with my family before. The restaurant is near Mandeok Station.
I like squid, but prices have gone up a lot lately and I feel it. This meal also cost more than 50,000 won.
The house style is to cut the squid as thin as thread. The menu says you can ask for a different thickness.

I had no real complaint about the knife work or the freshness. I do prefer wide, thin slices to thin threads, though. The portion was plenty for one person. I do not know whether it was one squid or two.
I hear the squid sells out early every day, so my guess is that it comes in daily. If you live near Mandeok, it is worth considering, at the restaurant or by delivery. Next time I plan to go and eat there with my family.
---
# 만덕 우오신의 실 오징어회 배달, 양은 넉넉하고 값은 부담됐어요
> 집에서 오징어가 당겨서 가까운 산오징어 횟집에 배달을 시켰어요. 혼자 먹기에 넉넉했고 손질도 괜찮았는데, 한 끼에 5만 원이 넘은 것은 부담이었어요.
- Canonical: https://jaemyeong.com/ko/blog/mandeok-uosin-20260626/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Mandeok, #squid, #delivery
금요일 오후 5시쯤, 집에서 오징어가 먹고 싶어져서 배달을 시켰어요. 우오신은 집에서 가까운 산오징어 횟집이고, 전에 가족과 매장에서 먹은 적이 있어요. 매장은 만덕역 근처에 있어요.
저는 오징어를 좋아하는 편인데 요즘 값이 많이 올라서 부담을 느껴요. 이날도 한 끼에 5만 원이 넘게 들었어요.
이 집은 오징어를 실처럼 가늘게 써는 것이 기본이에요. 메뉴에는 두께를 바꿀 수 있다고 적혀 있어요.

손질 솜씨나 신선도에는 크게 아쉬운 점이 없었어요. 다만 저는 가늘게 썬 것보다 넓고 얇게 썬 쪽을 더 좋아해요. 양은 혼자 먹기에 넉넉했어요. 한 마리인지 두 마리인지는 모르겠어요.
오징어가 날마다 일찍 떨어진다고 하는 것을 보면 매일 들여오는 것 같다고 짐작해요. 만덕 근처에 산다면 매장이든 배달이든 한 번 생각해 볼 만해요. 저는 다음에 가족과 매장에 가서 먹으려고 해요.
---
# Oncheoncheon Yanggopchang: staff-grilled tripe with no off smell
> A beef tripe grill in Dongnae where three of us went after sashimi. The staff grilled two servings of the premium mix, and we finished with ramyeon.
- Canonical: https://jaemyeong.com/en/blog/dongnae-oncheoncheon-yanggopchang-20260823/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #gopchang, #barbecue
At about 8 on a Sunday evening, after sashimi at [Namhae Hoetjip](/en/posts/oncheonjang-namhae-hoetjip-20260823/), three of us went to Oncheoncheon Yanggopchang for a second round. It is over by Oncheonjang Station.
We had already eaten, so we started with just two servings of the premium mix and planned to order more if we were still hungry. The mix has premium tripe, large intestine, and small intestine.

The staff do the grilling. I did not notice any off smell from the intestines. The service was friendly too. If you are in Oncheonjang, I think it is worth one visit.
The restaurant used to be down an alley, and it has grown since it moved to a bigger space. The inside looked clean. Autographs from baseball players cover the walls, and the screen by the entrance shows baseball-related things as well. I am told the place is known among baseball fans. I also heard it is a well-known player's regular spot, though I do not know whether he still comes. If you like baseball, the autographs are fun to look at.
It is open daily from 3 p.m. to 2 a.m. and has parking.
That night we did not order more tripe. We finished with the gopchang hot pot ramyeon and left.
---
# 온천천양곱창, 구워 준 특모듬에서 잡내가 나지 않았어요
> 남해횟집에서 회를 먹은 뒤 셋이서 2차로 간 동래의 양곱창집이에요. 특모듬 2인분을 직원분이 구워 줬고 곱창 냄새가 거슬리지 않았어요. 마지막에는 곱창전골라면을 먹었어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-oncheoncheon-yanggopchang-20260823/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #gopchang, #barbecue
[남해횟집](/posts/oncheonjang-namhae-hoetjip-20260823/)에서 회를 먹고 나온 일요일 저녁 8시쯤, 셋이서 두 번째 자리로 온천천양곱창에 갔어요. 온천장역 쪽에 있는 집이에요.
이미 한 끼를 먹은 터라 특모듬을 2인분만 먼저 시켰어요. 모자라면 더 시킬 생각이었어요. 특모듬에는 특양과 대창, 곱창이 들어가요.

굽는 것은 직원분이 맡아서 해 줘요. 곱창에서 거슬리는 냄새를 느끼지 못했어요. 응대도 친절했어요. 온천장에 갈 일이 있는 분께는 한 번 가 보라고 권할 만해요.
예전에는 골목 안에 있던 가게인데, 자리를 넓혀 옮긴 뒤로 규모가 커졌어요. 안은 깨끗해 보였어요. 벽에는 야구 선수들의 사인이 붙어 있고, 입구 쪽 화면에도 야구와 관련된 내용이 나와요. 야구팬들 사이에서 알려진 집이라고 해요. 이름난 야구 선수의 단골집이라는 말도 들었는데, 요즘도 오는지는 저도 몰라요. 야구를 좋아하면 사인을 구경하는 재미도 있어요.
영업은 매일 오후 3시부터 다음 날 새벽 2시까지이고, 주차를 할 수 있어요.
이날 곱창은 더 시키지 않았고, 마지막으로 곱창전골라면을 먹고 일어났어요.
---
# Dokdo Kkotsaeu in Dongnae: expensive, but very satisfying to eat
> A second stop at a spot prawn specialist near Dongnae Station. The raw prawn, cleaned in front of you, was great. The portion was small for the price.
- Canonical: https://jaemyeong.com/en/blog/dongnae-dokdo-kkotsaeu-20260711/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #shrimp, #seafood
After the first round at [Singsing Domae Hoetjip](/en/posts/dongnae-singsing-domae-hoetjip-20260711/), I looked for the next place and chose Dokdo Kkotsaeu. I like spot prawn. It was about 10 on a Saturday night. The restaurant specializes in spot prawn and is 59 m to the left of exit 4 of Dongnae Station.
Spot prawn comes in small, medium, and large, and the small starts at 70,000 won. They clean the prawns in front of you.

Eaten raw, the prawn tasted good and had a good bite. The heads come back deep-fried, and they were crisp. A salt-grilled dish was served too.
The price is a heavy burden, and the portion felt small for it. Still, eating it was so satisfying that I felt the price was worth bearing. I would eat this more often if I could afford to.
Raw horned turban was available that day, so I added it.

---
# 동래 독도 꽃새우, 비싸지만 먹는 만족은 컸어요
> 싱싱도매횟집에서 1차를 하고 꽃새우 전문점으로 옮겼어요. 눈앞에서 손질해 준 꽃새우 회는 맛과 식감이 좋았고, 값에 비해 양이 적은 점은 아쉬웠어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-dokdo-kkotsaeu-20260711/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #shrimp, #seafood
[싱싱도매횟집](/posts/dongnae-singsing-domae-hoetjip-20260711/)에서 1차를 마치고 다음에 갈 곳을 찾다가 독도 꽃새우를 골랐어요. 제가 꽃새우를 좋아하거든요. 토요일 밤 10시쯤이었어요. 동래역 4번 출구로 나와서 왼쪽으로 59m 거리에 있는 꽃새우 전문점이에요.
꽃새우는 소, 중, 대 크기가 있고 소가 70,000원부터예요. 새우는 손님 앞에서 손질해 줘요.

회로 먹은 꽃새우는 맛도 좋고 씹는 느낌도 좋았어요. 머리는 튀겨서 내주는데 바삭했어요. 소금구이도 나왔어요.
값은 부담이 커요. 가격에 비해 양도 아쉬웠어요. 그래도 먹는 만족이 커서, 비싼 값을 감수할 만하다고 느꼈어요. 형편이 넉넉하다면 더 자주 먹고 싶은 음식이에요.
이날은 뿔소라회를 주문할 수 있다고 해서 추가로 시켰어요.

---
# Namhae Hoetjip in Oncheonjang: good filefish, a few letdowns
> Filefish and squid sashimi with friends on a Sunday evening in Oncheonjang. The fish was good, but the sauce cost extra and the food was slow to arrive.
- Canonical: https://jaemyeong.com/en/blog/oncheonjang-namhae-hoetjip-20260823/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #sashimi, #seafood
I called some friends together, and we met in Oncheonjang at about 6 on a Sunday evening. Namhae Hoetjip was the first stop that night. I wrote up the second one in [my Oncheoncheon Yanggopchang note](/en/posts/dongnae-oncheoncheon-yanggopchang-20260823/).
The plan was sashimi, and at first I had gizzard shad and squid in mind. We went to Makssereo Hoetjip next door first. By the time we got there the gizzard shad had sold out, though it looked like they still had squid. After some back and forth we settled on Namhae Hoetjip, because I spotted filefish, which I have always liked.
We picked our fish, went up to the second floor, and waited. The side dishes came out first.

The sashimi took a long time. Getting a server's attention took a long time too. I could only guess that there were not many people working for a room that size. When I asked for water, it came frozen solid. While I was breaking up the ice, they swapped it for another bottle.
We ordered filefish and squid sashimi. As I remember it, the filefish was about 40,000 won and the squid about 25,000 won. There is a separate dipping sauce charge of 5,000 won per person.

Setting the inconveniences aside, I was happy with how the sashimi tasted. I was sorry the filefish liver did not come with it, though. I like the taste of that liver. The squid came in thin strips, and I prefer it sliced flat.
Once I learned about the sauce charge, I thought we would have spent less at Makssereo Hoetjip. I did not actually compare the two, so that is only my guess. By my own standards, Busan has plenty of sashimi places that do better on price or food. I have not decided whether I will go back. I am also thinking of trying Makssereo Hoetjip next door next time. About a month later [I did](/en/posts/oncheonjang-makssereo-hoetjip-20260925/).
The restaurant closes on the third Sunday of each month and has parking. The day I went was the fourth Sunday.
---
# 온천장 남해횟집, 쥐치회는 맛있었지만 아쉬움이 남았어요
> 친구들과 일요일 저녁에 온천장 남해횟집에서 쥐치회와 오징어회를 먹었어요. 회 맛은 좋았는데 초장값을 따로 받았고, 회가 늦게 나왔고, 기대한 쥐치 간은 나오지 않았어요.
- Canonical: https://jaemyeong.com/ko/blog/oncheonjang-namhae-hoetjip-20260823/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Oncheonjang, #sashimi, #seafood
친구들과 모임을 하려고 제가 불러서 일요일 저녁 6시쯤 온천장에 모였어요. 이날의 첫 자리가 남해횟집이었고, 두 번째 자리는 [온천천양곱창 글](/posts/dongnae-oncheoncheon-yanggopchang-20260823/)에 따로 적었어요.
회를 먹을 생각이었고, 처음에는 전어와 오징어를 떠올렸어요. 먼저 간 곳은 바로 옆에 있는 막썰어횟집이었어요. 도착했을 때 전어는 다 팔린 뒤였고, 오징어는 있는 듯 보였어요. 고민하다가 남해횟집으로 정했어요. 평소 좋아하는 쥐치가 눈에 띄었거든요.
생물을 고른 다음 2층으로 올라가서 회를 기다렸어요. 밑반찬이 먼저 깔렸어요.

회가 나오기까지 오래 기다렸어요. 직원분을 부르는 데도 시간이 오래 걸렸어요. 홀 크기에 비해 일하는 분이 적어서 그런가 하고 짐작만 했어요. 물을 부탁했더니 단단하게 언 물이 왔어요. 얼음을 깨고 있으니 다른 물로 바꿔 줬어요.
시킨 것은 쥐치회와 오징어회예요. 기억으로는 쥐치회가 4만 원쯤, 오징어회가 2만 5천 원쯤이었어요. 초장값은 한 사람에 5,000원씩 따로 받아요.

불편했던 일들과는 별개로 회 맛에는 만족했어요. 다만 기대한 쥐치 간이 나오지 않아서 아쉬웠어요. 저는 쥐치 간의 맛을 좋아해요. 오징어는 채를 썰어서 나왔는데, 저는 포를 뜬 쪽을 더 좋아해요.
초장값을 따로 받는다는 것을 알고 나서는, 막썰어횟집으로 갔으면 돈이 덜 들었겠다는 생각을 했어요. 실제로 견줘 본 것은 아니고 제 짐작이에요. 제 기준으로는 부산에 값이나 음식이 더 나은 횟집이 많아요. 이 집에 다시 갈지는 아직 정하지 못했어요. 다음에는 옆집 막썰어횟집에 가 볼까 하는 생각도 있어요. 한 달쯤 뒤에 [실제로 가 봤어요](/posts/oncheonjang-makssereo-hoetjip-20260925/).
가게는 매달 셋째 일요일에 쉬고, 주차를 할 수 있어요. 제가 간 날은 넷째 일요일이었어요.
---
# Sanmaru Garden: cold noodles, plus dumplings I recommend
> A cold noodle restaurant I make a point of visiting in Daegwallyeong. On a Friday at noon I had mul-naengmyeon and the corn cheese pumpkin dumplings.
- Canonical: https://jaemyeong.com/en/blog/pyeongchang-sanmaru-garden-20260904/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Pyeongchang, #Daegwallyeong, #noodles, #lunch
This is a cold noodle restaurant I make a point of visiting whenever I am in Daegwallyeong. I went at about noon on a Friday for a quick lunch.
I usually prefer the spicy mixed version, bibim-naengmyeon. That day, though, I wanted the one in broth. Maybe it was because I had been drinking the night before.

I had the corn cheese pumpkin dumplings with it. They are a limited item, and I recommend them. I did not get a photo.
When I come with a group we often get the boiled pork slices, which are good too. The rice cake and dumpling soup I had here in winter was also tasty. I think I have tried nearly everything on the menu.
The road is newly paved, and I am told it may not show up on navigation apps. The advice is to watch for the signs from about 300 m before you arrive. There is parking. It is open from 11 a.m. to 8 p.m., with a break from 3 to 5 p.m. Last orders are at 7:30 p.m., and it is closed on Tuesdays.
Masil Cafe is on the second floor. I suggest going up for coffee after the noodles.
---
# 대관령 산마루가든, 물냉면에 권하고 싶은 만두를 곁들였어요
> 대관령에 가면 챙겨 가는 냉면집이에요. 금요일 낮 12시쯤에 물냉면과 콘치즈호박만두로 점심을 간단히 먹었어요. 평소에는 비빔냉면을 고르는데 이날은 물냉면이 당겼어요.
- Canonical: https://jaemyeong.com/ko/blog/pyeongchang-sanmaru-garden-20260904/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Pyeongchang, #Daegwallyeong, #noodles, #lunch
대관령에 가면 챙겨 가는 냉면집이에요. 금요일 낮 12시쯤, 점심을 간단히 먹으려고 갔어요.
저는 평소에 비빔냉면을 좋아해요. 그런데 이날은 물냉면이 먹고 싶었어요. 전날 술을 마셔서 그랬는지도 모르겠어요.

콘치즈호박만두도 같이 먹었어요. 한정으로 내는 메뉴이고, 권하고 싶은 음식이에요. 사진은 찍지 못했어요.
여럿이 올 때는 수육을 자주 시켜요. 수육도 좋아요. 겨울에 먹은 떡만두국도 맛있었어요. 이 집 메뉴는 거의 다 먹어 본 것 같아요.
새로 포장한 길이라 내비게이션에 나오지 않을 수 있다고 해요. 도착하기 300m쯤 전부터 표지판을 보면서 가라는 안내가 있어요. 주차는 할 수 있어요. 영업은 오전 11시부터 저녁 8시까지이고, 오후 3시부터 5시까지는 쉬어요. 주문은 저녁 7시 반에 마감하고 화요일에 쉬어요.
2층에는 마실카페가 있어요. 냉면을 먹고 올라가서 커피를 마시는 순서를 권해요.
---
# Daegwallyeong Neungi Maru: the broth of the chicken baeksuk stood out
> Whole native chicken simmered with neungi mushrooms on a Wednesday evening in Daegwallyeong. It was my first neungi, and the broth was the best part.
- Canonical: https://jaemyeong.com/en/blog/pyeongchang-daegwallyeong-neungi-maru-20260902/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Pyeongchang, #Daegwallyeong, #chicken, #Korean
I went to Gangwon-do to see a friend. I told my friend to choose what we would eat, and this baeksuk restaurant was the pick. We went at about 8 on a Wednesday evening.
It is in central Hoenggye in Daegwallyeong-myeon. I heard it had been open for about two months. I was also told the place sits 700 m above sea level. Native chicken takes a while to cook, so we booked ahead to avoid the wait.

This was the first time I had eaten neungi mushrooms. Until then I had never had neungi, shiitake, or pine mushrooms. The baeksuk was good overall, and the broth most of all. The chicken was not tough. My guess is that comes from the long cooking. My friend enjoyed it too.

It is not cheap. I think it is worth ordering if three or four people are eating together.
The dining room and the restroom were very clean. Parking is free, with two large lots, and valet is available. It is open from 10 a.m. to 10 p.m. and closed on Tuesdays.
When you finish the chicken, mixed-grain rice comes out, and you can ask for more. You put it in the remaining broth and boil it into a porridge. I drank one bottle of soju that evening.
---
# 대관령능이마루, 능이백숙은 국물이 특히 좋았어요
> 친구를 만나러 간 대관령에서 수요일 저녁에 능이토종닭백숙을 먹었어요. 능이버섯은 처음이었고 국물이 특히 좋았어요. 남은 국물에는 영양밥을 넣어 죽으로 끓여 먹어요.
- Canonical: https://jaemyeong.com/ko/blog/pyeongchang-daegwallyeong-neungi-maru-20260902/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Pyeongchang, #Daegwallyeong, #chicken, #Korean
친구를 만나러 강원도에 갔어요. 먹고 싶은 것을 친구에게 정하라고 했더니 친구가 고른 곳이 이 백숙집이에요. 수요일 저녁 8시쯤에 갔어요.
대관령면 횡계의 중심가에 있어요. 문을 연 지 두 달쯤 됐다고 들었어요. 이곳이 해발 700m라는 이야기도 들었어요. 토종닭은 익히는 데 시간이 걸려서, 기다리지 않으려고 미리 예약하고 갔어요.

저는 능이버섯을 이날 처음 먹었어요. 그전까지는 능이도 표고도 송이도 먹어 본 적이 없어요. 백숙은 전반적으로 맛있었고, 그중에서도 국물이 좋았어요. 닭고기는 질기지 않았어요. 오래 익혀서 그런 것이 아닐까 싶어요. 친구도 맛있게 먹었어요.

값이 싼 편은 아니에요. 서너 명이 같이 먹는다면 시켜 볼 만하다고 생각해요.
안과 화장실이 아주 깨끗했어요. 주차는 무료이고 넓은 주차 공간이 두 곳 있으며, 발렛도 돼요. 영업은 오전 10시부터 밤 10시까지이고 화요일에 쉬어요.
백숙을 다 먹으면 영양밥이 나오고, 더 달라고 할 수도 있어요. 남은 국물에 밥을 넣어 죽처럼 끓여 먹어요. 저는 이날 소주를 한 병 마셨어요.
---
# Rentan Suyeong: I was full and kept eating the beef makchang
> A second stop next door to Bogwangdang. Grilled cuttlefish and Daegu-style beef makchang on one plate, plus roasted chestnuts for showing a receipt.
- Canonical: https://jaemyeong.com/en/blog/suyeong-rentan-20260827/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #grill, #bar
After raw beef and a cheeseburger at [Bogwangdang](/en/posts/suyeong-bogwangdang-20260827/), we moved next door to Rentan at about 8 on a Thursday evening. I was already full.
Rentan is a bar that serves food grilled over coal briquettes, and I have been many times. On [my visit in March](/en/posts/suyeong-rentan-20260324/) I had the seasoned Korean pork cartilage ribs. On [another visit in September](/en/posts/suyeong-rentan-20260919/) I ordered only the cuttlefish. I was told the briquettes they use are patented and that there are more than twenty things they can grill over them.
The signature dish is the cuttlefish, and it is what I usually pick. This time I ordered the Daegu-style beef makchang along with it.

The cuttlefish was grilled golden on the outside and springy to bite. I liked the richness of the butter too. As for the makchang, I liked it enough to keep reaching for it even though I was full.
If you show a receipt from Bogwangdang, they give you roasted chestnuts. I got some as well. I do not know whether the two places have the same owner.

There is a display of whisky inside. It has more room than the bar next door, which made it a good second stop. It is closed on Sundays and has no parking of its own. The shop points people to a paid lot next to the post office, to the right of the Paldo Market entrance.
I expect to keep ordering the cuttlefish, and next time I may add the chicken feet or chicken skewers.
---
# 렌탄 수영본점, 배부른데도 소막창이 계속 들어갔어요
> 보광당탕제원에서 먹고 바로 옆 렌탄으로 옮긴 2차예요. 갑오징어와 대구식 소막창을 한 접시로 받았고, 앞 가게의 영수증을 보여 주고 군밤도 받았어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-rentan-20260827/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #grill, #bar
[보광당탕제원](/posts/suyeong-bogwangdang-20260827/)에서 뭉티기와 치즈버거를 먹고 나서, 목요일 저녁 8시쯤 바로 옆에 있는 렌탄으로 옮겼어요. 이미 배가 부른 상태였어요.
렌탄은 연탄으로 구운 안주를 내는 술집이고, 저는 여러 번 온 집이에요. [3월에 온 날](/posts/suyeong-rentan-20260324/)에는 양념 오돌갈비를 먹었어요. [9월에 또 온 날](/posts/suyeong-rentan-20260919/)에는 갑오징어 하나만 시켰어요. 이 집에서 쓰는 연탄은 특허를 받은 것이고 구울 수 있는 재료가 스무 가지를 넘는다는 설명을 들었어요.
대표 메뉴는 갑오징어이고, 저도 평소에 이것을 골라요. 이날은 갑오징어에 대구식 소막창을 같이 시켰어요.

갑오징어는 겉이 노릇하게 구워졌고 씹는 맛이 탱탱했어요. 버터의 고소한 맛도 좋았어요. 소막창은 배가 부른데도 계속 손이 갈 만큼 마음에 들었어요.
보광당탕제원에서 계산한 영수증을 보여 주면 군밤을 줘요. 저도 받았어요. 두 가게가 같은 사장님의 가게인지는 저도 몰라요.

가게 안에는 위스키를 진열해 둔 곳도 있어요. 옆 가게보다 자리가 여유로워서 두 번째 자리로 삼기에 좋았어요. 일요일에 쉬고, 가게에는 주차할 수 없어요. 팔도시장 입구에서 오른쪽, 우체국 옆에 유료 주차장이 있다고 안내하고 있어요.
갑오징어는 앞으로도 시킬 것 같고, 다음에는 닭발이나 닭꼬치를 더해 볼까 해요.
---
# Bogwangdang in Suyeong: raw beef and a cheeseburger, both good
> A small bar in Suyeong that was always full when I passed. A friend and I got in at 6:30 on a Thursday and had raw beef and a cheeseburger.
- Canonical: https://jaemyeong.com/en/blog/suyeong-bogwangdang-20260827/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #bar, #raw, #burger
I had walked past this place often and been curious about it. Every time I tried, it was full and I could not get in. This time a friend and I went at about 6:30 on a Thursday evening, earlier than I usually go out, and there were seats. Afterwards we moved next door to Rentan, which I wrote about in [my August Rentan note](/en/posts/suyeong-rentan-20260827/).
It is a bar that serves food, near Suyeong Station. The first order has to include at least two dishes. I had the impression the place is known for its raw beef, so I chose the mungtigi, and added the cheeseburger I had seen in other people's reviews. On the menu the raw beef is 38,000 won and the cheeseburger 12,000 won.

The beef was in good condition and pleasantly chewy. I was told they use meat slaughtered that day.


I liked the patty in the cheeseburger. I wondered if that is because this is a place that handles meat.
We finished three bottles of soju quickly. I look for a soju cap printed with INTJ, and once again it did not turn up.
The prices seemed acceptable to me, and the menu is a collection of things I like. My one complaint is that the room is small. The bar opens at 6 p.m. and is closed on Sundays. Reservations are taken only for the 6 to 7 p.m. slot, and after that seating is in order of arrival. There is no parking.
Next time I want to try the sea urchin yukhoe.
---
# 수영 보광당탕제원, 뭉티기와 치즈버거가 둘 다 좋았어요
> 늘 자리가 없어서 못 들어가던 수영의 요리주점에 목요일 저녁 6시 반쯤 친구와 갔어요. 뭉티기는 고기 상태와 씹는 맛이 좋았고, 치즈버거는 패티가 마음에 들었어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-bogwangdang-20260827/
- Published: 2026.09.06
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #bar, #raw, #burger
자주 지나다니면서 궁금했던 가게예요. 그동안은 들를 때마다 자리가 차 있어서 들어가지 못했어요. 이날은 목요일 저녁 6시 반쯤으로, 평소보다 이르게 친구와 갔더니 빈자리가 있었어요. 먹고 나서는 옆집 렌탄으로 옮겼고, 그 이야기는 [렌탄 8월 글](/posts/suyeong-rentan-20260827/)에 적었어요.
수영역 근처의 요리주점이에요. 첫 주문은 메뉴를 두 가지 이상 시켜야 해요. 육사시미로 알려진 집이라는 인상이 있어서 뭉티기를 골랐고, 다른 분들의 후기에서 본 치즈버거를 하나 더 시켰어요. 메뉴판 가격은 육사시미가 38,000원, 치즈버거가 12,000원이에요.

뭉티기는 고기 상태가 좋았고 씹는 맛도 좋았어요. 그날 잡은 고기를 쓴다는 말을 들었어요.


치즈버거는 패티가 마음에 들었어요. 고기를 다루는 집이라서 그런가 하고 생각했어요.
소주 세 병을 금방 비웠어요. 소주 병뚜껑에서 INTJ가 적힌 것을 찾아보는데, 이번에도 나오지 않았어요.
값은 제 기준으로 받아들일 만했고, 제가 좋아하는 음식이 모여 있는 메뉴판이었어요. 아쉬운 점은 안이 좁다는 것이에요. 가게는 저녁 6시에 열고 일요일에 쉬어요. 예약은 6시부터 7시 사이의 시간만 받고, 그 뒤로는 온 순서대로 앉아요. 주차는 할 수 없어요.
다음에 가면 우니 육회를 먹어 보고 싶어요.
---
# Butasinise in Haeundae: good pork, but I could do without the music
> The Si set at a pork barbecue place in Haeundae: bone-in pork belly, neck, and skin. The meat was good and the staff were kind. The music was not to my taste.
- Canonical: https://jaemyeong.com/en/blog/haeundae-butasinise-20260628/
- Published: 2026.09.05
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #pork, #barbecue
Someone younger than me suggested a drink, so at about 7 on a Sunday evening we went to Butasinise Haeundae. It is a pork barbecue restaurant in the Haeundae Station area.
The room was tidy. I wondered whether it had opened recently, but I am not sure. A group having a company dinner was there when we went.
We ordered the Si set, which comes with bone-in pork belly, pork neck, and pork skin. The person who took the order and grilled the meat was a staff member from abroad. The grilling looked a little unpracticed, but they were kind, so I did not mind.

The meat had no off smell and tasted good.
What I did not like was the music. Korean pop songs were playing, and I felt this place would be better quiet, without music. The noise from the company dinner did not bother me much. The music did.
If you live nearby, it is worth one visit. I would go again too, but Haeundae is far from my home, so I do not know how often. There is also a note on [Issi](/en/posts/haeundae-issi-20260628/), which I visited on the same day.
---
# 해운대 부타시니세의 시 세트, 고기는 좋았고 음악은 아쉬웠어요
> 해운대의 고깃집에서 뼈오겹, 목살, 껍데기가 나오는 시 세트를 먹었어요. 고기는 잡내 없이 맛있었고 직원분은 친절했어요. 매장에 흐르던 가요는 제 취향이 아니었어요.
- Canonical: https://jaemyeong.com/ko/blog/haeundae-butasinise-20260628/
- Published: 2026.09.05
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #pork, #barbecue
저보다 어린 지인이 한잔하자고 해서 일요일 저녁 7시쯤에 부타시니세 해운대점에 갔어요. 해운대역 쪽에 있는 고깃집이에요.
실내는 깔끔했어요. 연 지 얼마 안 된 곳인가 싶었는데 확실하지는 않아요. 저희가 갔을 때는 회식을 하는 손님들이 있었어요.
뼈오겹과 목살, 껍데기가 나오는 시 세트를 주문했어요. 주문을 받고 고기를 구워 준 분은 외국인 직원이었어요. 굽는 솜씨는 조금 서툴러 보였지만 친절해서 괜찮았어요.

고기는 잡내가 없고 맛있었어요.
아쉬웠던 것은 음악이에요. 매장에 가요가 나오고 있었는데, 이 고깃집에서는 음악 없이 조용한 편이 더 좋겠다고 느꼈어요. 회식 자리의 소리는 크게 거슬리지 않았고 음악이 더 신경 쓰였어요.
근처에 산다면 한 번 가 볼 만해요. 저도 다시 갈 마음은 있는데, 집에서 해운대가 멀어서 얼마나 자주 갈지는 모르겠어요. 같은 날 밤에 다녀온 [잇시](/posts/haeundae-issi-20260628/)의 기록도 있어요.
---
# Marinated galbi at Silla Nongwon in Deokcheon: the kids liked it more
> After a hospital visit, my family ate at a galbi place near Deokcheon Station. The kids said it beat their usual spot. I liked the price and taste too.
- Canonical: https://jaemyeong.com/en/blog/deokcheon-silla-nongwon-20260624/
- Published: 2026.09.04
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #galbi, #family
We went for dinner after visiting a family member at Gupo Seongsim Hospital. It was about 7:20 on a Wednesday evening, and the family included two of my nieces and nephews.
We usually go to a galbi place near home that the kids like. That day we chose a different restaurant we had not been to for a while. Trying a new place was part of the fun. Silla Nongwon is near Deokcheon Station, and I heard it has been around for 20 years. The restaurant is large, and there seemed to be many families.
We ordered marinated galbi. The meat comes in a bowl, sitting in its marinade.


Both kids enjoyed it and said it tasted better than their usual place. I also felt the price and the taste were good enough to recommend.
I do not expect to be in this area often. If I do come back, though, I would eat here.
---
# 덕천 신라농원의 양념갈비, 조카들이 더 맛있다고 했어요
> 병문안을 마치고 조카 둘을 포함한 가족과 덕천역 근처 갈비집에 갔어요. 조카들은 늘 가던 집보다 맛있다고 했고, 저도 가격과 맛이 모두 괜찮다고 느꼈어요.
- Canonical: https://jaemyeong.com/ko/blog/deokcheon-silla-nongwon-20260624/
- Published: 2026.09.04
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #galbi, #family
구포 성심병원에서 가족 병문안을 마치고 저녁을 먹으러 갔어요. 수요일 저녁 7시 20분쯤이었고, 조카 둘을 포함한 가족이 함께였어요.
평소에는 조카들이 좋아하는 집 근처 갈비집에 주로 가요. 이날은 한동안 가지 않았던 다른 가게를 골랐어요. 새로운 식당을 찾아가 보는 재미도 있었어요. 신라농원은 덕천역 근처에 있고, 20년 된 집이라고 들었어요. 가게가 넓고 가족 손님이 많아 보였어요.
주문한 것은 양념갈비예요. 양념에 재워 둔 고기가 그릇에 담겨 나와요.


조카들은 둘 다 맛있어했고, 늘 가던 집보다 여기가 더 맛있다고 했어요. 저도 가격과 맛이 모두 권할 만한 수준이라고 느꼈어요.
이 근처에 자주 올 것 같지는 않아요. 그래도 다시 올 일이 생기면 여기에서 먹을 생각이에요.
---
# Miso, an oden bar in Dongnae: an easy second stop when you are full
> After filling up at the first place, we had a drink over oden and rice cake skewers. The staff are kind and the food is light, good for a second round.
- Canonical: https://jaemyeong.com/en/blog/dongnae-miso-20260625/
- Published: 2026.09.03
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #oden, #bar
We had already filled up at the first place, so for the second round we picked a place where a little food is enough. That was Miso, an oden bar, at about 9:30 on a Thursday night. There is also a note on [Dyeonbyeorak Makchang](/en/posts/dongnae-dyeonbyeorak-makchang-20260625/), which I visited on the same day.
It is across from the Dongnae public parking lot. There are two kinds of seats: ordinary tables, and tables built around the oden bar. We usually sit at the oden bar. The staff are very kind, and takeout is possible.
The oden bar is shared. You pick the oden you want yourself, and the rice cake skewer is ordered separately. Each person must order at least four oden.

I mostly eat the basic square oden, and I have one rice cake skewer every time I go. Sometimes I add grilled toothfish, but not that night. A night when I did have it is in [my May visit note](/en/posts/dongnae-miso-20260507/). It goes well with a bottle or two of soju.
---
# 동래 오뎅바 미소, 배부른 날 2차로 부담이 없어요
> 1차에서 배를 채운 뒤에 동래역 근처 오뎅바에서 오뎅과 물떡만 놓고 한잔했어요. 직원분이 친절하고 안주 부담이 적어서, 배부른 날의 2차로 자주 떠올리는 집이에요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-miso-20260625/
- Published: 2026.09.03
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #oden, #bar
1차에서 배를 채운 날이라 2차는 안주를 조금만 먹을 수 있는 곳으로 골랐어요. 목요일 밤 9시 반쯤에 간 오뎅바 미소예요. 같은 날 저녁에 다녀온 [뎐벼락막창](/posts/dongnae-dyeonbyeorak-makchang-20260625/)의 기록도 있어요.
위치는 동래공영주차장 건너편이에요. 자리는 일반 테이블과 오뎅바가 붙어 있는 테이블 두 가지가 있어요. 저희는 보통 오뎅바 쪽에 앉아요. 직원분이 무척 친절하고, 포장도 돼요.
오뎅바는 여럿이 같이 써요. 오뎅은 원하는 것을 직접 골라 먹고, 물떡은 따로 주문해요. 오뎅은 한 사람당 네 개 이상 주문해야 해요.

저는 주로 기본 네모 오뎅을 먹고, 물떡은 갈 때마다 하나씩 먹어요. 가끔 메로구이를 추가하는데 이날은 시키지 않았어요. 메로구이를 먹은 날은 [5월 방문 기록](/posts/dongnae-miso-20260507/)에 있어요. 소주 한두 병을 곁들이기에 좋아요.
---
# Small rock bream at Singsing Domae Hoetjip in Dongnae: fresh and fair
> A raw fish place in front of Dongnae Station I go to for sashimi at an easy price. I had the small rock bream for 65,000 won with a bottle of soju.
- Canonical: https://jaemyeong.com/en/blog/dongnae-singsing-domae-hoetjip-20260711/
- Published: 2026.09.02
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #sashimi, #raw fish
When I want sashimi at a price that does not hurt, I go to Singsing Domae Hoetjip. It is right in front of exit 4 of Dongnae Station, 22 m away. This time I went at about 8 on a Saturday evening. It suits a light first round of sashimi, so I plan to keep going.
It is a busy place. It is usually close to full, and sometimes there is a wait. They take reservations and do takeout and delivery.
I eat many kinds of sashimi, and rock bream is one of the fish I like most. That day I ordered the small rock bream for 65,000 won, and had one bottle of soju. In September [I had gizzard shad with mixed sashimi](/en/posts/dongnae-singsing-domae-hoetjip-20260917/).

The fish was fresh and the price was not a burden. My guess is that the low price brings many guests, the fish turns over quickly, and that keeps it fresh.
There is also a note on [Dokdo Kkotsaeu](/en/posts/dongnae-dokdo-kkotsaeu-20260711/), which I visited on the same day.
---
# 동래 싱싱도매횟집의 돌돔 소, 신선하고 값도 부담 없었어요
> 회를 부담 없는 값에 먹고 싶을 때 가는 동래역 앞 횟집이에요. 토요일 저녁에 65,000원짜리 돌돔 소에 소주 한 병을 곁들였고, 회는 이번에도 신선했어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-singsing-domae-hoetjip-20260711/
- Published: 2026.09.02
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #sashimi, #raw fish
회를 부담이 적은 값에 먹고 싶을 때 저는 싱싱도매횟집에 가요. 동래역 4번 출구 바로 앞, 22m 거리에 있어요. 이번에는 토요일 저녁 8시쯤에 갔어요. 첫 자리에서 가볍게 회를 먹기에 알맞아서 앞으로도 갈 생각이에요.
손님이 많은 집이에요. 평소에 자리가 거의 차 있고 가끔은 기다려야 해요. 예약을 받고, 포장과 배달도 돼요.
저는 회를 여러 가지 먹는 편인데 돌돔은 그중에서도 좋아하는 생선이에요. 이날은 돌돔 소를 시켰고 65,000원이었어요. 9월에는 [전어와 모듬회를 먹었어요](/posts/dongnae-singsing-domae-hoetjip-20260917/). 소주는 한 병을 마셨어요.

회는 신선했고 값도 부담스럽지 않았어요. 값이 싸서 손님이 많고, 그만큼 생선이 빨리 돌아서 신선한 것이 아닐까 하고 짐작해요.
같은 날 밤에 다녀온 [독도 꽃새우](/posts/dongnae-dokdo-kkotsaeu-20260711/)의 기록도 있어요.
---
# Dyeonbyeorak Makchang in Dongnae: clean-tasting and chewy
> A friend and I had the plain and seasoned makchang set near Dongnae Station, then added fried rice. The makchang had no off smell, and the room was tidy.
- Canonical: https://jaemyeong.com/en/blog/dongnae-dyeonbyeorak-makchang-20260625/
- Published: 2026.09.02
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #makchang, #bar
I like makchang, grilled pork intestine, and have been to quite a few places that serve it. This was a Thursday. A friend got off work, we met at about 7:30 in the evening, and we had drinks at Dyeonbyeorak Makchang.
Come out of exit 4 of Dongnae Station, cross the road, and go a short way to the left. The spot used to be Orihyang, which served duck and monkfish. That place closed and the makchang restaurant moved in. Perhaps because it opened not long ago, the room was tidy.
We ordered the set for two to three people with both plain and seasoned makchang, for 39,000 won.
## Plain and seasoned makchang
The makchang comes already grilled once. I did not notice any off smell, and it was mild and chewy, which I liked.

The seasoned makchang is part of the same set.

## Fried rice and soju
We added fried rice. It tasted the way I expected.

As I remember it, three bottles of soju went quickly. They also do takeout and delivery and take groups. It is a place I will think of on a day I want makchang.
There is also a note on [Miso, an oden bar](/en/posts/dongnae-miso-20260625/), which I visited on the same day.
---
# 동래 뎐벼락막창, 잡내 없이 담백하고 쫄깃했어요
> 퇴근한 친구와 동래역 근처 막창집에서 막창과 양념막창 세트에 볶음밥을 더했어요. 막창은 잡내가 없고 담백하면서 쫄깃했고, 연 지 오래되지 않은 가게라 실내도 깔끔했어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-dyeonbyeorak-makchang-20260625/
- Published: 2026.09.02
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #makchang, #bar
저는 막창을 좋아해서 막창집을 여러 곳 다녀 봤어요. 이날은 목요일이었고, 친구가 퇴근한 뒤 저녁 7시 반쯤에 만나 뎐벼락막창에서 한잔했어요.
동래역 4번 출구로 나와 길을 건넌 다음 왼쪽으로 조금만 가면 있어요. 원래 오리와 아구 요리를 하던 오리향이 있던 자리인데, 그 가게가 문을 닫고 막창집이 들어왔어요. 연 지 오래되지 않아서인지 실내가 깔끔했어요.
막창과 양념막창이 함께 나오는 2~3인 세트를 시켰고 39,000원이었어요.
## 막창과 양념막창
막창은 한 번 구워서 나와요. 잡내가 느껴지지 않았고, 담백하면서 쫄깃해서 마음에 들었어요.

양념막창은 세트에 같이 들어 있어요.

## 볶음밥과 소주
볶음밥은 추가로 주문했어요. 기대한 그 맛이었어요.

소주는 세 병을 금방 비운 것으로 기억해요. 포장과 배달도 되고 단체로도 갈 수 있다고 해요. 막창이 먹고 싶은 날에 다시 떠올릴 집이에요.
같은 날 밤에 다녀온 [오뎅바 미소](/posts/dongnae-miso-20260625/)의 기록도 있어요.
---
# Fresh assorted seafood at Yongwon Gadeok Hoetjip in Gupo Market
> My mother and I had the small 40,000 won assorted seafood plate in Gupo Market. The seafood was fresh, and even the small size came with spoon worm.
- Canonical: https://jaemyeong.com/en/blog/gupo-yongwon-gadeok-hoetjip-20260630/
- Published: 2026.09.02
- Category: 식기(食記)
- Tags: #Busan, #Gupo, #seafood, #market
At about noon on a Tuesday, after visiting my father at Gupo Seongsim Hospital, my mother and I went to Gupo Market. We first meant to go to Sijang Kalguksu, but it was closed that day, so we looked for another place nearby.
My mother and I both like seafood and sometimes go out for it together. The last place we went was Gijang Haenyeochon, and we ate well there. But it closes early and stops opening in the summer heat. So that day I really wanted seafood.
Gupo Market is fairly close to home, yet I rarely go. My mother goes often and knows the restaurants well. Yongwon Gadeok Hoetjip is inside that market.
We ordered the small assorted seafood for 40,000 won.

The seafood was fresh, and at this price it felt easy on the wallet. The side dishes that come with it were neat and tasty too.
The spoon worm stood out for its nutty taste. As I remember it, Gijang Haenyeochon only served spoon worm from the large size up, which disappointed me. Here it was included even in the small size.
I had to drive, so I could not have soju, and that was a pity. If I come to Gupo Market with a friend later, I think I will suggest this place.
---
# 구포시장 용원가덕횟집의 모듬해물 소, 신선하고 개불이 고소했어요
> 병문안을 마치고 어머니와 구포시장에서 4만 원짜리 모듬해물 소를 먹었어요. 해산물이 신선했고 밑반찬도 정갈했어요. 소 크기에도 개불이 나와서 반가웠어요.
- Canonical: https://jaemyeong.com/ko/blog/gupo-yongwon-gadeok-hoetjip-20260630/
- Published: 2026.09.02
- Category: 식기(食記)
- Tags: #Busan, #Gupo, #seafood, #market
구포 성심병원에서 아버지 병문안을 마친 화요일 낮 12시쯤, 어머니와 구포시장에 갔어요. 처음에는 시장칼국수에 가려고 했는데 그날 쉬는 날이어서 근처에서 다른 곳을 찾았어요.
어머니와 저는 둘 다 해산물을 좋아해서 가끔 같이 먹으러 가요. 가장 최근에 간 곳은 기장해녀촌이었고 그때 잘 먹었어요. 그런데 그곳은 일찍 닫는 데다 더운 여름에는 영업을 쉬어요. 그래서 이날은 해산물이 무척 당겼어요.
구포시장은 집에서 가까운 편인데 저는 잘 가지 않아요. 어머니는 자주 가셔서 식당을 잘 아세요. 용원가덕횟집은 그 시장 안에 있어요.
모듬해물은 소 크기로 시켰고 40,000원이었어요.

해산물은 신선했고 이 가격이면 부담이 적다고 느꼈어요. 기본으로 나오는 밑반찬도 정갈하고 맛있었어요.
특히 개불이 고소해서 기억에 남아요. 기장해녀촌에서는 대 크기를 시켜야 개불이 나왔던 것으로 기억해서 아쉬웠는데, 여기에서는 소 크기에도 개불이 들어 있었어요.
운전을 해야 해서 소주를 마시지 못한 것이 아쉬웠어요. 나중에 친구와 구포시장에 오게 되면 이 집에 가자고 할 것 같아요.
---
# Eochon Hoetjip in Suyeong: clean-tasting sashimi at a fair price
> I went back to the raw fish place in Suyeong Paldo Market for a drink with a friend. The sashimi tasted clean and seemed skillfully cut.
- Canonical: https://jaemyeong.com/en/blog/suyeong-eochon-hoetjip-20260818/
- Published: 2026.09.02
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #sashimi, #market
I had not had a drink in Suyeong for a while. At about 6:30 on a Tuesday evening I went to Eochon Hoetjip to have one with a friend. It is a raw fish restaurant inside Suyeong Paldo Market, and there is [a note from my first visit in June](/en/posts/suyeong-eochon-hoetjip-20260616/). The seating is on the tight side.
We ordered the assorted sashimi. I remember the small size being 40,000 won, but I am not certain. It included a little gizzard shad.

The taste was clean. They seem to cut the fish with skill. Side dishes come too. The price felt reasonable as well.
After we finished the sashimi we kept talking and drinking. That was when we were given a fried fish head on the house. I do not clearly remember whether it was rockfish or dark-banded rockfish, but I think it was rockfish.

There was a red-spotted grouper in the tank, and a notice saying they sell it as sashimi. I have heard it is good, so next time I want to try it.
---
# 수영 어촌횟집의 모듬회, 다시 가도 깔끔하고 값이 좋았어요
> 오랜만에 수영에서 친구와 한잔하려고 수영팔도시장 안의 횟집에 다시 갔어요. 모듬회는 깔끔했고 회를 써는 솜씨가 있는 집 같았어요. 회를 다 먹은 뒤에는 생선 머리 튀김을 서비스로 받았어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-eochon-hoetjip-20260818/
- Published: 2026.09.02
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #sashimi, #market
한동안 수영에서 술을 마시지 못했어요. 화요일 저녁 6시 반쯤, 친구와 한잔하려고 어촌횟집에 갔어요. 수영팔도시장 안에 있는 횟집이고, [6월에 처음 왔던 기록](/posts/suyeong-eochon-hoetjip-20260616/)이 있어요. 자리는 좁은 편이에요.
모듬회를 시켰어요. 소 크기가 40,000원이었던 것으로 기억하는데 확실하지는 않아요. 전어도 조금 들어 있었어요.

맛이 깔끔했어요. 회를 써는 솜씨가 있는 집 같아요. 밑반찬도 나와요. 값도 부담이 적다고 느꼈어요.
회를 다 먹고 나서도 이야기를 하면서 술을 더 마셨어요. 그때 생선 머리 튀김을 서비스로 받았어요. 우럭인지 볼락인지 기억이 분명하지 않은데 우럭이었던 것 같아요.

어항에는 붉바리가 있었고, 붉바리 회를 판다는 안내가 붙어 있었어요. 맛있다는 이야기를 들어서 다음에는 먹어 보고 싶어요.
---
# Gijang Gomjangeo in Haeundae: the spicy grill beat the salt grill
> A hagfish place in Jwa-dong that a friend recommended. The 80,000 won set has salt-grilled and spicy grilled hagfish. It was good, but the price felt high.
- Canonical: https://jaemyeong.com/en/blog/haeundae-gijang-gomjangeo-20260822/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #hagfish, #barbecue
It was a Saturday in late summer. During the day I played in the water at the valley in the Jangsan forest park, and at about 6:30 in the evening I went to Gijang Gomjangeo for dinner. A friend had recommended it. The name has Gijang in it, but the restaurant is in Jwa-dong, Haeundae, close to Jangsan Station.
There were many guests. Several photos hang on the walls, and one is of a singer. I think it is a well-known place. At the entrance there is a spot where you fry your own egg. The limit is one per person.
We ordered the set with both salt-grilled and spicy grilled hagfish. It is 80,000 won. The price felt high. The side dishes come neatly laid out.
Before grilling, the look of the hagfish put me off a little. The salt-grilled one was seasoned enough without dipping it in more salt. It was good in a wrap with chives too.

I liked the spicy one more. I chose the slightly spicy level, and it was milder than Shin Ramyun, which was about right.

We ordered one fried rice and drank two bottles of Chamisul.
---
# 해운대 기장곰장어 본점, 소금구이보다 양념구이가 맛있었어요
> 장산 계곡에서 물놀이를 한 토요일 저녁에 친구의 추천으로 간 좌동의 곰장어집이에요. 소금구이와 양념구이가 같이 나오는 80,000원짜리 세트를 먹었어요. 맛있었지만 값은 비싸게 느꼈어요.
- Canonical: https://jaemyeong.com/ko/blog/haeundae-gijang-gomjangeo-20260822/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #hagfish, #barbecue
늦여름의 토요일이었어요. 낮에는 장산산림욕장 계곡에서 물놀이를 했고, 저녁 6시 반쯤에 밥을 먹으러 기장곰장어 본점에 갔어요. 친구가 추천해 준 집이에요. 이름에는 기장이 들어가지만 가게는 해운대 좌동에 있고 장산역에서 가까워요.
손님이 많았어요. 벽에는 사진이 여러 장 걸려 있는데 가수의 사진도 있었어요. 유명한 집인 것 같아요. 들어가는 쪽에 계란 후라이를 손님이 직접 부쳐 가는 자리가 마련돼 있고, 한 사람이 하나씩만 가져갈 수 있어요.
소금구이와 양념구이가 같이 나오는 세트를 시켰어요. 80,000원이에요. 값은 비싸다고 느꼈어요. 밑반찬은 깔끔하게 나와요.
굽기 전의 곰장어는 생김새가 조금 꺼려졌어요. 소금구이는 이미 간이 배어 있어서 소금을 더 찍을 필요가 없었어요. 부추를 얹어 쌈으로 먹는 것도 잘 어울렸어요.

둘 가운데 제 입에는 양념구이가 더 맞았어요. 맵기는 «약간 매운맛»을 골랐어요. 먹어 보니 신라면에 못 미치는 정도여서 딱 좋았어요.

볶음밥을 하나 시켰고 참이슬은 두 병을 마셨어요.
---
# The bottled cola at Starbucks Reserve: trying it once was enough
> I bought the 4,700 won bottled cola at a Starbucks Reserve store because it comes with a bottle opener. Not bad, but the fizz was weak. Once was enough.
- Canonical: https://jaemyeong.com/en/blog/seomyeon-starbucks-jungangdaero-r-20260722/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #cafe, #cola
I like cola. I had been curious about the drinks sold at Starbucks Reserve stores, and a note saying the cola comes with a bottle opener made me more curious. So on a Wednesday during the day I bought a bottle.
The store is a Reserve store on the first floor of Samjeong Tower in Seomyeon. The drink is called The Real Premium Cola and costs 4,700 won. It is made by Baladin, an Italian brand, and a small bottle opener is attached to the bottle. To say it up front: with the price in mind I cannot call it a good pick, and I do not plan to buy it again.

The fizz was on the weak side. The taste was not bad, but it was different from the cola I know, enough that I wondered whether to call it cola. My only guess is that natural ingredients are the reason.
---
# 스타벅스 리저브의 병 콜라, 한 번 마셔 본 것으로 충분해요
> 병따개를 준다는 안내에 끌려 스타벅스 리저브 매장에서 4,700원짜리 병 콜라를 사 봤어요. 맛이 나쁘지는 않았는데 탄산이 약했고, 이 값에 다시 살 것 같지는 않아요.
- Canonical: https://jaemyeong.com/ko/blog/seomyeon-starbucks-jungangdaero-r-20260722/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #cafe, #cola
저는 콜라를 좋아해요. 스타벅스 리저브 매장에서 파는 음료가 궁금하던 차에, 콜라를 사면 병따개를 준다는 안내를 보고 더 궁금해졌어요. 그래서 수요일 낮에 한 병을 사 봤어요.
산 곳은 서면 삼정타워 1층에 있는 리저브 매장이에요. 음료 이름은 더 리얼 프리미엄 콜라이고 4,700원이에요. 이탈리아 브랜드인 발라딘의 제품이고, 병에 작은 병따개가 하나 달려 있어요. 미리 말하면, 값까지 생각했을 때 잘 골랐다고 하기는 어려워서 다시 살 생각은 없어요.

탄산은 약한 편이었어요. 맛이 나쁘지는 않았는데 제가 아는 콜라 맛과는 달라서, 이것을 콜라라고 불러도 되는지 고개를 갸웃했어요. 천연 재료를 써서 그런 것이 아닐까 하고 짐작만 해요.
---
# Bigfin reef squid at Amaika in Seomyeon: I ordered a second dish
> An izakaya in Seomyeon I had seen on YouTube. The squid was so good that I added the konowata ika somen. I wanted more but stopped because of the cost.
- Canonical: https://jaemyeong.com/en/blog/seomyeon-amaika-20260722/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #squid, #izakaya
After my dental treatment was finished, I wanted bigfin reef squid. Amaika is a place I had seen on YouTube and meant to visit for a while. I like the chef in the video and wanted to taste the food it showed. I walked in at about 5 on a Wednesday afternoon, right at opening.
The izakaya is on the second floor of the building diagonally across from Gaemijip Seomyeon, on the exit 1 side of Seomyeon Station. There is a sign board in front of the building, which seemed to list the day's suggested dishes and the reservations.
I first ordered one dish, the Amaika bigfin reef squid, for 42,000 won.

The taste and the bite were both very good. Among the squid I have eaten, I rate this especially high. So I ordered one more squid dish, the konowata ika somen for 48,000 won. It is the same squid cut into thin strips, served with salted sea cucumber innards.

I drank one bottle of Chamisul. I wanted to eat more, but the spending was a burden, so I stopped there.
---
# 서면 아마이까의 무늬오징어, 맛있어서 한 접시 더 시켰어요
> 유튜브에서 보고 가 보고 싶었던 서면의 이자카야예요. 무늬오징어 단품이 맛있어서 고노와다 이까소멘까지 시켰고, 더 먹고 싶었지만 값 때문에 멈췄어요.
- Canonical: https://jaemyeong.com/ko/blog/seomyeon-amaika-20260722/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Seomyeon, #squid, #izakaya
치과 치료를 마치고 나니 무늬오징어가 먹고 싶어졌어요. 아마이까는 유튜브에서 보고 전부터 가 보려고 했던 곳이에요. 영상에 나온 셰프를 좋아해서, 소개된 음식을 먹어 보고 싶었어요. 수요일 오후 5시쯤, 문을 열자마자 들어갔어요.
가게는 개미집 서면본점에서 대각선 건너편에 있는 건물의 2층이에요. 서면역 1번 출구 쪽에 있는 이자카야예요. 건물 앞에 입간판이 있는데, 그날 권하는 음식과 예약 현황을 적어 두는 것 같았어요.
처음에는 아마이까 무늬오징어 하나를 시켰어요. 42,000원이에요.

무늬오징어는 맛도 씹는 느낌도 아주 좋았어요. 제가 먹어 본 오징어 가운데 특히 높게 치는 맛이에요. 그래서 오징어 메뉴를 하나 더 시켰어요. 고노와다 이까소멘이고 48,000원이에요. 무늬오징어를 가늘게 썰고 해삼 내장 젓을 곁들인 음식이에요.

참이슬은 한 병을 마셨어요. 더 먹고 싶은 마음이 있었지만 지출이 부담스러워서 여기에서 멈췄어요.
---
# Uri Pocha in Gwangalli: a plate of only what we wanted to eat
> A second stop with a friend. I picked small squid and shrimp, my friend picked fresh tuna, and it came as one plate. It was fresh and the staff were kind.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-uri-pocha-20260723/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #sashimi, #seafood
After eating at [Golmok Pocha](/en/posts/gwangalli-golmok-pocha-20260723/), a friend and I went to the Uri Pocha main branch for a second round. It was about 7:30 on a Thursday evening. The raw fish restaurant is behind Hanseo Hospital, up past the Gwangan Homeplus.
I like small squid and shrimp, and my friend likes fresh tuna. We each told the owner what we wanted and asked for it on one plate. The plate we got had fresh tuna, mackerel sashimi, raw shrimp, abalone, and small squid.

Overall it was fresh and tasted good. The staff were kind as well.
The place is known for yellowtail in winter. I have heard that people line up in yellowtail season. There were not many guests when we went, and I suspect that is because it was not the season.
Next time I would like to come in winter for the yellowtail.
---
# 광안리 우리포차 본점, 먹고 싶은 것만 모은 한 접시가 좋았어요
> 골목포차에서 나와 친구와 2차로 간 횟집이에요. 저는 호래기와 새우, 친구는 생참치를 골라 한 접시로 받았어요. 신선하고 맛있었고 직원분도 친절했어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-uri-pocha-20260723/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #sashimi, #seafood
[골목포차](/posts/gwangalli-golmok-pocha-20260723/)에서 먹고 난 뒤, 친구와 2차로 우리포차 본점에 갔어요. 목요일 저녁 7시 반쯤이었어요. 한서병원 뒤쪽에서 광안 홈플러스보다 위쪽으로 올라가면 있는 횟집이에요.
저는 호래기와 새우를 좋아하고 친구는 생참치를 좋아해요. 각자 먹고 싶은 것을 사장님께 말씀드려서 한 접시에 모아 달라고 했어요. 그렇게 받은 접시에 생참치와 고등어회, 생새우회, 전복, 호래기가 담겼어요.

전체적으로 신선했고 맛도 좋았어요. 직원분도 친절했어요.
이 집은 겨울 방어로 알려진 곳이에요. 방어 철에는 줄을 선다고 들었어요. 저희가 갔을 때는 손님이 많지 않았는데, 방어 철이 아니어서 그런 것이 아닐까 싶어요.
다음에는 겨울에 방어를 먹으러 와 보고 싶어요.
---
# LA galbi at Golmok Pocha in Gwangalli: still good after a long gap
> I went back to a pub in Gwangan-dong that I used to visit often. The LA galbi, which the staff cut up at the table, tasted good again, and the service was kind.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-golmok-pocha-20260723/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #galbi, #bar
Golmok Pocha is a pub I went to several times in the past. I had not been for a while, and I came back at about 6 on a Thursday evening. This is the second branch, in Gwangan-dong, on the exit 1 side of Gwangan Station. There used to be a first branch, but it seems to be gone now.
I remembered liking the LA galbi before, so I ordered it again. The basic side dishes come first and the galbi follows. A staff member brings the galbi to the table and cuts all of it up for you.

The LA galbi was good this time too. I also ordered one bottle of soju. The staff were kind.
There was a fish tank, and I saw seafood on the menu. I do not think I saw seafood dishes here before, so I wondered whether the menu had changed in the meantime.
There is also a note on [Uri Pocha](/en/posts/gwangalli-uri-pocha-20260723/), which I visited on the same day.
---
# 광안리 골목포차의 LA갈비, 오랜만에 가도 맛있었어요
> 예전에 여러 번 갔던 광안동의 요리주점에 오랜만에 다시 갔어요. 직원분이 자리에서 손질해 주는 LA갈비는 이번에도 맛있었고 응대도 친절해서 또 갈 생각이에요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-golmok-pocha-20260723/
- Published: 2026.08.23
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #galbi, #bar
골목포차는 예전에 여러 번 갔던 요리주점이에요. 한동안 가지 못하다가 목요일 저녁 6시쯤에 오랜만에 다시 찾았어요. 이번에 간 곳은 광안동에 있는 2호점이에요. 광안역 1번 출구 쪽이에요. 1호점도 있었는데 지금은 없어진 것 같아요.
전에 먹었던 LA갈비가 맛있었던 기억이 있어서 이번에도 LA갈비를 시켰어요. 기본 상이 먼저 차려지고 그다음에 갈비가 나와요. 갈비는 직원분이 자리로 가져와서 먹기 좋게 전부 손질해 줘요.

LA갈비는 이번에도 맛있었어요. 소주도 한 병 주문했어요. 직원분의 응대도 친절했어요.
가게에 수조가 있고 해산물 메뉴도 보였어요. 예전에는 해산물 메뉴를 보지 못했던 것 같아서, 그사이에 메뉴가 바뀐 것이 아닐까 생각했어요.
같은 날 저녁에 다녀온 [우리포차 본점](/posts/gwangalli-uri-pocha-20260723/)의 기록도 있어요.
---
# Tanti in Jeonggwan: a fine view, but weak coffee and strict rules
> A large bakery cafe in Jeonggwan with my family. The room, the view, and the parking were good. The one-drink rule, the cold brew, and the sweet bread were not.
- Canonical: https://jaemyeong.com/en/blog/jeonggwan-tanti-20260729/
- Published: 2026.08.18
- Category: 식기(食記)
- Tags: #Busan, #Jeonggwan, #cafe, #bakery
After visiting the columbarium where my father rests, my family and I stopped at a cafe. It was about 5:30 on a Wednesday afternoon, and the children in my brother's family were with us. Tanti is a large cafe up on a mountain in Jeonggwan, Gijang. It is hard to reach without a car, but there was plenty of parking.
The room is large and the view is good. My first impression was positive. For the price, though, the service and the food did not satisfy me, so I have no plan to go back. I prefer the cafes on the Gijang coast.
I hear it stays open late at night, and my guess is that this is for the night view. It may suit couples on a date more than families.


## The ordering rules
That good first impression faded as we ordered. Each person has to order one drink, and the rule was applied to the children too. We were told that the children's drinks can only be ordered for kids of kindergarten age or younger. The second floor was closed to children and seniors. There are bungalows outside, and it seemed you need permission from the staff to use them.
A notice at the counter said, in effect, that they would give more value than what you pay. From what I experienced, I could not agree.
## Coffee and bread
I had stayed up the whole night before and needed caffeine, so I ordered a cold brew. It was very disappointing. It was the worst cold brew I have had. The coffee tasted thin overall, and I thought Compose Coffee would have been better.
The drinks the children ordered were so sweet that most of them were left. The bread was far too sweet as well. I do not expect much from the taste at very large bakery cafes to begin with.
---
# 정관 탄티, 전망은 좋았지만 커피와 주문 규칙은 아쉬웠어요
> 가족과 정관의 대형 베이커리 카페에 갔어요. 넓은 실내와 전망, 넉넉한 주차는 좋았는데, 1인 1음료 규칙과 맛없는 콜드브루, 너무 단 빵 때문에 다시 갈 생각은 없어요.
- Canonical: https://jaemyeong.com/ko/blog/jeonggwan-tanti-20260729/
- Published: 2026.08.18
- Category: 식기(食記)
- Tags: #Busan, #Jeonggwan, #cafe, #bakery
아버지를 모신 봉안당에 다녀온 뒤에 가족과 카페에 들렀어요. 수요일 오후 5시 반쯤이었고, 형네 가족의 아이들도 함께였어요. 탄티는 기장 정관의 산 위쪽에 있는 큰 카페예요. 차가 없으면 가기 어려운 곳이지만 주차 공간은 넉넉했어요.
실내가 넓고 전망이 좋아요. 첫인상은 좋았어요. 그런데 값에 비해 서비스와 음식이 만족스럽지 않아서 다시 갈 계획은 없어요. 저는 기장 바닷가 쪽의 카페가 더 좋아요.
밤늦게까지 영업한다고 하는데, 야경을 보라는 뜻이 아닐까 짐작해요. 가족보다는 연인이 데이트하러 오기에 맞는 곳일지도 모르겠어요.


## 주문 규칙
좋았던 첫인상은 주문을 하면서 줄어들었어요. 한 사람이 음료를 하나씩 꼭 시켜야 하는데, 이 규칙을 아이들에게도 적용했어요. 어린이용 음료는 유치원생 이하만 주문할 수 있다고 안내받았어요. 2층은 아이와 어르신이 들어갈 수 없는 구역이었어요. 바깥에는 방갈로가 있는데, 쓰려면 직원의 허락이 필요한 것 같았어요.
주문대에는 낸 돈보다 더 큰 가치를 드리겠다는 뜻의 안내가 있었어요. 제가 겪은 것으로는 그 말에 수긍하기 어려웠어요.
## 커피와 빵
저는 전날 밤을 새워서 카페인이 필요했고, 그래서 콜드브루를 시켰어요. 맛은 아주 실망스러웠어요. 제가 마셔 본 콜드브루 가운데 가장 맛이 없었어요. 커피 맛이 전체적으로 흐릿했고, 이럴 바에는 컴포즈 커피가 낫다고 생각했어요.
아이들이 시킨 음료는 너무 달아서 거의 다 남겼어요. 빵도 지나치게 달았어요. 저는 원래 아주 큰 베이커리 카페의 맛에는 기대를 크게 하지 않는 편이에요.
---
# Assorted sashimi at Seonpung in Gangneung: one I was happy with
> With a couple who live in Daegwallyeong, I went to Gangneung for sashimi. We found the place on Naver Map. I liked the mix of fish and the side dishes.
- Canonical: https://jaemyeong.com/en/blog/gangneung-seonpung-20260720/
- Published: 2026.08.18
- Category: 식기(食記)
- Tags: #Gangneung, #sashimi, #raw fish
I was visiting a couple, friends of mine, who live in Daegwallyeong. It feels hard to find a raw fish restaurant in Daegwallyeong, so the three of us went out to Gangneung for sashimi. It was about 8 on a Monday evening.
Whenever I travel in Gangwon, I have been unhappy with seafood prices. So this time I searched Naver Map and picked a place where the price looked fair. That was Seonpung, in Gyo-dong, Gangneung.
We ordered the assorted sashimi.

As I remember it, the plate had marumi, flounder, puffer, and pike conger. I am not certain. Compared with what I had eaten at raw fish places in Gangwon before, I was happy with the mix, and the side dishes were good too.
My friend, who is pregnant, drove that day while the rest of us drank. I felt sorry about leaving the driving to her.
---
# 강릉 선풍의 모둠회, 강원도에서 만족한 횟집이에요
> 대관령에 사는 친구 부부와 회를 먹으러 강릉 교동까지 갔어요. 네이버 지도에서 찾은 횟집인데, 모둠회의 구성과 밑반찬이 마음에 들었어요. 다음에도 후보로 둘 거예요.
- Canonical: https://jaemyeong.com/ko/blog/gangneung-seonpung-20260720/
- Published: 2026.08.18
- Category: 식기(食記)
- Tags: #Gangneung, #sashimi, #raw fish
대관령에 사는 친구 부부를 만나러 간 길이었어요. 대관령에서는 횟집을 찾기가 어렵다고 느껴서, 회를 먹으러 셋이서 강릉까지 나갔어요. 월요일 저녁 8시쯤이었어요.
저는 강원도를 여행할 때마다 해산물 값에 불만이 있었어요. 그래서 이번에는 네이버 지도에서 검색해 보고, 값이 적당해 보이는 곳으로 골랐어요. 강릉 교동에 있는 선풍이에요.
모둠회를 시켰어요.

들어 있던 생선은 마르미와 광어, 복어, 하모였던 것으로 기억해요. 확실하지는 않아요. 그동안 강원도의 횟집에서 먹었던 것과 견주면 회의 구성이 만족스러웠고, 밑반찬도 좋았어요.
이날 운전은 임신 중인 친구가 맡았고 저희는 술을 마셨어요. 운전을 맡겨서 미안했어요.
---
# Kalmat Hoetjip near PNU: twenty years of going back for the knife work
> A raw fish place in Jangjeon-dong I have gone to for about 20 years. Premium sashimi for three and for two, plus a rockfish stew with a coupon.
- Canonical: https://jaemyeong.com/en/blog/jangjeon-kalmat-hoetjip-20260717/
- Published: 2026.08.18
- Category: 식기(食記)
- Tags: #Busan, #Jangjeon, #sashimi, #raw fish
I have been going to Kalmat Hoetjip since I was a university student. By my count that is about 20 years. It is in Jangjeon-dong, close to Pusan National University Station. This time I went at about 3 on a Friday afternoon. I wrote about an earlier visit that April in [my April visit note](/en/posts/jangjeon-kalmat-hoetjip-20260411/).
The restaurant opens at 4 in the afternoon. If you go in the evening, there is a good chance the fish has run out and you cannot eat. So it is best to arrive at opening time. Since the restaurant expanded, it can take more guests.
I ordered the Kalmat premium sashimi for three (100,000 won) and for two (70,000 won).

As I remember it, the fish that day were black-spotted jack mackerel, grunt, and red sea bream, but I am not certain. These days I like the jack mackerel so much that I could eat it daily. It is about as fatty as yellowtail, a fish I do not care for much.
I think the knife work here is especially good, even next to other raw fish places in Busan. That is my personal view. The fish is in good condition for a price that does not hurt, and the side dishes are generous.
As someone who lives in Busan, I do not think a raw fish restaurant has to be by the sea. The hours are hard to match, which is the drawback, but if you have the time I would recommend it strongly.
I used saved coupons and got a spicy rockfish stew.

---
# 부산대 칼맛나는푸짐한횟집, 20년째 가는 이유는 칼질이에요
> 대학생 때부터 20년쯤 다닌 장전동의 횟집이에요. 칼맛 고급회 3인과 2인을 시키고 쿠폰으로 생우럭 매운탕을 받았어요. 회 상태가 좋고 값도 부담이 적어요.
- Canonical: https://jaemyeong.com/ko/blog/jangjeon-kalmat-hoetjip-20260717/
- Published: 2026.08.18
- Category: 식기(食記)
- Tags: #Busan, #Jangjeon, #sashimi, #raw fish
칼맛나는푸짐한횟집은 제가 대학에 다닐 때부터 가던 곳이에요. 헤아려 보면 20년쯤 됐어요. 장전동의 부산대역 가까이에 있어요. 이번에는 금요일 오후 3시쯤에 갔어요. 같은 해 4월에 왔던 날은 [4월 방문 기록](/posts/jangjeon-kalmat-hoetjip-20260411/)에 적었어요.
가게는 오후 4시에 문을 열어요. 저녁에 가면 재료가 떨어져서 먹지 못할 가능성이 커요. 그래서 문 여는 시간에 맞춰 가는 것이 좋아요. 가게를 넓힌 뒤로는 받을 수 있는 손님이 늘었어요.
칼맛 고급회를 3인(100,000원)과 2인(70,000원)으로 시켰어요.

이날 나온 생선은 흑점줄 전갱이와 벤자리, 참돔이었던 것으로 기억하는데 확실하지는 않아요. 저는 요즘 흑점줄 전갱이를 특히 좋아해서 매일 먹고 싶을 정도예요. 기름진 정도는 방어와 견줄 만한데, 방어는 제가 그리 좋아하지 않아요.
이 집의 회 써는 솜씨는 부산의 다른 횟집과 견주어도 특히 좋다고 생각해요. 개인적인 판단이에요. 회 상태가 좋은 데 비해 값은 부담이 적고, 밑반찬도 넉넉하게 나와요.
부산에 사는 사람으로서 저는 횟집이 꼭 바닷가에 있어야 한다고 생각하지 않아요. 시간을 맞추기 어려운 것이 단점이지만, 시간 여유가 있는 분께는 적극적으로 권하고 싶어요.
모아 둔 쿠폰을 써서 생우럭 매운탕을 받았어요.

---
# Bokcheon Anju Maeul in Dongnae: chewy octopus, and the food comes fast
> A bar with food in Dongnae I went to with a friend who had just left work. The boiled octopus was chewy, not tough. Food comes fast.
- Canonical: https://jaemyeong.com/en/blog/dongnae-bokcheon-anju-maeul-20260811/
- Published: 2026.08.18
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #octopus, #bar
On a Tuesday evening I suddenly felt like a drink. I called a friend who was leaving work, and we went to Dongnae at about 7. The place we first meant to go to was Jokdagwi, but many people were waiting and we gave up. So we went to Bokcheon Anju Maeul, which I had been curious about for a while. It is a bar with food close to Dongnae Station.
It is a place that is often full. That evening about two tables were free, and we sat toward the back.
There were several dishes I wanted. But some were sold out and my friend has trouble eating seafood, so the choices narrowed. We ordered the boiled octopus first.

The octopus came out quickly. It was chewy, not tough, and it tasted good. I think it was boiled well. I am told this place brings in seasonal seafood from around the country.
Partway through we ran short of food and added boneless chicken feet. I had not eaten chicken feet for a while. It is a taste I know, and it worked well with drinks.

The food comes fast and the prices are reasonable. I suspect that is why it is so busy. It is a good place for an easy drink.
We drank about two bottles of Chamisul. These days Chamisul caps have MBTI types printed on them. I am an INTJ and have not found that cap yet, so I check the cap every time I drink.
---
# 동래 복천안주마을, 문어숙회가 쫄깃하고 안주가 빨리 나와요
> 갑자기 술 생각이 나서 퇴근하는 친구를 불러 간 동래의 요리주점이에요. 문어숙회는 질기지 않고 쫄깃했고, 뼈없는 닭발을 추가했어요. 안주가 빨리 나오고 값도 부담이 적어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-bokcheon-anju-maeul-20260811/
- Published: 2026.08.18
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #octopus, #bar
화요일 저녁, 갑자기 술 생각이 났어요. 퇴근하던 친구에게 연락해서 7시쯤에 동래로 갔어요. 처음 가려던 곳은 족다귀였는데 기다리는 사람이 많아서 포기했어요. 그래서 전부터 궁금했던 복천안주마을로 갔어요. 동래역에서 가까운 요리주점이에요.
평소에는 자리가 꽉 찰 때가 많은 집이에요. 이날은 빈 테이블이 두 개쯤 있어서 안쪽 자리에 앉았어요.
먹고 싶은 메뉴가 여러 가지였어요. 그런데 몇 가지는 다 팔렸고 친구가 해산물을 잘 못 먹어서 고를 수 있는 것이 줄었어요. 먼저 문어숙회를 시켰어요.

문어숙회는 빨리 나왔어요. 질기지 않고 쫄깃했고 맛있었어요. 잘 삶은 것 같아요. 이 집은 전국 여러 곳에서 제철 해물을 들여온다고 해요.
먹다 보니 안주가 모자라서 뼈없는 닭발을 추가했어요. 닭발은 한동안 먹지 않았어요. 익히 아는 맛이고 술안주로 좋았어요.

음식이 빨리 나오고 값도 부담이 크지 않아요. 손님이 많은 것도 그 때문이 아닐까 싶어요. 편하게 한잔하기에 좋은 집이에요.
참이슬은 두 병쯤 마셨어요. 요즘 참이슬 뚜껑에는 MBTI가 적혀 있어요. 저는 INTJ인데 아직 그 뚜껑을 찾지 못해서, 마실 때마다 뚜껑을 들여다봐요.
---
# Rules that make an App Store review submission safe to rerun
> If the server accepts a review request but the response is lost, a rerun is rejected as a duplicate. How DailySudoku decides what a rerun should do.
- Canonical: https://jaemyeong.com/en/blog/app-store-connect-idempotent-submission/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #CI-CD, #fastlane, #Automation, #App Store Connect
DailySudoku is an app that gives one Sudoku puzzle per day. I build it alone and ship it on the [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), and the [web](https://dailysudoku.app/ko/). Pushing a tag runs everything from build upload to the iOS review request. When one channel fails, the design is to rerun only that channel.
That design had a gap. If the server processes a request but the client ends without receiving the response, the rerun is rejected as a duplicate. The Android side fixed the same problem earlier, and this time I added an idempotency check to iOS. What follows is based on the iOS automation on the develop branch as of August 16, 2026.
## The state names were different to begin with
To make a decision, the code has to read the current state of the version. I opened the [spaceship](https://docs.fastlane.tools/advanced/Spaceship/) implementation in the fastlane version pinned by `Gemfile.lock` and found two accessors for the state.
This is the accessor declaration in the spaceship app version model.
```ruby
attr_accessor :app_store_state # Deprecated in App Store Connect API specification 3.3
attr_accessor :app_version_state # ← 현행
```
The comment on the second line, 현행, means "current."
The two accessors differ in more than the name. They return different values. `READY_FOR_SALE` on the old one is `READY_FOR_DISTRIBUTION` on the current one, and `PROCESSING_FOR_APP_STORE` is `PROCESSING_FOR_DISTRIBUTION`. The filter that spaceship uses to look up versions also uses the current field. To decide based on the [app version resource in the App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi/app_store_versions), the code has to read the current accessor.
The name has a trap too. In JSON the field is `appVersionState`, but the accessor name after Ruby mapping is different. Accessing it with the camel-case name gives nil, and code that compares against nil passes the syntax check. The flow then falls into the "unknown state" branch without any error.
## When the version state alone gives the wrong answer
At first I classified `READY_FOR_REVIEW` as "submission accepted." That was wrong. The same state remains when the run stops after preparing the version and creating the submission object, right before the last API call. If this state counts as success, the rerun ends without making the actual review request.
The build needed its own check. Even with the same marketing version, the build number selected on the version can differ because of a manual submission or an earlier run. Whether the uploaded build exists and whether that build is attached to the version are two different conditions. Without separating them, the review state of a different binary is read as success for this build.
## A second axis: the submission object for the whole app
A second investigation showed that the submission tool does not reject based on the version state. It checks whether the app as a whole has a [review submission](https://developer.apple.com/documentation/appstoreconnectapi/review_submissions) in progress.
This is part of the branch that fastlane [deliver](https://docs.fastlane.tools/actions/deliver/) checks before submitting for review.
```ruby
if app.get_in_progress_review_submission(platform:)
UI.user_error!("Cannot submit for review - A review submission is already in progress")
# …
```
The `READY_FOR_REVIEW` state and an unfinished submission object can exist at the same time. So the version state alone cannot decide the outcome. The state of the submission object has to be read as well.
I got this wrong once more. Reducing the lookup result to "object exists or not" erases the difference between waiting, in review, and unresolved issues. A rejected version could still be judged a success because the build and the notes match. I changed the code to keep the submission object's state as it is and to apply blocking conditions first.
An enum value was also missing. Of the 15 states, I had classified only 14, and the missing one was the approved state. A rerun while waiting for manual release after approval could fail. Now a self-test walks through every enum value in the pinned gem and asserts that zero values are unclassified. If a gem update adds a new value, the verification step fails before any real submission.
## A third defect in the submission note comparison
In the test that compares submission notes per locale, I had typed the keys by hand. As a result the test exercised only the comparison function. It did not check whether my locale keys match the keys the API returns. If the spelling differs, every locale is judged missing and the whole rerun is blocked.
The error message was a problem too. The old message pointed to stale notes as the cause, which did not help when the real cause was a key mismatch. So I handle one case separately: every locale is missing, but the set of notes on the server is not empty. In that case the code prints the key list from the input and the key list from the server response.
## Four decision rules
After three rounds of fixes, I reduced the rules to four.
1. Keep the version state and the app-wide submission state as independent axes.
2. On an unknown value, fail closed and ask a person to decide.
3. If either axis shows a blocking signal, blocking wins over submitting.
4. A dry run only reports the state and does not stop because of that state.
The priority in rule 3 is fixed in the structure, not left to the order of conditional statements. Rule 4 exists because the state can change between the rehearsal and the real tag run.
## How I verified it without calling App Store Connect
The decision logic is split into pure functions that run without credentials. That makes it possible to verify with a self-test and no call to the [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi). The checks cover a table per version state, a table per submission object state, and the classification of every enum value in the gem.
There is also a guard against an implementation that ignores the second axis. Two contrast cases keep the version state the same and change only the submission object state. If they produce the same decision, the test fails. For the note comparison I used mutation. I replaced the comparison function with one that always returns false and one that always returns true, and confirmed that both the too-permissive defect and the too-strict defect are caught. I covered the idea of checking the checker with a control in [a separate post about negative controls in verification scripts](/en/posts/verification-script-negative-control/).
Looking back, one round of fixes was avoidable. A document that investigated the second axis was already in the repository, and I started without reading it. I need the habit of rereading the notes I wrote months ago.
Some things remain unverified. I have no record of reproducing the failure where the server finishes and only the response is lost. It was hard to build a way to reproduce it. What I verified is the decision logic that reads combinations of states, not the whole failure. The exhaustive enum check also covers only the values that the pinned gem knows. Whether the gem reflects the full, latest set of states on the server cannot be known until the gem is updated.
---
# Fixing the order of the UMP explainer and the ATT request on iOS
> DailySudoku requested ATT from the app, while Google UMP could also request it after an explainer. How I found the ATT calls in the SDK and reordered them.
- Canonical: https://jaemyeong.com/en/blog/att-ump-ordering-ios-consent/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #Advertising, #Privacy, #ATT
DailySudoku is a personal project that gives one Sudoku puzzle every day. It ships on the [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), and the [web](https://dailysudoku.app/ko/). To keep it free, I added banner ads and consent handling.
Here are the abbreviations used in this post. IDFA (Identifier for Advertisers) is Apple's advertising identifier. ATT (App Tracking Transparency) is the Apple framework that asks for tracking permission, and UMP (User Messaging Platform) is the consent screen SDK from Google. GDPR (General Data Protection Regulation) is the EU privacy regulation, EEA stands for European Economic Area, and NPA (Non-Personalized Ads) means ads that are not personalized.
The ATT system alert leaves little room to change its wording. So I wanted a separate screen that explains the reason before the request, and I drafted an IDFA explainer message in the AdMob console in 8 locales. The console said that this explainer screen triggers the ATT alert. If the SDK requests ATT by itself, then with the current structure, where the app already requests ATT, the explainer might never be shown. I stopped publishing and started investigating. This was about three and a half weeks after the earlier work.
## The design from a month before
I had connected ATT and audited it about a month earlier. The reasoning at the time was this. The UMP screen is skipped in some regions, and if ATT sits inside the UMP completion callback, ATT is skipped there too. At the time, I expected the UMP screen in regions under GDPR, the United Kingdom, and some US states. Korea is the main market, so the app needed to obtain the IDFA outside the EEA as well.
So the calling rule did not depend on the UMP screen. In the startup task of `AppCoordinator`, the call sat after `refreshConsent()` and before `maybeStartAdMob()`, and it applied to every user who was not adFree. The actual ATT request ran in its own `Task`, so it ran at the same time as UMP.
Ad requests used the more restrictive of the two answers. If either ATT was denied or UMP consent was missing, the request carried `npa=1`. Back then I checked the four combinations of ATT and UMP states with unit tests, and confirmed in the simulator that the ATT alert appears on first launch, including in a non-EEA locale.
## Whichever shows first wins
The ATT request screen appears only when the status is `.notDetermined`. A call after the user has answered just returns the existing status. So if the app's request shows first, the SDK's explainer has no place to appear. If the SDK moves first, the explainer appears and ATT follows. Which one came first depended on how long the network took. The reversed order, where ATT appears first and the explainer comes after it, could happen as well.
The existing tests did not reveal this problem. I found it while preparing to publish the AdMob message.
## The documents had no answer, so I opened the binary
The [Google guide to UMP privacy integration on iOS](https://developers.google.com/admob/ios/privacy) showed how to create the explainer message. At the time I could not find a basis for who calls ATT, how a sample uses it, or what the execution order is. The [Apple AppTrackingTransparency documentation](https://developer.apple.com/documentation/apptrackingtransparency) had no answer about UMP either. One line of guidance in the console was not enough to justify changing the code.
So I looked at the `UserMessagingPlatform` binary directly.
```text
UserMessagingPlatform 바이너리:
_objc_msgSend$requestTrackingAuthorizationWithCompletionHandler:
_objc_msgSend$trackingAuthorizationStatus
"ATTrackingManager" ← 문자열(동적 조회)
otool -L: AppTrackingTransparency 하드 링크 없음
```
The Korean labels in the block read "binary" (바이너리), "string, dynamic lookup" (문자열, 동적 조회), and "no hard link" (하드 링크 없음).
The binary contains the request selector and the status selector, and `ATTrackingManager` appears as a string. The `otool -L` output showed no direct link to AppTrackingTransparency. I read this as the SDK looking up the class dynamically. From that I inferred that the app has to link the framework, but I did not reproduce what happens when the link is removed.
## Four decisions that changed the order
First, the app's ATT request moved to after UMP finishes. If the permission is already decided, the guard returns right away, so there is no duplicate request.
Second, I kept the app's ATT call. I assumed that the SDK does not request ATT when the explainer message is not published, and in that case the app requests it instead. This reduces the dependence on whether the message is published before or after the code is merged. My judgment was that removing the app's call would leave no way to obtain the IDFA until publishing, and AdMob ads would be served as non-personalized.
Third, UMP runs once even on the path where no consent screen is needed. I took `needsForm` to mean only whether a consent screen is required. The point is to give the SDK a chance to handle the explainer outside the EEA too, and I expected it to return immediately when no message is published.
This is the branch for the case where no consent screen is needed.
```swift
case .none, .formOnly:
guard AdsConfig.adMobEnabled else { break }
// 폼이 «필요 없어도» UMP 흐름을 한 번 돌립니다.
// 메시지가 게시돼 있지 않으면 즉시 반환합니다(no-op).
umpFlowRan = await consentProvider.presentConsentForm()
```
The two comments say that the UMP flow runs once even when no form is needed, and that it returns immediately as a no-op when no message is published.
Fourth, `maybeStartAdMob()` does not wait for the ATT answer. In the main market the ad chain uses AdFit, so there is nothing to gain from waiting for the first AdMob impression. I also considered that the banner can be covered while the ATT alert is up.
This part schedules ATT only when the UMP flow actually ran.
```swift
if AdsConfig.adMobEnabled, umpFlowRan {
Task { await ATTAuthorization.request() }
}
maybeStartAdMob()
```
## Three fixes from code review
The first was the refresh failure. `.refreshFailed`, which comes from a network error, fell into the `.none` handling in an `if`/`else if` structure. If the cache still held "no screen needed," UMP returned success and the app could request ATT without knowing whether the explainer was published. I switched to a `switch` and handled the failure separately. A side benefit is that the compiler points out a missing branch when the enum gains a value later. In the excerpt below, the `.none` and `.formOnly` branch is the same as the code shown earlier, so it is shortened.
```swift
switch prompt {
case .refreshFailed:
// 게시 여부를 모르는 상태입니다. 아무것도 하지 않습니다.
// 동의 상태는 UMP가 마지막으로 아는 값이 유지되고, 다음 런치에 재시도됩니다.
break
case .introThenForm:
umpFlowRan = await presentConsentIntro()
case .none, .formOnly:
// …
}
```
The comments under `.refreshFailed` say that the publishing state is unknown so nothing is done, that the consent state keeps the last value UMP knows, and that it is retried on the next launch.
The second was the app's own intro sheet. When the intro sheet shown before the SDK screen is dismissed with the scrim or the grabber, `presentConsentForm()` is not called. `umpFlowRan` checks whether UMP actually ran, and on this path ATT is skipped. The intro sheet is shown again each time the app starts.
The third was the ad provider condition. A guard that looks at third-party ads as a whole passes when only another provider is enabled. ATT could run even in a configuration with AdMob turned off. The WKWebView-based provider does not use the IDFA. I narrowed the ATT and UMP conditions to `AdsConfig.adMobEnabled`.
## What ATTAuthorization.request() does
The code that imports AppTrackingTransparency is gathered in one file.
```swift
static func request() async {
guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
if UIApplication.shared.applicationState != .active {
await withCheckedContinuation { continuation in
var observer: NSObjectProtocol?
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { _ in
if let observer { NotificationCenter.default.removeObserver(observer) }
continuation.resume()
}
}
}
await withCheckedContinuation { continuation in
ATTrackingManager.requestTrackingAuthorization { _ in continuation.resume() }
}
}
```
The guard at the top ends right away unless the status is `.notDetermined`. Even though the function is called on every launch, it does not call the [request API](https://developer.apple.com/documentation/apptrackingtransparency/attrackingmanager/requesttrackingauthorization(completionhandler:)) for users who have already answered.
Next comes the check that the app is `.active`. Requesting directly from a startup callback such as `didFinishLaunching` or `willConnectTo` can return `.notDetermined` without showing a screen. Usually `sceneDidBecomeActive` has passed while UMP waits on the network, but the function waits for `didBecomeActiveNotification` itself so that it does not rely on that timing.
## What I confirmed and what I could not
Based on the develop branch as of 2026-08-16 (commits 2ae29c42 through 657d87c7), iOS passed 301 tests in 60 suites with `xcodebuild test`, and Android succeeded at `compileDebugKotlin`. On first launch in the simulator, the ATT alert appeared as before, and running UMP with no published message did not add another modal. The log on the following launch showed `[ATTrackingManager] Returning from trackingAuthorizationStatus - 0`. The value 0 means not determined, so that path requests again.
To imitate a regulated US state, I added the `-AdMobDebugUSState` argument on iOS and `--ez admobDebugUsState true` on Android. I confirmed in the SDK header that `UMPDebugGeographyRegulatedUSState` has the value 3 and wired it in.
Two things are unconfirmed. `simctl privacy grant tracking` failed with `Operation not permitted`, so there was no way to inject an ATT answer automatically. Whether the app stops asking after the user has answered is left for QA on a real device. I also could not see whether the explainer shows again on the second launch after the message is published, because the publishing conditions were not met at the time.
There are remaining limits too. Until the explainer message is published, the app's ATT alert can appear without the explainer. That is the intended fallback behavior. What I saw in the binary is an observation limited to the UMP release in use at the time, and it is not behavior that the official Google documentation guarantees. It has to be investigated again when the SDK changes.
One piece of documentation debt is left. The comments in `ATTAuthorization.swift` still describe the old calling rule. The statement that it runs regardless of the screen, the statement that it runs right after `refreshConsent()`, and the statement that ATT and UMP are independent no longer match the implementation. I corrected two comments in other files but missed this file, so I filed an issue for it. When a premise changes, an earlier decision has to be reviewed again. So a decision record should include the premises that make the conclusion hold, not only the conclusion.
---
# Keeping the export compliance answer in sync across two files and ASC
> The encryption export answer lives in Info.plist, a submission declaration file, and App Store Connect. Fixing a false success and a blocking regression.
- Canonical: https://jaemyeong.com/en/blog/export-compliance-two-source-validation/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #CI-CD, #App Store Connect, #Automation, #Testing
I ship DailySudoku, a personal app that gives one Sudoku game a day, on iOS, Android, and the web together. The release channels are the [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), and the [web](https://dailysudoku.app/ko/). Pushing a git tag runs everything automatically up to the review request in App Store Connect (ASC).
While [making the review submission safe to rerun](/en/posts/app-store-connect-idempotent-submission/), I added a check for the encryption declaration. In the process I fixed defects of my own making four more times. The code described here is the store metadata check and the iOS release automation on the develop branch as of August 16, 2026.
## Where the answer lives
Uploading an app requires [declaring whether it uses encryption](https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations). In the repository, that value is in two places. One is the [ITSAppUsesNonExemptEncryption](https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption) key in `Info.plist`. The other is the submission declaration file that the automation takes as input.
If the two values disagree, ASC asks an extra question and the pipeline stops. The declaration file had the value false and a comment saying that both files must be changed together. No code actually checked what that comment said.
## Having an answer is not the same as having the right one
At first I decided not to compare the values. If the version is waiting for a compliance answer, the flow moves to manual handling anyway, so I judged that a separate comparison was not needed.
That judgment had a gap. Whether an answer exists and whether that answer equals the approved value are two different conditions. After a person enters a value in ASC, or after an earlier automated run enters a different value, the state still becomes resolved. If the build, the release notes, and the release method also match, a rerun can be judged a success. Then the review goes ahead with a declaration outside what was approved. A wrong declaration can become a reason for a review rejection or a policy violation.
As the first fix, the actual value in ASC is compared with the approved value, and a missing answer blocks the run.
## A regression found 30 minutes later
That fix caused another problem right away. The function that supplies the approved value uses Ruby symbols as keys, but the code that reads the value looked it up with a string key.
This is the result of looking up the same value with the two kinds of key.
```text
문자열로 조회: nil ← 옛 코드
심볼로 조회 : false ← 고친 코드 (= 선언 파일의 승인값)
```
The first line is the lookup with a string, which was the old code. The second is the lookup with a symbol, which is the fixed code and equals the approved value in the declaration file.
Because the lookup returned nil and not false, the value was always judged different from the one in ASC. As a result, every retry was blocked.
This was the third defect that blocked everything all the time. The earlier two were in the promotion comparison and the locale identifiers, and this one was the lookup of the declared value. The existing self-test checked only the number of keys and the types of the values. So I added an assertion that looks the value up with exactly the key the consuming code uses.
## Looking once more for the same kind of defect
Before review, I searched for more blocking paths of the same kind, and a fourth candidate came up. The previous commit treated a nil value in ASC as a missing answer.
But when compliance is settled by the declaration in the app binary's `Info.plist`, the declaration field of the Build can be empty. This is an inference from how the submission tool is implemented. The submission tool, [fastlane deliver](https://docs.fastlane.tools/actions/deliver/), writes the value only when it is nil. Shipped as it was, the code would have blocked valid retries too. This time it was found before release.
After the change, the approved value is compared only when the ASC field has a value. A case that is really unresolved is stopped by the check on whether the version is waiting for an answer. The roles are separated so that a nil value and an unresolved state are not treated as the same thing.
## An offline check that compares the two files
Apart from the runtime decision, I turned the contract inside the repository into a check. It compares `Info.plist` with the submission declaration. A conflict is known as soon as only one file is edited, so it can be caught on every PR.
This is the part that asserts the two values read from the files are equal.
```python
plist_value = plist[key] # Info.plist
declared = decl[field] # 제출 선언 파일
assert plist_value == declared, (
"수출 규정 선언이 두 곳에서 어긋난다 — ASC가 되묻거나 잘못된 신고로 심사가 진행된다. "
"**한쪽만 고치지 말 것**(선언 파일 주석)."
)
```
The second comment means "submission declaration file." The message says that the export compliance declaration disagrees in two places, that ASC will ask again or the review will go ahead with a wrong declaration, and that one side must not be fixed alone.
The type is checked as well. When a value is extracted with `plutil`, a false inside an XML string node also comes out as a string. Converting only the result of comparing that string with "true" into a Boolean hides the fact that the original type was wrong. So the file is parsed with Python's [plistlib](https://docs.python.org/3/library/plistlib.html), and then the value is checked to be a bool.
This assertion confirms that the value read from `Info.plist` is a Boolean.
```python
assert isinstance(plist_value, bool), (
f"Info.plist 의 {key} 가 Boolean 이 아니다: {plist_value!r}. "
"ASC 는 이 키를 Boolean 으로 요구한다 — 또는 여야 한다"
)
```
The message says that the key in Info.plist is not a Boolean, and that ASC requires this key as a Boolean, written as `` or ``.
A missing key is a failure too, because without the key ASC asks again and the pipeline stops.
## The check also ran in the wrong place
I first put this check in the self-test of the iOS release workflow. On a PR, that workflow runs only when a path under `apps/ios/**` changes. The submission declaration file is outside that path, so a PR that changed only the declaration did not run the check.
I moved it into the store metadata check, which owns both files. That check runs on every PR and push.
I confirmed the result in three ways. A mutation that flips the Boolean in `Info.plist` makes the assertion fail. The self-test looks the value up with the key that the consuming code really uses. And I matched every gem accessor and constant used in the code against the pinned source, one by one, because referring to names without checking them was the repeated cause. Feeding a check an input that should fail is covered in [the post on applying a negative control to a verification script](/en/posts/verification-script-negative-control/).
A promise written as a comment is easy to mistake for a managed contract, so it should become an assertion. Searching for more failure paths of the same kind while fixing one defect paid off. And the change a check watches has to be included in the conditions that trigger that check. Adding a safeguard can itself create new errors, which is what happened here.
I have never made a real submission with mismatched declarations. Testing the review queue with a wrong declaration was not an option. The statement that ASC asks again when the two values conflict or the key is missing rests on the experience recorded in the declaration file's comment and on the API's requirements. The basis for the nil handling is also the tool's implementation, and I could not confirm in the documentation the exact condition under which ASC fills the Build's declaration field.
---
# When parallel tests disturbed each other through global notifications
> An ad banner test that passed locally in 0.02 seconds hit a 45-minute limit in CI. The cause was a global notification from another test, fixed by injection.
- Canonical: https://jaemyeong.com/en/blog/parallel-test-global-notification-isolation/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #Testing, #CI-CD, #Troubleshooting
DailySudoku, a Sudoku app I build as a side project, gives one free game a day and shows a banner ad. It is available on the [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), and the [web](https://dailysudoku.app/ko/). A failure showed up in the iOS tests for that banner ad.
A test named `holdsImpressionUntilTheSlotIsLiveAgain` passed locally in about 0.02 seconds, while the CI job ran into its 45-minute limit. After the first fix the endless wait was gone, but the test kept failing. The code baseline is the iOS implementation at commits `d44808bb` and `b7646ee2` on the develop branch as of August 14, 2026.
## The notifications the banner listened to
`AdBannerView` has to react to a remove-ads purchase, a consent change, and a return to the foreground. So it observed three notifications on `NotificationCenter.default`.
This is the code where the banner registered its observers.
```swift
for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
}
```
The last one, the did-become-active notification, is there for a reason. Something has to restart the load and the impression handling that were held back while the slot was inactive. When the user returns to a screen that is already showing, `viewWillAppear` cannot be expected to be called. Without this trigger, both the ad slot and the impression record can stay empty.
## At first I suspected load on the runner
Several jobs share the CI runner, so load was my first suspect. Then I found a different problem in the test code. Execution continued after `#expect` failed, and it then entered a `while` with no upper bound that waited for `shownKind`.
Reduced to its shape, the flow looked like this. The arguments and the loop body are omitted.
```swift
#expect(await waitForChain( // 실패해도 실행이 멈추지 않는다
// …
))
while banner.shownKind == nil { // 그래서 여기서 영원히 대기한다
// …
}
```
The Korean comment on the first line says that execution does not stop even on failure, and the one on the `while` line says that it therefore waits here forever.
I measured the time too. Run alone, this test took 0.024 seconds locally. Under contention, there was a case where the asynchronous MainActor work that builds the chain was delayed beyond the 4-second observation window.
I also ruled out other changes as the cause. `releaseForcedProvider` in the PR that changed ad routing is always `nil` in DEBUG. The PR under review has no change in the ad directory. Both were confirmed from the ancestry and the diff of the two PRs.
Defensive changes went in first. The wait was raised from 4 seconds to 8 to match the provider's network time limit, and `waitForShown` was built on the existing polling approach. [#require](https://developer.apple.com/documentation/testing/require(_:_:sourcelocation:)) stops progress after a failure, and the unbounded loop became a wait with a time limit. The result was that a 45-minute wait turned into an 8-second failure. The interference between tests was still there.
## A notification from another test reset the banner
The hypothesis is the combination of [parallel execution in Swift Testing](https://developer.apple.com/documentation/testing/parallelization) and a notification center shared by the whole process. A static search found 7 test files that post `SudokuStore.didChange`. A notification posted by an unrelated test synchronously calls `refresh()` on the banners that are observing. When ads are disabled, the `!enabled` branch clears `chain`, `shownKind`, and `pendingImpression` and bumps the generation.
Two conditions made only this test vulnerable.
- The watcher uses `shownKind` as input to decide the remove-ads state. A reset can happen once the value exists. A notification before the value exists only calls `setNeedsLayout()`.
- Unlike the other tests, this one does not call `layoutIfNeeded()` before its first wait. So after a reset there is no layout pass to restart the load.
I judged that this is the only test where both conditions hold together.
The same hypothesis explained the difference between local runs and CI. When the test passed locally, the suites running alongside it presumably did not post a notification during the risky window. The observation that contention delays timing was valid in itself, but it did not fit as the cause of this failure.
I confirmed the hypothesis with an experiment. Adding a `Task` that posts the notification made the failure reproducible locally. The existing test, run in parallel, failed at the same place in the code.
## Injecting the notification center
I added `notificationCenter: NotificationCenter = .default` to the initializer of `AdBannerView` and changed the observers to register on the injected center.
This is part of the changed initializer. The setup code before the observer registration is omitted.
```swift
init(
colors: SudokuColors,
adsProvider: AdsProvider,
consentProvider: ConsentProvider,
notificationCenter: NotificationCenter = .default // 추가
) {
// …
for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
notificationCenter.addObserver(self, selector: #selector(refresh), name: name, object: nil)
}
}
```
The Korean comment next to the new parameter means "added."
Three test files each pass their own instance. Because of the default value, the app keeps using the same [NotificationCenter](https://developer.apple.com/documentation/foundation/notificationcenter) as before. `adsProvider`, `consentProvider`, `makeAd`, and `logger` were already injected, so this follows the same pattern. The reset behavior in `refresh()` and `didMoveToWindow()` means that the creative is removed, so I left it as it was.
Other options were considered. `@Suite(.serialized)` only limits concurrency inside that suite, so it has no effect on notifications posted by other suites. And when layout does not run again, raising 8 seconds to 80, or waiting forever, does not recover the test.
## How the fix was confirmed
`xcodebuild test` reported 299 tests in 60 suites passing. One regression test was added, which took the count from 298 to 299. The problem test alone ran in 0.023 seconds, and the whole run took 8.58 seconds.
I also checked that the new test catches the defect. With a mutation that puts the center back to `.default`, both the new test and the existing test failed. Deliberately putting a failing condition into verification code is covered in [the post on applying a negative control to a verification script](/en/posts/verification-script-negative-control/).
Four rules came out of this. A failure that happens only in CI is not assumed to be a performance problem. A hypothesis built from reading code is confirmed by reproduction. A regression test is checked by putting the defect back and seeing it fail. A time limit and a stop after failure have value apart from the isolation fix, so they stay.
What I observed directly is the failure under an artificially created notification. I did not find which suite actually posted the notification in the CI run that failed. The search result of 7 files cannot settle who the sender was at that time. Whether the centers in the three test files are also independent per test case is not covered in this post.
---
# How a canceled Task still ran to the end in a review prompt
> A review prompt scheduled 2 seconds after a win could still run after the screen was left. A cancel after Task.sleep returns does not come back as an error.
- Canonical: https://jaemyeong.com/en/blog/task-sleep-cancellation-review-prompt-gate/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #StoreKit, #Concurrency, #Android
I added a feature to DailySudoku, a personal project that gives one Sudoku game a day, that asks for a store rating after a win. The store and web addresses are the [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), and the [web](https://dailysudoku.app/ko/).
[AppStore.requestReview(in:)](https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)-1q8qs) on iOS does not report whether the prompt actually appeared, and there is no completion callback. The system limits how often it shows, and the app also limits requests to one per version. If an attempt is recorded at a moment when the screen is not suitable for a request, the chance for that release is used up. Two code reviews each found one such path.
An earlier version of this post gave the wrong name for Swift's cancellation error and described the behavior of `Task.sleep` incorrectly. On October 4, 2026, I ran the cancellation behavior again in a standalone program on Swift 6.4 and corrected it. The app's tests and the real race were not rerun this time.
## The flow that schedules a request
When a game ends in a win, `markPending()` checks eligibility and only marks it pending. StoreKit is not called at that point. When the completion screen is shown, `screenDidShowCompletion()` schedules a run 2 seconds later only if it is pending, and when the screen is left, `screenWillDisappear()` cancels the schedule. Using a delay follows the sample in [Apple's guide to requesting reviews](https://developer.apple.com/documentation/storekit/requesting-app-store-reviews).
`fire`, which does the work, does not keep the scene from the time of scheduling. It reads `presenter.view.window?.windowScene` right before running.
```swift
private func fire(presenter: UIViewController?, repository: ReviewPromptRepository) {
scheduledTask = nil
guard let presenter, let scene = presenter.view.window?.windowScene else { return }
let version = ReviewPromptConfig.currentAppVersion
let epochDay = EpochDays.today(millis: Int64(Date().timeIntervalSince1970 * 1000))
// 시도 기록은 여기에서만 합니다.
repository.recordAttempt(epochDay: epochDay, version: version)
AppStore.requestReview(in: scene)
}
```
The Korean comment says the attempt is recorded only here.
Pending is not saved as an attempt so that a canceled schedule does not start the cooldown. The code assumed that the window is nil once the screen is left, but that assumption may not hold during a transition or in the background.
## First finding: the window stays in the background
The first review found the case where the app goes to the background during the 2-second wait. UIKit can keep the window attached in the background, so the presence of a window alone does not tell whether a request is appropriate.
I added a condition that the scene is `.foregroundActive`.
```swift
guard let presenter, let scene = presenter.view.window?.windowScene,
scene.activationState == .foregroundActive else { return }
```
During a screen transition, this condition does not guarantee a suitable state for a request either.
## Second finding: a path that reached fire after cancel
A review two days later found a path where `fire` can run even though the schedule was canceled when the screen was left. The schedule looked like this.
```swift
scheduledTask = Task { [weak presenter] in
do { try await Task.sleep(for: .seconds(2)) }
catch { return } // 취소됨 — 여기서 끝난다고 생각했습니다
fire(presenter: presenter, repository: repository)
}
```
The comment says "canceled, and I thought it ended here."
I thought that on cancel, [Task.sleep](https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:)) throws and the `catch` ends the task. That is only half right. The earlier version called this error `CancellationException`, which is the Kotlin name. In Swift it is `CancellationError`.
This is the behavior I confirmed by running code (Swift 6.4, a standalone program).
- Calling `try await Task.sleep` inside a task that is already canceled gives `CancellationError`.
- Canceling in the middle of a 30-second wait also gives `CancellationError`.
- Canceling after `Task.sleep` has returned normally lets the next statement run. `Task.isCancelled` is true at that point.
- Wrapping the call in `try?` only turns the error into nil, and the next statement runs. The canceled state remains.
- Calling `Task.checkCancellation()` in the canceled state gives `CancellationError`.
This is the output of running the three cases. `before` is calling sleep after cancel, `during` is canceling in the middle of the wait, and `after` is canceling after sleep returned.
```text
before: isCancelled=true
before: error=CancellationError, CancellationError=true
try?: nil=true, continued=true, isCancelled=true
check: CancellationError=true
during: CancellationError=true
after: sleep returned
after: continued=true, isCancelled=true
after: check CancellationError=true
```
The `after` result is the path in question. Once sleep has returned, a late cancel does not turn that call into a failure. The `catch` is skipped, so execution reaches `fire`. The earlier version stated this broadly as "an expired sleep does not throw," but what was confirmed goes only as far as "a cancel after a normal return." The exact race between the moment the timer ends and the moment the task resumes could not be settled by this experiment.
The fix is one line. After handling the error from sleep, the code checks once more whether it is canceled now.
```swift
do { try await Task.sleep(for: .seconds(2)) }
catch { return }
guard !Task.isCancelled else { return } // 이 줄이 있어야 합니다
fire(presenter: presenter, repository: repository)
```
The comment says "this line is needed."
The `catch` covers a cancel during the wait, and the `guard` covers a cancel after the return. Handling the error from `Task.checkCancellation()` is another way to do it. What this `guard` sees is the state at that instant. It does not remove the chance of a cancel arriving from another execution context after the check. Whether the screen events and this code run one after another in the same context cannot be confirmed from the excerpt alone.
## The comparison with Android
My judgment back then was that the same problem does not exist on the Android side, for two reasons. According to [the Kotlin cancellation documentation](https://kotlinlang.org/docs/coroutines-cancellation.html), suspending functions such as `delay` check for cancellation when they suspend. [The API documentation of `delay`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html) says that if it was cancelled while suspended, `CancellationException` is thrown even when it is ready to return. And `recordPrompt` is placed after `suspendCancellableCoroutine`. That guarantee covers a cancellation that arrives while the coroutine is suspended, though. If the cancellation arrives after `suspendCancellableCoroutine` has returned normally and before `recordPrompt` is called, the next statement can still run, as in the `after` case of the measurement above. Whether the Android code checks in between, for example with `ensureActive()`, and a result of reproducing the cancellation are not in this post, so the Android side cannot be called safe. `recordAttempt` on iOS is a synchronous call and has no point at all where cancellation is checked. The flow of [Google Play in-app reviews](https://developer.android.com/guide/playcore/in-app-review) is not part of this comparison.
The earlier version added here that "Swift's sleep checks for cancellation only while waiting." As `before` in the output above shows, a task that is already canceled also gets the error, so that explanation does not hold. The Kotlin side was not confirmed by running code. What an API guarantees and whether the whole feature is safe are different questions, and each platform has to be checked separately.
## Eligibility is a pure function
The part that computes whether a request is allowed is separated into a function that does not depend on StoreKit.
```swift
static func shouldRequestReview(
wonCompletionCount: Int,
lastAttemptEpochDay: Int64,
todayEpochDay: Int64,
lastPromptedVersion: String?,
currentVersion: String,
forceOverride: Bool
) -> Bool {
if forceOverride { return true }
guard wonCompletionCount >= ReviewPromptConfig.wonCompletionThreshold else { return false }
guard todayEpochDay - lastAttemptEpochDay >= ReviewPromptConfig.cooldownDays else { return false }
if let lastPromptedVersion, lastPromptedVersion == currentVersion { return false }
return true
}
```
If `forceOverride` is on, the request is allowed first. After that, the number of wins is compared with the threshold, the days since the last attempt are compared with the cooldown, and the request is refused when the current version equals the last prompted version. Since it does not call StoreKit's `AppStore`, it can be tested with a truth table.
iOS and Android each read the same 12 inputs from one JSON file and test against them. The Android check on the number of cases was changed from `>= 10` to exactly 12, because `>= 10` did not catch the shared fixture changing or the two sides drifting apart. How the date boundary is handled connects to [the post on leaderboard date boundaries](/en/posts/daily-leaderboard-date-boundary-submission-reliability/).
I chose the win threshold and the cooldown myself. By my own assessment, the values allow a request earlier than the intent of [Apple's guidelines on ratings and reviews](https://developer.apple.com/design/human-interface-guidelines/ratings-and-reviews), which is not to ask before the user has formed an opinion. I treat the system limit as extra protection and wrote the reason for the choice in a comment next to the constants. Nothing guarantees that the system limit stands in for the recommendation in the guidelines.
## What was verified
The original verification was based on commits `eeb4d90d`, `9884d36f`, and `fa69105c` on the develop branch as of August 15, 2026. `xcodebuild test` on iOS passed with 287 tests in 60 suites, and `testDebugUnitTest` on Android passed with 297 tests in 42 classes. Eligibility has 12 cases on both platforms. These numbers are the record from that time and were not rerun this time.
Much is not covered by automated tests. The API does not report whether the prompt was shown, so automated tests cannot confirm that the system prompt actually appeared. Neither platform has a test that automatically checks execution after cancel. iOS has no such test file, and only the part that marks pending is within unit tests. Even with a test file, the kill switch in the xctestplan would have blocked the real call. The cancellation path was reviewed by tracing the code and with a static call graph, and both defects came out of code review.
On a device, the check uses a debug build from Xcode with a launch argument that skips the eligibility decision. According to [the `requestReview(in:)` documentation](https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)-1q8qs), this method has no effect in apps distributed through TestFlight.
What I confirmed by running code this time goes only as far as cancellation behavior in a standalone Swift program. The real race involving UIKit, StoreKit, and the MainActor queue, and whether that path is closed in the app after the fix, could not be reproduced.
---
# App Store 심사 제출을 다시 실행해도 안전한 판정 규칙
> 서버는 심사 요청을 받았는데 응답만 잃어버리면, 자동화를 다시 실행했을 때 중복으로 거절돼요. DailySudoku의 iOS 제출 자동화에서 버전 상태와 제출 객체 상태를 따로 읽어 재실행을 판정하게 고친 과정을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/app-store-connect-idempotent-submission/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #CI-CD, #fastlane, #Automation, #App Store Connect
DailySudoku는 하루에 한 문제씩 스도쿠를 내는 앱이고, 혼자 개발해서 [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), [웹](https://dailysudoku.app/ko/)에 배포해요. 태그를 push하면 빌드 업로드부터 iOS 심사 요청까지 자동으로 진행돼요. 채널 하나가 실패하면 그 채널만 다시 실행해서 복구하도록 설계했어요.
이 설계에는 빈틈이 있었어요. 서버가 요청을 처리했는데 클라이언트가 응답을 받지 못한 채 끝나면, 다시 실행한 요청은 중복이라는 이유로 거절돼요. Android 쪽은 같은 문제를 먼저 고쳤고, 이번에는 iOS에 멱등 판정을 넣었어요. 아래 내용은 2026년 8월 16일 develop 브랜치의 iOS 자동화를 기준으로 해요.
## 상태 이름부터 달랐어요
판정을 만들려면 버전이 지금 어떤 상태인지 읽어야 해요. `Gemfile.lock`으로 고정한 fastlane의 [spaceship](https://docs.fastlane.tools/advanced/Spaceship/) 구현을 열어 보니 상태를 읽는 접근자가 둘이었어요.
spaceship의 앱 버전 모델에 있는 접근자 선언이에요.
```ruby
attr_accessor :app_store_state # Deprecated in App Store Connect API specification 3.3
attr_accessor :app_version_state # ← 현행
```
두 접근자는 이름만 다른 것이 아니라 돌려주는 값도 달라요. 예전 쪽의 `READY_FOR_SALE`은 현재 쪽에서 `READY_FOR_DISTRIBUTION`이고, `PROCESSING_FOR_APP_STORE`는 `PROCESSING_FOR_DISTRIBUTION`이에요. spaceship이 버전을 검색할 때 쓰는 필터도 현재 필드를 써요. [App Store Connect API의 앱 버전 리소스](https://developer.apple.com/documentation/appstoreconnectapi/app_store_versions)를 기준으로 판정하려면 현재 접근자를 읽어야 해요.
이름에도 함정이 있었어요. JSON에서는 `appVersionState`지만 Ruby로 매핑된 뒤의 접근자 이름은 달라요. 카멜 표기로 접근하면 nil이 나오고, nil과 비교하는 코드는 문법 검사에 걸리지 않아요. 그러면 오류 없이 «모르는 상태» 분기로 흘러가요.
## 버전 상태만 보면 틀리는 경우
처음에는 `READY_FOR_REVIEW`를 접수가 끝난 상태로 분류했어요. 이 판단이 틀렸어요. 버전 준비와 제출 객체 생성까지 마치고 마지막 API 호출 직전에 실행이 끊겨도 이 상태가 남기 때문이에요. 이 상태를 성공으로 처리하면, 재실행이 실제 심사 요청을 건너뛴 채 끝나요.
빌드도 따로 확인해야 했어요. 마케팅 버전이 같아도 수동 제출이나 이전 실행 때문에 버전에 선택된 빌드 번호가 다를 수 있어요. 업로드한 빌드가 존재하는지와 그 빌드가 버전에 연결돼 있는지는 서로 다른 조건이에요. 이 둘을 구분하지 않으면 다른 바이너리의 심사 상태를 이번 빌드의 성공으로 잘못 읽어요.
## 앱 전체의 제출 객체라는 두 번째 축
다시 조사해 보니 제출 도구가 요청을 거절하는 기준은 버전 상태가 아니었어요. 앱 전체에 진행 중인 [review submission](https://developer.apple.com/documentation/appstoreconnectapi/review_submissions)이 있는지를 봐요.
fastlane [deliver](https://docs.fastlane.tools/actions/deliver/)가 심사 제출 전에 확인하는 분기의 일부예요.
```ruby
if app.get_in_progress_review_submission(platform:)
UI.user_error!("Cannot submit for review - A review submission is already in progress")
# …
```
`READY_FOR_REVIEW` 상태와 끝나지 않은 제출 객체는 동시에 있을 수 있어요. 그래서 버전 상태 하나로는 판정할 수 없고, 제출 객체의 상태를 함께 읽어야 해요.
여기서도 한 번 더 틀렸어요. 조회 결과를 객체가 있는지 없는지로 줄여 버리면 대기, 심사 중, 문제 제기 같은 상태의 차이가 사라져요. 버전이 거절됐는데도 빌드와 노트가 일치한다는 이유로 성공이라고 판단할 수 있어요. 제출 객체의 상태를 그대로 보존하고, 차단 조건을 먼저 적용하도록 고쳤어요.
열거형을 빠뜨린 것도 있었어요. 상태 15개 가운데 14개만 분류했는데, 빠진 하나가 승인 상태였어요. 승인된 뒤 수동 출시를 기다리는 동안 재실행하면 실패할 수 있는 구멍이에요. 지금은 고정한 젬의 enum 전체를 self-test가 순회하고, 분류되지 않은 값이 0개라고 단언해요. 젬을 올려서 새 값이 생기면 실제 제출 전에 검증 단계에서 먼저 실패해요.
## 제출 노트 비교에서 나온 세 번째 결함
로케일별 제출 노트를 비교하는 테스트에서는 키를 손으로 적었어요. 그 결과 테스트가 비교 함수만 시험했고, 제가 쓰는 로케일 키와 API가 돌려주는 키가 맞는지는 확인하지 않았어요. 키 철자가 다르면 모든 로케일이 누락으로 판정되고, 재실행이 통째로 막혀요.
오류 안내도 문제였어요. 기존 메시지는 노트가 오래됐다는 것을 원인으로 지목해서, 키가 맞지 않는 상황을 조사하는 데는 도움이 되지 않았어요. 그래서 모든 로케일이 누락인데 서버의 노트 집합은 비어 있지 않은 경우를 따로 처리했어요. 이때는 입력 쪽 키 목록과 서버 응답 쪽 키 목록을 각각 출력해요.
## 판정 규칙 네 가지
세 번의 수정을 거쳐 규칙을 네 개로 줄였어요.
1. 버전 상태와 앱 전체의 제출 상태는 서로 독립된 축으로 유지해요.
2. 모르는 값을 만나면 fail-closed로 멈추고 사람의 판단을 요청해요.
3. 어느 축에서든 차단 신호가 나오면 제출보다 차단이 우선이에요.
4. dry-run은 상태를 보고하기만 하고, 그 상태를 이유로 중단하지 않아요.
세 번째 규칙의 우선순위는 조건문의 순서에만 맡기지 않고 구조로 고정했어요. 마지막 규칙은 리허설과 실제 태그 실행 사이에 상태가 바뀔 수 있어서 넣었어요.
## App Store Connect를 호출하지 않고 확인한 방법
판정은 인증 없이 실행할 수 있는 순수 함수로 분리했어요. 덕분에 [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi)를 호출하지 않고 self-test만으로 확인할 수 있어요. 확인한 범위는 버전 상태별 표, 제출 객체 상태별 표, 젬 enum 전체 분류 검사예요.
두 번째 축을 무시하는 구현을 잡는 장치도 넣었어요. 버전 상태는 같게 두고 제출 객체 상태만 바꾼 대조 케이스 두 개가 같은 판정을 내면 테스트가 실패해요. 노트 비교에는 뮤테이션을 썼어요. 비교 함수를 항상 false를 돌려주는 것과 항상 true를 돌려주는 것으로 각각 바꿔서, 지나치게 허용하는 결함과 지나치게 막는 결함이 모두 검출되는지 확인했어요. 검사 도구 자체를 대조군으로 확인하는 방법은 [검증 스크립트에 대조군을 적용한 글](/posts/verification-script-negative-control/)에서 따로 다뤘어요.
돌아보면 수정 라운드 하나는 피할 수 있었어요. 두 번째 판정 축을 조사한 문서가 이미 저장소에 있었는데 읽지 않고 시작했어요. 몇 달 전에 제가 남긴 기록부터 다시 확인하는 습관이 필요해요.
확인하지 못한 것도 분명히 적어 둬요. 서버가 처리를 마친 뒤 응답만 잃는 장애를 실제로 재현한 기록은 없어요. 재현할 수단을 마련하기 어려웠어요. 이번에 확인한 것은 상태 조합을 읽는 판정 로직이고, 장애 전체를 재현한 것이 아니에요. enum 전수 검사도 고정한 젬이 알고 있는 값만 보장해요. 서버의 최신 상태 집합을 젬이 모두 반영하는지는 젬을 갱신하기 전까지 알 수 없어요.
---
# UMP의 사전 안내와 ATT 요청 순서를 바로잡은 기록
> DailySudoku는 앱이 직접 ATT를 요청하는데, Google UMP도 사전 안내 뒤에 ATT를 요청할 수 있어요. SDK 바이너리에서 ATT 호출 흔적을 찾고, 두 요청이 겹치지 않도록 호출 순서를 바꾼 과정이에요.
- Canonical: https://jaemyeong.com/ko/blog/att-ump-ordering-ios-consent/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #Advertising, #Privacy, #ATT
개인 프로젝트로 DailySudoku를 만들고 있어요. 스도쿠 퍼즐을 날마다 하나씩 내는 앱이에요. 이 앱은 [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), [웹](https://dailysudoku.app/ko/)에 배포하고, 무료로 유지하려고 배너 광고와 동의 처리를 넣었어요.
글에 나오는 약어부터 풀어 둘게요. IDFA(Identifier for Advertisers)는 Apple의 광고 식별자예요. ATT(App Tracking Transparency)는 추적 허가를 묻는 Apple의 프레임워크이고, UMP(User Messaging Platform)는 Google이 제공하는 동의 화면 SDK예요. GDPR(General Data Protection Regulation)은 EU의 개인정보 보호 규정, EEA(European Economic Area)는 유럽경제지역, NPA(Non-Personalized Ads)는 비맞춤 광고를 뜻해요.
ATT 시스템 알림은 문구를 고칠 수 있는 범위가 좁아요. 그래서 요청 전에 이유를 설명하는 화면을 따로 보여 주려고, AdMob 콘솔에서 IDFA용 사전 안내 메시지 초안을 8개 로케일로 작성했어요. 그런데 콘솔에 이 안내 화면이 ATT 알림을 띄운다는 설명이 있었어요. SDK가 직접 ATT를 요청한다면, 이미 앱에서 ATT를 요청하고 있는 지금 구조에서는 안내가 아예 표시되지 않을 수 있어요. 게시를 멈추고 조사를 시작했어요. 앞선 작업에서 3주 반쯤 지난 시점이었어요.
## 한 달 전의 설계
ATT는 한 달쯤 전에 연결하고 감사까지 마친 상태였어요. 그때의 판단은 이랬어요. 지역에 따라 UMP 화면이 생략되는데, ATT를 UMP 완료 콜백 안에 넣으면 그 지역에서는 ATT도 같이 빠져요. UMP 화면이 나오는 곳으로는 GDPR 적용 지역, 영국, 미국 일부 주를 염두에 뒀어요. 주요 시장은 한국이어서 EEA 밖에서도 IDFA를 받을 수 있어야 했어요.
그래서 호출 규약을 UMP 화면과 무관하게 잡았어요. `AppCoordinator`의 시작 task에서 `refreshConsent()` 다음, `maybeStartAdMob()` 앞에 두고 adFree가 아닌 모든 사용자에게 적용했어요. 실제 ATT 요청은 별도 `Task`로 돌아서 UMP와 동시에 진행됐어요.
광고 요청에는 두 응답 가운데 더 제한적인 쪽을 따르는 정책을 썼어요. ATT 거부와 UMP 미동의 가운데 하나만 있어도 `npa=1`을 붙여요. 당시에는 ATT와 UMP 상태 조합 네 가지를 유닛 테스트로 확인했고, 시뮬레이터 첫 실행에서 ATT 알림이 뜨는 것도 EEA가 아닌 로케일을 포함해 확인했어요.
## 먼저 뜬 쪽이 이기는 구조
ATT 요청 화면은 상태가 `.notDetermined`일 때만 나와요. 이미 응답한 뒤의 호출은 기존 상태를 돌려줄 뿐이에요. 그래서 앱의 요청이 먼저 뜨면 SDK의 사전 안내가 나올 자리가 없어져요. SDK가 먼저 움직이면 안내가 나오고 그 뒤에 ATT가 떠요. 어느 쪽이 먼저일지는 네트워크에 걸리는 시간에 따라 달라졌어요. ATT가 먼저 뜬 다음에 안내가 나오는 뒤집힌 순서도 생길 수 있었어요.
이 문제는 기존 테스트에서 드러나지 않았고, AdMob 메시지를 게시하려고 준비하다가 발견했어요.
## 문서에 없어서 바이너리를 열었어요
[Google의 iOS UMP 통합 문서](https://developers.google.com/admob/ios/privacy)에서는 안내 메시지를 만드는 절차를 찾을 수 있었어요. 하지만 ATT를 누가 호출하는지, 샘플은 어떻게 쓰는지, 실행 순서가 어떻게 되는지에 대한 근거는 당시 찾지 못했어요. [Apple의 AppTrackingTransparency 문서](https://developer.apple.com/documentation/apptrackingtransparency)에도 UMP에 관한 답은 없었어요. 콘솔의 안내 문구 하나만 믿고 코드를 바꾸기에는 근거가 부족했어요.
그래서 `UserMessagingPlatform` 바이너리를 직접 봤어요.
```text
UserMessagingPlatform 바이너리:
_objc_msgSend$requestTrackingAuthorizationWithCompletionHandler:
_objc_msgSend$trackingAuthorizationStatus
"ATTrackingManager" ← 문자열(동적 조회)
otool -L: AppTrackingTransparency 하드 링크 없음
```
요청 selector와 상태 조회 selector가 있고, `ATTrackingManager`는 문자열로 들어 있어요. `otool -L` 결과에는 AppTrackingTransparency를 직접 링크한 흔적이 없었어요. SDK가 클래스를 동적으로 찾아 쓴다고 해석했어요. 그렇다면 프레임워크는 앱 쪽에서 링크해 둬야 한다고 추론했는데, 링크를 뺀 상황을 실제로 재현해 보지는 않았어요.
## 순서를 바꾼 네 가지 결정
첫째, 앱의 ATT 요청을 UMP 처리가 끝난 뒤로 옮겼어요. 권한이 이미 결정돼 있으면 가드에서 바로 돌아가니 중복 요청은 생기지 않아요.
둘째, 앱의 ATT 호출을 없애지 않았어요. 안내 메시지를 게시하지 않으면 SDK가 ATT를 요청하지 않는다고 보고, 그 경우에는 앱이 대신 요청하게 했어요. 메시지 게시와 코드 머지 가운데 무엇이 먼저냐에 덜 의존하게 돼요. 앱 호출을 없애면 게시 전까지 IDFA를 받을 길이 없고 AdMob 광고가 비맞춤으로 나간다고 판단했어요.
셋째, 동의 화면이 필요 없는 경로에서도 UMP를 한 번 실행해요. `needsForm`은 동의 화면이 필요한지만 알려 주는 값이라고 봤어요. EEA 밖에서도 SDK가 안내를 처리할 기회를 주려는 것이고, 메시지가 게시돼 있지 않으면 바로 돌아올 것으로 예상했어요.
동의 화면이 필요 없는 경우의 분기예요.
```swift
case .none, .formOnly:
guard AdsConfig.adMobEnabled else { break }
// 폼이 «필요 없어도» UMP 흐름을 한 번 돌립니다.
// 메시지가 게시돼 있지 않으면 즉시 반환합니다(no-op).
umpFlowRan = await consentProvider.presentConsentForm()
```
넷째, `maybeStartAdMob()`은 ATT 응답을 기다리지 않아요. 주요 시장에서는 AdFit을 쓰는 광고 체인이라 AdMob의 첫 노출을 기다려서 얻는 것이 없어요. ATT 알림이 떠 있는 동안 배너가 가려질 수 있다는 점도 고려했어요.
UMP 흐름이 실제로 돌았을 때만 ATT를 예약하는 부분이에요.
```swift
if AdsConfig.adMobEnabled, umpFlowRan {
Task { await ATTAuthorization.request() }
}
maybeStartAdMob()
```
## 코드 리뷰에서 나온 세 가지 수정
첫 번째는 갱신 실패였어요. 네트워크 오류로 생기는 `.refreshFailed`가 `if`/`else if` 구조에서 `.none` 처리로 흘러들어 갔어요. 캐시에 «화면 불필요»가 남아 있으면 UMP가 성공을 돌려주고, 안내가 게시됐는지 모르는 채로 ATT를 요청할 수 있었어요. `switch`로 바꿔서 실패를 따로 처리했어요. 나중에 enum에 값이 늘어나면 컴파일러가 빠진 분기를 알려 주는 효과도 있어요. 아래 발췌에서 `.none`·`.formOnly` 분기는 앞에서 본 코드와 같아서 줄였어요.
```swift
switch prompt {
case .refreshFailed:
// 게시 여부를 모르는 상태입니다. 아무것도 하지 않습니다.
// 동의 상태는 UMP가 마지막으로 아는 값이 유지되고, 다음 런치에 재시도됩니다.
break
case .introThenForm:
umpFlowRan = await presentConsentIntro()
case .none, .formOnly:
// …
}
```
두 번째는 자체 안내 시트였어요. SDK 화면 앞에 보여 주는 안내 시트를 scrim이나 그래버로 닫으면 `presentConsentForm()`이 호출되지 않아요. `umpFlowRan`으로 UMP가 실제로 실행됐는지 확인해서, 이 경로에서는 ATT를 건너뛰어요. 안내 시트는 앱을 시작할 때마다 다시 보여 줘요.
세 번째는 광고 제공자 조건이었어요. 제3자 광고 전체를 보는 가드는 다른 제공자만 켜져 있어도 통과해요. AdMob을 끈 구성에서도 ATT가 실행될 수 있었어요. WKWebView 기반 제공자는 IDFA를 쓰지 않아요. ATT와 UMP의 조건을 `AdsConfig.adMobEnabled`로 좁혔어요.
## ATTAuthorization.request()가 하는 일
AppTrackingTransparency를 import 하는 코드는 파일 하나에 모았어요.
```swift
static func request() async {
guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
if UIApplication.shared.applicationState != .active {
await withCheckedContinuation { continuation in
var observer: NSObjectProtocol?
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { _ in
if let observer { NotificationCenter.default.removeObserver(observer) }
continuation.resume()
}
}
}
await withCheckedContinuation { continuation in
ATTrackingManager.requestTrackingAuthorization { _ in continuation.resume() }
}
}
```
맨 위의 가드는 상태가 `.notDetermined`가 아니면 바로 끝내요. 매번 시작할 때 호출해도 이미 응답한 사용자에게는 [요청 API](https://developer.apple.com/documentation/apptrackingtransparency/attrackingmanager/requesttrackingauthorization(completionhandler:))를 부르지 않아요.
그다음은 앱이 `.active`인지 확인하는 부분이에요. `didFinishLaunching`이나 `willConnectTo` 같은 시작 콜백에서 바로 요청하면 화면 없이 `.notDetermined`가 돌아올 수 있어요. 보통은 UMP가 네트워크를 기다리는 동안 `sceneDidBecomeActive`가 지나가지만, 그 타이밍에 기대지 않으려고 `didBecomeActiveNotification`을 직접 기다려요.
## 확인한 것과 확인하지 못한 것
2026-08-16의 develop 브랜치(커밋 2ae29c42부터 657d87c7까지) 기준으로 iOS는 `xcodebuild test`에서 301 tests, 60 suites가 통과했고 Android는 `compileDebugKotlin`이 성공했어요. 시뮬레이터 첫 시작에서는 ATT 알림이 예전처럼 떴고, 메시지를 게시하지 않은 상태의 UMP 실행에서 모달이 추가로 뜨지 않았어요. 그다음 시작의 로그에는 `[ATTrackingManager] Returning from trackingAuthorizationStatus - 0`이 찍혔어요. 0은 미결정이라서 다시 요청하는 경로예요.
미국 규제 주를 흉내 낼 수 있게 iOS에는 `-AdMobDebugUSState`, Android에는 `--ez admobDebugUsState true` 인자를 넣었어요. SDK 헤더에서 `UMPDebugGeographyRegulatedUSState`의 값이 3인 것을 확인하고 연결했어요.
확인하지 못한 것은 두 가지예요. `simctl privacy grant tracking`은 `Operation not permitted`로 실패해서, ATT 응답을 자동으로 넣을 방법이 없었어요. 응답을 마친 뒤에 다시 묻지 않는지는 실기기 QA로 남겼어요. 메시지를 게시한 뒤 두 번째 실행에서 안내가 다시 뜨는지도 보지 못했어요. 당시에는 게시 조건을 채우지 못했기 때문이에요.
남은 한계도 있어요. 안내 메시지를 게시하기 전에는 앱의 ATT가 안내 없이 뜰 수 있어요. 폴백으로 의도한 동작이에요. 바이너리에서 본 내용은 그때 쓰던 UMP 배포본에 한정된 관찰이고, Google 공식 문서가 보장하는 동작이 아니에요. SDK를 바꾸면 다시 조사해야 해요.
문서 빚도 하나 남겼어요. `ATTAuthorization.swift`의 주석에 예전 호출 규약이 그대로 있어요. 화면 표시와 무관하게 실행한다는 설명, `refreshConsent()` 다음에 바로 실행한다는 설명, ATT와 UMP가 서로 독립이라는 설명이 지금 구현과 맞지 않아요. 다른 파일의 주석 두 건은 고쳤는데 이 파일은 놓쳐서 이슈로 등록해 뒀어요. 전제가 바뀌면 예전 결정도 다시 봐야 해요. 그래서 결정을 적을 때는 결론뿐 아니라 그 결론이 성립하는 전제를 같이 적어야 해요.
---
# 수출 규정 신고값을 두 파일과 ASC에서 맞추는 검사
> 암호화 수출 규정 신고값은 Info.plist와 제출 선언 파일 두 곳에 있고, App Store Connect에도 남아요. 세 값이 어긋났는데 성공으로 판정하는 경로와, 그것을 막으려다 재시도를 전부 차단한 회귀를 고친 과정을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/export-compliance-two-source-validation/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #CI-CD, #App Store Connect, #Automation, #Testing
하루에 스도쿠 한 판을 내는 개인 앱 DailySudoku를 iOS, Android, Web에 함께 내고 있어요. 배포 채널은 [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), [웹](https://dailysudoku.app/ko/) 세 곳이에요. git 태그를 push하면 App Store Connect(ASC)에 심사를 요청하는 데까지 자동으로 진행돼요.
[심사 제출을 다시 실행해도 안전하게 만드는 작업](/posts/app-store-connect-idempotent-submission/)을 하던 중에 암호화 신고 검사를 더했어요. 그 과정에서 제가 만든 결함을 네 번 다시 고쳤어요. 코드는 2026년 8월 16일 develop 브랜치의 스토어 메타데이터 검사와 iOS 배포 자동화를 기준으로 해요.
## 신고값이 있는 자리
앱을 업로드할 때에는 [암호화 사용 여부를 신고](https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations)해야 해요. 이 값은 저장소 안의 두 곳에 있어요. 하나는 `Info.plist`의 [ITSAppUsesNonExemptEncryption](https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption) 키이고, 다른 하나는 자동화가 입력으로 쓰는 제출 선언 파일이에요.
두 값이 어긋나면 ASC가 추가 질문을 하고 파이프라인이 멈춰요. 선언 파일에는 false 값과 함께, 두 파일을 같이 고쳐야 한다는 주석이 있었어요. 그 주석을 실제로 검사하는 코드는 없었어요.
## 응답이 있다는 것과 응답이 맞다는 것
처음에는 값을 비교하지 않기로 했어요. 버전이 규정 응답을 기다리는 상태라면 어차피 수동 대응으로 넘어가니, 따로 비교할 필요가 없다고 판단했어요.
이 판단에는 빈틈이 있었어요. 응답이 있는지와 그 응답이 승인한 값과 같은지는 서로 다른 조건이에요. 사람이 ASC에서 직접 입력했거나 이전의 자동 실행이 다른 값을 넣은 뒤에도 상태는 «해결됨»이 돼요. 여기에 빌드, 릴리스 노트, 출시 방식까지 맞으면 재실행이 성공으로 판정될 수 있어요. 그러면 승인한 범위를 벗어난 신고로 심사가 진행돼요. 잘못된 신고는 심사 거절이나 정책 위반의 이유가 될 수 있어요.
첫 보완으로 ASC에 있는 실제 값을 승인값과 비교하고, 응답이 없으면 막도록 했어요.
## 30분 뒤에 찾은 회귀
이 보완이 곧바로 다른 문제를 만들었어요. 승인값을 넘겨주는 함수는 Ruby 심볼을 키로 쓰는데, 값을 꺼내 쓰는 코드는 문자열 키로 조회했어요.
같은 값을 두 가지 키로 조회한 결과예요.
```text
문자열로 조회: nil ← 옛 코드
심볼로 조회 : false ← 고친 코드 (= 선언 파일의 승인값)
```
조회 결과가 false가 아니라 nil이어서, ASC의 값과 항상 다르다고 판정했어요. 그 결과 모든 재시도가 막혔어요.
항상 막아 버리는 결함은 이번이 세 번째였어요. 승격 비교, 로케일 식별자, 그리고 이번의 신고값 조회예요. 기존 self-test는 키의 개수와 값의 타입만 확인하고 있었어요. 그래서 값을 꺼내 쓰는 코드와 똑같은 키로 조회하는 단언을 더했어요.
## 같은 유형을 한 번 더 찾아봤어요
리뷰를 받기 전에 같은 유형의 차단 경로가 더 있는지 찾아봤고, 네 번째 후보가 나왔어요. 직전 커밋은 ASC의 신고값이 nil이면 응답이 없는 것으로 취급했어요.
그런데 앱 바이너리의 `Info.plist` 선언으로 규정 처리가 끝난 경우에는 Build의 신고 필드가 비어 있을 수 있어요. 이것은 제출 도구의 구현에서 끌어낸 추론이에요. 제출 도구인 [fastlane deliver](https://docs.fastlane.tools/actions/deliver/)는 이 값이 nil일 때에만 값을 기록해요. 이 상태로 내보냈다면 정상적인 재시도까지 막혔을 거예요. 이번에는 배포하기 전에 찾았어요.
수정한 뒤에는 ASC의 필드에 값이 있을 때에만 승인값과 비교해요. 실제로 해결되지 않은 건은 버전이 응답을 기다리는 상태인지를 보는 검사가 막아요. nil이라는 값과 미해결 상태를 같은 것으로 보지 않게 역할을 나눴어요.
## 두 파일을 비교하는 오프라인 검사
실행 중의 판정과 별개로, 저장소 안의 계약을 검사로 만들었어요. `Info.plist`와 제출 선언을 비교하는 검사예요. 한 파일만 고친 시점에 바로 충돌을 알 수 있어서 PR마다 잡을 수 있어요.
두 파일에서 읽은 값이 같은지 단언하는 부분이에요.
```python
plist_value = plist[key] # Info.plist
declared = decl[field] # 제출 선언 파일
assert plist_value == declared, (
"수출 규정 선언이 두 곳에서 어긋난다 — ASC가 되묻거나 잘못된 신고로 심사가 진행된다. "
"**한쪽만 고치지 말 것**(선언 파일 주석)."
)
```
값의 타입도 함께 봐요. `plutil`로 값을 뽑으면 XML의 문자열 노드에 들어 있는 false도 문자열로 나와요. 이 문자열을 "true"와 비교한 결과만 Boolean으로 바꾸면 원본의 타입이 틀렸다는 것을 놓쳐요. 그래서 Python의 [plistlib](https://docs.python.org/3/library/plistlib.html)로 파싱한 뒤 bool인지 검사해요.
`Info.plist`에서 읽은 값이 Boolean인지 확인하는 단언이에요.
```python
assert isinstance(plist_value, bool), (
f"Info.plist 의 {key} 가 Boolean 이 아니다: {plist_value!r}. "
"ASC 는 이 키를 Boolean 으로 요구한다 — 또는 여야 한다"
)
```
키가 아예 없는 경우도 실패로 처리해요. 키가 빠지면 ASC가 다시 묻고 파이프라인이 멈추기 때문이에요.
## 검사가 도는 자리도 틀렸어요
이 검사를 처음에는 iOS 배포 워크플로의 self-test에 넣었어요. 그 워크플로는 PR에서 `apps/ios/**` 경로가 바뀔 때만 돌아요. 제출 선언 파일은 그 경로 밖에 있어서, 선언만 고친 PR에서는 검사가 실행되지 않았어요.
두 파일을 모두 담당하는 스토어 메타데이터 검사로 옮겼어요. 이 검사는 모든 PR과 push에서 돌아요.
확인은 세 가지로 했어요. `Info.plist`의 Boolean 값을 뒤집는 뮤테이션을 넣었을 때 단언이 실패하는지 봤어요. self-test에서는 실제로 값을 꺼내 쓰는 키로 조회해요. 그리고 코드에서 쓰는 젬의 접근자와 상수를 고정한 소스와 빠짐없이 하나씩 대조했어요. 이름을 확인하지 않고 참조한 것이 반복된 원인이었기 때문이에요. 검사에 실패하는 입력을 넣어 보는 방법은 [검증 스크립트에 대조군을 적용한 글](/posts/verification-script-negative-control/)에서 다뤘어요.
주석으로 적은 약속은 읽는 사람이 관리되는 계약으로 착각하기 쉬우니 단언으로 바꿔야 해요. 결함 하나를 고칠 때 같은 유형의 실패 경로를 더 찾아보는 것이 효과가 있었어요. 검사가 지켜보는 변경이 그 검사의 실행 조건에도 들어 있는지 확인해야 해요. 안전장치를 더하는 작업도 새 오류를 만들 수 있다는 것을 이번에 겪었어요.
어긋난 선언으로 실제 제출을 해 본 적은 없어요. 잘못된 신고로 심사 큐를 시험할 수는 없었어요. 두 값이 충돌하거나 키가 빠졌을 때 ASC가 다시 묻는다는 것은 선언 파일의 주석에 남은 경험과 API의 요구사항에 기댄 설명이에요. nil 처리의 근거도 도구의 구현이고, ASC가 Build의 신고 필드를 채우는 정확한 조건은 문서에서 확인하지 못했어요.
---
# 병렬 테스트가 전역 알림으로 서로를 건드린 사례
> 로컬에서는 0.02초에 끝나던 광고 배너 테스트가 CI에서는 45분 제한에 걸렸어요. 러너의 부하를 의심했지만 원인은 병렬로 도는 다른 테스트가 보낸 전역 알림이었어요. 재현 실험으로 가설을 확인하고 NotificationCenter를 주입해 분리한 과정을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/parallel-test-global-notification-isolation/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #Testing, #CI-CD, #Troubleshooting
사이드 프로젝트로 만드는 스도쿠 앱 DailySudoku는 매일 한 판을 무료로 내고, 배너 광고를 붙여요. 받을 수 있는 곳은 [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), [웹](https://dailysudoku.app/ko/)이에요. 이 배너 광고의 iOS 테스트에서 장애가 나왔어요.
`holdsImpressionUntilTheSlotIsLiveAgain`이라는 테스트가 로컬에서는 약 0.02초에 통과하는데, CI 작업은 45분 제한에 닿아서 끝났어요. 첫 조치를 한 뒤에는 무한 대기가 없어졌지만 테스트는 계속 실패했어요. 코드의 기준은 2026년 8월 14일 develop 브랜치의 커밋 `d44808bb`와 `b7646ee2`의 iOS 구현이에요.
## 배너가 듣고 있던 알림
`AdBannerView`는 광고 제거 구매, 동의 변경, 포그라운드 전환에 반응해야 해요. 그래서 `NotificationCenter.default`에서 세 가지 알림을 관찰하고 있었어요.
배너가 관찰자를 등록하던 코드예요.
```swift
for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
}
```
마지막의 활성 전환 알림에는 이유가 있어요. 슬롯이 비활성인 동안 미뤄 둔 로드와 노출 처리를 다시 시작할 계기가 필요해요. 이미 떠 있는 화면으로 돌아오는 경우에는 `viewWillAppear`가 호출된다고 기대할 수 없어요. 이 계기가 없으면 광고 슬롯도 노출 기록도 비어 있을 수 있어요.
## 처음에는 러너의 부하를 의심했어요
CI 러너를 여러 작업이 함께 쓰는 구성이어서 처음에는 부하를 의심했어요. 그러다 테스트 코드에서 다른 문제를 찾았어요. `#expect`가 실패해도 실행은 계속되고, 그 뒤에 `shownKind`를 기다리는 상한 없는 `while`로 들어가는 흐름이었어요.
문제가 된 흐름을 줄여서 적으면 이래요. 인자와 루프 본문은 생략했어요.
```swift
#expect(await waitForChain( // 실패해도 실행이 멈추지 않는다
// …
))
while banner.shownKind == nil { // 그래서 여기서 영원히 대기한다
// …
}
```
시간도 재 봤어요. 이 테스트만 따로 실행하면 로컬에서 0.024초였어요. 경합이 있는 상황에서는 체인을 구성하는 MainActor의 비동기 작업이 늦어져서 4초의 관측 범위를 벗어난 사례가 있었어요.
다른 변경이 원인일 가능성도 배제했어요. 광고 라우팅을 바꾼 PR의 `releaseForcedProvider`는 DEBUG에서 항상 `nil`이에요. 리뷰를 요청한 PR에는 광고 디렉터리의 변경이 없어요. 두 PR의 ancestry와 diff로 확인했어요.
먼저 방어 조치를 넣었어요. provider의 네트워크 제한 시간에 맞춰 대기를 4초에서 8초로 늘리고, 기존의 폴링 방식으로 `waitForShown`을 만들었어요. [#require](https://developer.apple.com/documentation/testing/require(_:_:sourcelocation:))로 실패한 뒤의 진행을 막고, 상한 없는 루프를 시간 상한이 있는 대기로 바꿨어요. 결과는 45분 대기가 8초 실패로 바뀐 것이에요. 테스트가 서로 간섭하는 문제는 그대로였어요.
## 다른 테스트가 보낸 알림이 배너를 초기화했어요
원인 가설은 [Swift Testing의 병렬 실행](https://developer.apple.com/documentation/testing/parallelization)과 프로세스 전체가 공유하는 알림의 결합이에요. `SudokuStore.didChange`를 발행하는 테스트 파일은 정적으로 검색했을 때 7개였어요. 관련 없는 테스트가 보낸 알림이, 관찰 중인 배너들의 `refresh()`를 동기로 호출해요. 광고가 비활성인 상태라면 `!enabled` 분기에서 `chain`, `shownKind`, `pendingImpression`을 지우고 세대를 올려요.
이 테스트만 취약했던 조건은 둘이에요.
- watcher는 `shownKind`를 입력으로 광고 제거 상태를 판정해요. 값이 생긴 뒤부터 리셋될 수 있어요. 값이 생기기 전의 알림에서는 `setNeedsLayout()`만 해요.
- 다른 테스트와 달리 이 테스트는 첫 대기 전에 `layoutIfNeeded()`를 호출하지 않아요. 그래서 리셋된 뒤에 로드를 다시 시작할 레이아웃 단계가 없어요.
두 조건이 함께 있는 테스트는 이것 하나뿐이라고 판단했어요.
로컬과 CI가 달랐던 이유도 이 가설로 설명했어요. 로컬에서 통과했을 때에는 함께 실행된 스위트가 위험한 구간에 알림을 보내지 않았다고 봤어요. 경합으로 시간이 늦어진다는 관측 자체는 맞지만, 이번 실패의 원인으로는 맞지 않았어요.
가설은 실험으로 확인했어요. 알림을 발행하는 `Task`를 더한 로컬 실험에서 실패를 유도할 수 있었어요. 병렬로 실행하던 기존 테스트도 같은 코드 위치에서 실패했어요.
## NotificationCenter를 주입했어요
`AdBannerView`의 생성자에 `notificationCenter: NotificationCenter = .default`를 더하고, 관찰자를 주입받은 센터에 등록하도록 바꿨어요.
바꾼 생성자의 일부예요. 관찰자 등록 앞의 초기화 코드는 생략했어요.
```swift
init(
colors: SudokuColors,
adsProvider: AdsProvider,
consentProvider: ConsentProvider,
notificationCenter: NotificationCenter = .default // 추가
) {
// …
for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
notificationCenter.addObserver(self, selector: #selector(refresh), name: name, object: nil)
}
}
```
테스트 파일 3개가 각자 별도의 인스턴스를 넘겨요. 기본값이 있어서 앱은 원래 쓰던 [NotificationCenter](https://developer.apple.com/documentation/foundation/notificationcenter)를 그대로 써요. `adsProvider`, `consentProvider`, `makeAd`, `logger`를 이미 주입하고 있었으므로 같은 방식이에요. `refresh()`와 `didMoveToWindow()`의 초기화 동작은 크리에이티브를 지운다는 뜻이 있어서 그대로 뒀어요.
다른 방법도 검토했어요. `@Suite(.serialized)`는 그 스위트 안의 동시 실행만 제한하므로, 다른 스위트가 보내는 알림에는 효과가 없어요. 레이아웃이 다시 돌지 않는 상태에서는 8초를 80초로 늘리거나 무한히 기다려도 회복되지 않아요.
## 고친 것을 확인한 방법
`xcodebuild test`의 결과는 299 tests, 60 suites 성공이었어요. 회귀 테스트를 하나 더해서 298개에서 299개가 됐어요. 문제의 테스트만 실행하면 0.023초, 전체는 8.58초였어요.
새 테스트가 결함을 잡는지도 확인했어요. 센터 격리를 `.default`로 되돌리는 뮤테이션을 넣으면 새 테스트와 기존 테스트가 모두 실패했어요. 검증 코드에 일부러 실패 조건을 넣어 보는 방법은 [검증 스크립트에 대조군을 적용한 글](/posts/verification-script-negative-control/)에서 다뤘어요.
이번 일에서 남긴 기준은 넷이에요. CI에서만 실패한다는 이유로 성능 문제라고 단정하지 않아요. 코드 분석으로 세운 가설은 재현으로 확인해요. 회귀 테스트는 결함을 다시 넣어서 실패하는지 봐요. 시간 상한과 실패 뒤의 진행 차단은 격리 수정과 별개로 가치가 있으므로 함께 남겨요.
직접 관측한 것은 인위적으로 만든 알림 조건에서의 실패예요. 실패한 CI 실행에서 실제로 어느 스위트가 알림을 보냈는지는 찾지 못했어요. 파일이 7개라는 검색 결과로는 그때의 발신자를 확정할 수 없어요. 각 테스트 사례에 별도의 센터를 만드는지에 관한 설명은 이 글에 없어요.
---
# 리뷰 요청 예약에서 취소된 Task가 끝까지 실행된 경로
> 승리한 뒤 2초 기다렸다가 스토어 리뷰를 요청하는 예약이, 화면을 벗어나 취소한 뒤에도 실행될 수 있었어요. Task.sleep이 정상 반환한 뒤의 취소는 오류로 돌아오지 않기 때문이에요. 취소 동작을 Swift 6.4에서 다시 확인하고 고친 과정을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/task-sleep-cancellation-review-prompt-gate/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #StoreKit, #Concurrency, #Android
매일 스도쿠 한 판을 내는 개인 프로젝트 DailySudoku에, 이긴 뒤에 스토어 평가를 요청하는 기능을 넣었어요. 스토어와 웹의 주소는 [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), [웹](https://dailysudoku.app/ko/)이에요.
iOS의 [AppStore.requestReview(in:)](https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)-1q8qs)는 창이 실제로 떴는지 알려 주지 않고, 끝났다는 통지도 없어요. 시스템이 표시 빈도를 제한하고, 앱도 버전마다 한 번만 요청하도록 제한해요. 화면이 요청하기에 알맞지 않은 순간에 시도 이력을 남기면 그 릴리스의 요청 기회를 써 버리게 돼요. 코드 리뷰 두 번이 그런 경로를 하나씩 찾았어요.
이 글의 이전 판은 Swift의 취소 오류 이름과 `Task.sleep`의 동작을 틀리게 적었어요. 2026년 10월 4일에 Swift 6.4에서 독립된 프로그램으로 취소 동작을 다시 실행해 보고 고쳤어요. 앱의 테스트와 실제 경합은 이번에 다시 실행하지 않았어요.
## 요청을 예약하는 흐름
승리로 게임이 끝나면 `markPending()`이 자격을 확인하고 pending만 표시해요. 이때 StoreKit은 부르지 않아요. 완료 화면이 보이면 `screenDidShowCompletion()`이 pending일 때만 그 시점부터 2초 뒤의 실행을 예약하고, 화면을 벗어나면 `screenWillDisappear()`가 예약을 취소해요. 지연을 두는 방식은 [Apple의 리뷰 요청 안내](https://developer.apple.com/documentation/storekit/requesting-app-store-reviews)의 샘플과 같은 기준이에요.
실행하는 쪽인 `fire`는 예약할 때의 scene을 저장해 두지 않고, 실행 직전에 `presenter.view.window?.windowScene`을 조회해요.
```swift
private func fire(presenter: UIViewController?, repository: ReviewPromptRepository) {
scheduledTask = nil
guard let presenter, let scene = presenter.view.window?.windowScene else { return }
let version = ReviewPromptConfig.currentAppVersion
let epochDay = EpochDays.today(millis: Int64(Date().timeIntervalSince1970 * 1000))
// 시도 기록은 여기에서만 합니다.
repository.recordAttempt(epochDay: epochDay, version: version)
AppStore.requestReview(in: scene)
}
```
시도 이력은 여기에서만 남겨요. pending을 표시할 때 저장하지 않는 것은, 취소된 예약에 쿨다운이 걸리지 않게 하기 위해서예요. 화면을 벗어나면 window가 nil일 것이라고 가정했는데, 전환 중이거나 백그라운드에 있을 때에는 이 가정이 맞지 않을 수 있어요.
## 첫 번째 지적: 백그라운드에서도 window는 남아요
첫 리뷰가 찾은 것은 2초를 기다리는 동안 앱이 백그라운드로 가는 경우예요. 백그라운드에서도 UIKit의 window 연결은 남을 수 있어서, window가 있다는 것만으로는 요청해도 되는 상태인지 판단할 수 없어요.
scene이 `.foregroundActive`인지 확인하는 조건을 더했어요.
```swift
guard let presenter, let scene = presenter.view.window?.windowScene,
scene.activationState == .foregroundActive else { return }
```
화면이 전환되는 도중에는 이 조건도 요청하기 알맞은 상태를 보장하지 않아요.
## 두 번째 지적: 취소했는데 fire까지 간 경로
이틀 뒤의 리뷰가 찾은 것은 화면을 벗어나 예약을 취소했는데도 `fire`가 실행될 수 있는 경로예요. 예약은 이렇게 돼 있었어요.
```swift
scheduledTask = Task { [weak presenter] in
do { try await Task.sleep(for: .seconds(2)) }
catch { return } // 취소됨 — 여기서 끝난다고 생각했습니다
fire(presenter: presenter, repository: repository)
}
```
취소되면 [Task.sleep](https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:))이 오류를 던지고 `catch`에서 끝난다고 생각했어요. 이 생각은 절반만 맞아요. 이전 판에서는 이 오류를 `CancellationException`이라고 적었는데, 그것은 Kotlin의 이름이에요. Swift에서는 `CancellationError`예요.
이번에 실행해서 확인한 동작은 이래요(Swift 6.4, 독립 프로그램).
- 이미 취소된 Task 안에서 `try await Task.sleep`을 부르면 `CancellationError`가 나와요.
- 30초를 기다리는 도중에 취소해도 `CancellationError`가 나와요.
- `Task.sleep`이 정상으로 반환한 뒤에 취소하면, 다음 문장은 그대로 실행돼요. 이때 `Task.isCancelled`는 true예요.
- `try?`로 감싸면 오류가 nil로 바뀔 뿐이고, 다음 문장이 실행돼요. 취소 상태는 그대로 남아요.
- 취소된 상태에서 `Task.checkCancellation()`을 부르면 `CancellationError`가 나와요.
세 가지 경우를 실행한 출력이에요. `before`는 취소한 뒤에 sleep을 부른 경우, `during`은 기다리는 도중에 취소한 경우, `after`는 sleep이 반환한 뒤에 취소한 경우예요.
```text
before: isCancelled=true
before: error=CancellationError, CancellationError=true
try?: nil=true, continued=true, isCancelled=true
check: CancellationError=true
during: CancellationError=true
after: sleep returned
after: continued=true, isCancelled=true
after: check CancellationError=true
```
`after`의 결과가 문제의 경로예요. sleep이 이미 반환했다면 뒤늦은 취소는 그 호출을 실패로 되돌리지 않아요. `catch`를 거치지 않으므로 `fire`까지 가요. 이전 판은 이것을 «만료된 sleep은 오류를 던지지 않는다»고 넓게 적었는데, 확인된 것은 «정상 반환한 뒤의 취소»까지예요. 타이머가 끝난 시점과 Task가 다시 실행되는 시점 사이의 정확한 경합은 이 실험으로 판정하지 못했어요.
수정은 한 줄이에요. sleep의 오류를 처리한 뒤에 지금 취소 상태인지 한 번 더 봐요.
```swift
do { try await Task.sleep(for: .seconds(2)) }
catch { return }
guard !Task.isCancelled else { return } // 이 줄이 있어야 합니다
fire(presenter: presenter, repository: repository)
```
`catch`는 기다리는 도중의 취소를, `guard`는 반환한 뒤의 취소를 막아요. `Task.checkCancellation()`으로 오류를 받아 처리하는 방법도 있어요. 이 `guard`가 보는 것은 그 순간의 상태예요. 확인한 뒤에 다른 실행 문맥에서 취소가 일어날 가능성까지 없애 주지는 않아요. 화면 이벤트와 이 실행이 같은 문맥에서 차례로 처리되는지는 발췌한 코드만으로 확인할 수 없어요.
## Android와 비교한 것
당시에는 같은 문제가 Android 쪽에는 없다고 판단했어요. 근거는 둘이었어요. [Kotlin의 취소 문서](https://kotlinlang.org/docs/coroutines-cancellation.html)에 따르면 `delay` 같은 중단 함수는 중단될 때 취소를 확인해요. [`delay`의 API 문서](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html)는 중단된 동안 취소되면 결과를 돌려줄 준비가 됐더라도 `CancellationException`을 던진다고 적어요. 그리고 `recordPrompt`가 `suspendCancellableCoroutine` 뒤에 놓여 있어요. 다만 이 보장은 중단된 동안 들어온 취소에 대한 것이에요. `suspendCancellableCoroutine`이 정상으로 돌아온 뒤 `recordPrompt`를 부르기 전에 취소가 들어오면, 위 실측의 `after` 경우처럼 다음 문장이 실행될 수 있어요. Android 코드가 그 사이에 `ensureActive()` 같은 확인을 하는지와 취소를 재현한 결과는 이 글에 없어서, Android 쪽이 안전하다고 단정할 수는 없어요. iOS의 `recordAttempt`는 동기 호출이라 취소를 확인하는 지점이 아예 없어요. [Google Play의 앱 내 리뷰](https://developer.android.com/guide/playcore/in-app-review) 쪽 흐름은 이 비교의 대상이 아니에요.
이전 판은 여기에 «Swift의 sleep은 기다릴 때에만 취소를 확인한다»고 덧붙였는데, 위 출력의 `before`처럼 이미 취소된 Task에서도 오류가 나오므로 그 설명은 맞지 않아요. Kotlin 쪽은 코드를 실행해서 확인한 것이 아니에요. API가 보장하는 것과 기능 전체가 안전하다는 것은 다른 문제이고, 플랫폼마다 따로 확인해야 해요.
## 자격 판정은 순수 함수로 뺐어요
요청해도 되는지를 계산하는 부분은 StoreKit에 의존하지 않는 함수로 분리했어요.
```swift
static func shouldRequestReview(
wonCompletionCount: Int,
lastAttemptEpochDay: Int64,
todayEpochDay: Int64,
lastPromptedVersion: String?,
currentVersion: String,
forceOverride: Bool
) -> Bool {
if forceOverride { return true }
guard wonCompletionCount >= ReviewPromptConfig.wonCompletionThreshold else { return false }
guard todayEpochDay - lastAttemptEpochDay >= ReviewPromptConfig.cooldownDays else { return false }
if let lastPromptedVersion, lastPromptedVersion == currentVersion { return false }
return true
}
```
`forceOverride`가 켜져 있으면 먼저 허용해요. 그다음 이긴 횟수를 기준과 비교하고, 마지막 시도로부터 지난 날을 쿨다운과 비교하고, 현재 버전이 마지막으로 요청한 버전과 같으면 거부해요. StoreKit의 `AppStore`를 부르지 않으므로 진리표로 테스트할 수 있어요.
같은 JSON에 담긴 12개 입력을 iOS와 Android가 각자 읽어서 테스트해요. 케이스 수를 보는 Android의 검사는 `>= 10`에서 정확히 12로 바꿨어요. `>= 10`으로는 공유 픽스처가 달라지거나 두 쪽이 어긋나는 것을 잡지 못했기 때문이에요. 날짜 경계를 다루는 방식은 [리더보드의 날짜 경계를 다룬 글](/posts/daily-leaderboard-date-boundary-submission-reliability/)과 이어져요.
승리 기준과 쿨다운 값은 제가 골랐어요. 사용자가 의견을 갖기 전에 요청하지 말라는 [Apple의 평점과 리뷰 지침](https://developer.apple.com/design/human-interface-guidelines/ratings-and-reviews)의 취지보다 이른 요청을 허용하는 값이라고 스스로 평가해요. 시스템의 상한을 추가 보호로 보고, 고른 이유를 상수 옆 주석에 적어 뒀어요. 시스템 상한이 지침의 권고를 대신해 준다는 보장은 없어요.
## 확인한 범위
원래의 검증은 2026년 8월 15일 develop 브랜치의 커밋 `eeb4d90d`, `9884d36f`, `fa69105c`를 기준으로 했어요. iOS의 `xcodebuild test`는 287 tests, 60 suites가 통과했고, Android의 `testDebugUnitTest`는 297 tests, 42 classes가 통과했어요. 자격 판정은 두 플랫폼 모두 12케이스예요. 이 숫자는 그때의 기록이고, 이번에 다시 실행한 것이 아니에요.
자동 테스트가 덮지 못하는 것이 많아요. 이 API는 창을 표시했는지를 알려 주지 않으므로, 자동 테스트로는 시스템 창이 실제로 떴는지를 확인하지 못해요. 취소 뒤의 실행을 자동으로 검사하는 테스트는 두 플랫폼 모두 없어요. iOS에는 해당 테스트 파일이 없고, pending을 표시하는 부분만 유닛 테스트의 범위예요. 테스트 파일이 있었더라도 xctestplan의 킬스위치가 실제 호출을 막았을 거예요. 취소 경로는 코드 추적과 정적 call-graph로 검토했고, 두 결함은 모두 코드 리뷰에서 나왔어요.
기기에서는 Xcode의 디버그 빌드에 자격 판정을 건너뛰는 런치 인자를 줘서 확인해요. [`requestReview(in:)` 문서](https://developer.apple.com/documentation/storekit/appstore/requestreview(in:)-1q8qs)에 따르면 TestFlight로 배포한 앱에서는 이 메서드가 효과를 내지 않아요.
이번에 실행으로 확인한 것은 독립된 Swift 프로그램에서의 취소 동작까지예요. UIKit, StoreKit, MainActor의 큐가 얽힌 실제 경합과, 수정한 뒤의 앱에서 그 경로가 막혔는지는 재현하지 못했어요.
---
# A Guinness at Acorn in Suyeong, where the owner is as kind as ever
> A bar in Suyeong for a third round after Haesam Meongge Malmijal. I had only a Guinness, and my friend had a shot of whisky. We nearly did not get a seat.
- Canonical: https://jaemyeong.com/en/blog/suyeong-acorn-20260815/
- Published: 2026.08.16
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #bar, #whisky
At about 8 on a Saturday evening, after the second round at [Haesam Meongge Malmijal](/en/posts/suyeong-haesam-meongge-malmijal-20260815/), my friend and I went to Acorn for a third. It is a bar I have been to several times. There are also notes from [April 16](/en/posts/suyeong-acorn-20260416/) and [April 25](/en/posts/suyeong-acorn-20260425/).
You go into the alley to the right of the McDonald's near Suyeong Station. A standing sign is set out in the alley.
The bar is small and has few seats. That evening too, no seat was free when we arrived and we nearly could not sit. A group happened to leave, and we got in. It is better to ask whether a seat is free before you go.
I had drunk a lot of soju in the first two rounds, so here I had only a Guinness.

My friend ordered a shot of whisky. This bar has many kinds of whisky. If it is hard to choose, the owner recommends one. The owner is kind every time I come. I think the prices are reasonable too.
The room feels cozy. Figures are set out above the bar, and one wall is filled with Polaroid photos of guests who have visited.

---
# 수영 에이콘에서 기네스 한 잔, 사장님은 여전히 친절했어요
> 해삼멍게말미잘에서 나와 친구와 3차로 간 수영의 바예요. 앞에서 소주를 많이 마셔서 저는 기네스만 마셨고 친구는 위스키를 샷으로 시켰어요. 자리가 적어서 못 앉을 뻔했어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-acorn-20260815/
- Published: 2026.08.16
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #bar, #whisky
토요일 저녁 8시쯤, [해삼멍게말미잘](/posts/suyeong-haesam-meongge-malmijal-20260815/)에서 2차를 마치고 친구와 3차로 에이콘에 갔어요. 여러 번 온 바예요. [4월 16일](/posts/suyeong-acorn-20260416/)과 [4월 25일](/posts/suyeong-acorn-20260425/)에 왔던 기록도 있어요.
수영역 근처 맥도날드의 오른쪽 골목으로 들어가면 돼요. 골목에 입간판이 서 있어요.
가게가 작고 자리가 많지 않아요. 이날도 도착했을 때는 자리가 없어서 못 앉을 뻔했어요. 마침 한 팀이 나가서 앉을 수 있었어요. 가기 전에 빈자리가 있는지 물어보고 가는 것이 좋겠어요.
저는 1차와 2차에서 소주를 많이 마신 터라 여기에서는 기네스만 마셨어요.

친구는 위스키를 샷으로 시켰어요. 이 집에는 위스키 종류가 많아요. 고르기 어려우면 사장님이 추천해 줘요. 사장님은 올 때마다 한결같이 친절해요. 값도 적당하다고 생각해요.
실내는 포근한 느낌이에요. 바 위쪽에는 피규어가 놓여 있고, 한쪽 벽은 다녀간 손님들의 폴라로이드 사진으로 채워져 있어요.

---
# Haesam Meongge Malmijal in Suyeong: fresh seafood for a second round
> A seafood bar in Suyeong for a second round after Baeksu Nongwon. For the 25,000 won seasonal trio we picked turban shell, raw shrimp, and sea cucumber.
- Canonical: https://jaemyeong.com/en/blog/suyeong-haesam-meongge-malmijal-20260815/
- Published: 2026.08.16
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #seafood, #bar
After the meat at [Baeksu Nongwon](/en/posts/suyeong-baeksu-nongwon-20260815/), I wanted one more light round with seafood, and came here at about 7 on a Saturday evening. It is a bar that mainly serves seafood in season. There is also [a note from a visit with a friend in March](/en/posts/suyeong-haesam-meongge-malmijal-20260309/).
It is about a 6-minute walk from exit 5 of Suyeong Station, inside an alley.
We ordered the seasonal trio. It is 25,000 won. We picked turban shell, raw shrimp, and sea cucumber. We ordered a bottle of Chamisul too.

The seafood was fresh. Having eaten just before, we ate only a little and talked.
The inside is neatly done. That evening many of the guests were women. On the terrace side there was a guest who had brought a dog.
The third round was at [Acorn](/en/posts/suyeong-acorn-20260815/).
---
# 수영 해삼멍게말미잘, 2차로 먹은 제철 3종이 신선했어요
> 백수농원에서 고기를 먹고 2차로 간 수영의 해산물 술집이에요. 25,000원짜리 제철 3종을 소라와 생새우, 해삼으로 골랐어요. 해산물이 신선했고 실내가 단정했어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-haesam-meongge-malmijal-20260815/
- Published: 2026.08.16
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #seafood, #bar
[백수농원](/posts/suyeong-baeksu-nongwon-20260815/)에서 고기를 먹고 나서, 해산물로 가볍게 한 번 더 먹으려고 토요일 저녁 7시쯤에 2차로 갔어요. 제철 해산물을 주로 내는 술집이에요. [3월에 친구와 왔던 기록](/posts/suyeong-haesam-meongge-malmijal-20260309/)도 있어요.
수영역 5번 출구에서 걸어서 6분쯤 걸리고, 골목 안쪽에 있어요.
제철 3종을 시켰어요. 25,000원이에요. 저희는 소라와 생새우, 해삼을 골랐어요. 참이슬도 한 병 시켰어요.

해산물이 신선했어요. 앞에서 먹고 온 터라 조금씩만 먹으면서 이야기를 나눴어요.
실내는 단정하게 꾸며져 있어요. 이날은 여자 손님이 많았어요. 테라스 쪽에는 강아지를 데리고 온 손님도 있었어요.
3차는 [에이콘](/posts/suyeong-acorn-20260815/)이었어요.
---
# Baeksu Nongwon in Suyeong: cheap pork, then a roasted sweet potato
> A barbecue place in Suyeong I went back to on a Saturday evening with a close younger friend. Pork belly and marinated ribs at low prices, then sweet potato.
- Canonical: https://jaemyeong.com/en/blog/suyeong-baeksu-nongwon-20260815/
- Published: 2026.08.16
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #pork, #barbecue
At about 6 on a Saturday evening I went to Baeksu Nongwon with a close younger friend. I have come here with this friend several times. There is also [a note from a visit in April](/en/posts/suyeong-baeksu-nongwon-20260416/), and [another from September](/en/posts/suyeong-baeksu-nongwon-20260919/). You can eat meat here at low prices, so I end up choosing it often.
It is a large restaurant, close to exit 11 of Suyeong Station. It has more parking room than the restaurants around it. I have rarely driven to Suyeong myself.
Korean pork belly and marinated pork ribs are the main items, and they sell fresh duck too. We ordered three servings of pork belly and two of marinated ribs. The pork belly is 11,500 won per 130 g and the ribs are 13,000 won per 180 g. I think the prices have gone up a little from before.


Both kinds of meat were good. For drinks we ordered two bottles of Chamisul and one bottle of Cass. The soybean paste stew comes free. My friend orders noodles every time we come.
What I like here is the roasted sweet potato at the end. They put sweet potatoes in where the charcoal sits under the grill, and you can eat them after you finish the meat.
From here we went on to [Haesam Meongge Malmijal](/en/posts/suyeong-haesam-meongge-malmijal-20260815/) for a second round.
---
# 수영 백수농원, 싸게 고기 먹고 군고구마로 마무리했어요
> 친한 동생과 여러 번 간 수영의 고깃집에 토요일 저녁에 또 갔어요. 한돈 삼겹살 3인분과 양념갈비 2인분을 먹었어요. 값이 부담 없고, 다 먹은 뒤에 숯에 구운 고구마를 먹을 수 있어요.
- Canonical: https://jaemyeong.com/ko/blog/suyeong-baeksu-nongwon-20260815/
- Published: 2026.08.16
- Category: 식기(食記)
- Tags: #Busan, #Suyeong, #pork, #barbecue
토요일 저녁 6시쯤에 친한 동생과 백수농원에 갔어요. 이 동생과 여러 번 온 집이에요. [4월에 왔던 날의 기록](/posts/suyeong-baeksu-nongwon-20260416/)도 있고, [9월에 또 간 날](/posts/suyeong-baeksu-nongwon-20260919/)도 적어 뒀어요. 고기를 부담 없는 값에 먹을 수 있어서 자주 고르게 돼요.
큰 식당이에요. 수영역 11번 출구에서 가까워요. 주차할 자리는 주변 식당들보다 넉넉한 편이에요. 저는 수영에 차를 가지고 간 적이 거의 없어요.
한돈 삼겹살과 한돈 양념갈비가 주력이고 생오리도 팔아요. 저희는 삼겹살 3인분과 양념갈비 2인분을 시켰어요. 삼겹살은 130g에 11,500원, 양념갈비는 180g에 13,000원이에요. 예전보다 값이 조금 오른 것 같아요.


두 가지 고기 모두 맛있었어요. 술은 참이슬 두 병과 카스 한 병을 시켰어요. 된장찌개는 그냥 나와요. 동생은 올 때마다 국수를 시켜요.
제가 이 집에서 좋아하는 것은 마지막에 먹는 군고구마예요. 불판 아래 숯이 있는 곳에 고구마를 넣어 두었다가 고기를 다 먹은 뒤에 먹을 수 있어요.
이날은 여기에서 나와 [해삼멍게말미잘](/posts/suyeong-haesam-meongge-malmijal-20260815/)로 2차를 갔어요.
---
# Ninano in Jeonpo: the clams were so good that I added the pasta
> I went alone at opening time, once dental anesthesia had mostly worn off. I ordered the basil clam pasta without noodles, then added them.
- Canonical: https://jaemyeong.com/en/blog/jeonpo-ninano-20260730/
- Published: 2026.08.12
- Category: 식기(食記)
- Tags: #Busan, #Jeonpo, #clams, #pasta, #bar
At about 2 on a Thursday afternoon I had gum treatment at a dentist near Seomyeon. I left with my lower jaw numb, and I was hungry. I waited for the anesthesia to wear off at a cafe a friend had told me about, and when the feeling had mostly come back I went to Ninano. I walked in at 5, when it opens.
This time I was alone. Before this I came [once in March with friends](/en/posts/jeonpo-ninano-20260328/). I remember many people waiting then.
I wanted clams, so I chose the basil clam pasta. Being alone, I did not think I could eat much, so at first I ordered it without the noodles. I had soju with it.

I liked the clams. So I added the noodles. When you add them, they take the plate you were eating from, stir-fry it with the noodles, and bring it back.

I also got the impression that they carry many kinds of drinks. I recommend it to anyone looking for a place to drink in Jeonpo. I wrote the price down as 24,000 won.
---
# 전포 니나노, 조개가 맛있어서 면까지 추가했어요
> 치과에서 마취를 하고 나와 혼자 문 여는 시간에 맞춰 간 전포의 술집이에요. 바질조개술찜파스타를 면 없이 시켰다가, 조개가 맛있어서 면을 추가했어요. 소주를 곁들였어요.
- Canonical: https://jaemyeong.com/ko/blog/jeonpo-ninano-20260730/
- Published: 2026.08.12
- Category: 식기(食記)
- Tags: #Busan, #Jeonpo, #clams, #pasta, #bar
목요일 오후 2시쯤에 서면 근처 치과에서 잇몸 치료를 받았어요. 아래턱에 마취를 한 채로 나왔는데 배가 고팠어요. 친구가 알려 준 카페에서 마취가 풀리기를 기다렸다가, 감각이 거의 돌아왔을 때 니나노로 갔어요. 오후 5시, 문을 여는 시간에 들어갔어요.
이번에는 혼자였어요. 전에는 [3월에 친구들과 한 번](/posts/jeonpo-ninano-20260328/) 왔어요. 그때는 기다리는 사람이 많았던 것으로 기억해요.
조개가 먹고 싶어서 바질조개술찜파스타를 골랐어요. 혼자라서 많이 못 먹을 것 같아 처음에는 면을 빼고 시켰어요. 소주를 곁들였어요.

조개가 마음에 들었어요. 그래서 면을 추가했어요. 면을 추가하면 먹던 접시를 가져가서 면과 함께 볶아 다시 내줘요.

술 종류가 많다는 인상도 받았어요. 전포에서 술 마실 곳을 찾는 분께 권해요. 값은 24,000원으로 적어 두었어요.
---
# Namcheonri Patbingsu: good red beans for 4,000 won, many mosquitoes
> Old-style red bean shaved ice I had in Namcheon-dong at about 6 in the evening. Ice topped with red beans and green tea powder for 4,000 won. Many mosquitoes.
- Canonical: https://jaemyeong.com/en/blog/namcheon-namcheonri-patbingsu-20260810/
- Published: 2026.08.11
- Category: 식기(食記)
- Tags: #Busan, #Namcheon, #dessert, #shaved ice
I went to Namcheonri Patbingsu at about 6 in the evening. The same day I also went to [Cheongsong Yanggopchang](/en/posts/namcheon-cheongsong-yanggopchang-20260810/) and [Noneun Bada](/en/posts/namcheon-noneun-bada-20260810/).
You reach it through an alley, and the entrance and the building have an unusual look. It is a spot with wisteria in the middle of the city.
The patbingsu is 4,000 won. They start making it after you order, and it still comes out quickly.

It is old-style shaved ice, not milk shaved ice. Green tea powder sits on top of the red beans. The red beans were good. It is cheap, and I was satisfied for what I paid. It suits a light finish after a meal or a break from the heat.
Extra red beans cost 1,000 won more. I have never added them.
The drawback is the mosquitoes. There are many, and you can get bitten.
---
# 남천리팥빙수, 4,000원에 팥이 맛있는데 모기가 많아요
> 남천동에서 저녁 6시쯤에 먹은 옛날식 팥빙수예요. 우유빙수가 아니라 얼음에 팥을 올린 빙수이고, 팥 위에 녹차가루가 올라가요. 4,000원에 팥이 맛있어요. 다만 모기가 많아요.
- Canonical: https://jaemyeong.com/ko/blog/namcheon-namcheonri-patbingsu-20260810/
- Published: 2026.08.11
- Category: 식기(食記)
- Tags: #Busan, #Namcheon, #dessert, #shaved ice
저녁 6시쯤에 남천리팥빙수에 갔어요. 같은 날 [청송양곱창](/posts/namcheon-cheongsong-yanggopchang-20260810/)과 [노는바다](/posts/namcheon-noneun-bada-20260810/)에도 갔어요.
골목으로 들어가면 나오는데, 입구와 건물의 생김새가 독특해요. 도시 한가운데에 등나무가 있는 곳이에요.
팥빙수는 4,000원이에요. 주문을 받고 나서 만들기 시작하는데도 금방 나와요.

우유빙수가 아니라 옛날식 빙수예요. 팥 위에는 녹차가루가 올라가 있어요. 팥이 맛있었어요. 값이 싸고, 낸 돈에 비해 만족스러워요. 가볍게 입가심을 하거나 더위를 식히고 싶을 때 알맞아요.
팥을 추가하면 1,000원을 더 받아요. 저는 추가해 본 적이 없어요.
아쉬운 것은 모기예요. 모기가 많아서 물릴 수 있어요.
---
# Konowata sashimi at Noneun Bada in Namcheon-dong: still good years on
> An izakaya in Namcheon-dong for a second round after Cheongsong Yanggopchang. We had flounder sashimi with sea cucumber innards and tako wasabi.
- Canonical: https://jaemyeong.com/en/blog/namcheon-noneun-bada-20260810/
- Published: 2026.08.11
- Category: 식기(食記)
- Tags: #Busan, #Namcheon, #izakaya, #sashimi
After the beef tripe at [Cheongsong Yanggopchang](/en/posts/namcheon-cheongsong-yanggopchang-20260810/), my friend and I came here for a second round at about 5 on a Monday afternoon. Noneun Bada is an izakaya in Namcheon-dong, and I know it for its konowata. I am told it has been in this neighborhood since 2013. I remember coming around 2020 or 2021.
We ordered the konowata sashimi in the small size. It is 30,000 won. It is flounder sashimi served with sea cucumber innards, and the taste has character.

We also ordered tako wasabi. It is 7,000 won.

My friend and I both like seafood, so we were happy with the meal. On an earlier visit I had the karaage, and that was fine too. It is a place worth recommending to people who come to Namcheon-dong.
When we were choosing a drink, my friend joked that we should have Hwayo 41. I was tempted for a moment, but in the end we ordered Chamisul for 5,000 won.
---
# 남천동 노는바다의 고노와다 사시미, 몇 년 만에 가도 좋았어요
> 청송양곱창에서 나와 친구와 2차로 간 남천동의 이자카야예요. 광어회에 해삼 내장을 곁들인 고노와다 사시미와 타코와사비를 시켰어요. 몇 년 만에 왔는데도 맛있었어요.
- Canonical: https://jaemyeong.com/ko/blog/namcheon-noneun-bada-20260810/
- Published: 2026.08.11
- Category: 식기(食記)
- Tags: #Busan, #Namcheon, #izakaya, #sashimi
[청송양곱창](/posts/namcheon-cheongsong-yanggopchang-20260810/)에서 양곱창을 먹고 나서 월요일 오후 5시쯤에 친구와 2차로 간 곳이에요. 노는바다는 남천동의 이자카야이고, 저는 고노와다로 알려진 집이라고 알고 있어요. 2013년부터 이 동네에서 장사를 해 왔다고 해요. 저는 2020년이나 2021년쯤에 왔던 기억이 있어요.
고노와다 사시미를 소 크기로 시켰어요. 30,000원이에요. 광어회에 해삼 내장을 곁들인 음식이고, 개성이 있는 맛이에요.

타코와사비도 시켰어요. 7,000원이에요.

저와 친구는 둘 다 해산물을 좋아해서 만족스럽게 먹었어요. 예전에 왔을 때는 가라아게를 먹었는데 그것도 무난했어요. 남천동에 오는 분께 권할 만한 집이에요.
술을 고를 때 친구가 농담처럼 화요 41도를 마시자고 했어요. 잠깐 솔깃했지만 결국 5,000원짜리 참이슬을 시켰어요.
---
# Cheongsong Yanggopchang: the tripe and the fried rice were both good
> A grilled beef tripe place in Namcheon-dong I went to on a friend's Monday off. The staff grilled it all for us, and the fried rice at the end was very good.
- Canonical: https://jaemyeong.com/en/blog/namcheon-cheongsong-yanggopchang-20260810/
- Published: 2026.08.11
- Category: 식기(食記)
- Tags: #Busan, #Namcheon, #gopchang, #barbecue
People around me had said this place is good, and I was curious. A friend has Mondays off and my schedule happened to fit, so we went together at about 4 on a Monday afternoon. It is near Gwangalli, close to the Suyeong-gu Office. That day we walked right in without waiting.
The restaurant specializes in grilled beef intestines. It sells gopchang, makchang, and tripe. The minimum order is three servings. We ordered two servings of premium tripe and one of large intestine. Per 180 g, the tripe is 35,000 won and the large intestine is 25,000 won. We ordered Chamisul soju with it.
The meat comes already seasoned, and the staff do all the grilling. Side dishes come with it.

I did not notice any unpleasant smell. The texture was springy, and the large intestine was rich, which I especially liked.
I am very fond of [Yetnal Omakjip](/en/posts/daesin-yetnal-omakjip-20260502/) in Daesin-dong. This place was good enough to compare with it. The prices felt low. Yetnal Omakjip can fall short on service when it is busy, but here that day we ate in comfort.
At the end we ordered fried rice. They serve it one portion per bowl, and a soybean paste stew comes with it. The fried rice was very good.

After eating here we moved on to [Noneun Bada](/en/posts/namcheon-noneun-bada-20260810/).
---
# 남천동 청송양곱창, 특양과 대창에 볶음밥까지 다 좋았어요
> 맛있다는 이야기를 듣고 친구가 쉬는 월요일 오후에 간 남천동의 곱창구이집이에요. 특양 2인분과 대창 1인분을 직원분이 다 구워 줬고, 마무리로 먹은 볶음밥도 아주 맛있었어요.
- Canonical: https://jaemyeong.com/ko/blog/namcheon-cheongsong-yanggopchang-20260810/
- Published: 2026.08.11
- Category: 식기(食記)
- Tags: #Busan, #Namcheon, #gopchang, #barbecue
이 집이 맛있다는 이야기를 주변에서 들어서 궁금했어요. 친구가 월요일에 쉬는데 마침 제 일정도 맞아서, 월요일 오후 4시쯤에 같이 갔어요. 광안리 근처이고 수영구청에서 가까워요. 이날은 기다리지 않고 바로 들어갔어요.
곱창구이를 전문으로 하는 집이에요. 곱창과 막창, 양을 팔아요. 주문은 3인분부터 받아요. 저희는 특양 2인분과 대창 1인분을 시켰어요. 180g을 기준으로 특양은 35,000원, 대창은 25,000원이에요. 참이슬도 함께 시켰어요.
양곱창은 양념이 된 채로 나오고, 굽는 것은 직원분이 다 해 줘요. 밑반찬도 같이 나와요.

불쾌한 냄새를 느끼지 못했어요. 씹는 맛이 탱글탱글했고, 대창은 고소해서 특히 좋았어요.
저는 대신동의 [옛날오막집](/posts/daesin-yetnal-omakjip-20260502/)을 아주 좋아해요. 이 집도 그에 견줄 만큼 맛있었어요. 값은 싸다고 느꼈어요. 옛날오막집은 붐빌 때 서비스가 아쉬울 수 있는데, 이날 여기에서는 편하게 먹었어요.
마지막에 볶음밥을 시켰어요. 1인분씩 밥그릇에 나눠 담아 주고 된장찌개가 같이 나와요. 볶음밥이 아주 맛있었어요.

이날은 여기에서 먹고 [노는바다](/posts/namcheon-noneun-bada-20260810/)로 자리를 옮겼어요.
---
# Joseph in Gwangalli: back after a fire, and the whisky is still good
> I went back to my regular bar, which reopened after a fire. There is no menu; you pick the whisky you want. The whisky and the bartender were both good.
- Canonical: https://jaemyeong.com/en/blog/gwangalli-joseph-20260730/
- Published: 2026.08.10
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #whisky, #bar
Joseph reopened after a fire. I saw the news on Instagram. But I had no chance to go after it reopened, and in July something big happened in my life and I could not go. I went at about 9 on a Thursday night, after things had settled. This is my third note on the bar this year, after [April 21](/en/posts/gwangalli-joseph-20260421/) and [May 1](/en/posts/gwangalli-joseph-20260501/).
It is far from home, so I cannot go often. Still, when I get the chance, this is the place I choose. They usually keep candles lit, and to me it feels like a hideout. It is more comfortable than the other bars in Gwangalli.

There is no menu. You say what you want and they make a cocktail to match, or you can pick the whisky you want to drink. The price depends on what you order. They used to have no bar snacks, but they added some recently.
I drank whisky. It tasted good. Having a snack with the whisky works well too. I tend to avoid sweet drinks. I used to drink mostly peated whisky, and these days I like sherry-cask styles.
The owner was not in that night. The bartender was kind. I often recommend this bar to friends who drink and to acquaintances visiting from elsewhere. It is my favorite bar in Gwangalli, and it was the same after reopening.
I also met Dongdong, who is something like the mascot here, for the first time in a while.

---
# 광안리 조셉, 다시 문을 연 뒤에 마신 위스키도 여전히 좋았어요
> 불이 난 뒤에 다시 문을 연 단골 바에 오랜만에 갔어요. 메뉴판 없이 원하는 위스키를 골라 마시는 곳이에요. 위스키도 바텐더분의 응대도 좋았고, 강아지 동동이도 다시 만났어요.
- Canonical: https://jaemyeong.com/ko/blog/gwangalli-joseph-20260730/
- Published: 2026.08.10
- Category: 식기(食記)
- Tags: #Busan, #Gwangalli, #whisky, #bar
조셉은 불이 난 뒤에 다시 문을 열었어요. 저는 그 소식을 인스타그램에서 봤어요. 그런데 다시 연 뒤로 갈 기회가 없었고, 7월에는 개인적으로 큰일이 있어서 가지 못했어요. 일이 가라앉은 뒤인 목요일 밤 9시쯤에 갔어요. [4월 21일](/posts/gwangalli-joseph-20260421/)과 [5월 1일](/posts/gwangalli-joseph-20260501/)에 이어 올해 적는 세 번째 기록이에요.
집에서 멀어서 자주 가지는 못해요. 그래도 기회가 생기면 이 집을 골라요. 평소에 양초를 켜 두는 곳이고, 저에게는 아지트 같아요. 광안리의 다른 바보다 편해요.

메뉴판이 따로 없어요. 원하는 것을 말하면 그에 맞춰 칵테일을 만들어 주고, 마시고 싶은 위스키를 고를 수도 있어요. 값은 무엇을 시키는지에 따라 달라요. 예전에는 안주가 없었는데 최근에 생겼어요.
저는 위스키를 마셨어요. 술맛이 좋았어요. 안주와 위스키를 함께 먹는 것도 괜찮아요. 저는 단맛을 피하는 편이에요. 예전에는 피트 위스키를 주로 마셨고 요즘은 쉐리 계열을 좋아해요.
이날 사장님은 안 계셨어요. 바텐더분이 친절하게 맞아 줬어요. 술을 마시는 친구나 다른 지역에서 온 지인에게 저는 평소에도 이 집을 권해요. 광안리의 바 가운데 제가 가장 좋아하는 곳이고, 다시 연 뒤에도 그대로였어요.
이 집의 마스코트 같은 동동이도 오랜만에 만났어요.

---
# Yeonjeong Sikdang at Geumjeongsanseong: unplanned, with a good pajeon
> On the way to Gyeongju we could not get into the place we wanted, and ended up here by chance. The 10,000 won pajeon beat my expectations.
- Canonical: https://jaemyeong.com/en/blog/geumjeong-yeonjeong-sikdang-20260803/
- Published: 2026.08.10
- Category: 식기(食記)
- Tags: #Busan, #Geumjeong, #pajeon, #noodles, #lunch
At about 11 on a Monday morning, a close younger friend and I wanted lunch on the way to Gyeongju World. The place we meant to go to was a grilled pork restaurant. But the navigation took us to a different restaurant with the same name. That one serves duck and goat, and they said they take only guests with reservations on weekdays, so we could not go in. Only later did I realize it was a different place that happened to share the name.
We planned to eat quickly and leave. Skipping duck, we looked for somewhere nearby to have noodles and went into Yeonjeong Sikdang. It is in the restaurant village on the lower side of Geumjeongsanseong.
There is no air conditioner. The owner, worried we would be hot, turned on every fan there was. She is kind and cheerful. The place was not very tidy, but I accepted that much.
We ordered a pajeon and two bowls of bibim-guksu. The pajeon is 10,000 won.

The pajeon was better than I expected. The bibim-guksu is 7,000 won a bowl. It had a familiar home-cooked taste that reminded me of my mother's food.

I wanted makgeolli, but we had to drive on to Gyeongju, so we just ate and left. I think it would be a good stop for hikers coming down the mountain. Only after the visit did I learn that this restaurant was recently shown on the KBS program Dongne Hanbakwi as a pajeon place.
---
# 금정산성 연정식당, 얼떨결에 들렀는데 파전이 기대 이상이었어요
> 경주로 가는 길에 점심을 먹으려던 집에 들어가지 못해서 얼떨결에 간 금정산성의 식당이에요. 10,000원짜리 파전은 기대보다 맛있었고, 비빔국수는 집밥 같은 맛이었어요.
- Canonical: https://jaemyeong.com/ko/blog/geumjeong-yeonjeong-sikdang-20260803/
- Published: 2026.08.10
- Category: 식기(食記)
- Tags: #Busan, #Geumjeong, #pajeon, #noodles, #lunch
월요일 오전 11시쯤, 친한 동생과 경주월드로 가는 길에 점심을 먹으려고 했어요. 원래 가려던 곳은 돼지고기를 구워 먹는 집이었어요. 그런데 내비게이션이 이름이 같은 다른 식당으로 데려다줬어요. 그 식당은 오리와 염소 요리를 하는 곳이고, 평일에는 예약한 손님만 받는다고 해서 들어가지 못했어요. 이름만 같은 다른 집이었다는 것은 나중에야 알았어요.
짧게 먹고 출발할 생각이었어요. 오리 요리는 빼고, 근처에서 국수를 먹을 수 있는 곳을 찾다가 연정식당에 들어갔어요. 금정산성 아래쪽 먹거리촌에 있어요.
에어컨은 없어요. 사장님이 더울까 봐 선풍기를 있는 대로 다 틀어 주셨어요. 친절하고 유쾌한 분이에요. 가게가 아주 깔끔하지는 않았는데, 그 정도는 감수했어요.
파전과 비빔국수 두 그릇을 시켰어요. 파전은 10,000원이에요.

파전이 기대보다 맛있었어요. 비빔국수는 한 그릇에 7,000원이에요. 어머니가 해 주던 음식이 떠오르는, 익숙한 집밥 맛이었어요.

막걸리가 마시고 싶었지만 경주까지 가야 해서 밥만 먹고 일어났어요. 산에서 내려오는 등산객이 들르기에 좋은 집일 것 같아요. 다녀온 뒤에야, 이 집이 최근에 KBS 동네 한 바퀴에 파전집으로 나왔다는 것을 알았어요.
---
# Yeije Kalguksu in Gujora, Geoje: the noodle soup alone is enough
> A noodle soup place near Gujora Port where a family member and I went to cure a hangover. The soup in the 43,000 won set was generous; the sides were not.
- Canonical: https://jaemyeong.com/en/blog/geoje-yeije-kalguksu-20260804/
- Published: 2026.08.10
- Category: 식기(食記)
- Tags: #Geoje, #seafood, #kalguksu, #crab
A family member came on vacation, and we planned to fish and swim in Geoje. We had been drinking until dawn the day before, so at about 11 the next morning we looked for a restaurant to settle our stomachs. I like kalguksu and seafood. We went to this place near Gujora Port.
We came without a reservation, and one table for two was left, so we sat right away. After that, more guests were waiting. I also saw groups eating together. I think it was because it was peak season and there were many group reservations.
At first I meant to have only the kalguksu. But the set with sides looked good, so I switched to the couple set for 43,000 won. It comes with the seafood kalguksu, soy-marinated crab, spicy marinated crab, and Chungmu gimbap.

The kalguksu was generous and had a lot of seafood in it, which I liked.

The sides fell short. The marinated crab tasted fine, but the crabs were small. The Chungmu gimbap felt sparse.
Kalguksu alone for two is 30,000 won. I have not decided whether to go again. If I do, I think the kalguksu alone would be enough. I recommend the kalguksu to two or more people who like seafood. But the minimum order is for two, so it is hard to eat here alone.
The fishing went well the day before, so next time I may come to Geoje alone to fish.
---
# 거제 구조라 예이제 칼국수, 세트보다 칼국수만으로 충분해요
> 거제에서 해장할 곳을 찾다가 가족과 둘이 간 구조라항 근처의 칼국수집이에요. 43,000원짜리 연인세트를 시켰는데 칼국수는 양도 해산물도 넉넉했고, 게장과 충무김밥은 아쉬웠어요.
- Canonical: https://jaemyeong.com/ko/blog/geoje-yeije-kalguksu-20260804/
- Published: 2026.08.10
- Category: 식기(食記)
- Tags: #Geoje, #seafood, #kalguksu, #crab
휴가를 온 가족과 거제에서 낚시와 해수욕을 하기로 했어요. 전날 새벽까지 술을 마신 터라, 다음 날 오전 11시쯤에 해장할 식당을 찾았어요. 저는 칼국수와 해산물을 좋아해요. 구조라항 근처에 있는 이 집으로 갔어요.
예약 없이 갔는데 2인 자리가 하나 남아 있어서 바로 앉았어요. 그 뒤로 기다리는 손님이 늘었어요. 단체로 식사하는 손님들도 보였어요. 성수기이고 단체 예약이 많아서 그런 것 같아요.
처음에는 칼국수만 먹으려고 했어요. 그런데 곁들임이 함께 나오는 세트가 좋아 보여서 43,000원짜리 연인세트로 바꿨어요. 해물듬뿍칼국수에 간장게장과 양념게장, 충무김밥이 나오는 구성이에요.

칼국수는 양이 넉넉하고 해산물이 많이 들어 있어서 좋았어요.

곁들임은 아쉬웠어요. 게장은 맛은 괜찮았지만 게가 작았어요. 충무김밥은 구성이 허전했어요.
칼국수만 2인분을 시키면 30,000원이에요. 다시 갈지는 아직 정하지 못했어요. 다시 간다면 칼국수만 먹어도 충분할 것 같아요. 해산물을 좋아하는 두 명 이상이 간다면 칼국수를 권해요. 다만 2인분부터 주문할 수 있어서 혼자 먹기는 어려워요.
전날 낚시가 잘돼서, 다음에는 혼자 거제에 낚시하러 올지도 모르겠어요.
---
# Cheonga Yangkkochi in Dongnae: good lamb, but far too hot inside
> A lamb skewer place in Dongnae for a drink the night before my visiting companion went home. The 61,000 won meat set was good. The room was hot.
- Canonical: https://jaemyeong.com/en/blog/dongnae-cheonga-yangkkochi-20260804/
- Published: 2026.08.10
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #lamb, #skewers
A younger companion came from another city on vacation, and we spent time together. The night before my companion went home, we went out to Dongnae for a drink at about 8 in the evening. Cheonga Yangkkochi is a lamb skewer restaurant near exit 4 of Dongnae Station. I once tried to come with another friend and gave up because many people were waiting.
That night there were few guests. But the room was hot. The air conditioner was on and a big fan was running, and it was still hot enough that I wondered whether the air conditioner was broken.
We ordered the meat set for 61,000 won. We had lamb skewers, lamb rib meat skewers, and premium lamb ribs on the bone.

The meat was good in both quality and taste. The trouble was the fire. The fan blew toward the table and made the fire stronger, and it was hard to control how the meat cooked. The skewers burned often. The seasoning on the meat may have been part of it.

This restaurant suggests eating the two kinds of skewers first and the ribs after. Other places I have been to said to start with the ribs. I thought it might be better to fill up on the ribs first and have the skewers as a light snack with drinks.
Since they use charcoal grills, I tried to accept some heat. Still, the discomfort did not go away. At an empty table, a staff member was preparing skewers.
---
# 동래 청아양꼬치, 고기는 맛있는데 너무 더웠어요
> 휴가를 온 동생이 돌아가기 전날에 술 한잔하러 간 동래의 양꼬치집이에요. 61,000원짜리 고기세트는 고기 질도 맛도 좋았어요. 그런데 실내가 덥고 불 조절이 어려워서 불편했어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-cheonga-yangkkochi-20260804/
- Published: 2026.08.10
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #lamb, #skewers
다른 도시에서 휴가를 온 동생과 같이 놀았어요. 동생이 돌아가기 전날, 술 한잔하려고 저녁 8시쯤에 동래로 나갔어요. 청아양꼬치는 동래역 4번 출구 근처에 있는 양꼬치 전문점이에요. 전에 다른 친구와 오려고 했다가 기다리는 사람이 많아서 포기한 적이 있어요.
이날은 손님이 적었어요. 그런데 실내가 더웠어요. 에어컨도 켜져 있고 큰 선풍기도 돌아가고 있었는데 더워서, 에어컨이 고장 난 것은 아닌가 하는 생각까지 했어요.
61,000원짜리 고기세트를 시켰어요. 양꼬치와 양갈비살꼬치, 뼈가 붙은 고급 양갈비를 먹었어요.

고기는 질도 맛도 좋았어요. 문제는 불이었어요. 선풍기 바람이 테이블 쪽으로 불어서 화로의 불이 세졌고, 굽는 정도를 맞추기가 어려웠어요. 꼬치가 자주 탔어요. 고기에 묻은 양념 때문일 수도 있어요.

이 집은 꼬치 두 가지를 먼저 먹고 갈비를 나중에 먹으라고 권해요. 제가 다녀 본 다른 집들은 갈비부터 먹으라고 했어요. 갈비로 먼저 배를 채우고 꼬치는 가벼운 술안주로 먹는 쪽이 나을 수도 있겠다고 생각했어요.
화로를 쓰니 더운 것은 어느 정도 그러려니 하려고 했어요. 그래도 불편함이 가시지 않았어요. 빈 테이블에서는 직원분이 꼬치를 준비하고 있었어요.
---
# The set for two at Obujang in Haeundae: cheap, and it tasted fine
> A barbecue place where a friend and I had dinner after swimming at Haeundae. The charcoal-grilled set for two is 32,000 won. Cheap and fine for a light meal.
- Canonical: https://jaemyeong.com/en/blog/haeundae-obujang-20260807/
- Published: 2026.08.09
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #barbecue, #pork
In the afternoon we bought bread at [Milchanggo](/en/posts/haeundae-milchanggo-20260807/) and then swam at Haeundae Beach. After that, at about 8 in the evening, my friend and I went to Obujang.
We ordered the assorted meat for two. It is 32,000 won and you grill it over charcoal.

The meat seemed to be frozen. Still, for the price I had no big complaint about the taste. You refill the lettuce wraps and basic side dishes yourself at the self-service bar.
My friend said it was good, so we added the Bukchang-dong seafood soft tofu stew. It is 7,000 won. It was good.

We also had a little Chamisul soju. The owner was kind. With prices this low, I think it is popular with people in the neighborhood.
It is cheap and tastes fine, so it suits a light meal. But it did not strike me as special, and I have not decided whether to go again. The regular assorted meat seems to be grilled over charcoal, and the thin-sliced and frozen pork belly seem to be cooked on a plate over a gas burner.
---
# 해운대 오부장의 2인 모듬, 값이 싸고 맛은 무난했어요
> 해운대에서 물놀이를 하고 친구와 저녁을 먹은 고깃집이에요. 숯불에 굽는 2인 모듬이 32,000원이고, 친구가 권한 북창동 해물순두부도 맛있었어요. 싸고 무난해서 가볍게 먹기 좋아요.
- Canonical: https://jaemyeong.com/ko/blog/haeundae-obujang-20260807/
- Published: 2026.08.09
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #barbecue, #pork
오후에는 [밀창고](/posts/haeundae-milchanggo-20260807/)에서 빵을 산 뒤 해운대 해수욕장에서 물놀이를 했어요. 그러고 나서 저녁 8시쯤에 친구와 오부장에 갔어요.
2인 모듬을 시켰어요. 32,000원이고 숯불에 구워 먹어요.

고기는 냉동육 같았어요. 그래도 값을 생각하면 맛에 큰 불만은 없었어요. 쌈과 기본 반찬은 셀프바에서 직접 더 가져다 먹어요.
친구가 맛있다고 해서 북창동 해물순두부를 추가했어요. 7,000원이에요. 맛있었어요.

참이슬도 가볍게 마셨어요. 사장님이 친절했어요. 값이 부담 없어서 동네 분들에게 인기가 있을 것 같아요.
싸고 맛이 무난해서 가볍게 먹기에 알맞은 집이에요. 다만 특별하다는 인상은 받지 못해서, 다시 갈지는 정하지 못했어요. 일반 모듬은 숯불에 굽는 것 같고, 대패나 냉삼은 가스 버너에 불판을 올려 굽는 것 같아요.
---
# Jambon-beurre from Milchanggo: good on the beach with a stout
> A small bakery in Haeridan-gil where a friend and I bought bread to have with beer at Haeundae Beach. The pollack roe salt bread was sold out.
- Canonical: https://jaemyeong.com/en/blog/haeundae-milchanggo-20260807/
- Published: 2026.08.09
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #bakery, #sandwich
It was Ipchu, the first day of autumn on the traditional calendar. A friend and I went to Haeundae Beach to see the sea before summer ended. We stopped at a bakery first to buy something to eat with beer on the beach. My friend had said the pollack roe salt bread at Milchanggo is good.
Milchanggo is a small bakery in Haeridan-gil. You pass Prahran and go into the alley toward Orleans Store, and there is a signpost on the way. There is also room to eat inside.
When we arrived, the pollack roe salt bread was sold out. All the bread on display looked good, and there were several kinds made with pollack roe. The staff were pleasant too.
We bought a jambon-beurre and another piece of bread. The jambon-beurre is 5,900 won.

After swimming we ate it on the beach with Guinness stout. It was good.

That evening we had dinner at [Obujang](/en/posts/haeundae-obujang-20260807/). Next time at Milchanggo I want to try the pollack roe salt bread I could not buy this time.
---
# 해리단길 밀창고의 잠봉뵈르, 해변에서 흑맥주와 먹으니 좋았어요
> 입추에 친구와 해운대 바다에 가면서 맥주와 먹을 빵을 사러 들른 해리단길의 작은 빵집이에요. 명란소금빵은 다 팔려서 5,900원짜리 잠봉뵈르를 샀고, 해수욕 뒤에 해변에서 먹었어요.
- Canonical: https://jaemyeong.com/ko/blog/haeundae-milchanggo-20260807/
- Published: 2026.08.09
- Category: 식기(食記)
- Tags: #Busan, #Haeundae, #bakery, #sandwich
입추였어요. 여름이 가기 전에 바다에 가자고 친구와 해운대 해수욕장에 갔어요. 해변에서 맥주와 같이 먹을 것을 사려고 먼저 빵집에 들렀어요. 친구가 밀창고의 명란소금빵이 맛있다고 했거든요.
밀창고는 해리단길에 있는 작은 빵집이에요. 프라한을 지나 올리언스 스토어 쪽 골목으로 들어가면 되고, 가는 길에 안내하는 이정표가 있어요. 매장 안에 먹고 갈 자리도 있어요.
도착하니 명란소금빵은 다 팔리고 없었어요. 진열된 빵은 다 먹음직스러웠고, 명란을 쓴 빵이 여러 가지 있었어요. 직원분의 응대도 좋았어요.
잠봉뵈르와 다른 빵 하나를 샀어요. 잠봉뵈르는 5,900원이에요.

해수욕을 하고 나서 해변에서 기네스 흑맥주와 함께 먹었어요. 맛있었어요.

이날 저녁은 [오부장](/posts/haeundae-obujang-20260807/)에서 먹었어요. 다음에 밀창고에 가면 이번에 사지 못한 명란소금빵을 먹어 보고 싶어요.
---
# Chicken with chives at Ondaljip in Dongnae: the familiar spicy taste
> A grilled chicken place in Dongnae where a friend and I went on a Saturday evening. The spicy chicken thigh with chives went well with soju.
- Canonical: https://jaemyeong.com/en/blog/dongnae-ondaljip-20260808/
- Published: 2026.08.09
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #chicken, #barbecue
I felt like charcoal-grilled chicken. The first place that came to mind was the grilled chicken bulgogi sold at the outdoor tables of Mexican Chicken Beom 5. But my friend was not keen on that place. It also turned out to be closed that day. So we went to Ondaljip, which we know well. It was about 7:30 on a Saturday evening.
I have come to Ondaljip with this friend several times. There is also [a note from a visit in April](/en/posts/dongnae-ondaljip-20260403/), and [another from September](/en/posts/dongnae-ondaljip-20260922/). You come out of exit 4 of Dongnae Station and walk, and it is close to the station.
It is a popular place, so I think there are times when people line up. That evening, though it was a Saturday, about four tables were free.
We ordered the charcoal-grilled chicken with chives. It is chicken thigh, 14,000 won per serving, and the minimum order is two servings.

It is a taste I know well. It is spicy, so it goes well with drinks. We ordered the late-night udon with it.

We caught up on each other's news, drank two bottles of Chamisul, and left. My friend said Mexican Chicken and Ondaljip do not taste very different, so there is no need to go all the way to Beom 5 and stand in line. I thought that was fair, to a degree.
---
# 동래 온달집의 부추 숯불 닭구이, 익히 아는 매콤한 맛이에요
> 숯불에 구운 닭이 생각나서 친구와 토요일 저녁에 간 동래의 닭구이집이에요. 닭다리살로 만든 부추 숯불 닭구이는 익히 아는 매콤한 맛이라 소주와 잘 맞았고, 심야우동도 시켰어요.
- Canonical: https://jaemyeong.com/ko/blog/dongnae-ondaljip-20260808/
- Published: 2026.08.09
- Category: 식기(食記)
- Tags: #Busan, #Dongnae, #chicken, #barbecue
숯불에 구운 닭이 생각났어요. 처음 떠올린 곳은 멕시칸치킨 범5점의 야장에서 파는 닭숯불불고기였어요. 그런데 친구는 그 집이 내키지 않는다고 했어요. 마침 그날은 그 가게가 문을 닫기도 했어요. 그래서 익숙한 온달집으로 갔어요. 토요일 저녁 7시 반쯤이었어요.
이 친구와는 온달집에 여러 번 왔어요. [4월에 왔던 날의 기록](/posts/dongnae-ondaljip-20260403/)도 있고, [9월에 또 간 날](/posts/dongnae-ondaljip-20260922/)도 적어 뒀어요. 동래역 4번 출구로 나와서 걸어가면 되고, 역에서 가까워요.
인기가 있는 집이라 줄을 설 때도 있을 것 같아요. 이날은 토요일 저녁인데도 빈 테이블이 네 개쯤 있었어요.
부추 숯불 닭구이를 시켰어요. 닭다리살이고 1인분에 14,000원이에요. 2인분부터 주문할 수 있어요.

익히 아는 맛이에요. 매콤해서 술과 잘 맞아요. 심야우동도 같이 시켰어요.

서로 요즘 지낸 이야기를 하면서 참이슬 두 병을 마시고 일어났어요. 친구는 멕시칸치킨과 온달집의 맛이 크게 다르지 않으니, 범5점까지 가서 줄을 설 것까지는 없다고 했어요. 저도 어느 정도는 맞는 말이라고 생각했어요.
---
# Tofu dumpling soup at Pyeongyangjip in Deokcheon-dong: mild and good
> A northern-style tofu dumpling place in Deokcheon-dong where a friend and I went for lunch after drinking until dawn. The 10,000 won soup was mild and good.
- Canonical: https://jaemyeong.com/en/blog/deokcheon-pyeongyangjip-20260805/
- Published: 2026.08.07
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #dumpling soup, #lunch
A friend who had been visiting on vacation and I had a meal together before my friend went home. We had been drinking until dawn the night before, so around lunchtime I chose Pyeongyangjip to settle our stomachs. It mainly serves northern-style tofu dumplings.
I knew it as a place listed as a Bib Gourmand in the 2026 Michelin Guide Busan. It is in Deokcheon-dong, close to home, but I rarely go that way and had kept putting it off.
We parked at the Gupo Market public parking lot and walked. It is next to a residential area, so I think parking in front would be awkward. Two spots were free when we arrived, and we took the larger one. Guests seemed to turn over quickly, so even with a wait I do not think it would take long. I do not know what weekends are like.
My friend and I both ordered the tofu dumpling soup. It is 10,000 won. It comes with rice and sides such as chives, chili paste, and kimchi.

The seasoning was not strong. I enjoyed it.

Other tables seemed to order the mung bean pancake a lot. I am not fond of it and wanted a simple meal, so I did not order it.
On the wall are notes written by the owner: tips for eating the dumpling soup and something like a confession. They were fun to read.
---
# 덕천동 평양집의 두부만둣국, 해장하러 갔는데 간이 세지 않아요
> 새벽까지 술을 마신 다음 날 점심에 친구와 속을 달래러 간 덕천동의 이북식 두부만두집이에요. 10,000원짜리 두부만둣국은 간이 세지 않고 맛있었어요. 벽에 붙은 사장님의 글도 재미있어요.
- Canonical: https://jaemyeong.com/ko/blog/deokcheon-pyeongyangjip-20260805/
- Published: 2026.08.07
- Category: 식기(食記)
- Tags: #Busan, #Deokcheon, #dumpling soup, #lunch
휴가를 와 있던 친구가 돌아가기 전에 같이 밥을 먹었어요. 전날 새벽까지 술을 마셔서, 점심 무렵에 속을 달랠 곳으로 평양집을 골랐어요. 이북식 두부만두를 주로 하는 집이에요.
2026년 미쉐린 가이드 부산의 빕 구르망에 올랐다고 알고 있던 집이에요. 집에서 가까운 덕천동에 있는데도 그쪽으로는 발길이 뜸해서 미뤄 두고 있었어요.
차는 구포시장 공영주차장에 대고 걸어갔어요. 주택가 옆이라 가게 앞에 주차하기는 불편할 것 같아요. 도착했을 때 자리가 두 곳 비어 있어서 더 넓은 쪽에 앉았어요. 손님이 빨리 바뀌는 듯해서, 기다리더라도 오래 걸리지는 않을 것 같아요. 주말에는 어떤지 모르겠어요.
저도 친구도 두부만둣국을 시켰어요. 10,000원이에요. 공깃밥과 부추, 다대기, 김치 같은 반찬이 함께 나와요.

간이 세지 않았어요. 맛있게 먹었어요.

다른 테이블에서는 녹두빈대떡을 많이 시키는 듯했어요. 저는 빈대떡을 그리 좋아하지 않고 간단히 먹고 싶어서 시키지 않았어요.
벽에는 사장님이 쓴 만둣국 먹는 요령과 고백 같은 글이 붙어 있어요. 읽는 재미가 있었어요.
---
# Turning Apple Pencil handwriting into Sudoku digits
> Placing a handwritten digit in DailySudoku: splitting strokes into taps and ink, grouping them, building model input, and applying only trusted results.
- Canonical: https://jaemyeong.com/en/blog/apple-pencil-handwriting-digit-input-pipeline/
- Published: 2026.08.07
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #PencilKit, #Core ML, #Apple Pencil
The goal for Apple Pencil input in DailySudoku was simple. Writing a 7 on a cell places a 7, and a quick dot with the pencil selects that cell. When a palm touches the screen or the recognition result is uncertain, no digit should be placed.
Keeping that goal meant solving four things: receiving input without clashing with the existing board gestures, grouping several strokes into one character, converting the input into the same shape used in training, and deciding when to apply a result. Below is the implementation based on the develop branch as of 2026-08-07 (commit 6db30672), along with the parts that automated tests alone could not confirm.
## Sending the pencil and the finger down different paths
I placed a child view, `PencilDrawOverlay`, on top of `BoardView`. `PencilDrawOverlay` is a transparent `PKCanvasView` with the [`.pencilOnly` policy](https://developer.apple.com/documentation/pencilkit/pkcanvasviewdrawingpolicy), so only the pencil can draw on it. The board's recognizer, in turn, does not accept the pencil.
These are the touch types the board recognizer accepts.
```text
허용: direct, indirect, indirectPointer
제외: pencil
```
In the block, 허용 means "allowed" and 제외 means "excluded."
With this split, the pencil goes to the overlay, and the finger, mouse, and trackpad go to the original cell selection handling. While a stroke is being written, the board's pan is turned off and tap stays on. The overlay is not exposed as an accessibility element. VoiceOver users keep using the 81 virtual cells described in [the post about board accessibility](/en/posts/adaptive-accessible-sudoku-board/). The cell geometry is shared, and only the path a touch takes is different.
## Tap or ink, and which group
If the diagonal of the first stroke's bounding box is 6pt or less and the stroke lasts 0.12 seconds or less, it is a tap. Both conditions must hold. For a tap, the ink is cleared and the cell at the center of the bounding box is selected. A stroke near a group that is already being written counts as ink even when it is small and short, because it can be part of a digit.
A group is finalized 0.45 seconds after the last stroke. Each added stroke resets the end of the wait. The value accounts for digits such as 4, 5, and 7 that can take more than one stroke.
If a stroke that is not a tap is more than 60pt away from the center of the current group in the x or y direction, the previous group is finalized and a new group starts. For a short, small stroke far away, the tap decision comes first. The previous group is processed and then the new cell is selected. The target cell comes from passing the center of the whole group's box to `BoardView.cellIndex`. The rounded-corner check also uses the same function as the board. Classifying a stroke, assigning it to a group, and choosing the target cell are separate steps.
The weak point is that 60pt is a fixed distance. Cell spacing changes with screen size, and the boundary for writing in adjacent cells one after another is not settled by tests. I am considering a value proportional to the cell size, or measuring distances on supported devices and calibrating.
## Building the 28×28 the model sees
Point lists are taken from the paths of `PKDrawing` and drawn again into a grayscale buffer the size of the board. Then the image is cropped and scaled to match the MNIST format.
This is the preprocessing flow in order.
```text
PKDrawing
-> [[CGPoint]]
-> round-cap grayscale raster
-> ink bounding-box crop
-> longest side 20px, aspect ratio 유지
-> bilinear resample
-> center of mass를 28×28 중앙으로 이동
-> [1, 1, 28, 28] Float tensor
```
The Korean in the block says to keep the aspect ratio (유지) and to move the center of mass to the center of the 28×28 image.
The stroke width shown on screen is 6pt for normal input and 3pt for notes. For inference, both are redrawn at 6pt. This keeps the display mode from changing the distribution of the input the model receives.
The preprocessing tests check that empty input returns nil, that the same input gives the same result, that the longest side is 20px, and that the center of mass is in place. They also compare the Core ML label and probability from the Python reference with the Swift result. If any of the crop, scale, pixel direction, or intensity range differs from the training conditions, the model can run without an error and still misrecognize the digit. A successful run does not show that the preprocessing is correct.
## Results to accept and results to drop
The model keeps all 10 classes, 0 through 9. Sudoku has no 0, but I kept it so that an ambiguous circle is not forced into 6 or 9. Input classified as 0 is dropped at the apply step.
The default confidence threshold is 0.8. For notes, where a correction costs less, it is lowered by 0.1 to 0.7. Only when the result is between 1 and 9 and meets the threshold does the app select the cell and place the digit. A 0, a value out of range, low confidence, a model loading failure, a preprocessing failure, and an inference error are all rejects. A reject does not increase the mistake count. It simply does nothing. The ink is cleared regardless of the result.
In the current structure, failures with different causes all merge into a single `.reject`. There is no way to tell whether the model failed to load, the probability was low, or the label was wrong. Counts per cause are needed. I am considering watching the failure rate with on-device statistics only, without sending the original strokes, but that is not implemented yet.
## Results that arrive late
Rendering and prediction run in `Task.detached`, and an accepted result calls `selectCell` and `placeDigit` on the main actor. There are two problems here.
The first is sharing the model. `CoreMLDigitRecognizer` is declared `@unchecked Sendable`, so several tasks can use the same `MLModel` at once. The [`MLModel` documentation](https://developer.apple.com/documentation/coreml/mlmodel) sets a condition that calls on one instance be serialized. `@unchecked Sendable` only skips the compiler check and does not change the runtime condition of `MLModel`. So the current declaration may conflict with that condition.
This is a proposal to let an actor own the recognizer. It is not applied yet.
```swift
actor DigitInference {
private let recognizer: CoreMLDigitRecognizer
init() throws {
recognizer = try CoreMLDigitRecognizer()
}
func recognize(_ pixels: [Float]) throws -> DigitRecognition? {
try recognizer.recognize(pixels: pixels)
}
}
```
With this, predictions run one at a time, and callers have to use `await`. Cloning the model per queue is another option, but it is something to review only after measurements show that a single actor falls short on latency or throughput.
The second problem is that an old result can be applied after the state has changed. The completion checks only whether the game is in the playing state. After a pause and resume, or after switching note mode, a result requested earlier can still be applied. The two modes can also disagree: the threshold is chosen from the mode at capture time, while `placeDigit` acts on the mode at completion time. I am considering storing the three values below with each request and checking that they still match right before applying.
```text
input revision + target cell + entry mode
```
If the values differ, the result is discarded. A monotonically increasing number works as the revision.
## What automated tests covered and what they did not
The automated tests focus on the pure reducer and renderer. They cover hit testing on the rounded board, the tap and ink conditions, debounce, restart on a distant stroke, normalization, the reject branches, loading the bundled model, the Python and Swift comparison, the allowed touch list, and the delegate wiring.
There are only two handwriting fixtures, 1 and 7. That means only two kinds of input are checked from rendering through to the expected digit. The threshold search in the training script is also a proxy evaluation that uses MNIST with rotation and stroke-width changes, plus 0 and noise. So the recognition rate for real pencil handwriting, and whether 0.8 is the best value, are not proven.
For the delegate, there is a record in a source comment. It diagnosed that on iOS 26.5, PencilKit falls into recursion when the canvas sets itself as its own delegate, so a separate bridge is used. The test only checks whether the canvas and the delegate are the same object. I did not recheck the device logs from that time. Another limit is that the `.pencilOnly` path cannot be exercised with a mouse in the simulator.
Six things need to be checked on devices.
- Precision and reject rate for 1 through 9 across handwriting styles
- Latency of the first response and the order of consecutive results
- The 60pt boundary on each screen size
- Behavior when pencil, finger, pointer, and VoiceOver are mixed
- Discarding earlier results during pause and resume or a mode change
- Whether users can understand a model loading failure or repeated rejects
For metrics, I plan to look at the share of wrong inputs among accepted results, the reject rate, and p95 latency. My judgment was to protect the precision of applied actions first and accept that more inputs go unrecognized.
## What it takes to switch to PKStrokeRecognizer in iOS 27
The iOS 27 beta has [`PKStrokeRecognizer`](https://developer.apple.com/documentation/pencilkit/pkstrokerecognizer). It is an actor, and it is an API that recognizes strokes as text asynchronously on the device. It takes a `PKDrawing` as input. Recognition results can differ by OS, so storing a result means keeping `recognitionVersion` with it. A [guide to recognizing handwriting and converting it to text](https://developer.apple.com/documentation/pencilkit/recognizing-handwriting-and-converting-to-text) is published as well. An app with an iOS 26.5 deployment target needs the Xcode 27 beta SDK, `#available(iOS 27.0, *)`, and a fallback path that uses Core ML.
Swapping only the implementation is hard. The current `DigitRecognizing` takes `[Float]` and returns a digit and a confidence. `PKStrokeRecognizer` takes input through `updateDrawing(_:)`, returns `String?` from `recognizedText(strokeIDs:)`, and has no confidence. So a higher asynchronous boundary that takes the drawing and its context is needed.
```text
recognize(drawing, context) async -> candidate 또는 reject
```
Here 또는 means "or": the call returns either a candidate or a reject.
The Core ML side can keep the current rendering and probability policy, and the new adapter can limit the text to 1 through 9. The rule for applying a result that has no probability automatically needs separate product validation. During the beta period I plan to keep the existing model.
The values 0.8, 0.7, and 60pt in this post are constants set by policy, not optimal values found by measurement. Two handwriting fixtures and a proxy evaluation on MNIST cannot speak for real accuracy, and the six items above stay open until they are checked on devices.
---
# Apple Pencil 필기를 스도쿠 숫자로 바꾸는 입력 처리
> DailySudoku에서 펜으로 쓴 숫자를 셀에 넣으려면 획을 tap과 필기로 나누고, 여러 획을 묶고, 모델 입력으로 바꾸고, 믿을 수 있을 때만 적용해야 해요. 각 단계의 기준값과 아직 검증하지 못한 부분을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/apple-pencil-handwriting-digit-input-pipeline/
- Published: 2026.08.07
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Swift, #PencilKit, #Core ML, #Apple Pencil
Apple Pencil 입력을 DailySudoku에 넣으면서 정한 목표는 단순했어요. 셀 위에 7을 쓰면 7이 들어가고, 펜으로 콕 찍으면 그 셀이 선택돼요. 대신 손바닥이 닿거나 인식 결과가 불확실할 때는 아무 숫자도 들어가지 않아야 해요.
이 목표를 지키려면 네 가지를 풀어야 했어요. 기존 보드 조작과 충돌하지 않게 입력을 받는 일, 여러 획을 한 글자로 묶는 일, 학습 때와 같은 모양으로 입력을 바꾸는 일, 결과를 언제 적용할지 정하는 일이에요. 아래는 2026-08-07의 develop 브랜치(커밋 6db30672)를 기준으로 한 구현과, 자동 테스트만으로는 확인하지 못한 부분이에요.
## 펜과 손가락을 다른 길로 보내기
`BoardView` 위에 자식 뷰 `PencilDrawOverlay`를 올렸어요. `PencilDrawOverlay`는 투명한 `PKCanvasView`이고, [`.pencilOnly` 정책](https://developer.apple.com/documentation/pencilkit/pkcanvasviewdrawingpolicy)을 적용해서 펜만 그릴 수 있게 했어요. 반대로 보드의 recognizer는 펜을 받지 않아요.
보드 recognizer가 받는 touch 종류예요.
```text
허용: direct, indirect, indirectPointer
제외: pencil
```
이렇게 나누면 펜은 overlay로 가고, 손가락·마우스·트랙패드는 원래의 셀 선택 처리로 가요. 획을 쓰는 동안에는 보드의 pan을 끄고 tap은 그대로 둬요. overlay는 접근성 요소로 노출하지 않아요. VoiceOver 사용자는 [보드 접근성을 다룬 글](/posts/adaptive-accessible-sudoku-board/)에서 설명한 가상 셀 81개를 그대로 써요. 셀의 geometry는 하나를 같이 쓰고, touch가 들어오는 경로만 다른 구조예요.
## tap인지 필기인지, 어느 묶음인지
첫 획의 경계 상자 대각선이 6pt 이하이고 걸린 시간이 0.12초 이하이면 tap으로 봐요. 두 조건을 모두 만족해야 해요. tap이면 ink를 지우고 경계 상자의 중심에 있는 셀을 선택해요. 이미 쓰고 있는 묶음 근처에 찍힌 획은 작고 짧아도 ink로 취급해요. 숫자의 일부일 수 있기 때문이에요.
묶음은 마지막 획 뒤로 0.45초를 기다렸다가 확정해요. 획이 추가될 때마다 대기 종료 시각을 새로 잡아요. 4, 5, 7처럼 여러 획으로 쓸 수 있는 숫자를 고려한 값이에요.
tap이 아닌 획이 기존 묶음의 중심에서 x 방향이나 y 방향으로 60pt 넘게 떨어지면 이전 묶음을 확정하고 새 묶음을 시작해요. 멀리 떨어진 곳의 짧고 작은 획은 tap 판정이 먼저예요. 이전 묶음을 처리한 다음 새 셀을 선택해요. 대상 셀은 묶음 전체의 상자 중심을 `BoardView.cellIndex`에 넘겨서 구해요. 둥근 모서리 판정도 기존 보드와 같은 함수를 써요. 획의 분류, 묶음 소속, 대상 셀 결정은 서로 다른 단계로 분리했어요.
60pt는 고정 거리라는 점이 약점이에요. 화면 크기에 따라 셀 간격이 달라지는데, 인접한 셀에 연달아 쓸 때의 경계는 테스트로 확정하지 못했어요. 셀 크기에 비례하는 값으로 바꾸거나, 지원 기기에서 거리를 재서 보정하는 방법을 생각하고 있어요.
## 모델이 보는 28×28 만들기
`PKDrawing`의 path에서 점 목록을 꺼내 보드 크기의 회색조 버퍼에 다시 그려요. 그 뒤 MNIST 형식에 맞춰 자르고 줄여요.
전처리 단계를 순서대로 적은 흐름이에요.
```text
PKDrawing
-> [[CGPoint]]
-> round-cap grayscale raster
-> ink bounding-box crop
-> longest side 20px, aspect ratio 유지
-> bilinear resample
-> center of mass를 28×28 중앙으로 이동
-> [1, 1, 28, 28] Float tensor
```
화면에 보이는 획의 굵기는 일반 입력이 6pt, note가 3pt예요. 추론용으로 다시 그릴 때는 둘 다 6pt로 그려요. 표시 모드가 모델이 받는 입력의 분포를 바꾸지 않게 하려는 선택이에요.
전처리 테스트는 빈 입력이 nil을 돌려주는지, 같은 입력에 같은 결과가 나오는지, 긴 변이 20px인지, 질량 중심이 제자리인지를 봐요. Python 기준의 Core ML label·확률과 Swift 결과도 비교해요. crop, 배율, 픽셀 방향, 강도 범위 가운데 하나라도 학습 조건과 어긋나면 오류 없이 실행되면서도 잘못 인식할 수 있어요. 실행이 성공했다는 사실만으로는 전처리가 맞는지 알 수 없어요.
## 받아들일 결과와 버릴 결과
모델의 class는 0부터 9까지 10개를 그대로 뒀어요. 스도쿠에는 0이 없지만, 애매한 동그라미를 6이나 9로 억지로 배정하지 않으려고 0을 남겼어요. 0으로 분류된 입력은 적용 단계에서 탈락시켜요.
confidence 기준은 기본 0.8이에요. note는 고치는 비용이 낮다고 보고 0.1 낮춘 0.7을 써요. 결과가 1부터 9 사이이고 기준 이상일 때만 셀을 선택하고 숫자를 넣어요. 0, 범위 밖의 값, 낮은 confidence, 모델 적재 실패, 전처리 실패, 추론 오류는 모두 reject예요. reject는 mistake 수를 올리지 않고 그냥 아무 동작도 하지 않아요. ink는 결과와 상관없이 지워요.
지금 구조에서는 원인이 다른 실패가 전부 `.reject` 하나로 합쳐져요. 모델을 못 불러온 것인지, 확률이 낮았던 것인지, label이 틀린 것인지 구분할 수 없어요. 원인별 집계가 필요해요. 원본 획을 보내지 않고 기기 안의 통계만으로 실패율을 보는 방법을 생각하고 있지만 아직 구현하지 않았어요.
## 늦게 도착한 결과
렌더링과 prediction은 `Task.detached`에서 돌리고, 받아들인 결과는 main actor에서 `selectCell`과 `placeDigit`을 호출해요. 여기에 문제가 두 가지 있어요.
첫째는 모델 공유예요. `CoreMLDigitRecognizer`가 `@unchecked Sendable`로 선언돼 있어서 같은 `MLModel`을 여러 task가 동시에 쓸 수 있어요. [`MLModel` 문서](https://developer.apple.com/documentation/coreml/mlmodel)는 한 인스턴스에 대한 호출을 직렬화하라고 조건을 달아요. `@unchecked Sendable`은 컴파일러 검사를 건너뛸 뿐이고 `MLModel`의 실행 조건을 바꾸지는 않아요. 그래서 지금 선언은 그 조건과 충돌할 우려가 있어요.
actor가 recognizer를 소유하게 바꾸는 안이에요. 아직 적용한 코드가 아니라 제안이에요.
```swift
actor DigitInference {
private let recognizer: CoreMLDigitRecognizer
init() throws {
recognizer = try CoreMLDigitRecognizer()
}
func recognize(_ pixels: [Float]) throws -> DigitRecognition? {
try recognizer.recognize(pixels: pixels)
}
}
```
이렇게 하면 prediction이 직렬로 실행되고, 호출하는 쪽은 `await`로 불러야 해요. queue마다 모델을 복제하는 방법도 있지만, actor 하나로는 latency나 throughput이 부족하다고 측정된 뒤에 검토할 대안이에요.
둘째는 상태가 바뀐 뒤에 예전 결과가 적용될 수 있다는 점이에요. completion은 지금 playing 상태인지만 확인해요. pause 했다가 resume 하거나 note 모드를 바꾼 뒤에도 앞서 요청한 결과가 들어갈 수 있어요. 임계값은 획을 캡처할 때의 mode로 정하고, `placeDigit`은 완료 시점의 mode로 동작하는 불일치도 있어요. 요청할 때 아래 세 값을 함께 보관하고 적용 직전에 같은지 확인하는 방법을 생각하고 있어요.
```text
input revision + target cell + entry mode
```
값이 다르면 결과를 버려요. revision은 단조 증가하는 숫자로 두면 구분할 수 있어요.
## 자동 테스트가 본 것과 보지 못한 것
자동 테스트는 순수 reducer와 renderer를 중심으로 짰어요. 둥근 보드의 hit test, tap과 ink의 조건, debounce, 먼 획의 재시작, 정규화, reject 분기, 번들 모델 적재, Python과 Swift 결과 비교, touch 허용 목록, delegate 연결을 봐요.
필기 fixture는 1과 7뿐이에요. 렌더링부터 예상 숫자까지 확인하는 입력이 두 종류라는 뜻이에요. 학습 스크립트의 임계값 탐색도 회전과 굵기를 바꾼 MNIST, 그리고 0과 noise를 쓴 대리 평가예요. 그래서 실제 펜 필기의 인식률이나 0.8이 가장 좋은 값이라는 것은 입증되지 않았어요.
delegate에 관해서는 소스 주석에 기록이 있어요. iOS 26.5에서 canvas가 자기 자신을 delegate로 연결하면 PencilKit이 재귀에 빠진다고 진단했고, 그래서 별도의 bridge를 둬요. 테스트는 canvas와 delegate가 같은 객체인지 아닌지만 확인해요. 당시의 기기 로그를 이번에 다시 확인하지는 않았어요. simulator의 마우스로는 `.pencilOnly` 경로를 실행할 수 없다는 제약도 있어요.
기기에서 확인해야 할 일은 여섯 가지예요.
- 필체별 1~9의 precision과 reject 비율
- 첫 응답의 latency와 연속 결과의 순서
- 화면별 60pt 경계
- 펜, 손가락, pointer, VoiceOver를 섞어 쓸 때의 동작
- pause·resume이나 mode 변경 중 이전 결과의 폐기
- 모델 적재 실패나 반복 reject를 사용자가 이해할 수 있는지
지표로는 받아들인 결과 가운데 오입력의 비율, reject 비율, p95 latency를 볼 생각이에요. 적용된 동작이 맞는 비율을 먼저 지키고, 인식하지 못하는 경우가 늘어나는 것은 감수하는 쪽으로 판단했어요.
## iOS 27의 PKStrokeRecognizer로 바꾸려면
iOS 27 beta에는 [`PKStrokeRecognizer`](https://developer.apple.com/documentation/pencilkit/pkstrokerecognizer)가 있어요. actor이고, 기기 안에서 비동기로 획을 text로 인식하는 API예요. `PKDrawing`을 입력으로 받아요. OS에 따라 인식 결과가 달라질 수 있어서, 결과를 저장하려면 `recognitionVersion`을 함께 보존해야 해요. [필기를 인식해 text로 바꾸는 방법 안내](https://developer.apple.com/documentation/pencilkit/recognizing-handwriting-and-converting-to-text)도 같이 공개돼 있어요. 배포 대상이 iOS 26.5인 앱에서 쓰려면 Xcode 27 beta SDK와 `#available(iOS 27.0, *)`, 그리고 Core ML로 돌아가는 대체 경로가 필요해요.
구현만 바꿔 끼우기는 어려워요. 지금의 `DigitRecognizing`은 `[Float]`을 받아 digit과 confidence를 돌려줘요. `PKStrokeRecognizer`는 `updateDrawing(_:)`으로 입력을 넣고 `recognizedText(strokeIDs:)`에서 `String?`을 받으며 confidence가 없어요. 그래서 drawing과 context를 받는 더 위쪽의 비동기 경계가 필요해요.
```text
recognize(drawing, context) async -> candidate 또는 reject
```
Core ML 쪽은 지금의 렌더링과 확률 정책을 그대로 두고, 새 adapter는 text를 1~9로 제한하면 돼요. 확률이 없는 결과를 어떤 기준으로 자동 적용할지는 따로 제품 검증이 필요해요. beta 기간에는 기존 모델을 유지할 생각이에요.
이 글에 나온 0.8, 0.7, 60pt는 정책으로 정한 상수이고 측정으로 얻은 최적값이 아니에요. 필기 fixture 두 종류와 MNIST 대리 평가로는 실제 정확도를 말할 수 없고, 위에 적은 여섯 가지는 기기에서 확인하기 전까지 열려 있어요.
---
# Exposing 81 Sudoku cells to VoiceOver and TalkBack
> The DailySudoku board is one view on screen, but assistive technology needs 81 cells. How iOS and Android expose each cell, and what is still unverified.
- Canonical: https://jaemyeong.com/en/blog/adaptive-accessible-sudoku-board/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #Accessibility, #UIKit, #Jetpack Compose, #iOS, #Android
The DailySudoku board is a 9×9 square that shows digits and notes. For someone who looks at the screen and taps with a finger, turning a touch position into a cell number is enough. Someone who uses VoiceOver or TalkBack needs more: which cell has focus, what the cell contains, whether it is selected, and how to activate it.
This post compares the iOS and Android implementations based on the source code as of 2026-08-06. It is not a report that accessibility is done. It records the design rules I used and the parts that are still unverified.
## What each cell has to say
On screen, the board is a single UIView on iOS and a single gesture container on Android. Assistive technology still receives 81 separate cells. Each cell needs this information:
- Position: row and column numbers, starting from 1
- Content: empty, a given digit, a value the user entered, an error, or notes
- State: selected or not
- Action and area: selecting the cell, and a rectangle that matches the cell on screen
An identifier or test tag such as `game.cell.r0c0` is only for automated tests to find a cell. The description the user hears has to be separate from that identifier: a localized sentence that carries the position and the content. An example is a sentence saying that row 2, column 7 holds the digit 4.
## One model for state, one function for input
The core game state is projected into models in the `CellUi` family. The model holds five things.
```text
CellUi = value + given + error + notes + highlight
```
The colors and digits on screen, the calculation from coordinates to an index, and the description, state, and action for assistive technology all come from this model. The two platforms do not need the same view hierarchy. They need the same information from the same model.
Input also goes to one place. A tap, a drag, a VoiceOver activation, and a TalkBack double-tap all end in `selectCell(index)`. I avoided separate accessibility-only logic because it could drift away from the state on screen.
## iOS: one view, 81 accessibility elements
On iOS, a single `BoardView` draws the background, the grid, the digits, and the notes. Instead of one UIView per cell, it creates 81 [`UIAccessibilityElement`](https://developer.apple.com/documentation/uikit/uiaccessibilityelement) objects. Apple's documentation describes this as the way to expose items that are not views.
Each element gets these values:
- Array order: row-major (left to right, then top to bottom)
- `accessibilityLabel`: the coordinates plus whichever of empty, given, error, value, or notes applies
- `accessibilityTraits`: button, with selected added for the selected cell
- [`accessibilityFrameInContainerSpace`](https://developer.apple.com/documentation/uikit/uiaccessibilityelement/accessibilityframeincontainerspace): the rectangle of the cell
- `accessibilityActivate()`: calls `onSelect(index)`
When the size changes after rotation or Split View, the frames are calculated again. The values are set directly in container coordinates.
Two things are unverified. I still need to check whether the order in which indexes 0 through 80 are created matches the real VoiceOver swipe order and read-all order. I also have not verified that VoiceOver speaks the new value right away when the focused cell changes.
## Android: pointerInput has no button meaning
On Android, container coordinates are converted to a 9×9 index, and tap and drag use the same hit test. Pointer changes that the board handles are consumed so they do not interfere with outer scrolling.
The problem is that `pointerInput` alone does not make TalkBack treat a cell as a button. The [Compose guide to gesture handling levels](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures) explains that default semantics and focus support shrink as you go down from Button to clickable to pointerInput. So each `BoardCell` gets its description, role, and click action by hand.
These are the semantics attached to each `BoardCell`.
```kotlin
Modifier.semantics {
contentDescription = cellDescription
role = Role.Button
onClick {
onSelect(index)
true
}
}
```
The board handles continuous drags from ordinary touch, and semantics handles activation of the cell that TalkBack has focused. Putting clickable on each cell would make it compete with the board's `pointerInput` for the same events. This setup avoids that.
## Where the two platforms still differ
Here is the source comparison as a table.
| Item | iOS | Android |
|---|---|---|
| Each cell exposed separately | Yes | Yes |
| Position and content description | Yes | Yes |
| Button activation | Yes | Yes |
| Separate automation identifier | Yes | Yes |
| User-entered value | Read as a digit only | Distinguished as input |
| Selected state | `.selected` | None |
| Collection structure | None | None |
Android has no selected or stateDescription. The background color of the selected cell changes, but the selected state does not reach TalkBack. Android also does not use `collectionInfo` or `collectionItemInfo`. The collection information described in the [Compose semantics documentation](https://developer.android.com/develop/ui/compose/accessibility/semantics) tells the user the overall size and the current position. For now, both platforms convey the 9-row, 9-column structure only through the description text of each cell.
I set the next steps in this order. First, add the selected state on Android and listen to the speech output on both platforms. The 9×9 collection information comes after checking that it is not read twice together with the current row and column description. A custom rotor is worth considering only when there is evidence that moving through 81 cells in a line is a real problem.
## The cells are small
The board size has an upper limit: 560pt on iOS and 520dp on Android. On Android, when the width is 840dp or more, the board and the controls sit side by side. In a foldable tabletop posture, the hinge splits the screen into top and bottom areas.
At any size, the drawing, the hit test, and the accessibility cells must point to the same index. They are recalculated on rotation, window size change, and foldable posture change. While the pause or completion modal is up, the board behind it is removed from navigation. iOS makes the overlay a modal accessibility area, and Android clears the background semantics.
The concern is cell size. The [accessibility section of the Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/accessibility) gives a default control size of 44×44pt and a minimum of 28×28pt for iOS and iPadOS. The [Compose documentation on default accessibility behavior](https://developer.android.com/develop/ui/compose/accessibility/api-defaults) recommends at least 48dp for interactive elements. With nine cells per row, a cell on iOS is narrower than 44pt when the board is narrower than 396pt. On Android, the cell computed from the code under a compact 360dp condition is about 34dp. That number is calculated, not measured on a device.
Because the Android cells carry hand-written semantics, they do not rely on the automatic target expansion that clickable provides. Still, growing all 81 areas to 44pt or 48dp at once would make them overlap, and it could become unclear which cell was chosen. I can only judge this after measuring focus areas, touch exploration, and finger mis-taps on real devices. The alternatives are enlarging the board, navigating by row or box, or a separate input method. I do not yet have grounds to pick one.
Text size is also open. Digits and notes on iOS are drawn at a fixed ratio of the cell, so they do not follow Dynamic Type. Where and how to show larger text is left for later verification.
## What to lock down with tests
The existing checks cover cell state projection, coordinate hit testing, drag boundaries, part of the color contrast, and foldable areas. No test locks down the virtual accessibility elements or the cell semantics themselves. These are the items I plan to automate:
- 81 cells in the playing state
- First and last coordinates (1,1) and (9,9)
- A description for each of given, error, notes, empty, and an ordinary value
- Selected state exposed only on the selected cell
- One `selectCell(index)` call per activation
- Areas that match after a resize
- The board excluded after a modal appears
- The 9×9 information and the current row and column in Compose
The UI tests described in the [Compose accessibility testing documentation](https://developer.android.com/develop/ui/compose/accessibility/testing) can check semantics properties and actions. In UIKit, unit tests can check the label, traits, frame, and activation. Being able to find an element with a selector does not prove accessibility.
I also listed what to check on devices. On an iPhone and an Android phone: the point where a swipe moves to the next row, whether the spoken cell matches the finger position, whether focus stays after a double-tap, and whether changes to digits, notes, and errors are noticed. The last check is to select, enter, correct, and finish without looking at the screen.
Apple's [guide to supporting VoiceOver](https://developer.apple.com/documentation/uikit/supporting-voiceover-in-your-app) and the [VoiceOver section of the Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/voiceover) recommend turning VoiceOver on and auditing element access, traversal, and tasks that depend on sight. The automated checks introduced in the [Compose accessibility overview](https://developer.android.com/develop/ui/compose/accessibility) help find small targets and traversal order problems, but hands-on checking is still needed.
What the current source supports is this much: both platforms expose the cells separately and route selection to the same function. The selected state on Android, grid information on both platforms, whether the target size is adequate, speech output on state changes, and a full traversal on real devices are not verified. So I do not claim that the two platforms have reached the same level of accessibility.
---
# A contract for counting ad impressions the same way on three platforms
> A finished load on iOS, an impression callback on Android, a DOM mount on the web: one name, three meanings. A contract that splits the stages of an ad.
- Canonical: https://jaemyeong.com/en/blog/cross-platform-ad-impression-event-taxonomy/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Android, #Web, #Advertising, #Analytics
In ad analytics, the event named `ad_impression` often gets recorded at a different moment on each platform. One app sends it when the ad finishes loading on iOS, when the SDK reports an impression on Android, and when the ad element is attached to the DOM on the web. The numbers end up in one table, but each platform counts by a different rule. Depending on the platform, the result is an overcount or an undercount.
Five things get mixed under that one name. They are downloading the ad, attaching it to the view hierarchy, overlapping the viewport, being counted as an impression by the SDK, and receiving a revenue callback. On top of that, adding a manual event to one that is already logged automatically counts the same impression twice.
This post is a contract I wrote after comparing what each callback means in the Google, Firebase, and web documentation. I reopened and checked the documents on October 4, 2026. It does not give SDK versions, measured logs, or run results. Choosing an ad provider is out of scope.
## A finished load and a DOM mount are not impressions
Receiving an ad does not mean the user saw it. After loading, the ad may never be attached to the screen, and in the meantime the screen can close or the slot can switch to another ad. Being ready is not enough to count an impression.
Attaching to the DOM on the web is not enough evidence either. [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) observes whether the ratio of overlap between the target and the root crosses a threshold. The default threshold is 0, so a notification can arrive as soon as the edges touch. Right after `observe()` starts, the first notification can arrive even for a target that is not visible. That notification cannot be treated as the basis an ad network uses for billing. What the documentation describes stops at observing the overlap.
## Six stages an ad goes through
I split the mixed signals into stages, and for each stage I wrote down what is still undecided.
- `requested`: the request has started. The response, the display, and the impression are all undecided.
- `loaded`: a response or creative has arrived. The display and the impression are undecided.
- `rendered`: the ad is attached to a view or the DOM. Whether it is really visible and whether the SDK counted it are undecided.
- `app_visible`: the viewport condition defined by the app is met. Whether the network bills for it is undecided.
- `sdk_impression`: the SDK counted an impression. Whether revenue data comes and how precise it is are undecided.
- `paid_value`: revenue for the impression was observed. The settled amount and its accuracy are undecided.
Platform signals map to these stages as follows.
- `bannerViewDidReceiveAd(_:)` on iOS and `onAdLoaded()` on Android are `loaded`. They are not counted as impressions.
- `bannerViewDidRecordImpression(_:)` on iOS and `onAdImpression()` on Android are `sdk_impression`. The app records them only when there is no automatic collection.
- Mounting the ad element on the web is `rendered`. It is not counted as an impression.
- Meeting the observer rule is `app_visible`. It is used only for in-house ads.
- `paidEventHandler` on iOS and `OnPaidEventListener` on Android are `paid_value`. They do not add to the count.
## What counts as one impression
The SDK callback comes first as evidence of an impression. For in-house ads with no SDK, the visibility condition is written down, and when it is met the event is recorded with `evidence=app_visible`. That group is analyzed separately from the group observed by an SDK. Sharing an event name does not make the two groups directly comparable.
In this contract, a paid callback is treated as revenue data attached to an impression that was already counted. It does not add one more. Currency and precision are kept as they are. Precision can be unknown or estimated, so the value must not be read as a final settlement. When no revenue is provided, the value is not filled with 0 and is not calculated backward from the count. If a value is put into the Firebase `ad_impression` event, the currency has to go with it, and precision is kept in a separate revenue path.
The failure side is narrowed as well. `ad_load_failed` is recorded only when a provider that was actually requested failed to respond. A provider that was skipped because it is not registered or is turned off is not a failure. For the same reason, choosing a provider is not enough to send `ad_impression`. If a request was canceled when the screen closed or unmounted and its callback arrives late, it is not treated as a result of the current slot.
## When events are logged automatically, the app does not send them
With AdMob linked to Firebase and automatic logging turned on, the app must not send the same event by hand. Sending the same GA4 event again inside `onAdImpression()` turns one impression into two. An SDK with no automatic collection records one event per callback, and an in-house ad records one event each time the visibility condition is met. External web content where only the DOM can be observed needs its own evidence rule.
Automatic events can lack fields such as `slot` or `selection_source`. Instead of sending another impression to fill them in, the gap is accepted and the analysis is done per `evidence`. Before paid data that goes to a separate server is converted into a GA4 event, the first check is whether automatic logging is on. The contract also states how the filled fields differ between automatic and manual events.
## Slot, attempt, and ad instance
To remove duplicates, the app first has to define what one unit is. A slot is the unit for as long as the UI lives. An attempt is one request to one provider. An ad instance is the unit for as long as one creative lives.
This diagram shows one slot where a request fails, a second request succeeds, and a refresh produces a second impression.
```text
slot instance
├─ attempt 1 → load failed
└─ attempt 2 → loaded → impression 1
└─ refresh → impression 2
```
A single Boolean per session misses the impression that comes from a refresh. Sending every callback, on the other hand, can produce duplicates on retries and remounts. So a failure is kept once per attempt and an impression once per instance. When a refresh brings a new creative, it is a new instance, and only a repeated callback from the same instance is dropped. In-memory identifiers are enough for this. There is no need to send unique IDs to GA4 and create a dimension with too many distinct values.
## The shape of the event inside the app
Inside the app, the event has few fields and only fixed values. The example below is not a standard GA4 schema. It is an internal example.
```json
{
"name": "ad_impression",
"provider": "global_network",
"format": "banner",
"slot": "bottom",
"evidence": "sdk_impression",
"selection_source": "normal"
}
```
For `provider`, `format`, and `slot`, the mapping to each platform's enum is written in code. If a provider is added and a mapping is missing, the build or a test fails. The values given for `evidence` in the text are `sdk_auto`, `sdk_callback`, and `app_visible`. How `sdk_impression` in the example maps to those three values is not defined in this post.
`selection_source` is calculated for the object the event points to. If a forced provider fails and another provider's ad is shown, the source of the failure and the source of the impression can differ. Copying one source along the chain of requests distorts the meaning.
## Checking without depending on ad inventory
Waiting for a real ad makes the check depend on inventory. Injecting callbacks directly in a test removes that dependency. The list below gives the expected values for such a check. It is not a record of a run that passed.
- A successful load alone gives 0 impressions.
- One SDK callback gives 1.
- A repeated callback from the same instance still gives 1.
- After a refresh with a new ad, the total is 2.
- For a provider with automatic collection, manual events are 0.
- A provider skipped before any request gives 0 failures.
- When another provider is shown after a failure, there is 1 failure and 1 impression, and the source is calculated for each.
- A late callback after unmount gives 0.
- The first observer notification while not visible gives 0.
- Two independent slots that are each shown give 2.
Putting the SDK callback first, the units of identity, and the design of the internal enums and source are my reading of the documents. They do not guarantee what a network bills or settles. An in-house visibility rule on the web has to be verified down to the threshold, the dwell time, and the reset when the ad leaves the area, and a single entry at one moment cannot tell how long the ad was visible. `trackVisibility` is limited and experimental, so it is left out of the required dependencies. How to link a revenue callback to the earlier impression, and how to handle the order in which callbacks arrive, are not covered in this post.
---
# Daily puzzle scores: which day they belong to and how they get lost
> In a game started at 23:50 and finished at 00:05, the puzzle date, the submission time, and the leaderboard window can disagree. How far to guarantee delivery.
- Canonical: https://jaemyeong.com/en/blog/daily-leaderboard-date-boundary-submission-reliability/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Android, #GameKit, #Google Play Games, #Testing
When the result of a once-a-day puzzle is sent to both Game Center and Google Play Games, the date becomes a problem for a game that crosses midnight. In a game started at 23:50 and finished at 00:05, the day the puzzle belongs to, the day the SDK received the submission, and the range the leaderboard screen shows can all disagree.
Delivery is a problem too. The completion record and the statistics can remain on the device while the external score is lost. The opposite problem also exists: asking for sign-in in the middle of completion ties game progress to authentication.
The basis is the official Apple and Google documentation, which I reopened and checked on October 4, 2026. What those documents state is the behavior of the SDKs and the servers. The submission condition, where the sign-in screen goes, the in-memory slot, and the outbox are app-side design on top of that, not requirements of the documentation. The midnight case is a made-up example. This post does not give measured values, run logs, or SDK versions.
## Three values that all look like a date
Three values look like the same "date" but have different owners. `puzzle_day` is the puzzle's date, decided by the app. `submitted_at` is the submission time, decided by the app and the device. `leaderboard_window` is the range used for queries, decided by the SDK.
The puzzle date is set when the game is created and does not change within the session. The submission time can change on every retry. Nothing guarantees that the SDK's window matches midnight on the device. So the date is not calculated again at completion. The value stored in the session is used.
This example shows how the three values differ in a game that crosses midnight.
```text
퍼즐 생성: 2026-08-05 23:50 → puzzle_day = 2026-08-05
게임 완료: 2026-08-06 00:05 → submitted_at = 2026-08-06 00:05
제출 메타데이터 → 2026-08-05
```
The three Korean labels mean "puzzle created," "game completed," and "submission metadata."
## Extra values do not change the window
On Android, the puzzle date in `yyyy-MM-dd` form can be carried in `scoreTag`. `scoreTag` is an optional argument of `submitScore` in [LeaderboardsClient](https://developers.google.com/android/reference/com/google/android/gms/games/LeaderboardsClient). It allows only URI-safe characters and up to 64 characters. The documentation defines this value only as optional metadata. Using it to say which puzzle date a score came from is the app's own choice. It does not prevent duplicates per date and does not filter the screen. Putting 2026-08-05 in the tag does not split the rows of `TIME_SPAN_DAILY` by date.
`context` on Apple is the same. The `context` of [submitScore](https://developer.apple.com/documentation/gamekit/gkleaderboard/submitscore(_:context:player:leaderboardids:completionhandler:)) is an extra value whose meaning the app defines, so how to express a date in it is the app's responsibility. It is not a way to control `timeScope`. If only `context: 0` was sent, the puzzle date cannot be worked out from the score data. The fact that this value means different things on the two platforms should be written into the product requirements.
## The day that the default screens show
[Play Games leaderboards](https://developer.android.com/games/pgs/leaderboards) offer daily, weekly, and all-time, and the daily boundary is midnight UTC-7 all year. Apple's [today scope](https://developer.apple.com/documentation/gamekit/gkleaderboard/timescope-swift.enum/today) is the last 24 hours. When an occurrence of an Apple [recurring leaderboard](https://developer.apple.com/documentation/gamekit/creating-recurring-leaderboards) changes depends on the [recurrence settings in App Store Connect](https://developer.apple.com/help/app-store-connect/reference/game-center/leaderboards/). Neither default daily screen guarantees a match with `puzzle_day`.
Take an app whose puzzle changes at midnight in Seoul. Scores for the new puzzle and for the previous day's puzzle can fall into one Google daily window. Apple's today scope can also hold scores from both days. This comes from a different contract for the displayed range, and editing the tag does not solve it.
There are three paths, depending on the requirement.
- If the SDK's daily screen is enough, use the extra value and the default UI.
- If occurrences need to be told apart on Apple, consider a recurring leaderboard.
- If both platforms must use the same calendar rule and the same query rule, a separate store and a custom UI are needed.
The last path brings costs for operations, anti-cheating, and privacy management. For a simple daily competition, I judged that it does not need to be introduced in advance. That judgment is my reading, not something the documentation says.
## When to submit and when not to
The SDKs do not define when to submit. The policy below is an example of what the app decides: a score is submitted only when three conditions are all met.
```text
submit = won && daily && authenticated
```
In free mode, after a loss, or without authentication, nothing is sent. When the authentication state is uncertain, only a check that shows no screen is allowed. The sign-in screen belongs on the path where the user presses the leaderboard button. A disabled service, a signed-out user, or a canceled sign-in must not make completion, statistics, or the screen transition fail.
The device has its own duties at completion. It prevents the terminal transition from being handled twice, deletes the resume data, and updates the statistics and the daily completion record. The external submission is a separate attempt, and its failure does not roll completion back.
The phrase "local first" needs care here. The phrase alone does not say that the write to disk finishes before the submission. On Android, if the submission starts right after an asynchronous write is scheduled, the order in which the two finish is not defined. If the disk record has to be settled first, the design needs to wait for the save to complete.
## Holding one score for a short while
On Android, a score can arrive in the short moment when no UI host is connected. One design for that case holds at most one score in memory. This is a design on the app side, not a feature of the SDK. If A is held and B arrives, B replaces A. When an Activity connects, the score is taken out, authentication is checked, and it is submitted once.
```text
Activity 없음
score A 보류
score B 도착 → A를 B로 교체
Activity 연결
B를 꺼내 인증 확인 → 제출 시도
```
In the block, the lines read: no Activity, score A is held, score B arrives and replaces A, an Activity connects, and B is taken out, authentication is checked, and a submission is attempted.
The in-memory slot exists to reduce short lifecycle races, and its limits are clear. If the process dies, the held score is gone. If authentication is false or the check fails after the score is taken out, it is not put back. If iOS has no equivalent in-memory slot, the chance that a score is delivered also differs between the two platforms.
Keeping the latest item is a rule inside the device only. Google applies a score only when it is better than the value on the server, and Apple picks the best score or the most recent score depending on the setting. The app choosing the latest item does not mean the server adopts that record.
The call itself differs too. `submitScore` does not report a result, and `submitScoreImmediate` returns a `Task`. With the call that reports nothing, the app cannot tell whether the submission succeeded. For the one that returns a `Task`, too, the documentation says nothing about sending again after the app restarts.
## How far to guarantee delivery
Splitting the delivery level into three is also my own classification. The documentation does not make this distinction.
- One direct attempt: send once within the session. It costs the least and fits a feature that can afford to miss a score.
- One slot in memory: keep the latest item while there is no host. The cost is low, and it fits short lifecycle races.
- Durable outbox: the score can be sent again after a restart. The cost is high, and it fits cases where a lost submission affects rewards or contest results.
With a durable outbox, the payload is written to disk first. A stable submission ID, `puzzle_day`, the score, and the target leaderboard are stored. Only items with a success response are deleted, and the remaining items are restored on restart. Backoff and success confirmation are part of it. The server keeping an existing better record and the app deciding to stop resending are judged separately. Storing a submission ID does not mean the SDK removes duplicates or processes a score exactly once.
If loss is accepted, the queue is not extended into a general-purpose one. The conditions under which a score is dropped are written into the documentation and the tests.
## Expected values to pin with tests
The expected values below are a plan: keep the condition and the holding policy as pure functions and pin them with tests. This post does not include results from a run.
- A win in free mode or a loss in daily mode gives 0 submissions.
- A daily win without authentication gives 0 submissions and no sign-in screen.
- Playing from 23:50 to 00:05 keeps the session's puzzle date.
- With no Activity, A followed by B keeps only B.
- If the process dies while a score is held, nothing is submitted after restart.
- If authentication fails after the score is taken out, it is not held again.
- If the remote request fails, the completion record and statistics remain.
- For a latest score lower than the previous one, the device's latest choice and the server's best choice are checked separately.
- If the terminal event arrives again, completion is recorded once and submission is attempted once.
- The default daily and today queries use the SDK's time range, not the extra value.
Unit tests prove only the app's branches and the payload. They are not evidence that a score actually reached the server. Canceled sign-in, offline use, and display errors of the leaderboard intent and the access point have to be checked separately in an integrated environment. The different meaning of a day on the default screens, the loss of the in-memory score, the call whose result is unknown, and the order in which the disk write completes all remain as limits of this design.
---
# Why a daily puzzle ID needs a generator version
> A daily board is made from the date. If the generator changes, one date can give two boards, and a date-only ID cannot tell them apart. Design and proposal.
- Canonical: https://jaemyeong.com/en/blog/daily-puzzle-identity-generator-version/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #Rust, #Testing, #iOS, #Android, #WebAssembly
The board of a daily puzzle is made from the date. That is how the Sudoku app DailySudoku works. With the same day integer and the same core build, entering through the normal daily screen gives the same board on iOS, Android, and the web. But if the way boards are generated changes, the board can differ even when the seed stays the same. When the keys for storage, analytics, and leaderboards hold only the date, that difference is hard to express.
The baseline is commit 9c99930582be23d2c72d37fb46d77a488b9fe04c on the develop branch, dated August 6, 2026. I describe the current structure and the proposal on top of it separately. The `DailyPuzzleIdentity` type, the `device-local-v1` day policy, the g1 and g2 IDs, schema v2, and the resolver are all proposals and are not implemented yet.
## How a daily puzzle is created today
Entering the daily puzzle screen decides the board in this order.
```text
현재 시각
-> 기기 로컬 시간대의 epochDay
-> dailySeed(epochDay)
-> generate(MEDIUM, seed)
-> puzzleId = "DAILY-"
```
The first two lines read "current time" and "epochDay in the device's local time zone."
`dailySeed` uses the SplitMix64 finalizer and is computed with 64-bit [wrapping addition](https://doc.rust-lang.org/stable/std/primitive.u64.html#method.wrapping_add), [wrapping multiplication](https://doc.rust-lang.org/stable/std/primitive.u64.html#method.wrapping_mul), and logical shifts. The generator builds a complete board with its own RNG and then removes only the clues that keep the solution unique. This core is written in Rust. iOS and Android call it through UniFFI, and the web calls it through WebAssembly.
From the normal daily entry point, with the same day integer and the same core build, the fresh board and the solution match on all three platforms. That determinism and the golden fixtures are explained in [the post on testing and CI for the shared core](/en/posts/rust-shared-core-07-testing-ci/).
## The date is the device's local date
The day integer comes from the device's local date, not the UTC date.
```text
localEpochDay = floor((utcMillis + offsetAtInstant) / 86_400_000)
```
The offset is "local minus UTC" at that instant. The one that returns milliseconds is [TimeZone.getOffset](https://developer.android.com/reference/java/util/TimeZone#getOffset(long)) on Android. [secondsFromGMT(for:)](https://developer.apple.com/documentation/foundation/timezone/secondsfromgmt%28for%3A%29) on iOS returns seconds, so it is converted to milliseconds. [getTimezoneOffset](https://tc39.es/ecma262/#sec-date.prototype.gettimezoneoffset) on the web returns "UTC minus local" in minutes, so the sign is flipped and the value is multiplied by 60_000. Because of daylight saving time, the offset must not be a constant.
Under this policy, the day integer can differ between Seoul and Los Angeles at the same UTC instant. A time zone change after travel affects the ID on the next launch. A game that has already started keeps its date, and the date is decided again the next time the daily puzzle is entered. There is no guarantee that the whole world gets one board at the same moment. Conversely, devices that pick the same day integer, such as ones in Seoul and Tokyo, get the same board when the core and the difficulty are the same. So the time zone ID can be left out of the identity fields.
## What a date-only ID misses
`DAILY-` has neither the difficulty nor the generator version. The `version: 1` in the JSON that Rust stores is the number of the serialization format. The reader rejects any other schema that has no migration. That number is not the version of the generation algorithm.
There is a case where generation really changed. For the Expert difficulty, the maximum number of attempts was raised from 8 to 4,096, and for seed 159 a 24-clue board came out in place of the earlier 25-clue fallback. This is the change history of Expert, and it is not evidence that MEDIUM, which the daily puzzle uses, changed. It should be read only as a case showing that generation rules can change.
If the generator changes while old and new apps run together, this happens. A new game in the old app is a board made by g1. A new game in the new app is a board made by g2. A restored game is the stored g1 board. All three can use the same `DAILY-day`. When the storage format is compatible, the old game can continue by restoring the board and the solution, but the collision of the external ID in analytics remains. Standard Game Center and Play Games have no feature that splits scores by a dynamic puzzle ID. Which version's scores to accept has to be decided before submission. The limits of `context` and the score tag are covered in [the post on leaderboard date boundaries](/en/posts/daily-leaderboard-date-boundary-submission-reliability/).
## The proposed identity
I propose a type that groups the day policy, the day integer, the difficulty, and the generator version.
```ts
type DailyPuzzleIdentity = {
dayPolicy: "device-local-v1";
epochDay: number;
difficulty: "MEDIUM";
generatorVersion: number;
};
```
Each field has its own responsibility. `dayPolicy` covers the boundary of a day, `epochDay` the chosen date, `difficulty` the generation target, and `generatorVersion` the seed derivation, the RNG, and the generation rules. With `dayPolicy` in place, changing the date rule later does not apply the new rule to old identities. Metadata that records the observed time zone and `dayPolicy` have different roles.
Written as a string ID, the identity looks like this.
```text
DAILY-device-local-v1-20671-MEDIUM-g1
```
The seed can be computed again from `epochDay`, so it does not need to be in the ID. It can still be used in stored data or telemetry as a helper value for diagnosis and fixtures.
`generatorVersion` is kept separate from the Cargo package version. A change to the UI or an ad SDK alone does not need a bump. A change to the RNG, the shuffle, the difficulty target, clue removal, or retries bumps it depending on whether results are affected. The meaning of a version that has been issued is never changed. If Rust returns the identity and the board together as one result, a g2 board cannot end up with a g1 label.
## A unique solution and a stable identity are different questions
Each time the generator removes a clue, the solver looks for up to two solutions, and only a removal that leaves exactly one solution is accepted. This is a check on a freshly generated board. Whether an ID such as `DAILY-20671` points to exactly one board is a separate matter.
```text
solver invariant
-> 이 board에는 해답이 정확히 하나인가
identity invariant
-> 이 PuzzleIdentity는 배포·복원·분석 전체에서 같은 board를 뜻하는가
```
The first question asks whether this board has exactly one solution. The second asks whether this PuzzleIdentity means the same board across release, restore, and analytics.
The solver cannot catch two different uniquely solvable boards sharing an ID, and it cannot catch one board having several IDs.
Fixtures have limits too. A fixture only tells whether the result changed for the chosen inputs. The mistake of changing generation rules without bumping the version cannot be blocked automatically by the return API alone. If whole-board equality has to be guaranteed, a canonical digest or an archive fills the gap. A hash only says that something changed. It does not say why, or which generator to use to build the board again. So the version contract of the identity comes first, and whether to add a checksum is decided after that.
## Moving stored data
For schema v2, I propose storing the snapshot together with the versioned identity. Existing v1 saves are labeled g1 only when the release history confirms that all of v1 was g1.
A save whose origin is unknown is isolated as `legacy-`. The digest is a canonical digest of the givens, the solution, and the difficulty. The entries, the history, and the elapsed time are left out. Such a save is excluded from version comparison. A past record that lacks a board or a version stays as `legacy-unknown`.
A new game uses the active version, and regenerating a past date uses the recorded version.
```text
PuzzleIdentity.generatorVersion == 1 -> g1 또는 저장된 g1 board
PuzzleIdentity.generatorVersion == 2 -> g2 또는 저장된 g2 board
```
On each line, the right side reads "that generator, or the stored board of that version."
A past version that is not supported is reported as an error and is not quietly built with another version. A product with no replay of past games only needs the board and solution of the save in progress and a path that carries the identity. When there is replay or server-side verification, the old generator or an immutable archive is needed.
## What to keep when rolling back
If g2 has a problem and is rolled back, the version-aware binary stays, and only new issuance moves from g2 to g1. A board already assigned as g2 stays g2.
The order is fixed as well. First comes a compatible build that reads both v1 and v2 and uses versioned IDs but issues only g1. After that, g2 is turned on by a build or a remote policy. During the rollback period, both g1 generation and reading of g2 snapshots are kept. Going back to an old binary that does not know versions is not allowed.
This switch cannot be forced at once in the current structure. The generator is inside the client binary, and nothing central picks the version. Even if the server or the web is rolled back, a g2 mobile app that is already installed keeps running g2. Versioning cannot force one board per day in a mixed rollout. If that is required, a single authority has to manage an immutable mapping from the date to the generator version and also operate the handling of old clients. That means the server issues the identity or the board, or the minimum app version is controlled.
In a structure that ships both generators and picks one through the server or Remote Config, new issuance can be switched to g1. A stored g2 game finishes with its original identity and board. Excluding g2 scores has to happen before submission, and querying by version needs a custom backend or leaderboards that were split in advance.
## Checks that exist now and checks to add
Today the parity fixture pins the seed, the givens, and the solution for the combination of 4 epoch days and 5 difficulties. Overwriting the existing fixture with new results erases the contract of the old version. The fixed 64-bit seed arithmetic is checked with golden values, drift in fresh boards with the Rust tests and parity, and uniqueness with clue removal and tests on selected seeds. Checking 4 by 5 combinations does not prove every input.
For the switch, I propose leaving the g1 fixture as it is and creating the g2 fixture as a new file.
```text
g1 fixture: 변경 금지
(device-local-v1, day-before-cutover, MEDIUM, g1) -> board A
g2 fixture: 새 파일
(device-local-v1, cutover-day, MEDIUM, g2) -> board B
resolver test
저장된 g1 identity -> g1 또는 저장 snapshot
새 g2 identity -> g2
```
The Korean notes say that the g1 fixture must not be changed and that the g2 fixture is a new file. In the resolver test, a stored g1 identity resolves to g1 or the stored snapshot, and a new g2 identity resolves to g2.
There are four things to confirm. The g1 board and solution match on supported builds. Different versions give different external IDs. After a rollback, g2 is not treated as g1. The versions of the identity and the board are never mixed. On the Swift, Kotlin, and TypeScript side, the algorithm is not copied for testing. The tests cover storing and restoring the ID and passing it to the analytics and score policies.
The order of implementation is combining the Rust return value, keeping the identity in schema v2, replacing the analytics key, checking the version at score submission, and fixed fixtures per version. Until a second generator exists, a small switch is enough for dispatch, and there is no need to build a separate factory or a general migration system first.
Identifying the generator version, the contract for old and new versions living together, and migration and rollback per version are all still unimplemented. The determinism guaranteed today holds only while the input and the code are fixed, and that is different from an identity contract that lasts. The constraint on splitting scores in Game Center and Play Games has to be checked again if the platforms or the configuration change.
---
# Ordering ad providers by GeoIP region without the location permission
> When ad providers differ by region, an app needs a broad region, not coordinates. Consent first, then a server-side region bucket decides the provider order.
- Canonical: https://jaemyeong.com/en/blog/geoip-ad-routing-without-location-permission/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Android, #Advertising, #Privacy, #GeoIP
When the ad providers an app can use differ by region, the app has to know which region the user is in. The information it needs is not coordinates. A country, or an area broader than that, is enough. Asking for precise location access just to pick an ad is a heavy cost for both the user and the app.
Below is a design that does not use the OS location permission. It decides the order of providers from a region that the server estimates from the IP address. It explains what each signal means and where it can be wrong. This post has no record of observing or measuring a real failure, and it does not give the versions of the SDKs and services. Only the example function that decides the order was run, and the result is in the section "What running the example function showed."
What the official documentation explains directly is how the location permission, the CDN location headers, and the UMP gate each behave. Looking up the region after consent, the per-process cache, the split between the resolver and the loader, the provider chain, and the expected values in tests are not requirements of the documentation. They are my own design choices made on top of it.
## Four signals that hint at a region
Signals that look like a region mean different things.
- The app language is a preference for a language that is comfortable to read.
- The country in the Locale is a setting the user chose.
- OS location services give a value measured by the device, and they come with permissions and the handling of sensitive data.
- GeoIP is a region estimated from an IP address observed from outside.
Using Korean is not enough to choose a domestic provider. While traveling, or when the setting was never changed, the Locale and the current place can differ. GeoIP can also be wrong or empty because of a VPN, a relay service, or a carrier gateway. And the server that does the lookup sees the IP address.
So the signals are considered from the least precise up. The Locale is used only as a default for language and policy, not as the basis for whether a provider is available in a region. GeoIP is requested separately, only when a regional branch is needed, and when there is not enough to decide, the result stays `unknown`. For a feature that really needs location, the context and the minimum permission level are weighed against [Apple's guide to location authorization](https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services) and [Android's guide to minimizing permission requests](https://developer.android.com/privacy-and-security/minimize-permission-requests).
## Consent first, region second
The order at app start is as follows.
1. Refresh the stored consent state.
2. Handle the consent UI if it is needed.
3. Check the gate that says ads may be requested.
4. Resolve the region once per process and keep it in memory.
5. Build the provider array for the region bucket.
6. Request in the order of the array.
7. If everything fails, end with in-house content.
An example of the gate is `canRequestAds` in [Google UMP](https://developers.google.com/admob/ios/privacy). Being allowed to request ads and being able to trust the region estimate are separate matters.
When consent has not been confirmed, or external ads are blocked, the region lookup is not made at all. What to show in that case is decided by the product and its policy, but external ads must not be called through a side path.
Initialization being called twice has to be prevented as well. Initialization can be entered both right after the consent refresh and from the completion callback of the consent UI. Reusing the region task in progress, or the plan already built, reduces duplicate requests and races.
## The server returns only a bucket
The location headers that a CDN adds are based on the IP address the server observed. [CloudFront request headers](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html) are one example. The city value can be missing, and names that are not ASCII can arrive encoded. If every app parses raw city values and keeps its own policy, the work is duplicated, so the server builds the region bucket and sends that down.
There are three buckets. `regionalMarket` is a candidate for trying the regional provider first, `otherMarket` is the target of the global provider, and `unknown` means the region could not be classified. The response does not include coordinates, the IP address, or the city name. If branching by city is truly required, only an identifier used by the business rule is sent. A missing header, a decoding failure, or a validation failure gives `unknown`.
The CDN cache needs attention too. If the response varies by region but the header is not in the cache key, the result for one region can be reused for another. Conversely, splitting the key too finely lowers the cache hit rate. The policy states either that the response is not cached, or that it is cached by the coarse bucket the server built.
## Separate region resolution from ad loading
The region resolver has no dependency on an ad SDK, and the ad loader has no dependency on the IP address or the Locale. The two are connected only through enums.
This example checks the consent state first and returns an array of provider candidates for the region bucket. The function does not load ads. It only builds the array.
```swift
enum ConsentState {
case pending
case adsAllowed
case externalAdsBlocked
}
enum AdRegion {
case regionalMarket
case otherMarket
case unknown
}
enum AdProvider {
case regional
case global
case inHouse
}
func makeAdPlan(
consent: ConsentState,
resolveRegion: () async -> AdRegion
) async -> [AdProvider] {
switch consent {
case .pending, .externalAdsBlocked:
return [.inHouse]
case .adsAllowed:
break
}
switch await resolveRegion() {
case .regionalMarket:
return [.regional, .global, .inHouse]
case .otherMarket, .unknown:
return [.global, .inHouse]
}
}
```
The example turns one policy into code. Returning `[.inHouse]` when consent is not finished or is blocked, and trying the global provider for `unknown`, are not general rules. Whether to try the global provider for `unknown` depends on the regions the service supports and on the consent conditions. That choice is not hidden inside the resolver. It is made visible in the chain policy.
The function itself has no guard against running twice, no network timeout, and no loader. Running once and caching the result is implemented by the caller, or by the resolver reusing its task. The loader tries the array in order and stops at the first success. It does not call a failed provider again right away, and it does not restart the whole chain. `inHouse` is the last option that keeps the slot from being empty when every external request fails. When a provider is added, only the chain changes and the resolver is left alone.
iOS, Android, and the web can share the contract of buckets and priority. The implementation modules do not have to be merged. Each platform uses its own SDK and keeps only the contract `ConsentState → AdRegion → [AdProvider]`.
## What running the example function showed
I compiled `makeAdPlan` above as it is and ran every combination of the three consent states and the three region buckets. It ran on macOS 27.0.1 with Swift 6.4, on October 4, 2026. The run also prints how many times the closure that returns the region was called.
```text
pending + regionalMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + otherMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + unknown -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
adsAllowed + regionalMarket -> [geodemo.AdProvider.regional, geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + otherMarket -> [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + unknown -> [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
externalAdsBlocked + regionalMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + otherMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + unknown -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
```
`geodemo` is the module name of the executable. With consent `pending`, or with external ads blocked, the region lookup never happened and only the in-house provider came out. The lookup happened once only when ads were allowed, and the regional provider came first only for the regional bucket.
What this run confirmed stops at the function that decides the order. A real GeoIP lookup, an ad SDK, the loader that tries providers in order, the timeout, and the cache were not run.
## Keep going when the lookup fails
The network request for the region lookup is restricted.
- Only the specified HTTPS target is allowed, and unexpected redirects are blocked.
- The timeout is a few seconds at most, and the request is not repeated while the screen is rendering.
- The response size is limited, and only bucket values on an allow list are accepted.
- A response that does not fit is treated as `unknown`.
- The result and the request in progress are reused within the process.
- The raw IP address and city are not stored on disk and not written to analytics logs.
An HTTP error, a timeout, being offline, and a parsing error are all handled as ordinary failure values. Concrete numbers for the timeout and the response size are not set here.
The in-memory cache has a limit. After the network changes, the previous bucket can still be used. I judged this acceptable for a coarse priority hint, and the region is evaluated again when the process next starts. No measurement that backs this judgment is included here. Until measurement shows that a long-lived cache is needed, permanent storage and expiry handling are not added.
## No permission does not mean anonymous
Without the OS location API, the location permission prompt does not appear. But the GeoIP server and the CDN still process the IP address. Describing "no permission needed" as "anonymous" or "no location data is handled" is not accurate.
The purpose, the retention, the transfer to third parties, and the relation to consent have to be reviewed against the real data flow. The app receives only the bucket, the server does not keep the IP address and location headers longer than needed, and the value used for routing is not used for a long-term profile. Collecting less and complying with the law or store policy are separate things. [Apple's privacy guidelines](https://developer.apple.com/design/human-interface-guidelines/privacy) are a reference from this point of view.
## What to pin with tests
Calling the real GeoIP service and ad SDK can give a different result on each run because of VPNs, network state, and ad inventory. Unit tests inject the region function and the loader and pin the expected values below.
- If consent is `pending`, there are 0 region lookups and in-house is chosen.
- If ads are allowed and the bucket is regional, the regional provider comes first.
- If the regional provider fails, the chain moves to global once.
- On a timeout or a response error, global is chosen through the `unknown` path.
- If every external request fails, the chain ends with in-house.
- If the consent completion callback arrives twice, the lookup and the plan are each made once.
In an integrated environment, the conditions to check are offline, a slow response, a missing header, an encoded city value, a VPN, and Private Relay. The pass criteria are that only allowed provider combinations appear, that the flow ends within the time limit, and that raw sensitive values do not show up in logs. How often the real location is guessed correctly is not a pass condition.
The goal of this design is that the app does not stop even when GeoIP is wrong. The expected values and the integration conditions above are the criteria for checking that goal. Of these, what a run confirmed is that the function that builds the plan produces only allowed combinations. Results that confirm a real lookup ends within the time limit, and how the loader behaves, are not in this post.
---
# Keeping and revoking a one-time remove-ads purchase on iOS and Android
> A remove-ads purchase looks final, but reinstalls, pending payments, and refunds make the purchase result alone unreliable. Read current entitlements instead.
- Canonical: https://jaemyeong.com/en/blog/one-time-remove-ads-entitlement-storekit-play-billing/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Android, #StoreKit, #Google Play Billing, #In-App Purchase
I added a product that removes ads permanently to the iOS and Android versions of DailySudoku, a Sudoku app. At first it looked as if storing whether the purchase succeeded would be enough. But the result right after the purchase turned out to be a poor basis for judging the state later. The app can be reinstalled, a payment can stay pending, and a purchase can be refunded.
The source baseline is commit 9c999305 on the develop branch as of August 6, 2026. That is separate from anything confirmed in a release on the stores. Below, the behavior of that source, what the Apple and Google documentation says, and what the automated tests cover are kept apart. That is as far as this post goes. Records of paying and refunding on real devices, and run logs, are not in it.
## What is missed when only the purchase result is stored
A purchase notification is only one of the inputs. Ownership is refreshed by reading the current list of entitlements. Storing only the purchase result misses the following.
- The user canceled the payment.
- A pending payment completed after the app was closed.
- The purchase was made on another device.
- The data was reset or the app was newly installed.
- A refund, a cancellation, or a revoke happened.
- The connection dropped before the result arrived.
So three inputs are merged into one entitlement state.
```text
구매 이벤트 ───────┐
앱 시작·복귀 조회 ─┼─> 검증된 현재 권한 ─> 로컬 캐시 ─> 광고 게이트
스토어 외부 변경 ──┘
```
From top to bottom, the three inputs on the left are the purchase event, the query at app start and return, and a change made outside the app in the store. They lead to the verified current entitlement, then the local cache, then the ad gate.
Each input has its own job. The purchase notification is used to tell the user right away. The query at app start and on return to the foreground recovers missed changes and changes from other devices. Updates carry changes while the app is running. All three end up sharing the same `adFree` value.
## The local value is a cache
The Boolean stored on the device is a cache for drawing the screen, not the source of truth for ownership. Both the snapshot and the updates write true and false as they are. After a refund, false goes in and ads are allowed again.
There is one ambiguity here. `adFree == false` can mean "not owned," and it can also mean "not queried yet." If the two cases are not told apart, a user who bought the product can see an ad right after launch. So whether the query has finished is marked separately with `entitlementsSynced`.
The condition that allows ads is computed from three values.
```text
adsEnabled = entitlementsSynced && !adFree && consentAllowsAds
```
While `entitlementsSynced == false`, no ad is requested. A user who has not bought enters the ad path after the first restore finishes, and a user who has bought stays blocked because `adFree == true`. The settings, home, difficulty selection, game, and statistics screens share this decision.
How the ad objects are handled differs by platform. When blocked, the Android side does not create the Compose subtree at all and releases the native view that was attached. On iOS, the banner is collapsed and further loads are suppressed. Whether an already loaded provider is torn down on iOS is not verified by automated tests. The ad disappearing from the screen is not enough to say that the SDK has stopped its background work.
## iOS: what is read from StoreKit 2
A purchase is handled according to the result of [Product.purchase()](https://developer.apple.com/documentation/storekit/product/purchase(options:)).
- For `.success(.verified(transaction))`, the entitlement is granted and [the transaction is finished](https://developer.apple.com/documentation/storekit/finishing-a-transaction).
- For `.success(.unverified(...))`, the entitlement is not granted.
- For `.pending` and `.userCancelled`, it is not granted either.
Changes while running are observed through [Transaction.updates](https://developer.apple.com/documentation/storekit/transaction/updates). Ask to Buy, a purchase on another device, and a revoke arrive there. At app start and on return to the foreground, [Transaction.currentEntitlements](https://developer.apple.com/documentation/storekit/transaction/currententitlements) is checked for the product ID of the remove-ads product and for a verified result. According to the documentation, this list holds the latest entitlement for a non-consumable, and items that were refunded or revoked are left out. If the query finishes with no matching product, false is stored.
On a reinstall or a new device, the latest transaction is provided automatically, so in normal use the `currentEntitlements` query is enough. A manual restore runs only when the user chooses restore in settings. At that point [AppStore.sync()](https://developer.apple.com/documentation/storekit/appstore/sync()) is called and the entitlements are queried again. That call can show an authentication prompt, so it is not run automatically at app start.
The price is refreshed separately. The ownership flow and the consent flow do not need to wait for the price response.
## Android: what is read from Play Billing
The product type on Android is INAPP. [One-time products](https://developer.android.com/google/play/billing/one-time-products) have no separate API for non-consumables, so the purchase is acknowledged and not consumed.
The flow follows [the Play Billing integration guide](https://developer.android.com/google/play/billing/integrate). `queryProductDetailsAsync` returns the products that can be sold to that user and the localized price, and `launchBillingFlow` is called with the returned `ProductDetails` and the offer token. The purchase arrives through `PurchasesUpdatedListener` or is found by a later `queryPurchasesAsync`.
The entitlement is granted only when the product ID and the purchase state are checked and the state is `PURCHASED`, and since the purchase is not consumed, it is acknowledged. `PENDING` is not a confirmed failure. It is a state waiting for completion, such as a cash payment or an additional approval. Google's guidance is not to grant the benefit while the state is `PENDING` and to handle it after it turns `PURCHASED`. The state can change while the app is closed, so `queryPurchasesAsync()` in `onResume()` recovers missed changes.
Verification and acknowledgement are different jobs. Verification checks that the purchase is legitimate and has not been handled already. Acknowledgement tells Play that the benefit was delivered. According to the documentation, a normal purchase can be refunded automatically if it is not acknowledged within 3 days after turning `PURCHASED`, and for a purchase by a license tester that window is 3 minutes.
What the current implementation trusts is narrow. It checks `PURCHASED` and the product ID as reported by BillingClient, and acknowledges on the client. It does not verify the purchase token on a server. [Google's billing security guide](https://developer.android.com/google/play/billing/security) recommends verifying with the Google Play Developer API on a secure backend before granting the benefit, and acknowledging on the server as well. For now the setup accepts trusting the client, and it cannot be described as having completed server verification.
## Restore, refunds, and failed queries
The purpose of a restore is not to find out whether something was bought in the past. It is to make the cache match the current snapshot. At start and on return, the Android side queries with `queryPurchasesAsync(INAPP)`, and changes while running arrive through the listener or are recovered by the query on the next resume. A manual restore also queries the current inventory again.
The moment a refund takes effect differs by platform. On iOS it is reflected in an update or a later snapshot. On Android the value becomes false when the next inventory query succeeds.
An empty result and an error have to be told apart. The product not being there and the store being unreachable are different events. Right now, when the connection or the inventory query fails on Android, `adFree` is left as it is and `entitlementsSynced = true` is set. This policy has a cost in both directions. A buyer who reinstalled has a cache of false and can see ads until the state is recovered. A user who got a refund can stay blocked from ads until a query succeeds.
The iOS model has a gap too. With no verified matching item the value becomes false, but an unverified case and a temporarily empty false negative are not classified separately. The failure policies of the two platforms differ, and neither is a guarantee of safety.
The improvement I have in mind is to split the state into `unknown`, `owned`, and `notOwned`, or to tell an error apart from a successful empty result. On top of that, each product decides between fail-open and fail-closed. This is still a proposal, not a fix that has been made.
Restores that run at the same time need checking as well. Five asynchronous paths write to the cache: purchase, update, start, resume, and manual restore. The issue is the order in which the start query and the foreground query finish, and locking the connection alone does not serialize the whole reconcile. A past snapshot that arrives late can overwrite a newer value.
## When the price cannot be fetched
The price shown is the localized value from the store, and no fixed price is written on the screen. On both platforms, the settings screen hides the remove-ads row when the price is nil. But the manual restore control is on the screen opened from that row.
As a result, when the price query fails, the entry point for retrying by hand disappears. Automatic recovery at start and resume keeps running. The screen also cannot tell a product that is not registered from a temporary network failure.
The improvement is to disable only the buy button and show restore separately, and to tell an empty product response from an exception in retries and in monitoring. This too is a proposal.
## Ways to test and expected results
On the Apple side, [StoreKit configuration in Xcode](https://developer.apple.com/documentation/storekit/testing-in-app-purchases-in-xcode) allows local tests of transactions, interrupted purchases, refunds, and revokes. Flows that involve an account are checked in Sandbox or TestFlight.
With a [license tester](https://developer.android.com/google/play/billing/test), the real purchase flow and test payment methods can be exercised on Android. If the package name and the tester account conditions are met, a debug-signed sideloaded build can be tested too, and the test tracks are for checking before release. It is not correct to say flatly that a direct ADB install always fails or that only an install from Play works.
Below are the results I intend to confirm. They are not a record of runs that passed.
- New purchase: ad requests stop, the state holds after a relaunch, and the product cannot be bought again.
- User cancellation: no entitlement and no success message, and the purchase can be tried again.
- Approval after pending: no benefit while pending, and one grant after `PURCHASED`.
- Cancellation after pending: no benefit is granted.
- Already owned: ownership is recovered on a new device, after a reinstall, and by manual restore.
- Completed outside the app: recovered by an update or the next resume.
- Lost connection: a successful empty response and a query error are told apart.
- Failed acknowledgement: retried and completed within the allowed window.
- Refund and revoke: the cache becomes false and ads are allowed again.
- Fast app switching: repeated background and foreground does not let a past query overwrite the value.
The automated tests cover little. On iOS they cover storing false on a restore with nothing owned, and the price refresh. A hosted unit test cannot cover the UI path of a real successful purchase. The purchase and restore in `PlayBillingProvider`, the pending notice, a failed acknowledgement, the Settings row, and the full truth table of the ad gate are missing from the automated tests on the Android side. Passing local tests is not enough to say that real store payments were confirmed.
What remains is server verification on Android, the screen for the pending state, the error model, concurrent reconcile, the restore entry point, and checking the store scenarios on real devices. The 3-day and 3-minute acknowledgement windows, the offer token condition, and the eligibility of a debug-signed sideload are what the documentation said on the baseline date, so they have to be checked again in the official documentation when a policy or a library changes.
---
# Verifying a verification script with deliberately broken input
> A check script that exits with status 0 has not necessarily checked the rule. Break one thing in a copy of a good sample and confirm the expected category.
- Canonical: https://jaemyeong.com/en/blog/verification-script-negative-control/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Testing, #CI-CD, #Automation, #Troubleshooting
Automated checks before a release can all pass while the output is still missing a required file, or holds the same content for locales that should differ. This happens with scripts that check the resources and metadata of a cross-platform app. The checks ran to the end, yet the rule they were meant to protect was never examined.
Whether a check script really catches a defect only shows when it is given input that has one. This post goes through putting a single defect into a copy of a good sample and confirming that the check fails with the expected category. Combinations of platform builds and tests are out of scope. I ran the example with a small validator on Python 3.14.5, and the result is written below the example.
## What exit status 0 does not tell you
There are several ways for a check to skip the rule and still end in success.
- A glob does not match, so there are 0 targets.
- A missing tool or environment variable is treated as success.
- The generating side and the checking side share the same wrong default.
In each case the exit status can be 0, so the value 0 alone cannot tell whether the rule was checked. [The pytest exit codes](https://docs.pytest.org/en/9.0.x/reference/exit-codes.html) separate these two as well. The code is 0 when all collected tests pass, and 5 when no tests were collected.
I suggest making the same distinction in a check script you write yourself. Failing to find a required output is a problem with the input contract, and a missing tool or input in a required CI job is a problem with the setup. Skipping an optional check is a separate state with the reason recorded. The candidate states are pass, validation-failure, invalid-setup, and skip. These four are my own design, not something the pytest documentation defines.
Judging a failure needs more information than the exit status.
- `category` or `rule_id`
- The number of inputs processed and the number of required checks
- Exit statuses that separate environment errors from contract violations
- A distinction between an empty target set and an allowed skip
If the only requirement is "it exited abnormally," a syntax error or a permission problem can be miscounted as catching the defect. For a sample that breaks the image size rule, the expected category is `wrong_size`. An `internal_error` caused by failing to read the file is not evidence that the rule worked. CI compares against a stable category value, and the descriptive message is for investigating the cause.
## A sample with exactly one defect
In this post, a negative control means a defective sample that has what it takes to be rejected. The term is not carried over as defined in statistics or experimental fields. Put the disallowed value `dark` into a copy of the good data and require `invalid_value`, and everything from reading the input to reporting the error is checked in one go.
A sample is made by copying a reviewed fixture to a temporary location, changing it, and discarding it afterwards. The files that actually ship are not touched. If good data is rejected, or defective data is accepted, the check has not met the conditions for being trusted.
Putting a contract violation into a good sample is what this post calls a mutation. Each run changes one rule only. Examples are inserting the disallowed value `dark`, deleting a required file, making the content of two locales identical, and changing the size information of an image. With several defects at once it is hard to tell which one was caught, and a tool that stops at the first failure never reaches the later rules. So one mutation is paired with one expected category.
Tools such as [Stryker](https://stryker-mutator.io/docs/) and PIT also use the word mutation. They change the program code and watch whether the tests react. In [Stryker's description of states](https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/), a mutant is killed when at least one test fails and survived when all tests pass. [PIT's basic concepts](https://pitest.org/quickstart/basic_concepts/) describe equivalent mutations, changes that do not alter behavior. Tests cannot detect those. The same holds when input is changed by hand, so the cases chosen have to really break the contract. Up to here the content is from the documentation, and carrying the idea over to checks on input files is my own reading. The idea of trusting a test because it was seen to fail is also in [a Google Testing Blog post](https://testing.googleblog.com/2007/04/tott-refactoring-tests-in-red.html).
## An example that confirms the baseline first
Before any defective sample goes in, the good sample has to pass. The exit status must be 0 with 0 failures. Without this condition, an abnormal exit caused by a defect that was already there can be counted as catching the new one.
The example assumes that the JSON result of the validator contains the `category` of each entry in `failures`, and `checked`. Exit status 0 is a pass, 1 is a contract violation, and anything else is a setup or internal problem. There are 4 input contracts. The first part is the function that runs the validator and reads the result, followed by the check of the good sample.
```python
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
FIXTURE = Path("validator-fixture")
PASS = 0
VALIDATION_FAILED = 1
REQUIRED_CHECKS = 4
def validate(root: Path) -> tuple[int, set[str], int]:
result = subprocess.run(
["python", "validate.py", str(root), "--json"],
check=False,
capture_output=True,
text=True,
)
report = json.loads(result.stdout)
failures = {item["category"] for item in report["failures"]}
return result.returncode, failures, report["checked"]
baseline_code, baseline_failures, baseline_checked = validate(FIXTURE)
if baseline_code != PASS or baseline_failures or baseline_checked != REQUIRED_CHECKS:
raise SystemExit(
f"invalid baseline: checked={baseline_checked}, "
f"failures={sorted(baseline_failures)}"
)
```
The next part applies each of the four mutations to a copy in a fresh temporary directory. It uses the names from the block above.
```python
cases = [
("invalid-value", "invalid_value"),
("missing-file", "missing_file"),
("duplicate-content", "duplicate_content"),
("wrong-size", "wrong_size"),
]
if not cases:
raise SystemExit("no mutation cases")
for mutation, expected_category in cases:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp) / "fixture"
shutil.copytree(FIXTURE, root)
subprocess.run(["python", "mutate.py", mutation, str(root)], check=True)
code, failures, checked = validate(root)
introduced_failures = failures - baseline_failures
if code != VALIDATION_FAILED or checked == 0 or expected_category not in introduced_failures:
raise SystemExit(
f"survived mutation: {mutation}, checked={checked}, "
f"failures={sorted(failures)}"
)
```
When the list of mutation cases is empty, the script fails before it enters the loop. The failures after the mutation, minus the failures of the baseline, are the newly introduced failures. A case passes when the exit status is 1, the number of checks is not 0, and the introduced failures contain the expected category. In this example the baseline failures have to be empty to get this far, so the set difference equals the set of failures after the mutation. A case still passes when other categories appear alongside the expected one.
When a condition is not met, the script raises `SystemExit`. It does not use `assert`, because [Python's `-O` option](https://docs.python.org/3/using/cmdline.html#cmdoption-O) removes assert statements. The CI decision is made with explicit condition branches.
`validate.py`, `mutate.py`, and the fixture itself are not in this post. For the run I wrote a small separate validator with four rules. With the normal validator, all four mutations were detected. When I changed the validator to report the expected category while performing 0 checks on a mutated copy, the version without the `checked == 0` condition ended as a pass, and the version with it ended with `survived mutation`. That condition and the check for an empty list were added to the original example. Handling of JSON that cannot be parsed, and separate handling of environment errors, are not in the example either.
## Keeping the judgment apart from the generator
If the generating code and the checking code use the same decision function, or the fixture is built only from the latest generated output, the same error can be hidden on both sides. Sharing a neutral schema is fine. The criteria for allowed values, sizes, and uniqueness come from the written contract and from material a person has reviewed.
In CI, I suggest keeping one defective sample per core rule and running them when the validator or the discovery code changes. The order is this.
1. Confirm that the tool and the fixture exist.
2. Confirm the state of the good sample and the minimum number of checks.
3. Apply one mutation to each copy.
4. Confirm that the expected category was detected.
A required CI job fails when a tool or input is missing, when the number of tests is 0, when the number of checks is 0, or when there are 0 mutation cases. Only skipping an optional item is allowed, with a reason. When a defect goes undetected, look at how files are discovered, how errors are reported, and how the expected category is set. Keeping the baseline result and the expected and observed categories as CI artifacts makes that investigation easier.
A few reviewed samples are enough to start. The example compares only the set of `category` values, though, so it cannot tell apart several failures of the same category in different places. When the location of a failure matters, use the combination of `rule_id` and a normalized relative path. The CI procedure looks at whether the number of checks is at least a minimum, while the example checks that the number for the good sample equals 4. The number of checks on a mutated copy is only tested for being 0 and is not compared with a minimum. A change that does not alter behavior cannot be detected by this method either.
---
# Who watches the watcher: finding a scheduled job that vanished
> A deleted scheduled job leaves no error. Compare the expected roles with what the scheduler really holds, and alert on the time of the last success.
- Canonical: https://jaemyeong.com/en/blog/watch-the-watcher-retry-cron-reconciliation/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #CI-CD, #Automation, #Observability, #Reliability, #Cron
While checking the automation of an app, I once found the records and reality out of step. The job ledger said a retry job had been registered, but querying the scheduler returned only one gate poller. The gate kept doing its work, so the missing retry did not show. There was not enough evidence to pin down when or why it had been deleted.
A scheduled job that has been deleted cannot report its own failure. This post goes through a state comparison for finding that kind of gap, and a setup in which the monitoring does not stop together with what it monitors. Cron here is not only the Unix crontab. It also covers the GitHub Actions schedule and the Kubernetes CronJob. The original query output and logs, and measurements after a repair, are not included. The versions of the scheduler and tools used at the time, and the environment they ran in, were not recorded either.
## Zero errors is not zero runs
Different kinds of failure leave different traces.
- An error during a run leaves a start record, the error, and a failure count, and the job can retry from the inside.
- A run that stalls partway shows up as a start record with a stale heartbeat. Whether it can recover from the inside depends on conditions.
- An object that was made inactive is still in the scheduler but cannot recover from the inside.
- A deleted object, or a stopped scheduler, sends no report from the inside at all, and cannot recover from the inside either.
So zero errors alone cannot be read as healthy. The possibility of zero runs has to be ruled out first.
## Roles in the ledger, real objects in the query result
An ID written in the ledger is only a reference for looking things up. It is not evidence that the object is registered now. The desired state holds the role, the schedule, and the command, and the actual state holds the objects that were returned and whether they are active. Recreating an object can change its ID, so the comparison uses role keys such as `retry` and `gate`. Putting a version on the schedule and on the run template also helps detect outdated settings. The pseudocode for comparing the sets of roles is this.
```text
expected = desired_jobs_by_role
actual = scheduler.list()
missing = expected.roles - actual.roles
duplicates = actual.group_by(role).where(count > 1)
mismatched = actual.where(spec_version != expected.spec_version)
```
It states what the comparison means, and a result of running it is not in this post. The pseudocode alone misses two cases. First, comparing only the version does not catch someone editing the schedule or the command without bumping the version. The actual schedule and command, or a hash of the whole desired specification, have to be compared for each role. Second, an object whose role is not in the expected list is neither missing nor a duplicate, so it would stay. `actual.roles - expected.roles` needs its own category, with an alert to investigate ownership and no automatic deletion.
One desired state is matched against four observed stages.
- desired: the expected roles in a versioned specification
- registered: whether the real object exists, and its enabled or suspend state
- executed: the last start time and the run history
- succeeded: a last success within the time limit
- progressed: a checkpoint, the queue depth, the completion time
Each stage tells you something different. According to [the Kubernetes CronJob documentation](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/), when `.spec.suspend` is true, later runs stop even though the object exists. A run can also stall or fail after it starts. Even when the success time is recent, only a checkpoint tells whether the input was empty or a condition was wrong. I suggest obtaining the list of real objects and the last success time first, and widening to business metrics when the need comes up.
## What monitoring inside the same scheduler cannot see
The structure in the case I first saw had a flaw. The relation ran one way, with retry repairing the other poller, while that poller only did its own work. If retry is deleted, nothing is left to do the repair. Making the two check each other does not help against a failure that hits both in the same scheduler.
Comparing when a session restarts or when settings change provides a basic safeguard. It leaves a gap between one check and the next, though. When detection has to be continuous, an outside dead-man monitor checks the time of the last success. The outside monitor and the delivery of its alerts are things to check as well. The aim is to run the monitoring and what it monitors on separate foundations, so that one stopped scheduler does not stop both.
[The monitoring chapter of the Google SRE book](https://sre.google/sre-book/monitoring-distributed-systems/) calls monitoring based on metrics exposed by the internals of a system white-box, and testing externally visible behavior as a user would see it black-box. Seen this way, the internal list, the run history, and the heartbeat are white-box. My reading is that it is black-box only when a separate system directly checks the end result from the user's point of view, and that installing the monitor outside does not by itself make it black-box.
## The order for comparing and repairing
The repair is split into five steps.
```text
observe → classify → re-read → repair → verify
```
Query the list and the active state, then classify the result as missing, duplicate, inactive, drift, or unexpected, which is a role that is not in the expected list. Query once more to filter out races and temporary delays. After that, create only what is missing from a version-controlled literal template, check the role, the schedule, the command, and the spec version again, and update the ID in the ledger. The goal is one object per expected role. Several objects with the same role raise a duplicate alert, and a role that is not in the expected list raises an unexpected alert. Either way, an object whose ownership is unknown is not deleted.
Automatic repair is limited to missing objects whose ownership and specification are certain. The other states are handled like this.
- inactive: check the change history, then activate it explicitly.
- duplicate: alert on the risk of concurrent runs, and have a person look into the ownership.
- drift: check the expected version and who made the change.
- unexpected: have a person find out who created it and why. It is not deleted automatically.
An inactive state or a drift that someone made on purpose must not be overwritten. This comparison runs when a process starts or resumes, and right after settings change. When detection has to be faster, add a periodic check from outside.
## Alerting on the time of the last success
[The Prometheus instrumentation guide](https://prometheus.io/docs/practices/instrumentation/) says the key metric of a batch job is the last time it succeeded. The same document says that to track the time since something happened, you export the Unix timestamp at which it happened, not the time since. The elapsed time is calculated at query time. The condition for whether the time since the last success of retry has passed the threshold is this.
```text
time() - job_last_success_timestamp_seconds{job="retry"} > stale_after_seconds
```
As a starting threshold I suggest twice the run interval plus the longest expected run time. [The Prometheus alerting guide](https://prometheus.io/docs/practices/alerting/) also says to page when a batch job has not succeeded recently enough, and that this should generally be at least enough time for 2 full runs. The value is not a number settled by measurement. It is adjusted by how much the run time varies and how much detection delay is acceptable. A normal success that comes one interval late has its alert held back within a grace window, and a run that passes its timeout after starting is classified as a stale execution or a hang.
The case where the series does not exist at all is looked at separately. [The PromQL functions reference](https://prometheus.io/docs/prometheus/latest/querying/functions/) has `absent()` and `absent_over_time()`. According to [the Pushgateway guide](https://prometheus.io/docs/practices/pushing/), the Pushgateway does not remove the series it received on its own. It keeps exposing them until they are deleted by hand, so a stale success value can remain. That is why a missing series and a stale success time are told apart.
- When the object is missing, look at registration, the comparison process, and the real list.
- When the success metric is missing, look at instrumentation, collection, the first run, and scrape or push.
- When the success time is stale, look for delay, failure, or a stall in the history and logs.
Where to look also depends on the observed stage.
- With no recent start, look at the trigger and the scheduler.
- With a start and no success, look at the logs and the timeout.
- With a recent success and a checkpoint that has stopped moving, look at the input and the business conditions.
- With no heartbeat from the watcher, look at the outside probe and the alerts.
What a scheduler guarantees also differs by product. According to the Kubernetes documentation, a CronJob creates a Job about once per scheduled time. In some circumstances two Jobs are created, or none, so Jobs should be idempotent. `startingDeadlineSeconds` and `concurrencyPolicy` are settings that control delay and concurrent runs, and they do not guarantee exactly-once execution. According to [the GitHub Actions schedule documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule), the schedule event can be delayed during periods of high load and queued jobs may be dropped, and it runs only when the workflow file is on the default branch, and only on that branch. Whatever the product, the things to look at in common are the object, the run history, and the success time.
The part that compares and classifies sets can be checked with unit tests. Real delays, duplicates, and gaps are things to check in staging or in an isolated production check. Results of running them are not in this post. Why the first gap happened was not settled either. Comparing intermittently leaves a gap in detection, and the outside monitor and its alerts can fail too. There can be a delay before a newly created object shows up in a query. Querying again right after creation can still show it as missing, and a second object would then be created. To avoid a duplicate, wait with a bounded retry until the object is visible, or make creation idempotent by using the role key as a unique name.
---
# 스도쿠 81칸을 VoiceOver와 TalkBack에 알리는 방법
> DailySudoku 게임판은 화면에서는 뷰 하나지만 보조 기술에는 81개 칸을 따로 알려야 해요. iOS와 Android에서 칸의 위치·내용·선택 동작을 맞춘 방식과, 아직 실기기로 확인하지 못한 부분을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/adaptive-accessible-sudoku-board/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #Accessibility, #UIKit, #Jetpack Compose, #iOS, #Android
제가 만드는 스도쿠 앱 DailySudoku의 게임판은 숫자와 메모를 보여 주는 9×9 정사각형이에요. 화면을 보면서 손가락으로 누르는 사람에게는 터치 좌표만 칸 번호로 바꿔 주면 돼요. 하지만 VoiceOver나 TalkBack으로 쓰는 사람은 초점이 어느 칸에 있는지, 그 칸에 무엇이 들어 있는지, 선택된 칸인지, 어떻게 활성화하는지를 소리로 알아야 해요.
이 글은 2026-08-06 소스를 기준으로 iOS와 Android 구현을 나란히 비교한 내용이에요. 접근성을 다 맞췄다는 보고가 아니에요. 어떤 기준으로 설계했고 무엇이 아직 검증되지 않았는지를 적어요.
## 칸 하나가 알려야 하는 정보
화면은 iOS에서 UIView 하나, Android에서 제스처 컨테이너 하나로 구현했어요. 그래도 보조 기술에는 칸 81개를 하나씩 내보내요. 칸마다 필요한 정보는 이렇게 잡았어요.
- 위치: 1부터 세는 행 번호와 열 번호
- 내용: 빈칸, 처음부터 주어진 숫자, 사용자가 넣은 값, 오류, 메모
- 상태: 선택 여부
- 동작과 영역: 칸 선택, 그리고 화면의 실제 칸과 겹치는 사각형
`game.cell.r0c0` 같은 identifier나 test tag는 자동화 테스트가 칸을 찾을 때만 써요. 사용자가 듣는 설명은 이 표지와 분리해서, 위치와 내용을 담은 지역화된 문장으로 제공해야 해요. 예를 들면 2행 7열에 숫자 4가 있다는 정보를 담은 문장이에요.
## 상태는 한 모델에서, 입력은 한 함수로
코어 데이터는 `CellUi` 계열 모델로 옮겨서 써요. 모델이 담는 항목은 다섯 가지예요.
```text
CellUi = value + given + error + notes + highlight
```
화면의 색과 숫자, 좌표를 index로 바꾸는 계산, 보조 기술이 읽을 설명·상태·동작이 모두 이 모델에서 나와요. 두 플랫폼의 뷰 계층을 똑같이 만들 필요는 없어요. 같은 모델에서 같은 정보가 나오면 돼요.
입력도 한곳으로 모았어요. tap, drag, VoiceOver 활성화, TalkBack double-tap은 모두 `selectCell(index)`에서 끝나요. 접근성 전용 로직을 따로 두면 화면 상태와 어긋날 수 있어서 피했어요.
## iOS: 뷰 하나에 접근성 요소 81개
iOS에서는 `BoardView` 하나가 배경, 격자, 숫자, 메모를 직접 그려요. 칸마다 UIView를 두는 대신 [`UIAccessibilityElement`](https://developer.apple.com/documentation/uikit/uiaccessibilityelement) 81개를 만들어요. Apple 문서가 뷰가 아닌 항목을 보조 기술에 노출하는 방법으로 안내하는 방식이에요.
요소마다 넣는 값은 다음과 같아요.
- 배열 순서: 행 우선
- `accessibilityLabel`: 좌표와, empty·given·error·value·notes 가운데 해당하는 설명
- `accessibilityTraits`: button, 선택된 칸에는 selected 추가
- [`accessibilityFrameInContainerSpace`](https://developer.apple.com/documentation/uikit/uiaccessibilityelement/accessibilityframeincontainerspace): 칸의 사각형
- `accessibilityActivate()`: `onSelect(index)` 호출
화면을 회전하거나 Split View로 크기가 바뀌면 frame을 다시 계산해요. 값은 컨테이너 기준 좌표로 직접 넣어요.
확인하지 못한 것이 두 가지 있어요. index 0부터 80까지 만든 순서가 실제 VoiceOver의 swipe 순서나 전체 읽기 순서와 같은지는 따로 봐야 해요. 초점이 있는 칸의 값이 바뀌었을 때 VoiceOver가 곧바로 읽어 주는지도 검증하지 않았어요.
## Android: pointerInput에는 버튼 의미가 없어요
Android에서는 컨테이너 좌표를 9×9 index로 환산하고, tap과 drag에 같은 hit test를 적용해요. 처리한 포인터 변경은 소비해서 바깥 스크롤과 간섭하지 않게 했어요.
문제는 `pointerInput`만으로는 TalkBack이 칸을 버튼으로 다루지 않는다는 점이에요. [Compose의 제스처 처리 단계 문서](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures)는 Button, clickable, pointerInput 순서로 내려갈수록 기본으로 제공되는 semantics와 focus 지원이 줄어든다고 설명해요. 그래서 `BoardCell`마다 설명과 역할, 클릭 동작을 직접 붙였어요.
각 `BoardCell`에 붙이는 semantics예요.
```kotlin
Modifier.semantics {
contentDescription = cellDescription
role = Role.Button
onClick {
onSelect(index)
true
}
}
```
일반 터치의 연속 drag는 보드가 맡고, TalkBack에서 초점이 잡힌 칸의 활성화는 semantics가 맡아요. 셀에 clickable을 달면 보드의 `pointerInput`과 같은 이벤트를 두고 경쟁하게 되는데, 이 구성은 그 경쟁을 피해요.
## 두 플랫폼이 아직 다른 곳
소스를 비교한 결과를 표로 옮기면 이래요.
| 항목 | iOS | Android |
|---|---|---|
| 칸별 독립 노출 | 있음 | 있음 |
| 위치·내용 설명 | 있음 | 있음 |
| 버튼 활성화 | 있음 | 있음 |
| 자동화 표지 분리 | 있음 | 있음 |
| 사용자 입력 값 | 숫자로만 읽음 | input으로 구별 |
| 선택 상태 | `.selected` | 없음 |
| collection 구조 | 없음 | 없음 |
Android에는 selected나 stateDescription이 없어요. 선택한 칸의 배경색은 바뀌지만 TalkBack에는 선택 상태가 전달되지 않아요. `collectionInfo`와 `collectionItemInfo`도 쓰지 않아요. [Compose semantics 문서](https://developer.android.com/develop/ui/compose/accessibility/semantics)가 설명하는 collection 정보는 전체 크기와 현재 위치를 알려 주는 용도인데, 지금은 양쪽 모두 9행 9열 구조를 칸의 설명 문장으로만 전달해요.
다음 순서는 이렇게 정했어요. Android에 선택 상태를 넣고 양쪽 발화를 직접 들어 보는 일이 먼저예요. 9×9 collection 정보는 지금의 행·열 설명과 겹쳐서 두 번 읽히지 않는지 확인한 뒤에 검토해요. custom rotor는 81칸을 한 줄로 넘기는 탐색이 실제로 불편하다는 근거가 생기면 고려해요.
## 칸이 작다는 문제
보드 크기에는 상한을 뒀어요. iOS는 560pt, Android는 520dp예요. Android는 너비가 840dp 이상이면 보드와 조작부를 가로로 배치하고, 폴더블 tabletop 자세에서는 hinge를 기준으로 위아래 영역을 나눠요.
어느 크기에서든 그림, hit test, 접근성 칸은 같은 index를 가리켜야 해요. 회전, 창 크기 변경, 폴더블 자세 변화가 있으면 다시 계산해요. 일시정지나 완료 modal이 떠 있는 동안에는 뒤의 보드를 탐색 대상에서 빼요. iOS는 overlay를 modal 접근성 영역으로 두고, Android는 배경의 semantics를 제거해요.
걸리는 부분은 칸 크기예요. [접근성 HIG](https://developer.apple.com/design/human-interface-guidelines/accessibility)는 iOS와 iPadOS의 control 크기를 기본 44×44pt, 최소 28×28pt로 제시해요. [Compose의 기본 접근성 동작 문서](https://developer.android.com/develop/ui/compose/accessibility/api-defaults)는 상호작용 요소에 최소 48dp를 권장해요. 한 줄에 9칸이니 iOS에서는 보드 폭이 396pt보다 작으면 칸 폭이 44pt에 못 미쳐요. Android는 compact 360dp 조건에서 코드로 계산한 칸이 약 34dp예요. 이 값은 기기에서 잰 것이 아니라 계산값이에요.
Android 칸은 semantics를 직접 붙였기 때문에 clickable이 해 주는 자동 target 확장에 기대지 않아요. 그렇다고 81칸의 영역을 44pt나 48dp로 한꺼번에 키우면 서로 겹쳐서 어느 칸을 고른 것인지 모호해질 수 있어요. 실제 기기에서 focus 영역과 touch exploration을 확인하고, 손가락으로 잘못 선택하는 빈도를 측정해야 판단할 수 있어요. 대안으로는 보드 확대, 행이나 박스 단위 탐색, 별도 입력 방식이 있는데 어느 쪽이 나은지 정할 근거는 아직 없어요.
글자 크기도 남아 있어요. iOS의 숫자와 메모는 칸에 대한 고정 비율로 그려서 Dynamic Type을 따르지 않아요. 큰 글자를 어디에 어떻게 보여 줄지는 추가로 확인해야 해요.
## 무엇을 테스트로 고정할지
지금 있는 검사는 셀 상태 투영, 좌표 판정, drag 경계, 색 대비 일부, 폴더블 영역까지예요. 가상 접근성 요소나 셀 semantics 자체를 고정하는 테스트는 없어요. 자동으로 고정하려는 항목은 다음과 같아요.
- playing 상태에서 칸 수 81
- 처음과 끝 좌표 (1,1)과 (9,9)
- given, error, notes, empty, 일반 값 각각의 설명
- 선택된 칸에만 선택 상태 노출
- 활성화 한 번에 `selectCell(index)` 한 번
- resize 뒤 영역 일치
- modal이 뜬 뒤 보드 제외
- Compose의 9×9 정보와 현재 행·열 정보
[Compose 접근성 테스트 문서](https://developer.android.com/develop/ui/compose/accessibility/testing)가 안내하는 UI test로 semantics 속성과 action을 확인하고, UIKit에서는 unit test로 label, traits, frame, activation을 확인할 수 있어요. selector로 요소를 찾을 수 있다는 사실만으로는 접근성이 증명되지 않아요.
기기에서 직접 볼 것도 정했어요. iPhone과 Android에서 swipe로 넘기다가 행이 바뀌는 지점, 손가락 위치와 읽어 주는 칸의 일치, double-tap 뒤의 초점 유지, 숫자·메모·오류가 바뀌었을 때의 인지를 확인해요. 마지막으로 화면을 보지 않고 선택, 입력, 수정, 완료까지 해 봐요.
Apple의 [VoiceOver 지원 안내](https://developer.apple.com/documentation/uikit/supporting-voiceover-in-your-app)와 [VoiceOver HIG](https://developer.apple.com/design/human-interface-guidelines/voiceover)는 VoiceOver를 켠 상태로 요소에 접근하고 순회하면서 시각에 의존하는 작업을 점검하라고 권해요. [Compose 접근성 개요](https://developer.android.com/develop/ui/compose/accessibility)가 소개하는 자동 검사는 작은 target이나 traversal order 문제를 찾는 데 도움이 되지만, 직접 써 보는 확인은 따로 필요해요.
지금 소스로 말할 수 있는 것은 두 플랫폼이 칸을 따로 노출하고 같은 선택 함수로 모은다는 데까지예요. Android의 선택 정보, 양쪽의 grid 정보, target 크기가 적절한지, 상태가 바뀔 때의 발화, 실기기 전체 순회는 검증하지 못했어요. 그래서 두 플랫폼의 접근성이 같은 수준이 됐다고는 쓰지 않아요.
---
# 광고 노출 이벤트를 세 플랫폼에서 같은 뜻으로 세는 규약
> iOS의 로딩 완료, Android의 impression 통지, Web의 DOM 부착을 모두 ad_impression으로 보내면 플랫폼마다 세는 기준이 달라져요. 광고가 거치는 단계를 여섯으로 나누고, 어떤 신호를 노출 한 건으로 셀지 정한 규약이에요.
- Canonical: https://jaemyeong.com/ko/blog/cross-platform-ad-impression-event-taxonomy/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Android, #Web, #Advertising, #Analytics
광고 분석에서 `ad_impression`이라는 이름은 플랫폼마다 다른 순간에 기록되기 쉬워요. iOS에서는 광고 로딩이 끝났을 때, Android에서는 SDK가 impression을 알렸을 때, Web에서는 광고 요소를 DOM에 붙였을 때 같은 이름으로 보내는 경우예요. 이렇게 모은 숫자는 한 표에 들어가지만 세는 기준은 서로 달라요. 플랫폼에 따라 실제보다 많이 세기도 하고 적게 세기도 해요.
한 이름 아래 섞이는 것은 다섯 가지예요. 광고를 내려받은 것, 화면 계층에 붙인 것, viewport와 겹친 것, SDK가 노출로 집계한 것, 수익이 통지된 것이에요. 여기에 자동으로 기록되는 이벤트 위에 수동 기록을 더하면 같은 노출이 두 번 세어져요.
이 글은 Google, Firebase, Web 문서에서 callback의 의미를 비교해 정리한 규약이에요. 문서는 2026년 10월 4일에 다시 열어 확인했어요. SDK 버전, 측정한 로그, 실행 결과는 이 글에 싣지 않았어요. 어떤 광고 공급자를 고를지는 다루지 않아요.
## 로딩 완료와 DOM 부착은 노출이 아니에요
광고를 다 받았다고 해서 사용자가 본 것은 아니에요. 로딩이 끝난 뒤에도 광고가 화면에 붙지 않을 수 있고, 그 사이에 화면이 닫히거나 슬롯이 다른 광고로 바뀔 수 있어요. 준비가 끝났다는 사실만으로는 impression을 인정할 수 없어요.
Web에서 DOM에 붙인 것도 본 증거로는 모자라요. [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)는 대상과 root가 겹치는 비율이 threshold를 넘나드는지를 관측해요. threshold의 기본값은 0이라서 경계가 닿기만 해도 통지가 올 수 있어요. `observe()`를 시작한 직후에는 보이지 않는 대상에 대해서도 첫 통지가 올 수 있어요. 이 통지를 광고 네트워크가 과금하는 근거로 볼 수는 없어요. 문서가 설명하는 것은 겹침의 관측까지예요.
## 광고가 거치는 여섯 단계
섞여 있던 신호를 단계로 나누고, 단계마다 아직 정해지지 않은 것을 함께 적었어요.
- `requested`: 요청을 시작한 단계예요. 응답, 표시, 노출은 모두 정해지지 않았어요.
- `loaded`: 응답이나 creative를 받은 단계예요. 표시와 impression은 정해지지 않았어요.
- `rendered`: view나 DOM에 붙인 단계예요. 실제로 보이는지와 SDK가 집계했는지는 정해지지 않았어요.
- `app_visible`: 앱이 정한 viewport 조건을 채운 단계예요. 네트워크가 과금하는지는 정해지지 않았어요.
- `sdk_impression`: SDK가 노출로 집계한 단계예요. 수익 정보가 오는지와 그 정밀도는 정해지지 않았어요.
- `paid_value`: 노출별 매출이 관측된 단계예요. 정산 금액과 정확성은 정해지지 않았어요.
플랫폼의 신호는 이 단계에 이렇게 대응해요.
- iOS의 `bannerViewDidReceiveAd(_:)`와 Android의 `onAdLoaded()`는 `loaded`예요. impression으로 세지 않아요.
- iOS의 `bannerViewDidRecordImpression(_:)`와 Android의 `onAdImpression()`은 `sdk_impression`이에요. 자동 수집이 없을 때만 앱이 기록해요.
- Web에서 광고 요소를 mount한 것은 `rendered`예요. impression으로 세지 않아요.
- observer 규칙을 채운 것은 `app_visible`이에요. 인하우스 광고에만 써요.
- iOS의 `paidEventHandler`와 Android의 `OnPaidEventListener`는 `paid_value`예요. 건수를 더하지 않아요.
## 무엇을 노출 한 건으로 셀지
impression의 근거로는 SDK의 통지를 가장 먼저 써요. SDK가 없는 인하우스 광고는 가시성 조건을 글로 정해 두고, 그 조건을 채웠을 때 `evidence=app_visible`로 기록해요. 이 집단은 SDK가 관측한 집단과 따로 분석해요. 같은 이벤트 이름을 쓴다고 해서 두 집단을 그대로 비교할 수 있는 것은 아니에요.
이 규약에서 paid 통지는 이미 센 impression에 붙는 수익 정보로 다뤄요. 건수를 하나 더하지 않아요. 통화와 precision은 그대로 보존해요. precision은 unknown이나 estimated일 수 있어서, 이 값을 최종 정산으로 읽으면 안 돼요. 수익이 제공되지 않았을 때 0으로 채우거나 건수에서 거꾸로 계산하지도 않아요. Firebase의 `ad_impression`에 value를 넣는다면 currency도 함께 넣어야 하고, precision은 별도의 매출 경로에 보관해요.
실패 쪽도 범위를 좁혔어요. `ad_load_failed`는 실제로 요청을 보낸 공급자가 응답에 실패한 경우에만 기록해요. 등록되지 않았거나 꺼져 있어서 건너뛴 공급자는 실패가 아니에요. 같은 이유로, 공급자를 골랐다는 것만으로는 `ad_impression`을 보내지 않아요. 화면이 닫히거나 unmount된 뒤에 취소된 요청의 통지가 늦게 도착해도 현재 슬롯의 결과로 치지 않아요.
## 자동 기록이 있으면 앱은 보내지 않아요
Firebase와 연동한 AdMob에서 자동 기록이 켜져 있으면 앱이 같은 이벤트를 수동으로 보내면 안 돼요. `onAdImpression()` 안에서 같은 GA4 기록을 한 번 더 보내면 노출 한 번이 두 건으로 집계돼요. 자동 수집이 없는 SDK는 callback 한 번에 한 건을, 인하우스 광고는 가시성 조건을 한 번 채울 때 한 건을 기록해요. DOM만 관측할 수 있는 외부 Web 콘텐츠는 근거 규약을 따로 정해야 해요.
자동 기록에는 `slot`이나 `selection_source` 같은 필드가 빠질 수 있어요. 빠진 필드를 채우려고 impression을 하나 더 보내지 않고, 누락을 받아들인 채 `evidence`별로 분석해요. paid 데이터를 별도 서버로 보낼 때에도 GA4 이벤트로 바꾸기 전에 자동 기록이 켜져 있는지 먼저 확인해요. 자동 기록과 수동 기록이 채우는 필드의 차이도 규약에 적어 둬요.
## 슬롯, attempt, 광고 인스턴스를 구분해요
중복을 없애려면 무엇을 한 건의 단위로 볼지 정해야 해요. 슬롯은 UI가 살아 있는 동안의 단위이고, attempt는 공급자 하나에 보낸 요청 한 번이고, 광고 인스턴스는 creative 하나가 살아 있는 동안의 단위예요.
슬롯 하나에서 요청이 실패한 뒤 다시 요청해 성공하고, refresh로 두 번째 노출이 생기는 경우를 그린 도식이에요.
```text
slot instance
├─ attempt 1 → load failed
└─ attempt 2 → loaded → impression 1
└─ refresh → impression 2
```
세션에 Boolean 하나만 두면 refresh로 생긴 노출을 놓쳐요. 반대로 callback을 전부 보내면 재시도나 재마운트 때 중복이 생길 수 있어요. 그래서 실패는 attempt마다, impression은 인스턴스마다 한 번만 남겨요. refresh로 새 creative가 오면 새 인스턴스로 보고, 같은 인스턴스가 다시 통지한 것만 빼요. 이 구분에는 메모리 안의 식별자면 충분해요. GA4에 고유 ID를 보내서 값의 종류가 지나치게 많은 차원을 만들 필요는 없어요.
## 앱 안에서 주고받는 이벤트의 모양
앱 내부에서는 필드 수를 적게 두고 값도 정해진 것만 써요. 아래는 GA4의 표준 스키마가 아니고 앱 내부에서 쓰는 예시예요.
```json
{
"name": "ad_impression",
"provider": "global_network",
"format": "banner",
"slot": "bottom",
"evidence": "sdk_impression",
"selection_source": "normal"
}
```
`provider`, `format`, `slot`은 플랫폼의 enum과 어떻게 대응하는지를 코드에 적어요. 공급자를 추가했는데 대응이 빠지면 빌드나 테스트에서 오류가 나게 해요. `evidence`의 값으로 본문에서 든 예는 `sdk_auto`, `sdk_callback`, `app_visible`이에요. 위 예시의 `sdk_impression`과 이 세 값이 어떻게 대응하는지는 이 글에서 정의하지 않았어요.
`selection_source`는 이벤트가 가리키는 대상에 맞춰 계산해요. 강제로 고른 공급자가 실패한 뒤 다른 공급자의 광고가 표시됐다면, 실패의 출처와 impression의 출처가 다를 수 있어요. 요청이 이어진다고 같은 source를 복사해 붙이면 뜻이 틀어져요.
## 광고 재고에 기대지 않고 확인하기
실제 광고가 내려오기를 기다리면 확인이 재고 상황에 좌우돼요. callback을 테스트에서 직접 주입하면 그 의존을 없앨 수 있어요. 아래는 그렇게 확인할 때의 기댓값이고, 실제로 실행해서 통과한 기록은 아니에요.
- load만 성공하면 impression은 0건이에요.
- SDK가 한 번 통지하면 1건이에요.
- 같은 인스턴스가 다시 통지해도 1건이에요.
- 새 광고로 refresh된 뒤에는 누적 2건이에요.
- 자동 수집을 하는 공급자에서는 수동 기록이 0건이에요.
- 요청하기 전에 건너뛴 공급자의 실패는 0건이에요.
- 실패한 뒤 다른 공급자가 표시되면 실패 1건과 노출 1건이고, source는 각각 계산해요.
- unmount 뒤에 늦게 온 통지는 0건이에요.
- 보이지 않는 상태에서 온 observer의 첫 통지는 0건이에요.
- 독립된 슬롯 두 개가 각각 표시되면 2건이에요.
이 규약에서 SDK callback을 가장 앞선 근거로 삼은 것, 식별 단위, 내부 enum과 source의 설계는 문서를 읽고 내린 제 해석이에요. 네트워크의 과금이나 정산을 보장하지 않아요. Web의 자체 가시성 규칙은 threshold, 머문 시간, 영역을 벗어났을 때의 초기화까지 검증해야 하는데, 한순간의 entry만으로는 얼마나 오래 보였는지 판정할 수 없어요. `trackVisibility`는 제한적이고 실험적인 상태여서 반드시 필요한 의존 대상에서는 뺐어요. 수익 통지를 앞선 impression에 어떻게 연결할지, 통지가 도착하는 순서를 어떻게 처리할지는 이 글에서 다루지 않아요.
---
# 일일 퍼즐 점수의 날짜와 리더보드 전송 손실 다루기
> 23시 50분에 시작해 0시 5분에 끝낸 판에서는 퍼즐 날짜, 전송 시각, 리더보드의 조회 구간이 서로 어긋나요. Game Center와 Play Games 문서로 세 값의 차이를 확인하고, 점수 전송을 어디까지 보장할지 고르는 기준을 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/daily-leaderboard-date-boundary-submission-reliability/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Android, #GameKit, #Google Play Games, #Testing
하루에 한 문제씩 내는 퍼즐의 결과를 Game Center와 Google Play Games 두 곳에 보내면, 자정을 넘긴 판에서 날짜가 문제가 돼요. 23시 50분에 시작해서 0시 5분에 끝낸 판에서는 퍼즐이 속한 날, SDK가 전송을 받은 날, 리더보드 화면이 보여 주는 범위가 서로 어긋날 수 있어요.
전송 자체도 문제가 돼요. 완료 기록과 통계는 기기에 남았는데 외부 점수는 사라질 수 있어요. 반대로 완료하는 도중에 로그인을 요구하면 게임 진행이 인증에 묶여 버려요.
근거는 Apple과 Google의 공식 문서이고, 2026년 10월 4일에 다시 열어 확인했어요. 이 문서들이 말하는 것은 SDK와 서버의 동작까지예요. 전송 조건, 로그인 화면을 두는 자리, 메모리 보관, outbox는 그 위에 얹는 앱 쪽의 설계이고, 문서가 요구하는 것이 아니에요. 자정을 넘기는 사례는 가상의 예예요. 측정값, 실행 로그, SDK 버전은 이 글에 싣지 않았어요.
## 날짜처럼 보이는 값이 셋이에요
같은 «날짜»로 보이지만 정하는 쪽이 서로 다른 값이 셋 있어요. `puzzle_day`는 앱이 정하는 퍼즐의 날짜예요. `submitted_at`은 앱과 기기에서 정해지는 전송 시각이에요. `leaderboard_window`는 SDK가 정하는 조회 구간이에요.
퍼즐 날짜는 게임을 만들 때 정하고 세션 안에서 바꾸지 않아요. 전송 시각은 재시도할 때마다 달라질 수 있어요. SDK의 구간이 기기의 자정과 같다는 보장도 없어요. 그래서 완료 시점에 날짜를 새로 계산하지 않고 세션에 들어 있는 값을 써요.
자정을 넘긴 판에서 세 값이 어떻게 달라지는지를 적은 예시예요.
```text
퍼즐 생성: 2026-08-05 23:50 → puzzle_day = 2026-08-05
게임 완료: 2026-08-06 00:05 → submitted_at = 2026-08-06 00:05
제출 메타데이터 → 2026-08-05
```
## 부가 값은 구간을 바꾸지 못해요
Android에서는 `yyyy-MM-dd` 형식의 퍼즐 날짜를 `scoreTag`에 실을 수 있어요. `scoreTag`는 [LeaderboardsClient](https://developers.google.com/android/reference/com/google/android/gms/games/LeaderboardsClient)의 `submitScore`가 받는 선택 인자이고, URI에 안전한 문자만 쓸 수 있으며 길이는 64자까지예요. 문서는 이 값을 선택적인 metadata라고만 정의해요. 점수가 어느 퍼즐 날짜에서 왔는지를 나타내는 용도는 앱이 정한 것이에요. 날짜별로 중복을 막거나 화면에서 걸러 주지는 않아요. 태그에 2026-08-05를 넣어도 `TIME_SPAN_DAILY`의 행이 날짜별로 나뉘지 않아요.
Apple의 `context`도 같아요. [submitScore](https://developer.apple.com/documentation/gamekit/gkleaderboard/submitscore(_:context:player:leaderboardids:completionhandler:))의 `context`는 앱이 뜻을 정하는 부가 값이라서, 날짜를 어떻게 표현할지는 앱의 책임이에요. `timeScope`를 조절하는 수단이 아니에요. `context: 0`만 보냈다면 점수 데이터에서 퍼즐 날짜를 거꾸로 알아낼 수 없어요. 두 플랫폼에서 이 값의 뜻이 다르다는 점은 제품 요구사항에 적어 둬야 해요.
## 기본 화면이 보여 주는 하루
[Play Games 리더보드](https://developer.android.com/games/pgs/leaderboards)는 daily, weekly, all-time을 제공하고, daily의 경계는 일 년 내내 UTC-7의 자정이에요. Apple의 [today 범위](https://developer.apple.com/documentation/gamekit/gkleaderboard/timescope-swift.enum/today)는 직전 24시간이에요. Apple의 [반복형 리더보드](https://developer.apple.com/documentation/gamekit/creating-recurring-leaderboards)에서 회차가 언제 바뀌는지는 [App Store Connect의 반복 설정](https://developer.apple.com/help/app-store-connect/reference/game-center/leaderboards/)에 달려 있어요. 어느 쪽의 기본 일일 화면도 `puzzle_day`와 일치한다고 보장하지 않아요.
서울의 자정에 퍼즐이 바뀌는 앱을 예로 들면, 새 퍼즐의 점수와 전날 퍼즐의 점수가 Google의 daily 구간 하나에 함께 들어갈 수 있어요. Apple의 today에도 이틀 치 점수가 같이 있을 수 있어요. 이것은 표시 범위의 계약이 다르기 때문이고, 태그를 고쳐서 해결할 문제가 아니에요.
요구에 따라 고를 수 있는 길은 셋이에요.
- SDK의 일일 화면으로 충분하다면 부가 정보와 기본 UI를 써요.
- Apple에서 회차를 구분해야 한다면 반복형 리더보드를 검토해요.
- 두 플랫폼이 같은 달력 기준과 같은 조회 규칙을 써야 한다면 별도 저장소와 자체 UI가 필요해요.
마지막 길에는 운영, 부정행위 대응, 개인정보 관리 비용이 따라와요. 간단한 일일 경쟁이라면 미리 도입할 필요가 없다고 판단했어요. 이 판단은 문서에 적힌 내용이 아니고 제 해석이에요.
## 언제 보내고 언제 보내지 않을지
전송 조건은 SDK가 정해 주지 않아요. 아래는 앱 쪽에서 정하는 정책의 예이고, 세 가지를 모두 채울 때에만 보내요.
```text
submit = won && daily && authenticated
```
무료 모드이거나, 졌거나, 인증되지 않았으면 보내지 않아요. 인증 상태가 불확실할 때에는 화면을 띄우지 않는 조회만 허용해요. 로그인 화면은 사용자가 리더보드 버튼을 눌렀을 때의 경로에 둬요. 서비스가 꺼져 있거나, 로그아웃했거나, 인증을 취소했다고 해서 완료 처리, 통계, 화면 전환이 실패하면 안 돼요.
완료 처리에서 기기가 책임지는 일은 따로 있어요. 종료 전환이 두 번 처리되지 않게 막고, 이어 하기 데이터를 지우고, 통계와 일일 완료 정보를 갱신하는 일이에요. 외부 전송은 이와 독립된 시도이고, 실패해도 완료를 되돌리지 않아요.
여기서 «기기 먼저»라는 말을 조심해야 해요. 이 표현만으로는 디스크에 쓰는 일이 전송보다 먼저 끝난다고 말할 수 없어요. Android에서 비동기 쓰기를 예약한 직후에 전송을 시작하면 어느 쪽이 먼저 끝날지 정해져 있지 않아요. 디스크 기록이 먼저 확정돼야 한다면 저장 완료를 기다리는 설계를 더해야 해요.
## 점수 한 건을 잠시 보관하기
Android에서는 Activity가 연결되지 않은 짧은 순간에 점수가 도착할 수 있어요. 이런 경우를 위해 메모리에 최대 한 건을 보관하는 설계가 있어요. SDK의 기능이 아니고 앱 쪽의 설계예요. A가 있는데 B가 오면 B로 덮어쓰고, Activity가 연결되면 꺼내서 인증을 확인한 뒤 한 번 전송해요.
```text
Activity 없음
score A 보류
score B 도착 → A를 B로 교체
Activity 연결
B를 꺼내 인증 확인 → 제출 시도
```
이 보관은 짧은 수명주기 경합을 줄이는 용도이고, 한계가 분명해요. 프로세스가 죽으면 보관한 항목은 사라져요. 꺼낸 뒤에 인증이 false이거나 조회 요청이 실패해도 다시 넣지 않아요. iOS에 같은 보관이 없다면 두 플랫폼에서 점수가 전달될 가능성도 달라져요.
최신 항목을 남긴다는 규칙도 기기 안의 이야기예요. Google은 서버에 있는 값보다 좋은 점수만 반영하고, Apple은 설정에 따라 best score나 most recent score를 골라요. 앱이 최신 항목을 골랐다고 해서 서버가 그 기록을 채택하는 것은 아니에요.
호출 방식에도 차이가 있어요. `submitScore`는 결과를 알려 주지 않고, `submitScoreImmediate`는 `Task`를 돌려줘요. 결과를 받지 않는 방식이면 앱은 전송이 성공했는지 판정할 수 없어요. `Task`를 받는 쪽도, 문서에는 앱을 다시 시작한 뒤에 다시 보낸다는 내용이 없어요.
## 전송을 어디까지 보장할지
전달 수준을 세 가지로 나눈 것도 제 분류예요. 문서에 있는 구분이 아니에요.
- 직접 한 번 시도: 세션 안에서 한 번만 보내요. 비용이 가장 적고, 빠져도 되는 기능에 맞아요.
- 메모리 한 칸 보관: Activity가 연결되지 않은 동안 최신 항목을 보관해요. 비용이 낮고, 짧은 수명주기 경합에 맞아요.
- durable outbox: 앱을 다시 시작한 뒤에도 다시 보낼 수 있어요. 비용이 높고, 전송 손실이 보상이나 대회 결과에 영향을 줄 때 맞아요.
durable outbox로 간다면 payload를 먼저 디스크에 적어요. 안정적인 전송 ID, `puzzle_day`, 점수, 대상 리더보드를 저장해요. 성공 응답을 받은 항목만 지우고, 재시작할 때 남은 항목을 복구해요. backoff와 성공 확인도 들어가요. 서버가 기존의 좋은 기록을 유지하기로 한 것과 앱이 재전송을 그만두는 것은 따로 판단해요. 전송 ID를 저장한다고 해서 SDK가 중복을 없애 주거나 정확히 한 번 처리한다는 뜻은 아니에요.
손실을 받아들이기로 했다면 범용 큐를 키우지 않고, 어떤 조건에서 점수가 빠지는지를 문서와 테스트에 적어요.
## 테스트로 고정할 기댓값
아래 기댓값은 조건식과 보관 정책을 순수 함수로 두고 테스트로 고정하려는 계획이에요. 실행 결과는 이 글에 싣지 않았어요.
- 무료 모드에서 이기거나 일일 모드에서 지면 제출은 0회예요.
- 일일 모드에서 이겼지만 인증되지 않았으면 제출은 0회이고 로그인 화면도 뜨지 않아요.
- 23시 50분부터 0시 5분까지 플레이하면 세션의 퍼즐 날짜가 유지돼요.
- Activity가 없는 상태에서 A 다음에 B가 오면 B 하나만 보관해요.
- 보관 중에 프로세스가 죽으면 재시작 뒤에 제출하지 않아요.
- 꺼낸 뒤 인증에 실패하면 다시 보관하지 않아요.
- 원격 요청이 실패해도 완료 정보와 통계는 남아요.
- 이전보다 낮은 최신 점수는 기기의 최신 선택과 서버의 최고 기록 선택을 따로 검사해요.
- 종료 이벤트를 다시 받아도 완료 기록은 1회, 전송 시도는 1회예요.
- 기본 daily와 today 조회는 부가 값이 아니라 SDK의 시간 범위를 써요.
단위 테스트가 증명하는 범위는 앱의 분기와 payload까지예요. 서버에 점수가 실제로 반영됐다는 증거는 되지 않아요. 인증 취소, 오프라인, 리더보드 intent와 access point의 표시 오류는 통합 환경에서 따로 확인해야 해요. 기본 화면의 날짜 의미 차이, 메모리 보관의 유실, 전송 결과를 알 수 없는 호출, 디스크 기록의 완료 순서는 이 설계에 그대로 남아 있는 한계예요.
---
# 일일 퍼즐 ID에 생성기 버전이 필요한 이유
> DailySudoku의 일일 퍼즐은 날짜에서 seed를 만들어 보드를 생성해요. 생성 방식이 바뀌면 같은 날짜에서 다른 보드가 나올 수 있는데, 날짜만 담은 ID로는 그 차이를 적을 수 없어요. 현재 구조와, ID에 날짜 정책과 생성기 버전을 넣자는 제안을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/daily-puzzle-identity-generator-version/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #Rust, #Testing, #iOS, #Android, #WebAssembly
일일 퍼즐의 보드는 날짜에서 만들어져요. 스도쿠 앱 DailySudoku가 쓰는 방식이에요. 같은 날짜 정수와 같은 core 빌드로 정상적인 일일 화면에 들어오면 iOS, Android, Web에서 같은 보드가 나와요. 그런데 생성 방식을 바꾸면 seed가 그대로여도 보드가 달라질 수 있어요. 저장, 분석, 리더보드의 키가 날짜만 담고 있으면 그 차이를 표현하기 어려워요.
기준은 2026년 8월 6일 develop 브랜치의 커밋 9c99930582be23d2c72d37fb46d77a488b9fe04c예요. 지금의 구조와 그 위에 얹으려는 제안을 나눠서 적어요. `DailyPuzzleIdentity` 타입과 `device-local-v1` 날짜 정책, g1과 g2 ID, schema v2, resolver는 모두 제안이고 아직 구현하지 않았어요.
## 지금 일일 퍼즐이 만들어지는 경로
일일 퍼즐 화면에 들어오면 아래 순서로 보드가 정해져요.
```text
현재 시각
-> 기기 로컬 시간대의 epochDay
-> dailySeed(epochDay)
-> generate(MEDIUM, seed)
-> puzzleId = "DAILY-"
```
`dailySeed`는 SplitMix64 finalizer를 쓰고, 64비트 [wrapping 덧셈](https://doc.rust-lang.org/stable/std/primitive.u64.html#method.wrapping_add)과 [wrapping 곱셈](https://doc.rust-lang.org/stable/std/primitive.u64.html#method.wrapping_mul), logical shift로 계산해요. 생성기는 자체 RNG로 완성된 보드를 만든 뒤, 해답이 하나로 유지되는 clue(처음부터 채워져 있는 숫자)만 지워요. 이 core는 Rust이고, iOS와 Android는 UniFFI로, Web은 WebAssembly로 호출해요.
정상적인 일일 진입점에서 같은 날짜 정수와 같은 core 빌드를 쓰면 세 플랫폼의 새 보드와 해답이 일치해요. 이 결정성과 golden fixture는 [공통 core의 테스트와 CI를 다룬 글](/posts/rust-shared-core-07-testing-ci/)에서 설명했어요.
## 날짜는 기기의 로컬 날짜예요
날짜 정수는 UTC 날짜가 아니고 기기의 로컬 날짜에서 구해요.
```text
localEpochDay = floor((utcMillis + offsetAtInstant) / 86_400_000)
```
offset은 그 순간의 «로컬 빼기 UTC» 값이에요. 밀리초를 돌려주는 쪽은 Android의 [TimeZone.getOffset](https://developer.android.com/reference/java/util/TimeZone#getOffset(long))이에요. iOS의 [secondsFromGMT(for:)](https://developer.apple.com/documentation/foundation/timezone/secondsfromgmt%28for%3A%29)는 초를 돌려주므로 밀리초로 바꿔요. Web의 [getTimezoneOffset](https://tc39.es/ecma262/#sec-date.prototype.gettimezoneoffset)은 «UTC 빼기 로컬»을 분으로 돌려주므로 부호를 뒤집고 60_000을 곱해요. 서머타임이 있어서 offset을 상수로 두면 안 돼요.
이 정책에서는 같은 UTC 순간에도 서울과 로스앤젤레스의 날짜 정수가 다를 수 있어요. 여행으로 시간대가 바뀌면 다음 실행의 ID에 영향을 줘요. 이미 시작한 게임의 날짜는 유지하고, 다음에 일일 퍼즐에 들어올 때 다시 판정해요. 전 세계가 같은 순간에 같은 보드를 받는다는 보장은 없어요. 반대로 서울과 도쿄처럼 같은 날짜 정수를 고른 기기는 core와 난이도가 같으면 같은 보드를 받아요. 그래서 시간대 ID는 식별 필드에서 빼도 돼요.
## 날짜만 담은 ID가 놓치는 것
`DAILY-`에는 난이도도 생성기 버전도 없어요. Rust가 저장하는 JSON의 `version: 1`은 직렬화 규격의 번호예요. migration이 없는 다른 schema는 읽기를 거부해요. 이 번호는 생성 알고리즘의 버전과 다른 것이에요.
생성 방식이 실제로 바뀐 사례가 있어요. Expert 난이도에서 최대 시도 횟수를 8에서 4,096으로 올렸고, seed 159에서는 종전의 25-clue fallback 대신 24-clue 보드가 나왔어요. 이것은 Expert의 변경 이력이고, 일일 퍼즐이 쓰는 MEDIUM이 바뀌었다는 증거는 아니에요. 생성 규칙이 바뀔 수 있다는 것을 보여 주는 사례로만 읽어야 해요.
생성기가 바뀐 채로 신구 앱이 함께 돌면 이런 상황이 생겨요. 옛 앱의 새 게임은 g1으로 만든 보드이고, 새 앱의 새 게임은 g2로 만든 보드이고, 복원한 게임은 저장돼 있던 g1 보드예요. 셋 모두 같은 `DAILY-day`를 쓸 수 있어요. 저장 규격이 호환되면 보드와 해답을 복원해서 옛 판을 이어 갈 수 있지만, 분석에서 외부 ID가 겹치는 문제는 남아요. 표준 Game Center와 Play Games에는 동적인 퍼즐 ID별로 점수를 나누는 기능이 없어요. 어떤 버전의 점수를 받을지는 제출하기 전에 정해야 해요. `context`와 score tag의 한계는 [리더보드의 날짜 경계를 다룬 글](/posts/daily-leaderboard-date-boundary-submission-reliability/)에서 따로 다뤘어요.
## 제안하는 식별 정보
날짜 정책, 날짜 정수, 난이도, 생성 버전을 묶은 타입을 제안해요.
```ts
type DailyPuzzleIdentity = {
dayPolicy: "device-local-v1";
epochDay: number;
difficulty: "MEDIUM";
generatorVersion: number;
};
```
필드마다 책임이 달라요. `dayPolicy`는 날짜의 경계를, `epochDay`는 고른 날짜를, `difficulty`는 생성 목표를, `generatorVersion`은 seed 파생과 RNG와 생성 규칙을 가리켜요. `dayPolicy`를 넣어 두면 날짜 기준을 바꾸더라도 옛 identity에 새 날짜 규칙을 소급해서 적용하지 않아요. 시간대를 관측한 메타데이터와 `dayPolicy`는 역할이 달라요.
이 정보를 문자열 ID로 만들면 아래와 같아요.
```text
DAILY-device-local-v1-20671-MEDIUM-g1
```
seed는 `epochDay`에서 다시 계산할 수 있으므로 ID에 넣을 필요가 없어요. 저장 데이터나 telemetry에서 진단과 fixture의 보조값으로는 쓸 수 있어요.
`generatorVersion`은 Cargo의 package version과 분리해요. UI나 광고 SDK만 고쳤다면 올릴 필요가 없어요. RNG, shuffle, 난이도 목표, clue 제거, 재시도를 바꿨다면 결과에 영향을 주는지에 따라 올려요. 한 번 발급한 버전의 뜻은 바꾸지 않아요. Rust가 identity와 보드를 한 결과로 묶어서 돌려주게 하면, g2 보드에 g1 표지가 붙는 혼합을 막을 수 있어요.
## 유일해 검사와 식별 검사는 다른 질문이에요
생성기는 clue를 하나 지울 때마다 solver로 해답을 최대 두 개까지 찾아보고, 지운 뒤에도 해답이 정확히 하나인 경우만 받아들여요. 이것은 새로 만든 보드에 대한 검사예요. `DAILY-20671` 같은 ID가 보드 하나만 가리키는지와는 별개의 문제예요.
```text
solver invariant
-> 이 board에는 해답이 정확히 하나인가
identity invariant
-> 이 PuzzleIdentity는 배포·복원·분석 전체에서 같은 board를 뜻하는가
```
서로 다른 유일해 보드가 같은 ID를 쓰는 것도, 같은 보드에 ID가 여러 개 붙는 것도 solver로는 잡을 수 없어요.
fixture에도 한계가 있어요. fixture는 골라 둔 입력에서 결과가 달라졌는지만 알려 줘요. 버전 번호를 올리지 않고 생성 규칙을 바꾸는 실수를 반환 API만으로 자동으로 막을 수는 없어요. 보드 전체가 같은지까지 보장해야 한다면 canonical digest나 archive로 보완해요. hash는 달라졌다는 사실을 알려 줄 뿐이고, 왜 달라졌는지와 어느 generator로 다시 만들어야 하는지는 알려 주지 않아요. 그래서 identity의 버전 계약을 먼저 세우고, checksum을 더할지는 그다음에 판단해요.
## 저장 데이터를 옮기는 방법
schema v2에는 snapshot과 버전이 들어간 identity를 함께 적는 것을 제안해요. 기존 v1 저장본에 g1을 붙이는 것은 릴리스 기록으로 v1 전체가 g1이었다는 것을 확인한 경우에만 해요.
출처를 알 수 없는 저장본은 `legacy-`로 격리해요. digest는 given, solution, difficulty를 정규화한 canonical digest이고, 입력 내역, history, 경과 시간은 넣지 않아요. 이 저장본은 버전 비교의 대상에서 빼요. 보드나 버전이 없는 과거 기록은 `legacy-unknown`으로 남겨요.
새 게임에는 활성 버전을 쓰고, 과거 날짜를 다시 만들 때에는 기록된 버전을 써요.
```text
PuzzleIdentity.generatorVersion == 1 -> g1 또는 저장된 g1 board
PuzzleIdentity.generatorVersion == 2 -> g2 또는 저장된 g2 board
```
지원하지 않는 과거 버전은 조용히 다른 버전으로 만들지 않고 오류로 알려요. 과거 판을 다시 플레이하는 기능이 없는 제품이라면, 진행 중인 저장본의 보드와 해답, 그리고 identity를 전달하는 경로만 있으면 돼요. replay나 서버 검증이 있을 때에는 옛 generator나 바꿀 수 없는 archive가 필요해요.
## 되돌릴 때 지켜야 할 것
g2에 문제가 있어서 되돌린다면, 버전을 아는 바이너리는 그대로 두고 새로 발급하는 것만 g2에서 g1으로 바꿔요. 이미 g2로 배정한 보드는 계속 g2예요.
순서도 정해 뒀어요. 먼저 v1과 v2를 모두 읽고 버전이 붙은 ID를 쓰되 g1만 발급하는 호환 빌드를 배포해요. 그다음에 빌드나 원격 정책으로 g2를 켜요. 되돌리는 기간에는 g1 생성과 g2 snapshot 읽기를 둘 다 유지해요. 버전을 모르는 옛 바이너리로 되돌아가면 안 돼요.
지금 구조에서는 이 전환을 한 번에 강제할 수 없어요. generator가 클라이언트 바이너리에 들어 있고 중앙에서 버전을 고르는 장치가 없어요. 서버나 Web을 되돌려도 이미 설치된 g2 모바일 앱은 계속 g2를 실행해요. 버전 관리는 혼합 배포에서 하루에 보드 하나를 강제하지 못해요. 그 요구가 있다면 단일 권위가 날짜와 생성 버전의 대응을 바꿀 수 없게 관리하고 옛 클라이언트의 처리까지 운영해야 해요. 서버가 identity나 보드를 발급하거나, 최소 앱 버전을 통제하는 방식이에요.
두 generator를 모두 내장하고 서버나 Remote Config로 고르는 구조라면 새 발급을 g1으로 바꿀 수 있어요. 저장된 g2 게임은 원래의 identity와 보드로 끝내요. g2 점수를 빼려면 제출 전에 막아야 하고, 버전별로 조회하려면 커스텀 백엔드나 미리 나눠 둔 리더보드가 필요해요.
## 지금 있는 검사와 더할 검사
현재는 parity fixture가 epoch day 4개와 난이도 5개의 조합에서 seed, givens, solution을 고정해요. 새 결과로 기존 fixture를 덮어쓰면 옛 버전의 계약이 사라져요. 고정된 64비트 seed 연산은 golden value로, 새 보드의 변화는 Rust 테스트와 parity로, 유일해는 clue 제거와 선택한 seed의 테스트로 검사해요. 4개와 5개의 조합을 검사한다고 해서 모든 입력이 증명되는 것은 아니에요.
전환할 때에는 g1 fixture를 그대로 두고 g2 fixture를 새 파일로 만들 것을 제안해요.
```text
g1 fixture: 변경 금지
(device-local-v1, day-before-cutover, MEDIUM, g1) -> board A
g2 fixture: 새 파일
(device-local-v1, cutover-day, MEDIUM, g2) -> board B
resolver test
저장된 g1 identity -> g1 또는 저장 snapshot
새 g2 identity -> g2
```
확인할 항목은 네 가지예요. 지원하는 빌드에서 g1 보드와 해답이 일치하는지, 버전이 다르면 외부 ID도 다른지, 되돌린 뒤에 g2를 g1으로 취급하지 않는지, identity와 보드의 버전이 섞이지 않는지예요. Swift, Kotlin, TypeScript 쪽에서는 알고리즘을 복제해서 검사하지 않고, ID의 저장과 복원, 분석과 점수 정책으로의 전달을 검사해요.
구현 순서는 Rust의 반환값 결합, schema v2의 identity 보존, 분석 키 교체, 점수 제출 시 버전 검사, 버전별 고정 fixture예요. 두 번째 generator가 생기기 전에는 작은 switch로 분기하면 충분하고, 별도의 factory나 범용 migration 체계를 먼저 만들 필요는 없어요.
생성 버전의 식별, 신구 공존 계약, 버전별 migration과 rollback은 모두 구현 전이에요. 지금 보장되는 결정성은 입력과 코드가 고정돼 있다는 조건 안에서의 것이고, 오래 유지되는 식별 계약과는 다른 것이에요. Game Center와 Play Games의 점수 분할 제약은 플랫폼이나 구성이 바뀌면 다시 확인해야 해요.
---
# 위치 권한 없이 GeoIP 권역으로 광고 공급자 순서 정하기
> 지역마다 쓸 수 있는 광고 공급자가 다를 때 필요한 것은 좌표가 아니라 넓은 권역이에요. 동의를 먼저 확인하고, 서버가 IP로 추정한 권역 버킷만 받아 공급자 순서를 정하는 설계와 그 한계를 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/geoip-ad-routing-without-location-permission/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Android, #Advertising, #Privacy, #GeoIP
지역마다 쓸 수 있는 광고 공급자가 다르면 앱은 사용자가 어느 지역에 있는지를 알아야 해요. 이때 필요한 정보는 좌표가 아니에요. 국가나 그보다 넓은 권역이면 충분해요. 광고를 고르려고 정밀한 위치 접근을 요구하는 것은 사용자에게도 앱에도 부담이 커요.
아래는 OS의 위치 권한을 쓰지 않고, 서버가 IP로 추정한 권역만으로 공급자 순서를 정하는 설계예요. 신호마다 무엇을 뜻하는지와 어디서 틀릴 수 있는지를 설명한 것이고, 실제 장애를 관측하거나 측정한 기록은 이 글에 없어요. 각 SDK와 서비스의 버전도 싣지 않았어요. 순서를 정하는 예제 함수만 따로 실행해 봤고, 그 결과는 «예제 함수를 실행해 본 결과» 절에 있어요.
공식 문서가 직접 설명하는 것은 위치 권한, CDN의 위치 헤더, UMP의 게이트가 각각 어떻게 동작하는지까지예요. 지역을 동의 뒤에 조회하는 순서, 프로세스 단위의 캐시, 해석기와 로더의 분리, 공급자 체인, 테스트의 기댓값은 문서의 요구가 아니고 그 위에서 고른 설계이며 제 해석이에요.
## 지역을 알려 주는 신호 네 가지
지역처럼 보이는 신호는 뜻이 서로 달라요.
- 앱 언어는 읽기 편한 언어의 선호예요.
- Locale의 국가는 사용자가 고른 설정값이에요.
- OS의 위치 서비스는 기기가 측정한 값이고, 권한과 민감정보 처리가 따라와요.
- GeoIP는 외부에서 관측한 IP로 지역을 추정한 값이에요.
한국어를 쓴다는 이유만으로 국내 공급자를 고를 수는 없어요. 여행 중이거나 설정을 바꾸지 않았다면 Locale과 지금 있는 곳이 다를 수 있어요. GeoIP도 VPN, 중계 서비스, 통신사 게이트웨이의 영향으로 틀리거나 값이 없을 수 있어요. 그리고 조회하는 서버에는 IP가 보여요.
그래서 정밀도가 낮은 신호부터 검토해요. Locale은 언어와 정책의 기본값으로만 쓰고, 공급자를 쓸 수 있는 지역인지의 근거로 삼지 않아요. 지역별 분기가 필요할 때에만 GeoIP를 따로 요청하고, 판단할 근거가 모자라면 `unknown`으로 둬요. 실제로 위치가 필요한 기능이라면 [Apple의 위치 승인 안내](https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services)와 [Android의 권한 최소화 지침](https://developer.android.com/privacy-and-security/minimize-permission-requests)에 따라 그 기능의 맥락과 최소 권한 수준을 따져요.
## 동의가 먼저, 지역은 그다음
앱을 시작할 때의 순서는 이래요.
1. 저장된 동의 상태를 갱신해요.
2. 필요하면 동의 UI를 처리해요.
3. 광고를 요청해도 되는지 게이트를 확인해요.
4. 지역을 프로세스당 한 번 해석하고 메모리에 둬요.
5. 권역 버킷에 맞는 공급자 배열을 만들어요.
6. 배열 순서대로 요청해요.
7. 모두 실패하면 인하우스 콘텐츠로 끝내요.
게이트의 예는 [Google UMP](https://developers.google.com/admob/ios/privacy)의 `canRequestAds`예요. 광고를 요청해도 된다는 것과 지역 추정을 믿을 수 있다는 것은 별개의 문제예요.
동의를 확인하지 못했거나 외부 광고가 차단된 상태에서는 지역 조회를 아예 하지 않아요. 그때 무엇을 보여 줄지는 제품과 정책에 따라 정하지만, 우회해서 외부 광고를 호출하면 안 돼요.
초기화가 두 번 들어오는 경우도 막아야 해요. 동의 갱신 직후와 동의 UI의 완료 콜백 양쪽에서 초기화에 들어올 수 있어요. 진행 중인 지역 작업이나 이미 만든 계획을 다시 쓰면 요청의 중복과 경쟁을 줄일 수 있어요.
## 서버는 버킷만 돌려줘요
CDN이 붙여 주는 위치 헤더는 서버가 관측한 IP를 바탕으로 해요. [CloudFront의 요청 헤더](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html)가 그런 예예요. 도시 값은 빠질 수 있고, ASCII가 아닌 이름은 인코딩돼서 올 수 있어요. 앱마다 원시 도시 값을 파싱하고 정책을 따로 두면 중복이 생기므로, 서버에서 권역 버킷을 만들어서 내려 줘요.
버킷은 셋이에요. `regionalMarket`은 지역 공급자를 먼저 시도할 후보이고, `otherMarket`은 전역 공급자의 대상이고, `unknown`은 분류할 수 없는 경우예요. 응답에는 좌표, IP, 도시 이름을 넣지 않아요. 도시 단위의 분기가 꼭 필요하다면 사업 규칙에 쓰는 식별자만 전달해요. 헤더가 없거나, 디코딩에 실패하거나, 검증에 실패하면 `unknown`이에요.
CDN 캐시도 봐야 해요. 지역에 따라 달라지는 응답인데 그 헤더가 캐시 키에 없으면, 한 권역의 결과가 다른 권역에 재사용될 수 있어요. 반대로 키를 지나치게 잘게 나누면 캐시 적중률이 떨어져요. 응답을 캐시하지 않거나, 서버가 만든 거친 버킷을 기준으로 캐시하도록 정책에 적어 둬요.
## 지역 해석과 광고 로딩을 나눠요
지역 해석기에는 광고 SDK 의존성을 넣지 않고, 광고 로더에는 IP나 Locale 의존성을 넣지 않아요. 둘은 열거형으로만 이어져요.
동의 상태를 먼저 보고, 권역 버킷에 따라 공급자 후보 배열을 돌려주는 예시예요. 이 함수는 광고를 로드하지 않고 배열만 만들어요.
```swift
enum ConsentState {
case pending
case adsAllowed
case externalAdsBlocked
}
enum AdRegion {
case regionalMarket
case otherMarket
case unknown
}
enum AdProvider {
case regional
case global
case inHouse
}
func makeAdPlan(
consent: ConsentState,
resolveRegion: () async -> AdRegion
) async -> [AdProvider] {
switch consent {
case .pending, .externalAdsBlocked:
return [.inHouse]
case .adsAllowed:
break
}
switch await resolveRegion() {
case .regionalMarket:
return [.regional, .global, .inHouse]
case .otherMarket, .unknown:
return [.global, .inHouse]
}
}
```
이 예시는 정책 하나를 코드로 옮긴 것이에요. 동의가 끝나지 않았거나 차단됐을 때 `[.inHouse]`를 돌려주는 것도, `unknown`에서 전역 공급자를 시도하는 것도 일반 규칙이 아니에요. `unknown`에서 전역 공급자를 시도할지는 서비스가 지원하는 지역과 동의 조건에 달려 있어요. 이 선택은 해석기 안에 숨기지 않고 체인 정책에서 드러내요.
함수 자체에는 중복 실행을 막는 장치도, 네트워크 timeout도, 로더도 없어요. 한 번만 실행하고 결과를 캐시하는 일은 호출하는 쪽이나 해석기가 작업을 재사용해서 구현해요. 로더는 배열 순서대로 시도하고 처음 성공한 곳에서 멈춰요. 실패한 공급자를 곧바로 다시 부르거나 체인 전체를 처음부터 다시 돌리지 않아요. `inHouse`는 외부 요청이 모두 실패했을 때 슬롯이 비지 않게 하는 마지막 선택지예요. 공급자를 추가할 때에는 해석기를 건드리지 않고 체인만 바꾸면 돼요.
iOS, Android, Web은 버킷과 우선순위의 계약을 공유할 수 있어요. 구현 모듈까지 합칠 필요는 없어요. 플랫폼마다 자기 SDK를 쓰되 `ConsentState → AdRegion → [AdProvider]`라는 계약만 지켜요.
## 예제 함수를 실행해 본 결과
위의 `makeAdPlan`을 그대로 컴파일해서 동의 상태 셋과 지역 버킷 셋의 조합을 모두 실행해 봤어요. 실행한 곳은 macOS 27.0.1의 Swift 6.4이고, 날짜는 2026년 10월 4일이에요. 지역을 돌려주는 클로저가 몇 번 불렸는지도 함께 찍었어요.
```text
pending + regionalMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + otherMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + unknown -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
adsAllowed + regionalMarket -> [geodemo.AdProvider.regional, geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + otherMarket -> [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + unknown -> [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
externalAdsBlocked + regionalMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + otherMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + unknown -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
```
`geodemo`는 실행 파일의 모듈 이름이에요. 동의가 `pending`이거나 외부 광고가 막혀 있으면 지역 조회가 한 번도 일어나지 않았고 인하우스만 나왔어요. 광고가 허용된 경우에만 조회가 한 번 일어났고, 지역 버킷일 때만 지역 공급자가 앞에 왔어요.
이 실행이 확인한 것은 순서를 정하는 함수까지예요. 실제 GeoIP 조회, 광고 SDK, 순서대로 시도하는 로더, timeout, 캐시는 실행하지 않았어요.
## 조회가 실패해도 멈추지 않게
지역 조회의 네트워크 요청에는 제한을 걸어요.
- 지정한 HTTPS 대상만 허용하고, 예상하지 못한 redirect는 막아요.
- timeout은 몇 초 이하로 두고, 화면을 그리는 중에 다시 요청하지 않아요.
- 응답 크기를 제한하고, 버킷 값은 허용 목록에 있는 것만 받아요.
- 맞지 않는 응답은 `unknown`으로 처리해요.
- 결과와 진행 중인 요청은 프로세스 안에서 재사용해요.
- IP와 도시의 원본 값은 디스크에 저장하지 않고 분석 로그에도 남기지 않아요.
HTTP 오류, timeout, 오프라인, 파싱 오류도 모두 평범한 실패값으로 다뤄요. timeout과 응답 크기의 구체적인 수치는 이 글에서 정하지 않았어요.
메모리 캐시에는 한계가 있어요. 망이 바뀐 뒤에도 이전 버킷을 계속 쓸 수 있어요. 거친 우선순위 힌트로는 받아들일 만하다고 판단했고, 다음에 프로세스가 시작될 때 다시 평가해요. 이 판단을 뒷받침하는 측정 결과는 이 글에 싣지 않았어요. 장기 캐시가 필요하다는 측정이 나오기 전에는 영구 저장과 만료 관리를 더하지 않아요.
## 권한이 없다고 익명인 것은 아니에요
OS의 위치 API를 쓰지 않으면 위치 권한 창은 뜨지 않아요. 하지만 GeoIP 서버와 CDN은 여전히 IP 주소를 처리해요. «권한이 필요 없다»를 «익명이다» 또는 «위치 데이터를 다루지 않는다»로 설명하면 정확하지 않아요.
실제 데이터 흐름을 놓고 목적, 보관, 제3자 전달, 동의와의 관계를 검토해야 해요. 앱에는 버킷만 내려 주고, 서버는 IP와 위치 헤더를 필요 이상으로 보존하지 않고, 라우팅에 쓴 값을 장기 프로필에 쓰지 않아요. 수집을 줄였다는 것과 법률이나 스토어 정책을 지켰다는 것은 별개예요. [Apple의 개인정보 보호 지침](https://developer.apple.com/design/human-interface-guidelines/privacy)도 이 관점에서 참고해요.
## 테스트로 고정할 것
실제 GeoIP와 광고 SDK를 호출하면 VPN, 망 상태, 광고 재고 때문에 결과가 실행마다 달라질 수 있어요. 단위 테스트에서는 지역 함수와 로더를 주입해서 아래 기댓값을 고정해요.
- 동의가 `pending`이면 지역 조회는 0회이고 인하우스를 골라요.
- 광고가 허용되고 지역 버킷이면 지역 공급자가 먼저예요.
- 지역 공급자가 실패하면 전역으로 한 번 넘어가요.
- timeout이나 응답 오류면 `unknown` 경로로 전역을 골라요.
- 외부 요청이 모두 실패하면 인하우스로 끝나요.
- 동의 완료 콜백이 두 번 와도 조회와 계획 생성은 각각 한 번이에요.
통합 환경에서는 오프라인, 응답 지연, 헤더 누락, 인코딩된 도시 값, VPN, Private Relay 조건을 확인해요. 통과 기준은 허용된 공급자 조합만 나오는지, 시간 제한 안에 끝나는지, 원시 민감값이 로그에 보이지 않는지예요. 실제 위치를 얼마나 맞혔는지는 통과 조건으로 삼지 않아요.
이 설계의 목표는 GeoIP가 틀려도 앱이 멈추지 않는 것이에요. 위의 기댓값과 통합 조건은 그 목표를 확인하는 데 쓸 기준이에요. 이 가운데 실행으로 확인한 것은 계획을 만드는 함수가 허용된 조합만 내놓는다는 것까지예요. 실제 조회가 시간 제한 안에 끝나는지와 로더의 동작을 확인한 결과는 이 글에 없어요.
---
# 광고 제거 일회성 구매의 권한을 유지하고 되돌리는 방법
> 광고 제거 상품은 한 번 사면 끝나는 것처럼 보이지만, 재설치, 대기 중인 결제, 환불을 거치면 구매 직후의 결과만으로는 보유 여부를 알 수 없어요. StoreKit 2와 Play Billing에서 현재 권한을 다시 읽어 캐시를 맞추는 구현과 남은 공백을 기록해요.
- Canonical: https://jaemyeong.com/ko/blog/one-time-remove-ads-entitlement-storekit-play-billing/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #Android, #StoreKit, #Google Play Billing, #In-App Purchase
광고를 영구히 없애는 상품을 스도쿠 앱 DailySudoku의 iOS판과 Android판에 넣었어요. 처음에는 구매가 성공했는지만 저장하면 될 것처럼 보였어요. 그런데 구매 직후의 결과만으로는 그 뒤의 상태를 판단하기 어려웠어요. 앱을 다시 설치하거나, 결제가 대기 상태로 남거나, 환불되는 경우가 있기 때문이에요.
소스의 기준은 2026년 8월 6일 develop 브랜치의 커밋 9c999305예요. 스토어에 출시된 판에서 확인한 것과는 별개예요. 아래에는 그 소스의 동작, Apple과 Google 문서의 설명, 자동 테스트가 다루는 범위를 구분해서 적었어요. 이 글에서 확인할 수 있는 것은 여기까지이고, 실기기에서 결제하고 환불한 기록과 실행 로그는 들어 있지 않아요.
## 구매 결과만 저장하면 놓치는 일
구매 알림은 입력 가운데 하나일 뿐이에요. 보유 여부는 지금의 entitlement 목록을 읽어서 갱신해요. 구매 결과만 저장하면 아래 일들을 놓쳐요.
- 사용자가 결제를 취소한 경우
- 대기 상태였던 결제가 앱이 꺼진 뒤에 완료된 경우
- 다른 기기에서 구입한 경우
- 데이터를 초기화했거나 새로 설치한 경우
- 환불, 취소, revoke가 일어난 경우
- 결과를 받기 전에 연결이 끊긴 경우
그래서 입력 세 갈래를 하나의 권한 상태로 모아요.
```text
구매 이벤트 ───────┐
앱 시작·복귀 조회 ─┼─> 검증된 현재 권한 ─> 로컬 캐시 ─> 광고 게이트
스토어 외부 변경 ──┘
```
갈래마다 맡은 일이 달라요. 구입 알림은 사용자에게 바로 안내하는 데 써요. 앱을 시작하거나 foreground로 돌아올 때의 조회는 놓친 변경과 다른 기기의 변경을 회복해요. update는 앱이 실행 중일 때의 변경을 받아요. 셋이 최종적으로 같은 `adFree` 값을 공유해요.
## 로컬에 둔 값은 캐시예요
기기에 저장한 Boolean은 화면을 그리기 위한 캐시이고, 소유권의 원본이 아니에요. snapshot과 update 모두 true와 false를 그대로 반영해요. 환불되면 false가 들어가서 광고가 다시 허용돼요.
여기에 문제가 하나 있어요. `adFree == false`는 «보유하지 않음»일 수도 있고 «아직 조회하지 않음»일 수도 있어요. 두 경우를 구분하지 않으면 구매한 사용자에게 앱을 켠 직후 광고가 나올 수 있어요. 그래서 조회를 마쳤는지를 `entitlementsSynced`로 따로 표시해요.
광고를 허용하는 조건은 세 값으로 계산해요.
```text
adsEnabled = entitlementsSynced && !adFree && consentAllowsAds
```
`entitlementsSynced == false`인 동안에는 광고를 요청하지 않아요. 구매하지 않은 사용자는 첫 복원이 끝난 뒤에 광고 경로로 들어가고, 구매한 사용자는 `adFree == true`라서 계속 막혀요. 설정, 홈, 난이도 선택, 게임, 통계 화면은 이 판단을 함께 써요.
광고 객체의 처리는 플랫폼마다 달라요. 막힌 상태일 때 Android에서는 Compose subtree를 아예 만들지 않고, 붙어 있던 native view를 해제해요. iOS에서는 배너를 접고 추가 load를 억제해요. iOS에서 이미 로드된 provider를 파기하는지는 자동 테스트로 검증하지 않아요. 광고가 화면에서 사라졌다는 것만으로 SDK의 작업이 끝났다고 말할 수는 없어요.
## iOS: StoreKit 2에서 읽는 것
구매는 [Product.purchase()](https://developer.apple.com/documentation/storekit/product/purchase(options:))의 결과에 따라 처리해요.
- `.success(.verified(transaction))`이면 권한을 부여하고 [transaction을 finish](https://developer.apple.com/documentation/storekit/finishing-a-transaction)해요.
- `.success(.unverified(...))`이면 권한을 부여하지 않아요.
- `.pending`과 `.userCancelled`에서도 부여하지 않아요.
실행 중의 변경은 [Transaction.updates](https://developer.apple.com/documentation/storekit/transaction/updates)로 관찰해요. Ask to Buy, 다른 기기에서의 구매, revoke가 여기로 와요. 앱을 시작할 때와 foreground로 돌아올 때에는 [Transaction.currentEntitlements](https://developer.apple.com/documentation/storekit/transaction/currententitlements)에서 광고 제거 상품의 product ID가 있는지와 verified인지를 확인해요. 문서에 따르면 이 목록은 non-consumable의 최신 entitlement를 담고, 환불되거나 revoke된 항목은 빠져요. 조회가 끝났는데 일치하는 상품이 없으면 false를 저장해요.
재설치하거나 새 기기를 쓸 때에는 최신 transaction이 자동으로 제공되므로, 평소에는 `currentEntitlements` 조회로 충분해요. 수동 복원은 사용자가 설정에서 복원을 고를 때에만 실행해요. 이때 [AppStore.sync()](https://developer.apple.com/documentation/storekit/appstore/sync())를 호출한 뒤 권한을 다시 조회해요. 이 호출은 인증 창을 띄울 수 있어서 앱을 시작할 때 자동으로 실행하지 않아요.
가격은 따로 갱신해요. 소유권과 동의의 흐름이 가격 응답을 기다릴 필요는 없어요.
## Android: Play Billing에서 읽는 것
상품 유형은 Android에서 INAPP이에요. [일회성 상품](https://developer.android.com/google/play/billing/one-time-products)에는 non-consumable을 위한 별도 API가 없어서, consume을 하지 않고 acknowledge를 해요.
흐름은 [Play Billing 연동 절차](https://developer.android.com/google/play/billing/integrate)를 따라요. `queryProductDetailsAsync`로 그 사용자에게 팔 수 있는 상품과 현지화된 가격을 받고, 받은 `ProductDetails`와 offer token으로 `launchBillingFlow`를 호출해요. 구매는 `PurchasesUpdatedListener`로 오거나, 나중의 `queryPurchasesAsync`에서 발견돼요.
권한은 product ID와 구매 상태를 확인해서 `PURCHASED`일 때에만 부여하고, 소모하지 않는 구매이므로 acknowledge해요. `PENDING`은 실패가 확정된 것이 아니에요. 현금 결제나 추가 승인처럼 완료를 기다리는 상태예요. Google의 지침은 `PENDING`인 동안 혜택을 주지 말고 `PURCHASED`로 바뀐 뒤에 처리하라는 것이에요. 상태는 앱이 꺼져 있는 동안에도 바뀔 수 있어서, `onResume()`에서 `queryPurchasesAsync()`로 놓친 변경을 회복해요.
검증과 acknowledge는 다른 일이에요. 검증은 구매가 정당한지와 이미 처리한 것인지를 보는 일이고, acknowledge는 혜택을 전달했다고 Play에 알리는 일이에요. 문서에 따르면 일반 구매는 `PURCHASED`로 바뀐 뒤 3일 안에 acknowledge하지 않으면 자동으로 환불될 수 있고, 라이선스 테스터의 구매는 이 시한이 3분이에요.
지금 구현이 믿는 범위는 좁아요. BillingClient가 알려 준 `PURCHASED`와 product ID를 확인하고 클라이언트에서 acknowledge해요. 서버에서 purchase token을 검증하지 않아요. [Google의 결제 보안 안내](https://developer.android.com/google/play/billing/security)는 혜택을 주기 전에 secure backend에서 Google Play Developer API로 검증하고, acknowledge도 서버에서 하기를 권해요. 지금은 클라이언트를 믿는 구성을 받아들인 것이고, 서버 검증을 마쳤다고 말할 수 없어요.
## 복원, 환불, 그리고 조회 실패
복원의 목적은 과거에 샀는지를 찾는 것이 아니에요. 지금의 snapshot과 캐시를 일치시키는 것이에요. 시작할 때와 돌아올 때 Android에서는 `queryPurchasesAsync(INAPP)`로 조회하고, 실행 중의 변경은 listener로 받거나 다음 resume의 조회에서 회복해요. 수동 복원도 현재 inventory를 다시 조회하는 것이에요.
환불이 반영되는 시점은 플랫폼마다 달라요. iOS는 update나 그 뒤의 snapshot에서 반영돼요. 다음 inventory 조회가 성공했을 때 Android의 값이 false가 돼요.
빈 결과와 오류는 구별해야 해요. 상품이 없다는 것과 스토어에 접속하지 못했다는 것은 다른 사건이에요. 지금 Android는 연결이나 inventory 조회에 오류가 나면 `adFree`를 그대로 두고 `entitlementsSynced = true`로 해요. 이 정책에는 양쪽으로 불이익이 있어요. 재설치한 구매자는 캐시가 false라서 복구되기 전까지 광고를 볼 수 있어요. 환불한 사용자는 조회가 성공하기 전까지 광고가 계속 막힐 수 있어요.
iOS의 모델에도 빈 곳이 있어요. verified이면서 일치하는 항목이 없으면 false가 되는데, unverified인 경우나 일시적으로 잘못 비어 있는 경우를 따로 분류하지 않아요. 두 플랫폼의 장애 정책은 서로 다르고, 어느 쪽도 안전하다는 보장은 아니에요.
개선안은 상태를 `unknown`, `owned`, `notOwned`로 나누거나, 오류와 성공한 빈 결과를 구별하는 것이에요. 그 위에서 제품마다 fail-open으로 할지 fail-closed로 할지 정해요. 아직 제안이고 고친 것이 아니에요.
동시에 도는 복원도 확인해야 해요. 캐시에 쓰는 비동기 경로는 구매, update, 시작, resume, 수동 복원 다섯 개예요. 시작 조회와 foreground 조회가 어떤 순서로 끝나는지가 문제이고, connection을 잠그는 것만으로는 reconcile 전체가 직렬화되지 않아요. 늦게 도착한 과거의 snapshot이 새 값을 덮어쓸 수 있어요.
## 가격을 받지 못했을 때
가격은 스토어가 주는 현지화된 값을 쓰고, 고정된 가격을 화면에 적지 않아요. 두 플랫폼의 설정 화면은 가격이 nil이면 광고 제거 행을 숨겨요. 그런데 수동 복원이 그 행의 화면에 있어요.
그 결과 가격 조회가 실패하면 수동으로 다시 시도할 입구가 사라져요. 시작과 resume의 자동 복구는 계속 돌아요. 등록되지 않은 상품과 일시적인 네트워크 장애를 화면에서 구별하지도 못해요.
개선안은 구매 버튼만 비활성화하고 복원은 따로 보여 주는 것, 그리고 상품 조회의 빈 응답과 예외를 재시도와 관측에서 구별하는 것이에요. 이것도 제안이에요.
## 시험할 수단과 기댓값
Apple 쪽에서는 [Xcode의 StoreKit configuration](https://developer.apple.com/documentation/storekit/testing-in-app-purchases-in-xcode)으로 transaction, 중단된 구매, refund, revoke를 로컬에서 시험할 수 있어요. 계정이 관여하는 흐름은 Sandbox나 TestFlight에서 봐요.
[license tester](https://developer.android.com/google/play/billing/test)를 쓰면 Android에서 실제 구매 흐름과 테스트 결제 수단을 시험할 수 있어요. package name과 tester 계정 조건이 맞으면 debug 서명으로 sideload한 빌드에서도 시험할 수 있고, 테스트 트랙은 배포하기 전에 확인하는 용도예요. ADB로 직접 설치하면 무조건 실패한다거나 Play로 설치한 것만 된다는 단정은 맞지 않아요.
아래는 확인하려는 기댓값이에요. 실행해서 통과한 기록이 아니에요.
- 신규 구매: 광고 요청이 멈추고, 다시 실행해도 유지되고, 다시 구매할 수 없어요.
- 사용자 취소: 권한도 성공 메시지도 없고, 다시 구매를 시도할 수 있어요.
- 대기 뒤 승인: 대기 중에는 혜택이 없고, `PURCHASED`가 된 뒤 한 번 부여돼요.
- 대기 뒤 취소: 혜택이 부여되지 않아요.
- 이미 보유: 새 기기, 재설치, 수동 복원에서 보유가 회복돼요.
- 앱 밖에서 완료: update나 다음 resume에서 회복돼요.
- 통신 단절: 성공한 빈 응답과 조회 오류를 구별해요.
- acknowledge 실패: 다시 시도해서 정해진 기한 안에 끝내요.
- 환불과 revoke: 캐시가 false가 되고 광고가 다시 허용돼요.
- 빠른 앱 전환: background와 foreground를 반복해도 과거 조회가 덮어쓰지 않아요.
자동 테스트가 다루는 범위는 좁아요. iOS에서는 보유하지 않은 상태의 복원이 false를 저장하는 것과 가격 갱신을 다뤄요. hosted unit test로는 실제 구매가 성공하는 UI 경로를 다루지 못해요. `PlayBillingProvider`의 구매와 복원, pending 안내, acknowledgement 실패, Settings의 행, 광고 gate의 진리표 전체는 Android 쪽 자동 테스트에 없어요. 로컬 테스트가 통과했다는 것만으로 실제 스토어 결제를 확인했다고 말할 수 없어요.
남은 일은 Android의 서버 검증, pending 상태의 화면, 오류 모델, 동시 reconcile, 복원 입구, 실기기에서의 스토어 시나리오 확인이에요. acknowledge의 3일과 3분 시한, offer token의 조건, debug 서명 sideload의 자격은 기준일 당시의 문서 설명이므로, 정책이나 라이브러리가 바뀌면 공식 문서에서 다시 확인해야 해요.
---
# 검증 스크립트를 일부러 깨진 입력으로 검증하기
> 검사 스크립트가 종료 상태 0으로 끝났다고 해서 규칙을 검사했다는 뜻은 아니에요. 정상 샘플을 한 곳만 깨뜨린 사본을 넣어, 기대한 분류로 실패하는지 확인하는 방법과 Python 예제를 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/verification-script-negative-control/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Testing, #CI-CD, #Automation, #Troubleshooting
출시 전에 돌리는 자동 점검이 전부 통과했는데도, 결과물에 필수 파일이 없거나 언어별로 달라야 할 내용이 똑같이 들어 있을 때가 있어요. 크로스 플랫폼 앱의 리소스와 메타데이터를 검사하는 스크립트에서 생기는 일이에요. 점검은 끝까지 돌았지만 지켜야 할 규칙은 한 번도 확인하지 않은 상태예요.
검사 스크립트가 결함을 실제로 잡는지는 결함이 든 입력을 넣어 봐야 알 수 있어요. 정상 샘플의 사본에 결함을 하나 넣고, 검사가 기대한 분류로 실패하는지 보는 방법을 정리해요. 플랫폼별 빌드와 테스트 조합은 다루지 않아요. 예제는 작은 검증기를 만들어 Python 3.14.5로 실행해 봤고, 그 결과를 예제 아래에 적었어요.
## 종료 상태 0이 말해 주지 않는 것
검사가 규칙을 건너뛰고도 성공으로 끝나는 경로는 여럿이에요.
- glob이 맞지 않아 검사 대상이 0개가 돼요.
- 도구나 환경 변수가 없을 때 성공으로 처리해요.
- 생성하는 쪽과 검사하는 쪽이 같은 잘못된 기본값을 함께 써요.
어느 경우에도 종료 상태는 0일 수 있으므로, 0이라는 값만으로는 규칙을 검사했는지 판단할 수 없어요. [pytest의 종료 코드](https://docs.pytest.org/en/9.0.x/reference/exit-codes.html)도 이 둘을 구별해요. 수집한 테스트가 모두 통과하면 0이고, 수집한 테스트가 없으면 5예요.
직접 만드는 검사 스크립트에도 같은 구별을 두기를 제안해요. 필수 결과물을 찾지 못한 것은 입력 계약의 문제이고, 필수 CI에서 도구나 입력이 없는 것은 설정의 문제예요. 선택 점검을 건너뛴 것은 사유를 기록한 별도의 상태로 둬요. 상태의 후보는 pass, validation-failure, invalid-setup, skip 넷이에요. 이 넷은 pytest 문서가 정한 것이 아니고 제 설계예요.
실패를 판정하려면 종료 상태 말고도 정보가 더 있어야 해요.
- `category` 또는 `rule_id`
- 처리한 입력의 수와 필수 점검의 수
- 환경 오류와 계약 위반을 나누는 종료 상태
- 빈 대상과 허용된 생략의 구분
«비정상 종료했다»만 요구하면 문법 오류나 권한 문제까지 결함을 잡은 것으로 잘못 셀 수 있어요. 이미지 크기를 어긴 샘플이라면 기대하는 분류는 `wrong_size`예요. 파일을 읽지 못해서 나온 `internal_error`는 그 규칙이 동작했다는 증거가 아니에요. CI가 비교하는 기준은 안정된 분류값으로 하고, 설명 메시지는 원인을 조사할 때 써요.
## 결함을 하나만 넣은 샘플
이 글에서 negative control은 거부돼야 하는 조건을 갖춘 결함 샘플을 가리켜요. 통계나 실험 분야의 정의를 그대로 옮긴 말은 아니에요. 정상 자료의 사본에 허용되지 않는 값 `dark`를 넣고 `invalid_value`가 나오기를 요구하면, 입력을 읽는 곳부터 오류를 전달하는 곳까지 한 번에 점검할 수 있어요.
샘플은 검토를 마친 fixture를 임시 위치에 복제해서 바꾸고, 끝나면 버려요. 실제 배포 파일은 건드리지 않아요. 정상 자료가 거부되거나 결함 자료가 통과하면 그 검사는 믿을 조건을 채우지 못한 거예요.
정상 샘플에 계약 위반을 넣는 조작을 이 글에서는 mutation이라고 불러요. 한 번 실행할 때 규칙을 하나만 바꿔요. 허용되지 않는 값 `dark` 넣기, 필수 파일 지우기, 두 locale의 내용을 같게 만들기, 이미지의 크기 정보 바꾸기가 그 예예요. 결함을 여러 개 한꺼번에 넣으면 어느 것이 잡혔는지 가리기 어렵고, 첫 실패에서 멈추는 도구라면 뒤의 규칙은 확인하지 못해요. 그래서 조작 하나에 기대 분류 하나를 짝지어요.
[Stryker](https://stryker-mutator.io/docs/)나 PIT 같은 도구도 mutation이라는 말을 써요. 이 도구들은 프로그램 코드를 바꿔 놓고 테스트가 반응하는지 봐요. [Stryker의 상태 설명](https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/)에서 테스트가 하나라도 실패하면 mutant는 killed이고, 모두 통과하면 survived예요. [PIT의 개념 안내](https://pitest.org/quickstart/basic_concepts/)에는 바꿔도 동작이 달라지지 않는 equivalent mutation이 나와요. 그런 변경은 테스트로 가려낼 수 없어요. 입력을 손으로 바꿀 때에도 마찬가지여서, 계약을 실제로 어기는 사례를 골라야 해요. 여기까지가 문서에 있는 내용이고, 이 개념을 입력 파일의 검사에 옮겨 쓰는 것은 제 해석이에요. 테스트가 실패하는 것을 보고 테스트를 믿는다는 생각은 [Google Testing Blog의 글](https://testing.googleblog.com/2007/04/tott-refactoring-tests-in-red.html)에도 있어요.
## baseline을 먼저 확인하는 예제
결함 샘플을 넣기 전에 정상 샘플이 통과하는지부터 확인해요. 종료 상태가 0이고 실패가 0건이어야 해요. 이 조건이 없으면 이미 있던 결함 때문에 난 비정상 종료를 새 결함을 잡은 것으로 셀 수 있어요.
예제는 검증 결과 JSON에 `failures`의 `category`와 `checked`가 들어 있다고 전제해요. 종료 상태 0은 통과, 1은 계약 위반, 나머지는 설정이나 내부의 문제예요. 입력 계약은 4개예요. 앞부분은 검증기를 실행해 결과를 읽는 함수와 정상 샘플의 확인이에요.
```python
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
FIXTURE = Path("validator-fixture")
PASS = 0
VALIDATION_FAILED = 1
REQUIRED_CHECKS = 4
def validate(root: Path) -> tuple[int, set[str], int]:
result = subprocess.run(
["python", "validate.py", str(root), "--json"],
check=False,
capture_output=True,
text=True,
)
report = json.loads(result.stdout)
failures = {item["category"] for item in report["failures"]}
return result.returncode, failures, report["checked"]
baseline_code, baseline_failures, baseline_checked = validate(FIXTURE)
if baseline_code != PASS or baseline_failures or baseline_checked != REQUIRED_CHECKS:
raise SystemExit(
f"invalid baseline: checked={baseline_checked}, "
f"failures={sorted(baseline_failures)}"
)
```
이어지는 부분은 조작 네 가지를 각각 새 임시 디렉터리의 복제본에 적용해요. 위 블록의 이름을 그대로 써요.
```python
cases = [
("invalid-value", "invalid_value"),
("missing-file", "missing_file"),
("duplicate-content", "duplicate_content"),
("wrong-size", "wrong_size"),
]
if not cases:
raise SystemExit("no mutation cases")
for mutation, expected_category in cases:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp) / "fixture"
shutil.copytree(FIXTURE, root)
subprocess.run(["python", "mutate.py", mutation, str(root)], check=True)
code, failures, checked = validate(root)
introduced_failures = failures - baseline_failures
if code != VALIDATION_FAILED or checked == 0 or expected_category not in introduced_failures:
raise SystemExit(
f"survived mutation: {mutation}, checked={checked}, "
f"failures={sorted(failures)}"
)
```
조작 사례의 목록이 비어 있으면 반복문에 들어가기 전에 실패로 끝나요. 조작한 뒤의 실패 집합에서 baseline의 실패 집합을 뺀 것이 새로 생긴 실패예요. 종료 상태가 1이고, 검사 수가 0이 아니고, 새 실패에 기대한 분류가 들어 있어야 통과예요. 이 예제에서는 baseline의 실패가 비어 있어야 다음으로 넘어가므로, 집합의 차는 조작 뒤의 실패 집합과 같아요. 기대한 분류 말고 다른 분류가 함께 나와도 통과로 봐요.
조건이 맞지 않으면 `SystemExit`를 일으켜요. `assert`를 쓰지 않은 이유는 [Python의 `-O` 옵션](https://docs.python.org/3/using/cmdline.html#cmdoption-O)이 assert 문을 제거하기 때문이에요. CI의 판정은 명시적인 조건 분기로 해요.
`validate.py`와 `mutate.py`, fixture의 실물은 이 글에 없어요. 실행해 볼 때에는 규칙 넷짜리 작은 검증기를 따로 만들었어요. 정상 검증기에서는 네 조작이 모두 검출됐어요. 조작한 사본에서 검사를 0건 하고도 기대한 분류를 보고하도록 검증기를 바꿔 봤더니, `checked == 0` 조건이 없는 판은 통과로 끝났고 조건을 넣은 판은 `survived mutation`으로 끝났어요. 이 조건과 빈 목록의 검사는 원래의 예제에 없던 것을 보탠 것이에요. JSON을 해석하지 못할 때의 처리와 환경 오류를 따로 다루는 코드도 예제에 없어요.
## 판정을 생성 코드와 떼어 놓기
생성 코드와 검사 코드가 같은 판단 함수를 쓰거나, 최신 생성물만으로 fixture를 만들면 같은 오류가 양쪽에서 가려질 수 있어요. 중립적인 schema를 함께 쓰는 것은 괜찮아요. 허용 값, 크기, 고유성의 기준은 명시된 계약과 사람이 검토한 자료에서 가져와요.
CI에서는 핵심 규칙마다 결함 샘플을 하나씩 두고, 검증 도구나 탐색 코드가 바뀔 때 실행하기를 제안해요. 순서는 이래요.
1. 도구와 fixture가 있는지 확인해요.
2. 정상 샘플의 상태와 최소 검사 수를 확인해요.
3. 사본마다 조작을 하나씩 적용해요.
4. 기대한 분류가 검출됐는지 확인해요.
필수 CI에서는 도구나 입력이 없을 때, 테스트 수가 0일 때, 검사 수가 0일 때, 조작 사례가 0개일 때 실패로 처리해요. 선택 항목의 생략만 이유와 함께 허용해요. 결함이 검출되지 않았다면 파일 탐색 방식, 오류 전달, 기대 분류의 설정을 살펴봐요. 정상 기준의 결과와 기대한 분류, 관측한 분류를 CI artifact로 남겨 두면 조사하기 쉬워요.
검토를 마친 샘플 몇 개로 시작할 수 있어요. 다만 이 예제가 비교하는 것은 `category`의 집합뿐이어서, 같은 분류의 실패가 여러 곳에서 나도 구별하지 못해요. 실패한 위치까지 봐야 하면 `rule_id`와 정규화한 상대 경로의 조합을 써요. CI 절차는 검사 수가 최소 기준 이상인지를 보지만, 예제는 정상 샘플의 검사 수가 4와 같은지를 봐요. 조작한 사본의 검사 수는 0인지만 보고, 최소 기준과 대조하지는 않아요. 동작이 달라지지 않는 변경은 이 방법으로도 가려낼 수 없어요.
---
# 감시자를 누가 감시하나: 사라진 예약 작업 찾기
> 예약 작업이 지워지면 오류도 남지 않아요. 기대하는 역할과 scheduler에 실제로 있는 객체를 대조하고, 마지막 성공 시각으로 경보를 거는 방법을 Kubernetes, GitHub Actions, Prometheus 문서와 함께 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/watch-the-watcher-retry-cron-reconciliation/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #CI-CD, #Automation, #Observability, #Reliability, #Cron
앱의 자동화를 점검하다가 기록과 실제가 어긋난 것을 본 적이 있어요. 작업 원장에는 retry 작업을 등록했다는 기록이 있었는데, scheduler를 조회하니 gate poller 하나만 나왔어요. gate가 맡은 일은 계속 처리되고 있었기 때문에 retry가 빠진 것이 드러나지 않았어요. 언제 왜 지워졌는지를 특정할 근거는 부족했어요.
지워진 예약 작업은 스스로 실패를 알리지 못해요. 그런 누락을 찾기 위한 상태 대조와, 감시가 감시 대상과 함께 멈추지 않게 하는 구성을 정리해요. 여기서 cron은 Unix crontab만이 아니고 GitHub Actions의 schedule과 Kubernetes의 CronJob도 포함해요. 조회 출력이나 로그의 원본, 복구한 뒤의 실측은 싣지 않았어요. 그때 쓴 scheduler와 도구의 버전, 실행 환경도 기록이 남아 있지 않아요.
## 오류가 0건인 것과 실행이 0건인 것
실패의 모양에 따라 남는 흔적이 달라요.
- 실행 중에 오류가 나면 시작 기록, 오류, 실패 횟수가 남고 내부에서 다시 시도할 수 있어요.
- 실행 도중에 멈추면 시작 기록과 낡은 heartbeat로 알 수 있어요. 내부에서 복구할 수 있는지는 조건에 따라 달라요.
- 비활성으로 바뀐 객체는 scheduler에 남아 있지만 내부에서 복구할 수 없어요.
- 객체가 지워졌거나 scheduler가 멈추면 내부에서 올라오는 보고 자체가 없고, 내부 복구도 안 돼요.
그래서 오류가 0건이라는 것만으로는 정상이라고 판단할 수 없어요. 실행이 0건일 가능성부터 배제해야 해요.
## 원장에는 역할을, 조회 결과에는 실제 객체를
원장에 적힌 ID는 조회할 때 쓰는 참조일 뿐이고, 지금 등록돼 있다는 증거는 아니에요. 기대 상태(desired)에는 역할, 주기, 명령을 두고, 실제 상태(actual)에는 조회된 객체와 활성 여부를 둬요. 객체를 다시 만들면 ID가 바뀔 수 있으므로 `retry`, `gate` 같은 역할 키로 대조해요. schedule과 실행 템플릿에 버전을 붙여 두면 오래된 설정도 찾을 수 있어요. 역할 집합을 비교하는 의사코드는 이래요.
```text
expected = desired_jobs_by_role
actual = scheduler.list()
missing = expected.roles - actual.roles
duplicates = actual.group_by(role).where(count > 1)
mismatched = actual.where(spec_version != expected.spec_version)
```
비교의 뜻을 적은 것이고, 실행한 결과는 이 글에 없어요. 이 의사코드만으로는 놓치는 경우가 둘 있어요. 첫째, 버전만 비교하면 누군가 버전을 올리지 않고 schedule이나 명령을 고쳤을 때 잡지 못해요. 실제 schedule과 명령, 또는 기대 명세 전체의 해시를 역할마다 비교해야 해요. 둘째, 기대 목록에 없는 역할의 객체는 missing도 duplicate도 아니어서 그대로 남아요. `actual.roles - expected.roles`도 따로 분류하고, 자동으로 지우지 않고 소유권을 조사하도록 경보를 내요.
기대 상태 하나에 관측 단계 넷을 맞춰 봐요.
- desired: 버전이 붙은 명세에 적힌 기대 역할
- registered: 실제 객체가 있는지와 enabled 또는 suspend 상태
- executed: 마지막 시작 시각과 실행 이력
- succeeded: 제한 시간 안에 있는 마지막 성공
- progressed: checkpoint, queue의 깊이, 완료 시각
단계마다 서로 다른 것을 알려 줘요. [Kubernetes의 CronJob 문서](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)에 따르면 `.spec.suspend`가 true이면 객체가 있어도 그 뒤의 실행이 멈춰요. 시작한 뒤에 멈추거나 오류가 날 수도 있어요. 성공 시각이 최신이어도 입력이 비어 있었는지, 조건이 잘못됐는지는 checkpoint를 봐야 가려져요. 처음에는 실제 객체 목록과 마지막 성공 시각부터 확보하고, 필요해지면 업무 지표로 넓히기를 제안해요.
## 같은 scheduler 안의 감시가 보지 못하는 것
처음에 본 사례의 구조에는 결함이 있었어요. retry가 다른 poller를 복구하는 한 방향의 관계였고, 상대 poller는 자기 업무만 처리했어요. retry가 지워지면 복구를 맡을 쪽이 없어요. 서로 점검하게 해도, 같은 scheduler에서 함께 생기는 장애에는 소용이 없어요.
세션을 다시 시작할 때나 설정을 바꿀 때 대조하면 기본적인 보완책은 돼요. 다만 점검과 점검 사이에는 공백이 생겨요. 늘 감지해야 한다면 바깥의 dead-man monitor가 마지막 성공 시각을 확인하게 해요. 바깥의 monitor와 알림 전달도 점검 대상이에요. 목적은 감시 작업과 감시 대상이 같은 scheduler의 중단으로 함께 멈추지 않도록, 둘이 실행되는 기반을 나누는 것이에요.
[Google SRE 책의 감시 장](https://sre.google/sre-book/monitoring-distributed-systems/)은 시스템 내부가 내놓는 지표에 기대는 감시를 white-box, 사용자가 보는 것처럼 바깥에서 동작을 시험하는 감시를 black-box라고 불러요. 이 구분으로 보면 내부 목록, 실행 이력, heartbeat는 white-box예요. 별도의 시스템이 사용자 관점의 최종 결과를 직접 검사해야 black-box이고, 감시를 바깥에 설치했다는 것만으로 black-box가 되지는 않는다는 것이 제 해석이에요.
## 대조하고 고치는 순서
복구는 다섯 단계로 나눠요.
```text
observe → classify → re-read → repair → verify
```
목록과 활성 여부를 조회하고, 결과를 missing, duplicate, inactive, drift, 그리고 기대 목록에 없는 역할인 unexpected로 분류해요. 경쟁 상태나 일시적인 지연을 걸러 내기 위해 한 번 더 조회해요. 그런 다음 missing만 버전 관리되는 literal template으로 생성하고, 역할, schedule, 명령, spec version을 다시 확인한 뒤 원장의 ID를 고쳐요. 기대 역할마다 객체가 하나씩 있게 하는 것이 목표예요. 같은 역할의 객체가 여럿이면 duplicate 경보를, 기대 목록에 없는 역할이면 unexpected 경보를 내요. 어느 쪽이든 소유권을 알 수 없는 객체는 지우지 않아요.
자동으로 고치는 대상은 소유권과 명세가 확실한 missing으로 제한해요. 나머지 상태는 이렇게 다뤄요.
- inactive: 변경 내역을 확인한 뒤 명시적으로 활성화해요.
- duplicate: 동시에 실행될 위험을 경보로 알리고, 소유권은 사람이 조사해요.
- drift: 기대 버전과 변경한 사람을 확인해요.
- unexpected: 누가 왜 만들었는지 사람이 조사해요. 자동으로 지우지 않아요.
누군가 의도해서 만든 inactive나 drift를 덮어쓰면 안 돼요. 이 대조는 프로세스를 시작하거나 재개할 때, 그리고 설정을 바꾼 직후에 해요. 감지가 더 빨라야 하면 바깥에서 하는 정기 점검을 더해요.
## 마지막 성공 시각으로 경보하기
[Prometheus의 계측 안내](https://prometheus.io/docs/practices/instrumentation/)는 batch job의 핵심 지표를 마지막으로 성공한 시각이라고 적어요. 같은 문서는 경과 시간을 추적하려면 경과 시간이 아니라 일이 일어난 Unix timestamp를 내보내라고 해요. 경과 시간은 조회할 때 계산해요. retry의 마지막 성공 뒤로 지난 시간이 임계값을 넘는지 보는 조건식은 이래요.
```text
time() - job_last_success_timestamp_seconds{job="retry"} > stale_after_seconds
```
임계값의 출발점으로는 실행 주기의 2배에 예상되는 최대 소요 시간을 더한 값을 제안해요. [Prometheus의 경보 안내](https://prometheus.io/docs/practices/alerting/)도 batch job이 충분히 최근에 성공하지 못했을 때 알리되, 그 시간은 보통 전체 실행 2회분 이상이어야 한다고 적어요. 이 값은 실측으로 정한 수치가 아니에요. 실행 시간이 얼마나 흩어지는지와 감지 지연을 얼마나 허용할지에 따라 조정해요. 한 주기 늦게 정상으로 성공한 경우는 grace window 안에서 경보를 미루고, 시작한 뒤 timeout을 넘긴 경우는 stale execution이나 hang으로 분류해요.
시계열이 아예 없는 경우는 따로 봐요. [PromQL의 함수 문서](https://prometheus.io/docs/prometheus/latest/querying/functions/)에 `absent()`와 `absent_over_time()`이 있어요. [Pushgateway 안내](https://prometheus.io/docs/practices/pushing/)에 따르면 Pushgateway는 받은 시계열을 스스로 지우지 않아요. 직접 지우기 전까지 계속 내보내므로 낡은 성공 값이 남을 수 있어요. 그래서 시계열이 없는 것과 성공 시각이 낡은 것을 구분해요.
- 객체가 없으면 등록과 대조 과정, 실제 목록을 봐요.
- 성공 metric이 없으면 계측, 수집, 첫 실행, scrape나 push를 봐요.
- 성공 시각이 낡았으면 지연, 실패, 정지를 이력과 로그에서 찾아요.
관측 단계에 따라 조사할 곳도 달라요.
- 최근에 시작한 기록이 없으면 trigger와 scheduler를 봐요.
- 시작은 했는데 성공이 없으면 로그와 timeout을 봐요.
- 성공은 최신인데 checkpoint가 멈춰 있으면 입력과 업무 조건을 봐요.
- watcher의 heartbeat가 없으면 바깥의 probe와 알림을 봐요.
scheduler가 무엇을 보장하는지도 제품마다 달라요. Kubernetes 문서에 따르면 CronJob은 실행 시각마다 Job을 대략 한 번 만들어요. 상황에 따라 Job이 둘 생기거나 아예 생기지 않을 수 있어서, Job은 멱등이어야 해요. `startingDeadlineSeconds`와 `concurrencyPolicy`는 지연과 동시 실행을 조절하는 설정이고, 정확히 한 번 실행된다는 보장은 아니에요. [GitHub Actions의 schedule 안내](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule)에 따르면 부하가 높은 시간에는 schedule 이벤트가 늦어지거나 대기 중인 작업이 버려질 수 있고, workflow 파일이 기본 브랜치에 있어야 하며 기본 브랜치에서만 실행돼요. 제품이 달라도 공통으로 볼 것은 객체, 실행 이력, 성공 시각이에요.
집합을 비교하고 분류하는 부분은 단위 테스트로 확인할 수 있어요. 실제의 지연, 중복, 누락은 staging이나 격리된 운영 점검에서 확인할 대상이에요. 실행한 결과는 이 글에 없어요. 처음의 누락이 왜 생겼는지도 확정하지 못했어요. 간헐적으로 대조하면 감지에 공백이 남고, 바깥의 monitor와 알림도 고장 날 수 있어요. 생성한 결과가 조회에 반영되기까지 지연이 있을 수 있어요. 생성 직후에 바로 다시 조회하면 아직 없는 것으로 보여서 한 번 더 만들 수 있어요. 객체가 보일 때까지 한도를 두고 기다렸다가 확인하거나, 역할 키를 고유한 이름으로 써서 생성이 멱등이 되게 해야 중복을 피해요.
---
# Rust shared core, part 7: splitting tests and CI by layer
> Passing Rust tests still leaves iOS linking, Android release ABIs, and Wasm loading in web builds to break. Test the domain in Rust and build fresh in CI.
- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-07-testing-ci/
- Published: 2026.07.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #CI-CD, #Testing, #UniFFI, #FFI
The goal of checking the common rules in Rust and having three clients consume the result is not only less code. The results have to match. Yet each platform has places that can still break after the Rust tests pass.
- iOS: the Rust tests pass, and linking fails.
- Android: the debug build works, and an ABI is missing from the release AAB.
- Web: the dev server works, and wasm loading fails in the production build.
These three are cases that can happen, not a record of incidents I went through. This part sorts out which layer's checks should catch such failures.
## Domain checks belong in Rust
I recommend concentrating the checks on the rules in the Rust engine. For Sudoku, that covers puzzle generation, the correctness of the solver, invalid number input, the reducer for toggling notes, the reproducibility of the daily seed, difficulty classification, the serialization round trip, boundary values, and error conversion. I recommend setting them up to run with `cargo test`, with no mobile simulator or emulator.
It is good to pin the result for the same seed and the same sequence of actions. What can make results differ is the time, the locale, the source of randomness, and the time zone slipping in implicitly. So I suggest taking them as input: a timestamp parameter in place of reading the current time, a specified seed for randomness, and a locale code received from outside.
## Shared fixtures and the responsibility of each layer
I suggest using JSON fixtures that hold input and expected values as the contract. These are examples of files that could be there.
```text
fixtures/
start-game-easy-seed-001.json
apply-action-set-value-valid.json
apply-action-set-value-conflict.json
daily-puzzle-2026-06-24.json
completed-game-score.json
```
The list above gives example file names. Two of them were actually written in an example, and their content is in the section "Fixtures read from both sides in an example." In this design the Rust tests and the tests on each platform read the same fixture files directly. In the example, the two sides that actually read them are Rust and Node. A passing fixture means that range of input was checked.
Each layer checks something different.
```text
Rust test
-> 도메인 규칙이 맞는가
iOS test
-> Swift adapter가 Rust 결과를 맞게 해석하는가
Android test
-> Kotlin adapter와 native library 연결이 맞는가
Web test
-> wasm package와 TypeScript wrapper가 맞는가
```
In order, the Korean questions read: are the domain rules right, does the Swift adapter interpret the Rust result correctly, is the connection between the Kotlin adapter and the native library right, and do the wasm package and the TypeScript wrapper match.
iOS checks how Swift interprets the DTOs. Android checks the Kotlin conversion, the serialization settings, and the native linking. The web checks how TypeScript converts the values returned by wasm, and the package linking. I do not think each client needs to test the whole algorithm of the engine again. This division is not set by the [UniFFI](https://mozilla.github.io/uniffi-rs/latest/) documentation or that of any other tool. It is my own design choice.
## Fixtures read from both sides in an example
In a small example I wrote two fixtures and had a Rust test and a web-side test read the same files. It is not a real app, only a minimal workspace for checking the structure. The tools were cargo 1.96.0, wasm-pack 0.15.0, and Node 24.14.1, and the date was October 4, 2026. The fixture for an input that conflicts looks like this.
```json
{
"request": { "seed": 0 },
"action": { "type": "set_value", "row": 0, "col": 2, "value": 5 },
"expected": { "ok": false, "cell_index": 2, "cell": "0", "error_code": "conflict" }
}
```
The Rust test reads every file in the directory and compares the result with the expected values. It also checks the number of files, so a missing fixture fails the test. This is `fixtures.rs` without its imports and its other tests.
```rust
const ENV: &str = r#"{"locale":"ko-KR"}"#;
// 저장소의 fixtures/ 를 그대로 읽는다. Web 쪽 테스트도 같은 파일을 읽는다.
#[test]
fn fixtures_match_expected() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
let mut checked = 0;
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
let fixture: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let snapshot = sudoku_core::start_game_json(&fixture["request"].to_string());
let result: Value =
serde_json::from_str(&sudoku_core::apply_action_json(&snapshot, &fixture["action"].to_string(), ENV)).unwrap();
let expected = &fixture["expected"];
assert_eq!(result["ok"], expected["ok"], "{path:?}");
assert_eq!(result["error"]["code"], expected["error_code"], "{path:?}");
if result["ok"] == true {
let next: Value = serde_json::from_str(result["snapshot"].as_str().unwrap()).unwrap();
let index = expected["cell_index"].as_u64().unwrap() as usize;
assert_eq!(&next["cells"].as_str().unwrap()[index..index + 1], expected["cell"].as_str().unwrap(), "{path:?}");
}
checked += 1;
}
assert_eq!(checked, 2, "fixture 수가 달라졌다");
}
```
The Korean comment says that the test reads the repository's `fixtures/` as is and that the web-side test reads the same files. The message in the last assertion says the number of fixtures has changed.
The web side loads the package built by wasm-pack in Node and reads the same directory.
```js
// Rust 테스트와 같은 fixtures/ 를 읽어 wasm 패키지의 결과를 대조한다.
import { readdirSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import assert from 'node:assert/strict';
import test from 'node:test';
const wasm = createRequire(import.meta.url)('./wasm-node/sudoku_wasm.js');
const dir = new URL('../fixtures/', import.meta.url);
for (const name of readdirSync(dir)) {
test(name, () => {
const fixture = JSON.parse(readFileSync(new URL(name, dir), 'utf8'));
const snapshot = wasm.start_game(JSON.stringify(fixture.request));
const result = JSON.parse(wasm.apply_action(snapshot, JSON.stringify(fixture.action), '{"locale":"ko-KR"}'));
assert.equal(result.ok, fixture.expected.ok);
assert.equal(result.error?.code ?? null, fixture.expected.error_code);
if (result.ok) assert.equal(JSON.parse(result.snapshot).cells[fixture.expected.cell_index], fixture.expected.cell);
});
}
```
The comment on the first line says it reads the same `fixtures/` as the Rust test and compares the result of the wasm package.
These are the results from both sides, taking only the test result part of each run's output. `cargo test -p sudoku-core` is above, and `node --test` is below.
```text
running 5 tests
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
✔ apply-action-set-value-conflict.json (2.652125ms)
✔ apply-action-set-value-valid.json (0.324083ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
```
One of the five Rust tests is the fixture test. Adapter tests for iOS and Android were not written. Reading the same fixtures from Swift and Kotlin is a part that was not confirmed.
## CI builds the artifacts from a fresh checkout
The goal of CI is to reproduce building the artifacts from a fresh checkout. The steps can be split into five groups.
```text
Rust
cargo fmt
cargo clippy
cargo test
UniFFI
generate Swift binding
generate Kotlin binding
generated output validation
Apple
build Rust static library
create XCFramework
xcodebuild build/test
Android
build Rust .so per ABI
generate Kotlin binding
Gradle unit test
assemble release or debug
native library packaging check
Web
wasm-pack build
typecheck
test
production build
```
The block above lists the names of steps and is not a CI configuration file. No CI configuration file was written. In the example, the tests, binding generation, the XCFramework, the `.so` files for Android, and the Wasm build are tied together in one script. I copied only the sources to a fresh directory, ran it from the start, and it ended with exit code 0. That was a single run, and not on a CI server.
Stale output has to be caught too. Be wary of relying on generated code and binaries left on a local machine, and I suggest checking that there is no diff before and after regenerating in CI.
Splitting jobs makes it easier to decide where to look first when something fails. Examples of job names are `rust-core`, `uniffi-bindings`, `apple-xcframework`, `android-native`, `web-wasm`, and `contract-fixtures`. If a shared fixture fails, look at the domain contract first, and if only iOS fails, look at the Apple package setup first. The candidates to look at on each platform are these.
- iOS: the slices of the [XCFramework](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle), a mismatch in the Swift bindings, differences in Xcode, and the target difference between a device and the simulator
- Android: the ABI, the name of the `.so`, the NDK, [16 KB pages](https://developer.android.com/guide/practices/page-sizes), and the Gradle packaging settings
- Web: how wasm is started, the bundler, the point where it meets SSR, and a mismatch in the TypeScript declarations
## What to check before release, and how much to run
Six items are checked before a release.
- Whether the app reflects the version of the Rust core
- Whether the Rust API and the UniFFI output match
- Whether the XCFramework contains the device and simulator variants
- Whether the Android release has the required ABIs and supports 16 KB
- Whether wasm starts in the web production build
- Whether the expected values of the fixtures match on every platform
I recommend moving what a machine can check into CI and leaving only what a person has to look at on the release list.
What to run and when is for the team to decide. The team decides whether a PR that changes the core also requires the platform app builds, and whether the full combination runs on a nightly or on the release branch. Running everything on every PR can raise the cost. The compromise I suggest is this.
- A PR that changes the core: Rust tests, binding generation, and fixture checks
- A platform integration PR: the build and tests of that platform
- A release candidate: all of iOS, Android, and the web
This setup is a recommendation. How many times it succeeded after being applied in CI, how long a run takes, and a CI configuration file are not here. The only passing record is the local run of the example. The cost of running the full combination remains, and items that are hard to automate and need a person stay on the release list. A passing `cargo test` alone cannot guarantee the state of the adapters, the native packages, or the web release.
---
# Rust 공유 코어 7편: 테스트와 CI를 층별로 나누기
> Rust 테스트가 통과해도 iOS 링크, Android 릴리스의 ABI, Web 배포 빌드의 Wasm 로딩은 따로 깨질 수 있어요. 도메인 검사는 Rust에 모으고, 클라이언트는 변환과 연결만 검사하고, CI는 산출물을 새로 만들어 확인하는 구성을 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/rust-shared-core-07-testing-ci/
- Published: 2026.07.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #CI-CD, #Testing, #UniFFI, #FFI
공통 규칙을 Rust에서 검사하고 세 클라이언트가 그 결과를 쓰는 구조의 목표는 코드를 줄이는 것만이 아니에요. 결과가 같아야 해요. 그런데 Rust 테스트가 통과한 뒤에도 깨질 수 있는 곳이 플랫폼마다 있어요.
- iOS: Rust 테스트는 통과했는데 링크에서 오류가 나요.
- Android: 디버그 빌드는 되는데 릴리스 AAB에서 ABI가 빠져요.
- Web: 개발 서버에서는 되는데 배포 빌드에서 wasm 로딩이 실패해요.
위 셋은 일어날 수 있는 경우를 든 것이고, 실제로 겪은 사례의 기록은 아니에요. 이번 편은 이런 실패를 어느 층의 검사가 잡아야 하는지를 나눠요.
## 도메인 검사는 Rust에 모아요
규칙에 대한 검사는 Rust 엔진에 집중하기를 권해요. 스도쿠라면 퍼즐 생성, 풀이의 정확성, 잘못된 수 입력, 메모 토글 reducer, 일별 seed의 재현성, 난이도 분류, 직렬화 왕복, 경곗값, 오류 변환이 대상이에요. 모바일의 가상 장치 없이 `cargo test`로 실행할 수 있게 구성하기를 권해요.
같은 seed와 같은 액션 나열에 대해 결과를 고정해 두는 것이 좋아요. 결과를 달라지게 만드는 후보는 시각, 언어권, 난수의 출처, 시간대가 암묵적으로 끼어드는 경우예요. 그래서 입력으로 받기를 제안해요. 현재 시각을 읽지 않고 timestamp를 매개변수로 받고, 난수에는 seed를 지정하고, locale code는 바깥에서 받아요.
## 공통 fixture와 층별 책임
입력과 기댓값을 담은 JSON fixture를 계약으로 쓰기를 제안해요. 둘 수 있는 파일의 예예요.
```text
fixtures/
start-game-easy-seed-001.json
apply-action-set-value-valid.json
apply-action-set-value-conflict.json
daily-puzzle-2026-06-24.json
completed-game-score.json
```
위 목록은 파일 이름의 예예요. 이 가운데 둘은 예제에서 실제로 만들었고, 그 내용은 «예제에서 두 쪽이 읽은 fixture» 절에 있어요. 이 설계에서는 Rust 테스트와 각 플랫폼의 테스트가 같은 fixture 파일을 직접 읽어요. 예제에서 실제로 읽게 해 본 것은 Rust와 Node 쪽이에요. fixture가 통과한다는 것은 그 입력 범위를 확인했다는 뜻이에요.
층마다 검사할 것이 달라요.
```text
Rust test
-> 도메인 규칙이 맞는가
iOS test
-> Swift adapter가 Rust 결과를 맞게 해석하는가
Android test
-> Kotlin adapter와 native library 연결이 맞는가
Web test
-> wasm package와 TypeScript wrapper가 맞는가
```
iOS는 Swift가 DTO를 해석하는 부분을, Android는 Kotlin의 변환과 직렬화 설정과 네이티브 연결을, Web은 TypeScript가 wasm의 반환값을 바꾸는 부분과 패키지 연결을 검사해요. 클라이언트마다 엔진의 알고리즘 전체를 다시 검사할 필요는 없다고 봐요. 이 구분은 [UniFFI](https://mozilla.github.io/uniffi-rs/latest/)나 다른 도구의 문서가 정해 주는 것이 아니고 제 해석이에요.
## 예제에서 두 쪽이 읽은 fixture
작은 예제에서 fixture 두 개를 만들고 Rust 테스트와 Web 쪽 테스트가 같은 파일을 읽게 해 봤어요. 실제 앱이 아니고 구조를 확인하려는 최소 workspace예요. 쓴 도구는 cargo 1.96.0, wasm-pack 0.15.0, Node 24.14.1이고, 날짜는 2026년 10월 4일이에요. 충돌이 나는 입력의 fixture는 이렇게 생겼어요.
```json
{
"request": { "seed": 0 },
"action": { "type": "set_value", "row": 0, "col": 2, "value": 5 },
"expected": { "ok": false, "cell_index": 2, "cell": "0", "error_code": "conflict" }
}
```
Rust 쪽 테스트는 디렉터리의 파일을 모두 읽어 결과를 기대값과 대조해요. 파일 수도 함께 확인해서, fixture가 빠지면 테스트가 실패해요. `fixtures.rs`에서 import와 나머지 테스트를 뺀 부분이에요.
```rust
const ENV: &str = r#"{"locale":"ko-KR"}"#;
// 저장소의 fixtures/ 를 그대로 읽는다. Web 쪽 테스트도 같은 파일을 읽는다.
#[test]
fn fixtures_match_expected() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
let mut checked = 0;
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
let fixture: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let snapshot = sudoku_core::start_game_json(&fixture["request"].to_string());
let result: Value =
serde_json::from_str(&sudoku_core::apply_action_json(&snapshot, &fixture["action"].to_string(), ENV)).unwrap();
let expected = &fixture["expected"];
assert_eq!(result["ok"], expected["ok"], "{path:?}");
assert_eq!(result["error"]["code"], expected["error_code"], "{path:?}");
if result["ok"] == true {
let next: Value = serde_json::from_str(result["snapshot"].as_str().unwrap()).unwrap();
let index = expected["cell_index"].as_u64().unwrap() as usize;
assert_eq!(&next["cells"].as_str().unwrap()[index..index + 1], expected["cell"].as_str().unwrap(), "{path:?}");
}
checked += 1;
}
assert_eq!(checked, 2, "fixture 수가 달라졌다");
}
```
Web 쪽은 wasm-pack으로 만든 패키지를 Node에서 불러서 같은 디렉터리를 읽어요.
```js
// Rust 테스트와 같은 fixtures/ 를 읽어 wasm 패키지의 결과를 대조한다.
import { readdirSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import assert from 'node:assert/strict';
import test from 'node:test';
const wasm = createRequire(import.meta.url)('./wasm-node/sudoku_wasm.js');
const dir = new URL('../fixtures/', import.meta.url);
for (const name of readdirSync(dir)) {
test(name, () => {
const fixture = JSON.parse(readFileSync(new URL(name, dir), 'utf8'));
const snapshot = wasm.start_game(JSON.stringify(fixture.request));
const result = JSON.parse(wasm.apply_action(snapshot, JSON.stringify(fixture.action), '{"locale":"ko-KR"}'));
assert.equal(result.ok, fixture.expected.ok);
assert.equal(result.error?.code ?? null, fixture.expected.error_code);
if (result.ok) assert.equal(JSON.parse(result.snapshot).cells[fixture.expected.cell_index], fixture.expected.cell);
});
}
```
두 쪽의 실행 결과예요. 각 실행의 출력에서 테스트 결과 부분만 옮겼어요. 위가 `cargo test -p sudoku-core`, 아래가 `node --test`예요.
```text
running 5 tests
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
✔ apply-action-set-value-conflict.json (2.652125ms)
✔ apply-action-set-value-valid.json (0.324083ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
```
Rust 테스트 다섯 개 가운데 하나가 fixture 테스트예요. iOS와 Android의 adapter 테스트는 만들지 않았어요. Swift와 Kotlin에서 같은 fixture를 읽는 것은 확인하지 못한 부분이에요.
## CI는 새 체크아웃에서 산출물을 만들어요
CI의 목표는 새로 체크아웃한 상태에서 산출물이 만들어지는 것을 재현하는 일이에요. 단계는 다섯 묶음으로 나눌 수 있어요.
```text
Rust
cargo fmt
cargo clippy
cargo test
UniFFI
generate Swift binding
generate Kotlin binding
generated output validation
Apple
build Rust static library
create XCFramework
xcodebuild build/test
Android
build Rust .so per ABI
generate Kotlin binding
Gradle unit test
assemble release or debug
native library packaging check
Web
wasm-pack build
typecheck
test
production build
```
위 블록은 단계의 이름을 늘어놓은 것이고 CI 설정 파일이 아니에요. CI 설정 파일은 만들지 않았어요. 대신 예제에서는 테스트, 바인딩 생성, XCFramework, Android용 `.so`, Wasm 빌드를 스크립트 하나로 묶었어요. 소스만 새 디렉터리에 복사해서 처음부터 돌렸고 종료 코드 0으로 끝났어요. 한 번 실행한 것이고, CI 서버에서 돌린 것은 아니에요.
오래된 결과물도 잡아야 해요. 생성된 코드와 바이너리가 로컬에 남은 것에 기대고 있지 않은지 경계하고, CI에서 다시 생성한 앞뒤로 diff가 없는지 확인하기를 제안해요.
job을 나누면 실패했을 때 어디부터 볼지를 정하기 쉬워요. job 이름의 예는 `rust-core`, `uniffi-bindings`, `apple-xcframework`, `android-native`, `web-wasm`, `contract-fixtures`예요. 공유 fixture가 실패하면 도메인 계약부터 보고, iOS만 실패하면 Apple 패키지 구성을 먼저 봐요. 플랫폼마다 볼 후보는 이래요.
- iOS: [XCFramework](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle)의 slice, Swift 바인딩 불일치, Xcode의 차이, 실기기와 시뮬레이터의 타깃 차이
- Android: ABI, `.so`의 이름, NDK, [16 KB 페이지](https://developer.android.com/guide/practices/page-sizes), Gradle의 패키징 설정
- Web: wasm의 시작 처리, 번들러, SSR과 만나는 지점, TypeScript 선언 불일치
## 배포 전에 볼 것과 실행 범위
배포하기 전에 확인할 항목은 여섯이에요.
- 앱에 Rust core의 버전이 반영됐는지
- Rust API와 UniFFI 생성물이 맞는지
- XCFramework에 실기기와 시뮬레이터 구성이 들어 있는지
- Android 릴리스에 필요한 ABI가 있고 16 KB에 대응하는지
- Web 배포 빌드에서 wasm이 시작되는지
- 모든 플랫폼에서 fixture의 기댓값이 맞는지
기계로 검사할 수 있는 항목은 CI로 옮기고, 사람이 봐야 하는 것만 배포 목록에 남기기를 권해요.
무엇을 언제 돌릴지는 팀이 기준을 정해야 해요. core를 바꾸는 PR에서 플랫폼 앱의 빌드까지 요구할지, 전체 조합은 nightly나 release branch에서 돌릴지를 정해요. PR마다 전체를 돌리면 비용이 늘 수 있어요. 제가 제안하는 절충은 이래요.
- core를 바꾸는 PR: Rust 테스트, 바인딩 생성, fixture 검사
- 플랫폼 통합 PR: 그 플랫폼의 빌드와 테스트
- release candidate: iOS, Android, Web 전부
이 구성은 권장안이에요. CI에 적용해서 몇 번 성공했는지, 실행에 얼마나 걸리는지, CI 설정 파일은 여기에 없어요. 통과 기록은 예제를 로컬에서 돌린 것뿐이에요. 전체 조합을 돌리는 비용은 그대로 남고, 자동화하기 어려워 사람이 확인할 항목은 배포 목록에 남겨요. `cargo test`가 통과했다는 것만으로는 adapter, 네이티브 패키지, Web 배포의 상태를 보장할 수 없어요.
---
# Rust shared core, part 6: using it on the web as a Wasm package
> To use the Rust rules from mobile on the web, compile them to WebAssembly and ship a package. Roles, where to initialize in Next.js, and the call contract.
- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-06-web-wasm/
- Published: 2026.07.24
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #WebAssembly, #wasm-bindgen, #wasm-pack, #TypeScript
Applying the rules of the Rust core used on iOS and Android to the web as well needs a way to run the same code in a browser. [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly) is that way. It is a low-level binary format for browsers, Rust can compile to it with the web as the target, and it is used together with JavaScript.
That does not mean writing the whole web app in Rust. Part 6 uses Sudoku to explain what Rust and the web each take on, and when and where a Next.js app initializes Wasm.
## What Rust takes and what the web takes
The candidates on the Rust side are the things whose results and rules have to be the same across platforms: puzzle generation, the solver, validation, the action reducer, score calculation, and the seed of the daily puzzle.
What I recommend leaving on the web side is the React components, routing, localStorage and IndexedDB, keyboard shortcuts, pointer interaction, analytics, and the service worker. The DOM, fetch, hydration, accessibility, and browser storage are also better handled by TypeScript and the web framework. The web builds its UI state from the results of calling Rust.
## Two tools and a directory layout
Two tools share the work. [wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/) exposes Rust functions to JavaScript and generates the wrapper and TypeScript declarations. [wasm-pack](https://wasm-bindgen.github.io/wasm-pack/book/) builds the crate and bundles the WebAssembly binary, the JavaScript glue code, and the package metadata into a form that can be consumed from npm. Up to here the content comes from the documentation of the two tools. The directory layout and the initialization approach below are my own design choices.
```text
core/sudoku-rs/crates/
sudoku-core/ # 순수 Rust 로직
sudoku-wasm/ # wasm-bindgen 공개 API
packages/
sudoku-wasm/ # wasm-pack output 또는 wrapper package
apps/web/
app/ # Next.js / React 앱
```
The Korean comments in the block mean, in order: "pure Rust logic" on the `sudoku-core/` line, "the public wasm-bindgen API" on the `sudoku-wasm/` line under `crates/`, "wasm-pack output or a wrapper package" on the `sudoku-wasm/` line under `packages/`, and "the Next.js / React app" on the `app/` line.
`sudoku-wasm` calls `sudoku-core`. The web app imports `@app/sudoku-wasm` and does not see Rust's internal model directly. Managing it as a package makes the dependencies and the location of the artifacts visible. The versions and the target option I used in an example are in the section "A Wasm package built from an example." The current wasm-pack documentation says it may describe features that are not in a released version, so check that it matches the version in use.
## Where to initialize in Next.js
In Next.js, code runs on the server and in the browser. Running a browser-only module in a server component or during a static build can cause an error. So I suggest fixing the place and the time of initialization.
```text
Client Component
-> dynamic import("@app/sudoku-wasm")
-> init wasm
-> create SudokuEngineClient
```
A Client Component does a dynamic import, initializes Wasm, and then creates the `SudokuEngineClient`. Another option is lazy initialization in the state layer, on first use.
The UI has to be able to tell whether the engine is ready. I suggest managing five states.
- wasm loading
- wasm ready
- wasm failed
- engine action pending
- engine action failed
Without showing these states, the user can get a blank screen or a button that does nothing. Apart from whether the core is stable, the initialization process affects how stable the app feels. Real error messages and steps to reproduce are not included in this post.
## One call per UI event
I recommend avoiding calls to Rust per cell inside React render, or on every pointer move. It is better to pass the snapshot and the action once per UI event and receive the next snapshot and the effects.
The contract the app sees has two methods.
```ts
export interface SudokuEngineClient {
startGame(request: StartGameRequest): Promise;
applyAction(snapshot: GameSnapshot, action: GameAction): Promise;
}
```
In this structure React uses only this client, and the generated raw functions are called only inside the client implementation. The `SudokuEngineClient` on iOS, Android, and the web can differ in implementation, but the meaning in the domain and the contract the app sees stay the same. Conceptually it sits in the same place as the adapter on mobile.
The types may need one more step. The generated TypeScript declarations alone may not cover all the domain types of the app. So a separate wrapper is a good idea. That wrapper converts what Wasm returns into the app's types and does JSON decoding, schema validation, and error code mapping.
## A Wasm package built from an example
I built this structure as a small example and loaded it from Node. No real web app is involved. It is a minimal workspace that checks the structure. The tools were wasm-pack 0.15.0, wasm-bindgen 0.2.129, and Node 24.14.1, and the date was October 4, 2026. The binding crate only wraps the functions of the core. Keeping the JSON-facing functions in the core as well is where the example departs from the design in part 2, to stay small.
```rust
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn start_game(request_json: &str) -> String {
sudoku_core::start_game_json(request_json)
}
#[wasm_bindgen]
pub fn apply_action(snapshot_json: &str, action_json: &str, environment_json: &str) -> String {
sudoku_core::apply_action_json(snapshot_json, action_json, environment_json)
}
// … validate_snapshot and generate_daily_puzzle have the same shape.
```
There is one build command. The target is `nodejs`, because the run was in Node and not in a browser. The `grep` and `sed` after it keep only the completion lines of the output and shorten the absolute path. Run on its own, this line can end as a success even when the build fails, because of the last command in the pipe. The script that was run has `set -euo pipefail` at its top so that such a failure is not missed.
```bash
wasm-pack build crates/sudoku-wasm --target nodejs --out-dir ../../out/wasm-node 2>&1 | grep -E "Done|ready" | sed 's#at .*/out/#at out/#'
```
The output directory got `sudoku_wasm_bg.wasm`, `sudoku_wasm.js`, `sudoku_wasm.d.ts`, `sudoku_wasm_bg.wasm.d.ts`, and `package.json`. The `.wasm` file was 142,894 bytes. These are the TypeScript declarations generated in `sudoku_wasm.d.ts`. The two lint comment lines at the top of the file are left out.
```ts
export function apply_action(snapshot_json: string, action_json: string, environment_json: string): string;
export function generate_daily_puzzle(request_json: string): string;
export function start_game(request_json: string): string;
export function validate_snapshot(snapshot_json: string): string;
```
Every argument and return value is a `string`. As described earlier, turning that string into the app's types and validating it is work a wrapper has to do separately. I loaded this package in Node with `require` and called the functions, and two tests that read the shared fixtures passed. Those tests are in part 7.
What was not done is worth writing down too. No build was made for the `web` or `bundler` target. Initialization in a browser, the dynamic import in Next.js, and a client and wrapper that return a `Promise` were not built.
## Both sides read the same fixtures
I suggest that the Rust tests and the web tests use the same fixtures. The Rust side checks the output of the engine, and the web side checks that the wrapper interprets the same result correctly. The aim is to find the case where the engine is right and the type conversion is wrong.
```text
fixtures/
daily-seed-2026-06-24.json
apply-action-note-toggle.json
invalid-move-conflict.json
```
The date in the file name is an identifier in the example. How to set up tests and CI is covered in the next part.
What this setup emphasizes is not speed. It is that the three platforms use the same domain rules. Performance numbers and an implementation that handles initialization failure are not here. Testing went as far as running two fixtures in Node. The interface that returns a `Promise` is an example of the contract the app sees, and this post has no definition of the fields of `Transition`.
---
# Rust 공유 코어 6편: Web에서 Wasm 패키지로 쓰기
> 모바일에서 쓰는 Rust 규칙을 Web에서도 쓰려면 WebAssembly로 컴파일해서 패키지로 내놓아요. Rust와 Web이 맡을 일의 구분, Next.js에서 초기화할 자리와 상태, 액션 단위의 호출 계약, 타입 변환을 확인하는 fixture를 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/rust-shared-core-06-web-wasm/
- Published: 2026.07.24
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #WebAssembly, #wasm-bindgen, #wasm-pack, #TypeScript
iOS와 Android에서 쓰는 Rust core의 규칙을 Web에도 적용하려면, 같은 코드를 브라우저에서 돌릴 방법이 있어야 해요. [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly)가 그 방법이에요. 브라우저를 위한 저수준 바이너리 형식이고, Rust에서 Web을 대상으로 컴파일할 수 있으며, JavaScript와 함께 써요.
그렇다고 Web 전체를 Rust로 짜자는 것은 아니에요. 6편은 Rust와 Web이 무엇을 나눠 맡을지, 그리고 Next.js 앱에서 Wasm을 언제 어디서 초기화할지를 스도쿠를 예로 설명해요.
## Rust가 맡을 일과 Web이 맡을 일
Rust 쪽 후보는 계산 결과와 규칙이 플랫폼마다 같아야 하는 것들이에요. 퍼즐 생성, solver, validation, action reducer, score 계산, daily puzzle의 seed예요.
Web 쪽에 남기기를 권하는 것은 React component, routing, localStorage와 IndexedDB, 키보드 단축키, 포인터 조작, analytics, service worker예요. DOM, fetch, hydration, 접근성, browser storage도 TypeScript와 Web 프레임워크가 맡는 편이 좋아요. Web은 Rust를 호출한 결과로 UI state를 만들어요.
## 도구 둘과 디렉터리
도구는 둘이 나눠 맡아요. [wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/)은 Rust 함수를 JavaScript에 드러내고 wrapper와 TypeScript declaration을 만들어요. [wasm-pack](https://wasm-bindgen.github.io/wasm-pack/book/)은 crate를 빌드하고 WebAssembly binary, JavaScript glue code, package metadata를 묶어서 npm에서 쓸 수 있는 패키지 형태로 내놓아요. 여기까지는 두 도구의 문서에 있는 내용이고, 아래의 디렉터리 구성과 초기화 방식은 제 해석이에요.
```text
core/sudoku-rs/crates/
sudoku-core/ # 순수 Rust 로직
sudoku-wasm/ # wasm-bindgen 공개 API
packages/
sudoku-wasm/ # wasm-pack output 또는 wrapper package
apps/web/
app/ # Next.js / React 앱
```
`sudoku-wasm`이 `sudoku-core`를 호출해요. Web 앱은 `@app/sudoku-wasm`을 import하고, Rust 내부의 모델을 직접 보지 않아요. 패키지 단위로 관리하면 의존 관계와 산출물이 놓이는 자리가 드러나요. 제가 예제에서 쓴 버전과 target 옵션은 «예제로 빌드한 Wasm 패키지» 절에 적었어요. wasm-pack의 현재 문서는 출시 전의 기능도 담을 수 있다고 밝히고 있으니, 쓰는 버전에 맞는 내용인지 확인해야 해요.
## Next.js에서 초기화할 자리
Next.js는 코드가 서버에서도 브라우저에서도 실행돼요. 브라우저용 모듈을 서버 컴포넌트나 정적 빌드 과정에서 실행하면 오류가 날 수 있어요. 그래서 초기화하는 자리와 시점을 정해 두기를 제안해요.
```text
Client Component
-> dynamic import("@app/sudoku-wasm")
-> init wasm
-> create SudokuEngineClient
```
Client Component에서 dynamic import를 하고, Wasm을 초기화한 다음 `SudokuEngineClient`를 만들어요. 다른 방법으로는 state layer에서 처음 쓸 때 초기화하는 lazy initialization이 있어요.
UI가 준비 상태를 알 수 있어야 해요. 관리하기를 제안하는 상태는 다섯이에요.
- wasm loading
- wasm ready
- wasm failed
- engine action pending
- engine action failed
이 상태를 보여 주지 않으면 빈 화면이 뜨거나 버튼을 눌러도 반응이 없을 수 있어요. core가 안정적인지와 별개로, 초기화 과정이 사용자가 느끼는 안정성에 영향을 줘요. 실제 오류 메시지나 재현 절차는 이 글에 싣지 않았어요.
## 호출은 UI event 단위로
React의 render에서 cell마다 Rust를 부르거나 pointer move마다 Rust로 넘기는 방식은 피하기를 권해요. UI event 하나에 한 번, snapshot과 action을 넘기고 다음 snapshot과 effect를 받는 쪽이 좋아요.
앱이 보는 계약은 메서드 두 개예요.
```ts
export interface SudokuEngineClient {
startGame(request: StartGameRequest): Promise;
applyAction(snapshot: GameSnapshot, action: GameAction): Promise;
}
```
이 구조에서 React는 이 클라이언트만 쓰고, 생성된 raw function은 클라이언트 구현 안에서만 불러요. iOS, Android, Web의 `SudokuEngineClient`는 구현이 달라도 되지만 도메인의 뜻과 앱이 보는 계약은 같게 유지해요. 개념으로는 모바일의 adapter와 같은 자리예요.
타입은 한 번 더 손봐야 할 수 있어요. 생성된 TypeScript declaration만으로는 앱의 도메인 타입을 다 다루기 어려울 수 있어요. 그래서 별도의 wrapper를 두는 편이 좋아요. 이 wrapper에서 Wasm이 돌려준 값을 앱의 타입으로 바꾸고, JSON decoding, schema validation, error code 매핑을 해요.
## 예제로 빌드한 Wasm 패키지
이 구조를 작은 예제로 빌드해서 Node에서 불러 봤어요. 실제 Web 앱은 아니고, 구조를 확인하는 최소 workspace예요. 쓴 도구는 wasm-pack 0.15.0, wasm-bindgen 0.2.129, Node 24.14.1이고, 2026년 10월 4일에 실행했어요. 바인딩 crate는 코어의 함수를 감싸기만 해요. JSON을 다루는 함수까지 코어에 둔 것은 예제를 줄이려고 2편의 설계와 다르게 한 부분이에요.
```rust
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn start_game(request_json: &str) -> String {
sudoku_core::start_game_json(request_json)
}
#[wasm_bindgen]
pub fn apply_action(snapshot_json: &str, action_json: &str, environment_json: &str) -> String {
sudoku_core::apply_action_json(snapshot_json, action_json, environment_json)
}
// … validate_snapshot, generate_daily_puzzle도 같은 모양이에요.
```
빌드 명령은 하나예요. 브라우저가 아니라 Node에서 실행하려고 target을 `nodejs`로 줬어요. 뒤에 붙은 `grep`과 `sed`는 출력에서 완료 줄만 남기고 절대 경로를 줄이려는 것이에요. 이 줄만 따로 실행하면 빌드가 실패해도 파이프의 마지막 명령 때문에 성공처럼 끝날 수 있어요. 실행한 스크립트는 첫머리에 `set -euo pipefail`을 둬서 그런 실패를 놓치지 않게 했어요.
```bash
wasm-pack build crates/sudoku-wasm --target nodejs --out-dir ../../out/wasm-node 2>&1 | grep -E "Done|ready" | sed 's#at .*/out/#at out/#'
```
출력 디렉터리에는 `sudoku_wasm_bg.wasm`, `sudoku_wasm.js`, `sudoku_wasm.d.ts`, `sudoku_wasm_bg.wasm.d.ts`, `package.json`이 생겼어요. `.wasm` 파일은 142,894바이트였어요. `sudoku_wasm.d.ts`에 생성된 TypeScript 선언은 이래요. 파일 첫머리의 lint 주석 두 줄은 뺐어요.
```ts
export function apply_action(snapshot_json: string, action_json: string, environment_json: string): string;
export function generate_daily_puzzle(request_json: string): string;
export function start_game(request_json: string): string;
export function validate_snapshot(snapshot_json: string): string;
```
인자와 반환값이 모두 `string`이에요. 앞에서 적은 대로, 이 문자열을 앱의 타입으로 바꾸고 검증하는 일은 wrapper가 따로 해야 해요. 이 패키지를 Node에서 `require`로 불러 함수를 호출했고, 공용 fixture를 읽는 테스트 두 개가 통과했어요. 그 테스트는 7편에 실었어요.
하지 않은 것도 적어 둬요. `web`이나 `bundler` target으로는 빌드하지 않았어요. 브라우저에서의 초기화, Next.js의 dynamic import, `Promise`를 돌려주는 client와 wrapper는 만들지 않았어요.
## 같은 fixture를 두 쪽에서 읽어요
Rust 테스트와 Web 테스트가 같은 fixture를 쓰기를 제안해요. Rust 쪽은 엔진의 출력을 검사하고, Web 쪽은 wrapper가 같은 결과를 맞게 해석하는지 검사해요. 엔진은 맞는데 타입 변환이 틀린 경우를 찾으려는 것이에요.
```text
fixtures/
daily-seed-2026-06-24.json
apply-action-note-toggle.json
invalid-move-conflict.json
```
파일 이름의 날짜는 예시의 식별자예요. 테스트와 CI를 어떻게 짤지는 다음 편에서 정리해요.
이 구성에서 강조하려는 것은 속도가 아니라 세 플랫폼이 같은 도메인 규칙을 쓴다는 점이에요. 성능을 잰 수치와 초기화 실패를 처리하는 구현은 여기에 없어요. 테스트는 Node에서 fixture 두 개로 실행한 것까지예요. `Promise`를 돌려주는 인터페이스는 앱이 보는 계약의 예이고, `Transition`의 필드 정의는 이 글에 없어요.
---
# Rust shared core, part 5: what to check when shipping a .so on Android
> Generating Kotlin bindings does not finish Android integration. Per-ABI .so placement, wiring Rust into Gradle, the 16 KB page rule, and release checks.
- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-05-android-native-packaging/
- Published: 2026.07.05
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #Android, #Kotlin, #Gradle, #NDK
When a Rust module shared by several platforms goes into Android, generating the Kotlin bindings does not finish the integration. If an ABI is missing or in the wrong place, or if a platform or store condition is not met, the build, the install, or the run can fail somewhere.
Part 5 covers the build wiring and the compatibility checks for putting a Rust binary into an Android release. The versions and policies in it are the values in the Android documentation as I rechecked it on October 4, 2026. The environment in which I built an example is recorded separately in the section "The alignment of a .so built from an example."
## Each ABI needs its own .so
[The ABIs the NDK supports](https://developer.android.com/ndk/guides/abis) are four: `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. Which of them to ship is decided by the app's policy. For each chosen ABI, a `.so` has to be built with the matching Rust target.
The output goes under `jniLibs` in the app module, in a directory per ABI. Below is an example with three ABIs chosen.
```text
apps/android/
app/
src/main/
jniLibs/
arm64-v8a/libdomain_core.so
armeabi-v7a/libdomain_core.so
x86_64/libdomain_core.so
```
This layout makes the file for each ABI easy to find. Copying the files by hand carries a risk. After the Rust code changes, the app can still contain the old binary.
## Wiring the Rust build into Gradle
The ABIs, the page size, and the compatible versions come from the documentation. The Gradle task setup in this section and the checklists further down are my own design choices built on that, not requirements of the documentation.
To avoid copying by hand, connect it to the build. This setup makes `preBuild` depend on a task that builds Rust.
```text
preBuild
dependsOn buildRustCoreForAndroid
buildRustCoreForAndroid
-> ABI별 cargo build
-> UniFFI Kotlin binding 생성
-> jniLibs / generated source 경로에 output 배치
```
The three arrows read: build with cargo for each ABI, generate the UniFFI Kotlin binding, and place the output in the `jniLibs` and generated source paths.
The block above draws the relation between tasks and is not a Gradle configuration to use as written. The order is to compile with cargo, generate Kotlin code with [UniFFI](https://mozilla.github.io/uniffi-rs/latest/), and put the results in the `jniLibs` and generated source paths.
Without inputs and outputs declared on the Gradle task, unnecessary rebuilds can happen, or a change can go undetected. The boundary of the task has to be set with both local speed and reproducibility in CI in mind.
## Hide generated types behind a Kotlin interface
Exposing generated types across ViewModels, Composables, and repositories increases coupling. A Kotlin adapter and the `SudokuEngineClient` contract separate the internal model. This is the shape of the interface I propose.
```kotlin
interface SudokuEngineClient {
fun startGame(request: StartGameRequest): GameSnapshot
fun applyAction(snapshot: GameSnapshot, action: GameAction): Transition
}
```
The implementation calls UniFFI, and the ViewModel uses only this contract. Tests can put in a fake. Using the same Rust module on the web as a WebAssembly package is left to part 6.
Heavy Rust computation is handled off the main thread. I recommend separating the choice of dispatcher in the ViewModel scope from the update of UI state, and not exposing the Rust dependency to Compose UI. Measured cost and the specific dispatcher are not given in this post.
## The 16 KB page rule
According to [the Android guide to 16 KB page sizes](https://developer.android.com/guide/practices/page-sizes), apps on Google Play that target Android 15 (API level 35) or later have to support 16 KB pages on 64-bit devices. From February 1, 2027, updates that do not support 16 KB cannot be released. The earlier version of this post gave November 1, 2025 as the start date, and that date is not in the document as rechecked on October 4, 2026. Native code brought in by SDKs is in scope, so a Rust `.so` is included.
Per the documentation, NDK r28 and later use 16 KB alignment by default. Even so, every library in the release has to be checked without relying on the default. An old binary brought in by an ad or analytics SDK can affect the compatibility of the whole app.
Four items need checking.
- The ELF alignment of every `.so`
- Behavior on a 16 KB emulator or device
- Whether third-party SDKs contain native code
- Whether the inspection used the release APK or AAB, not a debug build
The alignment of an APK is checked with `zipalign`, and the page size of a connected device is read with `adb`.
```bash
zipalign -c -P 16 -v 4 app-release.apk
adb shell getconf PAGE_SIZE
```
These two commands look at the alignment of the APK packaging and at the condition of the device. They do not by themselves confirm the ELF alignment of every `.so` or that the app works. A project that ships mainly as an AAB adds the Play Console pre-launch report, or an APK built from the bundle, to what is checked. I could not run these two commands. No APK could be built, for the reason given in the next section.
## The alignment of a .so built from an example
I built a small Rust example for three ABIs and checked the ELF alignment myself. The workspace is not a real app. It is a minimal one made to check the structure. I ran it on October 4, 2026, on macOS 27.0.1 with cargo 1.96.0 and the NDK installed on this Mac, 30.0.15729638-beta2. That is a different version from the NDK 28.2 in the section "Pin the tool versions" below, and it is a beta. The three Android targets of Rust were already installed.
For each target, cargo is told to use the NDK's clang as the linker. The alignment is read from the LOAD lines of `llvm-objdump -p`, which is the method the Android guide gives. These lines are taken from the build script. Two things are simplified because it is an example. The first line picks the highest version among the installed NDKs, so installing another NDK can change the result. To reproduce it, give the NDK path or version explicitly. The `24` in the linker names stands for `minSdkVersion`. If the app's `minSdk` is lower than 24, it has to match that value.
```bash
NDK=$(ls -d "$HOME"/Library/Android/sdk/ndk/* | sort -V | tail -1)
TC="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin"
# …
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER="$TC/armv7a-linux-androideabi24-clang"
export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$TC/x86_64-linux-android24-clang"
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
cargo build -q -p sudoku-uniffi --release --target "$target"
so="target/$target/release/libsudoku_uniffi.so"
echo "$target: $(stat -f %z "$so") bytes"
# …
done
```
The alignment was read with one more script. `sort -u` collapses lines with the same value into one.
```bash
OBJDUMP="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objdump"
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
echo "$target"
"$OBJDUMP" -p "target/$target/release/libsudoku_uniffi.so" | awk '/LOAD/ {print " LOAD " $(NF-1) " " $NF}' | sort -u
done
```
The two scripts printed the following. The file size per ABI is above, and the alignment on the LOAD lines is below.
```text
aarch64-linux-android: 716632 bytes
armv7-linux-androideabi: 505140 bytes
x86_64-linux-android: 686000 bytes
aarch64-linux-android
LOAD align 2**14
armv7-linux-androideabi
LOAD align 2**12
x86_64-linux-android
LOAD align 2**14
```
For the 64-bit ABIs, `arm64-v8a` and `x86_64`, every LOAD segment was 2**14, which is 16 KB. The guide says the load segments must not have values less than 2**14. For the 32-bit `armeabi-v7a`, the value was 2**12. The 16 KB rule is about 64-bit devices. No linker option was passed separately.
More was left unchecked than checked. Gradle is not on this Mac, so no APK or AAB could be built. That means the `zipalign` check, the `jniLibs` placement, a release build, and loading on a device or an emulator were not tried. The Kotlin bindings were generated and not compiled.
## Pin the tool versions
In the compatibility table of [the AGP 9.1 release notes](https://developer.android.com/build/releases/agp-9-1-0-release-notes) as rechecked on October 4, 2026, the values for AGP 9.1.1 are Gradle 9.3.1, JDK 17, and NDK 28.2.13676358. From [AGP 9.0](https://developer.android.com/build/releases/agp-9-0-0-release-notes) on, the default NDK is in the r28 line, and 9.1.1 uses the same default. Different NDK versions among developers can be a cause of different alignment and linker behavior.
So the following six are pinned in one place and managed consistently.
```text
AGP version
Gradle version
JDK version
NDK version
Rust toolchain
Android Rust targets
```
A new configuration file is not needed. The version catalog, the Gradle wrapper, the CI image, and the README that are already maintained can serve as the reference. It is also worth confirming that `./gradlew assembleRelease` from a fresh clone, or the release job in CI, succeeds including the generation of the Rust output. For using the NDK with a build system other than Gradle, see [the NDK guide to other build systems](https://developer.android.com/ndk/guides/other_build_systems).
## A working debug build does not cover release
Because of R8, minify, packagingOptions, ABI splits, and App Bundle processing, the result of a debug build is not enough to judge a release. Five things are checked on the release output.
- Whether every required ABI is included
- Whether the name the binding looks for matches the name of the binary
- Whether `System.loadLibrary` succeeds
- Whether the 16 KB rule is met
- Whether a Rust call succeeds right after a fresh install
I recommend checking automatically in CI. When that is hard, a manual checklist for each release candidate is the minimum alternative. The basic concepts of native calls are in [the Android JNI tips](https://developer.android.com/ndk/guides/jni-tips).
At the release stage, a Rust module is one more native binary dependency. Which ABIs to support, how to design incremental builds for the Gradle task, which thread to run on, whether third-party code meets the rule, and whether the final release really works all have to be confirmed separately in each project. The policy date and the version values are those in the documentation as rechecked on October 4, 2026.
---
# Rust 공유 코어 5편: Android에 .so로 넣을 때 확인할 것
> Kotlin 바인딩을 생성했다고 Android 통합이 끝난 것은 아니에요. ABI별 .so를 배치하는 방법, Gradle 빌드에 Rust 컴파일을 잇는 방법, 16 KB 페이지 조건과 release 배포물에서 확인할 항목을 Android 문서를 바탕으로 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/rust-shared-core-05-android-native-packaging/
- Published: 2026.07.05
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #Android, #Kotlin, #Gradle, #NDK
여러 플랫폼이 함께 쓰는 Rust 모듈을 Android에 넣을 때, Kotlin 바인딩을 생성한 것만으로는 통합이 끝나지 않아요. ABI가 빠졌거나 잘못된 자리에 놓였거나, 플랫폼과 스토어의 조건을 채우지 못하면 빌드, 설치, 실행 가운데 어디선가 실패할 수 있어요.
5편은 Rust 바이너리를 Android 배포물에 넣을 때의 빌드 연결과 호환성 점검을 다뤄요. 여기에 나오는 버전과 정책은 2026년 10월 4일에 다시 확인한 Android 문서의 값이에요. 제가 예제를 빌드한 환경은 «예제로 빌드한 .so의 정렬» 절에 따로 적었어요.
## ABI마다 .so가 필요해요
[NDK가 지원하는 ABI](https://developer.android.com/ndk/guides/abis)는 `armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64` 넷이에요. 이 가운데 무엇을 배포할지는 앱의 정책으로 정해요. 고른 ABI마다 그에 맞는 Rust target으로 `.so`를 만들어야 해요.
산출물은 앱 모듈의 `jniLibs` 아래에 ABI별 디렉터리로 둬요. 세 ABI를 고른 경우의 예시예요.
```text
apps/android/
app/
src/main/
jniLibs/
arm64-v8a/libdomain_core.so
armeabi-v7a/libdomain_core.so
x86_64/libdomain_core.so
```
이 배치는 ABI별 파일을 찾기 쉽게 해 줘요. 파일을 손으로 복사하는 방식에는 위험이 있어요. Rust 코드를 고친 뒤에도 앱이 예전 바이너리를 담고 있을 수 있어요.
## Gradle 빌드에 Rust 컴파일을 이어요
ABI, 페이지 크기, 호환 버전은 문서에 있는 내용이에요. 이 절의 Gradle 작업 구성과 뒤에 나오는 점검 목록은 문서의 요구가 아니고 그 위에 세운 제 해석이에요.
손으로 복사하지 않으려면 빌드에 연결해요. `preBuild`가 Rust를 빌드하는 작업에 의존하게 하는 구성이에요.
```text
preBuild
dependsOn buildRustCoreForAndroid
buildRustCoreForAndroid
-> ABI별 cargo build
-> UniFFI Kotlin binding 생성
-> jniLibs / generated source 경로에 output 배치
```
위 블록은 작업 사이의 관계를 그린 것이고, 그대로 쓰는 Gradle 설정이 아니에요. cargo로 컴파일하고, [UniFFI](https://mozilla.github.io/uniffi-rs/latest/)로 Kotlin 코드를 생성하고, 결과를 `jniLibs`와 generated source 경로에 놓는 순서예요.
Gradle 작업에 inputs와 outputs를 적지 않으면 필요 없는 재빌드가 생기거나, 반대로 변경을 감지하지 못할 수 있어요. 로컬에서의 속도와 CI에서의 재현을 함께 생각해서 작업의 경계를 정해야 해요.
## Kotlin interface로 생성 타입을 가려요
생성된 타입을 ViewModel, Composable, repository에 두루 드러내면 결합이 커져요. Kotlin adapter와 `SudokuEngineClient` 계약으로 내부 모델을 분리해요. 제안하는 interface의 모양이에요.
```kotlin
interface SudokuEngineClient {
fun startGame(request: StartGameRequest): GameSnapshot
fun applyAction(snapshot: GameSnapshot, action: GameAction): Transition
}
```
구현체가 UniFFI를 부르고, ViewModel은 이 계약만 써요. 테스트에서는 fake를 넣을 수 있어요. 같은 Rust 모듈을 Web에서 WebAssembly 패키지로 쓰는 방법은 6편으로 넘겨요.
무거운 Rust 연산은 main thread 밖에서 처리해요. ViewModel scope에서 dispatcher를 고르는 일과 UI state를 갱신하는 일을 나누고, Compose UI에는 Rust 의존성을 드러내지 않기를 권해요. 연산 비용을 잰 수치와 dispatcher의 지정값은 이 글에 없어요.
## 16 KB 페이지 조건
[Android의 16 KB 페이지 안내](https://developer.android.com/guide/practices/page-sizes)에 따르면 Google Play에서 Android 15(API 레벨 35) 이상을 타깃으로 하는 앱은 64-bit 기기에서 16 KB 페이지를 지원해야 해요. 2027년 2월 1일부터는 16 KB를 지원하지 않는 업데이트를 출시할 수 없어요. 이 글의 이전 판에는 시행일이 2025년 11월 1일로 적혀 있었는데, 2026년 10월 4일에 다시 확인한 문서에는 이 날짜가 없어요. SDK가 가져오는 네이티브 코드도 대상이므로 Rust의 `.so`도 포함돼요.
문서상 NDK r28 이상은 기본으로 16 KB 정렬을 써요. 그래도 기본값에 기대지 않고 배포물 안의 모든 라이브러리를 확인해야 해요. 광고나 분석 SDK가 가져온 오래된 바이너리가 앱 전체의 호환성에 영향을 줄 수 있어요.
확인할 항목은 넷이에요.
- 모든 `.so`의 ELF 정렬
- 16 KB 에뮬레이터나 실기기에서의 동작
- 타사 SDK에 네이티브 코드가 있는지
- debug가 아니라 release APK나 AAB로 검사했는지
APK의 정렬은 `zipalign`으로 검사하고, 연결한 기기의 페이지 크기는 `adb`로 조회해요.
```bash
zipalign -c -P 16 -v 4 app-release.apk
adb shell getconf PAGE_SIZE
```
이 두 명령은 APK 포장의 정렬과 기기의 조건을 보는 것이에요. 이것만으로 모든 `.so`의 ELF 정렬이나 앱의 동작까지 확인되지는 않아요. AAB를 중심으로 배포하는 프로젝트는 Play Console의 pre-launch report나 bundle에서 만든 APK까지 확인 대상으로 넣어요. 이 두 명령은 실행하지 못했어요. APK를 만들지 못했기 때문이고, 이유는 다음 절에 적었어요.
## 예제로 빌드한 .so의 정렬
작은 Rust 예제를 세 ABI로 빌드해서 ELF 정렬을 직접 확인해 봤어요. 실제 앱이 아니고 구조를 확인하려고 만든 최소 workspace예요. 실행한 환경은 macOS 27.0.1, cargo 1.96.0, 그리고 이 Mac에 설치된 NDK 30.0.15729638-beta2이고, 날짜는 2026년 10월 4일이에요. 아래 «도구 버전을 고정해요» 절에 나오는 NDK 28.2와는 다른 버전이고, beta예요. Rust의 Android 타깃 셋은 미리 설치돼 있었어요.
cargo에는 타깃마다 NDK의 clang을 linker로 알려 줘요. 정렬은 Android 안내가 적은 방법대로 `llvm-objdump -p`의 LOAD 줄에서 읽었어요. 빌드 스크립트에서 옮긴 줄이에요. 두 가지는 예제라서 간단히 한 부분이에요. 첫 줄은 설치된 NDK 가운데 버전이 가장 높은 것을 고르므로, 다른 NDK를 설치하면 결과가 달라질 수 있어요. 재현하려면 NDK 경로나 버전을 명시해야 해요. linker 이름의 `24`는 `minSdkVersion`을 뜻해요. 앱의 `minSdk`가 24보다 낮다면 그 값에 맞춰야 해요.
```bash
NDK=$(ls -d "$HOME"/Library/Android/sdk/ndk/* | sort -V | tail -1)
TC="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin"
# …
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER="$TC/armv7a-linux-androideabi24-clang"
export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$TC/x86_64-linux-android24-clang"
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
cargo build -q -p sudoku-uniffi --release --target "$target"
so="target/$target/release/libsudoku_uniffi.so"
echo "$target: $(stat -f %z "$so") bytes"
# …
done
```
정렬은 스크립트를 하나 더 써서 읽었어요. `sort -u`로 같은 값의 줄을 하나로 줄여요.
```bash
OBJDUMP="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objdump"
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
echo "$target"
"$OBJDUMP" -p "target/$target/release/libsudoku_uniffi.so" | awk '/LOAD/ {print " LOAD " $(NF-1) " " $NF}' | sort -u
done
```
두 스크립트의 출력이에요. 위가 ABI별 파일 크기이고 아래가 LOAD 줄의 정렬 값이에요.
```text
aarch64-linux-android: 716632 bytes
armv7-linux-androideabi: 505140 bytes
x86_64-linux-android: 686000 bytes
aarch64-linux-android
LOAD align 2**14
armv7-linux-androideabi
LOAD align 2**12
x86_64-linux-android
LOAD align 2**14
```
64-bit인 `arm64-v8a`와 `x86_64`용은 모든 LOAD 세그먼트가 2**14, 곧 16 KB였어요. 안내는 LOAD 세그먼트의 값이 2**14보다 작지 않아야 한다고 적어요. 32-bit인 `armeabi-v7a`용은 2**12였어요. 16 KB 조건은 64-bit 기기에 대한 것이에요. linker 옵션은 따로 주지 않았어요.
확인하지 못한 것이 더 많아요. 이 Mac에는 Gradle이 없어서 APK와 AAB를 만들지 못했어요. 그래서 `zipalign` 검사, `jniLibs` 배치, release 빌드, 기기나 에뮬레이터에서의 로드는 해 보지 못했어요. Kotlin 바인딩은 생성만 했고 컴파일하지 않았어요.
## 도구 버전을 고정해요
2026년 10월 4일에 다시 확인한 [AGP 9.1 릴리스 노트](https://developer.android.com/build/releases/agp-9-1-0-release-notes)의 호환성 표에서 AGP 9.1.1에 대응하는 값은 Gradle 9.3.1, JDK 17, NDK 28.2.13676358이에요. [AGP 9.0](https://developer.android.com/build/releases/agp-9-0-0-release-notes)부터 기본 NDK가 r28 계열이고, 9.1.1도 같은 기본값을 써요. 개발자마다 NDK가 다르면 정렬과 링커의 동작이 달라지는 요인이 될 수 있어요.
그래서 아래 여섯 가지를 한곳에 고정해서 일관되게 관리해요.
```text
AGP version
Gradle version
JDK version
NDK version
Rust toolchain
Android Rust targets
```
새 설정 파일을 만들 필요는 없어요. 이미 관리하고 있는 version catalog, Gradle wrapper, CI image, README를 기준으로 삼으면 돼요. 새로 clone한 상태에서 `./gradlew assembleRelease`나 CI의 release 작업이 Rust 산출물 생성까지 포함해서 성공하는지도 확인해요. Gradle이 아닌 빌드 시스템과 NDK를 함께 쓸 때에는 [NDK의 다른 빌드 시스템 안내](https://developer.android.com/ndk/guides/other_build_systems)를 참고해요.
## debug가 되어도 release는 따로 봐요
R8, minify, packagingOptions, ABI split, App Bundle 처리 때문에 debug 빌드의 결과만으로는 release를 판단할 수 없어요. release 배포물에서 확인할 것은 다섯 가지예요.
- 필요한 ABI가 모두 들어 있는지
- 바인딩이 찾는 이름과 바이너리의 이름이 같은지
- `System.loadLibrary`가 성공하는지
- 16 KB 조건에 맞는지
- 새로 설치한 직후에 Rust 호출이 성공하는지
CI에서 자동으로 확인하기를 권해요. 그렇게 하기 어렵다면 release candidate마다 손으로 보는 점검표가 최소한의 대안이에요. 네이티브 호출의 기본 개념은 [Android의 JNI 안내](https://developer.android.com/ndk/guides/jni-tips)에 있어요.
배포 단계에서 보면 Rust 모듈도 네이티브 바이너리 의존성 가운데 하나예요. 어떤 ABI를 지원할지, Gradle 작업의 증분 빌드를 어떻게 짤지, 어느 스레드에서 실행할지, 타사 코드가 조건에 맞는지, 최종 배포물이 실제로 동작하는지는 프로젝트에서 따로 확인해야 해요. 정책의 날짜와 버전 값은 2026년 10월 4일에 다시 확인한 문서의 것이에요.
---
# Rust shared core, part 4: delivering it to iOS as an XCFramework
> Passing Rust tests does not mean an iOS app links. Bundle device and simulator static libraries into an XCFramework, ship it as a Swift package, and align CI.
- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-04-apple-xcframework/
- Published: 2026.07.01
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Xcode, #SwiftPM, #FFI
Using a shared Rust core in an iOS app takes more than passing tests on the Rust side. That is not a guarantee that the app links. Results can differ between the simulator and a device, or between a development machine and CI, and the binary checksum of a Swift package can fail to match.
Part 4 goes through delivering the Rust binary and the Swift calling layer to an Apple app, based on the Apple Developer, Rust Reference, rustc, and UniFFI documentation. I reopened and checked these documents on October 4, 2026.
## Bundle the static libraries into an XCFramework
The crate types for Rust output that another language links against include `staticlib` and `cdylib` ([linkage in the Rust Reference](https://doc.rust-lang.org/reference/linkage.html)). On iOS, the approach is to package a static library as an XCFramework.
The device build and the simulator build are made separately, the headers and the module map are prepared, and `xcodebuild -create-xcframework` combines them. Written out conceptually, the flow is this.
```text
cargo build --target aarch64-apple-ios
cargo build --target aarch64-apple-ios-sim
libdomain_core.a
headers/
module.modulemap
-> DomainCore.xcframework
```
The supported targets are listed in [rustc's Apple iOS platform notes](https://doc.rust-lang.org/rustc/platform-support/apple-ios.html). The block above is not commands that were run with their output. It is a picture of how the inputs relate to the artifact.
An [XCFramework](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle) can hold a variant per platform and architecture in one bundle. iOS devices, the simulator, macOS, and visionOS are examples. When it is used as a binary dependency of an Xcode project or a Swift package, Xcode can choose the slice that matches the build destination.
Reproducing the build needs matching conditions. I recommend making the installed targets, the toolchain version, the profile, the output location, and the place where headers are generated the same on development machines and in CI. I also recommend putting target installation and packaging into a script. The values of the environment in which I built the example are in the section "An XCFramework bundled from an example."
The headers, the module map, and the generated Swift bindings have to match each other. If the code UniFFI produced and the Rust library are from different versions, linking can fail or symbol problems can appear at run time. So I suggest managing the two generation steps as one release unit.
## An XCFramework bundled from an example
I ran the flow above for real with a small example. The example is deliberately small. Its job is to check the structure, and it is not a real app. The run was on October 4, 2026, on macOS 27.0.1 with Xcode 27.0, cargo 1.96.0, and uniffi 0.29.5. The Rust targets `aarch64-apple-ios` and `aarch64-apple-ios-sim` were already installed. The `crate-type` of the binding crate includes `staticlib`.
For the header and the module map, I used what UniFFI generated together with the Swift bindings. The generated `sudoku_uniffiFFI.modulemap` was copied under the name `module.modulemap`. Below are the lines of the script that was run which belong to this step. The `sed` at the end shortens the absolute path printed in the output, and `out/host` is the directory used for the Swift call in part 3.
```bash
mkdir -p out/headers out/host
cp out/swift/sudoku_uniffiFFI.h out/headers/
cp out/swift/sudoku_uniffiFFI.modulemap out/headers/module.modulemap
# …
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios-sim
xcodebuild -create-xcframework \
-library target/aarch64-apple-ios/release/libsudoku_uniffi.a -headers out/headers \
-library target/aarch64-apple-ios-sim/release/libsudoku_uniffi.a -headers out/headers \
-output out/SudokuCore.xcframework | sed 's#: .*/out/#: out/#'
```
This is the output and the directory that was produced.
```text
xcframework successfully written out to: out/SudokuCore.xcframework
SudokuCore.xcframework
SudokuCore.xcframework/Info.plist
SudokuCore.xcframework/ios-arm64
SudokuCore.xcframework/ios-arm64-simulator
SudokuCore.xcframework/ios-arm64-simulator/Headers
SudokuCore.xcframework/ios-arm64-simulator/libsudoku_uniffi.a
SudokuCore.xcframework/ios-arm64/Headers
SudokuCore.xcframework/ios-arm64/libsudoku_uniffi.a
```
`Info.plist` held two entries, `ios-arm64` and `ios-arm64-simulator`. Each static library was about 31 MB. That is the size of a release build that was not stripped.
That is as far as the check went. I went one step further to see whether this XCFramework can really be consumed. I made a Swift package that points at the XCFramework with a `binaryTarget` and puts the generated Swift bindings in a target, and built it with `xcodebuild build` for the iOS simulator and for a device. Both succeeded. The module map generated by UniFFI was used unchanged. That is the result on Xcode 27.0, and other Xcode versions need their own check. Running an app in the simulator and `xcodebuild test` were not tried. Calling Rust through the generated Swift bindings was confirmed on macOS only, and that record is in part 3.
## Delivering through a Swift package
What an XCFramework holds, how `xcodebuild -create-xcframework` is used, and Rust's crate types and targets are documented. Preparing a module map, Xcode choosing a slice, and the symptoms of a version mismatch were not found in the documents checked this time. The scripting and the single release unit recommended in the previous section, and the package layout and CI practice from this section on, are not decided by the documentation. They are my own design choices.
With a single app, the XCFramework can be added to the project directly. With several apps, or with samples and test targets, I suggest using a [Swift package](https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages). The binary target and the target for the Swift adapter can be separated.
```text
DomainEnginePackage/
Package.swift
Sources/
DomainEngine/
DomainEngineClient.swift
UniFFIAdapter.swift
GeneratedBindings.swift
Artifacts/
DomainCore.xcframework
```
The point of contact exposed to the app is `DomainEngineClient`. The generated code and the binary are treated as internal implementation. That reduces what the app has to know about the Rust connection, and the dependencies are clearer than piling up scripts in the project's build phases.
Distribution has decisions of its own: where to keep the artifact, how to manage the checksum of a remote ZIP, and how to match the version of the source package to the version of the binary. For internal use only, one way is to start with the artifact inside the repository and move it to release files once things are stable.
## Making local builds and CI follow the same order
For integration on the Apple side, I think managing the Swift layer, the package, the build phases, and CI takes more of the work than calling Rust functions does.
A local machine can still have targets that were installed earlier and artifacts that were built before. CI is assumed to start from a fresh clone. Different paths cause problems as well. So I recommend managing the inputs and the order in one place.
```text
install/check rust targets
cargo build for ios device
cargo build for ios simulator
generate UniFFI Swift bindings
generate headers/module map
create XCFramework
run xcodebuild test
```
This list names the steps to put into automation, not a shell script that runs as written.
Compiling Rust in a build phase every time can lengthen the edit-and-run cycle during development. I recommend setting inputs, outputs, and a cache locally, and running CI mainly as clean builds. A measured time for how much longer it gets is not in this post.
A setup that does not commit generated files adds one more condition. The required artifacts have to be generated automatically in the process of cloning fresh, opening the Xcode project, and building. Otherwise the first build of a newcomer fails.
## Put the UniFFI implementation behind a Swift protocol
Using the API generated by UniFFI directly in the app is an easy start. Over time, the range the app depends on can widen. So the UniFFI implementation goes behind a Swift protocol. This is the shape of the protocol I propose.
```swift
protocol SudokuEngineClient {
func startGame(request: StartGameRequest) throws -> GameSnapshot
func apply(_ action: GameAction, to snapshot: GameSnapshot) throws -> Transition
}
```
The ViewModel depends on this protocol, and the generated API is used only inside the adapter. Heavy Rust work can be wrapped in an async API in the adapter, with UI updates handled on the MainActor. With a fake client, the ViewModel can be tested by itself. Delivering the same structure to Android as a `.so` (ABI, Gradle, the NDK, and the 16 KB page size) is in part 5.
Three things are worth confirming. Build for both a device and the simulator. See that local builds and CI use the same commands. See that an Xcode build works from a fresh clone. Of these, the example covered building for a device and for the simulator. I also copied only the sources to a fresh directory and ran the same script from the start, and it passed to the end. Running in CI and building an Xcode project were not checked.
---
# Xcode UI tests and what automationmodetool does and does not cover
> When Xcode UI tests stall at an authentication prompt on an unattended Mac, automationmodetool covers one part and TCC permissions another. Here is how to tell.
- Canonical: https://jaemyeong.com/en/blog/xcode-ui-automation-automationmodetool/
- Published: 2026.07.01
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #macOS, #Xcode, #XCTest, #CI-CD, #Automation
Running Xcode UI tests in CI can stall on an authentication dialog before the run starts. On a Mac with a person in front of it, typing the password is enough. A self-hosted runner or a Mac in a test lab has nobody to type it, so the tests do not proceed.
`automationmodetool` is the tool that configures this authentication requirement. It does not solve every permission problem of UI tests, though. This post separates what the tool covers from what has to be handled on its own, such as TCC permissions, and goes through the order for preparing a machine. I ran the status query myself on October 4, 2026, on macOS 27.0.1 with Xcode 27.0. The commands that change the setting, and UI tests, were not run.
## With no argument it reports the status
According to [the man page](https://keith.github.io/xcode-man-pages/automationmodetool.1.html), the tool configures a device so that Automation Mode for UI testing can be enabled without user authentication. It says this is for preparing CI machines and lab machines whose users do not have administrator privileges. With no argument, it prints whether Automation Mode is enabled and whether authentication is required, and exits. The man page installed on my Mac says the same.
```bash
automationmodetool
```
This is the output from running it on my Mac.
```text
Automation Mode is disabled.
This device DOES NOT REQUIRE user authentication to enable Automation Mode.
```
The two lines say different things. The disabled on the first line is the current state: Automation Mode is off right now. The second line is the setting for whether authentication is needed when the mode is turned on. When it says DOES NOT REQUIRE, XCTest and testmanagerd can switch the mode during a run without a password being entered.
This command allows enabling it without authentication.
```bash
sudo automationmodetool enable-automationmode-without-authentication
```
This one goes back to requiring authentication.
```bash
sudo automationmodetool disable-automationmode-without-authentication
```
Changing the setting itself needs administrator authentication. So I recommend doing it when a machine is first prepared, not on every PR.
## The authentication setting and TCC permissions are separate problems
Xcode Helper or the runner asking for Accessibility, AppleEvents, or Post Event is a TCC matter. In [Apple's PPPC settings guide](https://support.apple.com/guide/deployment/privacy-preferences-policy-control-payload-dep38df53c2a/web), these three correspond to controlling the Mac via Accessibility APIs, sending a restricted AppleEvent to another process, and sending events with CoreGraphics APIs. `automationmodetool` cannot stand in for a TCC grant, an installed PPPC profile, or an Accessibility grant.
The wording in the log tells the two apart. A denial such as `Xcode Helper does not have permission to use Accessibility.` points to the Privacy & Security settings or the PPPC settings in MDM. `Timed out while enabling automation mode.` may be a problem at the authentication step, so query the status first. A timeout alone does not settle that it is an authentication problem, and the two failures can happen together. The prompt on screen is also a hint. A prompt asking for a password is a candidate for the mode authentication side, and a prompt asking for consent to control an app, or for Accessibility, is a candidate for the TCC and PPPC side.
Which process is asking for the permission can be seen in the TCC log. The command is given in the same PPPC guide.
```bash
log stream --debug --predicate 'subsystem == "com.apple.TCC" AND eventMessage BEGINSWITH "AttributionChain"'
```
I recommend confirming the responsible process in this log before allowing it, and not adding Xcode.app or the app under test to the allow list on a guess.
## Configure when preparing the machine, and only query in the job
Here is an example order for preparing a Mac for unattended UI tests. Select the Xcode developer directory, accept the license, run the first-launch tasks, change the authentication setting, and query the status.
```bash
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
sudo automationmodetool enable-automationmode-without-authentication
automationmodetool
```
On a self-hosted runner, be careful about putting this change into a workflow. A step like the one below is an example.
```yaml
- name: Enable automation mode
run: sudo automationmodetool enable-automationmode-without-authentication
```
Allowing sudo in a workflow where arbitrary code runs creates a risk of privilege escalation. I recommend a split in which the owner of the machine does the preparation and the job only builds and tests. At the start of the job, record the status without changing the setting.
```yaml
- name: Show UI automation state
run: |
automationmodetool || true
xcodebuild -version
xcode-select -p
```
`|| true` keeps the job going when the query fails, so that the record is still left. This way of operating is my own reading, not something the man page prescribes.
There is an exception. Hosted CI where the service manages sudo is a different situation. [The Bitrise guide](https://support.bitrise.io/en/articles/9676606-enabling-ui-automation-for-macos-tests) says to run the change command in a Script Step when this timeout appears.
The problem has been reported several times. [Issue 5410](https://github.com/actions/runner-images/issues/5410) reports UI tests failing with the timeout on the macOS 12 Monterey runner of GitHub Actions, and [issue 7778](https://github.com/actions/runner-images/issues/7778) covers the same error on macOS 13 with Xcode 14.3.1. [Flutter issue 166011](https://github.com/flutter/flutter/issues/166011) says that integration tests through XCUITest need this setting, and that the trouble is the setting command asking for a password. That issue quotes an approach that types the password with `expect`. That is fragile, so I recommend preparing the runner account and the machine in advance. Recording and replaying UI automation is the subject of [WWDC25 session 344](https://developer.apple.com/videos/play/wwdc2025/344/).
The per-device mode setting is separate from TCC and keychain permissions, which depend on the user, the process, and the signing. The signing keychain, Developer Tools Access, and DevToolsSecurity are also areas to look into on their own. With several Xcode versions or several runner accounts, work out which setting applies to which scope. The earlier version of this post was written against macOS 26.5.1 and Xcode 26.6, and this time only the status query was checked again. Whether the setting survives a reboot, an update, or an image replacement, and whether UI tests really pass after it is set, were not checked.
---
# Rust 공유 코어 4편: iOS에 XCFramework로 전달하기
> Rust 테스트가 통과해도 iOS 앱에서 링크가 된다는 보장은 없어요. 기기용과 시뮬레이터용 정적 라이브러리를 XCFramework로 묶고, Swift Package로 전달하고, 로컬과 CI가 같은 순서로 빌드하게 만드는 방법을 문서 기준으로 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/rust-shared-core-04-apple-xcframework/
- Published: 2026.07.01
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Xcode, #SwiftPM, #FFI
공용 Rust core를 iOS 앱에서 쓰려면 Rust 쪽 테스트가 통과하는 것만으로는 부족해요. 그것이 앱에서 링크가 된다는 보장은 아니에요. 시뮬레이터와 기기에서 결과가 다르거나, 개발 머신과 CI에서 다르거나, Swift Package의 바이너리 체크섬이 맞지 않는 문제가 생길 수 있어요.
4편은 Rust 바이너리와 Swift 호출 계층을 Apple 앱에 전달하는 과정을 Apple Developer, Rust Reference, rustc, UniFFI 문서로 정리해요. 이 문서들은 2026년 10월 4일에 다시 열어 확인했어요.
## 정적 라이브러리를 XCFramework로 묶어요
다른 언어에서 연결할 Rust 산출물의 crate type에는 `staticlib`과 `cdylib`이 있어요([Rust Reference의 linkage](https://doc.rust-lang.org/reference/linkage.html)). iOS에서는 정적 라이브러리를 XCFramework로 포장하는 방식을 써요.
기기용과 시뮬레이터용을 따로 빌드하고, 헤더와 module map을 준비한 뒤 `xcodebuild -create-xcframework`로 합쳐요. 이 흐름을 개념적으로 적으면 이래요.
```text
cargo build --target aarch64-apple-ios
cargo build --target aarch64-apple-ios-sim
libdomain_core.a
headers/
module.modulemap
-> DomainCore.xcframework
```
타깃의 지원 범위는 [rustc의 Apple iOS 안내](https://doc.rust-lang.org/rustc/platform-support/apple-ios.html)에 있어요. 위 블록은 실행한 명령과 출력이 아니고, 입력과 산출물의 관계를 보여 주는 그림이에요.
[XCFramework](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle)는 플랫폼과 아키텍처별 variant를 하나에 담을 수 있어요. iOS 기기, 시뮬레이터, macOS, visionOS가 그 예예요. Xcode 프로젝트나 Swift Package의 바이너리 의존성으로 쓰면, Xcode가 빌드 대상에 맞는 slice를 선택할 수 있어요.
재현하려면 조건을 맞춰야 해요. 타깃의 설치 상태, toolchain 버전, profile, 출력 위치, 헤더를 생성하는 위치를 개발 머신과 CI에서 같게 하기를 권해요. 타깃 설치와 패키징은 스크립트에 넣기를 권해요. 제가 예제를 빌드한 환경의 값은 «예제로 묶어 본 XCFramework» 절에 적었어요.
헤더, module map, 생성된 Swift 바인딩은 서로 맞아야 해요. UniFFI가 내놓은 코드와 Rust 라이브러리의 버전이 다르면 링크에 실패하거나 실행 중에 심벌 문제가 생길 수 있어요. 그래서 두 생성 작업을 같은 릴리스 단위로 관리하기를 제안해요.
## 예제로 묶어 본 XCFramework
위의 흐름을 작은 예제로 실제로 돌려 봤어요. 일부러 작게 만든 예제이고, 실제 앱은 아니에요. 실행한 날은 2026년 10월 4일이고, 환경은 macOS 27.0.1, Xcode 27.0, cargo 1.96.0, uniffi 0.29.5예요. Rust 타깃 `aarch64-apple-ios`와 `aarch64-apple-ios-sim`은 미리 설치돼 있었어요. 바인딩 crate의 `crate-type`에는 `staticlib`을 넣었어요.
헤더와 module map은 UniFFI가 Swift 바인딩과 함께 생성한 것을 썼어요. 생성된 `sudoku_uniffiFFI.modulemap`을 `module.modulemap`이라는 이름으로 복사했어요. 아래는 실행한 스크립트에서 이 과정에 해당하는 줄이에요. 마지막의 `sed`는 출력에 찍히는 절대 경로를 줄이려는 것이고, `out/host`는 3편의 Swift 호출에 쓴 디렉터리예요.
```bash
mkdir -p out/headers out/host
cp out/swift/sudoku_uniffiFFI.h out/headers/
cp out/swift/sudoku_uniffiFFI.modulemap out/headers/module.modulemap
# …
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios-sim
xcodebuild -create-xcframework \
-library target/aarch64-apple-ios/release/libsudoku_uniffi.a -headers out/headers \
-library target/aarch64-apple-ios-sim/release/libsudoku_uniffi.a -headers out/headers \
-output out/SudokuCore.xcframework | sed 's#: .*/out/#: out/#'
```
출력과 만들어진 디렉터리예요.
```text
xcframework successfully written out to: out/SudokuCore.xcframework
SudokuCore.xcframework
SudokuCore.xcframework/Info.plist
SudokuCore.xcframework/ios-arm64
SudokuCore.xcframework/ios-arm64-simulator
SudokuCore.xcframework/ios-arm64-simulator/Headers
SudokuCore.xcframework/ios-arm64-simulator/libsudoku_uniffi.a
SudokuCore.xcframework/ios-arm64/Headers
SudokuCore.xcframework/ios-arm64/libsudoku_uniffi.a
```
`Info.plist`에는 `ios-arm64`와 `ios-arm64-simulator` 두 항목이 들어 있었어요. 정적 라이브러리는 각각 약 31 MB였어요. strip을 하지 않은 release 빌드의 크기예요.
여기까지가 확인한 범위예요. 이 XCFramework를 실제로 가져다 쓸 수 있는지도 한 단계 더 확인했어요. `binaryTarget`으로 XCFramework를 가리키고 생성된 Swift 바인딩을 타깃에 넣은 Swift Package를 만들어, `xcodebuild build`로 iOS 시뮬레이터용과 기기용을 빌드했고 둘 다 성공했어요. UniFFI가 생성한 module map은 고치지 않고 그대로 썼어요. Xcode 27.0에서 본 결과이고, 다른 Xcode 버전에서는 따로 확인해야 해요. 시뮬레이터에서 앱을 실행하는 것과 `xcodebuild test`는 해 보지 않았어요. 생성된 Swift 바인딩으로 Rust를 부르는 것은 macOS에서만 확인했고, 그 기록은 3편에 있어요.
## Swift Package로 전달하기
XCFramework가 담는 것과 `xcodebuild -create-xcframework`의 사용법, Rust의 crate type과 타깃은 문서에 있어요. module map을 준비하는 일, Xcode가 slice를 고르는 동작, 버전이 어긋날 때의 증상은 이번에 확인한 문서에서 찾지 못했어요. 앞 절에서 권한 스크립트화와 릴리스 단위 관리, 그리고 이 절부터 나오는 패키지 구성과 CI 운영은 문서가 정해 주지 않는 것이고 제 해석이에요.
앱이 하나라면 XCFramework를 프로젝트에 직접 추가해도 돼요. 앱이 여럿이거나 샘플과 테스트 타깃이 있다면 [Swift Package](https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages)를 쓰는 쪽을 제안해요. binary target과 Swift adapter를 위한 타깃을 나눌 수 있어요.
```text
DomainEnginePackage/
Package.swift
Sources/
DomainEngine/
DomainEngineClient.swift
UniFFIAdapter.swift
GeneratedBindings.swift
Artifacts/
DomainCore.xcframework
```
앱에 공개하는 접점은 `DomainEngineClient`예요. 생성 코드와 바이너리는 내부 구현으로 취급해요. 이렇게 하면 앱이 Rust 연결에 대해 알아야 할 것이 줄어요. 프로젝트의 build phase에 스크립트를 쌓는 것보다 의존 관계가 분명해져요.
배포에서 정할 것도 있어요. artifact를 어디에 둘지, 원격 ZIP의 checksum을 어떻게 관리할지, 소스 패키지의 버전과 바이너리의 버전을 어떻게 맞출지예요. 사내에서만 쓴다면 저장소 안에 artifact를 두고 시작해서, 안정된 뒤에 릴리스 파일로 분리하는 방법이 있어요.
## 로컬과 CI가 같은 순서로 빌드하게
Apple 쪽 통합에서는 Rust 함수를 부르는 일보다 Swift 계층, 패키지, build phase, CI를 관리하는 일의 비중이 크다고 봐요.
로컬에는 이미 설치한 타깃과 예전에 만든 산출물이 남아 있을 수 있어요. CI는 새로 clone한 상태에서 시작한다고 가정해요. 경로가 달라서 생기는 문제도 있어요. 그래서 입력과 순서를 한곳에서 관리하기를 권해요.
```text
install/check rust targets
cargo build for ios device
cargo build for ios simulator
generate UniFFI Swift bindings
generate headers/module map
create XCFramework
run xcodebuild test
```
이 목록은 자동화에 넣을 단계이고, 그대로 실행되는 셸 스크립트가 아니에요.
build phase에서 매번 Rust를 컴파일하면 개발할 때 반복 시간이 늘어날 수 있어요. 로컬에서는 inputs와 outputs, cache를 설정하고, CI는 clean build를 중심으로 운영하기를 권해요. 얼마나 늘어나는지 잰 시간은 이 글에 없어요.
생성 파일을 커밋하지 않는 구성이라면 조건이 하나 더 붙어요. 새로 clone해서 Xcode 프로젝트를 열고 빌드하는 과정에서 필요한 산출물이 자동으로 만들어져야 해요. 그래야 새로 온 사람의 첫 빌드가 실패하지 않아요.
## Swift protocol 뒤에 UniFFI 구현을 둬요
UniFFI가 생성한 API를 앱에서 바로 쓰면 시작하기는 쉬워요. 길게 보면 앱이 의존하는 범위가 넓어질 수 있어요. 그래서 Swift protocol 뒤에 UniFFI 구현을 둬요. 제안하는 protocol의 모양이에요.
```swift
protocol SudokuEngineClient {
func startGame(request: StartGameRequest) throws -> GameSnapshot
func apply(_ action: GameAction, to snapshot: GameSnapshot) throws -> Transition
}
```
이 protocol에 ViewModel이 의존하고, 생성된 API는 adapter 안에서만 써요. 무거운 Rust 작업은 adapter에서 async API로 감싸고, UI 갱신은 MainActor에서 처리하도록 만들 수 있어요. fake client를 넣으면 ViewModel만 따로 검사할 수 있어요. 같은 구조를 Android에서 `.so`로 전달하는 방법(ABI, Gradle, NDK, 16 KB page size)은 5편에 있어요.
확인할 것은 셋이에요. 기기와 시뮬레이터를 모두 빌드해 봐요. 로컬과 CI가 같은 명령을 쓰는지 봐요. 새로 clone한 상태에서 Xcode 빌드가 되는지 봐요. 이 가운데 예제에서 해 본 것은 기기용과 시뮬레이터용 빌드예요. 소스만 새 디렉터리에 복사해서 같은 스크립트를 처음부터 다시 돌리는 것도 해 봤고, 끝까지 통과했어요. CI에서 돌리는 것과 Xcode 프로젝트의 빌드는 확인하지 않았어요.
---
# Xcode UI 테스트와 automationmodetool의 범위
> 무인 Mac에서 Xcode UI 테스트가 인증 창 앞에서 멈출 때, automationmodetool이 해결하는 것과 TCC 권한처럼 해결하지 못하는 것을 구분해요. 상태 조회 출력과 장비 준비 순서를 함께 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/xcode-ui-automation-automationmodetool/
- Published: 2026.07.01
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #macOS, #Xcode, #XCTest, #CI-CD, #Automation
CI에서 Xcode UI 테스트를 돌리면 실행 전에 인증 대화상자가 떠서 멈출 때가 있어요. 사람이 앞에 있는 Mac이라면 암호를 넣으면 돼요. self-hosted runner나 테스트 랩의 Mac에는 입력할 사람이 없어서 테스트가 진행되지 않아요.
`automationmodetool`은 이 인증 요구를 설정하는 도구예요. 다만 UI 테스트의 권한 문제를 전부 풀어 주지는 않아요. 이 도구가 맡는 범위와 TCC 권한처럼 따로 다뤄야 하는 것을 구분하고, 장비를 준비하는 순서를 정리해요. 상태 조회는 2026년 10월 4일에 macOS 27.0.1과 Xcode 27.0에서 직접 실행했어요. 설정을 바꾸는 명령과 UI 테스트는 실행하지 않았어요.
## 인자 없이 실행하면 상태를 알려 줘요
[man page](https://keith.github.io/xcode-man-pages/automationmodetool.1.html)에 따르면 이 도구는 UI 테스트용 Automation Mode를 사용자 인증 없이 켤 수 있게 장비를 설정해요. CI 장비와, 사용자에게 관리자 권한이 없는 랩 장비를 준비할 때 쓰라고 적혀 있어요. 인자를 주지 않으면 Automation Mode가 켜져 있는지와 인증이 필요한지를 출력하고 끝나요. 설치된 man page의 내용도 같았어요.
```bash
automationmodetool
```
제 Mac에서 실행한 출력이에요.
```text
Automation Mode is disabled.
This device DOES NOT REQUIRE user authentication to enable Automation Mode.
```
두 줄은 서로 다른 것을 말해요. 첫 줄의 disabled는 지금 Automation Mode가 꺼져 있다는 상태예요. 둘째 줄은 모드를 켤 때 인증이 필요한지에 대한 설정이에요. DOES NOT REQUIRE라고 나오면 XCTest와 testmanagerd가 실행 중에 모드를 전환할 때 암호를 넣지 않아도 돼요.
인증 없이 켤 수 있게 하는 명령은 이것이에요.
```bash
sudo automationmodetool enable-automationmode-without-authentication
```
다시 인증을 요구하게 되돌리는 명령은 이것이에요.
```bash
sudo automationmodetool disable-automationmode-without-authentication
```
설정을 바꾸는 일 자체에는 관리자 인증이 필요해요. 그래서 PR마다 실행하기보다 장비를 처음 준비할 때 넣기를 권해요.
## 인증 설정과 TCC 권한은 다른 문제예요
Xcode Helper나 러너가 Accessibility, AppleEvents, Post Event를 요청하는 것은 TCC의 문제예요. [Apple의 PPPC 설정 안내](https://support.apple.com/guide/deployment/privacy-preferences-policy-control-payload-dep38df53c2a/web)에서 이 셋은 각각 Accessibility API로 Mac을 제어하는 것, 제한된 AppleEvent를 다른 프로세스에 보내는 것, CoreGraphics API로 이벤트를 보내는 것에 대응해요. `automationmodetool`로는 TCC 허가, PPPC 설치, Accessibility 허가를 대신할 수 없어요.
로그의 문구로 둘을 나눠 봐요. `Xcode Helper does not have permission to use Accessibility.` 같은 거부 메시지가 나오면 Privacy & Security 설정이나 MDM의 PPPC 설정을 조사해요. `Timed out while enabling automation mode.`가 나오면 인증 단계의 문제일 가능성이 있으므로 상태부터 조회해요. timeout만으로 인증 문제라고 확정할 수는 없고, 두 장애가 함께 날 수도 있어요. 화면에 뜨는 창으로도 가늠해 볼 수 있어요. 암호를 묻는 창은 모드 인증 쪽의 후보이고, 앱 제어 동의나 Accessibility를 묻는 창은 TCC와 PPPC 쪽의 후보예요.
어느 프로세스가 권한을 요청하는지는 TCC의 로그에서 볼 수 있어요. 같은 PPPC 안내에 실린 명령이에요.
```bash
log stream --debug --predicate 'subsystem == "com.apple.TCC" AND eventMessage BEGINSWITH "AttributionChain"'
```
Xcode.app이나 테스트 대상 앱을 짐작으로 허용 목록에 계속 추가하지 말고, 이 로그에서 책임 프로세스를 확인한 뒤에 허용하기를 권해요.
## 장비를 준비할 때 설정하고 job에서는 조회해요
무인 UI 테스트용 Mac을 준비하는 순서의 예예요. Xcode의 개발 경로를 고르고, 라이선스를 수락하고, 첫 실행 작업을 한 뒤, 인증 설정을 바꾸고 상태를 조회해요.
```bash
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
sudo automationmodetool enable-automationmode-without-authentication
automationmodetool
```
self-hosted runner에서 이 변경을 workflow에 넣는 것은 조심해야 해요. 아래 같은 단계가 그 예예요.
```yaml
- name: Enable automation mode
run: sudo automationmodetool enable-automationmode-without-authentication
```
임의의 코드가 실행되는 workflow에 sudo를 허용하면 권한 상승의 위험이 생겨요. 장비의 소유자가 준비를 맡고, job은 빌드와 테스트만 하게 나누기를 권해요. job의 시작에는 설정을 바꾸지 않고 상태만 기록해 둬요.
```yaml
- name: Show UI automation state
run: |
automationmodetool || true
xcodebuild -version
xcode-select -p
```
`|| true`는 조회가 실패해도 job을 멈추지 않고 기록만 남기려는 것이에요. 이 운영 방식은 man page가 정한 것이 아니고 제 해석이에요.
예외도 있어요. sudo를 서비스가 관리하는 hosted CI에서는 사정이 달라요. [Bitrise의 안내](https://support.bitrise.io/en/articles/9676606-enabling-ui-automation-for-macos-tests)는 이 timeout이 나면 Script Step에서 변경 명령을 실행하라고 적어요.
이 문제는 여러 번 보고됐어요. GitHub Actions의 macOS 12 Monterey 러너에서 UI 테스트가 timeout으로 실패한다는 [이슈 5410](https://github.com/actions/runner-images/issues/5410)이 있고, macOS 13과 Xcode 14.3.1에서 같은 오류를 다룬 [이슈 7778](https://github.com/actions/runner-images/issues/7778)이 있어요. [Flutter의 이슈 166011](https://github.com/flutter/flutter/issues/166011)은 XCUITest 통합 테스트를 위해 이 설정이 필요한데 설정 명령이 암호를 요구하는 것이 문제라고 적어요. 그 이슈에는 `expect`로 암호를 넣는 방식이 인용돼 있는데, 불안정하므로 러너의 계정과 장비를 미리 준비하는 쪽을 권해요. UI 자동화의 녹화와 재생은 [WWDC25 세션 344](https://developer.apple.com/videos/play/wwdc2025/344/)의 주제예요.
장비 단위의 모드 설정과 사용자·프로세스·서명 조건에 따른 TCC, keychain 권한은 별개예요. signing keychain, Developer Tools Access, DevToolsSecurity도 따로 조사할 영역이에요. Xcode를 여러 버전으로 두거나 러너 계정을 여럿 운영한다면, 어느 설정이 어느 범위에 걸리는지 나눠서 봐야 해요. 이 글의 이전 판은 macOS 26.5.1과 Xcode 26.6을 기준으로 적었고, 이번에는 상태 조회만 다시 확인했어요. 설정이 재부팅이나 업데이트, 이미지 교체 뒤에도 유지되는지와 설정한 뒤에 UI 테스트가 실제로 통과하는지는 확인하지 않았어요.
---
# Rust shared core, part 3: keep UniFFI generated code behind an adapter
> UniFFI generates Swift and Kotlin calling code from Rust. What to expose and how far generated types spread is up to the app. An adapter keeps them contained.
- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-03-uniffi-mobile/
- Published: 2026.06.30
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Android, #UniFFI, #FFI
Calling common functionality written in Rust from iOS and Android needs a connecting layer on the Swift side and on the Kotlin side. One way is to build a C ABI and a wrapper per platform by hand. That is a reasonable choice for a small experiment, but I expect the maintenance burden to grow as the app gets larger and the API grows.
[UniFFI](https://mozilla.github.io/uniffi-rs/latest/) is a tool that generates this layer. The tool does the generating, but it does not decide where the generated code sits inside the app. Part 3 is about choosing that place.
## What UniFFI does and does not do
What UniFFI generates, as described in this section, comes from its documentation. The judgment that the risk of drift goes down, the account of what the tool leaves undecided, and the adapter structure and handling of generated files in the later sections are my own reading and design choices.
UniFFI produces Swift and Kotlin calling code from the Rust functions and types chosen for exposure. The generated code reaches the Rust library through a C-compatible FFI. After the API is changed, the code can be generated again, so a wrapper per language does not have to be maintained by hand. Both platforms end up using the same domain functions, so I expect the risk of rules drifting apart to go down. The Swift output and the Kotlin output are separate artifacts that come from the same Rust API.
The tool does not decide everything. Which functions to expose, what shape the DTOs take, how errors are represented, and how far the generated types are visible in the app are decided by the people building the app. Exposing Rust's internal model as it is can make it awkward to use on mobile.
## Generated code goes behind an adapter
If a ViewModel or a ViewController calls the generated API directly and the generated types spread through the whole app, every change to the Rust API may require changes to the UI on both platforms. So each platform gets a thin adapter.
```text
iOS
SudokuEngineClient protocol
-> UniFFISudokuEngineClient
-> generated Swift binding
Android
SudokuEngineClient interface
-> UniFFISudokuEngineClient
-> generated Kotlin binding
```
The app uses only the protocol on iOS and the interface on Android. Only `UniFFISudokuEngineClient` touches the generated code. Generated types are not passed to the ViewModel. Swift passes its own DTOs or domain types, and Kotlin passes data classes.
With this structure, a fake `SudokuEngineClient` can be put into the unit tests of a ViewModel. Integration tests that really call Rust are set up separately.
## Call size and types
I recommend handling one call per user action over calling many times on every render. The current state and the action go in, and the changed state comes back in one piece.
The API below is an example written with JSON strings going in and out.
```text
start_game(request_json: String) -> String
apply_action(snapshot_json: String, action_json: String) -> String
validate_snapshot(snapshot_json: String) -> String
```
JSON is not mandatory. UniFFI records, enums, and sequences are an option too. As the exposed types get more complex, though, keeping the meaning identical in the two languages can become hard.
I prefer to start with simple DTOs and fixtures. The types are split more finely after measurement shows a performance problem. Making the FFI design complex first does not help if the real bottleneck is somewhere else. Numbers comparing the performance of the two approaches are not in this post.
## What to look after on each platform
Using UniFFI does not make the two apps structurally the same. How things run inside the adapter is chosen to fit the runtime of that platform.
On iOS, Swift concurrency, MainActor, and state updates in UIKit and SwiftUI are taken into account. Calls that use a lot of CPU are better handled off the main thread. The adapter manages a background queue or an async boundary, and screen updates happen back on the main actor. Packaging the Rust output as an XCFramework comes in part 4.
ViewModel scope, the coroutine dispatcher, state updates in Compose, and the order of loading native libraries are the things to check on Android. If the Rust `.so` fails to load, an error can occur at app start or on the first call. Whether it is packaged correctly for each ABI has to be confirmed too. For the concepts of connecting Kotlin and native code, see [the Android JNI tips](https://developer.android.com/ndk/guides/jni-tips).
## What I generated and called in an example
I built this structure as a small example, and on October 4, 2026 I generated the bindings and called them. The workspace is small and only checks the structure. No real app is behind it. The environment is macOS 27.0.1, cargo 1.96.0, uniffi 0.29.5, and Swift 6.4. The binding crate only wraps and exports the functions of the core. Keeping the functions that parse and serialize JSON in the core crate as well is where the example departs from the design in part 2, to stay small.
```rust
uniffi::setup_scaffolding!();
#[uniffi::export]
pub fn start_game(request_json: String) -> String {
sudoku_core::start_game_json(&request_json)
}
#[uniffi::export]
pub fn apply_action(snapshot_json: String, action_json: String, environment_json: String) -> String {
sudoku_core::apply_action_json(&snapshot_json, &action_json, &environment_json)
}
// … validate_snapshot and generate_daily_puzzle have the same shape.
```
Unlike the API example above, `apply_action` takes three arguments. `environment_json` was added, following the contract in part 2. The bindings are generated from the built library. `uniffi-bindgen` is not installed separately. It is a binary in the same crate.
```bash
cargo build -q -p sudoku-uniffi
# …
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
--library target/debug/libsudoku_uniffi.dylib --language swift --out-dir out/swift
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
--library target/debug/libsudoku_uniffi.dylib --language kotlin --no-format --out-dir out/kotlin
```
`--no-format` is on the Kotlin side because ktlint is not on this Mac. Generating without the option printed a warning that ktlint could not be found. `-q` is the cargo option that reduces output. Four files were generated.
```text
kotlin/uniffi/sudoku_uniffi/sudoku_uniffi.kt
swift/sudoku_uniffi.swift
swift/sudoku_uniffiFFI.h
swift/sudoku_uniffiFFI.modulemap
```
The Swift file was 514 lines and the Kotlin file 1107 lines. In Swift the function names become `startGame(requestJson:)`, `applyAction(snapshotJson:actionJson:environmentJson:)`, and so on. I compiled the generated Swift file together with the calling code using `swiftc`, linked the Rust library, and ran it on macOS. This is part of the calling code.
```swift
let env = #"{"locale":"ko-KR"}"#
let snapshot = startGame(requestJson: #"{"seed":0}"#)
// …
print("validate:", validateSnapshot(snapshotJson: snapshot))
// …
let conflict = applyAction(snapshotJson: snapshot, actionJson: #"{"type":"set_value","row":0,"col":2,"value":5}"#, environmentJson: env)
print("conflict:", conflict)
```
This is the output of that part.
```text
validate: {"valid":true,"errors":[]}
conflict: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"conflict","message":"the value conflicts with a row, column, or box"}}
```
That confirms a function called from Swift returning the result from Rust as a string. The run was on the macOS host, not on iOS, and no adapter or protocol was built.
## Whether to commit the generated files
There are two options.
- Commit the generated Swift and Kotlin files. They are easy to browse in the IDE, and a PR shows how the API changed. The large diff of generated code can be a burden in review.
- Generate at build time. Rust stays the single source. The generation steps have to be reproduced on a fresh clone and in CI, and the build can become unstable when the UniFFI CLI or Rust toolchain versions differ across development environments.
Early in an app project, especially while the connection between iOS and Xcode has not settled, committing is worth considering. In that case it is good to record the generation command and have CI check that regenerating produces no diff. For a project centered on a library, or when the amount of change is a burden, generating at build time is worth considering. Neither is a policy that fits every project. Whichever is chosen, I recommend reproducing binding generation in CI.
How to manage generated files, along with the API boundary, the types, and the error representation, has to be decided per project. The options in this post are recommendations. What the example confirmed is generation and a Swift call on macOS. Compiling the Kotlin bindings and calling them on Android, and running on an iOS device, were not checked.
---
# Rust 공유 코어 3편: UniFFI 생성 코드는 adapter 뒤에
> UniFFI는 Rust의 함수와 타입에서 Swift와 Kotlin 호출 코드를 만들어 줘요. 무엇을 공개하고 생성된 타입을 어디까지 쓸지는 앱이 정해야 해요. 생성 코드를 adapter 뒤에 두는 구조와 플랫폼별로 챙길 것, 생성 파일의 관리 방식을 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/rust-shared-core-03-uniffi-mobile/
- Published: 2026.06.30
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Android, #UniFFI, #FFI
Rust로 만든 공통 기능을 iOS와 Android에서 부르려면 Swift 쪽과 Kotlin 쪽에 연결 계층이 있어야 해요. C ABI와 플랫폼별 wrapper를 손으로 만드는 방법도 있어요. 작은 실험에서는 고를 만하지만, 앱이 커지고 API가 늘어나면 관리 부담이 커질 것으로 예상해요.
[UniFFI](https://mozilla.github.io/uniffi-rs/latest/)는 이 계층을 생성해 주는 도구예요. 생성은 도구가 하지만, 생성된 코드를 앱 안의 어디에 둘지는 도구가 정해 주지 않아요. 3편은 그 자리를 정하는 이야기예요.
## UniFFI가 해 주는 일과 해 주지 않는 일
이 절에서 UniFFI가 무엇을 생성하는지는 문서에 있는 내용이에요. 규칙이 어긋날 위험이 준다는 판단과 도구가 정해 주지 않는 것에 대한 설명, 그리고 그 뒤의 절에 나오는 adapter 구조와 생성 파일의 관리 방식은 제 해석이에요.
UniFFI는 공개하기로 한 Rust 함수와 타입을 바탕으로 Swift와 Kotlin의 호출 코드를 만들어요. 만들어진 코드는 C와 호환되는 FFI를 거쳐 Rust 라이브러리로 이어져요. API를 고친 뒤에는 다시 생성하면 되므로, 언어마다 wrapper를 손으로 유지하지 않아도 돼요. 두 플랫폼이 같은 도메인 함수를 쓰게 되므로 규칙이 어긋날 위험이 줄 것으로 봐요. Swift용과 Kotlin용은 같은 Rust API에서 나온 별개의 산출물이에요.
도구가 정해 주지 않는 것도 있어요. 어떤 함수를 공개할지, DTO를 어떤 모양으로 할지, 오류를 어떻게 표현할지, 생성된 타입을 앱의 어디까지 드러낼지는 앱을 만드는 쪽이 정해요. Rust 내부의 모델을 그대로 공개하면 모바일에서 쓰기 불편해질 수 있어요.
## 생성 코드는 adapter 뒤에 둬요
생성된 API를 ViewModel이나 ViewController가 직접 부르고 생성된 타입이 앱 전체에 퍼지면, Rust API를 고칠 때마다 두 플랫폼의 UI까지 고쳐야 할 수 있어요. 그래서 플랫폼마다 얇은 adapter를 둬요.
```text
iOS
SudokuEngineClient protocol
-> UniFFISudokuEngineClient
-> generated Swift binding
Android
SudokuEngineClient interface
-> UniFFISudokuEngineClient
-> generated Kotlin binding
```
앱은 iOS의 protocol과 Android의 interface만 써요. 생성 코드에 닿는 것은 `UniFFISudokuEngineClient`뿐이에요. ViewModel에는 생성된 타입을 넘기지 않고, Swift에서는 자체 DTO나 domain type을, Kotlin에서는 data class를 넘겨요.
이 구조에서는 ViewModel의 단위 테스트에 fake `SudokuEngineClient`를 넣을 수 있어요. Rust를 실제로 부르는 integration test는 따로 구성해요.
## 호출 단위와 타입
렌더링할 때마다 여러 번 부르는 방식보다 사용자 액션마다 한 번 처리하는 방식을 권해요. 현재 상태와 액션을 넘기고, 바뀐 상태를 한 번에 돌려받아요.
JSON 문자열을 주고받는 형태로 적은 API의 예예요.
```text
start_game(request_json: String) -> String
apply_action(snapshot_json: String, action_json: String) -> String
validate_snapshot(snapshot_json: String) -> String
```
JSON을 꼭 써야 하는 것은 아니에요. UniFFI의 record, enum, sequence를 쓸 수도 있어요. 다만 공개하는 타입이 복잡해지면 두 언어에서 의미를 똑같이 유지하기가 어려워질 수 있어요.
저는 처음에는 간단한 DTO와 fixture로 시작하는 쪽을 선호해요. 측정해서 성능 문제가 드러난 뒤에 타입을 잘게 나눠요. FFI 설계부터 복잡하게 만들어도 실제 병목은 다른 곳에 있을 수 있어요. 두 방식의 성능을 비교한 수치는 이 글에 없어요.
## 플랫폼마다 챙길 것
UniFFI를 쓴다고 해서 두 앱의 구조가 같아지지는 않아요. adapter 안쪽의 실행 방식은 그 플랫폼의 런타임에 맞게 골라요.
iOS에서는 Swift concurrency, MainActor, UIKit과 SwiftUI의 상태 갱신을 고려해요. CPU를 많이 쓰는 호출은 main thread 밖에서 처리하는 것이 좋아요. adapter가 background queue나 async 경계를 관리하고, 화면 갱신은 main actor로 돌아와서 해요. Rust 결과물을 XCFramework로 묶는 과정은 4편에서 다뤄요.
ViewModel scope, coroutine dispatcher, Compose의 상태 갱신, 네이티브 라이브러리를 로드하는 순서는 Android에서 점검할 것들이에요. Rust의 `.so`를 로드하지 못하면 앱을 시작할 때나 처음 호출할 때 오류가 날 수 있어요. ABI마다 제대로 패키징됐는지도 확인해야 해요. Kotlin과 네이티브 코드의 연결 개념은 [Android의 JNI 안내](https://developer.android.com/ndk/guides/jni-tips)를 참고해요.
## 예제에서 생성하고 호출해 본 것
이 구조를 작은 예제로 만들어서 2026년 10월 4일에 바인딩을 생성하고 호출해 봤어요. 실제 앱의 코드가 아니고 구조를 확인하려는 최소 workspace예요. 환경은 macOS 27.0.1, cargo 1.96.0, uniffi 0.29.5, Swift 6.4예요. 바인딩 crate는 코어의 함수를 감싸서 내보내기만 해요. JSON을 해석하고 직렬화하는 함수까지 코어 crate에 둔 것은 예제를 줄이려고 2편의 설계와 다르게 한 부분이에요.
```rust
uniffi::setup_scaffolding!();
#[uniffi::export]
pub fn start_game(request_json: String) -> String {
sudoku_core::start_game_json(&request_json)
}
#[uniffi::export]
pub fn apply_action(snapshot_json: String, action_json: String, environment_json: String) -> String {
sudoku_core::apply_action_json(&snapshot_json, &action_json, &environment_json)
}
// … validate_snapshot, generate_daily_puzzle도 같은 모양이에요.
```
위의 API 예와 달리 `apply_action`의 인자가 셋이에요. 2편의 계약대로 `environment_json`을 더했어요. 바인딩은 빌드한 라이브러리에서 생성해요. `uniffi-bindgen`은 따로 설치하지 않고 같은 crate의 실행 파일로 뒀어요.
```bash
cargo build -q -p sudoku-uniffi
# …
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
--library target/debug/libsudoku_uniffi.dylib --language swift --out-dir out/swift
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
--library target/debug/libsudoku_uniffi.dylib --language kotlin --no-format --out-dir out/kotlin
```
Kotlin 쪽에 `--no-format`을 준 것은 이 Mac에 ktlint가 없어서예요. 옵션 없이 생성하면 ktlint를 찾지 못했다는 경고가 나왔어요. `-q`는 cargo의 출력을 줄이는 옵션이에요. 생성된 파일은 넷이에요.
```text
kotlin/uniffi/sudoku_uniffi/sudoku_uniffi.kt
swift/sudoku_uniffi.swift
swift/sudoku_uniffiFFI.h
swift/sudoku_uniffiFFI.modulemap
```
Swift 파일은 514줄, Kotlin 파일은 1107줄이었어요. Swift에서는 함수 이름이 `startGame(requestJson:)`, `applyAction(snapshotJson:actionJson:environmentJson:)`처럼 바뀌어요. 생성된 Swift 파일과 호출 코드를 `swiftc`로 함께 컴파일하고 Rust 라이브러리를 링크해서 macOS에서 실행했어요. 호출 코드의 일부예요.
```swift
let env = #"{"locale":"ko-KR"}"#
let snapshot = startGame(requestJson: #"{"seed":0}"#)
// …
print("validate:", validateSnapshot(snapshotJson: snapshot))
// …
let conflict = applyAction(snapshotJson: snapshot, actionJson: #"{"type":"set_value","row":0,"col":2,"value":5}"#, environmentJson: env)
print("conflict:", conflict)
```
이 부분의 출력이에요.
```text
validate: {"valid":true,"errors":[]}
conflict: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"conflict","message":"the value conflicts with a row, column, or box"}}
```
Swift에서 부른 함수가 Rust의 결과를 문자열로 돌려준 것까지 확인했어요. 이 실행은 iOS가 아니라 macOS 호스트에서 한 것이고, adapter와 protocol은 만들지 않았어요.
## 생성된 파일을 저장소에 넣을지
선택지는 둘이에요.
- 생성된 Swift와 Kotlin 파일을 저장소에 넣어요. IDE에서 찾아보기 쉽고 PR에서 API가 어떻게 바뀌었는지 보기 좋아요. 생성 코드의 큰 diff가 리뷰에 부담이 될 수 있어요.
- 빌드할 때 생성해요. Rust가 유일한 원본으로 남아요. 새로 clone한 환경과 CI에서 생성 절차를 재현해야 하고, 개발 환경마다 UniFFI CLI나 Rust toolchain의 버전이 다르면 빌드가 불안정해질 수 있어요.
앱 프로젝트의 초기, 특히 iOS와 Xcode의 연결이 아직 자리 잡지 않았다면 저장소에 넣는 쪽도 고려할 만해요. 이때는 생성 명령을 기록해 두고, CI에서 다시 생성했을 때 diff가 없는지 검사하는 것이 좋아요. 라이브러리가 중심인 프로젝트이거나 변경량이 부담스럽다면 빌드할 때 생성하는 쪽을 고려해요. 어느 쪽도 모든 프로젝트에 맞는 정책은 아니에요. 어느 쪽을 고르든 CI에서 binding 생성을 재현하기를 권해요.
생성 파일을 어떻게 관리할지를 포함해서, API의 경계, 타입, 오류 표현은 프로젝트마다 정해야 해요. 이 글의 선택지는 권고예요. 예제에서 확인한 것은 생성과 macOS에서의 Swift 호출까지이고, Kotlin 바인딩을 컴파일해서 Android에서 부르는 것과 iOS 기기에서의 실행은 확인하지 않았어요.
---
# DevToolsSecurity on macOS and debugger authorization on unattended CI
> Attaching a debugger on a Mac can show a password prompt, and unattended CI stalls on it. What DevToolsSecurity changes, read directly on macOS 27.0.1.
- Canonical: https://jaemyeong.com/en/blog/macos-devtoolssecurity-enable-developer-mode/
- Published: 2026.06.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #macOS, #Xcode, #DeveloperMode, #Debugging, #CI-CD
When a debugger attaches to a program built in Xcode or the terminal on a Mac, or when Instruments starts, a "Developer Tools Access" prompt can appear and ask whether one process may control another. On my own Mac, typing the password ends it. On an unattended test or a headless CI/CD runner, the job stalls while that prompt waits for input.
The command that removes the prompt ahead of time is `sudo DevToolsSecurity -enable`. This post covers what the command changes and where to look when the prompt remains after enabling. What I confirmed with read-only commands on macOS 27.0.1 on October 4, 2026 is marked as such. I did not run `-enable` or the group command again this time.
## Why a debugger asks for authorization
macOS isolates the memory of each process. Stopping another process, reading or writing its memory, and injecting code into it are restricted.
lldb and Instruments need access to the target process to analyze it. They get the target's Mach task port through `task_for_pid` in XNU, and use that port to step line by line or dump memory. Without enough privilege, the request is denied or goes through an administrator password prompt. [Authorization Services](https://developer.apple.com/documentation/security/authorization-services) handles that authorization, and the rules are stored in `/var/db/auth.db`.
## The rule that DevToolsSecurity changes
`/usr/sbin/DevToolsSecurity` changes the rule of a right named `system.privilege.taskport`. The earlier version of this post explained that before enabling, the rule asks for the login password or root credentials, and that after enabling, it is tied to the `_developer` group. Of these, I could not check the behavior before enabling this time.
I read the rule in the enabled state with `security authorizationdb read system.privilege.taskport`. These are the values I read (macOS 27.0.1).
- `class` is `user`.
- `group` is `_developer`.
- `authenticate-user` is true, and `allow-root` is false.
- `timeout` is 36000.
An earlier version of this post said the class after enabling is `rule`, but the value I read this time was `user`. The comment on the rule says this right is used by `task_for_pid` and applies only when the requesting program and the target program are run by the same user. It does not authorize access to another user's program. I could not check the values before enabling, because the Mac I checked already had it enabled.
## Use -status to check the state
Running `/usr/sbin/DevToolsSecurity` with no argument does not show the state. According to the usage text, the default action is `-enable`.
This is the usage text, printed by passing an option that does not exist.
```text
$ /usr/sbin/DevToolsSecurity -help
DevToolsSecurity: unrecognized option `-help'
Changes the security authorization policies for developer systems
Usage: /usr/sbin/DevToolsSecurity [-verbose] [-enable | -disable | -status]
-enable :: (default) enable developer mode policies
-disable :: return policies to system default
-status :: prints out whether or not developer mode policies are in effect
-verbose :: makes output more verbose
```
To see only the state, use `-status`.
```text
$ /usr/sbin/DevToolsSecurity -status
Developer mode is currently enabled.
```
The earlier version said to run the command with no argument and look for `enabled`. That explanation was wrong, and it is corrected as shown above.
## When the prompt remains after enabling
After `-enable`, lldb in the terminal or C++ debugging in VS Code can still show the prompt or fail with `attach failed`. The place to look is whether the account is in the `_developer` group.
This command adds the currently logged-in account to the group.
```bash
# 현재 로그인된 계정을 _developer 그룹에 강제 멤버십 추가
sudo dscl . append /Groups/_developer GroupMembership $(whoami)
```
The Korean comment says the command adds the currently logged-in account to the `_developer` group.
After adding, reopening the terminal or logging out and back in is recommended. The members of the group can be listed with `dscl . read /Groups/_developer GroupMembership`.
I did not compare causes myself for this part. On the Mac I checked, the `_developer` group had no `GroupMembership` key at all.
```text
$ dscl . read /Groups/_developer GroupMembership
No such key: GroupMembership
```
I did not check how attaching a debugger behaves on this Mac, where the `GroupMembership` key was absent, or under exactly which condition group membership is needed. There is also no basis for saying that joining the group resolves every `attach failed`.
## What happens on an unattended runner
Mobile release automation uses a Mac mini or a macOS virtual machine in the cloud as a runner. The problem case is when this setup step is omitted after a fresh Xcode install or an OS upgrade.
When Appium or an Xcode UI test installs the test app and tries to attach for debugging, the authorization request appears in the GUI in the background. Nobody is at the runner to answer. The job ends only after the configured time limit passes, and a runner with no time limit keeps waiting. A result from reproducing this flow and timing it is not included here.
So the script that prepares a runner runs two things together.
```bash
# 개발자 도구 액세스 허용 활성화
sudo /usr/sbin/DevToolsSecurity -enable
# 빌드 실행 에이전트 계정을 개발자 그룹에 병합
sudo dscl . append /Groups/_developer GroupMembership build_agent_user
```
The first comment says "enable developer tools access," and the second says "add the build agent account to the developer group."
Replace `build_agent_user` with the account name the runner uses. Putting the enable command into the runner setup guide as well keeps it from being missed on a new machine. There is no basis for saying that every server needs both commands. I also did not measure how installing a test app on a device or simulator connects to task port authorization on the Mac.
## What is given up in security
This setting removes one authorization step. To that extent, developer tools can handle the memory of other processes run by the same user more widely. Access to the machine should be controlled, and binaries or code of unknown origin should not run on it.
Developer Mode on an iPhone or iPad targets something else. [Developer Mode on a device](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device) allows locally installed apps, such as an app built and run from Xcode, to run on that device. It does not affect apps installed from the App Store or TestFlight. The setting in this post is the permission for tools such as lldb to control other processes on a Mac.
The values read this time come from one Mac running macOS 27.0.1, and because it was already enabled, the values before enabling could not be checked. A comparison across macOS versions is not included here either. I also could not confirm in the documentation exactly which signature the condition "only signed developer tools are allowed" refers to.
---
# Why Hangul file names fall apart into jamo, and how NFC fixes it
> A Hangul file name made on macOS can look decomposed or count as a different name elsewhere. The cause is the Unicode normalization form, checked with code.
- Canonical: https://jaemyeong.com/en/blog/macos-hangul-jaso-nfd-nfc/
- Published: 2026.06.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #macOS, #Unicode, #Troubleshooting, #Git
When a file with a Hangul name made on macOS is sent to Windows or Linux, the jamo in the name (the letters that make up each Hangul syllable) can appear separated. It is not only a display problem. Git can detect a file whose content did not change as renamed, and in CI the resource path the code looks for may not match the actual file name. The same can happen when a file travels as a mail attachment, through a messenger, in a ZIP, or through cloud storage.
The cause is that the same character has two Unicode representations. This post confirms the difference between the two with code points, and covers how to bring file names and strings to one of them.
Errors in the code points and the examples were present in the earlier version of this post. I corrected them by running string normalization myself on October 4, 2026, on macOS 27.0.1, with Python 3.14.5, Swift 6.4, and Node.js 24.14.1. What I checked is string normalization, plus the `convmv` dry run and rename on one file in a temporary folder. The `convmv` result is in the section below. I did not run Automator or Git, and I did not compare file systems. The definition of the normalization forms comes from [UAX #15](https://www.unicode.org/reports/tr15/), and the advice on where to normalize is my own reading.
## Two representations of the same character
NFD is the canonically decomposed form of a character, and NFC is the canonically composed form. For the syllable 한 it looks like this.
- NFC: the single code point U+D55C (HANGUL SYLLABLE HAN).
- NFD: the three code points U+1112, U+1161, and U+11AB. In order, they are HANGUL CHOSEONG HIEUH, HANGUL JUNGSEONG A, and HANGUL JONGSEONG NIEUN.
This is the Python code I used to check, with its output.
```python
import unicodedata as u
print([f"U+{ord(c):04X}" for c in u.normalize("NFD", "한")])
print(u.normalize("NFC", "\u1112\u1161\u11ab"))
print(u.normalize("NFC", "\u1106\u1161\u11ab"))
print(u.normalize("NFC", "ㅎㅏㄴ"), u.normalize("NFD", "ㅎㅏㄴ"))
print([f"U+{ord(c):04X}" for c in "ㅎㅏㄴ"])
```
Running the Python code prints the following.
```text
['U+1112', 'U+1161', 'U+11AB']
한
만
ㅎㅏㄴ ㅎㅏㄴ
['U+314E', 'U+314F', 'U+3134']
```
The Hangul strings in the code are the syllable 한 and the three separately typed letters that are explained in the next section.
The earlier version gave the initial consonant as U+1106. U+1106 is HANGUL CHOSEONG MIEUM. As the third line of the output shows, composing U+1106, U+1161, and U+11AB gives 만 (U+B9CC), not 한.
## The visible separated letters are not the decomposed form
To explain the decomposed form, it is tempting to type the letters separately and show them that way. The earlier version did so. But the letters typed on a keyboard are U+314E, U+314F, and U+3134, which are compatibility jamo, a different set of characters. They are not the same characters as U+1112, U+1161, and U+11AB used in the decomposed form.
The fourth line of the output above shows the difference. Applying NFC to the three compatibility letters does not give 한, and applying NFD leaves them as they are. The file name that the earlier version used as a decomposed example, `ㄱㅓㅁㅅㅐㄱㅎㅘㅁㅕㄴ.png`, is also written in compatibility jamo, so normalization does not turn it into `검색화면.png`. The real decomposed form of `검색화면.png` contains final-consonant jamo such as U+11B7, U+11A8, and U+11AB.
For that reason, this post writes the decomposed form as code points and escapes, not as visible letters.
## Where the problem shows up
The earlier version explained that macOS stores file names in the decomposed form and that Windows and Linux use the composed form. That explanation can vary with the file system, the program that draws the screen, and the transfer tool, and I did not check it this time. The two cases below are not reproduced from real logs either. They describe paths by which the problem can happen.
With Git, the case to think about is this. A file named `검색화면.png` is added and pushed on macOS and pulled on Windows. If the normalization form changes along the way, a file with the same content can be detected as renamed. Then diffs appear with no change in content, unnecessary commits pile up, and conflicts can become more likely. The outcome can differ with the Git configuration and with whether a conversion happens in transfer.
In builds, image and Markdown paths on the web or in iOS are affected. In a Linux container such as GitHub Actions, a path string written in the composed form in code and a file name stored in the decomposed form have different bytes. So the file may not be found.
## Renaming files to NFC with convmv
Existing file names can be changed in bulk with [convmv](https://www.j3e.de/linux/convmv/). I ran the dry-run command and the apply command below on October 4, 2026 with convmv 2.06, in a temporary folder that held one `.txt` file with a decomposed name. convmv was already installed, so I did not run the install command again.
Install it with [Homebrew](https://formulae.brew.sh/formula/convmv).
```bash
brew install convmv
```
First preview what would change. This command keeps the encoding as UTF-8, converts to NFC, and searches subdirectories.
```bash
convmv -f utf8 -t utf8 --nfc -r [변경을_원하는_디렉토리_경로]
```
The bracketed Korean placeholder stands for the path of the directory to change.
Nothing is renamed at this step. The tool shows each planned rename in the form `mv "old name" "new name"` and prints `No changes to your files done. Would have converted 1 files in 0 seconds.` and `Use --notest to finally rename the files.` That differs from the wording in the earlier version of this post. Checking the file name after the run showed it was still decomposed. After checking the list, add `--notest` to apply it.
```bash
convmv -f utf8 -t utf8 --nfc -r --notest [변경을_원하는_디렉토리_경로]
```
Running it with `--notest` printed `Ready! I converted 1 files in 0 seconds.`, and I confirmed that the file name had changed to the composed form (NFC). The name went from 10 code points to 6.
### Adding it to the Finder context menu
For frequent use, a Quick Action can be made in Automator.
1. Create a new Quick Action in Automator.
2. Set the input to files and folders, and the application to Finder.
3. Add a Run Shell Script action and set Pass input to as arguments.
4. Put in the script below.
5. Save it under a name such as "Compose Hangul jamo," and it appears in the Finder context menu.
```bash
for i in "$@"; do
/opt/homebrew/bin/convmv -f utf-8 -t utf-8 --nfc --notest "$i"
done
```
`/opt/homebrew` is the Homebrew path on Apple Silicon Macs from the M1 on. If the install location differs, the path has to be changed. This script has no `-r`, so unlike the command above it does not process the items inside a selected folder.
## Normalizing in code
If the form is unified at the boundary where file names or user input go into a database or storage, later comparisons become simple.
Swift has [precomposedStringWithCanonicalMapping](https://developer.apple.com/documentation/foundation/nsstring/precomposedstringwithcanonicalmapping) for the composed form and `decomposedStringWithCanonicalMapping` for the decomposed form. This is an example I ran, with the decomposed form written as escapes.
```swift
import Foundation
let decomposed = "\u{1112}\u{1161}\u{11AB}" // NFD: U+1112 U+1161 U+11AB
let composed = decomposed.precomposedStringWithCanonicalMapping
let back = composed.decomposedStringWithCanonicalMapping
func scalars(_ s: String) -> String {
s.unicodeScalars.map { String(format: "U+%04X", $0.value) }.joined(separator: " ")
}
print(scalars(decomposed))
print(scalars(composed))
print(scalars(back))
print(decomposed == composed)
print(Array(decomposed.utf8) == Array(composed.utf8))
```
The Swift code prints these code points.
```text
U+1112 U+1161 U+11AB
U+D55C
U+1112 U+1161 U+11AB
true
false
```
The fourth and fifth lines deserve attention. A Swift `String` compared with `==` treats the decomposed and composed forms as equal. Their UTF-8 bytes, however, differ. The difference stays hidden while comparing inside Swift, and appears at the moment the string is written out as bytes and used as a file name or a key.
In JavaScript, use `normalize("NFC")`.
```javascript
const decomposed = "\u1112\u1161\u11AB"; // NFD: U+1112 U+1161 U+11AB
const composed = decomposed.normalize("NFC");
const codePoints = (s) => [...s].map((c) => "U+" + c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(" ");
console.log(codePoints(decomposed));
console.log(codePoints(composed));
console.log(decomposed === composed);
console.log(decomposed.normalize("NFC") === composed);
```
The Node.js code prints this.
```text
U+1112 U+1161 U+11AB
U+D55C
false
true
```
The `===` operator in JavaScript treats the decomposed and composed forms as different. They become equal only after normalization.
In Python it is `unicodedata.normalize`. This function only returns a changed string. It does not rename a file on disk.
```python
import os
import sys
import unicodedata
def sanitize_nfc(path):
return unicodedata.normalize('NFC', path)
```
## Places to check
Using only English file names avoids the problem, but for a project that uses Hangul names, it is better to check the following.
- Whether decomposed names have entered a repository that several people use.
- Whether a build on Windows or Linux finds resources at Hangul paths.
- How Git detects renames. `core.ignorecase` is a setting about letter case, and there is no basis for saying it solves normalization.
- Whether the boundary that receives uploads normalizes and validates names.
Whether `convmv` converts every file, and whether names stay intact in transfer after the conversion, was not checked this time. The same goes for what happens when a converted name collides with an existing file, for permission problems, and for differences between file systems. What I confirmed by running is string normalization in Python, Swift, and JavaScript, plus the `convmv` dry run and rename on one file in a temporary folder.
---
# Rust shared core, part 2: keep the API across the boundary coarse
> Types that suit a Rust engine differ from types that are easy to use from Swift, Kotlin, and TypeScript. Expose coarse action-level functions under a contract.
- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-02-ffi-api-design/
- Published: 2026.06.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Android, #WebAssembly, #FFI
When a calculation engine shared by several platforms is written in Rust, the data types that suit the inside of the engine differ from the types that are easy to handle in an app. Following the convenience of each platform can also pull the design of the core toward outside concerns.
Part 2 asks two questions: how large the externally called functions should be, and which data types the values crossing the boundary should use. Sudoku is the running example.
## The inside model and the outside model
Inside Rust, the rules are expressed with types such as `Grid`, `Cell`, `CandidateSet`, `PuzzleSeed`, `Difficulty`, `Move`, `GameState`, and `ValidationError`. Enums, pattern matching, ownership, and traits can be used as they are.
Crossing the boundary changes the picture. Generics, lifetimes, nested enums, complex collections, and borrowed references are represented differently when passed to another language. Even when the generator supports something, whether the API is easy to understand and convenient to test and debug has to be judged separately. How bindings are made is in the [UniFFI](https://mozilla.github.io/uniffi-rs/latest/) and [wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/) documentation. Sizing the calls and the contract as shown below is my own design choice, not a requirement of that documentation. An example that implements this contract and was actually run, with its environment, is in the section "The contract, checked with an example."
So the roles are split in two.
```text
sudoku-core
- Rust 내부 도메인 모델
- Rust 테스트
- 순수 계산 로직
sudoku-uniffi / sudoku-wasm
- 외부 공개 DTO
- serialization
- error 변환
- coarse-grained 함수
```
The Korean lines under `sudoku-core` read "internal Rust domain model," "Rust tests," and "pure calculation logic." Under the binding crates, they read "externally exposed DTOs" and "error conversion."
The core is designed so that it does not depend on a binding tool, and the domain logic is kept as independent as possible. I recommend putting DTOs, serialization, error conversion, and coarse functions in the binding crates.
## A few large functions over many small ones
Exposing functions that each handle one cell gives this shape.
```text
get_cell(row, col)
set_value(row, col, value)
toggle_note(row, col, digit)
is_conflict(row, col)
get_candidates(row, col)
```
That increases the number of trips across the boundary. The state the app holds and the state the engine holds can drift apart, and calling per cell for rendering makes the flow hard to trace.
The alternative is to expose units of work.
```text
start_game(request) -> GameSnapshot
apply_action(snapshot, action) -> TransitionResult
validate_snapshot(snapshot) -> ValidationReport
generate_daily_puzzle(request) -> PuzzleEnvelope
```
The engine computes how the state changes for an action, and the app converts the returned value into state for the screen. I expect fewer calls and a clearer scope for tests. An experiment comparing the two approaches is not part of this post.
This structure is the same as a reducer.
```text
현재 상태 + 사용자 액션 + 설정
-> 다음 상태 + 효과 + 에러 또는 경고
```
The block reads: current state plus user action plus settings gives the next state plus effects plus an error or a warning.
This model fits the ViewModel on iOS and Android and the state layer on the web. Each platform can convert the result into UI state.
## Data types that cross the boundary
The candidates for DTOs are simple ones: string, integer, boolean, a flat array, an enum with clear cases, an optional value, and a JSON string when needed.
JSON has a benefit and a cost. It is convenient for passing state or actions in large units. In return it has a serialization cost, and type safety at compile time can get weaker. Calls made per frame for rendering and calls made when the user acts are judged separately. For a unit that applies one user action, the gain from a simpler boundary can outweigh the serialization cost. Numbers that measure how large each cost is are not in this post.
A contract built on JSON has this shape.
```text
apply_action(
snapshot_json: String,
action_json: String,
environment_json: String
) -> transition_json: String
```
On the app side, the shape of that JSON can be expressed with Codable, kotlinx.serialization, Zod, or TypeScript types, and the Rust side handles it with serde. I suggest that the team keep a JSON schema and fixtures together. In my view, fixtures can be a more accurate reference than documents.
## Errors come back as values
When Rust's `Result` is connected to the outside, its representation has to be decided early. Putting error handling off makes it more expensive later. The options are a thrown error, a result object, a nullable, and an error in a callback. For a domain engine, I prefer returning an explicit error object.
```text
TransitionResult
- ok: Boolean
- snapshot: String?
- effects: [Effect]
- error: EngineError?
```
The roles are divided like this. Expressing why something failed is the job of the core. Whether to ignore the action, show an alert, give haptic feedback, or send an analytics event is chosen by the app.
Messages are divided as well. Codes such as `INVALID_MOVE`, `PUZZLE_ALREADY_COMPLETED`, and `SEED_OUT_OF_RANGE` are stable machine-readable codes, and they are separated from the text people read. Korean and English translation, and localization, belong to the app.
## The contract, checked with an example
On October 4, 2026, I implemented this contract in a small example and ran it. This small workspace exists to check the structure and is not the code of a real app. The environment is cargo 1.96.0, serde 1.0.229, and serde_json 1.0.151. The function at the boundary takes three strings and returns one. In the example this function sits inside the `sudoku-core` crate. Under the structure laid out earlier, serialization and error conversion belong to the binding side, so this is a place where the example departs from the design to stay small. A proper split would move the JSON-facing functions into the binding crates, or into a separate crate shared by the two bindings.
```rust
pub fn apply_action_json(snapshot_json: &str, action_json: &str, environment_json: &str) -> String {
// 이 예제의 규칙은 environment 를 쓰지 않는다. 형식만 확인한다.
if let Err(e) = serde_json::from_str::(environment_json) {
return to_json(&fail("invalid_environment", &e.to_string()));
}
let result = match (serde_json::from_str(snapshot_json), serde_json::from_str(action_json)) {
(Ok(snapshot), Ok(action)) => apply_action(&snapshot, &action),
(Err(e), _) => fail("invalid_snapshot", &e.to_string()),
(_, Err(e)) => fail("invalid_action", &e.to_string()),
};
to_json(&result)
}
```
The Korean comment says that the rules of this example do not use the environment and only its format is checked. When the input cannot be parsed, the function does not throw. It returns a result of the same shape. `fail` builds a result with `ok` set to false and a code and message in `error`, and `to_json` serializes with serde_json. Below is the output of five calls through the Wasm package from Node. In order they are an action that succeeds, an action that conflicts, an action on a given cell, an action that cannot be parsed, and the validation of a broken snapshot. Long lines are cut at 150 characters and marked with `…`.
```text
valid : {"ok":true,"snapshot":"{\"version\":1,\"seed\":0,\"givens\":\"530070000600195000098000060800060003400803001700020006060000280000419005000080079\",\"ce …
conflict: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"conflict","message":"the value conflicts with a row, column, or box"}}
given : {"ok":false,"snapshot":null,"effects":[],"error":{"code":"given_cell","message":"a given cell cannot be changed"}}
bad json: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"invalid_action","message":"unknown variant `fly`, expected `set_value` or `clear` at line 1 …
validate: {"valid":false,"errors":[{"code":"invalid_snapshot","message":"missing field `version` at line 1 column 2"}]}
```
In this example, when `ok` is true, `snapshot` holds the JSON string of the next state and `error` is null. When `ok` is false, `snapshot` is null and `error` is present. The error codes are lowercase, such as `conflict`, `given_cell`, and `out_of_range`, and the message exists in English only. No translation was added.
## Keeping the contract with versions and fixtures
After an app ships on the App Store and the Play Store, different releases stay on different devices. The web and mobile also update at different speeds. So I recommend putting a version in the request and the response, and keeping fixtures per version.
When there is a breaking change, the Rust core, the Swift adapter, the Kotlin adapter, and the web package are checked in the same PR. The diff of generated bindings can be large and hard to read. I recommend confirming the contract from the wrapper or the schema in review, not from that diff alone. Two items can go into the PR template: a rule that a fixture is added when the public API changes, and a question on whether the tests of the Swift, Kotlin, and TypeScript adapters changed. The request and response fixtures serve directly as contract tests.
I think a simple outside contract also helps the work of connecting UniFFI, wasm-bindgen, XCFramework, and the Android `.so`. Calling this API from iOS and Android through UniFFI is the subject of part 3.
That is the shape of the design, and some things remain undecided. The fixture tests were run in the example from both Rust and Node. Five Rust tests and two Node tests passed. Adapter tests in Swift and Kotlin were not written. The combinations of `ok`, `snapshot`, and `error` are what I observed in the example, and they are not written down as a contract document.
---
# macOS의 DevToolsSecurity와 무인 CI의 디버거 인증
> Mac에서 디버거를 붙이면 암호를 묻는 창이 뜨고, 사람이 없는 CI에서는 그 창에서 작업이 멈춰요. DevToolsSecurity가 바꾸는 인증 규칙을 macOS 27.0.1에서 직접 읽어 확인하고, 상태 확인 명령과 CI 구성 방법을 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/macos-devtoolssecurity-enable-developer-mode/
- Published: 2026.06.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #macOS, #Xcode, #DeveloperMode, #Debugging, #CI-CD
Mac에서 Xcode나 터미널로 빌드한 프로그램에 디버거를 붙이거나 Instruments를 실행하면, 다른 프로세스를 제어해도 되는지 묻는 «Developer Tools Access» 인증 창이 뜰 수 있어요. 내 Mac에서는 암호를 넣으면 끝나요. 사람이 없는 테스트나 화면 없는 CI/CD 러너에서는 그 창이 입력을 기다리는 동안 작업이 멈춰요.
이 창을 미리 없애는 명령이 `sudo DevToolsSecurity -enable`이에요. 이 명령이 무엇을 바꾸는지와, 켠 뒤에도 인증이 남을 때 볼 곳을 정리했어요. 2026년 10월 4일에 macOS 27.0.1에서 읽기 전용 명령으로 확인한 내용은 따로 표시했어요. `-enable`과 그룹 추가 명령은 이번에 다시 실행하지 않았어요.
## 디버거가 인증을 요구하는 이유
macOS는 프로세스의 메모리를 서로 격리해요. 한 프로세스가 다른 프로세스를 멈추거나, 메모리를 읽고 쓰거나, 코드를 주입하는 일은 제한돼요.
lldb와 Instruments는 분석하려면 대상 프로세스에 접근해야 해요. XNU의 `task_for_pid`로 대상의 Mach task 포트를 얻고, 그 포트로 한 줄씩 실행하거나 메모리를 덤프해요. 권한이 모자라면 요청이 거부되거나 관리자 암호 인증을 거쳐요. 이 인증을 담당하는 것이 [Authorization Services](https://developer.apple.com/documentation/security/authorization-services)이고, 규칙은 `/var/db/auth.db`에 저장돼요.
## DevToolsSecurity가 바꾸는 규칙
`/usr/sbin/DevToolsSecurity`는 `system.privilege.taskport`라는 권한의 규칙을 바꿔요. 이전 판에서는 켜기 전에 로그인 암호나 root 증명을 요구하고, 켠 뒤에는 `_developer` 그룹과 연결된다고 설명했어요. 이 가운데 켜기 전의 동작은 이번에 확인하지 못했어요.
켠 상태의 규칙을 `security authorizationdb read system.privilege.taskport`로 읽어 봤어요. 읽은 값은 이래요(macOS 27.0.1).
- `class`는 `user`예요.
- `group`은 `_developer`예요.
- `authenticate-user`는 true, `allow-root`는 false예요.
- `timeout`은 36000이에요.
이 글의 이전 판에서는 켠 뒤의 클래스를 `rule`이라고 적었는데, 이번에 읽은 값은 `user`였어요. 규칙의 설명문에는 이 권한이 `task_for_pid`에 쓰이고, 요청하는 프로그램과 대상 프로그램을 같은 사용자가 실행할 때에만 적용된다고 적혀 있어요. 다른 사용자의 프로그램에 대한 접근은 허가하지 않아요. 켜기 전의 값은 이번에 확인하지 못했어요. 확인한 Mac에서는 이미 켜져 있었어요.
## 상태는 -status로 확인해요
인자 없이 `/usr/sbin/DevToolsSecurity`만 실행하면 상태를 보여 주는 것이 아니에요. 사용법을 보면 기본 동작이 `-enable`이에요.
없는 옵션을 넣어서 사용법을 출력한 결과예요.
```text
$ /usr/sbin/DevToolsSecurity -help
DevToolsSecurity: unrecognized option `-help'
Changes the security authorization policies for developer systems
Usage: /usr/sbin/DevToolsSecurity [-verbose] [-enable | -disable | -status]
-enable :: (default) enable developer mode policies
-disable :: return policies to system default
-status :: prints out whether or not developer mode policies are in effect
-verbose :: makes output more verbose
```
상태만 보려면 `-status`를 써요.
```text
$ /usr/sbin/DevToolsSecurity -status
Developer mode is currently enabled.
```
이전 판에서는 인자 없이 실행해서 `enabled` 여부를 본다고 적었어요. 그 설명은 틀렸고, 위와 같이 고쳤어요.
## 켠 뒤에도 인증이 남을 때
`-enable`을 실행한 뒤에도 터미널의 lldb나 VS Code의 C++ 디버깅에서 인증 창이 뜨거나 `attach failed`가 나올 수 있어요. 이때 볼 곳은 계정이 `_developer` 그룹에 들어 있는지예요.
현재 로그인한 계정을 그룹에 더하는 명령이에요.
```bash
# 현재 로그인된 계정을 _developer 그룹에 강제 멤버십 추가
sudo dscl . append /Groups/_developer GroupMembership $(whoami)
```
추가한 뒤에는 터미널을 다시 열거나 로그아웃했다가 로그인하기를 권해요. 그룹의 구성원은 `dscl . read /Groups/_developer GroupMembership`으로 볼 수 있어요.
이 부분은 제가 직접 원인을 비교해 본 것이 아니에요. 확인한 Mac에서는 `_developer` 그룹에 `GroupMembership` 키가 아예 없었어요.
```text
$ dscl . read /Groups/_developer GroupMembership
No such key: GroupMembership
```
이 키가 없는 Mac에서 디버거 연결이 어떻게 동작하는지, 그룹 가입이 필요한 조건이 정확히 무엇인지는 확인하지 않았어요. 그룹에 넣는다고 모든 `attach failed`가 풀린다고 볼 근거도 없어요.
## 무인 러너에서 생기는 일
모바일 배포 자동화에는 Mac mini나 클라우드의 macOS 가상머신을 러너로 써요. Xcode를 새로 설치하거나 OS를 올린 뒤에 이 조치를 빠뜨리는 경우가 문제예요.
Appium이나 Xcode UI Test가 테스트 앱을 설치하고 디버깅 연결을 시도하면 인증 요청이 백그라운드의 GUI에 떠요. 러너에는 응답할 사람이 없어요. 작업은 설정된 제한 시간이 지난 뒤에야 끝나고, 제한 시간이 없는 러너라면 계속 기다려요. 이 흐름을 재현해서 시간을 잰 결과는 이 글에 싣지 않았어요.
그래서 러너를 준비하는 스크립트에서 두 가지를 함께 실행해요.
```bash
# 개발자 도구 액세스 허용 활성화
sudo /usr/sbin/DevToolsSecurity -enable
# 빌드 실행 에이전트 계정을 개발자 그룹에 병합
sudo dscl . append /Groups/_developer GroupMembership build_agent_user
```
`build_agent_user`는 러너가 쓰는 계정 이름으로 바꿔요. 러너 설치 안내에도 활성화 명령을 넣어 두면 새 장비에서 빠뜨리지 않아요. 모든 서버에서 두 명령이 다 필요하다는 근거는 없어요. 테스트 앱을 기기나 시뮬레이터에 설치하는 일과 Mac의 task 포트 인증이 어떻게 이어지는지도 직접 측정하지 않았어요.
## 보안에서 내주는 것
이 설정은 인증 단계를 하나 줄여요. 그만큼 같은 사용자로 실행되는 다른 프로세스의 메모리를 개발 도구가 다룰 수 있는 범위가 넓어져요. 장비에 물리적으로 접근할 수 있는 사람을 관리하고, 출처를 알 수 없는 바이너리나 코드를 그 장비에서 실행하지 않아야 해요.
iPhone이나 iPad의 Developer Mode와는 대상이 달라요. [기기의 Developer Mode](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device)는 Xcode로 빌드해 실행하는 앱처럼 로컬에서 설치한 앱을 그 기기에서 실행하도록 허용하는 설정이에요. App Store나 TestFlight로 설치하는 앱에는 영향을 주지 않아요. 이 글의 설정은 Mac에서 lldb 같은 도구가 다른 프로세스를 제어하는 권한이에요.
이번에 읽은 값은 macOS 27.0.1 한 대의 것이고, 이미 켜져 있어서 켜기 전의 값은 확인하지 못했어요. macOS 버전별로 비교한 결과도 이 글에 싣지 않았어요. 서명된 개발 도구만 허용된다는 조건이 정확히 어떤 서명을 뜻하는지도 문서에서 확인하지 못했어요.
---
# 한글 파일 이름의 자모가 풀어지는 이유와 NFC 정규화
> macOS에서 만든 한글 파일 이름이 다른 환경에서 자모가 풀어져 보이거나 다른 이름으로 취급되는 것은 유니코드 정규화 형태가 달라서예요. NFD와 NFC의 코드 포인트를 직접 검산하고, convmv와 코드로 NFC에 맞추는 방법을 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/macos-hangul-jaso-nfd-nfc/
- Published: 2026.06.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #macOS, #Unicode, #Troubleshooting, #Git
macOS에서 만든 한글 이름의 파일을 Windows나 Linux로 보내면 이름의 자모가 풀어져 보일 수 있어요. 보이는 것만의 문제가 아니에요. Git이 내용이 그대로인 파일을 이름이 바뀐 것으로 감지할 수 있고, CI에서 코드가 찾는 리소스 경로와 실제 파일 이름이 맞지 않을 수 있어요. 메일 첨부, 메신저, ZIP, 클라우드로 파일을 전달할 때에도 같은 일이 생길 수 있어요.
원인은 같은 글자를 나타내는 유니코드 표현이 둘이라는 데 있어요. 이 글은 두 표현의 차이를 코드 포인트로 확인하고, 파일 이름과 문자열을 한쪽으로 맞추는 방법을 정리해요.
이 글의 이전 판에는 코드 포인트와 예제에 오류가 있었어요. 문자열 정규화를 2026년 10월 4일에 macOS 27.0.1에서 Python 3.14.5, Swift 6.4, Node.js 24.14.1로 직접 실행해서 고쳤어요. 확인한 것은 문자열의 정규화와, 임시 폴더의 파일 하나에 대한 `convmv`의 미리 보기와 이름 변경이에요. `convmv`의 결과는 아래 절에 있어요. Automator와 Git은 실행하지 않았고, 파일 시스템마다의 차이도 비교하지 않았어요. 정규화 형태의 정의는 [UAX #15](https://www.unicode.org/reports/tr15/)에 있는 내용이고, 어디에서 맞출지에 대한 권고는 제 해석이에요.
## 같은 글자의 두 가지 표현
NFD는 글자를 정준 분해한 형태이고, NFC는 정준 결합한 형태예요. «한»으로 보면 이래요.
- NFC: U+D55C 하나예요(HANGUL SYLLABLE HAN).
- NFD: U+1112, U+1161, U+11AB 셋이에요. 차례로 초성 히읗(HANGUL CHOSEONG HIEUH), 중성 아(HANGUL JUNGSEONG A), 종성 니은(HANGUL JONGSEONG NIEUN)이에요.
Python으로 검산한 코드와 출력이에요.
```python
import unicodedata as u
print([f"U+{ord(c):04X}" for c in u.normalize("NFD", "한")])
print(u.normalize("NFC", "\u1112\u1161\u11ab"))
print(u.normalize("NFC", "\u1106\u1161\u11ab"))
print(u.normalize("NFC", "ㅎㅏㄴ"), u.normalize("NFD", "ㅎㅏㄴ"))
print([f"U+{ord(c):04X}" for c in "ㅎㅏㄴ"])
```
Python 코드를 실행하면 이렇게 나와요.
```text
['U+1112', 'U+1161', 'U+11AB']
한
만
ㅎㅏㄴ ㅎㅏㄴ
['U+314E', 'U+314F', 'U+3134']
```
이전 판에서는 초성을 U+1106이라고 적었어요. U+1106은 초성 미음(HANGUL CHOSEONG MIEUM)이에요. 출력의 셋째 줄처럼 U+1106, U+1161, U+11AB를 결합하면 «한»이 아니라 «만»(U+B9CC)이 돼요.
## 눈에 보이는 «ㅎㅏㄴ»은 분해형이 아니에요
분해형을 설명할 때 «ㅎㅏㄴ»처럼 자모를 따로 적어서 보여 주기 쉬워요. 이전 판도 그렇게 했어요. 하지만 키보드로 입력한 «ㅎ», «ㅏ», «ㄴ»은 U+314E, U+314F, U+3134이고, 호환 자모라는 다른 문자예요. 분해형에 쓰이는 U+1112, U+1161, U+11AB와 같은 문자가 아니에요.
위 출력의 넷째 줄이 그 차이를 보여 줘요. «ㅎㅏㄴ»에 NFC를 적용해도 «한»이 되지 않고, NFD를 적용해도 그대로예요. 이전 판이 분해형의 예로 든 `ㄱㅓㅁㅅㅐㄱㅎㅘㅁㅕㄴ.png`도 호환 자모로 적은 문자열이어서, 정규화로는 `검색화면.png`가 되지 않아요. `검색화면.png`의 실제 분해형에는 U+11B7, U+11A8, U+11AB 같은 종성 자모가 들어 있어요.
그래서 이 글에서는 분해형을 눈에 보이는 글자로 적지 않고 코드 포인트와 이스케이프로 적어요.
## 문제가 생기는 자리
이전 판에서는 macOS가 파일 이름을 분해형으로 저장하고 Windows와 Linux는 결합형을 쓴다고 설명했어요. 이 설명은 파일 시스템, 화면에 그리는 프로그램, 전송 도구에 따라 달라질 수 있고, 이번에 확인하지 않았어요. 아래 두 사례도 실제 로그로 재현한 것이 아니고, 일어날 수 있는 경로를 설명한 것이에요.
Git에서는 이런 경우를 생각할 수 있어요. macOS에서 `검색화면.png`를 추가해서 push하고 Windows에서 pull했는데 정규화 형태가 달라지면, 내용이 같은 파일이 이름 변경으로 감지될 수 있어요. 그러면 내용 변경 없이 diff가 생기고 불필요한 커밋이 쌓이며, 충돌이 생기기 쉬워질 수 있어요. Git의 설정과 전송 과정에서 변환이 일어나는지에 따라 결과는 달라질 수 있어요.
빌드에서는 웹이나 iOS의 이미지와 Markdown 경로가 해당돼요. GitHub Actions 같은 Linux 컨테이너에서, 코드에 결합형으로 적힌 경로 문자열과 분해형으로 저장된 파일 이름은 바이트가 달라요. 그래서 파일을 찾지 못할 수 있어요.
## convmv로 파일 이름을 NFC로 바꾸기
이미 있는 파일 이름은 [convmv](https://www.j3e.de/linux/convmv/)로 한꺼번에 바꿀 수 있어요. 아래의 미리 보기 명령과 적용 명령은 2026년 10월 4일에 convmv 2.06으로 임시 폴더에서 실행해 봤어요. 분해형 이름의 `.txt` 파일 하나를 만들어 놓고 돌렸어요. convmv는 이미 설치돼 있어서 설치 명령은 다시 실행하지 않았어요.
[Homebrew](https://formulae.brew.sh/formula/convmv)로 설치해요.
```bash
brew install convmv
```
먼저 바꿀 대상을 미리 봐요. 인코딩은 UTF-8 그대로 두고, NFC로 맞추고, 하위 디렉터리까지 찾는 명령이에요.
```bash
convmv -f utf8 -t utf8 --nfc -r [변경을_원하는_디렉토리_경로]
```
이 단계에서는 실제로 바꾸지 않아요. 바꿀 이름을 `mv "원래 이름" "새 이름"` 형태로 보여 주고, `No changes to your files done. Would have converted 1 files in 0 seconds.`와 `Use --notest to finally rename the files.`라고 알려 줘요. 이 글의 이전 판에 적혀 있던 문구와는 달랐어요. 실행한 뒤에 파일 이름을 확인해 보니 분해형 그대로였어요. 목록을 확인한 뒤에 `--notest`를 붙여 적용해요.
```bash
convmv -f utf8 -t utf8 --nfc -r --notest [변경을_원하는_디렉토리_경로]
```
`--notest`를 붙여 실행하니 `Ready! I converted 1 files in 0 seconds.`가 나왔고, 파일 이름이 결합형(NFC)으로 바뀐 것을 확인했어요. 이름의 길이는 코드 포인트 10개에서 6개로 줄었어요.
### Finder의 우클릭 메뉴에 넣기
자주 쓴다면 Automator로 빠른 동작을 만들어 둘 수 있어요.
1. Automator에서 Quick Action을 새로 만들어요.
2. 입력을 파일과 폴더로, 대상 앱을 Finder로 정해요.
3. Run Shell Script 동작을 넣고 Pass input을 as arguments로 바꿔요.
4. 아래 스크립트를 넣어요.
5. «한글 자소 합치기» 같은 이름으로 저장하면 Finder의 우클릭 메뉴에 나와요.
```bash
for i in "$@"; do
/opt/homebrew/bin/convmv -f utf-8 -t utf-8 --nfc --notest "$i"
done
```
`/opt/homebrew`는 M1 이후 Apple Silicon Mac의 Homebrew 경로예요. 설치 위치가 다르면 경로를 바꿔야 해요. 이 스크립트에는 `-r`이 없어서, 앞의 명령과 달리 선택한 항목의 하위 항목까지 처리하지는 않아요.
## 코드에서 맞추기
파일 이름이나 사용자가 입력한 텍스트를 DB나 스토리지에 넣는 경계에서 형태를 하나로 맞춰 두면, 그 뒤의 비교가 단순해져요.
Swift에는 결합형으로 바꾸는 [precomposedStringWithCanonicalMapping](https://developer.apple.com/documentation/foundation/nsstring/precomposedstringwithcanonicalmapping)과 분해형으로 바꾸는 `decomposedStringWithCanonicalMapping`이 있어요. 분해형을 이스케이프로 적어서 실행한 예제예요.
```swift
import Foundation
let decomposed = "\u{1112}\u{1161}\u{11AB}" // NFD: U+1112 U+1161 U+11AB
let composed = decomposed.precomposedStringWithCanonicalMapping
let back = composed.decomposedStringWithCanonicalMapping
func scalars(_ s: String) -> String {
s.unicodeScalars.map { String(format: "U+%04X", $0.value) }.joined(separator: " ")
}
print(scalars(decomposed))
print(scalars(composed))
print(scalars(back))
print(decomposed == composed)
print(Array(decomposed.utf8) == Array(composed.utf8))
```
Swift 코드가 출력한 코드 포인트예요.
```text
U+1112 U+1161 U+11AB
U+D55C
U+1112 U+1161 U+11AB
true
false
```
넷째 줄과 다섯째 줄이 눈여겨볼 부분이에요. Swift의 `String`은 `==`로 비교하면 분해형과 결합형을 같다고 판정해요. 그런데 UTF-8 바이트는 달라요. Swift 안에서 비교할 때는 드러나지 않다가, 바이트로 내보내 파일 이름이나 키로 쓰는 순간에 차이가 생겨요.
JavaScript에서는 `normalize("NFC")`를 써요.
```javascript
const decomposed = "\u1112\u1161\u11AB"; // NFD: U+1112 U+1161 U+11AB
const composed = decomposed.normalize("NFC");
const codePoints = (s) => [...s].map((c) => "U+" + c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(" ");
console.log(codePoints(decomposed));
console.log(codePoints(composed));
console.log(decomposed === composed);
console.log(decomposed.normalize("NFC") === composed);
```
Node.js 코드의 실행 결과예요.
```text
U+1112 U+1161 U+11AB
U+D55C
false
true
```
JavaScript의 `===`는 분해형과 결합형을 다르다고 판정해요. 정규화한 뒤에 비교해야 같아져요.
Python에서는 `unicodedata.normalize`예요. 이 함수는 문자열을 바꿔서 돌려줄 뿐이고, 디스크에 있는 파일의 이름을 바꾸지는 않아요.
```python
import os
import sys
import unicodedata
def sanitize_nfc(path):
return unicodedata.normalize('NFC', path)
```
## 점검할 곳
파일 이름을 영어로만 짓는 것으로 피할 수도 있지만, 한글 이름을 쓰는 프로젝트라면 아래를 확인해 두는 편이 나아요.
- 여러 사람이 쓰는 저장소에 분해형 이름이 들어와 있는지 봐요.
- Windows나 Linux에서 빌드할 때 한글 경로의 리소스를 찾는지 봐요.
- Git이 이름 변경을 어떻게 감지하는지 봐요. `core.ignorecase`는 대소문자에 대한 설정이고, 정규화를 해결해 주는 옵션이라는 근거는 없어요.
- 업로드를 받는 경계에서 정규화하고 검증하는지 봐요.
`convmv`가 모든 파일을 바꿔 준다거나, 바꾼 뒤에는 전달해도 깨지지 않는다는 것은 이번에 확인하지 않았어요. 바꾼 이름이 기존 파일과 겹칠 때의 동작, 권한 문제, 파일 시스템마다의 차이도 확인하지 않았어요. 이번에 실행으로 확인한 범위는 Python, Swift, JavaScript의 문자열 정규화와, 임시 폴더의 파일 하나에 대한 `convmv`의 미리 보기와 이름 변경이에요.
---
# Rust 공유 코어 2편: 경계를 넘는 API를 크게 잡기
> Rust 엔진을 Swift, Kotlin, TypeScript에서 부를 때에는 내부에 알맞은 타입과 바깥에서 다루기 쉬운 타입이 달라요. 셀 단위의 작은 함수보다 액션 단위의 큰 함수를 내놓고, DTO와 오류와 버전을 계약으로 관리하는 설계를 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/rust-shared-core-02-ffi-api-design/
- Published: 2026.06.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Android, #WebAssembly, #FFI
여러 플랫폼이 함께 쓰는 계산 엔진을 Rust로 만들면, 엔진 안쪽에 알맞은 자료형과 앱에서 다루기 쉬운 자료형이 다르다는 문제를 만나요. 플랫폼의 편의에 맞추다 보면 core의 설계가 바깥 사정에 끌려가기도 해요.
2편의 질문은 둘이에요. 바깥에서 부르는 함수를 얼마나 크게 잡을지, 그리고 경계를 넘는 값을 어떤 자료형으로 할지예요. 스도쿠를 예로 들어 설명해요.
## 안쪽의 모델과 바깥의 모델
Rust 안에서는 `Grid`, `Cell`, `CandidateSet`, `PuzzleSeed`, `Difficulty`, `Move`, `GameState`, `ValidationError` 같은 타입으로 규칙을 표현해요. enum, pattern matching, ownership, trait을 그대로 쓸 수 있어요.
경계를 넘을 때에는 사정이 달라져요. generic, lifetime, 중첩된 enum, 복잡한 collection, 빌린 참조는 다른 언어로 넘길 때 표현이 달라져요. 생성 도구가 지원한다고 해도, API를 이해하기 쉬운지와 테스트하고 디버깅하기 편한지는 따로 따져야 해요. 바인딩을 만드는 방법은 [UniFFI](https://mozilla.github.io/uniffi-rs/latest/)와 [wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/) 문서에 있어요. 호출 단위와 계약을 아래처럼 잡는 것은 그 문서들의 요구가 아니고 제 해석이에요. 이 계약을 실제로 구현해서 실행한 예제와 그 환경은 «예제로 확인한 계약» 절에 있어요.
그래서 역할을 둘로 나눠요.
```text
sudoku-core
- Rust 내부 도메인 모델
- Rust 테스트
- 순수 계산 로직
sudoku-uniffi / sudoku-wasm
- 외부 공개 DTO
- serialization
- error 변환
- coarse-grained 함수
```
core가 바인딩 도구에 의존하지 않도록 설계하고, 도메인 로직은 가능한 한 독립적으로 유지해요. DTO, 직렬화, 오류 변환, 큰 단위의 함수는 바인딩 크레이트에 두기를 권해요.
## 작은 함수 여러 개보다 큰 함수 몇 개
셀 하나를 다루는 함수를 그대로 바깥에 내놓으면 이런 모양이 돼요.
```text
get_cell(row, col)
set_value(row, col, value)
toggle_note(row, col, digit)
is_conflict(row, col)
get_candidates(row, col)
```
이렇게 하면 경계를 오가는 횟수가 늘어요. 앱이 가진 상태와 엔진이 가진 상태가 어긋날 수 있고, 셀마다 렌더링을 위해 호출하면 흐름을 추적하기도 어려워요.
대신 작업 단위로 내놓아요.
```text
start_game(request) -> GameSnapshot
apply_action(snapshot, action) -> TransitionResult
validate_snapshot(snapshot) -> ValidationReport
generate_daily_puzzle(request) -> PuzzleEnvelope
```
액션에 따라 상태가 어떻게 바뀌는지는 엔진이 계산하고, 앱은 돌려받은 값을 화면용 상태로 바꿔요. 호출이 줄고 테스트의 범위가 분명해질 것으로 기대해요. 두 방식을 비교한 실험은 이 글에 들어 있지 않아요.
이 구조는 reducer와 같아요.
```text
현재 상태 + 사용자 액션 + 설정
-> 다음 상태 + 효과 + 에러 또는 경고
```
이 모델은 iOS와 Android의 ViewModel, Web의 state layer와 잘 맞아요. 각 플랫폼이 결과를 UI state로 바꾸면 돼요.
## 경계를 넘는 자료형
DTO에 쓸 후보는 단순한 것들이에요. string, integer, boolean, 평평한 array, 구분이 분명한 enum, optional 값이고, 필요하면 JSON 문자열을 써요.
JSON을 쓰면 편해지는 점과 비용이 함께 있어요. 상태나 액션을 큰 단위로 넘기기에는 편해요. 그 대신 직렬화 비용이 들고, 컴파일할 때의 타입 안전성이 약해질 수 있어요. 프레임마다 렌더링을 위해 부르는 호출과 사용자가 조작할 때 부르는 호출은 구분해서 판단해요. 사용자 액션 하나를 적용하는 단위라면 직렬화 비용보다 경계가 단순해지는 이득이 더 클 수 있어요. 어느 쪽 비용이 얼마나 큰지 잰 수치는 이 글에 없어요.
JSON으로 계약을 잡으면 이런 모양이에요.
```text
apply_action(
snapshot_json: String,
action_json: String,
environment_json: String
) -> transition_json: String
```
앱 쪽에서는 Codable, kotlinx.serialization, Zod나 TypeScript type으로 해석할 수 있고, Rust 쪽에서는 serde로 해석해요. 팀이 JSON schema와 fixture를 함께 보관할 것을 제안해요. 제 생각으로는 문서보다 fixture가 더 정확한 기준이 될 수 있어요.
## 오류는 값으로 돌려줘요
Rust의 `Result`를 바깥으로 이을 때에는 표현을 일찍 정해야 해요. 오류 처리를 뒤로 미루면 비용이 커져요. 오류를 던질지, result 객체로 돌려줄지, nullable로 나타낼지, callback의 error로 넘길지를 골라요. 저는 도메인 엔진이라면 명시적인 error 객체를 돌려주는 쪽을 선호해요.
```text
TransitionResult
- ok: Boolean
- snapshot: String?
- effects: [Effect]
- error: EngineError?
```
역할은 이렇게 나눠요. 왜 실패했는지를 표현하는 것은 core의 일이에요. 그 액션을 무시할지, alert를 띄울지, haptic을 줄지, analytics event를 보낼지는 앱이 골라요.
메시지도 나눠요. `INVALID_MOVE`, `PUZZLE_ALREADY_COMPLETED`, `SEED_OUT_OF_RANGE` 같은 안정적인 machine-readable 코드와 사람이 읽는 문구를 분리해요. 한국어와 영어 번역, localization은 앱이 맡아요.
## 예제로 확인한 계약
이 계약을 작은 예제로 구현해서 2026년 10월 4일에 실행했어요. 예제는 구조만 확인하려고 작게 만들었고, 실제 앱에서 가져온 코드가 아니에요. 환경은 cargo 1.96.0, serde 1.0.229, serde_json 1.0.151이에요. 경계에 놓인 함수는 문자열 셋을 받아 문자열 하나를 돌려줘요. 예제에서는 이 함수를 `sudoku-core` crate 안에 뒀어요. 앞에서 나눈 구조대로라면 직렬화와 오류 변환은 바인딩 쪽의 일이므로, 이 점은 예제를 줄이려고 설계와 다르게 한 부분이에요. 제대로 나누려면 JSON을 다루는 함수를 바인딩 crate로 옮기거나, 두 바인딩이 함께 쓰는 별도의 crate로 빼야 해요.
```rust
pub fn apply_action_json(snapshot_json: &str, action_json: &str, environment_json: &str) -> String {
// 이 예제의 규칙은 environment 를 쓰지 않는다. 형식만 확인한다.
if let Err(e) = serde_json::from_str::(environment_json) {
return to_json(&fail("invalid_environment", &e.to_string()));
}
let result = match (serde_json::from_str(snapshot_json), serde_json::from_str(action_json)) {
(Ok(snapshot), Ok(action)) => apply_action(&snapshot, &action),
(Err(e), _) => fail("invalid_snapshot", &e.to_string()),
(_, Err(e)) => fail("invalid_action", &e.to_string()),
};
to_json(&result)
}
```
입력을 해석하지 못해도 예외를 던지지 않고 같은 모양의 결과를 돌려줘요. `fail`은 `ok`가 false이고 `error`에 코드와 문구를 담은 결과를 만들고, `to_json`은 serde_json으로 직렬화해요. Node에서 Wasm 패키지로 다섯 가지를 호출한 출력이에요. 성공하는 액션, 충돌하는 액션, 주어진 칸을 바꾸려는 액션, 해석할 수 없는 액션, 잘못된 snapshot의 검증 순서예요. 긴 줄은 150자에서 잘라 `…`로 표시했어요.
```text
valid : {"ok":true,"snapshot":"{\"version\":1,\"seed\":0,\"givens\":\"530070000600195000098000060800060003400803001700020006060000280000419005000080079\",\"ce …
conflict: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"conflict","message":"the value conflicts with a row, column, or box"}}
given : {"ok":false,"snapshot":null,"effects":[],"error":{"code":"given_cell","message":"a given cell cannot be changed"}}
bad json: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"invalid_action","message":"unknown variant `fly`, expected `set_value` or `clear` at line 1 …
validate: {"valid":false,"errors":[{"code":"invalid_snapshot","message":"missing field `version` at line 1 column 2"}]}
```
이 예제에서는 `ok`가 true이면 `snapshot`에 다음 상태의 JSON 문자열이 들어 있고 `error`가 null이었어요. `ok`가 false이면 `snapshot`이 null이고 `error`가 있었어요. 오류 코드는 `conflict`, `given_cell`, `out_of_range`처럼 소문자로 잡았고, 문구는 영어 한 가지뿐이에요. 번역은 넣지 않았어요.
## 버전과 fixture로 계약을 지켜요
앱은 App Store와 Play Store에 배포된 뒤에도 기기마다 다른 릴리스가 함께 남아요. Web과 모바일은 갱신되는 속도도 달라요. 그래서 request와 response에 version을 넣고, 버전마다 fixture를 두기를 권해요.
breaking change가 있을 때에는 Rust core, Swift adapter, Kotlin adapter, Web package를 같은 PR에서 점검해요. 생성된 binding의 diff는 크고 읽기 어려울 수 있어요. 그것만 보고 리뷰하기보다 wrapper나 schema로 계약을 확인하기를 권해요. PR 템플릿에는 두 가지를 넣어 둘 수 있어요. 공개 API가 바뀌면 fixture를 추가한다는 기준과, Swift, Kotlin, TypeScript adapter의 테스트가 바뀌었는지를 묻는 항목이에요. request와 response의 fixture는 그대로 계약 테스트가 돼요.
바깥 계약이 단순하면 UniFFI, wasm-bindgen, XCFramework, Android의 `.so`를 잇는 작업에도 도움이 될 것으로 봐요. 이 API를 UniFFI로 iOS와 Android에서 부르는 방법은 3편에서 이어져요.
여기까지가 설계의 모양이고, 정해지지 않은 것이 남아 있어요. fixture 테스트는 예제에서 Rust와 Node 양쪽으로 실행했어요. Rust 테스트 다섯 개와 Node 테스트 두 개가 통과했어요. Swift와 Kotlin의 adapter 테스트는 만들지 않았어요. `ok`, `snapshot`, `error`의 조합은 예제에서 관찰한 것이고, 계약 문서로 적어 두지는 않았어요.
---
# Rust shared core, part 1: what to share and where to draw the line
> Building for iOS, Android, and the web means writing the same rules three times. Narrow what Rust shares to rules that must give the same result, in 3 layers.
- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-01-architecture-boundary/
- Published: 2026.06.24
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Android, #WebAssembly
Building for three platforms (iOS, Android, and the web) produces three copies of the logic. It is natural for the UI, the storage method, and the lifecycle to differ by platform. The trouble starts when puzzle rules, state changes, offline decisions, price calculation, cryptography, and sync conflict handling are also implemented three times. Then defects have to be managed per platform as well.
I consider sharing logic through Rust a practical choice as of 2026. The focus is not on merging the whole app. It is on extracting an engine that owns the common rules. As the first part of a series, this post covers what to share and where to draw the boundary.
What is written here is a design proposal. Sudoku is an example for explanation, and this post does not include measured results or benchmarks from a specific app. Whether this structure really builds is something I did check with a small example on October 4, 2026, and the result and the tool versions are in the section "The structure, built for real." The Rust [linkage](https://doc.rust-lang.org/reference/linkage.html), [UniFFI](https://mozilla.github.io/uniffi-rs/latest/), and [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly) documentation explains what each tool does. How to split the layers and the criterion for sharing are my own design choices.
## Why it gets complicated when Rust owns everything
Each platform has constraints to follow.
- iOS: the UIKit and SwiftUI lifecycle, MainActor, App Extensions, and App Store review
- Android: Activity, ViewModel, Compose state, Gradle packaging, and Play policy
- Web: routing, hydration, browser storage, the bundler, and the deployment target
If the Rust side takes on UI state, analytics events, storage I/O, networking, and permissions, it takes on these constraints along with them. So what to share is decided by how the maintenance responsibility is divided.
## What goes into the engine and what stays in the app
With Sudoku as the example, the candidates for the engine are these.
- Puzzle generation and the solver
- Judging user input
- Changes to notes and values
- Computing the seed of the daily puzzle
- Rating the difficulty
- Computing score and streak
- A reducer that can replay actions
What stays in the app is rendering, accessibility, ads, payments, push notifications, the analytics SDK, the choice of storage, network retries, and the setup for review and release.
The criterion is not whether the code looks similar. It is whether the result has to be the same. The valid and invalid judgment for the same board and the same input has to match across platforms, and the daily puzzle that comes from the same seed has to match.
The presentation can differ. On iOS a UIKit collection view may feel natural, and on Android Compose state may fit well. Keyboard shortcuts and pointer interaction on the web have to be designed separately. Sharing too much can cost the interaction that fits each platform.
Candidates for the core are filtered by four conditions.
1. The result is determined by the input alone.
2. It is not tied directly to the current time, the locale, storage, or the network.
3. It knows nothing about a UI framework or a platform SDK.
4. It can be tested with Rust alone.
A candidate that does not meet the conditions is reconsidered. How to pass a time value for time-related candidates such as the daily seed or the streak is not covered in this post.
## A structure in three layers
I propose three layers: domain, binding, and app.
```text
core/
sudoku-rs/
crates/
sudoku-core/ # 순수 Rust 도메인 로직
sudoku-uniffi/ # iOS/Android 바인딩 표면
sudoku-wasm/ # WebAssembly 바인딩 표면
apps/
ios/ # Swift/UIKit 또는 SwiftUI 앱
android/ # Kotlin/Compose 앱
web/ # TypeScript/React 앱
```
`sudoku-core` is centered on Rust's own internal model. It keeps out the representation constraints of Swift, Kotlin, TypeScript, and FFI as far as possible, and the tests are concentrated here.
`sudoku-uniffi` is the conversion boundary for mobile. It manages the DTOs shaped for Swift and Kotlin, error mapping, serialization, and the names of public functions.
`sudoku-wasm` is the calling boundary on the web side, using wasm-bindgen or wasm-pack. Its goal is to provide a TypeScript API and a package that the app imports.
With this split, the core is less tied to the binding tools. Even when the way UniFFI generates code or the web bundler setup changes, I expect the domain rules to stay relatively stable.
## The app does not call generated code directly
If a ViewController, a ViewModel, a Composable, or a React component is tied directly to generated code, a change in a function name or a DTO affects the whole UI. So the app has a layer in between.
```text
iOS ViewModel
-> SudokuEngineClient protocol
-> UniFFI generated binding
-> Rust core
Android ViewModel
-> SudokuEngineClient interface
-> UniFFI generated binding
-> Rust core
Web state layer
-> SudokuEngineClient
-> wasm package
-> Rust core
```
`SudokuEngineClient` is a protocol on iOS and an interface on Android, and on the web it is a client that sits between the state layer and the wasm package. The values exposed to the UI are types of the language that app uses. In tests, the adapter can be swapped for a fake. Which layers are allowed to use the generated binding needs to be written down as a team rule.
## The structure, built for real
I built the three layers above as a Cargo workspace. It is a minimal example made to check the structure, not the code of a real app. `sudoku-core` holds four rules (start, apply an action, validate, generate the daily puzzle), and `sudoku-uniffi` and `sudoku-wasm` only wrap those functions. The environment is macOS 27.0.1, Xcode 27.0, and cargo 1.96.0.
Listing the direct dependencies of each crate with `cargo tree --depth 1` gives this.
```text
sudoku-core v0.1.0
├── serde v1.0.229
└── serde_json v1.0.151
sudoku-uniffi v0.1.0
├── sudoku-core v0.1.0
└── uniffi v0.29.5
sudoku-wasm v0.1.0
├── sudoku-core v0.1.0
└── wasm-bindgen v0.2.129
```
The core depends only on serde and serde_json, and the two binding crates call the core. This output confirms that no dependency runs from the core toward the bindings. The core of the example does take on JSON serialization (serde_json) as well, though. What was assigned to the binding layer above is kept in the core in the example, to keep it short.
The tests of the core run with `cargo test -p sudoku-core`. They finish with no simulator and no browser. Only the part of the output where the five tests ran is shown, and the lines before and after it that report 0 tests are left out.
```text
running 5 tests
test same_seed_gives_same_snapshot ... ok
test given_cell_is_rejected ... ok
test snapshot_validation_reports_errors_as_values ... ok
test daily_puzzle_depends_only_on_the_date ... ok
test fixtures_match_expected ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
```
The five tests check that the same seed gives the same result, that an action on a given cell is rejected, and that the daily puzzle depends only on the date string. How this workspace was turned into artifacts for iOS, Android, and the web is spread over parts 3 to 7 of this series.
## The order of moving code
1. List the domain logic that is duplicated across platforms.
2. From that list, pick the candidates whose results have to match.
3. Keep the core from depending on UI, storage, network, analytics, or payments.
4. Separate the internal model from the binding model.
5. Put an adapter on each platform and set the rule for access to generated code.
6. Move small, deterministic logic first.
Good first candidates are the daily seed calculation, puzzle validation, and the reducer. Before setting up a Cargo workspace, decide whether sharing is really needed. Moving every candidate at once makes the scope large.
As a way to check, I suggest tests that run with Rust alone and a fake for the app adapter. The Rust tests were run in the example above. A fake for the app adapter was not built, and performance numbers are not part of this post.
Even with this structure, the responsibility for platform development, review, and release stays where it was. If the team does not keep the rules, dependencies can come back between the separated layers. Measurements that back the expectation of easier and more stable adoption are not included in this post. Once the boundary is set, the next step is to look at how to use UniFFI, XCFramework, the Android `.so`, and WebAssembly, and the next part covers the design of the FFI API that is called from outside.
---
# Rust 공유 코어 1편: 무엇을 공유하고 어디서 끊을지
> iOS, Android, Web을 함께 만들면 같은 규칙을 세 번 구현하게 돼요. Rust로 공유할 범위를 결과가 같아야 하는 도메인 규칙으로 좁히고, 코어와 바인딩과 앱의 세 층으로 나누는 설계를 스도쿠를 예로 설명해요.
- Canonical: https://jaemyeong.com/ko/blog/rust-shared-core-01-architecture-boundary/
- Published: 2026.06.24
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Android, #WebAssembly
세 플랫폼(iOS, Android, Web)을 함께 만들면 로직이 세 벌 생겨요. UI, 저장 방식, 수명주기가 플랫폼마다 다른 것은 자연스러워요. 문제는 퍼즐 규칙, 상태 변화, 오프라인 판정, 가격 계산, 암호 처리, 동기화 충돌 처리까지 세 번 구현할 때예요. 그러면 결함도 플랫폼마다 따로 관리해야 해요.
Rust로 로직을 공유하는 방식은 2026년 기준으로 실용적인 선택이라고 봐요. 다만 앱 전체를 합치는 것이 아니고, 공통 규칙을 맡을 엔진을 뽑아내는 데 초점을 둬요. 시리즈의 첫 편인 이 글은 무엇을 공유하고 어디서 경계를 그을지를 다뤄요.
여기에 적은 것은 설계 제안이에요. 스도쿠는 설명을 위한 예시이고, 특정 앱에서 측정한 결과나 벤치마크는 들어 있지 않아요. 다만 이 구조가 실제로 빌드되는지는 2026년 10월 4일에 작은 예제로 확인했고, 그 결과와 도구의 버전을 «직접 만들어 본 구조» 절에 실었어요. Rust의 [linkage](https://doc.rust-lang.org/reference/linkage.html), [UniFFI](https://mozilla.github.io/uniffi-rs/latest/), [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly) 문서는 각 도구가 무엇을 하는지를 설명하고, 층을 나누는 방식과 공유의 기준은 제 해석이에요.
## Rust가 전부 맡으면 복잡해지는 이유
플랫폼마다 따라야 할 제약이 있어요.
- iOS: UIKit과 SwiftUI의 수명주기, MainActor, App Extension, App Store 심사
- Android: Activity, ViewModel, Compose state, Gradle packaging, Play 정책
- Web: routing, hydration, browser storage, bundler, 배포 대상
Rust 쪽이 UI 상태, analytics event, 저장소 I/O, 통신, 권한까지 맡으면 이 제약들도 함께 떠안게 돼요. 그래서 무엇을 공유할지는 유지보수 책임을 어떻게 나눌지로 정해요.
## 엔진에 넣을 것과 앱에 둘 것
스도쿠를 예로 들면 엔진 후보는 이런 것들이에요.
- 퍼즐 생성과 solver
- 사용자 입력의 판정
- note와 value의 변경
- daily puzzle의 seed 계산
- 난이도 평가
- score와 streak 계산
- action을 다시 재생할 수 있는 reducer
앱에 남길 것은 렌더링, 접근성, 광고, 결제, push notification, analytics SDK, 저장소 선택, 통신 재시도, 심사와 배포 구성이에요.
기준은 코드가 비슷하게 생겼는지가 아니에요. 결과가 같아야 하는지예요. 같은 보드와 같은 입력에 대한 valid와 invalid 판정이 플랫폼마다 같아야 하고, 같은 seed에서 나오는 daily puzzle이 같아야 해요.
표현은 달라도 돼요. iOS에서는 UIKit의 collection view가 자연스러울 수 있고, Android에서는 Compose state가 잘 맞을 수 있어요. Web의 키보드 단축키와 포인터 조작은 따로 설계해야 해요. 지나치게 많이 공유하면 플랫폼에 맞는 조작 경험을 잃을 수 있어요.
코어에 넣을 후보는 네 조건으로 걸러요.
1. 입력만으로 결과가 정해져요.
2. 현재 시간, locale, storage, network에 직접 묶이지 않아요.
3. UI framework와 플랫폼 SDK를 알지 못해요.
4. Rust만으로 테스트할 수 있어요.
조건을 채우지 못하는 후보는 다시 검토해요. daily seed나 streak처럼 시간과 관련된 후보에 시간 값을 어떻게 넘길지는 이 글에서 다루지 않아요.
## 세 층으로 나눈 구조
도메인, 바인딩, 앱의 세 층을 제안해요.
```text
core/
sudoku-rs/
crates/
sudoku-core/ # 순수 Rust 도메인 로직
sudoku-uniffi/ # iOS/Android 바인딩 표면
sudoku-wasm/ # WebAssembly 바인딩 표면
apps/
ios/ # Swift/UIKit 또는 SwiftUI 앱
android/ # Kotlin/Compose 앱
web/ # TypeScript/React 앱
```
`sudoku-core`는 Rust 내부의 모델을 중심에 둬요. Swift, Kotlin, TypeScript나 FFI의 표현 제약을 가능한 한 들이지 않고, 테스트를 여기에 모아요.
`sudoku-uniffi`는 모바일을 위한 변환 경계예요. Swift와 Kotlin에 맞춘 DTO, error 매핑, serialization, 공개 함수의 이름을 관리해요.
`sudoku-wasm`은 wasm-bindgen이나 wasm-pack을 쓰는 Web 쪽 호출 경계예요. TypeScript API와, 앱에서 import할 package를 내놓는 것이 목표예요.
이렇게 나누면 코어가 바인딩 도구에 덜 묶여요. UniFFI가 코드를 생성하는 방식이나 Web bundler 설정이 달라지더라도, 도메인 규칙 쪽은 크게 흔들리지 않을 것으로 기대해요.
## 앱은 생성된 코드를 직접 부르지 않아요
ViewController, ViewModel, Composable, React Component가 생성된 코드에 직접 묶이면, 함수 이름이나 DTO가 바뀔 때 UI 전체가 영향을 받아요. 그래서 앱 안에 중간 계층을 둬요.
```text
iOS ViewModel
-> SudokuEngineClient protocol
-> UniFFI generated binding
-> Rust core
Android ViewModel
-> SudokuEngineClient interface
-> UniFFI generated binding
-> Rust core
Web state layer
-> SudokuEngineClient
-> wasm package
-> Rust core
```
`SudokuEngineClient`는 iOS에서는 protocol, Android에서는 interface이고, Web에서는 state layer와 wasm package 사이에 놓이는 클라이언트예요. UI에 내놓는 값은 그 앱이 쓰는 언어의 타입이에요. 테스트할 때에는 adapter를 fake로 바꿔 끼울 수 있어요. 생성된 binding을 어느 층까지 써도 되는지는 팀의 규칙으로 적어 둘 필요가 있어요.
## 직접 만들어 본 구조
위의 세 층을 그대로 Cargo workspace로 만들어 봤어요. 구조를 확인하려고 만든 최소 예제이고, 실제 앱의 코드가 아니에요. `sudoku-core`에는 시작, 액션 적용, 검증, 일일 퍼즐 생성 네 가지 규칙을 넣었고, `sudoku-uniffi`와 `sudoku-wasm`은 그 함수를 감싸기만 해요. 환경은 macOS 27.0.1, Xcode 27.0, cargo 1.96.0이에요.
crate마다 직접 의존하는 것을 `cargo tree --depth 1`로 뽑으면 이래요.
```text
sudoku-core v0.1.0
├── serde v1.0.229
└── serde_json v1.0.151
sudoku-uniffi v0.1.0
├── sudoku-core v0.1.0
└── uniffi v0.29.5
sudoku-wasm v0.1.0
├── sudoku-core v0.1.0
└── wasm-bindgen v0.2.129
```
코어는 serde와 serde_json에만 의존하고, 바인딩 crate 두 개가 코어를 불러요. 코어에서 바인딩 쪽으로 가는 의존이 없다는 것을 이 출력으로 확인했어요. 다만 예제의 코어는 JSON 직렬화(serde_json)까지 맡고 있어요. 위에서 바인딩 층의 일로 나눈 것을 예제에서는 줄여서 코어에 둔 것이에요.
코어의 테스트는 `cargo test -p sudoku-core`로 돌려요. 시뮬레이터나 브라우저 없이 끝나요. 전체 출력 가운데 테스트 다섯 개가 실행된 구간만 옮겼고, 앞뒤에 있는 0개짜리 결과 줄은 뺐어요.
```text
running 5 tests
test same_seed_gives_same_snapshot ... ok
test given_cell_is_rejected ... ok
test snapshot_validation_reports_errors_as_values ... ok
test daily_puzzle_depends_only_on_the_date ... ok
test fixtures_match_expected ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
```
테스트 다섯 개는 같은 seed가 같은 결과를 내는지, 주어진 칸을 바꾸려는 액션이 거부되는지, 일일 퍼즐이 날짜 문자열에만 의존하는지를 봐요. 이 workspace에서 iOS, Android, Web용 산출물을 만든 과정은 이 시리즈의 3편부터 7편에 나눠 실었어요.
## 옮기는 순서
1. 플랫폼마다 중복된 도메인 로직의 목록을 만들어요.
2. 그 가운데 결과가 같아야 하는 후보를 골라요.
3. 코어가 UI, storage, network, analytics, 결제에 의존하지 못하게 막아요.
4. 내부 모델과 바인딩 모델을 나눠요.
5. 플랫폼마다 adapter를 두고, 생성된 코드에 접근하는 규칙을 정해요.
6. 작고 결정적인 로직부터 옮겨요.
처음 옮기기 좋은 후보는 daily seed 계산, puzzle validation, reducer예요. Cargo workspace를 짜기 전에 공유가 정말 필요한지부터 판단해요. 후보를 한꺼번에 옮기면 범위가 커져요.
확인 방법으로는 Rust만으로 도는 테스트와 앱 adapter의 fake를 제안해요. Rust 테스트는 위 예제에서 실행했어요. 앱 adapter의 fake는 만들어 보지 않았고, 성능 수치는 이 글에 없어요.
이 구조로도 플랫폼 개발과 심사, 배포의 책임은 그대로 남아요. 팀이 규칙을 지키지 않으면 나눠 놓은 층 사이에 의존이 다시 생길 수 있어요. 도입이 수월해지고 안정적이 될 것이라는 기대를 뒷받침하는 측정 수치는 이 글에 싣지 않았어요. 경계를 정한 뒤에는 UniFFI, XCFramework, Android의 `.so`, WebAssembly를 어떻게 쓸지 검토하게 되고, 다음 편에서는 바깥에서 부르는 FFI API의 설계를 다뤄요.
---
# What to decide when registering fonts at runtime on iOS
> Bundled fonts need only an Info.plist entry, but downloaded or packaged fonts may suit runtime registration. This covers names, a registry, and file lifetime.
- Canonical: https://jaemyeong.com/en/blog/ios-runtime-font-registration/
- Published: 2026.06.18
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Swift, #CoreText, #Font
A font that always ships inside the app never needs runtime registration. Add the file in Xcode and declare it under [UIAppFonts](https://developer.apple.com/documentation/bundleresources/information-property-list/uiappfonts) in `Info.plist`, and the system loads it at launch. [The guide to adding a custom font to your app](https://developer.apple.com/documentation/uikit/adding-a-custom-font-to-your-app) explains this method.
Runtime registration is worth considering in other cases: a font downloaded from a server, a font chosen by theme, language, or content, a font inside a Swift Package, a framework, or a separate bundle, a font list that changes after the build, and a font swapped in for tests and previews. The two methods are used together, depending on what is required.
What follows covers how the API behaves and what has to be decided in operation, with example code. How the API behaves comes from the Apple documentation. Managing registration in one place, the ownership of the file and the order of deletion, and the advice to organize everything around the file URL are not requirements of the documentation. They are my own design choices. I compiled and ran the example code on macOS on October 4, 2026, and the result is in the section "What running it on macOS showed." Results from running it in an iOS app and applying the font on screen are not in this post.
## Registration lasts only within the process
Passing a file URL to [CTFontManagerRegisterFontsForURL](https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl%28_%3A_%3A_%3A%29) puts the fonts in that file into descriptor matching. After that they can be used with `UIFont(name:size:)` or `Font.custom(_:size:)`. For a font used only inside the app, the scope is [CTFontManagerScope.process](https://developer.apple.com/documentation/coretext/ctfontmanagerscope/process). This registration disappears when the process ends, so it has to be done again on the next launch.
This is the simplest registration code.
```swift
import CoreText
func registerFont(at url: URL) throws {
var error: Unmanaged?
let success = CTFontManagerRegisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() {
throw error
}
}
}
```
Three things are missing from this code. It does not check that the file exists, it does not find out the name of the font, and it has no handling for registering the same file twice.
## A file name is not a font name
Registering a file named `Pretendard-Regular.otf` does not guarantee that the font can be called by the name `Pretendard-Regular`. What [UIFont(name:size:)](https://developer.apple.com/documentation/uikit/uifont/init%28name%3Asize%3A%29) takes is a fully specified name.
The name is read from the file itself. [CTFontManagerCreateFontDescriptorsFromURL](https://developer.apple.com/documentation/coretext/ctfontmanagercreatefontdescriptorsfromurl%28_%3A%29) returns the descriptors, and [kCTFontNameAttribute](https://developer.apple.com/documentation/coretext/kctfontnameattribute) gives the PostScript name. One file can hold several faces, as in a `.ttc` file, so the result is an array.
This function collects the PostScript names from the descriptors in a file.
```swift
import CoreText
func postScriptNames(in url: URL) -> [String] {
guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else {
return []
}
return descriptors.compactMap {
CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
}
}
```
During development, logging the names that were read makes it easy to confirm which name to call.
```swift
let names = postScriptNames(in: fontURL)
print("Font PostScript names:", names)
```
The output of an actual run is in the section "What running it on macOS showed." The names that were read are gathered in one place, such as a design system or a registry. Writing the string directly in each view makes it hard to replace a font or trace an error.
## An example that manages registration state in one place
Calling registration every time a cell is created, every time a SwiftUI `body` is evaluated, or every time a screen appears can cause duplicate registration errors and unnecessary cost. So one object is put in charge of registration. `RuntimeFontRegistry.shared` handles the file check, the name collection, and the record of registrations per URL.
First comes the part that holds the state. It stores the URL as the key and the array of names read from that file as the value. The three blocks that follow are the rest of this class. To combine them, remove the `// …` line and the closing brace below it from this block, then append the three blocks in order. The last block closes the class and declares the error type outside it.
```swift
import Foundation
import CoreText
final class RuntimeFontRegistry {
static let shared = RuntimeFontRegistry()
private var registeredURLs: [URL: [String]] = [:]
private let lock = NSLock()
private init() {}
// …
}
```
The register method normalizes the key with `standardizedFileURL`, checks that the file exists, and checks that the descriptors and names are valid. It then looks up the cache under an `NSLock`. If the URL is already registered, it returns the stored names. If not, it registers with `.process` and puts the array of names into the cache.
```swift
@discardableResult
func registerFont(at url: URL) throws -> [String] {
let url = url.standardizedFileURL
guard FileManager.default.fileExists(atPath: url.path) else {
throw FontRegistrationError.fileNotFound(url)
}
let names = try Self.readPostScriptNames(from: url)
lock.lock()
if let cached = registeredURLs[url] {
lock.unlock()
return cached
}
lock.unlock()
var error: Unmanaged?
let success = CTFontManagerRegisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() as Error? {
throw error
}
}
lock.lock()
registeredURLs[url] = names
lock.unlock()
return names
}
```
The unregister method calls CoreText with the same scope used for registration and removes the entry from the cache.
```swift
func unregisterFont(at url: URL) throws {
let url = url.standardizedFileURL
var error: Unmanaged?
let success = CTFontManagerUnregisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() as Error? {
throw error
}
}
lock.lock()
registeredURLs.removeValue(forKey: url)
lock.unlock()
}
```
This is the part that reads the names, and the error type. It tells apart a missing file, a file that is not a font, and a file with no PostScript name.
```swift
private static func readPostScriptNames(from url: URL) throws -> [String] {
guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor],
!descriptors.isEmpty else {
throw FontRegistrationError.invalidFontFile(url)
}
let names = descriptors.compactMap {
CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
}
guard !names.isEmpty else {
throw FontRegistrationError.missingPostScriptName(url)
}
return names
}
}
enum FontRegistrationError: Error {
case fileNotFound(URL)
case invalidFontFile(URL)
case missingPostScriptName(URL)
}
```
Some things are not handled in this example. Interpreting the error codes that CoreText returns is extra work. I recommend treating a repeated request for the same URL as success, and treating a different file with the same PostScript name as a conflict. A wrong or damaged file has to be handled separately from a duplicate or a name conflict.
Reading the code also shows some limits. `NSLock` is held only while the cache is looked up and while it is stored, and the CoreText call is outside the lock. So two simultaneous requests for the same URL can both enter registration. Also, when `success` is false but `error` is nil, the code does not throw and moves on to the next line. I read these two points from the code. I did not reproduce them by running it.
## A downloaded font needs a decision about the file's lifetime
If a file in a temporary location is registered, there is a risk that the system keeps a reference after the file is deleted. So the file is moved to a location the app manages, and registered after that.
This function creates a `RuntimeFonts` directory under `Application Support`.
```swift
func fontStorageDirectory() throws -> URL {
let baseURL = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = baseURL.appendingPathComponent("RuntimeFonts", isDirectory: true)
if !FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
}
return directory
}
```
This function copies the downloaded file into that directory and registers it with the registry. The file name is the last path component of `temporaryURL` as it is, and if a file with the same name exists, it is deleted before the copy.
```swift
func installDownloadedFont(from temporaryURL: URL) throws -> [String] {
let directory = try fontStorageDirectory()
let destinationURL = directory.appendingPathComponent(temporaryURL.lastPathComponent)
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: temporaryURL, to: destinationURL)
return try RuntimeFontRegistry.shared.registerFont(at: destinationURL)
}
```
This install example does not unregister before deleting the existing file. That does not match the advice below to unregister before deleting. If the file at an already registered URL is replaced, the registry can also return the names it cached earlier. This is another limit read from the code. The descriptor check happens inside the registry after the copy, and there is no code that checks the integrity of the download, the license, or a name conflict.
What has to be decided in operation lies outside this example: when to delete the file, how to register on the next launch, the fallback and what the user sees when a download fails, the server response and distribution, cache invalidation, the relation to the app version, and the license review.
## What running it on macOS showed
I joined the Swift code of this post as it is, compiled it, and ran it. The run used Swift 6.4 on macOS 27.0.1. CoreText exists on macOS too, so registering and unregistering could be checked with no iOS device. There is no UIKit there, though, so the name was checked with `CTFontCreateWithName` in place of `UIFont(name:size:)`. The font was `PretendardStd-Bold.otf`, which was already on this Mac and was copied to a temporary folder. It was not installed in the system.
The code compiled with no warnings. Below is the output of a run with calling code added that checks whether the font can be found by name before and after registration.
```text
Font PostScript names: ["PretendardStd-Bold"]
before register: discoverable=false
registry.registerFont: ["PretendardStd-Bold"]
after register : discoverable=true
CTFontCreateWithName -> PretendardStd-Bold
registry.registerFont again (cached): ["PretendardStd-Bold"]
plain registerFont again: error com.apple.CoreText.CTFontManagerErrorDomain code=105
after unregister: discoverable=false
unregister again: error com.apple.CoreText.CTFontManagerErrorDomain code=201
missing file: fileNotFound()
```
Four things can be read from it.
- The font could not be found by name before registration, could be found after it, and could not be found again after unregistration.
- On the second registration, the registry returned the names it had cached. Registering the same URL again without going through the registry gave an error with code 105, the value of CoreText's `kCTFontManagerErrorAlreadyRegistered`.
- Unregistering twice gave an error with code 201 (`kCTFontManagerErrorNotRegistered`).
- A missing file was caught as `fileNotFound` before CoreText was called.
With a font file that is already installed in the system, the same run found the font by name before registration and after unregistration as well. The same font remains at another path. Code that judges registration by name cannot tell that case apart. `installDownloadedFont` was not called.
## Unregistering only removes the font from matching
Calling [CTFontManagerUnregisterFontsForURL](https://developer.apple.com/documentation/coretext/ctfontmanagerunregisterfontsforurl%28_%3A_%3A_%3A%29) takes the font out of descriptor matching. It should not be read as cleaning up `UIFont`, `CTFont`, `NSAttributedString`, and SwiftUI views that already exist.
The lifetime is decided by how the font is used. A font needed for the whole run stays registered. For a temporarily downloaded resource, an owner is decided and that owner unregisters it. Unregistering is attempted before the file is deleted, and if it fails, the deletion is postponed. The scope for registering and unregistering has to be the same, so a font registered with `.process` is unregistered with `.process`.
[CTFontManagerRegisterGraphicsFont](https://developer.apple.com/documentation/coretext/ctfontmanagerregistergraphicsfont%28_%3A_%3A%29) is the API that registers a `CGFont` object. For a font in a file, the Apple documentation points to URL registration. In the documentation as rechecked on October 4, 2026, this API is marked deprecated as of iOS 18.0 and macOS 15.0, with a note to use `CTFontManagerCreateFontDescriptorsFromData` or `CTFontManagerRegisterFontsForURL`. That designing storage, registration, unregistration, and the cache as one unit around the file URL is easier to handle is my own judgment.
The lifetime of `.process`, the choice between URL registration and `CGFont` registration, and the effect of unregistering on objects that already exist need to be checked against the SDK and OS version. Branching per error code, conflict handling, safe file replacement and cache invalidation, registration after a restart, and the fallback policy are only described in this post and not implemented in the examples. What the examples do contain is the per-URL cache and a simple file replacement.
---
# What to look at before adopting a Swift package registry
> Receiving Swift dependencies by registry ID changes the path they travel. Request order, configuration, mixed graphs, mirrors, authentication, and signing.
- Canonical: https://jaemyeong.com/en/blog/swift-package-registry/
- Published: 2026.06.18
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Swift, #SwiftPM, #Package Registry, #iOS
Swift dependencies are usually declared with a Git URL. The same dependency can also be declared with a registry identifier.
This is a declaration with a Git URL.
```swift
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0")
```
This is a declaration with a registry ID.
```swift
.package(id: "apple.swift-log", from: "1.5.0")
```
The difference is one line, but the path the package travels changes, and CI, reproducibility, internal distribution, security, and Xcode configuration are affected along with it. This post lists what an iOS team should look at when deciding whether to choose this approach. The post does not record applying it to a specific project, and it has no measured resolve times.
## The cost of fetching through Git
With a Git URL, the first build clones the repository and picks a version from the tags. Even when only one release is needed, the Git history comes along. With a large repository or a complex dependency graph, resolving can get slow.
Reproducibility is at risk too. What a tag points to can change. If the repository is deleted or moved, having `Package.resolved` does not guarantee a build.
Some environments have a restricted network. When `github.com` is blocked, clones and CI can fail. In such places the security team usually requires going through an approved Artifactory, a Git mirror, or an artifact store. With a registry, a setup that supplies packages without reaching GitHub becomes possible.
## The order in which a registry serves a package
A registry identifier has the form `scope.package-name`. For `mona.LinkedList`, the scope is `mona` and the name is `LinkedList`.
```swift
.package(id: "mona.LinkedList", .upToNextMajor(from: "1.0.0"))
```
According to [the service specification](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md), the main requests come in three steps. The list of releases comes from `/{scope}/{name}`, then the `Package.swift` of that version, then the archive from `/{scope}/{name}/{version}.zip`. Checking the checksum and the signature also fetches the metadata of the release ([usage documentation](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md)). Because the archive is served over HTTP, releases can be operated as immutable, and a CDN or an internal store can be used. Source control and distribution are separated. The background is in [SE-0292](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0292-package-registry-service.md).
The development repository does not have to move. Development can continue on GitHub, GitLab, GitHub Enterprise Server, or an internal Git server, and the registry only delivers releases.
## Where the configuration lives
The default registry is set with the CLI.
```bash
swift package-registry set https://packages.example.com
```
The setting is stored at project scope and at user scope. This is the file location at project scope.
```text
.swiftpm/configuration/registries.json
```
This is the file location at user scope.
```text
~/.swiftpm/configuration/registries.json
```
The file holds the default URL and the format version.
```json
{
"registries": {
"[default]": {
"url": "https://packages.example.com"
}
},
"version": 1
}
```
In the help text of Swift 6.4, `set` takes one URL and has the `--global` and `--scope` options. I confirmed this only with `--help` on October 4, 2026. I did not connect to a registry server or change any configuration.
For a package project, declaring by ID in `Package.swift` is enough. An iOS app is different. An Xcode app project usually has no `Package.swift`, and dependencies are managed through `project.pbxproj`, the workspace, and `Package.resolved`. Do not assume that a setting made with the CLI applies to Xcode as it is. Check where the setting is actually stored.
Within a team, the registry URL setting is shared, and access tokens are not committed. Another option is a CLI or GUI tool that finds the configuration location in the workspace or `.xcodeproj` and writes `mirrors.json`. That is my suggestion, and this post has no example of having built one.
## When Git and a registry are mixed
The case that needs the most care is one package arriving by two paths. The direct dependency is declared by registry ID, and another package pulls in the same thing by Git URL.
This is the direct dependency.
```swift
.package(id: "apple.swift-log", from: "1.5.0")
```
This is the declaration that comes in as a transitive dependency.
```swift
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0")
```
With different origins, the two can get separate identities. Then modules or products can be duplicated, and symbols can clash.
What connects the two is `/identifiers?url=`. It looks up the registry ID that corresponds to a Git URL. The mapping between URL and ID has to be exact. It is better to keep one origin per package, and I suggest carrying out the move to ID declarations and the upkeep of Git or mirrors together, step by step.
## A mirror is something else
A mirror has a different purpose from a registry. The mirror in [SE-0219](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0219-package-manager-dependency-mirroring.md) keeps the `.package(url:)` declaration and only changes the address fetched from to another Git server.
This is an example that maps the original address to an internal Git address. `git.example.com` stands for the address of the internal Git server.
```json
{
"object": [
{
"original": "https://github.com/apple/swift-log.git",
"mirror": "https://git.example.com/mirrors/apple/swift-log.git"
}
],
"version": 1
}
```
A registry is the path of IDs and archives, and a mirror is the path that swaps one Git for another. In a restricted network, I suggest applying an internal mirror first and moving to a registry after that.
## Authentication and signing
A private registry needs authentication, and the `login` command can store the credentials ([SE-0378](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0378-package-registry-auth.md)).
```bash
swift package-registry login https://packages.example.com --token "$TOKEN"
```
Authentication is about access rights. Whether a release is genuine is handled by signing. A signature is evidence that the publisher holds the key and that the release has not been altered. The two signature headers in the archive response (`X-Swift-Package-Signature-Format` and `X-Swift-Package-Signature`) are checked, and the signature and the certificate chain are validated. A verified signature does not mean that the publisher can be trusted. Which CAs to trust, and which certificate may publish which scope, are decided separately by the organization.
Signing needs an X.509 certificate for code signing and a private key. On macOS an identity in the Keychain can be used, and in CI passing a key file and certificate chain files is also possible ([SE-0391](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0391-package-registry-publish.md)). The publish endpoint that the command below uploads the archive to is defined in [SE-0321](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0321-package-registry-publish.md).
```bash
swift package-registry publish mycompany.design-system 1.2.0 \
--url https://packages.example.com \
--private-key-path ./private-key.pkcs8.der \
--cert-chain-paths ./leaf-cert.der ./intermediate.der ./root.der
```
I confirmed only that `--token` of `login` and `--url`, `--private-key-path`, and `--cert-chain-paths` of `publish` are in the help text of Swift 6.4. The commands were not run against a real server. The environment variable `SWIFTPM_REGISTRY_TOKEN`, which the earlier version mentioned for CI, did not appear in the help text. [The usage documentation](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md) on the main branch lists this variable as a way to authenticate (checked on October 4, 2026). I could not confirm whether Swift 6.4 supports it, so check with the toolchain in use before relying on it.
At the organization level, the scopes and packages each certificate may publish are managed, and when an internal CA is operated, the SwiftPM trust root configuration has to be distributed to the team.
## Deciding whether to adopt it
With few dependencies and free access to GitHub, the gain may be small. The following are reasons to consider it. Resolving is slow. There are many internal packages. Outside access is restricted. A store such as JFrog, Nexus, or Artifactory is already in operation. A private SDK is delivered to customers. There are requirements for checksums, signing, or audit logs.
I suggest checking in the following order.
1. List the dependencies.
2. Look at the Git URLs in `Package.resolved` and in Xcode.
3. Measure how long resolving takes in CI.
4. Check whether GitHub, GitHubusercontent, and external binary URLs are allowed.
5. Separate what to receive through a registry from what to receive through a mirror.
6. Check that no dependency graph brings the same package in by both a Git URL and a registry ID.
7. Confirm where the configuration lives in the workspace and `.xcodeproj`.
8. Decide how tokens and CI variables are handled.
9. Decide whether signing is needed and which CA to trust.
10. See how it connects to the internal store.
Package approval, the choice between a registry and a mirror, sharing the Xcode configuration, and the external hosts allowed in CI are matters for the team or a platform group to set as policy.
The request order, the configuration files, authentication, and signing come from the Swift Evolution proposals and the SwiftPM documentation. The adoption order and the checklist are my own design choices built on that, not results confirmed by applying them. Where the configuration lives in each Xcode version, and how compatible it is, was not checked. Results confirming how a mixed graph actually behaves, or the speed before and after moving to a registry, are not in this post either. The documentation links point to the main branch, so their content can change.
---
# Five changes from WWDC26 for iOS developers
> What Apple announced at WWDC26 for iOS developers: App Intents, where models run, Xcode 27, the screen, and subscriptions. Facts and my view are kept apart.
- Canonical: https://jaemyeong.com/en/blog/wwdc26-ios-developer-changes/
- Published: 2026.06.18
- Updated: 2026.10.04
- Category: IT/뉴스
- Tags: #WWDC26, #iOS, #App Intents, #Apple Intelligence
On June 8, 2026, at WWDC26, Apple announced iOS 27, iPadOS 27, macOS 27, watchOS 27, visionOS 27, and tvOS 27 ([Apple Newsroom](https://www.apple.com/newsroom/2026/06/apple-unveils-next-generation-of-apple-intelligence-siri-ai-and-more/)). According to the same press release, the developer beta was available from the day of the announcement, and the release comes this fall as a free software update.
This post sorts what reaches people who build iOS apps into five areas. The announced facts are what I rechecked on October 4, 2026 on six official Apple pages, and what comes later in each section is my own view. Results of implementing or measuring anything on the beta are not in this post.
## Exposing app content and actions to the system
According to [the WWDC26 iOS guide](https://developer.apple.com/wwdc26/guides/ios/), Siri reaches the content and actions of an app in natural language through the App Intents framework. Entity schemas contribute the content of the app to the Spotlight semantic index. With intent schemas, people can ask for actions on that content in natural language, with no specific phrases to define. The new View Annotations API maps views to entities, so that what is on screen can be referred to and acted on in conversation. The App Intents Testing framework validates the integration through real system pathways, without UI automation.
From here on it is my view. The ways into a feature from Siri and Spotlight, outside the app, are getting wider, so I see App Intents support as something to put into the first design, not an extra convenience. I expect it to be harder to add later. I recommend deciding first which entities and actions to expose. For a sports app the candidates are games, teams, leagues, and schedules, and for a commerce app they are products, orders, the cart, and delivery status. These examples are mine and are not in Apple's guide.
## Where the model runs
The same iOS guide introduces the Foundation Models framework as a Swift API for the on-device model that Apple Intelligence uses. It says the framework now works with Apple Foundation Models, with cloud models like Claude and Gemini, and with any other provider that conforms to the Language Model protocol. Multimodal prompts pass images alongside text, and Dynamic Profiles swap models, tools, and instructions within a continuous session. The Evaluations framework is a tool for verifying that AI features behave correctly across conditions.
Core AI is a new framework for running your own models on-device. It is built into the OS and made for Apple Silicon, and models are loaded and run through a Swift API. The guide names ahead-of-time compilation, zero-copy data paths, and stateful execution as its performance elements. For Private Cloud Compute, there is a condition. The guide says that if you are enrolled in the App Store Small Business Program and your app has fewer than 2 million total first-time downloads, you can access the next generation of Apple Foundation Models running on Private Cloud Compute at no cloud API cost.
As I read it, the two have different roles. Foundation Models connects language and multimodal models to an app, and Core AI runs a model you already have on Apple Silicon. I recommend looking first at handling summarizing, classifying, recommending, and input correction on the device, and at Private Cloud Compute or an outside provider for large inference and generation. For sensitive information, check whether it can be handled on the device before it is sent out. The things to compare are cost, latency, and privacy. Also look at whether testing and evaluation can be automated.
## Xcode 27 and agents
According to [the summary of the Platforms State of the Union](https://developer.apple.com/news/?id=lvart8mq), Xcode 27 is Apple silicon only. Projects load faster, settings sync via iCloud, and the toolbar is customizable. The new Device Hub replaces Simulator and brings virtual and physical devices together in one place. The same article says agents in Xcode can run tests, try things in Playground, run apps in the Simulator, fix issues, and localize apps. Plugins add skills and MCP tools, and other agents connect through the Agent Client Protocol. [The WWDC26 Xcode guide](https://developer.apple.com/wwdc26/guides/xcode/) covers Device Hub, improvements to Instruments, and localization with agents.
The sentence that Device Hub replaces Simulator and the sentence that agents run apps in the Simulator are in the same article. From that article alone I could not tell whether only the management screen changed or the runtime changed as well.
If you develop on an Intel Mac or run CI builds on one, I think a plan to move is needed. I expect agents to be useful for validating small changes, analyzing test failures, and tidying string catalogs. Even so, I recommend treating generated code as a draft and always reviewing the diff. A team does well to agree first on how far agents are used and on the review rules. Changes that touch architecture, authentication, payments, storage, or sync deserve a stricter look. I think decisions about structure and quality have to stay with the developer.
## The material and the size of the screen
According to [the iOS 27 page](https://www.apple.com/os/ios/), Liquid Glass has more uniform refraction and improved contrast, and app icons are sharper. A slider lets users adjust Liquid Glass from ultraclear to fully tinted. The page also gives performance figures. App launches are up to 30% faster, new photos load in Photos up to 70% faster, and AirDrop transfers are up to 80% faster. These numbers are upper bounds that Apple measured on specific devices under specific conditions, and the footnotes give the test conditions. They do not mean every app gets that much faster.
The State of the Union summary says iOS apps are now resizable. People can use larger displays on iPad, and in iPhone Mirroring on Mac.
I see custom navigation bars, tab bars, toolbars, blur backgrounds, translucent overlays, and floating buttons that an app implements itself as things to check again. Confirm that they stay readable with Dynamic Type, dark mode, Reduce Transparency, and increased contrast. A layout that assumes the size of an iPhone can break when the size changes, so I recommend checking touch areas, scrolling, and transitions on a real device.
## Subscription products and review submission
According to [the WWDC26 App Store guide](https://developer.apple.com/wwdc26/guides/app-store/), multiple In-App Purchases, including subscriptions, can be grouped into a single submission in App Store Connect. They can also go together with other review items such as In-App Events, custom product pages, and product page optimization tests.
Retention Messaging shows subscribers a message about the value of the subscription. Apple describes it as not adding friction to the cancellation flow, and says a special offer can be shown with it. It is listed as coming this fall. Subscription Bundles let people buy several auto-renewable subscriptions as a single subscription, and Suites offer a set of subscriptions that are not sold standalone. The guide says more on how to request these two will come later in the summer. Group Purchases let one subscriber buy multiple seats in a single purchase and invite others. Volume Purchasing makes a subscription available to enterprise and education buyers through Apple School Manager and Apple Business.
If you run subscriptions, I do not think the StoreKit code is the only thing to look at. The product model, operations in App Store Connect, review, entitlements after purchase, and the cancellation process need to be reviewed together. Pricing and bundling, the response to churn, and sales to organizations are questions to settle together with policy.
What this post relies on is Apple's press release and guide pages as they were on October 4, 2026. API names and availability conditions can change in the release version. Availability by region and device, and beta numbers, are not covered here. This post includes no implementation results for the parts written as my view.
---
# iOS에서 실행 중에 폰트를 등록할 때 정해야 하는 것
> 번들에 넣는 폰트는 Info.plist 선언으로 끝나지만, 내려받거나 패키지에 든 폰트는 실행 중에 등록하는 쪽이 더 맞을 수 있어요. CoreText로 파일을 등록할 때 PostScript name을 읽는 방법, 등록 상태를 한곳에서 관리하는 예제, 파일 수명에서 정할 것을 정리해요.
- Canonical: https://jaemyeong.com/ko/blog/ios-runtime-font-registration/
- Published: 2026.06.18
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Swift, #CoreText, #Font
앱에 늘 들어 있는 폰트라면 실행 중에 등록할 일이 없어요. Xcode에 파일을 추가하고 `Info.plist`의 [UIAppFonts](https://developer.apple.com/documentation/bundleresources/information-property-list/uiappfonts)에 선언하면 시스템이 실행할 때 불러와요. [앱에 커스텀 폰트를 추가하는 안내](https://developer.apple.com/documentation/uikit/adding-a-custom-font-to-your-app)가 이 방법을 설명해요.
실행 중 등록을 고려할 만한 경우는 따로 있어요. 서버에서 내려받는 폰트, 테마나 언어나 콘텐츠에 따라 고르는 폰트, Swift Package나 framework나 별도 bundle에 든 폰트, 빌드한 뒤에 목록이 바뀌는 폰트, 테스트와 프리뷰에서 바꿔 끼우는 폰트예요. 두 방식은 요구에 맞춰 함께 써요.
아래는 API의 동작과 운영에서 정할 것을 예제 코드와 함께 정리한 것이에요. API가 어떻게 동작하는지는 Apple 문서에 있는 내용이에요. 등록을 한곳에서 관리하는 방식, 파일의 소유권과 삭제 순서, 파일 URL을 기준으로 묶는 운영 권고는 문서의 요구가 아니고 제 해석이에요. 예제 코드는 2026년 10월 4일에 macOS에서 컴파일해 실행해 봤고, 그 결과를 «macOS에서 실행해 본 결과» 절에 실었어요. iOS 앱에서 실행해 화면에 적용한 결과는 이 글에 없어요.
## 등록은 프로세스 안에서만 유지돼요
파일 URL을 [CTFontManagerRegisterFontsForURL](https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl%28_%3A_%3A_%3A%29)에 넘기면 그 파일의 폰트가 descriptor 검색에 들어가요. 그 뒤로는 `UIFont(name:size:)`나 `Font.custom(_:size:)`로 쓸 수 있어요. 앱 안에서만 쓸 폰트에는 scope로 [CTFontManagerScope.process](https://developer.apple.com/documentation/coretext/ctfontmanagerscope/process)를 줘요. 이 등록은 프로세스가 끝나면 사라지므로 다음 실행에서 다시 등록해야 해요.
가장 단순한 등록 코드예요.
```swift
import CoreText
func registerFont(at url: URL) throws {
var error: Unmanaged?
let success = CTFontManagerRegisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() {
throw error
}
}
}
```
이 코드에는 빠진 것이 셋이에요. 파일이 있는지 검사하지 않고, 폰트의 이름을 알아내지 않고, 같은 파일을 두 번 등록할 때의 대응이 없어요.
## 파일 이름은 폰트 이름이 아니에요
`Pretendard-Regular.otf`라는 파일을 등록했다고 해서 `Pretendard-Regular`라는 이름으로 부를 수 있다고 확정할 수는 없어요. [UIFont(name:size:)](https://developer.apple.com/documentation/uikit/uifont/init%28name%3Asize%3A%29)가 받는 것은 fully specified name이에요.
이름은 파일에서 직접 읽어요. [CTFontManagerCreateFontDescriptorsFromURL](https://developer.apple.com/documentation/coretext/ctfontmanagercreatefontdescriptorsfromurl%28_%3A%29)로 descriptor를 얻고, [kCTFontNameAttribute](https://developer.apple.com/documentation/coretext/kctfontnameattribute)에서 PostScript name을 꺼내요. `.ttc`처럼 파일 하나에 face가 여러 개 든 경우가 있어서 결과는 배열이에요.
파일 안의 descriptor에서 PostScript name을 모으는 함수예요.
```swift
import CoreText
func postScriptNames(in url: URL) -> [String] {
guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else {
return []
}
return descriptors.compactMap {
CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
}
}
```
개발 중에는 읽은 이름을 로그로 남겨 두면 호출할 이름을 확인하기 쉬워요.
```swift
let names = postScriptNames(in: fontURL)
print("Font PostScript names:", names)
```
실제로 실행한 출력은 «macOS에서 실행해 본 결과» 절에 있어요. 읽은 이름은 디자인 시스템이나 registry 한곳에 모아 둬요. 뷰마다 문자열을 직접 적으면 폰트를 바꾸거나 오류를 추적하기 어려워요.
## 등록 상태를 한곳에서 관리하는 예제
셀을 만들 때마다, SwiftUI의 `body`가 계산될 때마다, 화면에 들어올 때마다 등록을 호출하면 중복 등록 오류와 불필요한 비용이 생길 수 있어요. 그래서 등록을 맡는 객체를 하나 둬요. `RuntimeFontRegistry.shared`가 파일 검사, 이름 수집, URL별 등록 기록을 맡아요.
먼저 상태를 담는 부분이에요. URL을 키로, 그 파일에서 읽은 이름 배열을 값으로 저장해요. 이어지는 세 블록은 이 클래스의 나머지예요. 하나로 합칠 때에는 이 블록의 `// …`와 그 아래의 닫는 중괄호를 지우고 세 블록을 순서대로 이어 붙여요. 마지막 블록이 클래스를 닫고, 클래스 밖에 오류 타입을 선언해요.
```swift
import Foundation
import CoreText
final class RuntimeFontRegistry {
static let shared = RuntimeFontRegistry()
private var registeredURLs: [URL: [String]] = [:]
private let lock = NSLock()
private init() {}
// …
}
```
등록 메서드는 `standardizedFileURL`로 키를 통일하고, 파일이 있는지 확인하고, descriptor와 이름이 유효한지 확인해요. 그다음 `NSLock`으로 캐시를 조회해서 이미 등록한 URL이면 저장된 이름을 돌려주고, 아니면 `.process`로 등록한 뒤 이름 배열을 캐시에 넣어요.
```swift
@discardableResult
func registerFont(at url: URL) throws -> [String] {
let url = url.standardizedFileURL
guard FileManager.default.fileExists(atPath: url.path) else {
throw FontRegistrationError.fileNotFound(url)
}
let names = try Self.readPostScriptNames(from: url)
lock.lock()
if let cached = registeredURLs[url] {
lock.unlock()
return cached
}
lock.unlock()
var error: Unmanaged?
let success = CTFontManagerRegisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() as Error? {
throw error
}
}
lock.lock()
registeredURLs[url] = names
lock.unlock()
return names
}
```
해제 메서드는 등록할 때와 같은 scope로 CoreText를 호출하고 캐시에서 지워요.
```swift
func unregisterFont(at url: URL) throws {
let url = url.standardizedFileURL
var error: Unmanaged?
let success = CTFontManagerUnregisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() as Error? {
throw error
}
}
lock.lock()
registeredURLs.removeValue(forKey: url)
lock.unlock()
}
```
이름을 읽는 부분과 오류 타입이에요. 파일이 없는 경우, 폰트 파일이 아닌 경우, PostScript name이 없는 경우를 구분해요.
```swift
private static func readPostScriptNames(from url: URL) throws -> [String] {
guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor],
!descriptors.isEmpty else {
throw FontRegistrationError.invalidFontFile(url)
}
let names = descriptors.compactMap {
CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
}
guard !names.isEmpty else {
throw FontRegistrationError.missingPostScriptName(url)
}
return names
}
}
enum FontRegistrationError: Error {
case fileNotFound(URL)
case invalidFontFile(URL)
case missingPostScriptName(URL)
}
```
이 예제에서 처리하지 않은 것이 있어요. CoreText가 돌려주는 오류 코드의 해석은 추가로 해야 해요. 같은 URL을 다시 요청하면 성공으로 취급하고, 다른 파일이 같은 PostScript name을 가지면 충돌로 취급할 것을 권해요. 잘못됐거나 손상된 파일과, 중복이나 이름 충돌은 따로 처리해야 해요.
코드에서 읽히는 한계도 있어요. `NSLock`은 캐시를 조회할 때와 저장할 때에만 걸리고 CoreText 호출은 잠금 밖에 있어요. 그래서 같은 URL을 동시에 요청하면 두 호출이 모두 등록에 들어갈 수 있어요. 또 `success`가 false인데 `error`가 nil이면 예외를 던지지 않고 다음 줄로 넘어가요. 이 두 가지는 실행해서 재현한 것이 아니고 코드에서 읽은 것이에요.
## 내려받은 폰트는 파일의 수명을 정해야 해요
임시 위치의 파일을 등록하면, 파일이 지워진 뒤에도 시스템에 참조가 남을 위험이 있어요. 그래서 앱이 관리하는 위치로 옮긴 뒤에 등록해요.
`Application Support` 아래에 `RuntimeFonts` 디렉터리를 만드는 함수예요.
```swift
func fontStorageDirectory() throws -> URL {
let baseURL = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = baseURL.appendingPathComponent("RuntimeFonts", isDirectory: true)
if !FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
}
return directory
}
```
내려받은 파일을 그 디렉터리로 복사하고 registry에 등록하는 함수예요. 파일 이름은 `temporaryURL`의 마지막 경로 요소를 그대로 쓰고, 같은 이름의 파일이 있으면 지운 뒤 복사해요.
```swift
func installDownloadedFont(from temporaryURL: URL) throws -> [String] {
let directory = try fontStorageDirectory()
let destinationURL = directory.appendingPathComponent(temporaryURL.lastPathComponent)
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: temporaryURL, to: destinationURL)
return try RuntimeFontRegistry.shared.registerFont(at: destinationURL)
}
```
이 설치 예제는 기존 파일을 지우기 전에 해제하지 않아요. 아래에서 권하는 «지우기 전에 해제»와 맞지 않는 부분이에요. 이미 등록한 URL의 파일을 바꿔 넣으면 registry가 예전에 캐시한 이름을 돌려줄 수도 있어요. 이것도 코드에서 읽은 한계예요. descriptor 검사는 복사한 뒤에 registry 안에서 하고, 다운로드의 무결성, 라이선스, 이름 충돌을 검사하는 코드는 없어요.
운영에서 정해야 할 것은 이 예제 밖에 있어요. 파일을 언제 지울지, 다시 실행했을 때 어떻게 등록할지, 다운로드가 실패했을 때의 fallback과 사용자에게 보여 줄 것, 서버 응답과 배포, 캐시 무효화, 앱 버전과의 관계, 라이선스 검토예요.
## macOS에서 실행해 본 결과
이 글의 Swift 코드를 그대로 이어 붙여서 컴파일하고 실행해 봤어요. macOS 27.0.1에서 Swift 6.4로 실행했어요. CoreText는 macOS에도 있어서 iOS 기기 없이 등록과 해제를 확인할 수 있었어요. 다만 UIKit이 없는 환경이라 `UIFont(name:size:)` 대신 `CTFontCreateWithName`으로 이름을 확인했어요. 폰트는 이 Mac에 있던 `PretendardStd-Bold.otf`를 임시 폴더에 복사해서 썼고, 시스템에는 설치돼 있지 않은 폰트였어요.
코드는 경고 없이 컴파일됐어요. 등록 전후에 이름으로 폰트를 찾을 수 있는지를 보는 호출 코드를 덧붙여 실행한 출력이에요.
```text
Font PostScript names: ["PretendardStd-Bold"]
before register: discoverable=false
registry.registerFont: ["PretendardStd-Bold"]
after register : discoverable=true
CTFontCreateWithName -> PretendardStd-Bold
registry.registerFont again (cached): ["PretendardStd-Bold"]
plain registerFont again: error com.apple.CoreText.CTFontManagerErrorDomain code=105
after unregister: discoverable=false
unregister again: error com.apple.CoreText.CTFontManagerErrorDomain code=201
missing file: fileNotFound()
```
읽을 수 있는 것은 넷이에요.
- 등록 전에는 이름으로 찾을 수 없었고, 등록한 뒤에는 찾을 수 있었고, 해제한 뒤에는 다시 찾을 수 없었어요.
- registry는 두 번째 등록에서 캐시한 이름을 돌려줬어요. registry를 거치지 않고 같은 URL을 다시 등록하면 code 105 오류가 났어요. CoreText의 `kCTFontManagerErrorAlreadyRegistered`에 해당하는 값이에요.
- 해제를 두 번 하면 code 201(`kCTFontManagerErrorNotRegistered`) 오류가 났어요.
- 없는 파일은 CoreText를 부르기 전에 `fileNotFound`로 걸러졌어요.
이미 시스템에 설치된 폰트 파일로 같은 실행을 하면 등록 전과 해제 뒤에도 이름으로 찾을 수 있었어요. 다른 경로에 같은 폰트가 남아 있기 때문이에요. 등록 여부를 이름으로 판단하는 코드는 이런 경우를 구분하지 못해요. `installDownloadedFont`는 호출해 보지 않았어요.
## 해제는 검색에서 빼는 것까지예요
[CTFontManagerUnregisterFontsForURL](https://developer.apple.com/documentation/coretext/ctfontmanagerunregisterfontsforurl%28_%3A_%3A_%3A%29)을 호출하면 그 폰트는 descriptor 검색에서 빠져요. 이미 만들어진 `UIFont`, `CTFont`, `NSAttributedString`, SwiftUI 뷰까지 곧바로 정리된다고 해석하면 안 돼요.
수명은 폰트의 쓰임에 따라 정해요. 실행 내내 필요한 폰트는 등록한 채로 둬요. 임시로 내려받은 리소스는 누가 소유하는지 정하고 그쪽에서 해제해요. 파일을 지우기 전에 해제를 시도하고, 해제에 실패하면 삭제를 미뤄요. 등록과 해제의 scope는 같아야 하므로 `.process`로 등록했으면 `.process`로 해제해요.
[CTFontManagerRegisterGraphicsFont](https://developer.apple.com/documentation/coretext/ctfontmanagerregistergraphicsfont%28_%3A_%3A%29)는 `CGFont` 객체를 등록하는 API예요. 파일에 든 폰트에는 URL 등록을 쓰라고 Apple 문서가 안내해요. 다시 확인한 문서(2026년 10월 4일)에는 이 API가 iOS 18.0과 macOS 15.0부터 폐기(deprecated)된 것으로 표시돼 있고, 대신 `CTFontManagerCreateFontDescriptorsFromData`나 `CTFontManagerRegisterFontsForURL`을 쓰라고 적혀 있어요. 파일 URL을 기준으로 보관, 등록, 해제, 캐시를 한 묶음으로 설계하는 편이 다루기 쉽다는 것은 제 판단이에요.
`.process`의 수명, URL 등록과 `CGFont` 등록 가운데 무엇을 고를지, 해제가 이미 만들어진 객체에 미치는 영향은 SDK와 OS 버전에 따라 확인이 필요해요. 오류 코드별 분기, 충돌 처리, 안전한 파일 교체와 캐시 무효화, 재시작 뒤의 등록, fallback 정책은 이 글에서 설명만 했고 예제로 구현하지 않았어요. 예제에 있는 것은 URL별 캐시와 단순한 파일 교체까지예요.
---
# Swift Package Registry를 도입하기 전에 볼 것
> Swift 의존성을 Git 주소가 아니라 registry의 ID로 받으면 받아 오는 경로가 달라져요. 요청 순서, 설정 파일, Git과 registry가 섞인 의존 그래프의 위험, mirror와의 차이, 인증과 서명을 정리하고, 팀이 도입을 판단할 때 볼 항목을 적어요.
- Canonical: https://jaemyeong.com/ko/blog/swift-package-registry/
- Published: 2026.06.18
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Swift, #SwiftPM, #Package Registry, #iOS
Swift 의존성은 보통 Git 주소로 선언해요. 같은 의존성을 registry의 식별자로도 선언할 수 있어요.
Git 주소로 선언한 경우예요.
```swift
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0")
```
registry ID로 선언한 경우예요.
```swift
.package(id: "apple.swift-log", from: "1.5.0")
```
한 줄 차이지만 받아 오는 경로가 달라지고, 그에 따라 CI, 재현성, 사내 유통, 보안, Xcode 설정이 영향을 받아요. iOS 팀이 이 방식을 고를지 판단할 때 볼 것을 정리했어요. 특정 프로젝트에 적용한 기록은 아니고, resolve 시간을 잰 결과도 이 글에 없어요.
## Git으로 받을 때의 부담
Git 주소로 받으면 처음 빌드할 때 저장소를 clone하고 tag를 보고 버전을 골라요. 릴리스 하나만 필요해도 Git 이력을 함께 다뤄요. 저장소가 크거나 의존 관계가 복잡하면 resolve가 느려질 수 있어요.
재현성에도 위험이 있어요. tag가 가리키는 대상은 바뀔 수 있어요. 저장소가 지워지거나 옮겨지면 `Package.resolved`를 갖고 있어도 빌드를 보장할 수 없어요.
망이 제한된 환경도 있어요. `github.com`이 막혀 있으면 clone과 CI가 실패할 수 있어요. 그런 곳에서는 보안팀이 보통 승인된 Artifactory, Git mirror, artifact 저장소를 거치라고 요구해요. registry를 쓰면 GitHub에 접속하지 않고 공급하는 구성을 생각할 수 있어요.
## registry가 패키지를 주는 순서
registry의 식별자는 `scope.package-name` 꼴이에요. `mona.LinkedList`라면 scope가 `mona`이고 이름이 `LinkedList`예요.
```swift
.package(id: "mona.LinkedList", .upToNextMajor(from: "1.0.0"))
```
[서비스 규격](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md)에 따른 주요 요청은 세 단계예요. 먼저 `/{scope}/{name}`에서 릴리스 목록을 받고, 그 버전의 `Package.swift`를 받고, `/{scope}/{name}/{version}.zip`에서 archive를 받아요. checksum과 서명을 확인할 때에는 릴리스의 metadata도 조회해요([사용 문서](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md)). HTTP로 archive를 내려 주는 방식이라서, 릴리스를 바꾸지 않는 것으로 운영할 수 있고 CDN이나 내부 저장소를 활용할 수 있어요. 소스 관리와 배포가 분리돼요. 이 배경은 [SE-0292](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0292-package-registry-service.md)에 있어요.
개발하는 저장소를 옮길 필요는 없어요. GitHub, GitLab, GitHub Enterprise Server, 사내 Git에서 계속 개발할 수 있고, registry는 릴리스를 전달하는 역할만 해요.
## 설정이 놓이는 자리
기본 registry는 CLI로 지정해요.
```bash
swift package-registry set https://packages.example.com
```
설정은 프로젝트 범위와 사용자 범위에 저장돼요. 프로젝트 범위의 파일 위치예요.
```text
.swiftpm/configuration/registries.json
```
사용자 범위의 파일 위치예요.
```text
~/.swiftpm/configuration/registries.json
```
파일에는 기본 URL과 형식 버전이 들어가요.
```json
{
"registries": {
"[default]": {
"url": "https://packages.example.com"
}
},
"version": 1
}
```
Swift 6.4의 도움말에서 `set`은 URL 하나를 받고 `--global`과 `--scope` 옵션이 있어요. 2026년 10월 4일에 `--help`로만 확인한 것이고, registry 서버에 접속하거나 설정을 바꿔 보지는 않았어요.
패키지 프로젝트라면 `Package.swift`에 ID로 선언하면 돼요. iOS 앱은 사정이 달라요. Xcode 앱 프로젝트에는 보통 `Package.swift`가 없고, `project.pbxproj`, workspace, `Package.resolved`로 의존성을 관리해요. CLI로 넣은 설정이 Xcode에 그대로 적용된다고 추정하지 말고, 설정이 실제로 어디에 저장되는지 확인해야 해요.
팀에서는 registry URL 설정을 공유하되 access token은 커밋하지 않아요. workspace나 `.xcodeproj`에서 설정 위치를 찾아 주고 `mirrors.json`을 만들어 주는 CLI나 GUI 도구를 두는 것도 방법이에요. 이것은 제 제안이고, 만들어 본 사례는 이 글에 없어요.
## Git과 registry가 섞이면
가장 조심할 것은 같은 패키지가 두 경로로 들어오는 경우예요. 직접 의존성은 registry ID로 선언했는데, 다른 패키지가 같은 것을 Git 주소로 끌고 올 수 있어요.
직접 의존성이에요.
```swift
.package(id: "apple.swift-log", from: "1.5.0")
```
간접 의존성으로 들어오는 선언이에요.
```swift
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0")
```
origin이 다르면 identity가 따로 잡힐 수 있어요. 그러면 module이나 product가 중복되고 symbol이 충돌할 수 있어요.
이 둘을 이어 주는 수단이 `/identifiers?url=`이에요. Git 주소에 대응하는 registry ID를 조회해요. URL과 ID의 대응이 정확해야 해요. 한 패키지의 origin은 하나로 맞추는 것이 좋고, 선언을 ID로 바꾸는 동안에도 Git이나 mirror 경로를 유지하면서 단계별로 옮기기를 제안해요.
## mirror는 다른 것이에요
mirror는 registry와 목적이 달라요. [SE-0219](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0219-package-manager-dependency-mirroring.md)의 mirror는 `.package(url:)` 선언을 그대로 두고, 가져오는 주소만 다른 Git으로 바꿔요.
원래 주소와 내부 Git 주소의 대응을 적은 예시예요. `git.example.com` 자리에 사내 Git 서버의 주소가 들어가요.
```json
{
"object": [
{
"original": "https://github.com/apple/swift-log.git",
"mirror": "https://git.example.com/mirrors/apple/swift-log.git"
}
],
"version": 1
}
```
registry는 ID와 archive로 받는 경로이고, mirror는 Git을 다른 Git으로 바꾸는 경로예요. 망이 제한된 곳에서는 내부 mirror를 먼저 적용하고 그다음에 registry로 넘어가는 순서를 제안해요.
## 인증과 서명
비공개 registry에는 인증이 필요하고, `login` 명령으로 인증 정보를 저장할 수 있어요([SE-0378](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0378-package-registry-auth.md)).
```bash
swift package-registry login https://packages.example.com --token "$TOKEN"
```
인증은 접근 권한에 관한 것이에요. 배포물이 진짜인지는 서명이 다뤄요. 서명은 발행자가 그 키를 갖고 있다는 것과 배포물이 바뀌지 않았다는 것의 증거예요. archive 응답에 실린 signature header 두 개(`X-Swift-Package-Signature-Format`, `X-Swift-Package-Signature`)를 확인하고, 서명과 인증서 체인을 검사해요. 서명이 검증됐다고 해서 그 발행자를 믿어도 된다는 뜻은 아니에요. 어떤 CA를 믿을지와 어떤 인증서가 어떤 scope를 발행할 수 있는지는 조직이 따로 정해요.
서명하려면 code signing용 X.509 인증서와 private key가 있어야 해요. macOS에서는 Keychain의 identity를 쓸 수 있고, CI에서는 키 파일과 인증서 체인 파일을 넘기는 방식도 가능해요([SE-0391](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0391-package-registry-publish.md)). 아래 명령이 archive를 올리는 publish endpoint는 [SE-0321](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0321-package-registry-publish.md)에 정의돼 있어요.
```bash
swift package-registry publish mycompany.design-system 1.2.0 \
--url https://packages.example.com \
--private-key-path ./private-key.pkcs8.der \
--cert-chain-paths ./leaf-cert.der ./intermediate.der ./root.der
```
`login`의 `--token`과 `publish`의 `--url`, `--private-key-path`, `--cert-chain-paths`가 Swift 6.4의 도움말에 있는 것까지는 확인했어요. 명령을 실제 서버에 실행하지는 않았어요. 이전 판에서 CI용으로 든 `SWIFTPM_REGISTRY_TOKEN`이라는 환경 변수는 도움말에 나오지 않았어요. main 브랜치의 [사용 문서](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md)에는 이 변수가 인증 수단으로 적혀 있어요(2026년 10월 4일 확인). Swift 6.4가 이 변수를 지원하는지는 확인하지 못했으므로, 쓰기 전에 사용하는 toolchain에서 확인해야 해요.
조직 차원에서는 인증서마다 허용하는 scope와 package를 관리하고, 내부 CA를 운영한다면 SwiftPM의 trust root 설정을 팀에 배포해야 해요.
## 도입을 판단할 때
의존성이 적고 GitHub에 자유롭게 접속할 수 있다면 얻는 것이 크지 않을 수 있어요. 검토할 계기는 이런 것들이에요. resolve가 느려요. 사내 패키지가 많아요. 외부 접속이 제한돼 있어요. JFrog, Nexus, Artifactory 같은 저장소를 이미 운영해요. 고객사에 비공개 SDK를 전달해요. checksum, 서명, audit log에 대한 요구가 있어요.
확인은 아래 순서로 하기를 제안해요.
1. 의존성 목록을 뽑아요.
2. `Package.resolved`와 Xcode에 적힌 Git 주소를 봐요.
3. CI에서 resolve에 걸리는 시간을 재요.
4. GitHub, GitHubusercontent, 외부 binary URL이 허용되는지 확인해요.
5. registry로 받을 것과 mirror로 받을 것을 나눠요.
6. 같은 패키지가 Git 주소와 registry ID 두 경로로 함께 들어오는 의존 그래프가 없는지 점검해요.
7. workspace와 `.xcodeproj`에서 설정이 놓이는 위치를 확인해요.
8. token과 CI 변수를 어떻게 다룰지 정해요.
9. 서명이 필요한지와 어떤 CA를 믿을지 정해요.
10. 사내 저장소와 어떻게 연결할지 봐요.
패키지 승인, registry와 mirror 가운데 무엇을 쓸지, Xcode 설정의 공유, CI에서 허용할 외부 host는 팀이나 플랫폼 조직이 정책으로 정할 일이에요.
요청 순서, 설정 파일, 인증, 서명은 Swift Evolution 제안과 SwiftPM 문서에 있는 내용이에요. 도입 순서와 점검 목록은 그 위에 세운 제 해석이고, 적용해서 확인한 결과가 아니에요. Xcode 버전마다 설정이 놓이는 위치와 호환성은 확인하지 않았어요. 섞인 그래프의 실제 동작이나 registry로 바꾼 앞뒤의 속도를 확인한 결과도 이 글에 없어요. 문서 링크는 main 브랜치를 가리키므로 내용이 바뀔 수 있어요.
---
# WWDC26에서 iOS 개발자가 볼 다섯 가지 변화
> Apple이 WWDC26에서 발표한 내용 가운데 iOS 개발자에게 닿는 것을 App Intents, 모델 실행, Xcode 27, 화면, 구독 운영으로 나눠 정리해요. 발표된 사실은 Apple의 공식 페이지에서 확인했고, 제 관점은 따로 적어요.
- Canonical: https://jaemyeong.com/ko/blog/wwdc26-ios-developer-changes/
- Published: 2026.06.18
- Updated: 2026.10.04
- Category: IT/뉴스
- Tags: #WWDC26, #iOS, #App Intents, #Apple Intelligence
Apple은 2026년 6월 8일 WWDC26에서 iOS 27, iPadOS 27, macOS 27, watchOS 27, visionOS 27, tvOS 27을 발표했어요([Apple Newsroom](https://www.apple.com/newsroom/2026/06/apple-unveils-next-generation-of-apple-intelligence-siri-ai-and-more/)). 같은 발표문에 따르면 개발자용 베타는 발표 당일부터 제공됐고, 정식 버전은 가을에 무료 업데이트로 나와요.
발표 가운데 iOS 앱을 만드는 사람에게 닿는 것을 다섯 갈래로 나눠 정리해요. 발표된 사실은 Apple의 공식 페이지 여섯 곳에서 2026년 10월 4일에 다시 확인한 내용이고, 절마다 뒤쪽에 적은 것은 제 관점이에요. 베타에서 직접 구현하거나 측정한 결과는 이 글에 없어요.
## 앱의 내용과 동작을 시스템에 공개하기
[WWDC26 iOS 안내](https://developer.apple.com/wwdc26/guides/ios/)에 따르면 Siri는 App Intents framework를 통해 앱의 내용과 동작에 자연어로 닿아요. Entity schema는 앱의 내용을 Spotlight의 semantic index에 올려요. Intent schema를 쓰면 정해진 문구를 따로 정의하지 않아도 사람이 그 내용에 대한 동작을 자연어로 요청할 수 있어요. 새 View Annotations API는 view를 entity에 대응시켜서, 화면에 보이는 것을 대화로 가리키고 다룰 수 있게 해요. App Intents Testing framework는 UI 자동화 없이 실제 시스템 경로로 연동을 검증하게 해 줘요.
여기부터는 제 관점이에요. 앱 바깥의 Siri와 Spotlight에서 기능에 닿는 길이 넓어지므로, App Intents 대응은 부가 편의가 아니라 처음 설계에 넣을 과제라고 봐요. 나중에 덧붙이기는 더 어려울 것으로 예상해요. 어떤 entity와 action을 공개할지부터 정하기를 권해요. 스포츠 앱이라면 경기, 팀, 리그, 일정이 후보이고, 커머스 앱이라면 상품, 주문, 장바구니, 배송 상태가 후보예요. 이 예시는 제가 든 것이고 Apple의 안내에 있는 것이 아니에요.
## 모델을 어디에서 실행할지
같은 iOS 안내는 Foundation Models framework를 Apple Intelligence가 쓰는 기기 내 모델에 접근하는 Swift API로 소개해요. 이제 Apple Foundation Models뿐 아니라 Claude와 Gemini 같은 클라우드 모델, 그리고 Language Model protocol을 따르는 다른 provider와도 쓸 수 있다고 적혀 있어요. Multimodal prompt로 이미지와 텍스트를 함께 넘길 수 있고, Dynamic Profiles로 세션을 이어 가면서 모델, 도구, instruction을 바꿀 수 있어요. Evaluations framework는 AI 기능이 여러 조건에서 맞게 동작하는지 검증하는 도구예요.
Core AI는 자기 모델을 기기에서 실행하기 위한 새 framework예요. OS에 내장돼 있고 Apple Silicon에 맞춰져 있으며, Swift API로 모델을 불러와 실행해요. 안내는 ahead-of-time compilation, zero-copy data path, stateful execution을 성능 요소로 들어요. Private Cloud Compute에 대해서는 조건이 붙어 있어요. App Store Small Business Program에 등록돼 있고 앱의 첫 다운로드가 200만 건 미만이면, Private Cloud Compute에서 도는 차세대 Apple Foundation Models를 클라우드 API 비용 없이 쓸 수 있다고 적혀 있어요.
제가 읽기로는 둘의 역할이 달라요. Foundation Models는 언어 모델과 멀티모달 모델을 앱에 잇는 쪽이고, Core AI는 가지고 있는 모델을 Apple Silicon에서 돌리는 쪽이에요. 요약, 분류, 추천, 입력 보정은 기기 안에서 처리하는 것을 먼저 검토하고, 큰 추론과 생성은 Private Cloud Compute나 외부 provider를 검토하기를 권해요. 민감한 정보는 밖으로 보내기 전에 기기 안에서 처리할 수 있는지 봐요. 비교할 항목은 비용, 지연, 개인정보예요. 테스트와 평가를 자동화할 수 있는지도 함께 봐요.
## Xcode 27과 에이전트
[Platforms State of the Union 요약](https://developer.apple.com/news/?id=lvart8mq)에 따르면 Xcode 27은 Apple silicon 전용이에요. 프로젝트를 더 빨리 열고, 설정이 iCloud로 동기화되고, toolbar를 사용자가 바꿀 수 있어요. 새 Device Hub는 Simulator를 대체하면서 가상 기기와 실제 기기를 한곳에 모아요. 같은 글은 Xcode의 에이전트가 테스트를 실행하고, Playground에서 시도해 보고, Simulator에서 앱을 실행하고, 이슈를 고치고, 앱을 현지화할 수 있다고 적어요. 플러그인으로 skills와 MCP tools를 더하고, Agent Client Protocol로 다른 에이전트를 붙일 수 있어요. [WWDC26 Xcode 안내](https://developer.apple.com/wwdc26/guides/xcode/)는 Device Hub, Instruments의 개선, 에이전트를 쓰는 현지화를 다뤄요.
Device Hub가 Simulator를 대체한다는 문장과 에이전트가 Simulator에서 앱을 실행한다는 문장이 같은 글에 함께 있어요. 관리 화면이 바뀐 것인지 실행 환경까지 바뀐 것인지는 이 글만으로는 알 수 없었어요.
Intel Mac으로 개발하거나 CI 빌드를 돌리고 있다면 옮길 계획이 필요하다고 봐요. 에이전트는 작은 변경의 검증, 테스트 오류의 분석, string catalog 정리에 쓸모가 있을 것으로 예상해요. 그래도 생성된 코드는 초안으로 다루고 diff를 꼭 검토하기를 권해요. 팀이라면 에이전트를 어디까지 쓸지와 리뷰 규칙을 먼저 정해 두는 것이 좋아요. 아키텍처, 인증, 결제, 저장소, 동기화에 닿는 변경은 더 엄격하게 봐요. 구조와 품질에 대한 판단은 개발자가 계속 맡아야 한다고 생각해요.
## 화면의 재질과 크기
[iOS 27 소개 페이지](https://www.apple.com/os/ios/)에 따르면 Liquid Glass는 굴절이 더 고르고 대비가 좋아졌고, 앱 아이콘이 더 선명해졌어요. 사용자는 슬라이더로 Liquid Glass를 ultraclear에서 fully tinted까지 조절할 수 있어요. 성능 수치도 적혀 있어요. 앱 실행은 최대 30%, Photos에서 새 사진을 불러오는 것은 최대 70%, AirDrop 전송은 최대 80% 빨라졌다고 해요. 이 숫자는 Apple이 특정 기기와 조건에서 측정한 상한이고, 각주에 측정 조건이 적혀 있어요. 모든 앱이 그만큼 빨라진다는 뜻이 아니에요.
State of the Union 요약은 iOS 앱의 크기를 바꿀 수 있게 됐다고 적어요. iPad에서, 그리고 Mac의 iPhone Mirroring에서 더 큰 화면을 쓸 수 있어요.
직접 구현한 navigation bar, tab bar, toolbar, blur 배경, 반투명 overlay, floating button은 다시 점검할 대상이라고 봐요. Dynamic Type, 다크 모드, Reduce Transparency, 고대비 설정에서 읽을 수 있는지 확인해요. iPhone 크기를 가정한 레이아웃은 크기가 바뀔 때 어긋날 수 있으므로, 실제 기기에서 터치 범위와 스크롤, 전환까지 보기를 권해요.
## 구독 상품과 심사 제출
[WWDC26 App Store 안내](https://developer.apple.com/wwdc26/guides/app-store/)에 따르면 App Store Connect에서 In-App Purchase 여러 개를 구독까지 포함해 한 번의 제출로 묶을 수 있어요. In-App Events, custom product pages, product page optimization tests 같은 다른 심사 항목과도 함께 낼 수 있어요.
Retention Messaging은 구독자에게 구독의 가치를 알리는 메시지를 보여 주는 기능이에요. Apple은 해지 흐름에 마찰을 더하지 않는다고 설명하고, 특별 제안도 함께 보여 줄 수 있다고 적어요. 제공 시점은 올가을로 적혀 있어요. Subscription Bundles는 자동 갱신 구독 여러 개를 구독 하나로 살 수 있게 하고, Suites는 따로 팔지 않는 구독들을 한 세트로 제공해요. 두 기능을 신청하는 방법은 여름에 더 안내한다고 적혀 있어요. Group Purchases는 구독자 한 사람이 여러 자리를 한 번에 사서 다른 사람을 초대하는 방식이에요. Volume Purchasing은 Apple School Manager와 Apple Business를 통해 기업과 교육 기관의 구매자에게 구독을 제공하는 방식이에요.
구독을 운영한다면 StoreKit 코드만 볼 일이 아니라고 생각해요. 상품 모델, App Store Connect의 운영, 심사, 구매 뒤의 권한, 해지 절차를 함께 검토해야 해요. 가격과 묶음, 이탈 대응, 조직 판매는 정책과 같이 정할 문제예요.
이 글이 기대는 것은 Apple의 발표문과 안내 페이지이고, 2026년 10월 4일의 내용이에요. 정식 버전에서 API의 이름이나 제공 조건이 달라질 수 있어요. 지역과 기기에 따른 제공 조건, 베타 번호는 여기서 다루지 않았어요. 제 관점으로 적은 부분을 구현해서 확인한 결과는 이 글에 없어요.