C# - Klockworks 수행결과


개요

KlockWorks(http://www.klocwork.com/) 라는 정적분석툴을 사용하면 코드상의 문제되는 부분들을 잡아줍니다.
아래 내용들은 C# 코드를 KlockWorks 수행 후 나온 이슈(http://www.atlassian.com/software/jira/)들에 대한 코드리뷰(http://www.atlassian.com/software/crucible/)시 정리했던 사항들입니다.

예전에 WIKI에 써놓은걸 옮겨오느라 출처를 까먹었습니다.
C#이 아닌 C 얘기가 많이 나오는데.. 그놈이나 이놈이나 개념은 같아서 별 상관은 없을듯합니다.

CS.FLOAT.EQCHECK

우리가 알고있는 c언어의 float, double에 대해서 가수와 지수 등등…, 출력, 사용법 등 floating point 데이터 타입들(float, double)은 매우 큰 영역의 값들을 표현하기 위해서 약간의 정밀도를 상실 하는 대신 지수와 가수 라는 방법을 써서 데이터를 나타낸다.

예를 들면,
300000000000000000은 3E+17 과 같이 나타낸다. 여기서 3이 가수, 17이 지수가 되는 것이다.
이 말은 3 곱하기 10의 17승 이라는 것이다.

하지만, 실제 데이터가 이렇게 곱게 저장되어 있진 않고, 이거를 2진수로 바꾸어서 들어가게 된다.
예를 들어, 1.6은 2진수 0.8 곱하기 2의 1승이 되는 것이다. 이따위로 들어가기 때문에 정확도가 매우 떨어진다.
"어떤 2진수 X 곱하기 2의 Y승" 이란것은 절대로 딱 나누어 떨어지는 10진 정수가 될수 없는 것이다. 아무튼, 이건 그다지 중요한 내용은 아니고, 여기서부터 진짜 시작이다.

IEEE 754 floating point 표준에 보면은, float형은 7자리까지는 그 정밀도를 보장할 수 있고, 그 이후로는 부정확하다고 되어 있다. 그리고, double형은 15자리까지 정밀도를 보장할 수 있다고 한다.

우리는 보통 float, double을 char로 바꿀 때 무심결에 sprintf( aBuf, "%f", aDoubleVar); 라고 쓰지 않는가? 이렇게 쓴다면은 이건 정말 float과 double에 대한 모욕인 것이다..

%f 옵션은 정말 무식하게도 지맘대로 막 자릿수 정해서 짤라버린다.. IEEE 754 표준을 무시하는 아주 강력한? 옵션인 것이다. %lf, %g, %e옵션 등등, fcvt, gcvt, ecvt등등의 표준함수를 다 써봐도 이건 정말 지맘대로 정확도 값이 튀어나온다..

한번 실험해보자. 0.0002348을 double 변수에 집어넣고 %f 로 char로 찍어보자. 그러면 0.000235 라는 정확도가 매우 떨어지는 값이 나올것이다. 괜히 소수점 6자리에서 반올림을 해버린 것이다.  분명 7자리까지 보장한다는 말은 가수에만 해당되기 때문에 2348은 고작 4자리이므로, 충분히 이걸 처리할 수 있어야 하는데도 말이다.

여기서 별 고민을 다 하고 찾고 해 본 결과..
"%.자릿수f" 변환을 하면 되는거였다.. 여기서 f는 g, e 등등 다 된다.. f,g,e등의 옵션은 알아서 찾아보시길..

0.0002348을 다시 sprintf로 변환해보자. 이번에는 "%.15f" 로 해보자. 그러면 결과가 0.0002348이라고 제대로 나올 것이다.

중요한건, float은 정확도 보장 자릿수가 7이고, double은 15라는 거다. 따라서 float과 double의 equal 연산은 매우 무의미 하다.  그리고, 같은 타입이라도 equal 연산은 무의미 하다. 1도 1이 아니기 때문에.. 예를 들어, float a= 1.2 이고, double b = 1.2이다. 이건 서로 다른 값이다.. a는 정확도 7자리이고, b는 15자리이다... 이 이후로는 무슨값이 들어올지 모른다.

그러면 이렇게 해보자. float a = 1.2이고, double b = (double)a 이고, double c = 1.2 이다.
이러면 b와 c가 같은 값일까?    아니다. 다른값이다...
여기서 b는 float형인 a의 1.2를 형변환 해서 들어갔기 때문에 정확도가 고작 7자리인 것이다....

따라서, 모든 대소비교를 할때 크거나 작거나.. 를 통해서 오차값을 줘서 해야한다는 것이다. 그 오차값은 이 자릿수를 고려해서 넣으면 될것이다. float과 double을 비교한다면 7자리까지 봐줘야 하고, double과 double은 15자리.. 하지만, 그중 하나가 float값을 형변환 해서 사용했다면 7자리까지만 봐줘야 한다.

그리고, 0도 두개씩 갖고있다.. 값이 0이라도 -0과 +0이 있기 때문에 이 처리도 해줘야 한다.
예를 들면, 대소비교 등을 할때 만약 double 형 a변수값이 0이라면 한번더 a=0이라고 해주고 비교하는것이 팁이라면 팁이겠다.

CS.HIDDEN.MEMBER.PARAM

C 프로그래밍에 있어서 변수의 생존성(생존권)이란, 변수가 메인 메모리에서 생성된 후부터 메인 메모리에서 소멸될 때까지 변수가 메인 메모리에 존재하는 범위이다.
그리고 변수의 가시성(가시권)은 이미 메모리에 생성되어 생존성이 있는 변수들이 사용될 수 있는 영역의 범위이다.
변수들이 생존성과 가시성에 대한 개념은 모듈화 프로그래밍과 프로젝트(소스파일의 묶음 단위) 관리에 유용하게 사용되는 개념이다.

이러한 변수의 생존성과 가시성에 대해 보다 자세히 알기 위해서는 프로그램이 실행될 때 메인 메모리의 상태에 대해서 먼저 알아야 한다.

-------------------------------------------------------
 코드 영역 : 실행코드들이 저장
 -------------------------------------------------------
 데이터 영역 : 전역데이터와 동적데이터들이 저장
 -------------------------------------------------------
 스택 영역 : 지역데이터와 시스템의 임시데이터들이 저장
 ------------------------------------------------------- 
void main()
{
     int a=8, b=7, c;
     {
            // main 함수 안의 지역 영역
            int  k=3, b=10
            c = a + b + k;      // ① a : 8, b : 10, k : 3, c : 21
     }
    c = a + b;                  // ② a : 8, b : 7, c : 15
}
void foo(int a) {
  int a = 0;        // 로컬변수 a로 인해 파라메터 a가 hidden됨.
  a++;
}

생존성(생존권)

앞에서도 얘기 했듯이 프로그램에서 선언된 변수가 메인 메모리로부터 영역을 할당 받아 자신의 데이터를 저장할 수 있는 범위를 변수의 생존성이라고 한다. 이는 변수가 전언된 위치에 따라서 생성되는 메모리의 위치가 다르기 때문에 발생하는 문제이다.

즉, 전역변수는 데이터 영역에서 생성되므로 프로그램이 종료되는 시점까지 메인 메모리에 존재 할 수 있지만, 지역변수는 스택영역에서 생성 되기 때문에 자신이 선언된 영역을 벗어나면 자동적으로 소멸된다. 이는 스택영역이 프로그램에서 임시로 사용되는 데이터들을 저장하는 영역이기 때문이다.

가시성(가시권)

역시 앞서 얘기 했듯이 프로그램에서 선언된 변수가 해당 영역에서 사용될 수 있는 범위를 변수의 가시성이라고 한다. 이는 이미 프로그램에서 선언된 변수더라도 처리중인 지역 영역에서 동일 명의 변수가 선언된 다면, 처리중인 영역에서 선언된 변수를 우선적으로 사용하기 때문에 발생하는 문제이다.

 (예1) 보면 main() 함수의 내부 지역 영역에서는 main() 함수의 변수 b와 내부 지역 영역의 변수 b가 동일한 이름으로 메인 메모리인 스택 영역에서 위의 그림과 같이 공존한다. 그렇기 때문에 운영 시스템은 프로그램의 실행문 ("c = a + b+ K;")에서 사용할 변수 b를 선택해야만 한다. 이런 경우엔 처리중인 영역에서 선언된 변수를 우선적으로 사용하기 때문에 자연히 main() 함수의 내부 지역 영역에서 선언된 지역 변수 b가 실행문에서 처리된다.

이와 같이 main()에 선언된 지역 변수 b는 스택 영역에 존재하기 때문에 생존성은 있지만, main() 함수의 내부 지역영역에서는 동일명의 지역변수 b에 의해 사용될 수 없기 때문에 가시성은 없다. 그러나 main() 함수 내부 지역영역을 제외한 main()함수 내의 모든 영역에서 가시성을 가진다. main() 함수의 내부 지역 영역에 선언된 지역변수 b는 내부 지역 영역에서만 가시성과 생존성을 가진다. 내부 지역 영역을 벗어나면 가시성과 생존성을 잃어버리게 된다. 

static 변수

여기까지 보면 전역 변수는 데이터 영역에 생성되기에 생존성과 가시성이 프로그램 전체(지역영역에 동일한 이름의 변수가 없을 경우)가 되고, 지역변수는 스택 영역에 생성되기에 생존성과 가시성이 지역(모듈) 내가 된다. 감 잡기 쉬워서 좋다. 그러나 이런 전역 변수와 지역 변수의 중간적 성격을 가진 변수가 있다. 바로 static 변수다.

static 변수는 static라는 키워드를 "static int a=10" 처럼 데이터 형 선언 앞에 붙여 줌으로 간단히 사용할 수 있다. 이 static 변수는 지역 영역에서 선언 되더라도 메모리의 스택 영역에 할당되지 않고 전역 변수처럼 데이터 영역에 선언된다. 따라서 static 변수의 생존성은 전역 변수와 마찬가지로 프로그램 전체가 된다. 하지만 가시성은 성언 된 지역으로 한정된다. 이 때문에 static 변수가 지역변수와 전역변수의 중간적 성격을 띠는 것이다.

이와 같이 static 변수를 사용하는 이유는 프로그램에서 임시로 사용될 지역 데이터들이 스택 영역에 생성되기 때문에 선언된 영역을 벗어나면, 자동 소멸되므로 선언된 영역에서만 사용되면서도 생존성이 프로그램 전체인 임시 데이터가 필요하기 때문이다.

CS.LOOP.STR.CONCAT

.NET 기반의 string 특징을 살펴보면

  1. 16비트 유니코드기반이다
  2. string은 궁극적으로 유니코드 포인터를 저장하고 있는 배열인 char의 배열이다
  3. string은 class형이며 래퍼런스 타입이다
  4. string은 한번 선언되면 편집될수없는 immutable의 특징을 가진다.
using System;

class AppClass
{
    [STAThread]
    static void Main(string[] args)
    {
        string strA;
        strA=" Hello ";
        Console.WriteLine("strA ID:"+strA.GetHashCode().ToString());
        Console.WriteLine("strA Value:"+strA);

        strA+=" World ";
        Console.WriteLine();
        Console.WriteLine("strA ID:"+strA.GetHashCode().ToString());
        Console.WriteLine("strA Value:"+strA);

        strA=" Other World ";
        Console.WriteLine();
        Console.WriteLine("strA ID:"+strA.GetHashCode().ToString());
        Console.WriteLine("strA Value:"+strA);

        string strB=" Hello ";
        Console.WriteLine();
        Console.WriteLine("strB ID:"+strB.GetHashCode().ToString());
        Console.WriteLine("strB Value:"+strB);
    }
}
strA ID:312799047
strA Value: Hello

strA ID:300494021
strA Value: Hello  World

strA ID:1086160259
strA Value: Other World

strB ID:312799047
strB Value: Hello

그럼 .NET Framework이 이렇게 문자열을 처리하는 이유는 무엇일까요? 성능을 향상 시키기위해서? 아닙니다.
성능적으로는 오히려 위와 같이하면 매번 힙에 메모리 할당받기 위해 주소계산과 메모리의 빈블럭을 찾기위한 연산을 해야하는데 이것이 시간이 꽤 걸립니다.

MSDN문서에 찾아보시면 이유를 간단하게 설명해놨는데 아쉬운데로 이해가 되는부분입니다. 주요이유는 Thread 안정성을 쉽게 보장하기 위해서라고 되어있습니다.

문자열 타입의 값은 한번 할당되면 바뀔수 없으므로 많은 Thread가 동시에 접근해서 읽고/쓰고 한다해도 다른 클래스 타입보다 상대적으로 쉽게 Multi-Thread 안전성을 보장할수있습니다.
그래서 Multi-Thread프로그램을 쉽게 만들수있죠.

뭐 MS의 입장에서는 프로그램보단 그하단이 되는 Framework의 여러 Base Class에서 좀더쉽고 안전하게 Thread안정성을 수현하기위해서였겠죠. 결과적으로는 MS는 .NET을 디자인 할때 메모리의 효율적인 사용보단 Multi-Thread의 쉬운 개발 생산성에 좀더 무게를 두고 디자인한것 같습니다.

그럼 빈번한 문자열 조작의 경우 많은 힙의 매모리를 잠식하게 될것인데 이는 성능적으로나 메모리의 효율적인 사용으로나 모두 비효율적입니다. 이때 사용할수있는 것이 StringBuilder라는 클래스입니다.

이 클레스는 문자열을 다루는 일종의 Helper클레스로서 일반 문자열과 달리 메모리에 충분한(하지만 필요한 만큼만) 블럭을(일종의 버퍼) 확보한후 이 블럭에서만 문자열을 조작합니다. 따라서 메모리 할당이 한번 혹은 또 필요한경우에만 일어나므로 동작이 훨씬 빠르고 힙 메모리를 절약할수있습니다.

실제로 문자열을 더하는 반복루프를 stirng과 StringBuilder로 실행해보면 약 100~5000배 정도까지의 성능차이를 보입니다. 빈번하지 않은 문자열작업이라도 COM+와 같은 미들웨어에서는 꼭 StringBuilder를 사용할것을 권장합니다. 이유는 많은 인스턴스가 만들어지고 지워지는 작업을 빈번하게 반복하는 미들웨어에서는 조그마한 문자열작업도 클라이언트의 호출에 따라서 결과적으로는 시스템 전체의 성능에 영향을 줄수도 있기 때문입니다.

아마도 .NET 세미나나 혹은 관련서적을 한두번 보신분들은 되도록이면 StringBuilder를 사용하란것과 또 string 타입은 편집될수없는 immutable의 속성을 가진다는 말을 들으시거나 보셨을겁니다. 모든 문자열은 .NET Framework하에서 힙에 그때 그때 할당 되기 때문에 시간이 오래걸리고 또 메모리도 상대적으로 많이 소비하게됩니다.

따라서 StringBulder를 자주사용하는 습관이 좋을것 같습니다.

하지만 !!!!!
StringBuilder도 알고 써야합니다.
"StringBuilder에 대한 진실 혹은 거짓말 시리즈"를 참고하기 바랍니다.

요약하자면..
문자열 + 연산은 String.Concat 호출로 변환되고 4개 이하의 문자열 + 연산은 압도적으로 + 연산이 효율적이며 빠릅니다. 작은 문자열 400여 개를 연결하는 작업 역시 String.Concat과 StringBuilder의 성능차이는 그다지 크지 않습니다.

StringBuilder를 사용하면서 유의할 사항들은 다음과 같습니다.

  • StringBuilder는 배부 문자열 버퍼를 유지하며 그 초기 값은 16이다.
  • StringBuilder는 내부 버퍼가 부족하게 되면 새로운 내부 버퍼를 할당하며 기존 버퍼의 내용을 복사하는 오버헤드를 갖는다.
  • StringBuilder.ToString()은 문자열을 새로이 생성하여 반환하므로 또 다른 오버헤드를 유발할 수도 있다.

기타

unmanaged 자원을 사용하는 타입들은 IDisposable interface의 Dispose()메서드를 호출하여 명시적인 자원 해지를 해주어야 한다. Dispose()호출은 프로그래머의 책임인데 항상 Dispose()가 호출되도록 유도할 필요가 있다. 
가장 쉬운 방법은 예외 try/finally블록을 사용하는 경우이다.

try 
{ 
    ... 
} 
... 
finally 
{ 
    ... 
    객체.Dispose(); 
}

또 다른 방법은 using 키워드를 사용하는 방법이다.

using(객체형 객체 = new 객체형()) 
{ 
    ... 
}

객체형이 IDisposable 인터페이스를 구현하지 않았다면 컴파일 오류가 발생한다. 
먼저 객체 생성후 using키워드의 인자에 as 연산를 사용하면 좀 더 방어적인 코딩이 될 수 있다.

두개 이상의 객체가 동시에 사용되는 트랜잭션과 같은 경우라면 
try/finally 중첩 구문을 사용할 수도 있고 finally 구문에 객체들의 Dispose()를 호출하게 할 수 있다. 
하지만 using을 두번 걸쳐 쓴다면 논리적 오류가 발생할 경우도 있다.

객체형 ... new ...; 
객체형2 ... new ...; 
using(객체형 as IDisposable) 
// 객체형 을 생성후 객체형2 생성시 예외가 발생한다면 
// 객체형의 Dispose()를 호출하지 못한다.
using(객체형2 as IDisposable)  
{ 
    ... 
}

참고로 닷넷 프레임워크의 1,500개가 넘는 타입중에 IDisposable interface를 구현한 타입은 100개 미만이다.

 

 

광고

댓글 남기기