전체 글 (186)
2026-08-26 04:25:22

컴퓨터 일반

다음 중 컴퓨터 운영체제의 운영 방식에 대한 설명으로 옳지 않은 것은?

  1. 일괄 처리는 컴퓨터에 입력하는 데이터를 일정량 또는 일정 시간 동안 모았다가 한꺼번에 처리하는 방식이다.
  2. 실시간 처리는 오프라인에서 처리할 데이터가 입력될 때 마다 즉시 처리하는 방식이다.
  3. 시분할 시스템은 한 대의 시스템을 여러 사용자가 동시에 사용하는 방식이다.
  4. 분산 처리 시스템은 여러 대의 컴퓨터들에 의해 작업한 결과를 통신망을 이용하여 상호 교환할 수 있도록 연결되어 있는 방식이다.

정답 : 2. 실시간 처리는 오프라인에서 처리할 데이터가 입력될 때 마다 즉시 처리하는 방식이다.

해설 : 실시간 처리는 온라인에서 처리할 데이터가 입력될 때 마다 즉시 처리하는 방식입니다.

 

다음 중 한글 Window 10 바로 가기 아이콘의 [속성] 대화상자에 대한 설명으로 옳지 않은 것은?

  1. 대상 파일이나 대상 형식, 대상 위치 등에 관한 연결된 항목의 정보를 확인할 수 있다.
  2. 연결된 항목을 바로 열 수 있는 바로 가기 키를 지정할 수 있다.
  3. 연결된 항목의 디스크 할당 크기를 확인할 수 있다.
  4. 바로 가기 아이콘을 만든 날짜와 수정한 날짜, 액세스한 날짜 등을 확인할 수 있다.

정답 : 3. 연결된 항목의 디스크 할당 크기를 확인할 수 있다.

해설 : '속성' 대화상자의 '일반' 탭에 있는 '디스크 할당 크기'는 바로가기 아이콘의 크기이다. 연결된 항목의 디스크 할당 크기는 해당 항목의 '속성' 대화상자에서 확인할 수 있다.

 

다음 중 컴퓨터에서 사용하는 자료의 표현에 관한 설명으로 옳지 않은 것은?

  1. 실수형 데이터는 정해진 크기에 부호(1bit)와 가수부(7bit)로 구분하여 표현한다.
  2. 2진 정수 데이터는 실수 데이터 보다 표현할 수 있는 범위가 작으며 연산 속도는 빠르다.
  3. 숫자 데이터 표현 중 10진 연산을 위하여 "팩(Pack) 과 언팩(Unpack)" 표현 방식이 사용된다.
  4. 컴퓨터에서 뺄셈을 수행하기 위해서는 보수와 덧셈 연산을 이용한다.

정답 : 1. 실수형 데이터는 정해진 크기에 부호(1bit)와 가수부(7bit)로 구분하여 표현한다.

해설 : 실수형 데이터는 정해진 크기에 부호(1bit), 지수부(7bit), 가수부(소수부)로 구분하여 표현한다.

 

다음 중 한글 Window 10에서 하드디스크의 용량 부족 문제가 발행하였을 때의 해결 방법으로 적절하지 않은 것은?

  1. [휴지통 비우기]를 수행한다.
  2. [디스크 정리]를 통해 임시 파일들을 삭제한다.
  3. 사용하지 않는 응용 프로그램을 삭제한다.
  4. 드라이브 조각 모음 및 최적화를 수행한다.

정답 : 4. 드라이브 조각 모음 및 최적화를 수행한다.

해설 : '드라이브 조각 모음 및 최적화'는 드라이브의 접근 속도를 향상시키기 위해 드라이브를 최적화하는 기능으로, 하드디스크의 용량 증가와는 관계가 없습다.

 

다음 중 컴퓨터의 CMOS 에서 설정할 수 있는 항목으로 옳지 않은 것은?

  1. 시스템 날짜와 시간
  2. 칩셋 설정
  3. 부팅 순서
  4. Windows 로그인 암호 변경

정답 : 4. windows 로그인 암호 변경

해설 : CMOS에서 설정할 수 있는 항목으로는 시스템의 날짜와 시간, 하드디스크 타입(Type), 부팅 순서, 칩셋 설정, 전원 관리, PnP 설정, 시스템 암호 설정, Anti-Virus 기능 등이 있다.

 

다음 중 정보 보안을 위한 비밀키 안호화 기법에 대한 설명으로 옳지 않은 것은?

  1. 비밀키 암호화 기법의 안전성은 키의 길이 및 키의 비밀성 유지 여부에 영향을 많이 받는다.
  2. 암호화와 복호화 시 사용하는 키가 동일한 암호화 기법이다.
  3. 복잡한 알고리즘으로 인해 암호화와 복호화 속도가 느리다.
  4. 사용자가 증가할 경우 상대적으로 관리해야 할 키의 수가 많아진다.

정답 : 3. 복잡한 알고리즘으로 인해 암호화와 복호화 속도가 느리다.

해설 : 비밀키 암호화 기법은 알고리즘이 단순하여 암호화나 복호화 속도가 빠르다. 복잡한 알고리즘으로 인해 암호화와 복호화 속도가 느린 기법은 공개키 암호화 기법이다.


스프레드시트 일반

다음 중 아래 시트에서 부서별 인원수[H3:H6]를 구하기 위하여 [H3] 셀에 입력되는 배열 수식으로 옳지 않은 것은?

  1. {=SUM($C$3:$C$9=G3)*1)}
  2. {=COUNT(($C$3:$C$9=G3)*1)}
  3. {=SUM(IF($C$3:$C$9=G3, 1))}
  4. {=COUNT(IF($C$3:$C$9=G3, 1))}

정답  : 2. {=COUNT(($C$3:$C$9=G3)*1)}

해설 : 조건이 하나일 때 배열 수식을 이용하여 개수를 구하는 방법은 다음의 3가지 방법이 있다.

  • {=SUM((조건) * 1)}
  • {=SUM(IF(조건, 1) )}
  • {=COUNT(IF(조건, 1) )}

다음 중 연속적인 위치에 데이터가 입력되어 있는 여러개의 셀을 범위로 설정한 후, 셀 병합을 실행하였을 때의 결과에 대한 설명으로 옳은 것은?

  1. 기존에 입력되어 있던 데이터들이 한 셀에 모두 표시된다.
  2. 데이터가 들어 있는 여러 셀은 셀 보호가 자동으로 설정되어 병합할 수 없다.
  3. 가장 아래쪽 또는 오른쪽의 셀 데이터만 남고 나머지 셀 데이터는 모두 지워진다.
  4. 가장 위쪽 또는 왼쪽의 셀 데이터만 남고 나머지 셀 데이터는 모두 지워진다.

정답: 4번. 가장 위쪽 또는 왼쪽의 셀 데이터만 남고 나머지 셀 데이터는 모두 지워진다.

해설:

  A B
1 1 2
2 3 4

  A B
1 1
2

 

다음 중 피벗 테이블 보고서와 피벗 차트 보고서에 대한 설명으로 옳지 않은 것은?

  1. 피벗 테이블 보고서에서는 값 영역에 표시된 데이터 일부를 삭제하거나 추가할 수 없다.
  2. 피벗 차트 보고서를 만들 때마다 동일한 데이터로 관련된 피벗 테이블 보고서가 자동으로 생성된다.
  3. 피벗 차트 보고서는 분산형, 주식형, 거품형 등 다양한 차트 종류로 변경할 수 있다.
  4. 행 또는 열 레이블에서의 데이터 정렬은 수동(항목을 끌어 다시 정렬), 오름차순, 내림차순 중 선택할 수 있다.

정답 : 3번. 피벗 차트 보고서는 분산형, 주식형, 거품형 등 다양한 차트 종류로 변경할 수 있다.

해설 : 피벗 차트 보고서는 분산형, 거품형, 주식형 차트를 제외한 다른 차트로 변경할 수 있다.

 

VBA 코드를 실행했을 때 표시되는 메시지 박스

MsgBox는 MsgBox(메시지 내용, 버튼 종류+아이콘, 제목 표시줄에 표시될 글자) 형식으로 사용된다.

  • 버튼 종류 : 생략하면 <확인> 단추만 표시되고, 1이면 <확인>과 <취소> 단추가 표시됨
  • 아이콘 : 48이면 '경고' 아이콘, 64면 '정보' 아이콘(i) 이 표시됨

 

다음 중 선택된 차트의 페이지 설정에 관한 설명으로 옳지 않은 것은?

  1. 인쇄 품질을 '간단하게 인쇄' 또는 '흑백으로 인쇄'를 선택하여 출력할 수 있다.
  2. 머리글/바닥글을 이용하여 일반 시트 인쇄 방법과 동일하게 머리글 및 바닥글을 인쇄할 수 있다.
  3. 차트의 일부분을 인쇄하기 위해 인쇄 영역을 지정할 수 없다.
  4. 차트를 축소하여 인쇄하기 위해 확대/축소 배율을 지정할 수 있다.

정답 : 4. 차트를 축소하여 인쇄하기 위해 확대/축소 배율을 지정할 수 있다.

해설 : 차트는 '확대/축소 배열'을 지정하여 인쇄할 수 없다.

 

숫자 -246000을 입력한 후 아래의 표시 형식을 적용했을 때 표시되는 결과로 옳은 것은?

#0.0"천원";(#0.0,"천원");0.0;@"님"
  1. 246.0천원
  2. 246,000
  3. (-246.0천원)
  4. (246.0천원)

정답 : 4.(246.0천원)

해설 : 지문에 제시된 내용은 사용자 지정 표시 형식이다. 숫자 -246000을 입력한 후 지문의 표시 형식을 지정하면 '-246000'이 음수이므로 '(#0.0, "천원")' 서식이 적용되어 '(246.0천원)'으로 표시된다.

 

다음 중 공유 통합 문서에 대한 설명으로 옳지 않은 것은?

  1. 여러 사용자가 동시에 동일한 셀을 변경하려면 충돌이 발생한다.
  2. 통합 문서를 공유한 후 셀을 삽입하거나 삭제할 수 있다.
  3. 통합 문서를 공유한 후 여러 셀을 하나로 병합할 수 있다.
  4. 공유 통합 문서를 네트워크 위치에 복사해도 다른 통합 문서나 문서의 연결은 그대로 유지된다.

정답 : 3. 통합 문서를 공유한 후 여러 셀을 하나로 병합할 수 있다.

해설 : 공유 통합 문서에서는 셀을 삽입하거나 삭제할 수는 있어도 병합할 수는 없다.

 

다음 중 아래 시트에서 <변경 전> 내용을 <변경 후>와 같이 변경하는 수식으로 옳은 것은?

  A
1 <변경 전>
2 서울시 도봉구 459 남위 36 북위 36
3 <변경 후>
4 서울시 도봉구 459 남위 136 북위 36
  1. =SUBTITUTE(A2, "136", "36", 1)
  2. =SUBTITUTE(A2, "136", "36", 2)
  3. =SUBTITUTE(A2, "36", "136", 1)
  4. =SUBTITUTE(A2, "36", "136", 2)

정답 : 3번

해설 : SUBTITUTE(텍스트, 인수 1, 인수 2, n 번째) 함수는 텍스트에서 인수1을 찾아, n번째에 있는 인수1을 인수2로 변경한다. <변경 전>과 <변경 후>를 비교하면 [A2] 셀에 입력된 텍스트 중 첫 번째에 있는 "36"을 "136"으로 변경하였으므로 =SUBTITUTE(A2, "36", "136", 1)로 지정하면 된다.

 

다음 중 워크시트의 인쇄 영역 설정에 대한 설명으로 옳지 않은 것은?

  1. 인쇄 영역은 리본 메뉴 [페이지 레이아웃] 탭이나 [페이지 설정] 대화상자의 [시트] 탭에서 설정할 수 있다.
  2. 인쇄 영역을 설정했더라도 인쇄 시 활성 시트 전체가 인쇄되도록 설정할 수 있다.
  3. 여러 시트에서 원하는 영역을 추가하여 인쇄 영역을 확대할 수 있다.
  4. 여러 영역이 인쇄 영역으로 설정된 경우 설정한 순서대로 각기 다른 페이지에 인쇄된다.

정답 : 3. 여러 시트에서 원하는 영역을 추가하여 인쇄 영역을 확대할 수 있다.

해설 : 하나의 시트에서는 원하는 영역을 기존 인쇄 영역에 추가하여 인쇄 영역을 확대할 수 있지만 여러 시트에서는 불가능하다.


데이터베이스 일반

다음 중 [보고서 마법사]로 보고서를 만드는 과정에 대한 설명으로 틀린 것은?

  1. 보고서 마법서는 정해진 절차에 따라 설정 사항을 지정하면 보고서를 자동으로 만들어 준다.
  2. 그룹을 설정한 경우 보고서 모양을 단계, 블록, 외곽선 중에서 선택할 수 있다.
  3. [요약 옵션]에서 모든 필드에 대해 합계, 평균, 개수 등의 함수를 사용하여 값을 표시할 수 있다.
  4. 레코드 원본, 필드. 레이아웃, 서식 등을 직접 선택하여 보고서를 작성할 수 있다.

정답 : 3. [요약 옵션]에서 모든 필드에 대해 합계, 평균, 개수 등의 함수를 사용하여 값을 표시할 수 있다.

해설 : [요약 옵션]에서는 모든 필드가 아니라 숫자 필드에 대해서나 합계, 평균, 최소, 최대 함수를 사용해서 값을 표시할 수 있다.

 

다음 중 기본키(Primary Key)에 대한 설명으로 옳은 것은?

  1. 모든 테이블에는 기본키를 반드시 설정해야 한다.
  2. 액세스에는 단일 필드 기본키와 일련번호 기본키만 정의 가능하다.
  3. 데이터가 이미 입력된 필드도 기본키로 지정할 수 있다.
  4. OLE 개체나 첨부 파일 형식의 필드에도 기본키를 지정할 수 있다.

정답 : 3. 데이터가 이미 입력된 필드도 기본키로 지정할 수 있다.

해설 : 테이블에 기본키를 설정하지 않을 수 있고, 기본키의 종류에는 일련번호 기본키, 단일 필드 기본키, 다중 필드 기본키가 있다. 그리고 OLE 개체나 첨부파일 형식의 필드에는 기본키를 설정할 수 없다.

 

'cmb조회' 명령 단추를 클릭하면 '항공사코드' 필드의 값과 'cmb조회' 컨트롤에 입력된 값이 같은 레코드만 표시되도록 이벤트 프로시저를 작성할 경우 ㉮에 들어갈 알맞은 코드는?

Private Sub cmd조회_Click()
Me.Filter = "항공사코드 = '"&cmb조회&"'"

End Sub
  1. Me.FilterOn
  2. Me.FilterOn = True
  3. Me.FilterOn = False
  4. Me.FilterOn = OK

정답 : 2. Me.FilterOn = True

해설 : FilterOn 속성은 Filter 속성에 정의된 조건의 적용 여부를 지정한다. FilterOn 속성이 True이면 Filter 속성에 정의된 조건을 적용하고, False면 적용된 조건을 해제한다.

 

다음 중 아래 보고서에 대한 설명으로 옳지 않은 것은?

대리점명 : 서울지점
순번 모델명 판매날짜 판매량 판매단가
1 PC4203 2018-07-31 7 ₩1,350,000
2   2018-07-23 3 ₩1,350,000
3 PC4204 2018-07-16 4 ₩1,400,000
  서울지점소계 : ₩4,100,000
대리점명 : 충북지점
순번 모델명 판매날짜 판매량 판매단가
1 PC3102 2018-07-13 6 ₩830,000
2   2018-07-12 4 ₩830,000
3 PC402 2018-07-31 4 ₩1,300,000
4   2018-07-07 1 ₩1,300,000
    충북지점소계 : ₩4,260,000
  1. '모델명' 필드를 기준으로 그룹이 설정되어 있다.
  2. '모델명' 필드에는 '중복 내용 숨기기' 속성을 '예'로 설정하였다.
  3. 지점별 소계가 표시된 텍스트 상자는 그룹 바닥글에 삽입하였다.
  4. 순번은 컨트롤 원본을 '=1'로 입력한 후 '누적 합게' 속성을 '그룹'으로 설정하였다.

정답 : 1. '모델명' 필드를 기준으로 그룹이 설정되어 있다.

해설 : 문제에 제시된 보고서에는 '대리점명' 필드를 기준으로 그룹이 설정되어 있다.

 

다음 중 테이블을 만드는 과정에 대한 설명으로 틀린 것은?

  1. 테이블 '디자인 보기'나 '데이터시트 보기'에서 새로운 필드를 추가할 수 있다.
  2. '디자인 보기'에서 행 선택기를 클릭한 후 바로 가기 메뉴에서 [행 삽입]을 선택하여 필드를 추가할 수 있다.
  3. '데이터시트 보기'에서 데이터가 입력된 마지막 열의 필드명 부분을 더블클릭하여 이름을 변경하면 데이터 형식을 선택할 수 있는 바로가기 메뉴가 표시된다.
  4. '데이터시트 보기'에서 '추가하려면 클릭' 필드에 데이터를 입력하면 '짧은 텍스트'로 데이터 형식이 자동으로 지정된다.

정답 : 4. '데이터시트 보기'에서 '추가하려면 클릭' 필드에 데이터를 입력하면 '짧은 텍스트'로 데이터 형식이 자동으로 지정된다.

해설 : '데이터시트 보기'에서 '추가하려면 클릭' 필드에 데이터를 입력하면 '짧은 텍스트' 형식이 지정되는 것이 아니라 숫자 데이터가 입력되면 '숫자' 형식이, 문자 데이터가 입력되면 '짧은 텍스트' 형식이 자동으로 지정된다.

 

다음 중 폼 바닥글에 있는 텍스트 상자의 컨트롤 원본으로 <사원> 테이블에서 직급이 '부장'인 레코드의 급여 평균을 구하는 함수식으로 옳은 것은?

  1. =DAVG(“[급여]”, “[사원]”, “[직급]=‘부장’”)
  2. =DAVG(“[사원]”, “[급여]”, “[직급]=‘부장’”)
  3. =AVG(“[급여]”, “[사원]”, “[직급]=‘부장’”)
  4. =AVG(“[사원]”, “[급여]”, “[직급]=‘부장’”)

정답 : 1. =DAVG("[급여]", "[사원]", '"[직급]"='부장'")

해설 : DAVG(인수, 도메인, 조건)

 

다음은 보고서 보기 형태에 대한 내용이다. 괄호 ㉮, ㉯에 알맞은 형태는 무엇인가?

㉮ : 보고서로 출력될 실제 데이터를 보면서 컨트롤의 크기 및 위치를 변경할 수 있다.
㉯ : 컨트롤 도구를 이용하여 보고서를 만들거나 수정할 수 있는 형태로, 실제 데이터는 표시되지 않는다.
  1. 레이아웃 보기, 디자인 보기
  2. 인쇄 미리 보기, 레이아웃 보기
  3. 디자인 보기, 보고서 보기
  4. 레이아웃 보기, 보고서 보기

정답 : 1. 레이아웃 보기, 디자인 보기

해설 : 출력될 실제 데이터의 레이아웃(윤곽)을 보면서 작업하는 형태는 '레이아웃 보기', 실제 데이터는 표시되지 않지만 컨트롤 도구를 이용하여 보고서를 디자인 하는 것은 '디자인 보기이다.

 

다음 중 아래의 설명에 해당하는 컨트롤로 옳은 것은?

폼이나 보고서의 원본으로 사용되는 데이터를 표시한다.
게산 결과를 표시한다
  1. 레이블
  2. 텍스트 상자
  3. 콤보 상자
  4. 목록 상자

정답 : 2. 텍스트 상자

해설 : 계산 결과를 표시하는 컨트롤은 텍스트 상자이다.

 

<상품> 폼에 있는 '재고' 필드를 참조하고자 한다. 참조 형식이 바르게 설정된 것은?

  1. [Forms]![상품]![재고]
  2. [Forms]@[상품]@[재고]
  3. [Forms]![상품]@[재고]
  4. [Forms]@[상품]![재고]

정답 : 1. [Forms]![상품]![재고]

해설 : 다른 개체에 있는 컨트롤을 컨트롤 원본에 지정할 경우에는 =[개체]![개체이름]![컨트롤이름]과 같은 형식으로 지정한다.

  1.  
2026-08-11 03:38:23

요구사항(또는 커밋 메시지)은 재료일 뿐이다. 실제 테스트 케이스는 기능 단위로 쪼갠 뒤, 아래 기법을 적용해서 뽑아낸다.


동등분할

입력값의 전체 범위를 "같은 방식으로 처리될 것으로 예상되는 구간"으로 나누고, 각 구간에서 대표값 하나씩만 테스트한다. 구간 안의 모든 값이 동일하게 처리된다는 가정하에, 모든 값을 다 테스트할 필요 없이 대표값만 확인하면 충분하다는 논리다.

 

예를 들어 "레벨업에 필요한 경험치는 1~100 사이여야 한다"는 규칙이 있으면

  • 무효 구간(0 이하) - 대표값 : 5
  • 유효 구간 (1 ~ 100) - 대표값 : 50
  • 무효 구간 (101 이상) - 대표값 : 150

3개의 테스트로 전체 입력 공간을 대표할 수 있다.

경계값 분석

동등분할과 짝을 이루는 기법이다. 버그는 구간 한가운데보다 경계선에서 훨씬 자주 발생한다는 경험적 사실에 기반한다. < 를 <=로 잘못 썼다든가, 반복문이 한

번 더/덜 도는 것(off-by-one 오류)같은 버그는 전부 경계 지점에서 드러난다.

같은 예시에 경계값 분석을 적용하면, 대표값 하나 대신 경계 바로 안쪽과 바로 바깥쪽 값 쌍을 테스트한다.

  • 하한 경계 : 0(무효), 1(무효) - 두 값 다 테스트
  • 상한 경계 : 100(유효), 101(무효) - 두 값 다 테스트

동등 분할이 "구간이 넓으니 대표 하나면 충분하다"는 효율화 기법이라면, 경계값 분석은 "그 효율화가 놓치는 취약 지점을 정확히 찍어서 보완하는" 기법이다. 실무에서는 항상 이 둘을 같이 쓴다.


결정 테이블

조건이 여러 개 얽혀서 결과가 결정될 때 쓴다. 예를 들어 "쿨다운이 끝났고, 스태미나도 충분해야만 대쉬가 가능하다" 처럼 여러 조건의 조합이 하나의 결과를 결정하는 로직에 적합하다.

조건이 N개면 이론상 2^N개의 조합이 나온다. 조건 2개면 4개, 3개면 8개다. 실무에서는 조합이 너무 많아지면 "결과에 영향을 주지 않는 조건"을 표에서 축소하기도 하지만(dont'care 처리), 기본은 모든 조합을 표로 그려놓고 하나씩 대응하는 테스트 케이스를 만드는 것이다. 이 방식의 장점은 코드 리뷰나 기획 검토때 표 자체가 "이 조합을 놓쳤다"를 시각적으로 바로 드러낸다는 점이다.


상태 전이 테스트

시스템이 명확한 상태를 가지고, 이벤트에 따라 상태가 바뀌는 경우에 쓴다. 대쉬 시스템, 캐릭터 애니메이션 상태 머신, UI 화면 전환 같은 게 전형적인 대상이다.

이 기법에서 테스트 케이스는 상태 자체가 아니라 "전이" 단위로 나온다.

  • Idle → Dashing : 대쉬 입력이 들어오면 상태가 바뀌는가
  • Dashing  → Cooldown : 대쉬가 끝나면 쿨다운으로 넘어가는가
  • Cooldown  → Idle : 쿨다운이 끝나면 다시 대쉬 가능 상태로 돌아오는가
  • 잘못된 전이 : Dashing 상태에서 대쉬 입력이 다시 들어오면? 이게 정상 경로만 테스트했을 떼 흔히 빠지는 케이스다. "정의되지 않은 전이가 시도됐을 때 무시되는가"를 반드시 확인해야 한다.

에러 추측

앞의 네 기법은 전부 체계적이지만, 실무에서는 경험에 기반한 직관적 추측도 필효하다. "이런 경우 개발자가 놓쳤을 가능성이 높다"는 걸 목록화한 것이다.

 

자주 쓰는 체크리스트 : 

  • null / 빈 값 : 리스트가 비어있을 때, 참조가 null일 때
  • 동시성 : 같은 프레임에 두 입력이 동시에 들어올 때 (예 : 이동과 대쉬를 같은 프레임에 누르면?)
  • 경합 데이터 : 두 오브젝트가 같은 리소스(스탯 SO)를 동시에 참 수정할 때
  • 극단값 : 0, 음수, float.MAXVALUE, 매우 큰 리스트
  • 과거 버그 재발

실무 절차 (다섯 기법을 어떻게 조합해서 쓰는가

  1. 기능을 코드 단위로 분해한다.
    실제 클래스, 메서드 기준으로 테스트 대상ㅇ르 정한다
  2. 입력 구조를 본다.
    숫자 범위가 있으면  → 동등 분할 + 경계값 분석. 여러 조건이 결과를 결정하면 → 결정 테이블. 상태를 가진 시스템이면 → 상태 전이 테스트
  3. 에러 추측으로 빈 틈을 메운다.
    위 세 기법을 기계적으로 적용한 뒤, "그래도 놓친게 있을까" 를 경험적으로 점검한다.
  4. 케이스를 문서화한다.
  5. 우선순위를 매긴다.
    모든 케이스를 한 번에 구현하지 않는다. 핵심 로직, 과거 버그 재발 지점을 먼저, 부가 기능은 나중에.

케이스 문서화 템플릿

ID : DASH-003
제목 : 대쉬 중 재입력은 무시된다
전제조건(Given) : 대쉬 입력이 다시 들어온다
기대 결과 (Then) : 상태가 Dashing을 유지하고, 두 번째 대쉬는 발동하지 않는다.
도출 기법 : 상태 전이 테스트
우선순위 : 높음 (과거 실제 버그 있음)

 

이 형태(Given-When-Then)로 적어두면, 9~10에서 배운 AAA 패턴(Arrange-Act-Assert)에 거의 그대로 대응된다.

Given이 Arrange, When이 Act, Then이 Assert다. 케이스 문서가 곧 테스트 코드의 초안이 되는 셈이다.

[Test]
public void 대쉬_중_재입력은_무시된다() // DASH-003
{
	// Arrange (Given)
    var dash = new DashController();
    dash.StartDash(Vector2.right);
    
    // Act (When)
    dash.TryDash(Vector2.up);
    
    //Assert (Then)
    Assert.AreEqual(DashState.Dashing, dash.CurrentState);
    Assert.AreEqual(Vector2.right, dash.CurrentDirection);
}
2026-08-10 03:45:54

9장에서 NUnit 문법 자체 ( [Test], Assert, 생명주기)를 봤다. 10장은 원래 선수 지식 목록의 마지막 항목 TDD / 유닛 테스트 기초 개념이다. 유니티 공식 문서가 이 항목에서 명시적으로 꼽은 세 가지가 있다. AAA 패턴, 속성(Attribute)으로 메서드에 표시를 다는 문법 자체에 대한 이해, 인터페이스와 콜백 구현.

 

AAA 패턴 (Arrange-Act-Assert)

9장에서 마지막 예시에서 이미 이 구조를 쓰고 있었다. 이제 명시적으로 짚는다.

  • Arrange (준비) : 테스트 대상 객체를 만들고, 필요한 입력값을 세팅한다.
  • Act (실행) : 검증하고 싶은 동작을 딱 한 번 호출한다.
  • Assert (검증) : 실행 결과가 기대와 일치하는지 확인한다.

 

[Test]
public void 희귀등급은_기준가의_2배다()
{
	// Arrange
    var calculatror = new PriceCalculator();
    var 감자 = new CropData(name:"감자", basePrice:0.2f, grage:Grade.Rare);
    
    // Act
    var 실제가격 = calculator.CalculateSellPreice(감자);
    
    Assert.AreEqual(0.4f, 실제가격, 0.00001f);
}

 

왜 굳이 이 구조를 강제하는가

이건 단순한 코드 스타일 취향이 아니다. 한 테스트 메서드 안에 Act가 여러 번 등장하면, 어느 Act에서 실패가 유발됐는지가 모호해진다. 예를 들어 이런 코드를 생각해보자.

 

// 안티패턴 - Act와 Assert가 계속 섞여있음
[Test]
public void 나쁜_예시()
{
	var calc = new PriceCalculator();
    Assert.AreEqual(0.2f, calc.CalculateSellPrice(감자_일반));
    calc.ApplyMarketEvent(EventType.Festival);
    Assert.AreEqual(0.3f, calc.CalculatrSellPrice(감자_일반));
    calc.ApplyMarketEvnet(EvnetType.Drought);
    Assert.AreEqual(0.3f, calc.CalculatrSellPrice(감자_일반));
}

이 테스트가 실패하면, "축제 이벤트 적용 로직이 잘못됐다, 가뭄 이벤트 로직이 잘못됐나, 아니면 두 이벤트가 겹쳐서 뭔가 꼬였나"를 코드 한 줄씩 다시 읽어야 알 수 있다. 하나의 테스트 = 하나의 검증 대상이라는 원칙을 지키면, 테스트 이름만 보고도 뭐가 깨졌는지 바로 알 수 있다. 위 코드는 세개의 테스트로 쪼개는 게 맞다.

 

이게 왜 "TDD 기초 개념"으로 분류되는지도 여기서 설명된다. TDD(테스트 주도 개발)에서는 테스트를 코드보다 먼저 쓰는데, 그러려면 애초에 "지금 내가 검증하려는 것이 정확히 무엇인가"를 한 문장으로 특정할 수 있어야 한다. AAA 구조는 그 특정을 강제하는 틀이다.


속성(Attribute) 문법 자체에 대한 이해

9장에서 [Test]가 "메서드에 붙는 메타데이터 태그"라고 했는데, 이번엔 그게 문법적으로 정확히 무엇인지 한 단계 더 들어간다.

 

속성은 사실 클래스다

[Test] 라는 대괄호 문법은 사실 다음 코드의 축약형이다.

[Test]
public void 어떤_테스트() { ... }

// 이것과 개념적으로 동일함 (실제로 이렇게 쓰지는 않지만)
[TestAttribute()]
public void 어떤_테스트() { ... }

즉 Test 는 관례상 이름 뒤에 Attrib

ute 가 생략된 것이고, 실제로는 System.Attribute 를 상속한 평범한 C# 클래스다.

 

NUnit 라이브러리 내부에는 실제로 이런 정의가 존재한다. (단순화한 형태)

public class TestAttribute : Attribute
{
	// 필요하다면 여기에 필드를 둘 수도 있다 (예 실행 순서, 설명 등)
}

 

이게 왜 "실행되지 않는" 코드인가

여기가 초심자가 가장 헷갈리는 지점이다. [Test]TestAttribute인스턴스를 만들어서 메서드에 붙이는 것 뿐이고, 이 클래스 자체에는 "테스트를 실행하는 로직"이 전혀 들어있지 않다. TestAttribute 는 그냥 데이터 조각이다.

 

실행 로직은 전부 NUnit 엔진 쪽에 있다. 9장에서 본 리플렉션이 여기서 다시 등장한다. NUnit 엔진이 어셈블리를 리플렉션으로 훑다가 "이 메서드에 TestAttribute 인스턴스가 붙어있네" 를 확인하면, 그제서야 엔진이 그 메서드를 호출한다. 속성 자신은 아무것도 하지 않고 그냥 "난를 찾는 사람에게 정보를 제공하는 표지판" 역할만 한다.

 

이 구조를 이해하면 왜 [SetUp], [TearDown], [Test] 같은 것들이 전부 같은 방식으로 동작하는지, 그리고 왜 커스텀 속성ㅇ르 직접 만들어서 자기만의 테스트 태깅 시스템을 만드는 것도 이론적으로 가능한지 (실제로 NUnit도 [Category("Slow")] 처럼 확장 가능한 속성을 제공한다)가 자연스럽게 이해된다.


인터페이스와 콜백 구현 - Mock의 원리

마지막 항목이다. 4장에서 순환 참조를 해결할 때 잠깐 이 인터페이스를 썼는데, 이번엔 테스트 관점에서 왜 인터페이스가 필요한지를 본다.

 

문제상황

public class GameManager
{
	public void SaveProgress(GameData data)
    {
    	System.IO.File.WriteAllTest("save.json"), JsonUtillity.ToJson(data)); // 디스크에 직접 씀
    }
}

GameManager.SaveProgress 를 테스트하고 싶은데, 이 메서드는 실제로 디스크에 파일을 쓴다. 테스트를 돌릴 때 마다 진짜 파일이 생성되고, 테스트 환경(CI 서버 등)에 파일 시스템 권한이 없으면 테스트 자체가 실패한다. 게다가 "파일이 제대로 저장됐는지"를 검증하려면 파일을 다시 읽어야 하는데, 이건 이미 단위 테스트의 범위를 넘어선 통합 테스트에 가깝다.

 

해결 - 저장 동작을 인터페이스 뒤로 숨긴다

public interface ISaveSystem
{
	void Save(GameData data);
}

public class GameManager
{
	private readonly ISaveSystem saveSystem;
    
    public GameManager(ISaveSystem saveSystem) => this.saveSystem = saveSystem;
    
    public void SaveProgress(GameData data)
    {
    	saveSystem.Save(data); //실제로 어떻게 저장하는지는 GameManager의 관심사가 아님
    }
}

실제 게임에서는 디스크에 쓰는 구현체를 주입한다.

 

public class FileSaveSystem : ISaveSystem
{
	public void Save(GameData data) =>
    	System.IO.File.WriteAllText("save.json", JsonUtillity.ToJson(data));
}

테스트에서는 디스크에 아무것도 안 쓰고 메모리에만 기록하는 가짜 구현를 만들어서 대신 주입한다.

public class FakeSaveSystem : ISaveSystem
{
	public GameData LastSavedData {get; private set;} // 무엇이 저장 시도했는지만 기록
    
    public void save(GameData data) => LastSavedData = data;
}

이제 테스트로는 이렇게 된다

[Test]
public void SaveProgress는_전달받은_데이터를_그대로_저장소에_넘긴다()
{
	// Arrange
    var fakeSave = new FakeSaveSystem();
    var manager = new GameManager();
    var data = new GameData(gold : 500);
    
    // Act
    manager.SaveProgress(data);
    
    // Assert - 디스크 I/O 없이, 메모리에 기록된 값만 확인
    Assert.AreEqual(500, fakeSave.LastSavedData.Gold);

파일 시스템을 전혀 건드리지 않고도 "GameManager가 저장소에 올바른 데이터를 넘기는가"만 정확히 검증했다. 이게 인터페이스/콜백을 이용한 Mock의 핵심이다. 진짜 구현을 통해 대체 가능한 가짜로 갈아끼워서, 테스트 대상 로직만 딱 분리해서 검증하는 것.

 

"콜백"은 어디에 해당하는가

문서가 언급한 "콜백"은 이 예시에서 saveSystem.save(data) 호출이 사실상 "저장이 필요할 때 나를 불러줘" 라고 등록해둔 약속을 실행하는 것과 같은 구조라는 의미다. GameManager 는 저장이 필요한 시점에 자신이 들고 있는 ISaveSystem 참조의 Save를  호출할 뿐이고, 그 호출이 실제로 무엇을 하는지는 호출된느 쪽(콜백을 등록한 쪽)이 결정한다. 생성자 주입이 바로 그 "콜백을 등록하는" 지점이다.

 

지금 단계에서 필요한 정도

지금은 손으로 FakeSaveSystem 같은 클래스를 직접 만드는 정도로 충분하다. 프로젝트가 커지고 인터페이스가 늘어나면 이런 가짜 클래스를 매번 손으로 작성하는 게 번거로워지는데, 그때 3장에서 언급했던 Moq나 NSubtitude 같은 라이브러리가 이 가짜 클래스를 코드 한줄로 자동 생성해주는 도구로 등장한다. 지금은 "인터페이스 뒤에 실제 구현을 숨기면, 테스트에서 그 자리에 가짜를 끼워넣을 수 있다"는 원리만 확실히 잡으면 된다.


정리

  1. AAA 패턴은 한 테스트가 정확히 무엇을 검증하는 지 모호해지지 않도록, Arrange-Act-Assert 세 구간을 명확히 분히라는 관례다.
  2. [Test] 같은 속성은 실행 로직이 없는 순수한 데이터(클래스 인스턴스)이며, 실제 실행은 그걸 리플렉션으로 찾아 호출하는 NUnit 엔진이 담당한다.
  3. 테스트하기 어려운 외부 의존(파일, 네트워크 등)은 인터페이스 뒤로 숨기고, 테스트에서는 그 인터페이스를 구현한 가짜 객체를 주입해서 검증한다.
  4. 이 가짜 객체 교체가 가능한 이유는 코드가 구체 타입이 아니라 인터페이스에 의존하도록 설계했기 때문이다.
2026-08-10 02:30:29

1~8장으로 어셈블리 파트를 마쳤다. 월대 UTF 문서가 요구하는 성수 지식 세 가지 중 두번째, NUnit으로 넘어간다. NUnit은 유니티가 만든 게 아니라, .NET 진영에서 이미 20년 가까이 쓰이던 표준 단위 테스트 프레임워크이고, UTF는 이걸 그대로 가져다 쓴다. 그래서 NUnit 문법을 알면 UTF 문법을 아는 것과 사실상 같다.

 

NUnit이 정확히 뭘 하는 물건인가

NUnit을 한 문장으로 정의하면 : "어떤 메서드가 테스트인지 찾아내고, 그걸실행하고, 성공/실패를 판정하는 라이브러리 + 실행기"다.

 

8장에서 봤듯 asmdef의 precompiledReferencesnunit.framework.dll 을 추가해야 하는 이유가 여기서 나온다. [TEST], Assert 같은 것들이 전부 이 DLL 안에 정의된 타입/ 속성이다. NUnit 자체는 유니티와 무관한 순수 .NET 라이브러리이고 유니티가 하는 일은 이 라이브러리의 실행 결과를 Test Runner 창에 예쁘게 그려주는 것 뿐이다.


[Test] 속성 -- 어떻게 "이게 테스트다"를 알아채는가

using NUnit.Framework;

public class 가격계산_테스트
{
	[Test]
   	public void 감자_기본가는_영원히_변하지_않는다()
    {
    	Assert.AreEqual(0.2f, PriceCalculator.BasePrice("감자"));
    }
    
    public void 이건_그냥_헬퍼_메서드()
    {
    	// [TEST] 가 없을므로 NUnit은 이 메서드의 존재조차 모른다
    }
}

[Test] 는 C#의 속성이다. 속성은 실행되는 코드가 아니라, 메서드에 붙는 메타데이터 태그.

클래스 정의에 스티커를 붙이는 것과 비슷하다고 보면 된다.

 

핵심은 NUnit이 이 메타데이터를 어떻게 읽어내는가다.

NUnit 엔진은 테스트 어셈블리 ( .dll )를 로드한 뒤, 리플렉션(Reflection)을 사용해서 그 안의 모든 타입과 메서드를 순회한다. 리플렉션은 .NET 런타임이 제공하는 기능으로, 컴파일된 코드를 실행하지 않고도 "이 어셈블리 안에 어떤 클래스가 있고, 어떤 메서드가 있고, 그 메서드에 어떤 속성이 붙어있는지"를 런타임에 조사할 수 있게 해준다. NUnit은 이 리플렉션으로 [Test] 가 붙은 메서드만 골라내고, 나머지는 완전히 무시한다.

 

이게 왜 중요하냐면, 테스트를 "실행하는 코드"와 "발견하는 코드"가 분리되어 있다는 걸 이해해야 나중에 [SetUp], [TearDown] 같은 다른 속성들도 자연스럽게 이해되기 때문이다. 전부 같은 메커니즘(리플렉션으로 속성 탐색)의 응용일 뿐이다.


테스트 실행 생명주기 - SetUp / TearDown

실제 테스트 코드에는 보통 "매번 반복되는 준비 작업"이 있다. 예를 들어 매 테스트마다 새 EconomySystem 인스턴스를 만들어야 한다면, 그걸 테스트 메서드마다 복사-붙여넣기 하는 대신 별도로 뽑아낼 수 있다.

public class EconomySystem_테스트
{
	private EconomySystem economy;
    
    [SetUp] // 각 [Test] 메서드 실행 "직전"에 매번 호출됨
    public void Setup()
    {
    	economy = new EconomySystem();
    }
    
    [TearDown] // 각 [Test] 메서드 실행 "직후"에 매번 호출됨
    public void Teardown()
    {
    	economy = null;
    }
    
    [Test]
    public void 감자_판매가는_0보다_크다()
    {
    	Assert.IsTrue(economy.GetSellPrce("감자") > 0);
    }
    
    [Test]
    public void 존재하지_않는_작물은_예외를_던진다()
    {
    	Assert.Throws<KeyNotFoundException>(() => economy.GetSellPrice("없는작물"));
    }
}

이 클래스에 테스트가 2개 있으므로, 실제 실행 순서는 SetUp -> 테스트1 -> Teardown -> SetUp -> 테스트2 -> Teardown 이다. 매 테스트가 완전히 독립된 상태에서 시작하도록 보장하는 게 목적이다. 이전 테스트가 남긴 상태가 다음 테스트에 영향을 주면 안된다는 단위 테스트의 기본 원칙(테스트 간 격리)을 두 속성이 구현한다.

 

믈래스 전체에서 한 번만 실행하고 싶은 무건운 초기화(예 : DB 연결, 대용량 데이터 로드) 가 있다면 [OneTimeSetUp] / [OneTimeTearDown] 을 쓴다.

[OneTimeSetUp] // 이 클래스의 모든 테스트를 통틀어 딱 1번
public void ClassSetup() { /* 무거운 초기화 */ }

[OneTimeTearDown] // 이 클래스의 모든 테스트가 끝난 뒤 딱 1번
public void ClassTearDown() { /* 정리 */}

Setup / Teardown 은 테스트마다, OneTimeSetUp / OneTimeTearDown 은 클래스마다. 이 스코프 차이만 기억하면 된다.


Assert 클래스 - 판정의 실체

[Test] 메서드 안에 실제로 "성공/실패"를 가르는 건 Assert 클래스다. 내부 동작은 단순하다. 조건을 검사해서 거짓이면 예외( AssertionException )를 던진다. NUnit 엔진은 이 예외를 잡아서 "이 테스트는 실패"로 기록한다. 예외가 안던져지고 메서드가 끝까지 실행되면 "성공"이다.

가장 자구 쓰는 네 갈래 : 

// 동등성 비교 - 첫 인자가 기대값 (expected), 들때가 실제값 (actual)
Assert.AreEqual(60f, crop.GrowTimeSeconds);

// 부동소수점은 반올림 오차 때문에 tolerance (허용 오차)를 반드시 지정
Assert.AreEqual(0.333f, 1f, 3f, 0.001f);

// 조건 판정
Assert.IsTrue(economy.GetSellPrice("감자") > 0 );
Assert.IsNull(inventory.Find("존재하지않는아이템"));

// 예외 검증 - "이 코드는 반드시 이 예외를 던져야 정상"
Assert.Throws<ArgumentException>(() => new CropData(growTime : -1));

// 컬렉션 비교
CoolectionAssert.AreEqual(new[] {1, 2, 3}, harvestResult.ItemIds);

 

주의할 점 하나 : Assert.AreEqual 의 인자 순서는 (exception, actual) 이다. 반대로 넣어도 컴파일은 되고 결과 판정도 동일하지만, 실패했을 때 출력되는 메시지가 "기대값 X인데 실제로 Y가 나왔다"는 식으로 뒤바뀌어서 디버깅할 때 헷갈린다. 관례를 지키는 세 좋다.


전체를 합친 예시

지금까지 배운 걸 하나의 클래스로 합치면 이렇다. 판매가 공식 ( 기준가 * 등급 배율 )을 검증하는 테스트다.

using NUnit.Framework;

public void PriceCalculator_테스트
{
	private PriceCalculator calculator;
    
    [SetUp]
    public void Setup()
    {
    	calculator = new Pricecalculator();
    }
    
    [Test]
    public void 일반등급_감자_가격은_기준가와_같다()
    {
    	// Arrange
        var 감자 = new cropData(name : "감자", basePrice: 0.2f, grade : Grade.Normal);
        
        // Act 
        var 실제가격 = calculator.CalculateSellPrice(감자);
        
        // Assert
        Assert.AreEqual(0.2f, 실제가격, 0.0001f);
    }
    
    [Test]
    public void 희귀등급은_기준가의_2배다()
    {
    	var 감자 = new cropData(name : "감자", basePrice : 0.2f, grade : Grade.Rare);
        var 실제가격 = calculator.CalculateSellPrice(감자);
        Assert.AreEqual(0.4f, 실제가격, 0.00001f);
    }
    
    [Test]
    public void 음수_기준가는_예외를_던진다()
    {
    	Assert.Throws<ArguemtExeception(() =>
        	new CropData(name:"감자", basePrce=-1f, grade:Grade.Normal));
    }
}

 

이 코드가 어떻게 실행되는지 한줄로 요약하면 : NUnit이 리플렉션으로 이 클래스의 [Test] 메서드 3개를 찾아내고, 각각의 실행 전후로 [SetUp]을 호출하고, 메서드 안에서 Assert 가 조용히 통과하면 성공, 예외를 던지면 실패로 기록한다.


정리

  1. NUnit은 유니티와 무관한 표준 .NET 테스트 라이브러리이고, UTF는 이를 그대로 가져다 쓴다.
  2. [Test] 는 속성(메타데이터)일 뿐이며, NUnit은 리플렉션으로 어셈블리를 훑어 이 속성이 붙은 메서드를 찾아낸다.
  3. [SetUp], [TearDown]은 테스트마다, [OneTimeSetUp] / [OneTimeTearDown]은 클래스마다 한 번 실행되어 테스트 간 격리를 보장한다.
  4. Assert 는 조건이 거짓이면 예외를 던지고, NUnit은 그 예외 발생 여부로 성공 / 실패를 판정한다.
  5. Assert.AreEqual( expected, actual ) 순서 관례를 따르고, 부동소수점 비교에는 tolerance가 필요하다.
2026-08-06 03:44:37

1장부터 7장까지 어셈블리, asmdef, 참조, 인스펙터 필드, JSON, asmref을 순서대로 다졌다. 8장은 이 모든게 실제로 왜 UTF (Unity Test Framework)를 쓸 때 어떻게 합쳐지는지 연결하는 장이다. 여기까지 오면 UTF 공식 문서가 왜 첫 선수 지식으로 어셈블리를 꼽았는지 명확해진다.

 

유니티 공식 문서가 말하는 요지

UTF 문서를 요약하면 이렇다 -- 유니티에서는 테스트를 테스트 어셈블리로 그룹화하고, 테스트가 실행될 플랫폼을 지정하고, 테스트 대상 프로젝트 어셈블리를 선택적으로 참조할 수 있도록 어셈블리 정의를 설정한다.

 

문장 하나에 지금까지 배운 개념이 세 개

다 들어있다. 각각을 asmdef 필드로 분해하면 이렇게 매핑된다.

  1. 격리 : Define Constraints 로 테스트 코드를 실제 빌드에서 제외
  2. 실행 환경 지정 : Platforms 로 EditMode 테스트를 Editor 전용으로 제한
  3. 테스트 대상 지정 : References 로 "무엇을 테스트할 수 있는가"를 결정

셋 다 asmdef의 표준 필드일 뿐, UTF가 새로 발명한 개념이 하나도 없다는 게 핵심이다. UTF는 asmdef 위에 얹힌 얇은 규칙 집합에 가깝다.


전형적인 참조 구조

실제 프로젝트에서 테스트 어셈블리들이 어떻게 배치되는지 보면, 모든 장이 한 그림으로 합쳐진다.

이 그래프를 읽는 방법은 4장에서 배운 그대로이다.

  • Tests.EditMode Tests.PlayMode Gameplay 를 참고하고, GameplayCore를 참조한다.(단방향)
  • Core 는 자기를 테스트하는 코드의 존재를 알 필요가 없다. 참조는 항상 테스트 쪽에서 대상 쪽으로만 걸린다.
  • 경우에 따라 Tests.EditModeGameplay  를 거치지 않고 Core 를 직접 참조하기도 한다. 순수 계산 로직만 빠르게 검증하고 싶을 때는 굳이 Gameplay 까지 참조할 필요가 없기 때문이다.

Core  가 특히 중요한가

2장에서 다룬 No Engine ReferencsCore 에 켜두면, 이 어셈블리는 UnityEngine 을 전혀 참조하지 않는 순수 C#이 된다. 그결과 :

  • 유니티 엔진을 부팅하지 않고도 테스트를 돌릴 수 있음 → 초 단위가 아니라 밀리초 단위로 실행
  • 판매가격 공식, 확률 계산, 자료구조 로직처럼 엔진과 무관한 계산이 여기 들어갈 후보가 됨

Edit Mode 테스트가 왜 "빠르다"고 하는지가 여기서 설명된다. 정확히는 Edit Mode 자체가 빠른게 아니라, 엔진 의존이 없는 어셈블리를 테스트하는 게 빠른 것이고, No Engine References 가 그걸 구조적으로 강제해준다.


Test Runner의 자동 생성 버튼이 실제로 하는 일

Test Runner 창에는 Create EditMode Test Assembly Folder 라는 버튼이 있다. 누르면 폴더와 asmdef이 자동으로 만들어지는데, 지금까지 배운 지식으로 그 결과물을 그대로 해석할 수 있다.

 

Test Runner 자동 생성 버튼이 만드는 asmdef 내용

자동 생성되는 asmdef을 6장에서 배운 JSON으로 풀어보면 대략 이렇다.

{
    "name" : "Tests.EditMode"
    "references" : [
    	"UnityEngine.TestRunner",
        "UnirtEditor.TestRunner"
        ],
    "includePlatforms" : [ "Editor" ],
    "excludePlatforms" : [],
    "overrideReferences" : true,
    "precompiledReferences" : [ "nunit.framework.dll" ],
    "autoRefernced" : true,
    "defineconstraints" : [ "UNITY_INCLUDE_TESTS" ]
}

 

한 줄씩 대응해보면 : 

  • referenecesTestRunner 관련 어셈블리 - UTF 자체와 연결되는 부분
  • includePlatforms : ["Editor"] - 5장에서 본 포함 모드 토글
  • overrideReferences : true + precompiledReferences : ["nunit.framework.dll"] - 5, 6장에서 본 짝 필드.NUnit 문법 ([Test], Assert.AreEqual 등)을 쓰려면 이  DLL 참조가 필수
  • defineConstraints : ["UNITY_INCLUDE_TESTS"] - 5장에서 본 조건부 컴파일. 이게 있어야 테스트 코드가 실제 게임 빌드에서 빠짐

여기서 얻어가야 할 태도는 이거다. 이 버튼이 마법처럼 뭔가를 해주는 게 아니라, 지금까지 7개 장에 걸쳐 배운 필드들을 UTF에 맞는 값으로 미리 채워주는 것뿐이다. 값을 채우는 로직 자체는 전혀 새롭지 않다. 그러니 이 자동 생성 asmdef을 열어보았을 때 "왜 이렇게 설정되어있지" 에 답할 수 있어야 하고, 이제는 답할 수 있다.

 

참조 대상을 직접 추가해야 한다는 점도 잊지 말 것. 버튼이 만들어주는 건 TestRunner 연결까지고, 실제로 테스트하고 싶은 Vegeroid.CoreVegeroid.Gameplay 는 4장에서 배운 대로 References에 직접 추가해야한다. 자동 생성 asmdef이 알아서 프로젝트 코드를 참조해주지는 않는다.


정리 - 1~7장이 UTF로 수렴하는 지점

배운 내용 UTF에서의 역할
1장 어셈블리, 참조, public/ internal 테스트 어셈블리도 결국 하나의 컴파일 단위
2장 사전 정의 어셈블리의 한계 asmdef 없이는 테스트 어셈블리 자체를 구성할 수 없음
3장 asmdef 생성, 컴파일 시간 원리 Core를 하위 레이어에 둬야 테스트 파급 범위가 작음
4장 참조 방향, Auto Referenced, GUID 테스트 → 댓아 코드로만 참조, 대상은 테스트를 모름
5장 Platforms, Define Constraints EditMode 격리와 빌드 제외의 실체
6장 JSON 포맷 자동 생성된 asmdef을 읽고 진단하는 능력
7장 asmref 패키지형 테스트 유틸리티를 여러 폴더에 걸쳐 구성할 때

 

정리

  1. UTF는 새로운 개념을 발명한 게 아니라, asmdef의 표준 필드(References, Platfoems, Define Constrainrs)를 조합해서 쓰는 것 뿐이다.
  2. 전형적인 테스트 어셈블리는 Tests.EditMode / Tests.PlayModeGameplay Core 순의 단방향 참조 구조를 가진다.
  3. CoreNo Engine Referencs 를 켜두면 엔진 없이 빠르게 테스트할 수 있는 순수 로직 레이어가 된다.
  4. Test Runner의 자동 생성 버튼은 지금까지 배운 필드들에 UTF용 기본값을 채워주는 것 뿐이며, 실제 테스트 대상 참조는 직접 추가해야 한다.
2026-08-06 02:33:00

지금까지 6개 장에 걸쳐 .asmdef 을 파고들었다. 7장은 그 형제뻘인 .asmref 을 짧게 다룬다. 등장 빈도는 낮지만, "어? 이 폴더엔 asmdef이 없는데 왜 asmdef 소속으로 컴파일되지?"라는 상황을 만나면 반드시 필요한 지식이다.

정의

.asmref 은 "이 폴더의 스크립트들을, 다른 곳에 이미 정의되어 있는 어셈블리에 편입시켜라"는 지시를 담은 파일이다.

 

.asmdef 이 새로운 어셈블리를 정의(define)하는 파일이라면, .asmref 은 기존 어셈블리에 합류(join) 시키는 파일이다. 이 차이가 전부다.


JSON 구조

asmdef과 비교하면 압도적으로 단순하다. 필수 필드가 references 문자열 하나뿐이다.

{ "reference" : "Vegeroid.Core" }

(GUID 참조 방식을 쓴다면 "reference" : "GUID:27619889..." 처럼 4장에서 본 형식 그대로 쓸 수도 있다.)

 

asmdef이 가지고 있던 references, includePlatforms, defineConstraints, autoReferenced 같은 필드들이 asmref에는 아예 없다. 이건 실수로 빠뜨린 게 아니라 설계상 당연한 결과다. 이 폴더는 독립된 컴파일 단위가 아니라 다른 어셈블리의 일부이므로, 컴파일 설정 자체가 그 대상 어셈블리(reference 가 가리키는 asmdef)의 설정을 그대로 물려받는다. 별도로 설정할 게 없는 것이다.


실제로 무엇이 합쳐지는가

Scripts/Core/
    Economy.cs
    Core.asmdef              ← "name": "Vegeroid.Core"

Plugins/ThirdParty/Extra/
    MathHelpers.cs
    Extra.asmref              ← { "reference": "Vegeroid.Core" }

 

물리적으로 완전히 떨어진 두 폴더인데, Extra.asmref 이 Vegeroid.Core 를 가리키고 있으므로 컴파일 결과는 이렇게 된다.

 

Economy.csMathHelpers.cs 가 서로 다른 폴더에 있었다는 사실은 컴파일 결과물(Vegeroid.Core.dll)에 아무 흔적도 남기지 않는다. 둘은 완전히 동일한 어셈블리에 속한 코드로 취급된다. 즉 서로 internal 멤버레도 접근할 수 있고, 참조를 걸 필요도 없다.


언제 쓰는가

폴더 구조와 어셈블리 구조가 1:1로 맞아떨어지지 않는 상황에서 쓴다. 몇 가지 전형적인 케이스 :

  1. 패키지(UPM) 개발 시 플랫폼별 폴더 분리
    유니티 패키지를 만들 때 Runtime/ Runtime/iOS/, Runtime/Android/ 처럼 플랫폼별로 물리적 폴더를 나눠놓고 싶지만, 컴파일 결과는 하나의 어셈블리로 유지하고 싶을 때. 각 하위 폴더에 asmref을 두고 상위 asmdef을 가리키게 한다.
  2. 기존 대형 어셈블리를 점진적으로 재구성할 때
    큰 어셈블리 하나를 폴더 여러 개로 리팩터링하여 나누는 중간 단계에서, 아직 완전히 분리하지 않은 폴더를 임시로 원래 어셈블리에 asmref로 붙여둔 채 점진적으로 옮기는 식으로 쓸 수 있다.
  3. 코드 생성기가 산출물을 다른 폴더에 뱉을 때
    빌드 도구나 코드 제너레이터가 산출물을 프로젝트 구조상 별도 폴더에 생성하지만, 논리적으로 기존 어셈블리의 일부여야 하는 경우.

세 경우 모두 공통점은 "물리적 위치는 분리하고 싶지만 컴파일 단위는 하나로 유지하고 싶다"는 요구다.


asmref이 실무에서 자주 쓰이지 않는 이유

개인 프로젝트나 소규모 팀 프로젝트에서는 asmref을 쓸 일이 거의 없다. 폴더 구조를 처음부터 어셈블리 경계와 일치시키면 되기 때문이다. - Vegeroid.Core 에 넣을 코드는 처음부터 Scripts/Core/ 폴더에 만들면 그만이지, 굳이 다른 폴더에 만들어놓고 asmref로 편입시킬 이유가 없다.

 

asmref이 진가를 발휘하는 곳은 주로 UPM 패키지 배포레거시 코드베이스의 점진적 마이그레이션처럼, 폴더 구조에 대한 제약이 어셈블리 설계보다 우선하는 특수한 상황이다. 지금은 "이런 게 있고, 필요할 때 왜 쓰는지" 만 기억해두면 충분하다.


정리

  1. .asmdef 이 새 어셈블리를 정의한다면, .asmref 은 기존 어셈블리에 폴더를 편입시킨다.
  2. asmref의 필수 필드는 reference 하나뿐이며, 컴파일 설정은 전부 대상 asmdef을 그대로 물려받는다.
  3. asmref로 묶인 폴더의 코드는 물리적 위치와 무관하게 완전히 동일한 어셈블리로 취급된다. (internal 접근도 가능)
  4. 주 용도는 UPM 패키지의 플랫폼별 폴더 분리, 점진적 리팩터링, 코드 생성기 산출물 통합 등 "물리적 분리 + 논리적 통합"이 필요한 특수 상황
  5. 일반적인 프로젝트 구조에서는 폴더를 어셈블리 경계와 일치시키면 되므로 등장 빈도가 낮다.

 

2026-08-06 01:31:40

5장에서 인스펙터 필드를 하나씩 봤는데, 사실 인스펙터는 실체가 아니라 '뷰'. 진짜 데이터는 .asmdef 파일 안의 JSON이고, 인스펙터는 그 JSON을 읽고 쓰는 GUI 레이어일 뿐이다. 6장은 그 JSON을 직접 열어서 필드가 실제로 어떤 키값으로 저장되는지 확인한다.

실제 예시 파일

EditMode 테스트 어셈블리 하나를 통째로 보면 이렇다.

{
	"name" : "Vegeroid.Tests.EditMode",
	"rootNamespace" : "Vegeroid.Tests",
 	"references": [
        "UnityEngine.TestRunner",
        "UnityEditor.TestRunner",
        "Vegeroid.Core",
        "Vegeroid.Gameplay"
    ],
	"includePlatforms": [ "Editor" ],
	"excludePlatforms": [],
	"allowUnsafeCode": false,
	"overrideReferences": true,
	"precompiledReferences": [ "nunit.framework.dll" ],
	"autoReferenced": false,
	"defineConstraints": [ "UNITY_INCLUDE_TESTS" ],
	"versionDefines": [],
	"noEngineReferences": false
}

지금까지 배운 것과 대조해보면 이 파일 하나가 사실상 3~5장 전체의 요약이다.

  • name - 3장에서 본 유일한 필수 필드
  • references - 4장에서 다룬 참조 목록 (지금은 이름 기반)
  • includePlatforms - 5장의 Platforms, Any Platform 해제 후 Editor만 체크한 상태
  • overrideReferenced + precompiledReferences - 5장의 Override References, NUnit DLL을 명시적으로 끌어옴
  • autoReferenced : false - 테스트 어셈블리는 다른 곳에서 자동으로 참조될 이유가 없으므로 꺼둠
  • defineContraints - UNITY_INCLUDES_TEST 로 빌드에서 제외

필드별 저장 규칙

  1. name 은 유일한 필수 필드
    JSON 스키마 관점에서 이 파일에 꼭 있어야 하는 키는 name 하나뿐이다. 나머지는 전부 생략 가능하며, 생략 시 필드별 기본값(대부분 빈 배열이거나 true/ faslse 의 관례적 기본값)으로 처리된다
  2. references 는 두 형식 중 하나로 통일되어야 한다

    이름 기반이면 어셈블리 이름 문자열 그대로 : 
    "references" : [ "Vegeroid.Core", "Vegeroid.Gameplay" ]

    GUID 기반이면 :
    "references" : [ " GUID:27619889b8ba8c24980f49ee34dbb44a" ]

    중요한 건 한 배열 안에서 두 형식을 섞으면 안 된다는 점이다. 인스펙터의 Use GUIDs 토글은 이 배열 전체를 한 형식에서 다른 형식으로 일괄 변환해주는 스위치일 뿐, JSON 자체에 별도의 "useGuids" : true 같은 키로 저장된는 게 아니다. 즉 이 설정값은 파일에 있는 참조 표기 형태로부터 역으로 추론된다. GUID: 로 시작하면 GUID 모드, 아니면 이름 보드로 인스펙터가 판단해서 보여주는 것이다.
  3. includePlatforms / excludePlatfoems 는 상호 배타
    5장에서 본 Any Platform 토글의 실제 저장 형태다.
    둘 중 하나에 값이 있으면 다른 하나는 반드시 빈 배열이어야 한다. 이 규칙은 인스펙터가 UI가 지켜주지만, JSON을 손으로 편집하거나 스크립트로 생성할 때는 직접 신경 써야 하는 부분이다.
  4. precompiledReferencesoverrideReferences 가 켜져야 의미가 생긴다



    overrideReferencesfalse 인 상태에서 precompiledReferences 배열에 뭘 적어놔도 완전히 무시된다. 두 필드가 한 세트로 묶여서 동작한다는 걸 모르면, "DLL 이름을 분명 적었는데 왜 참조가 안 걸리지" 하는 원인 파악이 어려운 문제로 이어진다.

    배열 안의 값은 경로 없이 확장자를 포함한 파일명만 적는다는 점도 주의할 부분이다.
    Assets/Plugins/nunit.framework.dll 이 아니라 그냥 nunit.framework.dll 이다. 유니티가 프로젝트 내에서 그 이름의 DLL을 알아서 찾는다.

왜 JSON을 직접 읽을 줄 알아야 하는가

인스펙터로도 다 설정할 수 있는데 왜 굳이 JSON 구조를 알아야 할까. 실무적인 이유 세가지.

  1. 버전 관리(Git) diff 가독성
    asmdef을 여러 명이 건드리는 프로젝트에서는 PR 리뷰 시 JSON diff를 그대로 읽게 된다.
    "autoReferenced" : truefalse 로 바뀐 한 줄짜리 diff를 보고 그게 어떤 인스펙터 필드의 변경인지, 무슨 영향을 주는지 바로 해석할 수 있어야 리뷰가 빨라진다.
  2. 스크립트로 asmdef을 일괄 생성/검증할 때
    프로젝트 초기 세팅을 자동화하거나, CI에서 "모든 테스트 asmdef이  UNITY_INCLUDE_TESTS 제약을 갖고 있는지" 같은 규칙을 강제하려면 JSON을 직접 파싱해야 한다.
  3. 인스펙터에 노출되지 않은 미묘한 상태 파악
    Use GUIDs 처럼 JSON에 직접 대응되는 키가 없고 추론되는 필드가 있다는 걸 알아야, "왜 인스펙터에서 이 토글이 이상하게 표시되지" 같은 상황(예 : 참조가 이름과 GUID 형식이 섞여 있어서 인스펙터가 판단을 못 하는 경우)을 진단할 수 있다.

정리

  1. 인스펙터는 JSON에 대한 뷰일 뿐이고, name 이 유일한 필수 키다
  2. references 배열은 이름 기반과 GUID 기반 중 한 형식으로 통일해야 하며, Use GUIDs 토글은 JSON에 별도 키로 저장되지 않고 참조 형태로부터 추론된다.
  3. includePlatformsexcludePlatforms 는 상호배타적이다. 하나가 값을 가지면 다른 하나는 반드시 빈 배열.
  4. precompiledReferencesoverrideReferencestrue 일 때만 실제로 작동하며, 경로 없이 파일명만 적는다.
  5. JSON 구조를 알아두면 diff 리뷰, 자동화 스크립트 작성, 인스첵터에 안보이는 상태 진단에 실질적으로 도움이 된다.
2026-08-05 23:13:38

4장까지 asmdef의 핵심 동작(참조, 순환 금지, Auto Referenced, GUID)을 다뤘다. 5장은 실제로 인스펙터를 열었을 때 보이는 필드 하나하나가 정확히 무엇을 컴파일러에게 지시하는지를 정리한다. 필드 자체는 유니티 고유의 UI이지만, 각 필드가 내부적으로 하는 일은 대부분 표준 컴파일러 옵션이나 조건부 컴파일 개념으로 환원된다.

 

전체 구조부터 보면 이렇다

크게 두 갈래다. Name·General Options는 "이 어셈블리 자체의 성질"을 정의하고, Platforms·Define Constraints·Version Defines는 "이 어셈블리가 언제·어디서 컴파일 대상에 포함되는가"를 정의한다

 

Name

어셈블리의 이름. 3장에서 다뤘듯 실제 결과물(.dll)의 이름을 결정하는 유일한 필드이고, 프로젝트 전역에서 유일해야 한다. asmdef JSON에서 유일한 필수 필드이기도 하다. 나머지는 전부 생략 시 기본값으로 처리된다.


General Options

  1. Allow 'unsafe' Code
    C#의 unsafe 컨텍스트(포인터 연산 등, 런타임의 안전성 검사를 우회하는 코드)를 이 어셈블리에서 쓸지 여부다. 켜면 컴파일러 호출 시 /unsafe 플래그가 추가된다. 일반적인 게임 로직에서는 거의 쓸 일이 없고, 대용량 버퍼를 다루는 저수준 최적화 코드(예: Burst/Jobs 시스템과 연계하는 코드)에서나 필요해진다.
  2. Auto Referenced
    4장에서 이미 깊게 다뤘다. 이 어셈블리를 유니티의 사전 정의 어셈블리(Asesmbly-CSharp 등)가 자동으로 참조할지를 결정하며, 기본값은 켜짐이다. 라이브러리성 코드는 꺼서 의존 관계를 명시적으로 드러내는 편이 낫다.
  3. No Engine References
    켜면 UnityEngine · UnityEditor 어셈블리에 대한 참조가 자동으로 추가되지 않는다. 기본적으로 모든 asmdef은 이 두 어셈블리를 암묵적으로 참조하고 있는데(그래서 아무 설정 없이 MonobehaviourDebug.Log를 쓸 수 있었던 것이다). 이 옵션을 켜면 그 암묵적 참조가 사라진다.

    이게 왜 유용하나면, 엔진API에 대한 의존을 컴파일 타임에 강제로 차단할 수 있기 때문이다. 순수 계산 로직(가격 공식, 확률 계산, 자료구조)을 담는 어셈블리에 이 옵션을 켜두면, 실수로 UnityEngine.Debug.Log 를 호출하는 코드가 섞여 들어오는 순간 컴파일 에러로 즉시 드러난다. "이 코드는 유니티 없이도 실행 가능해야 한다"는 설계 의도를 코드 리뷰가 아니라 컴파일러가 검증해주는 셈이다.
    부수 효과로, 유니티 엔진을 부팅하지 않고도 순수 .NET 환경에서 이 어셈블리의 단위 테스트를 돌릴 수 있게된다. Edit Mode 테스트가 빠른 이유의 근본 원인이기도 하다.

  4. Override References

    기본적으로 어셈블리는 프로젝트에 임포트된 모든 사전 컴파일(precompiled) DLL을 자동으로 참조한다. 이 옵션을 켜면 그 자동 참조를 끊고, Assembly Rerences 목록에 명시적으로 추가된 DLL만 참조하게 된다.

    주로 쓰이는 상황은 테스트 어셈블리가 nunit.framework.dll 을 참조해야 할 때다. 프로젝트에 여러 버전의 특정 DLL이 섞여있거나, 어셈블리별로 참조하는 서드파티 라이브러리 버전을 다르게 가져가야 할 때도 쓴다.
  5. Root Namespace
    이 asmdef 폴더 안에 새 스크립트를 생성할 때 IDE(Rider, Visual Studio)가 자동으로 붙여주는 기본 네임스페이스다. 컴파일 자체에는 영향을 주지 않는 개발 편의용 메타데이터이고, 실제로 그 네임스페이스를 강제하지는 않는다. 어디까지나 코드 생성 시 힌트일 뿐이다.

Platforms

이 어셈블리가 어떤 빌드 타겟에서 컴파일되는지를 지정한다. 여기서 반드시 이해해야 할 게 토글 구조다.

Any Platform 체크 여부가 의미를 뒤집는다.

  • Any Platform체크된 상태 → 기본이 "전부 포함"이고, 개별로 체크한 항목은 제외 목록이 된다
  • Any Platform해제된 상태 → 기본이 "전부 미포함"이고, 개별로 체크한 항목은 포함 목록이 된다

즉 같은 체크박스 UI인데 Any Platform 의 상태에 따라 그 의미가 정반대로 뒤집힌다. Editor 전용 어셈블리를 만들 때는 Any Platforms 을 해제하고 Editor만 체크하는 게 표준 패턴이다. "이건 에디터에서만 존재하야 한다"는 화이트리스트 방식이 실수를 줄이기 때문이다.

 

내부적으로 JSON에서는 이게 includePlatforms 배열과 excludePlatforms 배열로 나뉘어 저장되고, 둘 중 하나만 값을 가질 수 있다 (동시에 값이 있으면 안됨)


Define Constraints

컴파일러의 #if 전처리기 지시문은 소스 파일 안의 특정 코드 블록을 조건부로 켜고 끄지만, Define Constraints는 그 조건부 컴파일을 어셈블리 전체 단위로 끌어올린 것이다. 여기 나열된 심볼이 전부 정의되어 있어야만 이 어셈블리 자체가 컴파일 대상에 포함된다. 하나라도 정의되어 있지 않으면 어셈블리 전체가 톤째로 컴파일에서 제외된다

 

문법도 #if 와 유사하게 지원된다. 심볼 앞에 ! 를 붙이면 부정("이 심볼이 정의되어 있지 않아야 한다")이고 || 로 여러 조건 중 하나만 만족해도 되게 묶을 수 있다.

 

UTF에서의 실전 활용

가장 대표적인 사용처가 테스트 어셈블리다. UNITY_INCLUDE_TESTS 라는 심볼은 유니티가 에디터 컨텍스트나 테스트 실행 컨텍스트에서는 자동으로 정의하지만, 실제 게임을 빌드할 때는 정의하지 않는다.

이 심볼을 테스트 asmdef의 Define Constraints에 걸어두면, 에디터에서 작업할 때는 정상적으로 컴파일되어 테스트를 돌릴 수 있지만, 실제 게임 빌드에는 테스트 코드도 NUnit 관련 DLL도 전혀 포함되지 않는다. 별도로 #if 지시문을 코드 곳곳에 넣을 필요 없이, asmdef 설정 하나로  "이 어셈블리는 테스트 목적으로만 존재한다"를 선언하는 것이다.


Version Defines

프로젝트에 설치된 특정 패키진 모듈의 버전에 따라 심볼을 조건부로 정의하는 기능이다. Define Constraints가 "이미 정의된 심볼이 있는지"를 검사한다면, Version Defines는 "그 심볼을 애초에 정의할지 말지를 패키지 버전으로 결정"한다는 점에서 한 단계 앞선 개념이다.

 

예를 들어 "Test Framework 패키지가 2.0 이상이면 UTF_2_0_NEWER 심볼을 정의" 처럼 설정해두면, 그 심볼을 코드에서 #if UTF_2_0_OR_NEWER 로 검사해 패키지 버전에 따라 다른 API를 선택적으로 쓰는 게 가능해 진다. 패키지 업데이트시 하위 호환성을 유지해야 하는 라이브러리 코드에서 자주 쓰인다.


정리

  1. Name은 유일한 필수 필드이며 실제 어셈블리 이름을 결정한다
  2. Auto Referenced·No Engine References·Override References는 "이 어셈블리가 무엇을 자동으로 참조하는가"를 조정하는 필드들이고, 그중 No Engine References는 순수 로직 어셈블리를 강제하는 데 유용하다.
  3. Root Namespacesms 컴파일에 영향을 주지 않는 IDE 편의 메타데이터다
  4. Platforms는 Any Platform 체크 여부에 따라 포함 목록/제외 목록의 의미가 반전되는 토글 구조다
  5. Define Constraints는 #if 를 어셈블리 단위로 끌어올린 것이며, UNITY_INCLUDES_TESTS 로 테스트 어셈블리를 빌드에서 제외하는 게 대표 활용 사례다
  6. Version Defines는 패키지 버전에 따라 심볼 정의 여부 자체를 결정하는, 한 단계 더 상위의 조건부 컴파일 도구다
2026-08-04 20:42:21

명시적 참조 원칙

1장에서 이미 봤듯, 참조는 "이 어셈블리가 저 어셈블리의 public 타입을 볼 수 있게 허락하는 선언"이다. 유니티는 이 허락을 암묵적으로 추론하지 않는다. 같은 프로젝트 안이든, 외부 패키지든, 필요한 참조는 asmdef 안에 명시적으로 나열해야 한다.

 

이게 왜 문제가 되냐면, asmdef를 도입하기 전에는 프로젝트 전체가 사실상 하나의 전역 네임스페이스 풀처럼 동작했기 때문이다. 아무 스크립트나 아무 클래스나 서로 참조할 수 있었다. asmdef를 만드는 순간 그 폴더는 독립된 컴파일 단위로 격리되고, 참조를 명시하지 않은 모든 타입은 CS0246 (형식 또는 네임스페

이스 이름을 차을 수 없음) 에러로 돌아온다.

핵심은 이거다 참조가 컴파일러에게 "이 타입 정보를 로드해도 된다"는 허가증 역할을 한다는 것. 링커가 심볼을 해석할 때 참조되지 않은 어셈블리의 심볼 테이블은 아예 탐색 대상에 들어가지 않는다고 이해하면 된다.


순환 참조 금지

A가 B를 참조하면, B는 A를 참조할 수 없다. 이걸 어기면 컴파일 에러 (Cyclic assembly reference)다.

 

왜 금지되는가 - 그래프 이론 관점

3장에서 어셈블리 참조 관계가 방향성 그래프를 이룬다고 했다. 컴파일러가 "어떤 순서로 어셈블리를 빌드할지" 정하려면 이 그래프에 대해 위상 정렬(topological sort)을 수행해야 하는데, 위상 정렬은 그래프가 비순환(acyclic)일 때만 가능하다. A와 B가 서로를 참조하면 "A를 먼저 빌드해야 하는데 그러려면 B가 먼저 빌드돼 있어야 하고, B를 빌드하려면 A가 먼저 빌드돼있어야 하는 "순환 의존이 생겨서 애초에 순서를 정할 수가 없다. 그래서 이건 유니티의 임의적인 제약이 아니라 컴파일 순서가 존재하려면 필연적으로 따라오는 수학적 제약이다.

순환이 "필요해 보이는" 상황과 해법

순환 참조 에러를 만나는 전형적인 패턴은 이렇다.

Gameplay (전투 시스템)이 UI (데미지 표시 텍스트)를 참조하고 싶다.
UI는 이미 Gameplay (플레이어 체력 값)을 참조하고 있다.
→ 순환

 

이건 대게 의존성 역전 원칙 (DIP, Dependency Inversion Principle)을 적용하라는 신호다. GameplayUI의 구체 타입을 직접 참조하는 대신, Gampelay 쪽(또는 둘 다 참조하는 더 하위 레이어)에 인터페이스를 두고 UI가 그 인터페이스를 구현하게 뒤집는다.

 

// Gameplay 어셈블리 안 (하위 레이어)
public interface IDamageDisplay
{
	void ShowDamage(float amount);
}

public class Combat
{
	private readonly IDamageDisplay display; // 구체 타입이 아니라 인터페이스에 의존
    
 	private Combat(IDamageDisplay display) => this.display = display;
}
// UI 어셈블리 안 (상위 레이어, Gameplay를 참조)
public class DamageText : MonoBehaviour, IDamageDisplay
{
	public void ShowDamage(float amount) { /* 텍스트 표시 */ }
}

이제 화살표는 UI -> Gameplay 한 방향뿐이다. CombatIDamageDisplay 라는 추상에만 의존하고, 그 구현체가 UI에 있다는 사실 자체를 모른다. 런타임에는 DamageText 인스턴스가 주입(콜백/DI)되어 실제로 연결된다.


참조의 비대칭성 - Auto Referenced

Auto Referenced는 유니티의 사전 정의 어셈블리 (Assembly-CSharp) 가 이 asmdef을 자동으로 참조할지를 결정하는 옵션이다. 기본값은 켜짐이다.

 

이게 뭘 의미하냐면 :

  • 기본값(켜짐) 상태에서, Assembly-CSharp(asmdef 없는 잡다한 스크립트쪽에서는 내 asmdef의 public 타입이 자동으로 보인다. 참조를 따로 안 걸어도 된다.
  • 그런데 반대 방향은 절대 안 된다. 내 asmdef 안에서 Assembly-CSharp의 타입을 쓰고 싶어도, Assembly-CSharp 자체가 asmdef으로 취급되지 않기 때문에 References 목록에 애초에 추가할 대상으로 나타나지 않는다.

왜 이게 "일부만" 도입하기 어렵게 만드는가

이 비대칭성 때문에 asmdef 도입에는 관성이 생긴다. 핵심 로직 하나를 asmdef으로 분리했다고 하자. 그 로직이 여전히 Assembly-CSharp 에 남아있는 다른 클래스를 참조하고 있었다면? 컴파일이 깨진다Assembly-CShap를 참조할 방법이 없기 때문이다.

 

결국 asmddef으로 옮긴 코드가 의존하던 것들도 전부 연쇄적으로 asmdef 세계로 끌고 들어와야 한다. 이건 그래프에서 "asmdef으로 옮긴 노드가 참조하는 모든 하위 노드도 asmdef이어야 한다"는 제약과 같다. 그래서 프로젝트 초기, 아직 Assembly-CSharp 에 코드가 몇 개 없을 때 도입하는 편이 압도적으로 유리하다. 프로젝트가 커진 뒤에 시작하면 이 연쇄 이전 작업 자체가 큰 리팩터링이 된다.

라이브러리성 어셈블리에서의 실전 활용

역으로, 재사용 가능한 유틸리티 라이브러리를 만든다면 Auto Referenced꺼두는 게 낫다. 꺼두면 Assembly-CSharp 쪽에서 자동으로 보이지 않으므로, 그 라이브러리를 쓰고 싶은 어셈블리가 명시적으로 References에 추가해야 한다. 이러면 "누가 이 라이브러리에 의존하고 있는가"를 asmdef 파일들만 훑어봐도 파악할 수 있다 - 암묵적 의존을 없애고 의존 관계를 전부 선언적으로 드러내는 셈이다.


이름참조 VS GUID 참조

References 목록에 다른 어셈블리를 추가할 때, 유니티는 내부적으로 두 가지 방법 중 하나로 참조를 저장한다.

  • 이름 기반 참조 : references 배열에 어셈블리 이름 문자열을 그대로 저장 ( "Vegeroid.Core" )
  • GUID 기반 참조 : 어셈블리 정의 에셋 자체의 GUID를 저장 (  "GUID : 27619889b8ba8c24980f49ee34dbb44a" )

인스펙터의 Use GUIDs 토그롤 전환할 수 있다

왜 GUID가 권장되는가

이름 기반 참조는 사람이 읽기는 편하지만, 참조가 문자열 매칭에 의존한다. Vegeroid.Core 라는 어셈블리 이름을 나중에 Vegeroid.Foundation 으로 바꾸면, 그 이름 문자열을 참조하고 있던 다른 모든 asmdef의 references 배열이 더 이상 어떤 어셈블리도 가리키지 못하는 깨진 참조가 된다. 이걸 고치려면 참조하던 곳을 전부 찾아서 손으로 고쳐야 한다.

 

GUID는 유니티 에셋 시스템에서 에셋 자체의 불변 식별자다. 파일을 옮기거나 이름을 바꿔도 GUID는 유지된다( .meta 파일에 저장되어 있다.) 그래서 GUID로 참조를 걸어두면 Vegeroid.Core Vegeroid.Foundation 으로 리네임해도 참조하던 다른 asmdef들은 아무 수정 없이 계속 올바른 대상을 가리킨다.


정리

  1. 참조는 컴파일러에게 "이 타입 정보를 봐도 된다"는 명시적 허가이며, 유니티는 이를 암묵적으로 추론하지 않는다.
  2. 순환 참조 금지는 임의 제약이 아니라 컴파일 순서(위상 정렬)가 성립하려면 그래프가 비순환이어야 한다는 수학적 요구다.
  3. 순환이 필요해 보이면 대개 인터페이스를 하위 레이어에 두는 의존성 역전(DIP)으로 해결한다.
  4. Auto Referenced는 비대칭적이다 - Assembly-CSharp 은 내 asmdef을 자동으로 볼 수 있지만, 내 amsdef은 Assemby-CSharp을 결코 볼 수 없다.
  5. 이 비대칭성 때문에 asmdef 도입은 프로젝트 초기에 할수록 유리하다 - 늦게 시작하면 연쇄적 이전 비용이 커진다.
  6. 참조는 이름 또는 GUID로 저장되며, 이름 변경에도 참조가 깨지지 않는 GUID 방식이 사실상 표준이다.

 

2026-08-04 04:48:43

2장에서 asmdef이 없을 때 유니티가 알아서 4단계로 나누는 방식과 그 한계(컴파일 시간, 의존성 통제 불가, 조건부 컴파일 부재)를 봤다. 3장은 그 한계를 해결하는 실체 .asmdef 파일 자체를 다룬다

정의

.asmdef폴더 단위로 컴파일 경계를 명시적으로 선언하는 에셋이다. Assets > Create > Assembly Definition 으로 생성하며, 이 파일이 놓인 폴더 안의 모든 스크립트(하위 폴더 포함)가 하나의 컴파일 단위로 묶인다.

 

일반적인 C#/.NET 개발에서는 .csproj가 컴파일 단위를 정의한다. 유니티는 빌드 시스템이 .csproj 를 매번 자동 생성하는 구조이기 때문에, 그 자동 생성 로직에 개입할 훅으로 .asmde 을 도입한 것이다. 즉 .asmdef 은 유니티 진영에서 .csproj가 하는 역할을 대신하는 선언적 메타데이터라고 보면된다.

 

실체는 JSON이다. 인스펙터는 이 JSON에 대한 GUI 래퍼일 뿐이고, 텍스트 에디터로 직접 열어서 수정해도 무방하다.


규칙

  1. 이름의 결정권은 오직 Name 필드에 있다
    폴더 이름 .asmdef 파일 이름은 실제 어셈블리 이름과 아무 관계가 없다. 컴파일러가 최종적으로 만들어내는 .dll의 이름은 전히 인스펙터의 Name 필드(JSON의 name 키)로 결정된다.

    폴더를 Weird_Folder_123 이라고 지어놓 NameVegeroid.Gameplay로 설정하면, 결과물은 Weird_Folder_123.dll 이 아니라 Vegeroid.Gameplay.dl 이다. 다른 asmdef에서 이 어셈블리를 참조할 때도 Name 필드 값을 쓴다


  2. 이름은 프로젝트 전역에서 유일해야 한다
    어셈블리 이름은 곧 링크 타임에 사용되는 식별자이므로, 프로젝트 안에서 중복되면 안된다. 이름 충돌은 컴파일 에러다.

    여러 프로젝트에서 재사용할 가능성이 있는 라이브러리성 코드라면 역방향 DNS 스타일 (Vegeroid.Gameplay.combat 처럼 최상위 네임스테이스로부터 점으로 좁혀가능 명명)을 쓰는게 권장된다. 자바의 패키지 명명 관례와 동일한 발상이다.

  3. asmdef은 특수 폴더 규칙보다 우선한다
    2장에서 이미 다룬 내용이지만 asmdef 자체의 규칙이므로 다시 짚는다. asmdef 폴더 내부에 EditorPlugins 같은 이름의 하위 폴더를 만들어도, 사전 정의 컴파일 시스템의 자동 분리는 적용되지 않는다.
    amsdef이 있는 순간 그 폴더 트리 전체가 하나의 명시적 선언 아래로 통합되기 때문이다. 에디터 코드를 분리 하려면 별도의 asmdef를 만들어 Platforms를 Editor로 제한해야 한다

 


컴파일 시간이 줄어드는 원리

  1. 빌드 시스템의 기본 전제 - 의존 그래프
    여러 컴파일 단위(어셈블리)가 서로를 참조하면, 이는 방향성 비순환 그래프(DAG)를 이룬다. 노드는 어셈블리, 간선은 참조 관계다. (순환 참조가 금지되는 이류도 이 그래프가 acyclic 해야 컴파일 순서를 위상 정렬할 수 있기 때문이다.

    증분 빌드 시스템의 표준 전략은  :어떤 노드의 소스가 바뀌면, 그 노드와 그 노드에 (직접 또는 간접으로) 의존 하는 모든 노드만 재컴파일하고, 나머지는 이전 빌드 결과물을 그대로 재사용한다. 

    이걸 유니티의 3단계 어셈블리 구조에 대입하면 이렇게 된다.
    core (아무도 참조하지 않는 최하위 계층)를 고치면 → core 를 참조하는 Gameplay, 그리고 Gameplay를 참조하는 UI 까지 전부 파급된다.
    UI (아무도 참조하지 않는 최상위 계층)를 고치면 → UI 하나만 재컴파일되고 나머지는 그대로다


  2. 단일 어셈블리와 비교
    asmdef이 없을 때는 이 그래프에 노드가 하나( Assembly-CSharp )뿐이다. 그래프 이론적으로 보면 최악의 경우다 - 어떤 변경이든 그 하나뿐인 노드 전체가 무효화된다. 노드를 잘게 쪼갤수록 평균적인 변경 하나가 무효화하는 범위(blast radius)가 줄어든다

    다만 무한정 잘게 쪼갠다고 무조건 좋은 것도 아니다. 어셈블리 로딩 자체의 오버헤드, 그리고 관리해야 할 참조 관계의 복잡도가 늘어난다. 실무에서는 보통 계층(Core / Gameplay / UI / Editor / Tests)이나 기능 도메인 (Economy, Combat, Inventory) 단위로 나누는 절충안을 쓴다.

  3. 도메인 리로드와의 관계
    한가지 유니티  고유의 사저을 덧붙이면, 재컴파일 뒤에는 도메인 리로드(AppDomain을 다시 로드하며 모든 정적 상태를 초기화하는 과정)가 따라온다. 이건 어셈블리를 몇 개로 나누든 "재컴파일이 발생했다"는 사실 자체에 걸리는 고정 비용이라, asmdef 분리로 직접 줄일 수는 없다. 다만 재컴파일이 발생하는 빈도범위는 asmdef로 줄일 수 있으므로, 결과적으로 도메인 리로드를 겪는 총 시간이 감소한다.

정리

  1. .asmdef 은 폴더 단위 컴파일 경계 선언이며, 유니티에서 .csproj 역할을 대신한다
  2. 어셈블리의 실제 이름은 Name 필드가 결정하고, 폴더명·파일명과는 무관하다
  3. 이름은 프로젝트 전역에서 유일해야 하며, 재사용 가능성이 있다면 역방향 DNS 스타일을 권장
  4. asmdef은 Editor/ Plugins 같은 특수 폴더 자동 분리 규칙보다 우선한다
  5. 어셈블리 참조는 방향성 비순환 그래프(DAG)를 이루고, 증분 빌드는 변경된 노드와 그에 의존하는 노드만 재컴파일한다
  6. 의존 그래프 하위 계층을 고칠수록 재컴파일 범위가 커지므로, 자주 안 바뀌는 코드를 하위 레이어에 두는 설계가 유리하다